feat(calendar): Phase 3 – Weboberfläche mit Monats-, Wochen- und Listenansicht
Neue Seite CalendarPage (Menüpunkt 'Calendar' in der Seitenleiste,
zwischen Home und Papierkorb) mit Kalenderliste zum Anlegen/Umbenennen/
Löschen/Ein- und Ausblenden, Export/Import als .ics über die
Phase-2-API, und Termine anlegen/ändern/löschen über einen neuen
JSON-Endpunkt.
Backend: neuer events_api.go-Handler unter /api/v1/calendars/{id}/events
(Session-Auth) neben dem bestehenden CalDAV-Protokoll – ein Termin, den
die Web-Oberfläche anlegt, ist ein ganz normales CalDAV-Objekt und
synchronisiert genauso zu DAVx5 wie umgekehrt. Ein per CalDAV mit RRULE
angelegter, wiederkehrender Termin zeigt sich hier nur (recurring:true),
lässt sich aber bewusst nicht über dieses vereinfachte Formular
bearbeiten (409) – das würde die Wiederholungsregel eher beschädigen
als korrekt anpassen.
Gegen den laufenden Container getestet: Kalender/Termin anlegen, per
Web-API ändern und löschen, ein per CalDAV mit RRULE angelegter
Wiederkehrer taucht als recurring:true auf und blockt PATCH mit 409.
TypeScript-Build (tsc + vite build) sauber.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4d635fff20
commit
27a026421f
10 files changed
+1163
-6
No files matched your search
@@ -534,6 +534,10 @@ func main() {
|
||||
r.Delete("/calendars/{id}", calAPIHandler.Delete)
|
||||
r.Get("/calendars/{id}/export", calAPIHandler.Export)
|
||||
r.Post("/calendars/{id}/import", calAPIHandler.Import)
|
||||
r.Get("/calendars/{id}/events", calAPIHandler.ListEvents)
|
||||
r.Post("/calendars/{id}/events", calAPIHandler.CreateEvent)
|
||||
r.Patch("/calendars/{id}/events/{uid}", calAPIHandler.UpdateEvent)
|
||||
r.Delete("/calendars/{id}/events/{uid}", calAPIHandler.DeleteEvent)
|
||||
|
||||
r.Get("/app-passwords", authHandler.ListAppPasswords)
|
||||
r.Post("/app-passwords", authHandler.CreateAppPassword)
|
||||
|
||||
@@ -294,6 +294,14 @@ func timeRange(cal *ical.Calendar) (start, end *time.Time) {
|
||||
return
|
||||
}
|
||||
|
||||
func encodeICal(cal *ical.Calendar) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := ical.NewEncoder(&buf).Encode(cal); err != nil {
|
||||
return "", fmt.Errorf("encode object: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func etagFor(data string) string {
|
||||
sum := sha256.Sum256([]byte(data))
|
||||
return hex.EncodeToString(sum[:])
|
||||
@@ -328,11 +336,10 @@ func (b *Backend) PutCalendarObject(ctx context.Context, p string, cal *ical.Cal
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := ical.NewEncoder(&buf).Encode(cal); err != nil {
|
||||
return nil, fmt.Errorf("encode object: %w", err)
|
||||
data, err := encodeICal(cal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := buf.String()
|
||||
etag := etagFor(data)
|
||||
start, end := timeRange(cal)
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ical "github.com/emersion/go-ical"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"aether/internal/auth"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
|
||||
// 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
|
||||
// ab – reicht für Anlegen/Ändern eines einfachen Termins im Browser. Ein
|
||||
// über einen echten CalDAV-Client angelegter Termin mit Wiederholung bleibt
|
||||
// über /caldav/ voll erhalten, zeigt sich hier nur mit Recurring=true und
|
||||
// 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 {
|
||||
UID string `json:"uid"`
|
||||
Summary string `json:"summary"`
|
||||
Description string `json:"description"`
|
||||
Location string `json:"location"`
|
||||
Start time.Time `json:"start"`
|
||||
End *time.Time `json:"end,omitempty"`
|
||||
AllDay bool `json:"all_day"`
|
||||
Recurring bool `json:"recurring"`
|
||||
ETag string `json:"etag"`
|
||||
}
|
||||
|
||||
func eventFromObject(o *Object) (*EventDTO, error) {
|
||||
dec := ical.NewDecoder(strings.NewReader(o.Data))
|
||||
cal, err := dec.Decode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ev *ical.Component
|
||||
for _, child := range cal.Children {
|
||||
if child.Name == "VEVENT" || child.Name == "VTODO" {
|
||||
ev = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if ev == nil {
|
||||
return nil, errNoEventComponent
|
||||
}
|
||||
|
||||
summary, _ := ev.Props.Text(ical.PropSummary)
|
||||
description, _ := ev.Props.Text(ical.PropDescription)
|
||||
location, _ := ev.Props.Text(ical.PropLocation)
|
||||
_, recurring := ev.Props["RRULE"]
|
||||
|
||||
dto := &EventDTO{
|
||||
UID: o.UID, Summary: summary, Description: description, Location: location,
|
||||
Recurring: recurring, ETag: o.ETag,
|
||||
}
|
||||
|
||||
startProp := ev.Props.Get(ical.PropDateTimeStart)
|
||||
if startProp != nil {
|
||||
dto.AllDay = startProp.ValueType() == ical.ValueDate
|
||||
if t, err := startProp.DateTime(time.UTC); err == nil {
|
||||
dto.Start = t
|
||||
}
|
||||
}
|
||||
if endProp := ev.Props.Get(ical.PropDateTimeEnd); endProp != nil {
|
||||
if t, err := endProp.DateTime(time.UTC); err == nil {
|
||||
dto.End = &t
|
||||
}
|
||||
}
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
var errNoEventComponent = &icalError{"calendar: object has no VEVENT/VTODO component"}
|
||||
|
||||
type icalError struct{ msg string }
|
||||
|
||||
func (e *icalError) Error() string { return e.msg }
|
||||
|
||||
// GET /api/v1/calendars/{id}/events
|
||||
func (h *APIHandler) ListEvents(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
calID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
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")
|
||||
return
|
||||
}
|
||||
objs, err := h.store.ListObjects(r.Context(), calID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "list events failed")
|
||||
return
|
||||
}
|
||||
out := make([]EventDTO, 0, len(objs))
|
||||
for i := range objs {
|
||||
dto, err := eventFromObject(&objs[i])
|
||||
if err != nil {
|
||||
continue // eine kaputte Einzeldatei darf die Liste nicht abbrechen
|
||||
}
|
||||
out = append(out, *dto)
|
||||
}
|
||||
response.JSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type eventBody struct {
|
||||
Summary string `json:"summary"`
|
||||
Description string `json:"description"`
|
||||
Location string `json:"location"`
|
||||
Start time.Time `json:"start"`
|
||||
End *time.Time `json:"end"`
|
||||
AllDay bool `json:"all_day"`
|
||||
}
|
||||
|
||||
func buildEventICal(uid string, body eventBody) *ical.Calendar {
|
||||
root := ical.NewCalendar()
|
||||
root.Props.SetText(ical.PropVersion, "2.0")
|
||||
root.Props.SetText(ical.PropProductID, "-//Aether//Kalender//DE")
|
||||
|
||||
ev := ical.NewEvent()
|
||||
ev.Props.SetText(ical.PropUID, uid)
|
||||
ev.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC())
|
||||
if body.Summary != "" {
|
||||
ev.Props.SetText(ical.PropSummary, body.Summary)
|
||||
}
|
||||
if body.Description != "" {
|
||||
ev.Props.SetText(ical.PropDescription, body.Description)
|
||||
}
|
||||
if body.Location != "" {
|
||||
ev.Props.SetText(ical.PropLocation, body.Location)
|
||||
}
|
||||
if body.AllDay {
|
||||
ev.Props.SetDate(ical.PropDateTimeStart, body.Start)
|
||||
if body.End != nil {
|
||||
ev.Props.SetDate(ical.PropDateTimeEnd, *body.End)
|
||||
}
|
||||
} else {
|
||||
ev.Props.SetDateTime(ical.PropDateTimeStart, body.Start.UTC())
|
||||
if body.End != nil {
|
||||
ev.Props.SetDateTime(ical.PropDateTimeEnd, body.End.UTC())
|
||||
}
|
||||
}
|
||||
root.Children = append(root.Children, ev.Component)
|
||||
return root
|
||||
}
|
||||
|
||||
// POST /api/v1/calendars/{id}/events
|
||||
func (h *APIHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
calID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
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")
|
||||
return
|
||||
}
|
||||
var body eventBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Summary == "" || body.Start.IsZero() {
|
||||
response.Error(w, http.StatusBadRequest, "summary and start are required")
|
||||
return
|
||||
}
|
||||
|
||||
uid := uuid.New().String()
|
||||
cal := buildEventICal(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 {
|
||||
response.Error(w, http.StatusInternalServerError, "create event failed")
|
||||
return
|
||||
}
|
||||
dto, err := eventFromObject(obj)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "read back event failed")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusCreated, dto)
|
||||
}
|
||||
|
||||
// PATCH /api/v1/calendars/{id}/events/{uid}
|
||||
func (h *APIHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
calID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
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")
|
||||
return
|
||||
}
|
||||
uid := chi.URLParam(r, "uid")
|
||||
existing, err := h.store.GetObjectByUID(r.Context(), calID, uid)
|
||||
if err == ErrNotFound {
|
||||
response.Error(w, http.StatusNotFound, "event not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "load event failed")
|
||||
return
|
||||
}
|
||||
existingDTO, err := eventFromObject(existing)
|
||||
if err == nil && existingDTO.Recurring {
|
||||
// Diese Ansicht kennt keine RRULE-Bearbeitung – ein Formular mit nur
|
||||
// "Titel/Start/Ende" würde die Wiederholungsregel beim Speichern
|
||||
// stillschweigend verwerfen. Lieber ablehnen als Daten beschädigen.
|
||||
response.Error(w, http.StatusConflict, "recurring events can only be edited in a CalDAV client")
|
||||
return
|
||||
}
|
||||
|
||||
var body eventBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Summary == "" || body.Start.IsZero() {
|
||||
response.Error(w, http.StatusBadRequest, "summary and start are required")
|
||||
return
|
||||
}
|
||||
cal := buildEventICal(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 {
|
||||
response.Error(w, http.StatusInternalServerError, "update event failed")
|
||||
return
|
||||
}
|
||||
dto, err := eventFromObject(obj)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "read back event failed")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, dto)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/calendars/{id}/events/{uid}
|
||||
func (h *APIHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
calID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
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")
|
||||
return
|
||||
}
|
||||
uid := chi.URLParam(r, "uid")
|
||||
if err := h.store.DeleteObject(r.Context(), calID, uid); err == ErrNotFound {
|
||||
response.Error(w, http.StatusNotFound, "event not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "delete event failed")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
@@ -884,4 +884,78 @@ export const adminApi = {
|
||||
request<AppSettings>('PATCH', '/admin/settings', data),
|
||||
}
|
||||
|
||||
// ── Calendar ─────────────────────────────────────────────────────────────
|
||||
// Termine selbst laufen für CalDAV-Clients (DAVx5 & Co.) über /caldav/ mit
|
||||
// App-Passwörtern – ein eigenes, Basic-Auth-geschütztes Protokoll. Diese
|
||||
// Web-Oberfläche spricht stattdessen /api/v1/calendars mit der normalen
|
||||
// Session, wie jeder andere Bereich.
|
||||
|
||||
export interface CalendarItem {
|
||||
id: string
|
||||
owner_id: string
|
||||
name: string
|
||||
color: string
|
||||
description: string
|
||||
supports_todo: boolean
|
||||
timezone: string
|
||||
ctag: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
uid: string
|
||||
summary: string
|
||||
description: string
|
||||
location: string
|
||||
/** ISO 8601, immer UTC ("...Z") – der Server rechnet nicht um, das
|
||||
* übernimmt der Browser beim Anzeigen (new Date(...) kennt die lokale
|
||||
* Zeitzone bereits). */
|
||||
start: string
|
||||
end?: string
|
||||
all_day: boolean
|
||||
/** Über einen echten CalDAV-Client mit Wiederholung angelegt – hier nur
|
||||
* sichtbar, nicht bearbeitbar (siehe events_api.go, UpdateEvent). */
|
||||
recurring: boolean
|
||||
etag: string
|
||||
}
|
||||
|
||||
export interface EventInput {
|
||||
summary: string
|
||||
description?: string
|
||||
location?: string
|
||||
start: string
|
||||
end?: string
|
||||
all_day: boolean
|
||||
}
|
||||
|
||||
export const calendarApi = {
|
||||
list: () => request<CalendarItem[]>('GET', '/calendars'),
|
||||
create: (name: string, color: string, description = '') =>
|
||||
request<CalendarItem>('POST', '/calendars', { name, color, description }),
|
||||
update: (id: string, patch: Partial<Pick<CalendarItem, 'name' | 'color' | 'description' | 'timezone'>>) =>
|
||||
request<CalendarItem>('PATCH', `/calendars/${id}`, patch),
|
||||
remove: (id: string) => request<void>('DELETE', `/calendars/${id}`),
|
||||
|
||||
exportUrl: (id: string) => `/api/v1/calendars/${id}/export`,
|
||||
|
||||
import: async (id: string, file: File): Promise<{ imported: number }> => {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'text/calendar' }
|
||||
if (csrfToken) headers['X-CSRF-Token'] = csrfToken
|
||||
const res = await fetch(`/api/v1/calendars/${id}/import`, {
|
||||
method: 'POST', headers, credentials: 'include', body: file,
|
||||
})
|
||||
const json = await res.json().catch(() => ({ error: res.statusText }))
|
||||
if (!res.ok) throw new APIError(res.status, json.error ?? 'Import failed')
|
||||
return json.data
|
||||
},
|
||||
|
||||
listEvents: (id: string) => request<CalendarEvent[]>('GET', `/calendars/${id}/events`),
|
||||
createEvent: (id: string, input: EventInput) =>
|
||||
request<CalendarEvent>('POST', `/calendars/${id}/events`, input),
|
||||
updateEvent: (id: string, uid: string, input: EventInput) =>
|
||||
request<CalendarEvent>('PATCH', `/calendars/${id}/events/${encodeURIComponent(uid)}`, input),
|
||||
deleteEvent: (id: string, uid: string) =>
|
||||
request<void>('DELETE', `/calendars/${id}/events/${encodeURIComponent(uid)}`),
|
||||
}
|
||||
|
||||
export { APIError }
|
||||
@@ -12,6 +12,7 @@ import SharedWithMePage from '../pages/SharedWithMePage'
|
||||
import AdminPage from '../pages/AdminPage'
|
||||
import ProfilePage from '../pages/ProfilePage'
|
||||
import TagsPage from '../pages/TagsPage'
|
||||
import CalendarPage from '../pages/CalendarPage'
|
||||
import { UserMenuProvider } from './UserMenu'
|
||||
|
||||
export type { View }
|
||||
@@ -112,6 +113,7 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
|
||||
reloadSignal={trashBump}
|
||||
/>
|
||||
)}
|
||||
{view === 'calendar' && <CalendarPage />}
|
||||
{view === 'trash' && <TrashPage onUserChanged={onUserChanged} />}
|
||||
{view === 'incoming' && <SharedWithMePage onOpenFolder={navigateToFolder} />}
|
||||
{view === 'shared' && <SharedPage />}
|
||||
|
||||
@@ -36,6 +36,16 @@ const nav: { id: View; label: string; icon: React.ReactNode }[] = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'calendar',
|
||||
label: 'Calendar',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M3 10h18M8 3v4M16 3v4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'trash',
|
||||
label: 'Trash',
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useRef, useState, type FormEvent } from 'react'
|
||||
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
|
||||
import type { CalendarItem } from '../../api/client'
|
||||
|
||||
const palette = ['#4285f4', '#ea4335', '#fbbc04', '#34a853', '#a142f4', '#ff6d00', '#00acc1', '#607d8b']
|
||||
|
||||
interface Props {
|
||||
calendar?: CalendarItem
|
||||
onSave: (name: string, color: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function CalendarDialog({ calendar, onSave, onCancel }: Props) {
|
||||
const overlay = useOverlayDismiss(onCancel)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [name, setName] = useState(calendar?.name ?? '')
|
||||
const [color, setColor] = useState(calendar?.color ?? palette[0])
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
onSave(name.trim(), color)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.6)', padding: 16 }} {...overlay}>
|
||||
<form onSubmit={handleSubmit} style={{
|
||||
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 14, padding: 24, width: 340, maxWidth: '100%',
|
||||
}}>
|
||||
<h2 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
{calendar ? 'Kalender bearbeiten' : 'Neuer Kalender'}
|
||||
</h2>
|
||||
|
||||
<label style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4, display: 'block' }}>Name</label>
|
||||
<input
|
||||
ref={inputRef} type="text" value={name} onChange={e => setName(e.target.value)}
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%', boxSizing: 'border-box', marginBottom: 16,
|
||||
background: 'var(--color-surface-2)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 8, padding: '8px 10px', fontSize: 13.5, color: 'var(--color-text)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<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' }}>
|
||||
{palette.map(c => (
|
||||
<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 style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button type="button" onClick={onCancel} style={{
|
||||
padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)',
|
||||
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
|
||||
}}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" style={{
|
||||
padding: '8px 16px', borderRadius: 8, border: 'none',
|
||||
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||
}}>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
|
||||
import type { CalendarEvent, EventInput } from '../../api/client'
|
||||
|
||||
interface Props {
|
||||
/** Fehlt sie, entsteht ein neuer Termin. */
|
||||
event?: CalendarEvent
|
||||
/** Vorbelegter Starttag – aus dem angeklickten Tag der Monats-/Wochenansicht. */
|
||||
defaultDate: Date
|
||||
onSave: (input: EventInput) => void
|
||||
onDelete?: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
function toDateInput(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
function toTimeInput(d: Date): string {
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export default function EventDialog({ event, defaultDate, onSave, onDelete, onCancel }: Props) {
|
||||
const overlay = useOverlayDismiss(onCancel)
|
||||
const titleRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const start = event ? new Date(event.start) : defaultDate
|
||||
const end = event?.end ? new Date(event.end) : new Date(start.getTime() + 60 * 60 * 1000)
|
||||
|
||||
const [summary, setSummary] = useState(event?.summary ?? '')
|
||||
const [description, setDescription] = useState(event?.description ?? '')
|
||||
const [location, setLocation] = useState(event?.location ?? '')
|
||||
const [allDay, setAllDay] = useState(event?.all_day ?? false)
|
||||
const [startDate, setStartDate] = useState(toDateInput(start))
|
||||
const [startTime, setStartTime] = useState(toTimeInput(start))
|
||||
const [endDate, setEndDate] = useState(toDateInput(end))
|
||||
const [endTime, setEndTime] = useState(toTimeInput(end))
|
||||
|
||||
useEffect(() => { titleRef.current?.focus() }, [])
|
||||
|
||||
const readOnly = !!event?.recurring
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (readOnly || !summary.trim()) return
|
||||
const s = allDay ? new Date(`${startDate}T00:00:00`) : new Date(`${startDate}T${startTime}:00`)
|
||||
const en = allDay ? new Date(`${endDate}T00:00:00`) : new Date(`${endDate}T${endTime}:00`)
|
||||
onSave({
|
||||
summary: summary.trim(),
|
||||
description: description.trim() || undefined,
|
||||
location: location.trim() || undefined,
|
||||
all_day: allDay,
|
||||
start: s.toISOString(),
|
||||
end: en.toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
background: 'var(--color-surface-2)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 8, padding: '8px 10px', fontSize: 13.5, color: 'var(--color-text)',
|
||||
}
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4, display: 'block',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.6)', padding: 16 }} {...overlay}>
|
||||
<form onSubmit={handleSubmit} style={{
|
||||
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 14, padding: 24, width: 420, maxWidth: '100%', maxHeight: '90vh', overflowY: 'auto',
|
||||
}}>
|
||||
<h2 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
{event ? 'Termin bearbeiten' : 'Neuer Termin'}
|
||||
</h2>
|
||||
|
||||
{readOnly && (
|
||||
<div style={{
|
||||
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,
|
||||
}}>
|
||||
Wiederholender Termin – nur mit einem CalDAV-Client (z. B. DAVx5) bearbeitbar, nicht hier.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>Titel</label>
|
||||
<input ref={titleRef} type="text" value={summary} disabled={readOnly}
|
||||
onChange={e => setSummary(e.target.value)} style={inputStyle} required />
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: 13, color: 'var(--color-text)' }}>
|
||||
<input type="checkbox" checked={allDay} disabled={readOnly} onChange={e => setAllDay(e.target.checked)} />
|
||||
Ganztägig
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Start</label>
|
||||
<input type="date" value={startDate} disabled={readOnly} onChange={e => setStartDate(e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
{!allDay && (
|
||||
<div style={{ width: 110 }}>
|
||||
<label style={labelStyle}> </label>
|
||||
<input type="time" value={startTime} disabled={readOnly} onChange={e => setStartTime(e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Ende</label>
|
||||
<input type="date" value={endDate} disabled={readOnly} onChange={e => setEndDate(e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
{!allDay && (
|
||||
<div style={{ width: 110 }}>
|
||||
<label style={labelStyle}> </label>
|
||||
<input type="time" value={endTime} disabled={readOnly} onChange={e => setEndTime(e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>Ort</label>
|
||||
<input type="text" value={location} disabled={readOnly} onChange={e => setLocation(e.target.value)} style={inputStyle} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label style={labelStyle}>Beschreibung</label>
|
||||
<textarea value={description} disabled={readOnly} onChange={e => setDescription(e.target.value)}
|
||||
rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||
{onDelete ? (
|
||||
<button type="button" onClick={onDelete} style={{
|
||||
padding: '8px 14px', borderRadius: 8, border: 'none',
|
||||
background: 'var(--color-danger)', color: '#fff', fontSize: 13, cursor: 'pointer',
|
||||
}}>
|
||||
Löschen
|
||||
</button>
|
||||
) : <span />}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" onClick={onCancel} style={{
|
||||
padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)',
|
||||
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
|
||||
}}>
|
||||
Abbrechen
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button type="submit" style={{
|
||||
padding: '8px 16px', borderRadius: 8, border: 'none',
|
||||
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||
}}>
|
||||
Speichern
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { FolderItem } from '../api/client'
|
||||
|
||||
export type View = 'files' | 'trash' | 'incoming' | 'shared' | 'admin' | 'profile' | 'tags' | 'from'
|
||||
export type View = 'files' | 'calendar' | 'trash' | 'incoming' | 'shared' | 'admin' | 'profile' | 'tags' | 'from'
|
||||
|
||||
const views: View[] = ['files', 'trash', 'incoming', 'shared', 'admin', 'profile', 'tags', 'from']
|
||||
const views: View[] = ['files', 'calendar', 'trash', 'incoming', 'shared', 'admin', 'profile', 'tags', 'from']
|
||||
|
||||
export interface NavState {
|
||||
view: View
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
calendarApi, APIError,
|
||||
type CalendarItem, type CalendarEvent, type EventInput,
|
||||
} from '../api/client'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import EventDialog from '../components/calendar/EventDialog'
|
||||
import CalendarDialog from '../components/calendar/CalendarDialog'
|
||||
import { TrashIcon, RenameIcon, DownloadIcon } from '../components/icons'
|
||||
|
||||
type ViewMode = 'month' | 'week' | 'list'
|
||||
|
||||
const weekdayLabels = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
const monthLabels = [
|
||||
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||
]
|
||||
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
||||
}
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const r = new Date(d)
|
||||
r.setDate(r.getDate() + n)
|
||||
return r
|
||||
}
|
||||
/** Montag der Woche, in der `d` liegt. */
|
||||
function startOfWeek(d: Date): Date {
|
||||
const r = new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||
const dow = (r.getDay() + 6) % 7 // 0 = Montag
|
||||
return addDays(r, -dow)
|
||||
}
|
||||
function startOfMonth(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1)
|
||||
}
|
||||
|
||||
interface EventWithCalendar extends CalendarEvent {
|
||||
calendarId: string
|
||||
color: string
|
||||
}
|
||||
|
||||
/** Ein flacher Zeiger von jedem Tag auf seine Termine – Grundlage für Monats-,
|
||||
* Wochen- und Listenansicht gleichermaßen. Mehrtägige Termine erscheinen nur
|
||||
* am Starttag; eine echte Spanne über mehrere Zellen ist für eine erste
|
||||
* Fassung bewusst zurückgestellt. */
|
||||
function groupByDay(events: EventWithCalendar[]): Map<string, EventWithCalendar[]> {
|
||||
const map = new Map<string, EventWithCalendar[]>()
|
||||
for (const ev of events) {
|
||||
const d = new Date(ev.start)
|
||||
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(ev)
|
||||
}
|
||||
for (const list of map.values()) list.sort((a, b) => a.start.localeCompare(b.start))
|
||||
return map
|
||||
}
|
||||
function dayKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
const [calendars, setCalendars] = useState<CalendarItem[]>([])
|
||||
const [visible, setVisible] = useState<Set<string>>(new Set())
|
||||
const [eventsByCal, setEventsByCal] = useState<Record<string, CalendarEvent[]>>({})
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('month')
|
||||
const [cursor, setCursor] = useState(new Date())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [eventDialog, setEventDialog] = useState<{ calId: string; event?: CalendarEvent; date: Date } | null>(null)
|
||||
const [calDialog, setCalDialog] = useState<{ calendar?: CalendarItem } | null>(null)
|
||||
const [deleteCalendar, setDeleteCalendar] = useState<CalendarItem | null>(null)
|
||||
const [deleteEvent, setDeleteEvent] = useState<{ calId: string; uid: string } | null>(null)
|
||||
|
||||
const importInputRef = useRef<HTMLInputElement>(null)
|
||||
const importTargetRef = useRef<string | null>(null)
|
||||
|
||||
const loadCalendars = useCallback(async () => {
|
||||
const list = await calendarApi.list()
|
||||
setCalendars(list)
|
||||
setVisible(prev => {
|
||||
// Neue Kalender starten sichtbar; einmal ausgeblendete bleiben es beim Neuladen.
|
||||
const next = new Set(prev)
|
||||
for (const c of list) if (!prev.has(c.id) && prev.size === 0) next.add(c.id)
|
||||
if (prev.size === 0) for (const c of list) next.add(c.id)
|
||||
return next
|
||||
})
|
||||
return list
|
||||
}, [])
|
||||
|
||||
const loadEvents = useCallback(async (list: CalendarItem[]) => {
|
||||
const pairs = await Promise.all(list.map(async c => {
|
||||
try {
|
||||
return [c.id, await calendarApi.listEvents(c.id)] as const
|
||||
} catch {
|
||||
return [c.id, []] as const
|
||||
}
|
||||
}))
|
||||
setEventsByCal(Object.fromEntries(pairs))
|
||||
}, [])
|
||||
|
||||
const reloadAll = useCallback(async () => {
|
||||
setError(null)
|
||||
try {
|
||||
const list = await loadCalendars()
|
||||
await loadEvents(list)
|
||||
} catch (e) {
|
||||
setError(e instanceof APIError ? e.message : 'Laden fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [loadCalendars, loadEvents])
|
||||
|
||||
useEffect(() => { reloadAll() }, [reloadAll])
|
||||
|
||||
const allEvents: EventWithCalendar[] = useMemo(() => {
|
||||
const out: EventWithCalendar[] = []
|
||||
for (const cal of calendars) {
|
||||
if (!visible.has(cal.id)) continue
|
||||
for (const ev of eventsByCal[cal.id] ?? []) {
|
||||
out.push({ ...ev, calendarId: cal.id, color: cal.color })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [calendars, visible, eventsByCal])
|
||||
|
||||
const byDay = useMemo(() => groupByDay(allEvents), [allEvents])
|
||||
|
||||
function toggleVisible(id: string) {
|
||||
setVisible(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const defaultCalendarId = calendars[0]?.id
|
||||
|
||||
function openNewEvent(date: Date) {
|
||||
if (!defaultCalendarId) return
|
||||
setEventDialog({ calId: defaultCalendarId, date })
|
||||
}
|
||||
function openEditEvent(ev: EventWithCalendar) {
|
||||
setEventDialog({ calId: ev.calendarId, event: ev, date: new Date(ev.start) })
|
||||
}
|
||||
|
||||
async function saveEvent(input: EventInput) {
|
||||
if (!eventDialog) return
|
||||
try {
|
||||
if (eventDialog.event) {
|
||||
await calendarApi.updateEvent(eventDialog.calId, eventDialog.event.uid, input)
|
||||
} else {
|
||||
await calendarApi.createEvent(eventDialog.calId, input)
|
||||
}
|
||||
setEventDialog(null)
|
||||
await loadEvents(calendars)
|
||||
} catch (e) {
|
||||
setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteEvent() {
|
||||
if (!deleteEvent) return
|
||||
try {
|
||||
await calendarApi.deleteEvent(deleteEvent.calId, deleteEvent.uid)
|
||||
setDeleteEvent(null)
|
||||
setEventDialog(null)
|
||||
await loadEvents(calendars)
|
||||
} catch (e) {
|
||||
setError(e instanceof APIError ? e.message : 'Löschen fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCalendar(name: string, color: string) {
|
||||
try {
|
||||
if (calDialog?.calendar) {
|
||||
await calendarApi.update(calDialog.calendar.id, { name, color })
|
||||
} else {
|
||||
await calendarApi.create(name, color)
|
||||
}
|
||||
setCalDialog(null)
|
||||
await reloadAll()
|
||||
} catch (e) {
|
||||
setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteCalendar() {
|
||||
if (!deleteCalendar) return
|
||||
try {
|
||||
await calendarApi.remove(deleteCalendar.id)
|
||||
setDeleteCalendar(null)
|
||||
await reloadAll()
|
||||
} catch (e) {
|
||||
setError(e instanceof APIError ? e.message : 'Löschen fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
function triggerImport(calId: string) {
|
||||
importTargetRef.current = calId
|
||||
importInputRef.current?.click()
|
||||
}
|
||||
async function handleImportFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
const calId = importTargetRef.current
|
||||
e.target.value = ''
|
||||
if (!file || !calId) return
|
||||
try {
|
||||
await calendarApi.import(calId, file)
|
||||
await loadEvents(calendars)
|
||||
} catch (err) {
|
||||
setError(err instanceof APIError ? err.message : 'Import fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
function stepPeriod(dir: 1 | -1) {
|
||||
setCursor(prev => {
|
||||
if (viewMode === 'month') return new Date(prev.getFullYear(), prev.getMonth() + dir, 1)
|
||||
if (viewMode === 'week') return addDays(prev, dir * 7)
|
||||
return addDays(prev, dir * 30)
|
||||
})
|
||||
}
|
||||
|
||||
const periodLabel = viewMode === 'month'
|
||||
? `${monthLabels[cursor.getMonth()]} ${cursor.getFullYear()}`
|
||||
: viewMode === 'week'
|
||||
? (() => {
|
||||
const s = startOfWeek(cursor)
|
||||
const e = addDays(s, 6)
|
||||
return `${s.getDate()}.${s.getMonth() + 1}. – ${e.getDate()}.${e.getMonth() + 1}.${e.getFullYear()}`
|
||||
})()
|
||||
: 'Anstehend'
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: 24, color: 'var(--color-text-muted)' }}>Lädt…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', minWidth: 0 }}>
|
||||
<PageHeader title="Kalender">
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{(['month', 'week', 'list'] as ViewMode[]).map(m => (
|
||||
<button key={m} onClick={() => setViewMode(m)} style={{
|
||||
padding: '6px 12px', borderRadius: 8, fontSize: 12.5, cursor: 'pointer',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: viewMode === m ? 'var(--color-primary)' : 'transparent',
|
||||
color: viewMode === m ? '#fff' : 'var(--color-text-muted)',
|
||||
}}>
|
||||
{m === 'month' ? 'Monat' : m === 'week' ? 'Woche' : 'Liste'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{viewMode !== 'list' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<button onClick={() => stepPeriod(-1)} style={navBtnStyle}>‹</button>
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text)', minWidth: 130, textAlign: 'center' }}>{periodLabel}</span>
|
||||
<button onClick={() => stepPeriod(1)} style={navBtnStyle}>›</button>
|
||||
<button onClick={() => setCursor(new Date())} style={{ ...navBtnStyle, width: 'auto', padding: '0 10px', fontSize: 12 }}>Heute</button>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => openNewEvent(new Date())} disabled={!defaultCalendarId} style={{
|
||||
padding: '7px 14px', borderRadius: 8, border: 'none',
|
||||
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||
opacity: defaultCalendarId ? 1 : 0.5,
|
||||
}}>
|
||||
+ Termin
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{error && (
|
||||
<div style={{ padding: '8px 16px', background: 'var(--color-danger)', color: '#fff', fontSize: 13 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||
<aside style={{ width: 220, flexShrink: 0, borderRight: '1px solid var(--color-border)', padding: 14, overflowY: 'auto' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase' }}>Kalender</span>
|
||||
<button onClick={() => setCalDialog({})} title="Neuer Kalender" style={{
|
||||
border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-primary)', fontSize: 16, lineHeight: 1,
|
||||
}}>+</button>
|
||||
</div>
|
||||
{calendars.map(cal => (
|
||||
<div key={cal.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0', fontSize: 13, color: 'var(--color-text)' }}>
|
||||
<input type="checkbox" checked={visible.has(cal.id)} onChange={() => toggleVisible(cal.id)} />
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: cal.color, flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{cal.name}</span>
|
||||
<CalMenu
|
||||
onRename={() => setCalDialog({ calendar: cal })}
|
||||
onExport={() => window.open(calendarApi.exportUrl(cal.id), '_blank')}
|
||||
onImport={() => triggerImport(cal.id)}
|
||||
onDelete={() => setDeleteCalendar(cal)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{calendars.length === 0 && (
|
||||
<p style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>Noch kein Kalender – leg einen an.</p>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'auto' }}>
|
||||
{viewMode === 'month' && (
|
||||
<MonthGrid cursor={cursor} byDay={byDay} onDayClick={openNewEvent} onEventClick={openEditEvent} />
|
||||
)}
|
||||
{viewMode === 'week' && (
|
||||
<WeekColumns cursor={cursor} byDay={byDay} onDayClick={openNewEvent} onEventClick={openEditEvent} />
|
||||
)}
|
||||
{viewMode === 'list' && (
|
||||
<ListAgenda events={allEvents} onEventClick={openEditEvent} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input ref={importInputRef} type="file" accept=".ics,text/calendar" style={{ display: 'none' }} onChange={handleImportFile} />
|
||||
|
||||
{eventDialog && (
|
||||
<EventDialog
|
||||
event={eventDialog.event}
|
||||
defaultDate={eventDialog.date}
|
||||
onSave={saveEvent}
|
||||
onDelete={eventDialog.event ? () => setDeleteEvent({ calId: eventDialog.calId, uid: eventDialog.event!.uid }) : undefined}
|
||||
onCancel={() => setEventDialog(null)}
|
||||
/>
|
||||
)}
|
||||
{calDialog && (
|
||||
<CalendarDialog calendar={calDialog.calendar} onSave={saveCalendar} onCancel={() => setCalDialog(null)} />
|
||||
)}
|
||||
{deleteCalendar && (
|
||||
<ConfirmDialog
|
||||
title="Kalender löschen"
|
||||
message={`"${deleteCalendar.name}" mit allen Terminen darin unwiderruflich löschen?`}
|
||||
confirmLabel="Löschen" danger
|
||||
onConfirm={confirmDeleteCalendar}
|
||||
onCancel={() => setDeleteCalendar(null)}
|
||||
/>
|
||||
)}
|
||||
{deleteEvent && (
|
||||
<ConfirmDialog
|
||||
title="Termin löschen"
|
||||
message="Diesen Termin unwiderruflich löschen?"
|
||||
confirmLabel="Löschen" danger
|
||||
onConfirm={confirmDeleteEvent}
|
||||
onCancel={() => setDeleteEvent(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const navBtnStyle: React.CSSProperties = {
|
||||
width: 26, height: 26, borderRadius: 6, border: '1px solid var(--color-border)',
|
||||
background: 'transparent', color: 'var(--color-text)', cursor: 'pointer', fontSize: 14,
|
||||
}
|
||||
|
||||
function CalMenu({ onRename, onExport, onImport, onDelete }: {
|
||||
onRename: () => void; onExport: () => void; onImport: () => void; onDelete: () => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
function onDoc(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
return () => document.removeEventListener('mousedown', onDoc)
|
||||
}, [])
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button onClick={() => setOpen(o => !o)} style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', padding: 2 }}>⋮</button>
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', right: 0, top: 22, zIndex: 30, minWidth: 150,
|
||||
background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)', overflow: 'hidden',
|
||||
}}>
|
||||
<MenuRow icon={<RenameIcon />} label="Bearbeiten" onClick={() => { setOpen(false); onRename() }} />
|
||||
<MenuRow icon={<DownloadIcon />} label="Exportieren (.ics)" onClick={() => { setOpen(false); onExport() }} />
|
||||
<MenuRow label="Importieren (.ics)" onClick={() => { setOpen(false); onImport() }} />
|
||||
<MenuRow icon={<TrashIcon />} label="Löschen" danger onClick={() => { setOpen(false); onDelete() }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function MenuRow({ icon, label, onClick, danger }: { icon?: React.ReactNode; label: string; onClick: () => void; danger?: boolean }) {
|
||||
return (
|
||||
<button onClick={onClick} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 12px',
|
||||
border: 'none', background: 'none', cursor: 'pointer', fontSize: 12.5, textAlign: 'left',
|
||||
color: danger ? 'var(--color-danger)' : 'var(--color-text)',
|
||||
}}>
|
||||
{icon}{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function EventChip({ ev, onClick }: { ev: EventWithCalendar; onClick: () => void }) {
|
||||
return (
|
||||
<div onClick={onClick} title={ev.summary} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, padding: '1px 4px', borderRadius: 4,
|
||||
background: ev.color + '22', color: ev.color, cursor: 'pointer', marginBottom: 2,
|
||||
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{!ev.all_day && <span style={{ opacity: 0.8 }}>{new Date(ev.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{ev.summary}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MonthGrid({ cursor, byDay, onDayClick, onEventClick }: {
|
||||
cursor: Date
|
||||
byDay: Map<string, EventWithCalendar[]>
|
||||
onDayClick: (d: Date) => void
|
||||
onEventClick: (ev: EventWithCalendar) => void
|
||||
}) {
|
||||
const first = startOfMonth(cursor)
|
||||
const gridStart = startOfWeek(first)
|
||||
const today = new Date()
|
||||
const weeks: Date[][] = []
|
||||
let d = gridStart
|
||||
for (let w = 0; w < 6; w++) {
|
||||
const row: Date[] = []
|
||||
for (let i = 0; i < 7; i++) { row.push(d); d = addDays(d, 1) }
|
||||
weeks.push(row)
|
||||
if (d > addDays(startOfMonth(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)), 0) && w >= 3) break
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--color-border)' }}>
|
||||
{weekdayLabels.map(l => (
|
||||
<div key={l} style={{ padding: '6px 8px', fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 600 }}>{l}</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateRows: `repeat(${weeks.length}, 1fr)`, flex: 1 }}>
|
||||
{weeks.map((row, i) => (
|
||||
<div key={i} style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--color-border)' }}>
|
||||
{row.map(day => {
|
||||
const inMonth = day.getMonth() === cursor.getMonth()
|
||||
const events = byDay.get(dayKey(day)) ?? []
|
||||
return (
|
||||
<div key={day.toISOString()} onClick={() => onDayClick(day)} style={{
|
||||
borderRight: '1px solid var(--color-border)', padding: 4, minHeight: 90, cursor: 'pointer',
|
||||
opacity: inMonth ? 1 : 0.4, display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 11.5, color: isSameDay(day, today) ? '#fff' : 'var(--color-text-muted)',
|
||||
background: isSameDay(day, today) ? 'var(--color-primary)' : 'transparent',
|
||||
borderRadius: 10, padding: '1px 6px', alignSelf: 'flex-start', marginBottom: 3,
|
||||
}}>
|
||||
{day.getDate()}
|
||||
</span>
|
||||
{events.slice(0, 4).map(ev => (
|
||||
<EventChip key={ev.uid} ev={ev} onClick={() => onEventClick(ev)} />
|
||||
))}
|
||||
{events.length > 4 && (
|
||||
<span style={{ fontSize: 10.5, color: 'var(--color-text-muted)' }}>+{events.length - 4} mehr</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WeekColumns({ cursor, byDay, onDayClick, onEventClick }: {
|
||||
cursor: Date
|
||||
byDay: Map<string, EventWithCalendar[]>
|
||||
onDayClick: (d: Date) => void
|
||||
onEventClick: (ev: EventWithCalendar) => void
|
||||
}) {
|
||||
const start = startOfWeek(cursor)
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(start, i))
|
||||
const today = new Date()
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', height: '100%' }}>
|
||||
{days.map(day => {
|
||||
const events = byDay.get(dayKey(day)) ?? []
|
||||
return (
|
||||
<div key={day.toISOString()} onClick={() => onDayClick(day)} style={{
|
||||
borderRight: '1px solid var(--color-border)', padding: 8, cursor: 'pointer', minHeight: '100%',
|
||||
}}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{weekdayLabels[(day.getDay() + 6) % 7]}</div>
|
||||
<span style={{
|
||||
fontSize: 14, fontWeight: 600,
|
||||
color: isSameDay(day, today) ? '#fff' : 'var(--color-text)',
|
||||
background: isSameDay(day, today) ? 'var(--color-primary)' : 'transparent',
|
||||
borderRadius: 12, padding: '1px 8px',
|
||||
}}>
|
||||
{day.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
{events.map(ev => <EventChip key={ev.uid} ev={ev} onClick={() => onEventClick(ev)} />)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ListAgenda({ events, onEventClick }: { events: EventWithCalendar[]; onEventClick: (ev: EventWithCalendar) => void }) {
|
||||
const sorted = useMemo(() => [...events].sort((a, b) => a.start.localeCompare(b.start)), [events])
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, EventWithCalendar[]>()
|
||||
for (const ev of sorted) {
|
||||
const d = new Date(ev.start)
|
||||
const key = dayKey(d)
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(ev)
|
||||
}
|
||||
return [...map.entries()]
|
||||
}, [sorted])
|
||||
|
||||
if (groups.length === 0) {
|
||||
return <p style={{ padding: 24, color: 'var(--color-text-muted)', fontSize: 13 }}>Keine Termine.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px 20px' }}>
|
||||
{groups.map(([key, evs]) => {
|
||||
const d = new Date(evs[0].start)
|
||||
return (
|
||||
<div key={key} style={{ marginBottom: 18 }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||
{weekdayLabels[(d.getDay() + 6) % 7]}, {d.getDate()}. {monthLabels[d.getMonth()]} {d.getFullYear()}
|
||||
</div>
|
||||
{evs.map(ev => (
|
||||
<div key={ev.uid} onClick={() => onEventClick(ev)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '7px 10px', borderRadius: 8,
|
||||
cursor: 'pointer', border: '1px solid var(--color-border)', marginBottom: 6,
|
||||
}}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: ev.color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)', width: 50, flexShrink: 0 }}>
|
||||
{ev.all_day ? 'Ganztägig' : new Date(ev.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<span style={{ fontSize: 13.5, color: 'var(--color-text)' }}>{ev.summary}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user