feat(board): Listenansicht, und die mobile Ansicht laeuft nicht mehr aus dem Bild
CI / pruefen (push) Canceled after 0s
CI / pruefen (push) Canceled after 0s
Auf 375px war das Dashboard 40px breiter als der Bildschirm. Ursache war die Steuerleiste: Suchfeld, Sortierung, Ansichtsumschalter und "Neues Projekt" lagen in einer Flex-Zeile ohne Umbruch, und weil das Sortierfeld ein min-width mitbringt, wurde der Knopf am Ende schlicht hinausgeschoben. Das overflow-x: hidden am body hat ihn dann abgeschnitten statt erreichbar zu lassen. Dasselbe Muster im Projektkopf, wo "Loeschen" ausserhalb lag. Die Zeilen brechen jetzt um, der Ansichtsumschalter steht auf schmalen Anzeigen ueber die volle Breite. Gemessen ueber alle Seiten und beide Testbreiten: scrollWidth == clientWidth. Weil der Umschalter ohnehin umgebaut wurde, kommt die vierte Ansicht gleich mit: eine Zeile je Projekt statt einer Karte. Auf dem Telefon ist das der eigentliche Gewinn — eine Karte braucht rund 260px Hoehe und steht dort zwangslaeufig allein in der Zeile, fuenf Projekte fuellen also zwei Bildschirme. Als Liste passen sie auf einen, und weil alle Zeilen gleich gebaut sind, liegen Name, Fortschritt und Status untereinander. Was nicht mehr in die Breite passt, faellt der Reihe nach weg, statt sie zu sprengen. Dazu die Kleinigkeiten, die auf dem Telefon dranhaengen: - .field bekommt auf Beruehrungsgeraeten 16px. Darunter zoomt iOS Safari beim Hineintippen die Seite heran und kehrt nicht von selbst zurueck. - .btn mindestens 44px hoch, der Schieberegler einen 24px-Griff. - Der Popover klemmt seine Breite ans Fenster. Unter 280px lieferte die Rechnung eine negative linke Kante, das Menue stand halb ausserhalb. - Das Tag-Formular der Auswahlleiste war 16px breiter als sein Popover und wurde von dessen overflow-hidden beschnitten – auf jeder Breite. - Die Heatmap scrollt wieder seitlich, statt sich zu stauchen. - Die Kennzahlen stehen zweispaltig; einspaltig fuellten die fuenf Kacheln den ganzen ersten Bildschirm, bevor das erste Projekt kam. - viewportFit und die safe-area-Abstaende, damit im installierten Betrieb nichts unter Statusleiste und Home-Anzeige verschwindet. - Die beiden Verweise in der Kopfleiste und "Neues Projekt" tragen ein aria-label. Unterhalb von sm sind sie reine Symbole und hiessen fuer einen Screenreader gar nichts; axe-core meldete das als link-name. axe-core laeuft auf 375px ueber alle vier Ansichten, den Dialog und die Projektseite jetzt ohne Beanstandung. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+19
-1
@@ -115,7 +115,8 @@ Der Status ist **direkt auf der Projektkarte** umschaltbar, ohne den Detailberei
|
|||||||
- **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar.
|
- **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar.
|
||||||
- **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität —
|
- **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität —
|
||||||
Favoriten bleiben stets oben.
|
Favoriten bleiben stets oben.
|
||||||
- **Drei Ansichten**: freies Raster, nach Status gruppierte Abschnitte oder **Kanban**.
|
- **Vier Ansichten**: freies Raster, **kompakte Liste**, nach Status gruppierte Abschnitte
|
||||||
|
oder **Kanban**.
|
||||||
- **Volltextsuche über Notizen.** Die Suche im Board arbeitet auf den geladenen Projekten
|
- **Volltextsuche über Notizen.** Die Suche im Board arbeitet auf den geladenen Projekten
|
||||||
(Name, Kurzbeschreibung, Tags, Repo-Pfad). Notizen liegen dort nicht vor — sie
|
(Name, Kurzbeschreibung, Tags, Repo-Pfad). Notizen liegen dort nicht vor — sie
|
||||||
mitzuladen würde die Seite aufblähen. Stattdessen sucht Postgres serverseitig mit
|
mitzuladen würde die Seite aufblähen. Stattdessen sucht Postgres serverseitig mit
|
||||||
@@ -165,6 +166,23 @@ Karte dauerhaft eines steht. Sobald etwas ausgewählt ist, fährt unten eine Lei
|
|||||||
Was der Filter ausblendet, fällt aus der Auswahl. Sonst träfe eine Massenänderung Projekte,
|
Was der Filter ausblendet, fällt aus der Auswahl. Sonst träfe eine Massenänderung Projekte,
|
||||||
die man gar nicht mehr vor Augen hat. Fremde IDs fallen serverseitig still unter den Tisch.
|
die man gar nicht mehr vor Augen hat. Fremde IDs fallen serverseitig still unter den Tisch.
|
||||||
|
|
||||||
|
### Liste
|
||||||
|
|
||||||
|
Eine Zeile je Projekt, alle gleich aufgebaut: Statusfarbe als Streifen, Name,
|
||||||
|
Fortschrittsbalken mit Prozentwert, danach Aufgaben, Notizen, Branch und Alter des
|
||||||
|
letzten Commits. Der Statuswechsler sitzt rechts, die ganze Zeile ist ein Link.
|
||||||
|
|
||||||
|
Gedacht ist die Ansicht für zwei Fälle:
|
||||||
|
|
||||||
|
- **Auf dem Telefon.** Eine Karte braucht rund 260 px Höhe und steht dort zwangsläufig
|
||||||
|
allein in der Zeile — fünf Projekte füllen zwei Bildschirme, bevor man überhaupt
|
||||||
|
etwas vergleichen kann. Eine Zeile kommt mit einem Viertel davon aus.
|
||||||
|
- **Beim Vergleichen.** Weil alle Zeilen gleich gebaut sind, liegen Name, Fortschritt
|
||||||
|
und Status untereinander. Genau das verliert das Raster, sobald es einspaltig wird.
|
||||||
|
|
||||||
|
Was nicht mehr in die Zeile passt, fällt der Reihe nach weg, statt sie zu sprengen:
|
||||||
|
Notizen ab `sm`, Branch ab `md`, der letzte Commit und die Priorität ab `lg`.
|
||||||
|
|
||||||
### Kanban
|
### Kanban
|
||||||
|
|
||||||
Spalten je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und
|
Spalten je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und
|
||||||
|
|||||||
@@ -106,6 +106,31 @@ test("Volltextsuche findet Notizen", async ({ page }) => {
|
|||||||
await expect(page.getByText("Treffer in Notizen")).toBeVisible();
|
await expect(page.getByText("Treffer in Notizen")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Listenansicht auf Telefonbreite ohne seitliches Scrollen", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 667 });
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Liste" }).click();
|
||||||
|
await expect(page.getByRole("link", { name: /Projekt Oberflaechentest/ })).toBeVisible();
|
||||||
|
|
||||||
|
// Der eigentliche Punkt der Ansicht: sie passt in die Breite. Frueher schob
|
||||||
|
// die Steuerleiste den Knopf "Neues Projekt" aus dem Bild und die ganze
|
||||||
|
// Seite liess sich seitwaerts schieben.
|
||||||
|
const masse = await page.evaluate(() => ({
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
clientWidth: document.documentElement.clientWidth,
|
||||||
|
}));
|
||||||
|
expect(masse.scrollWidth).toBeLessThanOrEqual(masse.clientWidth + 1);
|
||||||
|
|
||||||
|
// Auch der Knopf muss erreichbar bleiben – auf dieser Breite traegt er nur
|
||||||
|
// noch das Pluszeichen und lebt von seinem aria-label. Geprueft wird die
|
||||||
|
// rechte Kante: genau die lag vorher ausserhalb des Bildschirms.
|
||||||
|
const knopf = page.getByRole("button", { name: "Neues Projekt" });
|
||||||
|
await expect(knopf).toBeVisible();
|
||||||
|
const kasten = await knopf.boundingBox();
|
||||||
|
expect(kasten).not.toBeNull();
|
||||||
|
expect(kasten!.x + kasten!.width).toBeLessThanOrEqual(masse.clientWidth);
|
||||||
|
});
|
||||||
|
|
||||||
test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
|
test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
|
||||||
await page.keyboard.press("Control+k");
|
await page.keyboard.press("Control+k");
|
||||||
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
|
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
|
||||||
|
|||||||
@@ -277,6 +277,21 @@
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auf Zeigergeraeten reichen die 36px, die sich aus Schriftgroesse und
|
||||||
|
* Innenabstand ergeben. Ein Finger trifft das nicht zuverlaessig – dort
|
||||||
|
* gilt die uebliche Untergrenze von 44px.
|
||||||
|
*
|
||||||
|
* Ueber "pointer: coarse" statt einer Breitenabfrage: entscheidend ist das
|
||||||
|
* Eingabegeraet, nicht die Fenstergroesse. Ein Tablet im Querformat ist
|
||||||
|
* breit und wird trotzdem mit dem Finger bedient.
|
||||||
|
*/
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.btn {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -335,6 +350,19 @@
|
|||||||
box-shadow 0.2s ease;
|
box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unter 16px zoomt iOS Safari beim Hineintippen die ganze Seite heran und
|
||||||
|
* kehrt danach nicht von selbst zurueck – aus einem Formular wird so eine
|
||||||
|
* Schiebepartie. Deshalb auf Beruehrungsgeraeten volle 16px, unabhaengig
|
||||||
|
* von der Fensterbreite.
|
||||||
|
*/
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.field {
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.field::placeholder {
|
.field::placeholder {
|
||||||
color: rgba(148, 163, 184, 0.6);
|
color: rgba(148, 163, 184, 0.6);
|
||||||
}
|
}
|
||||||
@@ -416,4 +444,22 @@
|
|||||||
.range::-moz-range-track {
|
.range::-moz-range-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ein 16px-Griff ist mit dem Finger kaum zu fassen. Die Spur bleibt
|
||||||
|
schmal, damit der Regler nicht ploetzlich klobig wirkt. */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.range {
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range::-webkit-slider-thumb {
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range::-moz-range-thumb {
|
||||||
|
height: 22px;
|
||||||
|
width: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,13 @@
|
|||||||
Consolas, "DejaVu Sans Mono", monospace;
|
Consolas, "DejaVu Sans Mono", monospace;
|
||||||
|
|
||||||
--radius-card: 1.25rem;
|
--radius-card: 1.25rem;
|
||||||
|
|
||||||
|
/* Eine Stufe unterhalb von Tailwinds "sm" (40rem). Zwischen 320px und
|
||||||
|
640px liegt der gesamte Telefonbereich, und dort ist der Unterschied
|
||||||
|
zwischen "Beschriftung passt noch" und "passt nicht mehr" oft nur ein
|
||||||
|
Zentimeter breit. 22rem trennt die wirklich schmalen Geraete (iPhone SE
|
||||||
|
der ersten Reihe, 320px) vom Rest, der bei 360px und mehr anfaengt. */
|
||||||
|
--breakpoint-xs: 22rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -63,7 +70,14 @@
|
|||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
|
/* Ein Notnagel gegen seitliches Scrollen, kein Ersatz fuer richtiges
|
||||||
|
Umbrechen: was hier abgeschnitten wird, ist auf dem Telefon einfach
|
||||||
|
nicht mehr erreichbar. Die Ursachen gehoeren in die Komponenten. */
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
/* Mit viewportFit: "cover" reicht die Seite bis in die Ecken; ohne diese
|
||||||
|
Einrueckung stuende der Inhalt im Querformat unter der Kameraleiste. */
|
||||||
|
padding-left: env(safe-area-inset-left);
|
||||||
|
padding-right: env(safe-area-inset-right);
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ export const viewport: Viewport = {
|
|||||||
themeColor: "#05060f",
|
themeColor: "#05060f",
|
||||||
width: "device-width",
|
width: "device-width",
|
||||||
initialScale: 1,
|
initialScale: 1,
|
||||||
|
// Die Seite reicht bis unter Statusleiste und Home-Anzeige; das ist noetig,
|
||||||
|
// damit der Hintergrund im installierten Betrieb nicht an einem weissen
|
||||||
|
// Balken endet. Was dadurch verdeckt wuerde, holen die env(safe-area-*)
|
||||||
|
// in globals.css wieder heraus.
|
||||||
|
viewportFit: "cover",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
|
|||||||
+5
-1
@@ -52,13 +52,17 @@ export default async function DashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
{/* Zwei Spalten schon auf dem Telefon: einspaltig fuellten die fuenf
|
||||||
|
Kacheln dort den ganzen ersten Bildschirm, und die Projekte – der
|
||||||
|
eigentliche Inhalt – begannen erst nach dem zweiten Wischen. */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-5">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Projekte"
|
label="Projekte"
|
||||||
value={active.length}
|
value={active.length}
|
||||||
hint={`Ø Fortschritt ${averageProgress}%`}
|
hint={`Ø Fortschritt ${averageProgress}%`}
|
||||||
icon={CircleDot}
|
icon={CircleDot}
|
||||||
color="#a78bfa"
|
color="#a78bfa"
|
||||||
|
className="col-span-2 lg:col-span-1"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Ideen"
|
label="Ideen"
|
||||||
|
|||||||
@@ -66,8 +66,13 @@ export function BulkBar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-[90] flex justify-center px-4">
|
// Der Abstand nach unten beruecksichtigt die Home-Anzeige des iPhones;
|
||||||
<div className="popover pointer-events-auto flex flex-wrap items-center gap-2 px-3 py-2 shadow-2xl">
|
// ohne das liegt die Leiste im installierten Betrieb darunter.
|
||||||
|
<div
|
||||||
|
className="pointer-events-none fixed inset-x-0 bottom-4 z-[90] flex justify-center px-3"
|
||||||
|
style={{ bottom: "calc(1rem + env(safe-area-inset-bottom))" }}
|
||||||
|
>
|
||||||
|
<div className="popover pointer-events-auto flex max-w-full flex-wrap items-center justify-center gap-2 px-3 py-2 shadow-2xl">
|
||||||
<span className="px-1 text-sm tabular-nums text-slate-200">
|
<span className="px-1 text-sm tabular-nums text-slate-200">
|
||||||
{ids.length} ausgewählt
|
{ids.length} ausgewählt
|
||||||
</span>
|
</span>
|
||||||
@@ -122,8 +127,17 @@ export function BulkBar({
|
|||||||
<Tag className="h-3.5 w-3.5" />
|
<Tag className="h-3.5 w-3.5" />
|
||||||
Tags
|
Tags
|
||||||
</button>
|
</button>
|
||||||
<Popover open={tagsOpen} anchorRef={tagsRef} onClose={() => setTagsOpen(false)} align="start">
|
{/* Die Breite gehoert an den Popover, nicht an sein Kind: der Popover
|
||||||
<div className="w-64 space-y-2 p-3">
|
setzt overflow-hidden, ein breiteres Kind wird schlicht
|
||||||
|
abgeschnitten – frueher um die Haelfte des Abbrechen-Knopfs. */}
|
||||||
|
<Popover
|
||||||
|
open={tagsOpen}
|
||||||
|
anchorRef={tagsRef}
|
||||||
|
onClose={() => setTagsOpen(false)}
|
||||||
|
align="start"
|
||||||
|
width={288}
|
||||||
|
>
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
<label className="field-label" htmlFor="bulk-tags">
|
<label className="field-label" htmlFor="bulk-tags">
|
||||||
Tags (kommagetrennt)
|
Tags (kommagetrennt)
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -114,12 +114,18 @@ export function Heatmap({ calendar }: { calendar: CommitCalendar }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="-mx-1 overflow-x-auto px-1">
|
||||||
|
{/*
|
||||||
|
Kein max-w-full: zusammen mit der festen Hoehe staucht das die
|
||||||
|
Wochen auf schmalen Anzeigen zu einem unlesbaren Streifen, statt
|
||||||
|
den Elter seitlich scrollen zu lassen. Die Kalenderfelder haben
|
||||||
|
eine feste Groesse – die Karte gehoert geschoben, nicht gequetscht.
|
||||||
|
*/}
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${breite + 22} ${hoehe}`}
|
viewBox={`0 0 ${breite + 22} ${hoehe}`}
|
||||||
width={breite + 22}
|
width={breite + 22}
|
||||||
height={hoehe}
|
height={hoehe}
|
||||||
className="max-w-full"
|
className="block shrink-0"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`Commits je Tag seit ${formatDate(calendar.from)}: ${gesamt} an ${tageMitCommits} Tagen`}
|
aria-label={`Commits je Tag seit ${formatDate(calendar.from)}: ${gesamt} an ${tageMitCommits} Tagen`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -253,7 +253,11 @@ function Column({
|
|||||||
// Eindeutiger Anker fuer Tests und Automatisierung.
|
// Eindeutiger Anker fuer Tests und Automatisierung.
|
||||||
data-column={status}
|
data-column={status}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-[17rem] shrink-0 flex-col rounded-2xl border border-white/6 bg-white/2 transition-colors",
|
// Schmaler auf dem Telefon: bei 17rem stand die zweite Spalte fast
|
||||||
|
// vollstaendig ausserhalb des Bildschirms, und ohne einen sichtbaren
|
||||||
|
// Rest der naechsten Spalte sieht man dem Brett nicht an, dass es
|
||||||
|
// seitlich weitergeht.
|
||||||
|
"flex w-[14rem] shrink-0 flex-col rounded-2xl border border-white/6 bg-white/2 transition-colors sm:w-[17rem]",
|
||||||
isOver && "border-violet-400/40 bg-violet-500/6",
|
isOver && "border-violet-400/40 bg-violet-500/6",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
Download,
|
Download,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
List,
|
||||||
ListFilter,
|
ListFilter,
|
||||||
NotebookPen,
|
NotebookPen,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -21,6 +22,7 @@ import { ImportDialog } from "@/components/ImportDialog";
|
|||||||
import { KanbanBoard } from "@/components/KanbanBoard";
|
import { KanbanBoard } from "@/components/KanbanBoard";
|
||||||
import { ProjectCard } from "@/components/ProjectCard";
|
import { ProjectCard } from "@/components/ProjectCard";
|
||||||
import { ProjectDialog } from "@/components/ProjectDialog";
|
import { ProjectDialog } from "@/components/ProjectDialog";
|
||||||
|
import { ProjectList } from "@/components/ProjectList";
|
||||||
import { Popover } from "@/components/ui/Popover";
|
import { Popover } from "@/components/ui/Popover";
|
||||||
import { STATUS_META, STATUS_ORDER } from "@/lib/status";
|
import { STATUS_META, STATUS_ORDER } from "@/lib/status";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -53,9 +55,17 @@ function highlight(snippet: string): React.ReactNode[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SortKey = "updated" | "created" | "name" | "progress" | "priority";
|
type SortKey = "updated" | "created" | "name" | "progress" | "priority";
|
||||||
type ViewMode = "grid" | "grouped" | "kanban";
|
type ViewMode = "grid" | "list" | "grouped" | "kanban";
|
||||||
|
|
||||||
const VIEWS: ViewMode[] = ["grid", "grouped", "kanban"];
|
const VIEWS: ViewMode[] = ["grid", "list", "grouped", "kanban"];
|
||||||
|
|
||||||
|
/** Reihenfolge der Umschalter – zugleich die Beschriftung der Knoepfe. */
|
||||||
|
const VIEW_META: Record<ViewMode, { label: string; title: string; icon: typeof LayoutGrid }> = {
|
||||||
|
grid: { label: "Raster", title: "Rasteransicht", icon: LayoutGrid },
|
||||||
|
list: { label: "Liste", title: "Kompakte Liste", icon: List },
|
||||||
|
grouped: { label: "Gruppiert", title: "Nach Status gruppiert", icon: Rows3 },
|
||||||
|
kanban: { label: "Brett", title: "Kanban – Status per Ziehen aendern", icon: Columns3 },
|
||||||
|
};
|
||||||
|
|
||||||
/** Wo Ansicht, Sortierung und Archivschalter im Browser liegen. */
|
/** Wo Ansicht, Sortierung und Archivschalter im Browser liegen. */
|
||||||
const ANSICHT_SCHLUESSEL = "nebula.board";
|
const ANSICHT_SCHLUESSEL = "nebula.board";
|
||||||
@@ -255,10 +265,14 @@ export function ProjectBoard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-5">
|
<section className="space-y-5">
|
||||||
{/* Steuerleiste */}
|
{/* Steuerleiste.
|
||||||
<div className="glass flex flex-col gap-4 p-4">
|
Auf dem Telefon steht die Suche allein in der ersten Zeile und die
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
Werkzeuge darunter. Frueher lagen beide in einer Zeile, die nicht
|
||||||
<div className="relative min-w-[220px] flex-1">
|
umbrechen konnte – der Knopf "Neues Projekt" wurde dabei aus dem
|
||||||
|
Bild geschoben und die ganze Seite seitlich scrollbar. */}
|
||||||
|
<div className="glass flex flex-col gap-3 p-3 sm:gap-4 sm:p-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
|
<div className="relative min-w-0 sm:min-w-[220px] sm:flex-1">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
|
||||||
<input
|
<input
|
||||||
className="field pl-9"
|
className="field pl-9"
|
||||||
@@ -282,9 +296,12 @@ export function ProjectBoard({
|
|||||||
<label className="sr-only" htmlFor="sort">
|
<label className="sr-only" htmlFor="sort">
|
||||||
Sortierung
|
Sortierung
|
||||||
</label>
|
</label>
|
||||||
|
{/* min-w-0 ist hier entscheidend: ohne das weigert sich ein
|
||||||
|
Flex-Kind, unter seine Inhaltsbreite zu schrumpfen, und
|
||||||
|
drueckt stattdessen die Nachbarn hinaus. */}
|
||||||
<select
|
<select
|
||||||
id="sort"
|
id="sort"
|
||||||
className="field w-auto min-w-[11rem]"
|
className="field w-auto min-w-0 flex-1 truncate sm:flex-none sm:min-w-[11rem]"
|
||||||
value={sort}
|
value={sort}
|
||||||
onChange={(event) => setSort(event.target.value as SortKey)}
|
onChange={(event) => setSort(event.target.value as SortKey)}
|
||||||
// Im Brett zaehlt die Anordnung von Hand. Die Sortierung hier
|
// Im Brett zaehlt die Anordnung von Hand. Die Sortierung hier
|
||||||
@@ -303,50 +320,11 @@ export function ProjectBoard({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<div className="flex overflow-hidden rounded-xl border border-white/9">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setView("grid")}
|
|
||||||
className={cn(
|
|
||||||
"p-2 transition-colors",
|
|
||||||
view === "grid" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
|
|
||||||
)}
|
|
||||||
aria-label="Rasteransicht"
|
|
||||||
title="Rasteransicht"
|
|
||||||
>
|
|
||||||
<LayoutGrid className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setView("grouped")}
|
|
||||||
className={cn(
|
|
||||||
"p-2 transition-colors",
|
|
||||||
view === "grouped" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
|
|
||||||
)}
|
|
||||||
aria-label="Nach Status gruppiert"
|
|
||||||
title="Nach Status gruppiert"
|
|
||||||
>
|
|
||||||
<Rows3 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setView("kanban")}
|
|
||||||
className={cn(
|
|
||||||
"p-2 transition-colors",
|
|
||||||
view === "kanban" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
|
|
||||||
)}
|
|
||||||
aria-label="Kanban-Spalten"
|
|
||||||
title="Kanban – Status per Ziehen aendern"
|
|
||||||
>
|
|
||||||
<Columns3 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
ref={dataButtonRef}
|
ref={dataButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDataMenuOpen((value) => !value)}
|
onClick={() => setDataMenuOpen((value) => !value)}
|
||||||
className="rounded-xl border border-white/9 p-2 text-slate-500 transition-colors hover:text-white"
|
className="shrink-0 rounded-xl border border-white/9 p-2.5 text-slate-500 transition-colors hover:text-white"
|
||||||
aria-label="Daten exportieren oder importieren"
|
aria-label="Daten exportieren oder importieren"
|
||||||
title="Export und Import"
|
title="Export und Import"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
@@ -355,13 +333,51 @@ export function ProjectBoard({
|
|||||||
<Database className="h-4 w-4" />
|
<Database className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" className="btn btn-primary" onClick={() => setDialogOpen(true)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary shrink-0"
|
||||||
|
onClick={() => setDialogOpen(true)}
|
||||||
|
// Unterhalb von sm bleibt nur das Pluszeichen stehen. Ohne
|
||||||
|
// eigene Beschriftung liest ein Screenreader dann gar nichts vor.
|
||||||
|
aria-label="Neues Projekt"
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Neues Projekt</span>
|
<span className="hidden sm:inline">Neues Projekt</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Ansichtsumschalter: auf schmalen Anzeigen ueber die volle Breite,
|
||||||
|
damit die vier Knoepfe gross genug zum Treffen bleiben. */}
|
||||||
|
<div
|
||||||
|
className="flex overflow-hidden rounded-xl border border-white/9 sm:w-auto sm:self-start"
|
||||||
|
role="group"
|
||||||
|
aria-label="Ansicht"
|
||||||
|
>
|
||||||
|
{VIEWS.map((mode) => {
|
||||||
|
const { label, title, icon: Icon } = VIEW_META[mode];
|
||||||
|
const active = view === mode;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setView(mode)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 items-center justify-center gap-1.5 px-3 py-2 text-xs transition-colors sm:flex-none",
|
||||||
|
active ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
|
||||||
|
)}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
{/* Nur auf den schmalsten Anzeigen bleiben die Symbole allein –
|
||||||
|
dort waeren vier Beschriftungen enger als die Fingerkuppe. */}
|
||||||
|
<span className="hidden xs:inline">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="mr-1 inline-flex items-center gap-1.5 text-[11px] uppercase tracking-wider text-slate-500">
|
<span className="mr-1 inline-flex items-center gap-1.5 text-[11px] uppercase tracking-wider text-slate-500">
|
||||||
<ListFilter className="h-3.5 w-3.5" />
|
<ListFilter className="h-3.5 w-3.5" />
|
||||||
@@ -471,6 +487,8 @@ export function ProjectBoard({
|
|||||||
selection={selection}
|
selection={selection}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
/>
|
/>
|
||||||
|
) : view === "list" ? (
|
||||||
|
<ProjectList projects={visible} selection={selection} onToggleSelect={toggleSelect} />
|
||||||
) : view === "grid" ? (
|
) : view === "grid" ? (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||||
{visible.map((project, index) => (
|
{visible.map((project, index) => (
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function ProjectHeader({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className="glass relative overflow-hidden p-6"
|
className="glass relative overflow-hidden p-4 sm:p-6"
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--accent-from": accent.from,
|
"--accent-from": accent.from,
|
||||||
@@ -168,9 +168,13 @@ export function ProjectHeader({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-end gap-3">
|
{/* Auf dem Telefon steht dieser Block unter dem Titel und faengt
|
||||||
|
links an; erst ab sm rueckt er nach rechts neben ihn. Vorher
|
||||||
|
standen vier Knoepfe in einer Zeile, die nicht umbrechen durfte –
|
||||||
|
"Loeschen" lag damit ausserhalb des Bildschirms. */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 sm:flex-col sm:items-end sm:gap-3">
|
||||||
<StatusSelect projectId={project.id} status={project.status} />
|
<StatusSelect projectId={project.id} status={project.status} />
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
ref={exportButtonRef}
|
ref={exportButtonRef}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -179,6 +183,7 @@ export function ProjectHeader({
|
|||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={exportOpen}
|
aria-expanded={exportOpen}
|
||||||
title="Exportieren"
|
title="Exportieren"
|
||||||
|
aria-label="Exportieren"
|
||||||
>
|
>
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -187,6 +192,7 @@ export function ProjectHeader({
|
|||||||
className="btn btn-ghost px-2.5 py-1.5 text-xs"
|
className="btn btn-ghost px-2.5 py-1.5 text-xs"
|
||||||
onClick={() => setTemplateOpen(true)}
|
onClick={() => setTemplateOpen(true)}
|
||||||
title="Als Vorlage sichern"
|
title="Als Vorlage sichern"
|
||||||
|
aria-label="Als Vorlage sichern"
|
||||||
>
|
>
|
||||||
<BookmarkPlus className="h-3.5 w-3.5" />
|
<BookmarkPlus className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckSquare,
|
||||||
|
GitBranch,
|
||||||
|
GitCommitHorizontal,
|
||||||
|
NotebookPen,
|
||||||
|
Star,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { StatusSelect } from "@/components/StatusSelect";
|
||||||
|
import { PRIORITY_META, accentOf, statusOf } from "@/lib/status";
|
||||||
|
import { cn, formatRelative } from "@/lib/utils";
|
||||||
|
import type { ProjectListItem } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listenansicht des Boards.
|
||||||
|
*
|
||||||
|
* Warum zusaetzlich zum Raster: eine Karte braucht rund 260px Hoehe, auf dem
|
||||||
|
* Telefon steht sie zwangslaeufig allein in der Zeile. Fuenf Projekte fuellen
|
||||||
|
* damit zwei Bildschirme, bevor man ueberhaupt etwas vergleichen kann. Eine
|
||||||
|
* Zeile kommt mit einem Viertel davon aus, und weil alle Zeilen gleich
|
||||||
|
* aufgebaut sind, liegen Name, Fortschritt und Status untereinander – genau
|
||||||
|
* das, was das Raster auf schmalen Anzeigen verliert.
|
||||||
|
*/
|
||||||
|
export function ProjectList({
|
||||||
|
projects,
|
||||||
|
selection,
|
||||||
|
onToggleSelect,
|
||||||
|
}: {
|
||||||
|
projects: ProjectListItem[];
|
||||||
|
selection: Set<string>;
|
||||||
|
onToggleSelect?: (id: string, selected: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ul className="glass fade-up divide-y divide-white/6 overflow-hidden">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<ProjectRow
|
||||||
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
selected={selection.has(project.id)}
|
||||||
|
onToggleSelect={onToggleSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectRow({
|
||||||
|
project,
|
||||||
|
selected,
|
||||||
|
onToggleSelect,
|
||||||
|
}: {
|
||||||
|
project: ProjectListItem;
|
||||||
|
selected: boolean;
|
||||||
|
onToggleSelect?: (id: string, selected: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const accent = accentOf(project.accent);
|
||||||
|
const meta = statusOf(project.status);
|
||||||
|
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
|
||||||
|
const snapshot = project.repositories[0]?.snapshot ?? null;
|
||||||
|
const syncError = project.repositories.find((repo) => repo.syncError)?.syncError ?? null;
|
||||||
|
const [favorite, setFavorite] = useState(project.favorite);
|
||||||
|
|
||||||
|
async function toggleFavorite() {
|
||||||
|
const next = !favorite;
|
||||||
|
setFavorite(next);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${project.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ favorite: next }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("failed");
|
||||||
|
} catch {
|
||||||
|
setFavorite(!next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={cn("group relative transition-colors", selected && "bg-violet-500/8")}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--accent-from": accent.from,
|
||||||
|
"--accent-to": accent.to,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Die ganze Zeile ist der Link; was darueber bedienbar sein soll,
|
||||||
|
bekommt weiter unten die Zeigerereignisse zurueck. */}
|
||||||
|
<Link
|
||||||
|
href={`/projects/${project.id}`}
|
||||||
|
className="absolute inset-0 z-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-violet-400/60"
|
||||||
|
aria-label={`Projekt ${project.name} oeffnen`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="pointer-events-none relative z-10 flex items-center gap-2.5 px-3 py-2.5 transition-colors group-hover:bg-white/4 sm:gap-3 sm:px-4 sm:py-3">
|
||||||
|
{onToggleSelect ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={(event) => onToggleSelect(project.id, event.target.checked)}
|
||||||
|
aria-label={`${project.name} auswaehlen`}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto h-4 w-4 shrink-0 accent-violet-500 transition-opacity",
|
||||||
|
!selected && "opacity-35 group-hover:opacity-100 focus-visible:opacity-100",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Statusfarbe als Streifen: auf einen Blick erkennbar, ohne Platz
|
||||||
|
fuer eine Beschriftung zu verbrauchen. */}
|
||||||
|
<span
|
||||||
|
className="h-8 w-1 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: meta.color }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<h3 className="truncate text-sm font-medium text-white group-hover:text-violet-200">
|
||||||
|
{project.name}
|
||||||
|
</h3>
|
||||||
|
{favorite ? (
|
||||||
|
<Star className="h-3 w-3 shrink-0 fill-amber-300 text-amber-300" />
|
||||||
|
) : null}
|
||||||
|
{syncError ? (
|
||||||
|
<AlertTriangle className="h-3 w-3 shrink-0 text-rose-300/80" aria-label="Sync-Fehler" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-slate-500">
|
||||||
|
<span className="h-1 w-16 shrink-0 overflow-hidden rounded-full bg-white/8 sm:w-24">
|
||||||
|
<span
|
||||||
|
className="accent-bar block h-full rounded-full"
|
||||||
|
style={{ width: `${Math.max(2, project.progress)}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 tabular-nums">{project.progress}%</span>
|
||||||
|
|
||||||
|
{/* Was nicht mehr in die Zeile passt, faellt der Reihe nach weg,
|
||||||
|
statt sie zu sprengen. */}
|
||||||
|
{project._count.tasks > 0 ? (
|
||||||
|
<span className="hidden shrink-0 items-center gap-1 xs:inline-flex">
|
||||||
|
<CheckSquare className="h-3 w-3" />
|
||||||
|
{project.tasks.length}/{project._count.tasks}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{project._count.notes > 0 ? (
|
||||||
|
<span className="hidden shrink-0 items-center gap-1 sm:inline-flex">
|
||||||
|
<NotebookPen className="h-3 w-3" />
|
||||||
|
{project._count.notes}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{snapshot?.defaultBranch ? (
|
||||||
|
<span className="hidden shrink-0 items-center gap-1 font-mono md:inline-flex">
|
||||||
|
<GitBranch className="h-3 w-3" />
|
||||||
|
{snapshot.defaultBranch}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{snapshot?.lastCommitAt ? (
|
||||||
|
<span
|
||||||
|
className="hidden shrink-0 items-center gap-1 lg:inline-flex"
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
<GitCommitHorizontal className="h-3 w-3" />
|
||||||
|
{formatRelative(snapshot.lastCommitAt)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<span
|
||||||
|
className="ml-auto hidden shrink-0 uppercase tracking-wider md:inline"
|
||||||
|
style={{ color: priority.color }}
|
||||||
|
title={`Prioritaet: ${priority.label}`}
|
||||||
|
>
|
||||||
|
{priority.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleFavorite}
|
||||||
|
aria-label={favorite ? "Favorit entfernen" : "Als Favorit markieren"}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto hidden shrink-0 rounded-lg p-2 transition-colors sm:block",
|
||||||
|
favorite
|
||||||
|
? "text-amber-300 hover:bg-amber-400/10"
|
||||||
|
: "text-slate-600 hover:bg-white/8 hover:text-amber-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Star className={cn("h-4 w-4", favorite && "fill-current")} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="pointer-events-auto shrink-0">
|
||||||
|
<StatusSelect projectId={project.id} status={project.status} variant="compact" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -6,26 +7,33 @@ interface StatCardProps {
|
|||||||
hint?: string;
|
hint?: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
color: string;
|
color: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatCard({ label, value, hint, icon: Icon, color }: StatCardProps) {
|
export function StatCard({ label, value, hint, icon: Icon, color, className }: StatCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="glass relative overflow-hidden p-4">
|
<div className={cn("glass relative overflow-hidden p-3 sm:p-4", className)}>
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute -right-6 -top-8 h-24 w-24 rounded-full opacity-25 blur-2xl"
|
className="pointer-events-none absolute -right-6 -top-8 h-24 w-24 rounded-full opacity-25 blur-2xl"
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-2 sm:gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
|
{/* Umbrechen statt abschneiden: in der zweispaltigen Anordnung auf
|
||||||
|
dem Telefon endeten "In Entwicklung" und "Repos verknuepft"
|
||||||
|
sonst beide als "In Entwickl…" – zwei Kacheln, die sich nicht
|
||||||
|
mehr unterscheiden liessen. */}
|
||||||
|
<p className="text-[11px] font-semibold uppercase leading-tight tracking-[0.14em] text-slate-500">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-3xl font-semibold tabular-nums text-white">{value}</p>
|
<p className="mt-1.5 text-2xl font-semibold tabular-nums text-white sm:mt-2 sm:text-3xl">
|
||||||
{hint ? <p className="mt-1 truncate text-xs text-slate-500">{hint}</p> : null}
|
{value}
|
||||||
|
</p>
|
||||||
|
{hint ? <p className="mt-1 text-xs leading-tight text-slate-500">{hint}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl"
|
className="grid h-8 w-8 shrink-0 place-items-center rounded-xl sm:h-9 sm:w-9"
|
||||||
style={{ backgroundColor: `${color}1f`, color }}
|
style={{ backgroundColor: `${color}1f`, color }}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
|
|||||||
@@ -34,16 +34,18 @@ export function TopNav({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 border-b border-white/5 bg-[#05060f]/70 backdrop-blur-xl">
|
<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-6 px-4 py-3 sm:px-6 lg:px-8">
|
<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 items-center gap-3">
|
<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">
|
<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} />
|
<Sparkles className="h-4 w-4 text-white" strokeWidth={2.4} />
|
||||||
</span>
|
</span>
|
||||||
<span className="flex flex-col leading-none">
|
<span className="flex min-w-0 flex-col leading-none">
|
||||||
<span className="text-gradient text-lg font-semibold tracking-tight">
|
<span className="text-gradient truncate text-lg font-semibold tracking-tight">
|
||||||
{appName}
|
{appName}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 text-[10px] uppercase tracking-[0.2em] text-slate-500">
|
{/* Der weite Zeichenabstand kostet auf 375px mehr Platz, als die
|
||||||
|
Zeile wert ist – dort bleibt nur der Name stehen. */}
|
||||||
|
<span className="mt-0.5 hidden truncate text-[10px] uppercase tracking-[0.2em] text-slate-500 xs:block">
|
||||||
{tagline}
|
{tagline}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -51,13 +53,20 @@ export function TopNav({
|
|||||||
|
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
<nav className="flex items-center gap-1">
|
<nav className="flex shrink-0 items-center gap-1">
|
||||||
{LINKS.map(({ href, label, icon: Icon }) => {
|
{LINKS.map(({ href, label, icon: Icon }) => {
|
||||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
href={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(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors",
|
"flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||||
active
|
active
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function Modal({
|
|||||||
if (!mounted || !open) return null;
|
if (!mounted || !open) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto p-4 sm:p-8">
|
<div className="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto p-3 sm:p-8">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Dialog schliessen"
|
aria-label="Dialog schliessen"
|
||||||
@@ -65,7 +65,7 @@ export function Modal({
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
className={cn(
|
className={cn(
|
||||||
"popover fade-up relative z-10 my-auto w-full rounded-2xl p-6",
|
"popover fade-up relative z-10 my-auto w-full rounded-2xl p-4 sm:p-6",
|
||||||
width === "md" && "max-w-lg",
|
width === "md" && "max-w-lg",
|
||||||
width === "lg" && "max-w-2xl",
|
width === "lg" && "max-w-2xl",
|
||||||
width === "xl" && "max-w-4xl",
|
width === "xl" && "max-w-4xl",
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ export function Popover({
|
|||||||
}) {
|
}) {
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
const [position, setPosition] = useState<{
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(() => setMounted(true), []);
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
@@ -56,14 +60,19 @@ export function Popover({
|
|||||||
const margin = 8;
|
const margin = 8;
|
||||||
const gap = 6;
|
const gap = 6;
|
||||||
|
|
||||||
|
// Passt die Wunschbreite nicht ins Fenster, gilt das Fenster. Sonst
|
||||||
|
// liefert die Klemmung unten eine negative linke Kante und das Menue
|
||||||
|
// stuende zur Haelfte ausserhalb des Bildschirms.
|
||||||
|
const effektiv = Math.min(width, window.innerWidth - 2 * margin);
|
||||||
|
|
||||||
// Bevorzugte Ausrichtung, bei Ueberlauf auf die andere Seite wechseln.
|
// Bevorzugte Ausrichtung, bei Ueberlauf auf die andere Seite wechseln.
|
||||||
// Nur Festklemmen am Fensterrand waere schlecht: bei einem Ausloeser weit
|
// Nur Festklemmen am Fensterrand waere schlecht: bei einem Ausloeser weit
|
||||||
// links stuende das Menue sonst quer ueber der Seite statt an seinem
|
// links stuende das Menue sonst quer ueber der Seite statt an seinem
|
||||||
// Ausloeser.
|
// Ausloeser.
|
||||||
let left = align === "end" ? rect.right - width : rect.left;
|
let left = align === "end" ? rect.right - effektiv : rect.left;
|
||||||
if (left < margin) left = rect.left;
|
if (left < margin) left = rect.left;
|
||||||
if (left + width > window.innerWidth - margin) left = rect.right - width;
|
if (left + effektiv > window.innerWidth - margin) left = rect.right - effektiv;
|
||||||
left = Math.min(Math.max(margin, left), window.innerWidth - width - margin);
|
left = Math.min(Math.max(margin, left), window.innerWidth - effektiv - margin);
|
||||||
|
|
||||||
const panelHeight = panelRef.current?.offsetHeight ?? 0;
|
const panelHeight = panelRef.current?.offsetHeight ?? 0;
|
||||||
let top = rect.bottom + gap;
|
let top = rect.bottom + gap;
|
||||||
@@ -73,7 +82,7 @@ export function Popover({
|
|||||||
const fitsAbove = rect.top - gap - panelHeight >= margin;
|
const fitsAbove = rect.top - gap - panelHeight >= margin;
|
||||||
if (!fitsBelow && fitsAbove) top = rect.top - gap - panelHeight;
|
if (!fitsBelow && fitsAbove) top = rect.top - gap - panelHeight;
|
||||||
|
|
||||||
setPosition({ top, left });
|
setPosition({ top, left, width: effektiv });
|
||||||
}, [anchorRef, align, width]);
|
}, [anchorRef, align, width]);
|
||||||
|
|
||||||
// Bewusst useEffect statt useLayoutEffect: Letzteres warnt beim
|
// Bewusst useEffect statt useLayoutEffect: Letzteres warnt beim
|
||||||
@@ -127,7 +136,7 @@ export function Popover({
|
|||||||
position: "fixed",
|
position: "fixed",
|
||||||
top: position?.top ?? -9999,
|
top: position?.top ?? -9999,
|
||||||
left: position?.left ?? -9999,
|
left: position?.left ?? -9999,
|
||||||
width,
|
width: position?.width ?? width,
|
||||||
// Bis die Position steht, unsichtbar halten – sonst blitzt das
|
// Bis die Position steht, unsichtbar halten – sonst blitzt das
|
||||||
// Menue kurz an der falschen Stelle auf.
|
// Menue kurz an der falschen Stelle auf.
|
||||||
visibility: position ? "visible" : "hidden",
|
visibility: position ? "visible" : "hidden",
|
||||||
|
|||||||
Reference in New Issue
Block a user