Die Kalenderseite war durchgehend deutsch, die übrige Oberfläche englisch – 80 Stellen in sechs Dateien. Kommentare bleiben deutsch; das Ersetzen hat Kommentarzeilen übersprungen. Shell, TagChip und UserMenu standen nur wegen ihrer Kommentare auf der Verdachtsliste, deren Oberfläche war längst englisch. Der automatisch angelegte Kalender hieß „Persönlich" und heißt jetzt „Personal". Migration 000027 zieht die bestehenden nach, bewusst eng gefasst: nur Zeilen mit Name *und* Farbe des Automaten. Wer sich selbst einen Kalender „Persönlich" angelegt hat, behält seinen Namen – das ist seine Beschriftung, nicht unsere. Nachgeprüft: Die API meldet danach 'Personal', nicht nur „Migration gelaufen". Dazu zwei Punkte aus Marcs Rückmeldung: fix(ui): Der Leerzustand der Freigabe-Links stand auf anderer Höhe als die übrigen – die Seite hatte ich beim Umbau übersehen. Nutzt jetzt EmptyState wie alle anderen. fix(ui): Beim Durchklicken von Ordner zu Ordner blitzte für einen Moment ein Feld grauer Kästen auf. Sie sahen aus wie Dateien, die gleich wieder verschwinden – „Geister". Der Platzhalter kommt jetzt erst nach 220 ms; ist die Liste vorher da, wird er nie sichtbar, und solange bleibt die vorherige stehen (load() löscht sie nicht). Nur beim allerersten Laden kommt er sofort: Da gibt es nichts, was stehenbleiben könnte. Nebenbei: Ein fehlgeschlagenes Laden eines Ordners ist nicht mehr stumm. Vorher sah es aus wie ein Ordner, der zufällig denselben Inhalt hat wie der vorige. Geprüft: go build/vet sauber, tsc sauber, 41 Vitest-Tests, alle 234 Prüfungen der sieben Selbsttest-Suiten grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
194 lines
7.5 KiB
TypeScript
194 lines
7.5 KiB
TypeScript
import { useEffect, useRef, useState, 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', 'Calendar color')}
|
||
{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>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Kalenderwahl als Ausklappliste – mit dem Farbpunkt des Kalenders.
|
||
*
|
||
* Bewusst kein natives `<select>`: Dessen Einträge lassen sich in keinem
|
||
* Browser verlässlich einfärben, und ausgerechnet die Farbe ist das, woran
|
||
* man einen Kalender im Raster wiedererkennt. Wer „Arbeit" wählt, soll dabei
|
||
* schon das Blau sehen, in dem der Termin gleich erscheint.
|
||
*
|
||
* Der Punkt spricht dieselbe Sprache wie überall sonst im Kalender: gefüllt
|
||
* heißt gewählt, ein leerer Ring heißt nicht gewählt.
|
||
*/
|
||
export function CalendarSelect({ calendars, value, onChange, disabled }: {
|
||
calendars: { id: string; name: string; color: string }[]
|
||
value: string
|
||
onChange: (id: string) => void
|
||
disabled?: boolean
|
||
}) {
|
||
const [open, setOpen] = useState(false)
|
||
const ref = useRef<HTMLDivElement>(null)
|
||
const current = calendars.find(c => c.id === value)
|
||
|
||
useEffect(() => {
|
||
if (!open) return
|
||
function onDoc(e: globalThis.MouseEvent) {
|
||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||
}
|
||
document.addEventListener('mousedown', onDoc)
|
||
return () => document.removeEventListener('mousedown', onDoc)
|
||
}, [open])
|
||
|
||
const field: CSSProperties = {
|
||
width: '100%', boxSizing: 'border-box', display: 'flex', alignItems: 'center', gap: 9,
|
||
background: 'var(--color-surface-2)', border: '1px solid var(--color-border)',
|
||
borderRadius: 8, padding: '8px 10px', fontSize: 13.5, color: 'var(--color-text)',
|
||
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.6 : 1, textAlign: 'left',
|
||
}
|
||
|
||
const dot = (color: string, filled: boolean) => (
|
||
<span style={{
|
||
width: 12, height: 12, borderRadius: '50%', flexShrink: 0,
|
||
border: `2px solid ${color}`, background: filled ? color : 'transparent',
|
||
}} />
|
||
)
|
||
|
||
return (
|
||
<div
|
||
ref={ref}
|
||
style={{ position: 'relative' }}
|
||
// Escape schließt nur die Liste. Ohne das Anhalten liefe es weiter bis
|
||
// zum Fenster und machte gleich den ganzen Termindialog zu – mitsamt
|
||
// allem, was schon eingetippt war.
|
||
onKeyDown={e => { if (open && e.key === 'Escape') { e.stopPropagation(); setOpen(false) } }}
|
||
>
|
||
<button type="button" disabled={disabled} onClick={() => setOpen(o => !o)}
|
||
aria-haspopup="listbox" aria-expanded={open} style={field}>
|
||
{current && dot(current.color, true)}
|
||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{current?.name ?? 'Choose calendar'}
|
||
</span>
|
||
<span style={{ color: 'var(--color-text-muted)', fontSize: 10, flexShrink: 0 }}>▼</span>
|
||
</button>
|
||
|
||
{open && !disabled && (
|
||
<div role="listbox" style={{
|
||
position: 'absolute', left: 0, right: 0, top: 'calc(100% + 4px)', zIndex: 40,
|
||
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
|
||
borderRadius: 8, boxShadow: '0 6px 18px rgba(0,0,0,0.28)',
|
||
maxHeight: 220, overflowY: 'auto',
|
||
}}>
|
||
{calendars.map(cal => {
|
||
const on = cal.id === value
|
||
return (
|
||
<button
|
||
key={cal.id} type="button" role="option" aria-selected={on}
|
||
onClick={() => { onChange(cal.id); setOpen(false) }}
|
||
className="aether-cal-row"
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: 9, width: '100%',
|
||
padding: '9px 10px', border: 'none', textAlign: 'left', cursor: 'pointer',
|
||
fontSize: 13.5, color: 'var(--color-text)',
|
||
// Nur ein Hauch der Kalenderfarbe für den gewählten Eintrag:
|
||
// voll ausgefüllt schriee die Liste den Nebendarsteller aus.
|
||
background: on ? cal.color + '22' : 'transparent',
|
||
}}
|
||
>
|
||
{dot(cal.color, on)}
|
||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{cal.name}
|
||
</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|