feat(calendar): Wiederholungen, Tagesansicht, Terminfarben, Farbpunkt als Schalter
Fünf Wünsche aus dem Durchtesten: 1. Wiederholungen einstellbar (täglich/wöchentlich/monatlich/jährlich, alle N, Ende nie/nach Anzahl/an einem Datum). Der eigentliche Aufwand lag nicht im Formular, sondern im Aufklappen: eine wöchentliche Reihe erschien sonst als *ein* Eintrag. Der Server klappt sie jetzt im angefragten Zeitfenster auf (?from=&to=, rrule-go über go-icals RecurrenceSet), die Ansichten fragen ihren sichtbaren Ausschnitt an. Geprüft: 5 Termine wöchentlich, nach Umstellung auf 14-tägig korrekt neu gerechnet, Fenster einer Woche liefert genau einen. Regeln, die das Formular nicht abbildet (etwa jeder zweite Dienstag im Monat), werden weiterhin korrekt *angezeigt* -- geprüft, die vier Termine stimmen -- aber nicht zum Bearbeiten angeboten (409). Ein halb verstandenes Speichern würde die Regel sonst still beschneiden. 2. Tag wird erst geöffnet, statt sofort einen Termin anzulegen: Klick auf einen Tag zeigt dessen Termine als Liste, dort wählt man einen aus oder legt bewusst einen neuen an. 3. Eigene Farbe je Termin (COLOR nach RFC 7986), leer = Kalenderfarbe. 4. Kalenderliste ohne Kästchen: der Farbpunkt selbst ist der Schalter -- gefüllt heißt sichtbar, leerer Ring ausgeblendet. 5. Ganztägig als Schiebeschalter statt Kästchen -- es stellt das Formular um, das ist keine Nebenangabe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
105ce8de4a
commit
79a7cc6a3d
8 files changed
+770
-193
No files matched your search
+1
-1
@@ -13,6 +13,7 @@ require (
|
|||||||
github.com/gorilla/sessions v1.4.0
|
github.com/gorilla/sessions v1.4.0
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/rs/zerolog v1.33.0
|
github.com/rs/zerolog v1.33.0
|
||||||
|
github.com/teambition/rrule-go v1.8.2
|
||||||
golang.org/x/crypto v0.53.0
|
golang.org/x/crypto v0.53.0
|
||||||
golang.org/x/image v0.42.0
|
golang.org/x/image v0.42.0
|
||||||
golang.org/x/net v0.56.0
|
golang.org/x/net v0.56.0
|
||||||
@@ -29,7 +30,6 @@ require (
|
|||||||
github.com/lib/pq v1.10.9 // indirect
|
github.com/lib/pq v1.10.9 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
github.com/teambition/rrule-go v1.8.2 // indirect
|
|
||||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
|
|||||||
@@ -9,19 +9,44 @@ import (
|
|||||||
ical "github.com/emersion/go-ical"
|
ical "github.com/emersion/go-ical"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/teambition/rrule-go"
|
||||||
|
|
||||||
"aether/internal/auth"
|
"aether/internal/auth"
|
||||||
"aether/pkg/response"
|
"aether/pkg/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// propColor ist die Farbe eines einzelnen Termins (RFC 7986). Der Standard
|
||||||
|
// sieht dort einen CSS3-*Namen* vor, wir schreiben einen Hex-Wert – so wie
|
||||||
|
// es auch Nextcloud und Thunderbird tun, weil eine Palette aus benannten
|
||||||
|
// Farben in einer Oberfläche niemand pflegen will. Clients, die die
|
||||||
|
// Eigenschaft nicht verstehen, überspringen sie; niemand zeigt deswegen
|
||||||
|
// etwas Falsches an.
|
||||||
|
const propColor = "COLOR"
|
||||||
|
|
||||||
|
// maxOccurrences begrenzt, wie viele Einzeltermine eine Reihe in einem
|
||||||
|
// Zeitfenster beisteuern darf. Ein täglicher Termin über zehn Jahre wäre
|
||||||
|
// sonst allein genug, um eine Monatsansicht lahmzulegen.
|
||||||
|
const maxOccurrences = 750
|
||||||
|
|
||||||
|
// RecurrenceDTO ist die **vereinfachte** Wiederholung, die die Oberfläche
|
||||||
|
// anbietet: alle N Tage/Wochen/Monate/Jahre, wahlweise mit Ende nach einer
|
||||||
|
// Anzahl oder an einem Datum.
|
||||||
|
//
|
||||||
|
// Eine RRULE kann weit mehr ("jeden zweiten Dienstag im Monat, außer im
|
||||||
|
// August"). Solche Regeln werden gelesen und unverändert behalten, aber
|
||||||
|
// nicht in dieses Formular gezwängt – siehe recurrenceFromRule.
|
||||||
|
type RecurrenceDTO struct {
|
||||||
|
Freq string `json:"freq"` // DAILY | WEEKLY | MONTHLY | YEARLY
|
||||||
|
Interval int `json:"interval"` // >= 1
|
||||||
|
Count int `json:"count,omitempty"`
|
||||||
|
Until *time.Time `json:"until,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// EventDTO ist die vereinfachte Sicht eines Termins für die Web-Oberfläche.
|
// EventDTO ist die vereinfachte Sicht eines Termins für die Web-Oberfläche.
|
||||||
// Anders als CalDAV (byte-genaues .ics) bildet sie nur die gängigen Felder
|
// Anders als CalDAV (byte-genaues .ics) bildet sie nur die gängigen Felder
|
||||||
// ab – reicht für Anlegen/Ändern eines einfachen Termins im Browser. Ein
|
// ab. Was hier nicht auftaucht (Teilnehmer, Anhänge, Erinnerungen), bleibt
|
||||||
// über einen echten CalDAV-Client angelegter Termin mit Wiederholung bleibt
|
// im gespeicherten .ics trotzdem unangetastet erhalten – geändert wird nur,
|
||||||
// über /caldav/ voll erhalten, zeigt sich hier nur mit Recurring=true und
|
// was das Formular auch wirklich kennt.
|
||||||
// lässt sich in der Web-Oberfläche bewusst nicht bearbeiten (siehe Update) –
|
|
||||||
// eine RRULE über dieses vereinfachte Formular zu verändern, würde sie eher
|
|
||||||
// beschädigen als korrekt anpassen.
|
|
||||||
type EventDTO struct {
|
type EventDTO struct {
|
||||||
UID string `json:"uid"`
|
UID string `json:"uid"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
@@ -34,23 +59,97 @@ type EventDTO struct {
|
|||||||
Start time.Time `json:"start"`
|
Start time.Time `json:"start"`
|
||||||
End *time.Time `json:"end,omitempty"`
|
End *time.Time `json:"end,omitempty"`
|
||||||
AllDay bool `json:"all_day"`
|
AllDay bool `json:"all_day"`
|
||||||
|
// Color leer = Farbe des Kalenders. Ein eigener Wert übersteuert sie nur
|
||||||
|
// für diesen einen Termin.
|
||||||
|
Color string `json:"color,omitempty"`
|
||||||
|
// Recurring: es hängt überhaupt eine Wiederholung dran.
|
||||||
|
// Recurrence: die Regel, *sofern* sie sich im Formular abbilden lässt.
|
||||||
|
// Beides zusammen ergibt den Fall „wiederkehrend, aber zu verwickelt zum
|
||||||
|
// Bearbeiten" – dann ist Recurring true und Recurrence nil.
|
||||||
Recurring bool `json:"recurring"`
|
Recurring bool `json:"recurring"`
|
||||||
|
Recurrence *RecurrenceDTO `json:"recurrence,omitempty"`
|
||||||
|
// Bei aufgeklappten Wiederholungen tragen Start/End den *einzelnen*
|
||||||
|
// Termin, SeriesStart/SeriesEnd dagegen die Reihe selbst. Das Formular
|
||||||
|
// bearbeitet immer die Reihe und braucht daher deren Anfang.
|
||||||
|
SeriesStart *time.Time `json:"series_start,omitempty"`
|
||||||
|
SeriesEnd *time.Time `json:"series_end,omitempty"`
|
||||||
ETag string `json:"etag"`
|
ETag string `json:"etag"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstEventComponent(cal *ical.Calendar) *ical.Component {
|
||||||
|
for _, child := range cal.Children {
|
||||||
|
if child.Name == "VEVENT" || child.Name == "VTODO" {
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDateOnly erkennt einen Ganztagstermin. Nicht jeder Client schreibt
|
||||||
|
// VALUE=DATE dazu, die achtstellige Form ("20260903") ist aber eindeutig –
|
||||||
|
// eine Zeitangabe hat immer mindestens 15 Zeichen ("20260903T090000").
|
||||||
|
func isDateOnly(prop *ical.Prop) bool {
|
||||||
|
return prop.ValueType() == ical.ValueDate || len(prop.Value) == 8
|
||||||
|
}
|
||||||
|
|
||||||
|
var freqToString = map[rrule.Frequency]string{
|
||||||
|
rrule.DAILY: "DAILY", rrule.WEEKLY: "WEEKLY",
|
||||||
|
rrule.MONTHLY: "MONTHLY", rrule.YEARLY: "YEARLY",
|
||||||
|
}
|
||||||
|
|
||||||
|
var stringToFreq = map[string]rrule.Frequency{
|
||||||
|
"DAILY": rrule.DAILY, "WEEKLY": rrule.WEEKLY,
|
||||||
|
"MONTHLY": rrule.MONTHLY, "YEARLY": rrule.YEARLY,
|
||||||
|
}
|
||||||
|
|
||||||
|
// recurrenceFromRule bildet eine RRULE auf das einfache Formular ab – oder
|
||||||
|
// gibt nil zurück, wenn sie mehr kann, als das Formular darstellt. Wichtig
|
||||||
|
// ist die Richtung: Im Zweifel **nil**. Eine Regel, die wir nur halb
|
||||||
|
// verstehen, dürfen wir nicht zum Bearbeiten anbieten, sonst schreibt das
|
||||||
|
// Speichern die verlorenen Teile still weg.
|
||||||
|
func recurrenceFromRule(opt *rrule.ROption, start time.Time) *RecurrenceDTO {
|
||||||
|
if opt == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
freq, ok := freqToString[opt.Freq]
|
||||||
|
if !ok {
|
||||||
|
return nil // stündlich und feiner bietet die Oberfläche nicht an
|
||||||
|
}
|
||||||
|
if len(opt.Bysetpos) > 0 || len(opt.Bymonth) > 0 || len(opt.Bymonthday) > 0 ||
|
||||||
|
len(opt.Byyearday) > 0 || len(opt.Byweekno) > 0 || len(opt.Byhour) > 0 ||
|
||||||
|
len(opt.Byminute) > 0 || len(opt.Bysecond) > 0 || len(opt.Byeaster) > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// BYDAY mit genau dem Wochentag des Starts ist die Schreibweise vieler
|
||||||
|
// Clients für „jede Woche" und ändert nichts an der Bedeutung. Alles
|
||||||
|
// andere (mehrere Tage, Wochentag ≠ Start) ist eine echte Erweiterung.
|
||||||
|
if len(opt.Byweekday) > 0 {
|
||||||
|
if freq != "WEEKLY" || len(opt.Byweekday) != 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if opt.Byweekday[0].Day() != int(start.Weekday()+6)%7 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dto := &RecurrenceDTO{Freq: freq, Interval: opt.Interval, Count: opt.Count}
|
||||||
|
if dto.Interval < 1 {
|
||||||
|
dto.Interval = 1
|
||||||
|
}
|
||||||
|
if !opt.Until.IsZero() {
|
||||||
|
u := opt.Until.UTC()
|
||||||
|
dto.Until = &u
|
||||||
|
}
|
||||||
|
return dto
|
||||||
|
}
|
||||||
|
|
||||||
func eventFromObject(o *Object) (*EventDTO, error) {
|
func eventFromObject(o *Object) (*EventDTO, error) {
|
||||||
dec := ical.NewDecoder(strings.NewReader(o.Data))
|
dec := ical.NewDecoder(strings.NewReader(o.Data))
|
||||||
cal, err := dec.Decode()
|
cal, err := dec.Decode()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var ev *ical.Component
|
ev := firstEventComponent(cal)
|
||||||
for _, child := range cal.Children {
|
|
||||||
if child.Name == "VEVENT" || child.Name == "VTODO" {
|
|
||||||
ev = child
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ev == nil {
|
if ev == nil {
|
||||||
return nil, errNoEventComponent
|
return nil, errNoEventComponent
|
||||||
}
|
}
|
||||||
@@ -58,11 +157,11 @@ func eventFromObject(o *Object) (*EventDTO, error) {
|
|||||||
summary, _ := ev.Props.Text(ical.PropSummary)
|
summary, _ := ev.Props.Text(ical.PropSummary)
|
||||||
description, _ := ev.Props.Text(ical.PropDescription)
|
description, _ := ev.Props.Text(ical.PropDescription)
|
||||||
location, _ := ev.Props.Text(ical.PropLocation)
|
location, _ := ev.Props.Text(ical.PropLocation)
|
||||||
_, recurring := ev.Props["RRULE"]
|
color, _ := ev.Props.Text(propColor)
|
||||||
|
|
||||||
dto := &EventDTO{
|
dto := &EventDTO{
|
||||||
UID: o.UID, Summary: summary, Description: description, Location: location,
|
UID: o.UID, Summary: summary, Description: description, Location: location,
|
||||||
Recurring: recurring, ETag: o.ETag,
|
Color: color, ETag: o.ETag,
|
||||||
}
|
}
|
||||||
|
|
||||||
startProp := ev.Props.Get(ical.PropDateTimeStart)
|
startProp := ev.Props.Get(ical.PropDateTimeStart)
|
||||||
@@ -93,14 +192,67 @@ func eventFromObject(o *Object) (*EventDTO, error) {
|
|||||||
end := dto.Start
|
end := dto.Start
|
||||||
dto.End = &end
|
dto.End = &end
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opt, err := ev.Props.RecurrenceRule(); err == nil && opt != nil {
|
||||||
|
dto.Recurring = true
|
||||||
|
dto.Recurrence = recurrenceFromRule(opt, dto.Start)
|
||||||
|
}
|
||||||
return dto, nil
|
return dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isDateOnly erkennt einen Ganztagstermin. Nicht jeder Client schreibt
|
// expand klappt eine Wiederholung im gefragten Zeitfenster in einzelne
|
||||||
// VALUE=DATE dazu, die achtstellige Form ("20260903") ist aber eindeutig –
|
// Termine auf. Ohne das zeigt eine wöchentliche Reihe im Kalender genau
|
||||||
// eine Zeitangabe hat immer mindestens 15 Zeichen ("20260903T090000").
|
// **einen** Eintrag – nämlich den ersten.
|
||||||
func isDateOnly(prop *ical.Prop) bool {
|
func expand(o *Object, base *EventDTO, from, to time.Time) []EventDTO {
|
||||||
return prop.ValueType() == ical.ValueDate || len(prop.Value) == 8
|
if !base.Recurring {
|
||||||
|
// Einzeltermin: nur, wenn er das Fenster überhaupt berührt.
|
||||||
|
end := base.Start
|
||||||
|
if base.End != nil {
|
||||||
|
end = *base.End
|
||||||
|
}
|
||||||
|
if end.Before(from) || base.Start.After(to) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []EventDTO{*base}
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := ical.NewDecoder(strings.NewReader(o.Data))
|
||||||
|
cal, err := dec.Decode()
|
||||||
|
if err != nil {
|
||||||
|
return []EventDTO{*base}
|
||||||
|
}
|
||||||
|
ev := firstEventComponent(cal)
|
||||||
|
if ev == nil {
|
||||||
|
return []EventDTO{*base}
|
||||||
|
}
|
||||||
|
set, err := ev.RecurrenceSet(time.UTC)
|
||||||
|
if err != nil || set == nil {
|
||||||
|
return []EventDTO{*base}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Dauer bleibt über alle Einzeltermine gleich; nur der Anfang wandert.
|
||||||
|
var dur time.Duration
|
||||||
|
if base.End != nil {
|
||||||
|
dur = base.End.Sub(base.Start)
|
||||||
|
}
|
||||||
|
seriesStart, seriesEnd := base.Start, base.End
|
||||||
|
|
||||||
|
out := make([]EventDTO, 0, 16)
|
||||||
|
for i, occ := range set.Between(from, to, true) {
|
||||||
|
if i >= maxOccurrences {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
item := *base
|
||||||
|
item.Start = occ.UTC()
|
||||||
|
if seriesEnd != nil {
|
||||||
|
end := item.Start.Add(dur)
|
||||||
|
item.End = &end
|
||||||
|
}
|
||||||
|
item.SeriesStart = &seriesStart
|
||||||
|
item.SeriesEnd = seriesEnd
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
var errNoEventComponent = &icalError{"calendar: object has no VEVENT/VTODO component"}
|
var errNoEventComponent = &icalError{"calendar: object has no VEVENT/VTODO component"}
|
||||||
@@ -109,7 +261,30 @@ type icalError struct{ msg string }
|
|||||||
|
|
||||||
func (e *icalError) Error() string { return e.msg }
|
func (e *icalError) Error() string { return e.msg }
|
||||||
|
|
||||||
// GET /api/v1/calendars/{id}/events
|
// parseWindow liest from/to aus der Abfrage. Ohne Angabe ein großzügiges
|
||||||
|
// Fenster um heute – die Listenansicht fragt ohne Grenzen und soll trotzdem
|
||||||
|
// etwas sehen, ohne dass eine tägliche Reihe über Jahre aufgeklappt wird.
|
||||||
|
func parseWindow(r *http.Request) (from, to time.Time) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
from = now.AddDate(0, -1, 0)
|
||||||
|
to = now.AddDate(1, 0, 0)
|
||||||
|
if v := r.URL.Query().Get("from"); v != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||||
|
from = t.UTC()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := r.URL.Query().Get("to"); v != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||||
|
to = t.UTC()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if to.Before(from) {
|
||||||
|
to = from
|
||||||
|
}
|
||||||
|
return from, to
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/calendars/{id}/events?from=…&to=…
|
||||||
func (h *APIHandler) ListEvents(w http.ResponseWriter, r *http.Request) {
|
func (h *APIHandler) ListEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
user := auth.UserFromContext(r.Context())
|
user := auth.UserFromContext(r.Context())
|
||||||
calID, err := uuid.Parse(chi.URLParam(r, "id"))
|
calID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||||
@@ -126,13 +301,15 @@ func (h *APIHandler) ListEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.Error(w, http.StatusInternalServerError, "list events failed")
|
response.Error(w, http.StatusInternalServerError, "list events failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
from, to := parseWindow(r)
|
||||||
|
|
||||||
out := make([]EventDTO, 0, len(objs))
|
out := make([]EventDTO, 0, len(objs))
|
||||||
for i := range objs {
|
for i := range objs {
|
||||||
dto, err := eventFromObject(&objs[i])
|
base, err := eventFromObject(&objs[i])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue // eine kaputte Einzeldatei darf die Liste nicht abbrechen
|
continue // eine kaputte Einzeldatei darf die Liste nicht abbrechen
|
||||||
}
|
}
|
||||||
out = append(out, *dto)
|
out = append(out, expand(&objs[i], base, from, to)...)
|
||||||
}
|
}
|
||||||
response.JSON(w, http.StatusOK, out)
|
response.JSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
@@ -144,6 +321,8 @@ type eventBody struct {
|
|||||||
Start time.Time `json:"start"`
|
Start time.Time `json:"start"`
|
||||||
End *time.Time `json:"end"`
|
End *time.Time `json:"end"`
|
||||||
AllDay bool `json:"all_day"`
|
AllDay bool `json:"all_day"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Recurrence *RecurrenceDTO `json:"recurrence"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildEventICal(uid string, body eventBody) *ical.Calendar {
|
func buildEventICal(uid string, body eventBody) *ical.Calendar {
|
||||||
@@ -163,6 +342,10 @@ func buildEventICal(uid string, body eventBody) *ical.Calendar {
|
|||||||
if body.Location != "" {
|
if body.Location != "" {
|
||||||
ev.Props.SetText(ical.PropLocation, body.Location)
|
ev.Props.SetText(ical.PropLocation, body.Location)
|
||||||
}
|
}
|
||||||
|
if body.Color != "" {
|
||||||
|
ev.Props.SetText(propColor, body.Color)
|
||||||
|
}
|
||||||
|
|
||||||
if body.AllDay {
|
if body.AllDay {
|
||||||
// Ganztägig heißt: ein Datum ohne Uhrzeit und ohne Zeitzone. Deshalb
|
// Ganztägig heißt: ein Datum ohne Uhrzeit und ohne Zeitzone. Deshalb
|
||||||
// hier ausdrücklich .UTC() – der Aufrufer schickt für einen
|
// hier ausdrücklich .UTC() – der Aufrufer schickt für einen
|
||||||
@@ -190,19 +373,56 @@ func buildEventICal(uid string, body eventBody) *ical.Calendar {
|
|||||||
ev.Props.SetDateTime(ical.PropDateTimeEnd, body.End.UTC())
|
ev.Props.SetDateTime(ical.PropDateTimeEnd, body.End.UTC())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if rule := ruleFromRecurrence(body); rule != nil {
|
||||||
|
ev.Props.SetRecurrenceRule(rule)
|
||||||
|
}
|
||||||
|
|
||||||
root.Children = append(root.Children, ev.Component)
|
root.Children = append(root.Children, ev.Component)
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/v1/calendars/{id}/events
|
func ruleFromRecurrence(body eventBody) *rrule.ROption {
|
||||||
func (h *APIHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
rec := body.Recurrence
|
||||||
|
if rec == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
freq, ok := stringToFreq[strings.ToUpper(rec.Freq)]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
interval := rec.Interval
|
||||||
|
if interval < 1 {
|
||||||
|
interval = 1
|
||||||
|
}
|
||||||
|
opt := &rrule.ROption{Freq: freq, Interval: interval, Count: rec.Count}
|
||||||
|
if rec.Until != nil {
|
||||||
|
// Bis zum **Ende** des genannten Tages, damit ein Termin an diesem
|
||||||
|
// Tag noch dazugehört. Mit reiner Mitternacht fiele der letzte
|
||||||
|
// Termin je nach Uhrzeit heraus – ein Fehler, den man erst Wochen
|
||||||
|
// später bemerkt.
|
||||||
|
u := rec.Until.UTC()
|
||||||
|
opt.Until = time.Date(u.Year(), u.Month(), u.Day(), 23, 59, 59, 0, time.UTC)
|
||||||
|
}
|
||||||
|
return opt
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) calendarOf(r *http.Request) (uuid.UUID, bool) {
|
||||||
user := auth.UserFromContext(r.Context())
|
user := auth.UserFromContext(r.Context())
|
||||||
calID, err := uuid.Parse(chi.URLParam(r, "id"))
|
calID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
return uuid.UUID{}, false
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil {
|
if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil {
|
||||||
|
return uuid.UUID{}, false
|
||||||
|
}
|
||||||
|
return calID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/calendars/{id}/events
|
||||||
|
func (h *APIHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calID, ok := h.calendarOf(r)
|
||||||
|
if !ok {
|
||||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -213,35 +433,23 @@ func (h *APIHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
uid := uuid.New().String()
|
uid := uuid.New().String()
|
||||||
cal := buildEventICal(uid, body)
|
dto, err := h.writeEvent(r, calID, uid, body)
|
||||||
data, err := encodeICal(cal)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(w, http.StatusInternalServerError, "encode event failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
start, end := timeRange(cal)
|
|
||||||
obj, err := h.store.PutObject(r.Context(), calID, uid, "VEVENT", data, etagFor(data), start, end)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(w, http.StatusInternalServerError, "create event failed")
|
response.Error(w, http.StatusInternalServerError, "create event failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dto, err := eventFromObject(obj)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(w, http.StatusInternalServerError, "read back event failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.JSON(w, http.StatusCreated, dto)
|
response.JSON(w, http.StatusCreated, dto)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /api/v1/calendars/{id}/events/{uid}
|
// PATCH /api/v1/calendars/{id}/events/{uid}
|
||||||
|
//
|
||||||
|
// Bei einer Reihe gilt die Änderung für **alle** Termine der Reihe. Einzelne
|
||||||
|
// Ausnahmen (RECURRENCE-ID) sind bewusst nicht dabei: Sie brauchen eine
|
||||||
|
// eigene Bedienführung ("dieser Termin / alle folgenden / alle"), und ohne
|
||||||
|
// die richtet ein stilles "gilt für alles" mehr Schaden an als Nutzen.
|
||||||
func (h *APIHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
func (h *APIHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
||||||
user := auth.UserFromContext(r.Context())
|
calID, ok := h.calendarOf(r)
|
||||||
calID, err := uuid.Parse(chi.URLParam(r, "id"))
|
if !ok {
|
||||||
if err != nil {
|
|
||||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil {
|
|
||||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -255,12 +463,11 @@ func (h *APIHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.Error(w, http.StatusInternalServerError, "load event failed")
|
response.Error(w, http.StatusInternalServerError, "load event failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
existingDTO, err := eventFromObject(existing)
|
if dto, err := eventFromObject(existing); err == nil && dto.Recurring && dto.Recurrence == nil {
|
||||||
if err == nil && existingDTO.Recurring {
|
// Wiederholung, die das Formular nicht vollständig abbildet (etwa
|
||||||
// Diese Ansicht kennt keine RRULE-Bearbeitung – ein Formular mit nur
|
// „jeden zweiten Dienstag"). Speichern würde die Regel auf das
|
||||||
// "Titel/Start/Ende" würde die Wiederholungsregel beim Speichern
|
// reduzieren, was das Formular kennt – also lieber ablehnen.
|
||||||
// stillschweigend verwerfen. Lieber ablehnen als Daten beschädigen.
|
response.Error(w, http.StatusConflict, "this recurrence rule can only be edited in a CalDAV client")
|
||||||
response.Error(w, http.StatusConflict, "recurring events can only be edited in a CalDAV client")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,35 +476,32 @@ func (h *APIHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.Error(w, http.StatusBadRequest, "summary and start are required")
|
response.Error(w, http.StatusBadRequest, "summary and start are required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cal := buildEventICal(uid, body)
|
dto, err := h.writeEvent(r, calID, uid, body)
|
||||||
data, err := encodeICal(cal)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(w, http.StatusInternalServerError, "encode event failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
start, end := timeRange(cal)
|
|
||||||
obj, err := h.store.PutObject(r.Context(), calID, uid, "VEVENT", data, etagFor(data), start, end)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(w, http.StatusInternalServerError, "update event failed")
|
response.Error(w, http.StatusInternalServerError, "update event failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dto, err := eventFromObject(obj)
|
|
||||||
if err != nil {
|
|
||||||
response.Error(w, http.StatusInternalServerError, "read back event failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.JSON(w, http.StatusOK, dto)
|
response.JSON(w, http.StatusOK, dto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *APIHandler) writeEvent(r *http.Request, calID uuid.UUID, uid string, body eventBody) (*EventDTO, error) {
|
||||||
|
cal := buildEventICal(uid, body)
|
||||||
|
data, err := encodeICal(cal)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
start, end := timeRange(cal)
|
||||||
|
obj, err := h.store.PutObject(r.Context(), calID, uid, "VEVENT", data, etagFor(data), start, end)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return eventFromObject(obj)
|
||||||
|
}
|
||||||
|
|
||||||
// DELETE /api/v1/calendars/{id}/events/{uid}
|
// DELETE /api/v1/calendars/{id}/events/{uid}
|
||||||
func (h *APIHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
|
func (h *APIHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
|
||||||
user := auth.UserFromContext(r.Context())
|
calID, ok := h.calendarOf(r)
|
||||||
calID, err := uuid.Parse(chi.URLParam(r, "id"))
|
if !ok {
|
||||||
if err != nil {
|
|
||||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil {
|
|
||||||
response.Error(w, http.StatusNotFound, "calendar not found")
|
response.Error(w, http.StatusNotFound, "calendar not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -902,20 +902,40 @@ export interface CalendarItem {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Die Wiederholung, die das Formular anbietet: alle N Tage/Wochen/Monate/
|
||||||
|
* Jahre, wahlweise mit Ende. Verwickeltere Regeln aus fremden Clients
|
||||||
|
* bleiben erhalten, kommen hier aber als `recurrence: undefined` an und
|
||||||
|
* sind dann nur lesbar (siehe events_api.go). */
|
||||||
|
export interface Recurrence {
|
||||||
|
freq: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'
|
||||||
|
interval: number
|
||||||
|
count?: number
|
||||||
|
/** ISO 8601. Der Tag, an dem die Reihe endet (einschließlich). */
|
||||||
|
until?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface CalendarEvent {
|
export interface CalendarEvent {
|
||||||
uid: string
|
uid: string
|
||||||
summary: string
|
summary: string
|
||||||
description: string
|
description: string
|
||||||
location: string
|
location: string
|
||||||
/** ISO 8601, immer UTC ("...Z") – der Server rechnet nicht um, das
|
/** ISO 8601, immer UTC ("...Z"). Bei ganztägigen Terminen Mitternacht UTC
|
||||||
* übernimmt der Browser beim Anzeigen (new Date(...) kennt die lokale
|
* als reine Datumsangabe – nie über new Date(...) in Ortszeit umrechnen,
|
||||||
* Zeitzone bereits). */
|
* sondern über eventDay() aus components/calendar/dates.ts. */
|
||||||
start: string
|
start: string
|
||||||
end?: string
|
end?: string
|
||||||
all_day: boolean
|
all_day: boolean
|
||||||
/** Über einen echten CalDAV-Client mit Wiederholung angelegt – hier nur
|
/** Leer = Farbe des Kalenders. */
|
||||||
* sichtbar, nicht bearbeitbar (siehe events_api.go, UpdateEvent). */
|
color?: string
|
||||||
|
/** Es hängt überhaupt eine Wiederholung dran. */
|
||||||
recurring: boolean
|
recurring: boolean
|
||||||
|
/** Die Regel – nur gesetzt, wenn sie sich im Formular abbilden lässt. */
|
||||||
|
recurrence?: Recurrence
|
||||||
|
/** Bei aufgeklappten Wiederholungen: Anfang/Ende der **Reihe**, während
|
||||||
|
* start/end den einzelnen Termin tragen. Das Formular bearbeitet immer
|
||||||
|
* die Reihe. */
|
||||||
|
series_start?: string
|
||||||
|
series_end?: string
|
||||||
etag: string
|
etag: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -926,6 +946,8 @@ export interface EventInput {
|
|||||||
start: string
|
start: string
|
||||||
end?: string
|
end?: string
|
||||||
all_day: boolean
|
all_day: boolean
|
||||||
|
color?: string
|
||||||
|
recurrence?: Recurrence | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const calendarApi = {
|
export const calendarApi = {
|
||||||
@@ -949,7 +971,13 @@ export const calendarApi = {
|
|||||||
return json.data
|
return json.data
|
||||||
},
|
},
|
||||||
|
|
||||||
listEvents: (id: string) => request<CalendarEvent[]>('GET', `/calendars/${id}/events`),
|
/** Termine im Zeitfenster. Wiederholungen kommen vom Server bereits in
|
||||||
|
* einzelne Termine aufgeklappt zurück – deshalb braucht es die Grenzen:
|
||||||
|
* eine tägliche Reihe ohne Ende hat sonst kein natürliches Ende. */
|
||||||
|
listEvents: (id: string, from?: Date, to?: Date) => {
|
||||||
|
const qs = from && to ? `?from=${from.toISOString()}&to=${to.toISOString()}` : ''
|
||||||
|
return request<CalendarEvent[]>('GET', `/calendars/${id}/events${qs}`)
|
||||||
|
},
|
||||||
createEvent: (id: string, input: EventInput) =>
|
createEvent: (id: string, input: EventInput) =>
|
||||||
request<CalendarEvent>('POST', `/calendars/${id}/events`, input),
|
request<CalendarEvent>('POST', `/calendars/${id}/events`, input),
|
||||||
updateEvent: (id: string, uid: string, input: EventInput) =>
|
updateEvent: (id: string, uid: string, input: EventInput) =>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||||
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
|
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
|
||||||
import type { CalendarItem } from '../../api/client'
|
import type { CalendarItem } from '../../api/client'
|
||||||
|
import { ColorSwatches, palette } from './controls'
|
||||||
const palette = ['#4285f4', '#ea4335', '#fbbc04', '#34a853', '#a142f4', '#ff6d00', '#00acc1', '#607d8b']
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
calendar?: CalendarItem
|
calendar?: CalendarItem
|
||||||
@@ -50,18 +49,8 @@ export default function CalendarDialog({ calendar, onSave, onCancel }: Props) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<label style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8, display: 'block' }}>Farbe</label>
|
<label style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8, display: 'block' }}>Farbe</label>
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 20, flexWrap: 'wrap' }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
{palette.map(c => (
|
<ColorSwatches value={color} onChange={setColor} />
|
||||||
<button
|
|
||||||
key={c} type="button" onClick={() => setColor(c)}
|
|
||||||
aria-label={c}
|
|
||||||
style={{
|
|
||||||
width: 28, height: 28, borderRadius: '50%', background: c, cursor: 'pointer',
|
|
||||||
border: color === c ? '2px solid var(--color-text)' : '2px solid transparent',
|
|
||||||
boxShadow: color === c ? '0 0 0 2px var(--color-surface)' : 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
|
||||||
|
import type { CalendarEvent } from '../../api/client'
|
||||||
|
|
||||||
|
const weekdayLong = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']
|
||||||
|
const monthLabels = [
|
||||||
|
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||||
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface DayEvent extends CalendarEvent {
|
||||||
|
calendarId: string
|
||||||
|
/** Endgültige Farbe: eigene Farbe des Termins, sonst die des Kalenders. */
|
||||||
|
color: string
|
||||||
|
calendarName: string
|
||||||
|
startsAt: Date
|
||||||
|
endsAt: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein angeklickter Tag wird erst **geöffnet**, statt sofort einen neuen
|
||||||
|
* Termin anzulegen.
|
||||||
|
*
|
||||||
|
* Vorher legte jeder Klick auf eine Tageszelle direkt einen neuen Termin an –
|
||||||
|
* auch der Klick, mit dem man eigentlich nachsehen wollte, was an dem Tag
|
||||||
|
* ansteht. Hier steht zuerst, was da ist; einen neuen Termin gibt es auf
|
||||||
|
* Ansage.
|
||||||
|
*/
|
||||||
|
export default function DayDialog({ day, events, onPick, onCreate, onClose }: {
|
||||||
|
day: Date
|
||||||
|
events: DayEvent[]
|
||||||
|
onPick: (ev: DayEvent) => void
|
||||||
|
onCreate: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const overlay = useOverlayDismiss(onClose)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose() }
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const timeLabel = (ev: DayEvent) => {
|
||||||
|
if (ev.all_day) return 'ganztägig'
|
||||||
|
const from = ev.startsAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
if (!ev.endsAt) return from
|
||||||
|
const to = ev.endsAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
return `${from} – ${to}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 190, display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.6)', padding: 16,
|
||||||
|
}} {...overlay}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 14, width: 420, maxWidth: '100%', maxHeight: '80vh',
|
||||||
|
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{ padding: '18px 22px 12px', borderBottom: '1px solid var(--color-border)' }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{weekdayLong[day.getDay()]}</div>
|
||||||
|
<div style={{ fontSize: 17, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||||
|
{day.getDate()}. {monthLabels[day.getMonth()]} {day.getFullYear()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: 12, overflowY: 'auto', flex: 1 }}>
|
||||||
|
{events.length === 0 && (
|
||||||
|
<p style={{ margin: '12px 10px', fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||||
|
Nichts an diesem Tag.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{events.map(ev => (
|
||||||
|
<button
|
||||||
|
key={ev.calendarId + ev.uid + ev.start}
|
||||||
|
onClick={() => onPick(ev)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
|
||||||
|
padding: '9px 10px', marginBottom: 4, borderRadius: 8, textAlign: 'left',
|
||||||
|
background: 'transparent', border: '1px solid var(--color-border)', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
className="aether-cal-cell"
|
||||||
|
>
|
||||||
|
<span style={{ width: 4, alignSelf: 'stretch', borderRadius: 2, background: ev.color, flexShrink: 0 }} />
|
||||||
|
<span style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<span style={{ display: 'block', fontSize: 13.5, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{ev.summary || '(ohne Titel)'}
|
||||||
|
{ev.recurring && <span style={{ marginLeft: 6, fontSize: 11, color: 'var(--color-text-muted)' }}>↻</span>}
|
||||||
|
</span>
|
||||||
|
<span style={{ display: 'block', fontSize: 11.5, color: 'var(--color-text-muted)' }}>
|
||||||
|
{timeLabel(ev)}
|
||||||
|
{ev.location ? ` · ${ev.location}` : ''}
|
||||||
|
{` · ${ev.calendarName}`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '12px 16px', borderTop: '1px solid var(--color-border)' }}>
|
||||||
|
<button onClick={onCreate} style={{
|
||||||
|
padding: '8px 14px', borderRadius: 8, border: 'none',
|
||||||
|
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
+ Termin an diesem Tag
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} style={{
|
||||||
|
padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)',
|
||||||
|
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useRef, useState, type CSSProperties, type FormEvent } from 'react'
|
import { useEffect, useRef, useState, type CSSProperties, type FormEvent } from 'react'
|
||||||
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
|
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
|
||||||
import type { CalendarEvent, CalendarItem, EventInput } from '../../api/client'
|
import type { CalendarEvent, CalendarItem, EventInput, Recurrence } from '../../api/client'
|
||||||
import { addDays, allDayISO, eventDay, timedISO, toDateInput, toTimeInput } from './dates'
|
import { addDays, allDayISO, eventDay, timedISO, toDateInput, toTimeInput } from './dates'
|
||||||
|
import { ColorSwatches, Toggle } from './controls'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Fehlt er, entsteht ein neuer Termin. */
|
/** Fehlt er, entsteht ein neuer Termin. */
|
||||||
@@ -15,43 +16,66 @@ interface Props {
|
|||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FreqChoice = 'NONE' | Recurrence['freq']
|
||||||
|
type EndChoice = 'NEVER' | 'COUNT' | 'UNTIL'
|
||||||
|
|
||||||
|
const freqLabels: Record<FreqChoice, string> = {
|
||||||
|
NONE: 'Einmalig',
|
||||||
|
DAILY: 'Täglich',
|
||||||
|
WEEKLY: 'Wöchentlich',
|
||||||
|
MONTHLY: 'Monatlich',
|
||||||
|
YEARLY: 'Jährlich',
|
||||||
|
}
|
||||||
|
|
||||||
export default function EventDialog({ event, defaultStart, calendars, calendarId, onSave, onDelete, onCancel }: Props) {
|
export default function EventDialog({ event, defaultStart, calendars, calendarId, onSave, onDelete, onCancel }: Props) {
|
||||||
const overlay = useOverlayDismiss(onCancel)
|
const overlay = useOverlayDismiss(onCancel)
|
||||||
const titleRef = useRef<HTMLInputElement>(null)
|
const titleRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const allDayInit = event?.all_day ?? false
|
const allDayInit = event?.all_day ?? false
|
||||||
const start = event ? eventDay(event.start, allDayInit) : defaultStart
|
// Bei einer Reihe bearbeitet das Formular die **Reihe**, nicht den
|
||||||
const startTimed = event && !allDayInit ? new Date(event.start) : defaultStart
|
// angeklickten Einzeltermin – deshalb series_start, wo vorhanden.
|
||||||
// Bei ganztägigen Terminen liefert die Schnittstelle den letzten *belegten*
|
const baseStartISO = event?.series_start ?? event?.start
|
||||||
// Tag – genau das, was im Formular stehen soll (siehe events_api.go).
|
const baseEndISO = event?.series_end ?? event?.end
|
||||||
const end = event?.end
|
const start = baseStartISO ? eventDay(baseStartISO, allDayInit) : defaultStart
|
||||||
? eventDay(event.end, allDayInit)
|
const startTimed = baseStartISO && !allDayInit ? new Date(baseStartISO) : defaultStart
|
||||||
|
const end = baseEndISO
|
||||||
|
? eventDay(baseEndISO, allDayInit)
|
||||||
: new Date(startTimed.getTime() + 60 * 60 * 1000)
|
: new Date(startTimed.getTime() + 60 * 60 * 1000)
|
||||||
|
|
||||||
const [calId, setCalId] = useState(calendarId)
|
const [calId, setCalId] = useState(calendarId)
|
||||||
const [summary, setSummary] = useState(event?.summary ?? '')
|
const [summary, setSummary] = useState(event?.summary ?? '')
|
||||||
const [description, setDescription] = useState(event?.description ?? '')
|
const [description, setDescription] = useState(event?.description ?? '')
|
||||||
const [location, setLocation] = useState(event?.location ?? '')
|
const [location, setLocation] = useState(event?.location ?? '')
|
||||||
|
const [color, setColor] = useState(event?.color ?? '')
|
||||||
const [allDay, setAllDay] = useState(allDayInit)
|
const [allDay, setAllDay] = useState(allDayInit)
|
||||||
const [startDate, setStartDate] = useState(toDateInput(start))
|
const [startDate, setStartDate] = useState(toDateInput(start))
|
||||||
const [startTime, setStartTime] = useState(toTimeInput(startTimed))
|
const [startTime, setStartTime] = useState(toTimeInput(startTimed))
|
||||||
const [endDate, setEndDate] = useState(toDateInput(end))
|
const [endDate, setEndDate] = useState(toDateInput(end))
|
||||||
const [endTime, setEndTime] = useState(toTimeInput(end))
|
const [endTime, setEndTime] = useState(toTimeInput(end))
|
||||||
|
|
||||||
|
const rec = event?.recurrence
|
||||||
|
const [freq, setFreq] = useState<FreqChoice>(rec?.freq ?? 'NONE')
|
||||||
|
const [interval, setInterval] = useState(String(rec?.interval ?? 1))
|
||||||
|
const [endChoice, setEndChoice] = useState<EndChoice>(
|
||||||
|
rec?.count ? 'COUNT' : rec?.until ? 'UNTIL' : 'NEVER')
|
||||||
|
const [count, setCount] = useState(String(rec?.count ?? 10))
|
||||||
|
const [until, setUntil] = useState(
|
||||||
|
rec?.until ? toDateInput(eventDay(rec.until, true)) : toDateInput(addDays(start, 90)))
|
||||||
|
|
||||||
useEffect(() => { titleRef.current?.focus() }, [])
|
useEffect(() => { titleRef.current?.focus() }, [])
|
||||||
|
|
||||||
// Escape schließt – erwartet man bei jedem Dialog, und ohne fühlt sich die
|
|
||||||
// Oberfläche zäh an.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onCancel() }
|
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onCancel() }
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [onCancel])
|
}, [onCancel])
|
||||||
|
|
||||||
const readOnly = !!event?.recurring
|
// Nur Regeln, die dieses Formular vollständig abbildet, sind bearbeitbar.
|
||||||
|
// Alles andere ("jeden zweiten Dienstag im Monat") bliebe beim Speichern
|
||||||
|
// auf der Strecke – der Server lehnt es deshalb auch ab.
|
||||||
|
const readOnly = !!event?.recurring && !event?.recurrence
|
||||||
|
const isSeries = !!event?.recurring
|
||||||
|
|
||||||
/** Ende darf nicht vor dem Start liegen – statt einer Fehlermeldung zieht
|
|
||||||
* das Ende einfach mit, so wie man es von anderen Kalendern kennt. */
|
|
||||||
function changeStartDate(v: string) {
|
function changeStartDate(v: string) {
|
||||||
const shift = (+new Date(v) - +new Date(startDate)) / 86400000
|
const shift = (+new Date(v) - +new Date(startDate)) / 86400000
|
||||||
setStartDate(v)
|
setStartDate(v)
|
||||||
@@ -60,6 +84,14 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRecurrence(): Recurrence | null {
|
||||||
|
if (freq === 'NONE') return null
|
||||||
|
const r: Recurrence = { freq, interval: Math.max(1, parseInt(interval, 10) || 1) }
|
||||||
|
if (endChoice === 'COUNT') r.count = Math.max(1, parseInt(count, 10) || 1)
|
||||||
|
if (endChoice === 'UNTIL') r.until = allDayISO(until)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
function handleSubmit(e: FormEvent) {
|
function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (readOnly || !summary.trim()) return
|
if (readOnly || !summary.trim()) return
|
||||||
@@ -80,6 +112,8 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
location: location.trim() || undefined,
|
location: location.trim() || undefined,
|
||||||
all_day: allDay,
|
all_day: allDay,
|
||||||
|
color: color || undefined,
|
||||||
|
recurrence: buildRecurrence(),
|
||||||
start: startISO,
|
start: startISO,
|
||||||
end: endISO,
|
end: endISO,
|
||||||
}, calId)
|
}, calId)
|
||||||
@@ -94,6 +128,12 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
const labelStyle: CSSProperties = {
|
const labelStyle: CSSProperties = {
|
||||||
fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4, display: 'block',
|
fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4, display: 'block',
|
||||||
}
|
}
|
||||||
|
const noteStyle: CSSProperties = {
|
||||||
|
background: 'var(--color-surface-2)', border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 8, padding: '8px 10px', fontSize: 12.5,
|
||||||
|
color: 'var(--color-text-muted)', marginBottom: 14, lineHeight: 1.45,
|
||||||
|
}
|
||||||
|
const calColor = calendars.find(c => c.id === calId)?.color ?? '#4285f4'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -102,18 +142,23 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
}} {...overlay}>
|
}} {...overlay}>
|
||||||
<form onSubmit={handleSubmit} style={{
|
<form onSubmit={handleSubmit} style={{
|
||||||
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
|
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
|
||||||
borderRadius: 14, padding: 24, width: 420, maxWidth: '100%', maxHeight: '90vh', overflowY: 'auto',
|
borderRadius: 14, padding: 24, width: 440, maxWidth: '100%', maxHeight: '90vh', overflowY: 'auto',
|
||||||
}}>
|
}}>
|
||||||
<h2 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
|
<h2 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||||
{event ? 'Termin bearbeiten' : 'Neuer Termin'}
|
{event ? (isSeries ? 'Terminreihe bearbeiten' : 'Termin bearbeiten') : 'Neuer Termin'}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{readOnly && (
|
{readOnly && (
|
||||||
<div style={{
|
<div style={noteStyle}>
|
||||||
background: 'var(--color-surface-2)', border: '1px solid var(--color-border)',
|
Diese Wiederholungsregel kann dieses Formular nicht vollständig
|
||||||
borderRadius: 8, padding: '8px 10px', fontSize: 12.5, color: 'var(--color-text-muted)', marginBottom: 14,
|
abbilden – zum Ändern einen CalDAV-Client verwenden (z. B. DAVx5).
|
||||||
}}>
|
Der Termin bleibt unverändert erhalten.
|
||||||
Wiederholender Termin – nur mit einem CalDAV-Client (z. B. DAVx5) bearbeitbar, nicht hier.
|
</div>
|
||||||
|
)}
|
||||||
|
{isSeries && !readOnly && (
|
||||||
|
<div style={noteStyle}>
|
||||||
|
Terminreihe: Änderungen gelten für <strong>alle</strong> Termine der
|
||||||
|
Reihe. Angezeigt wird deshalb ihr Beginn, nicht der angeklickte Tag.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -135,10 +180,9 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: 13, color: 'var(--color-text)', cursor: readOnly ? 'default' : 'pointer' }}>
|
<div style={{ marginBottom: 14 }}>
|
||||||
<input type="checkbox" checked={allDay} disabled={readOnly} onChange={e => setAllDay(e.target.checked)} />
|
<Toggle checked={allDay} onChange={setAllDay} disabled={readOnly} label="Ganztägig" />
|
||||||
Ganztägig
|
</div>
|
||||||
</label>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
@@ -154,7 +198,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<label style={labelStyle}>Ende</label>
|
<label style={labelStyle}>Ende</label>
|
||||||
<input type="date" value={endDate} min={startDate} disabled={readOnly}
|
<input type="date" value={endDate} min={startDate} disabled={readOnly}
|
||||||
@@ -168,6 +212,60 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Wiederholung ───────────────────────────────────────────── */}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<label style={labelStyle}>Wiederholung</label>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<select value={freq} disabled={readOnly}
|
||||||
|
onChange={e => setFreq(e.target.value as FreqChoice)}
|
||||||
|
style={{ ...inputStyle, flex: 1 }}>
|
||||||
|
{(Object.keys(freqLabels) as FreqChoice[]).map(f => (
|
||||||
|
<option key={f} value={f}>{freqLabels[f]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{freq !== 'NONE' && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||||
|
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>alle</span>
|
||||||
|
<input type="number" min={1} max={99} value={interval} disabled={readOnly}
|
||||||
|
onChange={e => setInterval(e.target.value)}
|
||||||
|
style={{ ...inputStyle, width: 60 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{freq !== 'NONE' && (
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<label style={labelStyle}>Endet</label>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<select value={endChoice} disabled={readOnly}
|
||||||
|
onChange={e => setEndChoice(e.target.value as EndChoice)}
|
||||||
|
style={{ ...inputStyle, flex: 1 }}>
|
||||||
|
<option value="NEVER">Nie</option>
|
||||||
|
<option value="COUNT">Nach Anzahl</option>
|
||||||
|
<option value="UNTIL">An einem Datum</option>
|
||||||
|
</select>
|
||||||
|
{endChoice === 'COUNT' && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||||
|
<input type="number" min={1} max={999} value={count} disabled={readOnly}
|
||||||
|
onChange={e => setCount(e.target.value)} style={{ ...inputStyle, width: 70 }} />
|
||||||
|
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>Mal</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{endChoice === 'UNTIL' && (
|
||||||
|
<input type="date" value={until} min={startDate} disabled={readOnly}
|
||||||
|
onChange={e => setUntil(e.target.value)} style={{ ...inputStyle, width: 160 }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Farbe ──────────────────────────────────────────────────── */}
|
||||||
|
<div style={{ marginBottom: 14 }}>
|
||||||
|
<label style={labelStyle}>Farbe</label>
|
||||||
|
<ColorSwatches value={color} onChange={setColor} inherit={calColor} disabled={readOnly} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<label style={labelStyle}>Ort</label>
|
<label style={labelStyle}>Ort</label>
|
||||||
<input type="text" value={location} disabled={readOnly} onChange={e => setLocation(e.target.value)} style={inputStyle} />
|
<input type="text" value={location} disabled={readOnly} onChange={e => setLocation(e.target.value)} style={inputStyle} />
|
||||||
@@ -185,7 +283,7 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
|
|||||||
padding: '8px 14px', borderRadius: 8, border: 'none',
|
padding: '8px 14px', borderRadius: 8, border: 'none',
|
||||||
background: 'var(--color-danger)', color: '#fff', fontSize: 13, cursor: 'pointer',
|
background: 'var(--color-danger)', color: '#fff', fontSize: 13, cursor: 'pointer',
|
||||||
}}>
|
}}>
|
||||||
Löschen
|
{isSeries ? 'Reihe löschen' : 'Löschen'}
|
||||||
</button>
|
</button>
|
||||||
) : <span />}
|
) : <span />}
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
|
||||||
|
/** Eine Palette für Kalender **und** Termine – zwei getrennte Listen liefen
|
||||||
|
* sonst mit der Zeit auseinander, und ein Termin in einer Farbe, die es als
|
||||||
|
* Kalenderfarbe nicht gibt, sieht aus wie ein Fehler. */
|
||||||
|
export const palette = [
|
||||||
|
'#4285f4', '#ea4335', '#fbbc04', '#34a853',
|
||||||
|
'#a142f4', '#ff6d00', '#00acc1', '#607d8b',
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schiebeschalter statt Kästchen.
|
||||||
|
*
|
||||||
|
* Nicht nur Zierde: „Ganztägig" schaltet das Formular um (die Uhrzeitfelder
|
||||||
|
* verschwinden). Ein Kästchen liest sich wie eine Angabe, die man nebenbei
|
||||||
|
* ankreuzt – ein Schalter zeigt, dass hier etwas umgestellt wird.
|
||||||
|
*/
|
||||||
|
export function Toggle({ checked, onChange, disabled, label }: {
|
||||||
|
checked: boolean
|
||||||
|
onChange: (v: boolean) => void
|
||||||
|
disabled?: boolean
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.6 : 1,
|
||||||
|
fontSize: 13, color: 'var(--color-text)',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-label={label}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => !disabled && onChange(!checked)}
|
||||||
|
style={{
|
||||||
|
width: 38, height: 22, borderRadius: 11, flexShrink: 0, padding: 0,
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
background: checked ? 'var(--color-primary)' : 'var(--color-surface-2)',
|
||||||
|
cursor: disabled ? 'default' : 'pointer',
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: 2, left: checked ? 18 : 2,
|
||||||
|
width: 16, height: 16, borderRadius: '50%',
|
||||||
|
background: checked ? '#fff' : 'var(--color-text-muted)',
|
||||||
|
transition: 'left 0.15s',
|
||||||
|
}} />
|
||||||
|
</button>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Farbwahl als Kreise. Der gewählte Kreis ist gefüllt, die anderen sind
|
||||||
|
* Umrisse – dieselbe Sprache wie in der Kalenderliste, wo ein gefüllter
|
||||||
|
* Punkt „wird angezeigt" heißt.
|
||||||
|
*/
|
||||||
|
export function ColorSwatches({ value, onChange, inherit, disabled }: {
|
||||||
|
value: string
|
||||||
|
onChange: (c: string) => void
|
||||||
|
/** Ist sie gesetzt, gibt es zusätzlich die Wahl „Farbe des Kalenders"
|
||||||
|
* (leerer Wert) und dieser Kreis zeigt sie an. */
|
||||||
|
inherit?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
const dot = (color: string, selected: boolean, onClick: () => void, key: string, title: string) => (
|
||||||
|
<button
|
||||||
|
key={key} type="button" title={title} aria-label={title} disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
width: 26, height: 26, borderRadius: '50%', padding: 0, flexShrink: 0,
|
||||||
|
background: selected ? color : 'transparent',
|
||||||
|
border: `2px solid ${color}`,
|
||||||
|
cursor: disabled ? 'default' : 'pointer',
|
||||||
|
boxShadow: selected ? `0 0 0 2px var(--color-surface), 0 0 0 3.5px ${color}` : 'none',
|
||||||
|
transition: 'background 0.12s, box-shadow 0.12s',
|
||||||
|
} as CSSProperties}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 9, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
{inherit !== undefined && dot(inherit, value === '', () => onChange(''), 'inherit', 'Farbe des Kalenders')}
|
||||||
|
{inherit !== undefined && (
|
||||||
|
<span style={{ width: 1, height: 20, background: 'var(--color-border)', margin: '0 2px' }} />
|
||||||
|
)}
|
||||||
|
{palette.map(c => dot(c, value.toLowerCase() === c.toLowerCase(), () => onChange(c), c, c))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import PageHeader from '../components/PageHeader'
|
|||||||
import ConfirmDialog from '../components/ConfirmDialog'
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
import EventDialog from '../components/calendar/EventDialog'
|
import EventDialog from '../components/calendar/EventDialog'
|
||||||
import CalendarDialog from '../components/calendar/CalendarDialog'
|
import CalendarDialog from '../components/calendar/CalendarDialog'
|
||||||
|
import DayDialog, { type DayEvent } from '../components/calendar/DayDialog'
|
||||||
import {
|
import {
|
||||||
addDays, dayKey, eventDay, isSameDay, spannedDays, startOfDay, startOfMonth, startOfWeek,
|
addDays, dayKey, eventDay, isSameDay, spannedDays, startOfDay, startOfMonth, startOfWeek,
|
||||||
} from '../components/calendar/dates'
|
} from '../components/calendar/dates'
|
||||||
@@ -25,24 +26,27 @@ const HOUR_H = 44
|
|||||||
/** Breite der Stundenspalte links im Wochenraster. */
|
/** Breite der Stundenspalte links im Wochenraster. */
|
||||||
const GUTTER = 52
|
const GUTTER = 52
|
||||||
|
|
||||||
interface EventWithCalendar extends CalendarEvent {
|
type EventWithCalendar = DayEvent
|
||||||
calendarId: string
|
|
||||||
color: string
|
|
||||||
/** Anfang als örtlicher Zeitpunkt – ganztägige Termine als Tagesbeginn. */
|
|
||||||
startsAt: Date
|
|
||||||
endsAt: Date | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function decorate(ev: CalendarEvent, cal: CalendarItem): EventWithCalendar {
|
function decorate(ev: CalendarEvent, cal: CalendarItem): EventWithCalendar {
|
||||||
return {
|
return {
|
||||||
...ev,
|
...ev,
|
||||||
calendarId: cal.id,
|
calendarId: cal.id,
|
||||||
color: cal.color,
|
calendarName: cal.name,
|
||||||
|
// Eigene Terminfarbe schlägt die Kalenderfarbe – aber nur, wenn eine
|
||||||
|
// gesetzt ist. Sonst erbt der Termin sie weiterhin.
|
||||||
|
color: ev.color || cal.color,
|
||||||
startsAt: eventDay(ev.start, ev.all_day),
|
startsAt: eventDay(ev.start, ev.all_day),
|
||||||
endsAt: ev.end ? eventDay(ev.end, ev.all_day) : null,
|
endsAt: ev.end ? eventDay(ev.end, ev.all_day) : null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Eindeutig auch für aufgeklappte Wiederholungen: die teilen sich die UID
|
||||||
|
* und unterscheiden sich nur im Anfangszeitpunkt. */
|
||||||
|
function eventKey(ev: EventWithCalendar): string {
|
||||||
|
return `${ev.calendarId}|${ev.uid}|${ev.start}`
|
||||||
|
}
|
||||||
|
|
||||||
/** Termine je Tag. Ein mehrtägiger Termin steht in **jeder** Zelle, die er
|
/** Termine je Tag. Ein mehrtägiger Termin steht in **jeder** Zelle, die er
|
||||||
* belegt – vorher nur am Starttag, was ihn ab dem zweiten Tag verschwinden
|
* belegt – vorher nur am Starttag, was ihn ab dem zweiten Tag verschwinden
|
||||||
* ließ. */
|
* ließ. */
|
||||||
@@ -66,7 +70,7 @@ function groupByDay(events: EventWithCalendar[]): Map<string, EventWithCalendar[
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Vorschlag für einen neuen Termin: heute die nächste volle Stunde, an
|
/** Vorschlag für einen neuen Termin: heute die nächste volle Stunde, an
|
||||||
* anderen Tagen 9 Uhr. Mitternacht als Vorgabe (vorher) trifft fast nie zu. */
|
* anderen Tagen 9 Uhr. Mitternacht als Vorgabe trifft fast nie zu. */
|
||||||
function suggestStart(day: Date): Date {
|
function suggestStart(day: Date): Date {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
if (isSameDay(day, now)) {
|
if (isSameDay(day, now)) {
|
||||||
@@ -83,8 +87,7 @@ function suggestStart(day: Date): Date {
|
|||||||
export default function CalendarPage() {
|
export default function CalendarPage() {
|
||||||
const [calendars, setCalendars] = useState<CalendarItem[]>([])
|
const [calendars, setCalendars] = useState<CalendarItem[]>([])
|
||||||
/** Ausgeblendete Kalender. Bewusst andersherum als „sichtbare": So ist ein
|
/** Ausgeblendete Kalender. Bewusst andersherum als „sichtbare": So ist ein
|
||||||
* **neu angelegter** Kalender automatisch sichtbar. Vorher landete er in
|
* **neu angelegter** Kalender automatisch sichtbar. */
|
||||||
* keiner Sichtbar-Liste und blieb nach dem Anlegen unsichtbar. */
|
|
||||||
const [hidden, setHidden] = useState<Set<string>>(new Set())
|
const [hidden, setHidden] = useState<Set<string>>(new Set())
|
||||||
const [eventsByCal, setEventsByCal] = useState<Record<string, CalendarEvent[]>>({})
|
const [eventsByCal, setEventsByCal] = useState<Record<string, CalendarEvent[]>>({})
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('month')
|
const [viewMode, setViewMode] = useState<ViewMode>('month')
|
||||||
@@ -94,6 +97,7 @@ export default function CalendarPage() {
|
|||||||
const [showList, setShowList] = useState(() =>
|
const [showList, setShowList] = useState(() =>
|
||||||
typeof window === 'undefined' ? true : window.matchMedia('(min-width: 900px)').matches)
|
typeof window === 'undefined' ? true : window.matchMedia('(min-width: 900px)').matches)
|
||||||
|
|
||||||
|
const [dayDialog, setDayDialog] = useState<Date | null>(null)
|
||||||
const [eventDialog, setEventDialog] = useState<{ calId: string; event?: CalendarEvent; start: Date } | null>(null)
|
const [eventDialog, setEventDialog] = useState<{ calId: string; event?: CalendarEvent; start: Date } | null>(null)
|
||||||
const [calDialog, setCalDialog] = useState<{ calendar?: CalendarItem } | null>(null)
|
const [calDialog, setCalDialog] = useState<{ calendar?: CalendarItem } | null>(null)
|
||||||
const [deleteCalendar, setDeleteCalendar] = useState<CalendarItem | null>(null)
|
const [deleteCalendar, setDeleteCalendar] = useState<CalendarItem | null>(null)
|
||||||
@@ -102,10 +106,28 @@ export default function CalendarPage() {
|
|||||||
const importInputRef = useRef<HTMLInputElement>(null)
|
const importInputRef = useRef<HTMLInputElement>(null)
|
||||||
const importTargetRef = useRef<string | null>(null)
|
const importTargetRef = useRef<string | null>(null)
|
||||||
|
|
||||||
const loadEvents = useCallback(async (list: CalendarItem[]) => {
|
/**
|
||||||
|
* Sichtbares Zeitfenster. Der Server klappt Wiederholungen **darin** auf –
|
||||||
|
* eine wöchentliche Reihe ohne Ende hat kein natürliches Ende, also muss
|
||||||
|
* die Ansicht sagen, welchen Ausschnitt sie braucht.
|
||||||
|
*/
|
||||||
|
const range = useMemo(() => {
|
||||||
|
if (viewMode === 'month') {
|
||||||
|
const s = startOfWeek(startOfMonth(cursor))
|
||||||
|
return { from: addDays(s, -1), to: addDays(s, 43) }
|
||||||
|
}
|
||||||
|
if (viewMode === 'week') {
|
||||||
|
const s = startOfWeek(cursor)
|
||||||
|
return { from: addDays(s, -1), to: addDays(s, 8) }
|
||||||
|
}
|
||||||
|
const today = startOfDay(new Date())
|
||||||
|
return { from: addDays(today, -31), to: addDays(today, 365) }
|
||||||
|
}, [viewMode, cursor.getFullYear(), cursor.getMonth(), viewMode === 'week' ? +startOfWeek(cursor) : 0])
|
||||||
|
|
||||||
|
const loadEvents = useCallback(async (list: CalendarItem[], from: Date, to: Date) => {
|
||||||
const pairs = await Promise.all(list.map(async c => {
|
const pairs = await Promise.all(list.map(async c => {
|
||||||
try {
|
try {
|
||||||
return [c.id, await calendarApi.listEvents(c.id)] as const
|
return [c.id, await calendarApi.listEvents(c.id, from, to)] as const
|
||||||
} catch {
|
} catch {
|
||||||
return [c.id, [] as CalendarEvent[]] as const
|
return [c.id, [] as CalendarEvent[]] as const
|
||||||
}
|
}
|
||||||
@@ -113,20 +135,29 @@ export default function CalendarPage() {
|
|||||||
setEventsByCal(Object.fromEntries(pairs))
|
setEventsByCal(Object.fromEntries(pairs))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const reloadAll = useCallback(async () => {
|
const loadCalendars = useCallback(async () => {
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const list = await calendarApi.list()
|
setCalendars(await calendarApi.list())
|
||||||
setCalendars(list)
|
|
||||||
await loadEvents(list)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof APIError ? e.message : 'Laden fehlgeschlagen')
|
setError(e instanceof APIError ? e.message : 'Laden fehlgeschlagen')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [loadEvents])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { reloadAll() }, [reloadAll])
|
useEffect(() => { loadCalendars() }, [loadCalendars])
|
||||||
|
|
||||||
|
// Termine hängen an Kalenderliste **und** Zeitfenster: Beim Blättern in
|
||||||
|
// einen anderen Monat müssen die Wiederholungen dort neu aufgeklappt werden.
|
||||||
|
useEffect(() => {
|
||||||
|
if (calendars.length === 0) { setEventsByCal({}); return }
|
||||||
|
loadEvents(calendars, range.from, range.to)
|
||||||
|
}, [calendars, range.from, range.to, loadEvents])
|
||||||
|
|
||||||
|
const refreshEvents = useCallback(
|
||||||
|
() => loadEvents(calendars, range.from, range.to),
|
||||||
|
[calendars, range.from, range.to, loadEvents])
|
||||||
|
|
||||||
const allEvents = useMemo(() => {
|
const allEvents = useMemo(() => {
|
||||||
const out: EventWithCalendar[] = []
|
const out: EventWithCalendar[] = []
|
||||||
@@ -149,13 +180,10 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const defaultCalendarId = calendars[0]?.id
|
const defaultCalendarId = calendars[0]?.id
|
||||||
|
|
||||||
const openNewEvent = useCallback((start: Date) => {
|
function openNewEvent(start: Date) {
|
||||||
setCalendars(cals => {
|
if (!defaultCalendarId) return
|
||||||
const first = cals[0]
|
setEventDialog({ calId: defaultCalendarId, start })
|
||||||
if (first) setEventDialog({ calId: first.id, start })
|
}
|
||||||
return cals
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
function openEditEvent(ev: EventWithCalendar) {
|
function openEditEvent(ev: EventWithCalendar) {
|
||||||
setEventDialog({ calId: ev.calendarId, event: ev, start: ev.startsAt })
|
setEventDialog({ calId: ev.calendarId, event: ev, start: ev.startsAt })
|
||||||
@@ -170,7 +198,7 @@ export default function CalendarPage() {
|
|||||||
await calendarApi.createEvent(calId, input)
|
await calendarApi.createEvent(calId, input)
|
||||||
}
|
}
|
||||||
setEventDialog(null)
|
setEventDialog(null)
|
||||||
await loadEvents(calendars)
|
await refreshEvents()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen')
|
setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen')
|
||||||
}
|
}
|
||||||
@@ -182,7 +210,7 @@ export default function CalendarPage() {
|
|||||||
await calendarApi.deleteEvent(deleteEvent.calId, deleteEvent.uid)
|
await calendarApi.deleteEvent(deleteEvent.calId, deleteEvent.uid)
|
||||||
setDeleteEvent(null)
|
setDeleteEvent(null)
|
||||||
setEventDialog(null)
|
setEventDialog(null)
|
||||||
await loadEvents(calendars)
|
await refreshEvents()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof APIError ? e.message : 'Löschen fehlgeschlagen')
|
setError(e instanceof APIError ? e.message : 'Löschen fehlgeschlagen')
|
||||||
}
|
}
|
||||||
@@ -196,7 +224,7 @@ export default function CalendarPage() {
|
|||||||
await calendarApi.create(name, color)
|
await calendarApi.create(name, color)
|
||||||
}
|
}
|
||||||
setCalDialog(null)
|
setCalDialog(null)
|
||||||
await reloadAll()
|
await loadCalendars()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen')
|
setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen')
|
||||||
}
|
}
|
||||||
@@ -207,7 +235,7 @@ export default function CalendarPage() {
|
|||||||
try {
|
try {
|
||||||
await calendarApi.remove(deleteCalendar.id)
|
await calendarApi.remove(deleteCalendar.id)
|
||||||
setDeleteCalendar(null)
|
setDeleteCalendar(null)
|
||||||
await reloadAll()
|
await loadCalendars()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof APIError ? e.message : 'Löschen fehlgeschlagen')
|
setError(e instanceof APIError ? e.message : 'Löschen fehlgeschlagen')
|
||||||
}
|
}
|
||||||
@@ -224,7 +252,7 @@ export default function CalendarPage() {
|
|||||||
if (!file || !calId) return
|
if (!file || !calId) return
|
||||||
try {
|
try {
|
||||||
await calendarApi.import(calId, file)
|
await calendarApi.import(calId, file)
|
||||||
await loadEvents(calendars)
|
await refreshEvents()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof APIError ? err.message : 'Import fehlgeschlagen')
|
setError(err instanceof APIError ? err.message : 'Import fehlgeschlagen')
|
||||||
}
|
}
|
||||||
@@ -238,11 +266,6 @@ export default function CalendarPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function jumpToDay(day: Date) {
|
|
||||||
setCursor(day)
|
|
||||||
setViewMode('week')
|
|
||||||
}
|
|
||||||
|
|
||||||
const periodLabel = viewMode === 'month'
|
const periodLabel = viewMode === 'month'
|
||||||
? `${monthLabels[cursor.getMonth()]} ${cursor.getFullYear()}`
|
? `${monthLabels[cursor.getMonth()]} ${cursor.getFullYear()}`
|
||||||
: viewMode === 'week'
|
: viewMode === 'week'
|
||||||
@@ -272,7 +295,7 @@ export default function CalendarPage() {
|
|||||||
...navBtnStyle, width: 30,
|
...navBtnStyle, width: 30,
|
||||||
color: showList ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
color: showList ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||||
}}>
|
}}>
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" style={{ display: 'block', margin: '0 auto' }}>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
|
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
|
||||||
<circle cx="3.5" cy="6" r="1.2" fill="currentColor" /><circle cx="3.5" cy="12" r="1.2" fill="currentColor" /><circle cx="3.5" cy="18" r="1.2" fill="currentColor" />
|
<circle cx="3.5" cy="6" r="1.2" fill="currentColor" /><circle cx="3.5" cy="12" r="1.2" fill="currentColor" /><circle cx="3.5" cy="18" r="1.2" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -330,16 +353,30 @@ export default function CalendarPage() {
|
|||||||
fontSize: 18, lineHeight: 1, padding: '0 2px',
|
fontSize: 18, lineHeight: 1, padding: '0 2px',
|
||||||
}}>+</button>
|
}}>+</button>
|
||||||
</div>
|
</div>
|
||||||
{calendars.map(cal => (
|
{calendars.map(cal => {
|
||||||
|
const on = !hidden.has(cal.id)
|
||||||
|
return (
|
||||||
<div key={cal.id} className="aether-cal-row" style={{
|
<div key={cal.id} className="aether-cal-row" style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 4px',
|
display: 'flex', alignItems: 'center', gap: 9, padding: '6px 4px',
|
||||||
fontSize: 13, color: 'var(--color-text)', borderRadius: 6,
|
fontSize: 13, color: 'var(--color-text)', borderRadius: 6,
|
||||||
}}>
|
}}>
|
||||||
<input type="checkbox" checked={!hidden.has(cal.id)} onChange={() => toggleVisible(cal.id)}
|
{/* Der Farbpunkt **ist** der Schalter: gefüllt = wird
|
||||||
style={{ accentColor: cal.color, cursor: 'pointer' }} />
|
angezeigt, leerer Ring = ausgeblendet. Ein Kästchen
|
||||||
|
daneben sagte dasselbe zweimal. */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleVisible(cal.id)}
|
||||||
|
role="switch" aria-checked={on}
|
||||||
|
title={on ? 'Ausblenden' : 'Einblenden'}
|
||||||
|
style={{
|
||||||
|
width: 15, height: 15, borderRadius: '50%', flexShrink: 0, padding: 0,
|
||||||
|
border: `2px solid ${cal.color}`,
|
||||||
|
background: on ? cal.color : 'transparent',
|
||||||
|
cursor: 'pointer', transition: 'background 0.12s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<span onClick={() => toggleVisible(cal.id)} style={{
|
<span onClick={() => toggleVisible(cal.id)} style={{
|
||||||
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer',
|
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer',
|
||||||
opacity: hidden.has(cal.id) ? 0.45 : 1,
|
opacity: on ? 1 : 0.45,
|
||||||
}}>
|
}}>
|
||||||
{cal.name}
|
{cal.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -350,7 +387,8 @@ export default function CalendarPage() {
|
|||||||
onDelete={() => setDeleteCalendar(cal)}
|
onDelete={() => setDeleteCalendar(cal)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
{calendars.length === 0 && (
|
{calendars.length === 0 && (
|
||||||
<p style={{ fontSize: 12.5, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
<p style={{ fontSize: 12.5, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||||
Noch kein Kalender – über <strong>+</strong> einen anlegen.
|
Noch kein Kalender – über <strong>+</strong> einen anlegen.
|
||||||
@@ -361,10 +399,10 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', display: 'flex' }}>
|
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', display: 'flex' }}>
|
||||||
{viewMode === 'month' && (
|
{viewMode === 'month' && (
|
||||||
<MonthGrid cursor={cursor} byDay={byDay} onDayClick={openNewEvent} onEventClick={openEditEvent} onMore={jumpToDay} />
|
<MonthGrid cursor={cursor} byDay={byDay} onOpenDay={setDayDialog} onEventClick={openEditEvent} />
|
||||||
)}
|
)}
|
||||||
{viewMode === 'week' && (
|
{viewMode === 'week' && (
|
||||||
<WeekGrid cursor={cursor} byDay={byDay} onSlotClick={openNewEvent} onEventClick={openEditEvent} />
|
<WeekGrid cursor={cursor} byDay={byDay} onSlotClick={openNewEvent} onOpenDay={setDayDialog} onEventClick={openEditEvent} />
|
||||||
)}
|
)}
|
||||||
{viewMode === 'list' && (
|
{viewMode === 'list' && (
|
||||||
<ListAgenda events={allEvents} onEventClick={openEditEvent} />
|
<ListAgenda events={allEvents} onEventClick={openEditEvent} />
|
||||||
@@ -374,6 +412,15 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
<input ref={importInputRef} type="file" accept=".ics,text/calendar" style={{ display: 'none' }} onChange={handleImportFile} />
|
<input ref={importInputRef} type="file" accept=".ics,text/calendar" style={{ display: 'none' }} onChange={handleImportFile} />
|
||||||
|
|
||||||
|
{dayDialog && (
|
||||||
|
<DayDialog
|
||||||
|
day={dayDialog}
|
||||||
|
events={byDay.get(dayKey(dayDialog)) ?? []}
|
||||||
|
onPick={ev => { setDayDialog(null); openEditEvent(ev) }}
|
||||||
|
onCreate={() => { const d = dayDialog; setDayDialog(null); openNewEvent(suggestStart(d)) }}
|
||||||
|
onClose={() => setDayDialog(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{eventDialog && (
|
{eventDialog && (
|
||||||
<EventDialog
|
<EventDialog
|
||||||
event={eventDialog.event}
|
event={eventDialog.event}
|
||||||
@@ -400,7 +447,7 @@ export default function CalendarPage() {
|
|||||||
{deleteEvent && (
|
{deleteEvent && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Termin löschen"
|
title="Termin löschen"
|
||||||
message="Diesen Termin unwiderruflich löschen?"
|
message="Diesen Termin unwiderruflich löschen? Bei einer Reihe verschwinden alle Termine der Reihe."
|
||||||
confirmLabel="Löschen" danger
|
confirmLabel="Löschen" danger
|
||||||
onConfirm={confirmDeleteEvent}
|
onConfirm={confirmDeleteEvent}
|
||||||
onCancel={() => setDeleteEvent(null)}
|
onCancel={() => setDeleteEvent(null)}
|
||||||
@@ -478,8 +525,7 @@ function timeLabel(d: Date): string {
|
|||||||
|
|
||||||
/** Ein Termin im Monatsraster bzw. in der ganztägigen Zeile der Woche.
|
/** Ein Termin im Monatsraster bzw. in der ganztägigen Zeile der Woche.
|
||||||
* `stopPropagation` ist hier das Entscheidende: Ohne das reichte der Klick
|
* `stopPropagation` ist hier das Entscheidende: Ohne das reichte der Klick
|
||||||
* an die Tageszelle darunter durch, die sofort den Dialog „Neuer Termin"
|
* an die Tageszelle darunter durch und öffnete den Tag statt den Termin. */
|
||||||
* öffnete – ein vorhandener Termin ließ sich dadurch gar nicht öffnen. */
|
|
||||||
function EventChip({ ev, onClick }: { ev: EventWithCalendar; onClick: () => void }) {
|
function EventChip({ ev, onClick }: { ev: EventWithCalendar; onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -495,16 +541,16 @@ function EventChip({ ev, onClick }: { ev: EventWithCalendar; onClick: () => void
|
|||||||
>
|
>
|
||||||
{!ev.all_day && <span style={{ opacity: 0.75 }}>{timeLabel(ev.startsAt)}</span>}
|
{!ev.all_day && <span style={{ opacity: 0.75 }}>{timeLabel(ev.startsAt)}</span>}
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{ev.summary || '(ohne Titel)'}</span>
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{ev.summary || '(ohne Titel)'}</span>
|
||||||
|
{ev.recurring && <span style={{ opacity: 0.7, flexShrink: 0 }}>↻</span>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MonthGrid({ cursor, byDay, onDayClick, onEventClick, onMore }: {
|
function MonthGrid({ cursor, byDay, onOpenDay, onEventClick }: {
|
||||||
cursor: Date
|
cursor: Date
|
||||||
byDay: Map<string, EventWithCalendar[]>
|
byDay: Map<string, EventWithCalendar[]>
|
||||||
onDayClick: (start: Date) => void
|
onOpenDay: (day: Date) => void
|
||||||
onEventClick: (ev: EventWithCalendar) => void
|
onEventClick: (ev: EventWithCalendar) => void
|
||||||
onMore: (day: Date) => void
|
|
||||||
}) {
|
}) {
|
||||||
const gridStart = startOfWeek(startOfMonth(cursor))
|
const gridStart = startOfWeek(startOfMonth(cursor))
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
@@ -532,7 +578,7 @@ function MonthGrid({ cursor, byDay, onDayClick, onEventClick, onMore }: {
|
|||||||
<div
|
<div
|
||||||
key={+day}
|
key={+day}
|
||||||
className="aether-cal-cell"
|
className="aether-cal-cell"
|
||||||
onClick={() => onDayClick(suggestStart(day))}
|
onClick={() => onOpenDay(day)}
|
||||||
style={{
|
style={{
|
||||||
borderRight: '1px solid var(--color-border)', padding: 4, cursor: 'pointer',
|
borderRight: '1px solid var(--color-border)', padding: 4, cursor: 'pointer',
|
||||||
background: inMonth ? 'transparent' : 'var(--color-surface-2)',
|
background: inMonth ? 'transparent' : 'var(--color-surface-2)',
|
||||||
@@ -550,18 +596,12 @@ function MonthGrid({ cursor, byDay, onDayClick, onEventClick, onMore }: {
|
|||||||
</span>
|
</span>
|
||||||
<div style={{ overflow: 'hidden', minWidth: 0 }}>
|
<div style={{ overflow: 'hidden', minWidth: 0 }}>
|
||||||
{events.slice(0, 3).map(ev => (
|
{events.slice(0, 3).map(ev => (
|
||||||
<EventChip key={ev.calendarId + ev.uid} ev={ev} onClick={() => onEventClick(ev)} />
|
<EventChip key={eventKey(ev)} ev={ev} onClick={() => onEventClick(ev)} />
|
||||||
))}
|
))}
|
||||||
{events.length > 3 && (
|
{events.length > 3 && (
|
||||||
<button
|
<span style={{ fontSize: 10.5, color: 'var(--color-text-muted)', paddingLeft: 4 }}>
|
||||||
onClick={e => { e.stopPropagation(); onMore(day) }}
|
|
||||||
style={{
|
|
||||||
border: 'none', background: 'none', padding: '0 4px', cursor: 'pointer',
|
|
||||||
fontSize: 10.5, color: 'var(--color-text-muted)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
+{events.length - 3} weitere
|
+{events.length - 3} weitere
|
||||||
</button>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -624,10 +664,11 @@ function layoutDay(events: EventWithCalendar[], day: Date): Slot[] {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: {
|
function WeekGrid({ cursor, byDay, onSlotClick, onOpenDay, onEventClick }: {
|
||||||
cursor: Date
|
cursor: Date
|
||||||
byDay: Map<string, EventWithCalendar[]>
|
byDay: Map<string, EventWithCalendar[]>
|
||||||
onSlotClick: (start: Date) => void
|
onSlotClick: (start: Date) => void
|
||||||
|
onOpenDay: (day: Date) => void
|
||||||
onEventClick: (ev: EventWithCalendar) => void
|
onEventClick: (ev: EventWithCalendar) => void
|
||||||
}) {
|
}) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -643,7 +684,7 @@ function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const cols = `${GUTTER}px repeat(7, minmax(0, 1fr))`
|
const cols = `${GUTTER}px repeat(7, minmax(0, 1fr))`
|
||||||
const nowTop = ((now: Date) => (now.getHours() * 60 + now.getMinutes()) / 60 * HOUR_H)(today)
|
const nowTop = (today.getHours() * 60 + today.getMinutes()) / 60 * HOUR_H
|
||||||
|
|
||||||
/** Klick ins Raster: die angeklickte Stunde, nicht Mitternacht. */
|
/** Klick ins Raster: die angeklickte Stunde, nicht Mitternacht. */
|
||||||
function slotFromClick(e: MouseEvent<HTMLDivElement>, day: Date) {
|
function slotFromClick(e: MouseEvent<HTMLDivElement>, day: Date) {
|
||||||
@@ -657,13 +698,17 @@ function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
|
||||||
{/* Kopfzeile */}
|
{/* Kopfzeile – der Tag darin öffnet den Tag, wie im Monatsraster. */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: cols, borderBottom: '1px solid var(--color-border)', flexShrink: 0 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: cols, borderBottom: '1px solid var(--color-border)', flexShrink: 0 }}>
|
||||||
<div />
|
<div />
|
||||||
{days.map(day => {
|
{days.map(day => {
|
||||||
const isToday = isSameDay(day, today)
|
const isToday = isSameDay(day, today)
|
||||||
return (
|
return (
|
||||||
<div key={+day} style={{ padding: '6px 4px', textAlign: 'center', borderLeft: '1px solid var(--color-border)' }}>
|
<button key={+day} onClick={() => onOpenDay(day)} className="aether-cal-cell" style={{
|
||||||
|
padding: '6px 4px', textAlign: 'center', borderLeft: '1px solid var(--color-border)',
|
||||||
|
background: 'transparent', border: 'none', borderLeftWidth: 1, borderLeftStyle: 'solid',
|
||||||
|
borderLeftColor: 'var(--color-border)', cursor: 'pointer',
|
||||||
|
}}>
|
||||||
<div style={{ fontSize: 10.5, color: 'var(--color-text-muted)' }}>{weekdayLabels[(day.getDay() + 6) % 7]}</div>
|
<div style={{ fontSize: 10.5, color: 'var(--color-text-muted)' }}>{weekdayLabels[(day.getDay() + 6) % 7]}</div>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: 14, fontWeight: isToday ? 700 : 500,
|
fontSize: 14, fontWeight: isToday ? 700 : 500,
|
||||||
@@ -673,20 +718,19 @@ function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: {
|
|||||||
}}>
|
}}>
|
||||||
{day.getDate()}
|
{day.getDate()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ganztägige Termine – eigene Zeile über dem Stundenraster, so wie sie
|
{/* Ganztägige Termine – eigene Zeile über dem Stundenraster. */}
|
||||||
auch gemeint sind: ohne Uhrzeit. */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: cols, borderBottom: '1px solid var(--color-border)', flexShrink: 0, minHeight: 26 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: cols, borderBottom: '1px solid var(--color-border)', flexShrink: 0, minHeight: 26 }}>
|
||||||
<div style={{ fontSize: 9.5, color: 'var(--color-text-muted)', padding: '4px 6px', textAlign: 'right' }}>ganztg.</div>
|
<div style={{ fontSize: 9.5, color: 'var(--color-text-muted)', padding: '4px 6px', textAlign: 'right' }}>ganztg.</div>
|
||||||
{days.map(day => (
|
{days.map(day => (
|
||||||
<div key={+day} className="aether-cal-cell" onClick={() => onSlotClick(suggestStart(day))}
|
<div key={+day} className="aether-cal-cell" onClick={() => onOpenDay(day)}
|
||||||
style={{ borderLeft: '1px solid var(--color-border)', padding: 2, cursor: 'pointer', minWidth: 0 }}>
|
style={{ borderLeft: '1px solid var(--color-border)', padding: 2, cursor: 'pointer', minWidth: 0 }}>
|
||||||
{(byDay.get(dayKey(day)) ?? []).filter(ev => ev.all_day).map(ev => (
|
{(byDay.get(dayKey(day)) ?? []).filter(ev => ev.all_day).map(ev => (
|
||||||
<EventChip key={ev.calendarId + ev.uid} ev={ev} onClick={() => onEventClick(ev)} />
|
<EventChip key={eventKey(ev)} ev={ev} onClick={() => onEventClick(ev)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -733,7 +777,7 @@ function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: {
|
|||||||
|
|
||||||
{slots.map(({ ev, top, height, col, cols: n }) => (
|
{slots.map(({ ev, top, height, col, cols: n }) => (
|
||||||
<div
|
<div
|
||||||
key={ev.calendarId + ev.uid}
|
key={eventKey(ev)}
|
||||||
className="aether-cal-chip"
|
className="aether-cal-chip"
|
||||||
onClick={e => { e.stopPropagation(); onEventClick(ev) }}
|
onClick={e => { e.stopPropagation(); onEventClick(ev) }}
|
||||||
title={`${ev.summary} – ${timeLabel(ev.startsAt)}`}
|
title={`${ev.summary} – ${timeLabel(ev.startsAt)}`}
|
||||||
@@ -746,7 +790,7 @@ function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 10.5, color: ev.color, fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
<div style={{ fontSize: 10.5, color: ev.color, fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
{ev.summary || '(ohne Titel)'}
|
{ev.summary || '(ohne Titel)'}{ev.recurring ? ' ↻' : ''}
|
||||||
</div>
|
</div>
|
||||||
{height > 28 && (
|
{height > 28 && (
|
||||||
<div style={{ fontSize: 9.5, color: 'var(--color-text-muted)' }}>{timeLabel(ev.startsAt)}</div>
|
<div style={{ fontSize: 9.5, color: 'var(--color-text-muted)' }}>{timeLabel(ev.startsAt)}</div>
|
||||||
@@ -794,7 +838,7 @@ function ListAgenda({ events, onEventClick }: { events: EventWithCalendar[]; onE
|
|||||||
{isSameDay(day, today) && ' · heute'}
|
{isSameDay(day, today) && ' · heute'}
|
||||||
</div>
|
</div>
|
||||||
{evs.map(ev => (
|
{evs.map(ev => (
|
||||||
<div key={ev.calendarId + ev.uid} className="aether-cal-cell" onClick={() => onEventClick(ev)} style={{
|
<div key={eventKey(ev)} className="aether-cal-cell" onClick={() => onEventClick(ev)} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8,
|
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8,
|
||||||
cursor: 'pointer', border: '1px solid var(--color-border)', marginBottom: 6,
|
cursor: 'pointer', border: '1px solid var(--color-border)', marginBottom: 6,
|
||||||
}}>
|
}}>
|
||||||
@@ -803,7 +847,7 @@ function ListAgenda({ events, onEventClick }: { events: EventWithCalendar[]; onE
|
|||||||
{ev.all_day ? 'ganztg.' : timeLabel(ev.startsAt)}
|
{ev.all_day ? 'ganztg.' : timeLabel(ev.startsAt)}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 13.5, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ fontSize: 13.5, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{ev.summary || '(ohne Titel)'}
|
{ev.summary || '(ohne Titel)'}{ev.recurring ? ' ↻' : ''}
|
||||||
</span>
|
</span>
|
||||||
{ev.location && (
|
{ev.location && (
|
||||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)', marginLeft: 'auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ fontSize: 12, color: 'var(--color-text-muted)', marginLeft: 'auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
|||||||
Reference in new issue
Block a user