CI / pruefen (push) Canceled after 0s
Zwei Wege, die zusammengehoeren: etwas festhalten, ohne den Rechner
aufzuklappen, und danach einen Ort haben, an dem alles zusammenlaeuft.
**Schnellerfassung.** Ein Blitzsymbol in der Kopfleiste, auf jeder Seite
erreichbar. Ein Feld, Enter, fertig - als Idee entsteht ein Projekt im
Status IDEA, als Aufgabe eine Aufgabe im gewaehlten Projekt. Beide Schemas
kommen mit einem einzigen Feld aus, an der API war deshalb nichts zu
aendern.
Der Dialog bleibt nach dem Anlegen offen und leert nur das Feld. Wer eine
Idee notiert, hat meistens gleich die naechste; ihn jedes Mal neu zu oeffnen
waere genau die Huerde, die hier wegsoll. Das zuletzt gewaehlte Projekt
wird gemerkt.
Der Einstieg sitzt in der Kopfleiste und nicht als schwebender Knopf unten
rechts - dort waere er auf schmalen Anzeigen mit der Auswahlleiste
kollidiert, die beim Markieren von Projekten einfaehrt.
**Aufgabenuebersicht.** /tasks sammelt jede Aufgabe aus jedem Projekt.
Geordnet wird nach Faelligkeit, nicht nach Spalte: projektuebergreifend
lautet die Frage "was ist ueberfaellig", nicht "was steht wo im Brett".
Abhaken geht direkt aus der Liste; den Rest - Zeitpunkt, abgeleiteter
Fortschritt, naechste Fassung wiederkehrender Aufgaben - erledigt der
bestehende Endpunkt.
Zwei Entscheidungen, die im Code begruendet sind:
- Gerechnet wird auf Kalendertage in fester Zeitzone, nicht auf Stunden und
nicht nach Serverzeit. Eine um 9 Uhr faellige Aufgabe ist um 17 Uhr nicht
ueberfaellig, sondern immer noch "heute". Und im Container laeuft die
Anwendung nach UTC - ohne feste Zeitzone kippte die Einteilung abends um
einen Tag. Das Fach bestimmt der Server, sonst liefen Server-Render und
Hydration auseinander.
- Bewusst nicht per Ziehen sortierbar. Die Position gilt innerhalb ihrer
Spalte in ihrem Projekt und waere hier ohne Bedeutung; eine Reihenfolge
anzubieten, die nirgends ankommt, waere ein leeres Versprechen.
Die Aufgabe kennt keinen Besitzer - die Trennung laeuft ueber das Projekt,
"project: { ownerId }" steht als einzige und erste Bedingung.
Nebenbei: der Untertitel in der Kopfleiste brach mit dem dritten
Navigationspunkt auf 375px mitten im Wort ab und bleibt unterhalb von sm
jetzt weg.
Geprueft: 200 Unit-Tests (davon 7 neue zu dueBucket, inklusive des
UTC-Falls), 27 E2E-Tests mit zwei neuen Wegen, axe-core auf 375px ueber
Uebersicht und beide Zustaende der Schnellerfassung ohne Beanstandung.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
214 lines
7.6 KiB
TypeScript
214 lines
7.6 KiB
TypeScript
"use client";
|
||
|
||
import { useRef, useState } from "react";
|
||
import Link from "next/link";
|
||
import { usePathname, useRouter } from "next/navigation";
|
||
import {
|
||
CheckSquare,
|
||
GitBranch,
|
||
LayoutDashboard,
|
||
LogOut,
|
||
Settings,
|
||
ShieldCheck,
|
||
Sparkles,
|
||
UserRound,
|
||
Zap,
|
||
} from "lucide-react";
|
||
import { QuickCapture } from "@/components/QuickCapture";
|
||
import { Popover } from "@/components/ui/Popover";
|
||
import type { SessionUser } from "@/lib/auth/session";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
const LINKS = [
|
||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||
{ href: "/tasks", label: "Aufgaben", icon: CheckSquare },
|
||
{ href: "/connections", label: "Git-Verbindungen", icon: GitBranch },
|
||
];
|
||
|
||
export function TopNav({
|
||
user,
|
||
appName,
|
||
tagline,
|
||
}: {
|
||
user: SessionUser | null;
|
||
appName: string;
|
||
tagline: string;
|
||
}) {
|
||
const pathname = usePathname();
|
||
|
||
return (
|
||
<header className="sticky top-0 z-40 border-b border-white/5 bg-[#05060f]/70 backdrop-blur-xl">
|
||
<div className="mx-auto flex w-full max-w-[1400px] items-center gap-3 px-4 py-3 sm:gap-6 sm:px-6 lg:px-8">
|
||
<Link href="/" className="group flex min-w-0 items-center gap-2 sm:gap-3">
|
||
<span className="relative grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-violet-500 via-fuchsia-500 to-cyan-400 shadow-[0_8px_28px_-10px_rgba(168,85,247,0.9)] transition-transform group-hover:scale-105">
|
||
<Sparkles className="h-4 w-4 text-white" strokeWidth={2.4} />
|
||
</span>
|
||
<span className="flex min-w-0 flex-col leading-none">
|
||
<span className="text-gradient truncate text-lg font-semibold tracking-tight">
|
||
{appName}
|
||
</span>
|
||
{/* Der weite Zeichenabstand kostet mehr Platz, als die Zeile wert
|
||
ist. Seit die Aufgaben als dritter Punkt danebenstehen, brach
|
||
sie auf 375px mitten im Wort ab – unterhalb von sm bleibt
|
||
deshalb nur der Name. */}
|
||
<span className="mt-0.5 hidden truncate text-[10px] uppercase tracking-[0.2em] text-slate-500 sm:block">
|
||
{tagline}
|
||
</span>
|
||
</span>
|
||
</Link>
|
||
|
||
{user ? (
|
||
<>
|
||
<nav className="flex shrink-0 items-center gap-1">
|
||
{LINKS.map(({ href, label, icon: Icon }) => {
|
||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||
return (
|
||
<Link
|
||
key={href}
|
||
href={href}
|
||
// Unterhalb von sm bleibt nur das Symbol stehen. Ohne
|
||
// eigene Beschriftung ist der Verweis dann fuer einen
|
||
// Screenreader namenlos – axe-core meldet das als
|
||
// "link-name", und zwar zu Recht: vorgelesen wird nichts.
|
||
aria-label={label}
|
||
title={label}
|
||
aria-current={active ? "page" : undefined}
|
||
className={cn(
|
||
"flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors",
|
||
active
|
||
? "bg-white/8 text-white"
|
||
: "text-slate-400 hover:bg-white/5 hover:text-slate-100",
|
||
)}
|
||
>
|
||
<Icon className="h-4 w-4" />
|
||
<span className="hidden sm:inline">{label}</span>
|
||
</Link>
|
||
);
|
||
})}
|
||
</nav>
|
||
|
||
<div className="ml-auto flex items-center gap-2">
|
||
<QuickCaptureButton />
|
||
<UserMenu user={user} />
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Der Einstieg in die Schnellerfassung sitzt in der Kopfleiste, nicht als
|
||
* schwebender Knopf unten rechts: dort waere er auf schmalen Anzeigen mit
|
||
* der Auswahlleiste kollidiert, die beim Markieren von Projekten einfaehrt.
|
||
* In der Kopfleiste ist er auf jeder Seite erreichbar.
|
||
*/
|
||
function QuickCaptureButton() {
|
||
const [open, setOpen] = useState(false);
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen(true)}
|
||
className="btn btn-ghost px-2.5 py-1.5 sm:px-3"
|
||
// Unterhalb von sm bleibt nur das Symbol – ohne Beschriftung waere
|
||
// der Knopf fuer einen Screenreader namenlos.
|
||
aria-label="Schnell erfassen"
|
||
title="Schnell erfassen – Idee oder Aufgabe"
|
||
>
|
||
<Zap className="h-4 w-4" />
|
||
<span className="hidden lg:inline">Erfassen</span>
|
||
</button>
|
||
|
||
<QuickCapture open={open} onClose={() => setOpen(false)} />
|
||
</>
|
||
);
|
||
}
|
||
|
||
function UserMenu({ user }: { user: SessionUser }) {
|
||
const router = useRouter();
|
||
const [open, setOpen] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||
|
||
async function logout() {
|
||
setBusy(true);
|
||
await fetch("/api/auth/logout", { method: "POST" }).catch(() => undefined);
|
||
setOpen(false);
|
||
router.replace("/login");
|
||
router.refresh();
|
||
}
|
||
|
||
const label = user.displayName?.trim() || user.username;
|
||
const initials = label.slice(0, 2).toUpperCase();
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
ref={triggerRef}
|
||
type="button"
|
||
onClick={() => setOpen((value) => !value)}
|
||
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/4 py-1.5 pl-1.5 pr-3 text-sm text-slate-200 transition-colors hover:bg-white/8"
|
||
aria-haspopup="menu"
|
||
aria-expanded={open}
|
||
// Auf schmalen Anzeigen bleibt nur das Kuerzel sichtbar – ohne
|
||
// Beschriftung liest ein Screenreader dann nur zwei Buchstaben vor.
|
||
aria-label={`Benutzermenue fuer ${label}`}
|
||
>
|
||
<span className="grid h-7 w-7 place-items-center rounded-lg bg-gradient-to-br from-violet-500/80 to-cyan-400/70 text-[11px] font-semibold text-white">
|
||
{initials}
|
||
</span>
|
||
<span className="hidden max-w-[10rem] truncate sm:inline">{label}</span>
|
||
{user.role === "ADMIN" ? (
|
||
<ShieldCheck className="hidden h-3.5 w-3.5 text-violet-300 sm:inline" />
|
||
) : null}
|
||
</button>
|
||
|
||
<Popover anchorRef={triggerRef} open={open} onClose={() => setOpen(false)} width={240}>
|
||
<div className="border-b border-white/6 px-3 py-2">
|
||
<p className="truncate text-sm text-slate-100">{label}</p>
|
||
<p className="truncate text-[11px] text-slate-500">
|
||
@{user.username}
|
||
{user.role === "ADMIN" ? " · Administrator" : ""}
|
||
</p>
|
||
</div>
|
||
|
||
<Link
|
||
href="/account"
|
||
role="menuitem"
|
||
onClick={() => setOpen(false)}
|
||
className="mt-1 flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||
>
|
||
<UserRound className="h-4 w-4" />
|
||
Mein Konto
|
||
</Link>
|
||
|
||
{user.role === "ADMIN" ? (
|
||
<Link
|
||
href="/admin"
|
||
role="menuitem"
|
||
onClick={() => setOpen(false)}
|
||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||
>
|
||
<Settings className="h-4 w-4" />
|
||
Admin-Einstellungen
|
||
</Link>
|
||
) : null}
|
||
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={logout}
|
||
disabled={busy}
|
||
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm text-rose-200 transition-colors hover:bg-rose-500/10 disabled:opacity-50"
|
||
>
|
||
<LogOut className="h-4 w-4" />
|
||
Abmelden
|
||
</button>
|
||
</Popover>
|
||
</>
|
||
);
|
||
}
|