diff --git a/FEATURES.md b/FEATURES.md index eb76b42..6ceecf1 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -115,7 +115,8 @@ Der Status ist **direkt auf der Projektkarte** umschaltbar, ohne den Detailberei - **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar. - **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität — 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 (Name, Kurzbeschreibung, Tags, Repo-Pfad). Notizen liegen dort nicht vor — sie 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, 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 Spalten je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und diff --git a/e2e/ui.spec.ts b/e2e/ui.spec.ts index 0937601..e6fb6dc 100644 --- a/e2e/ui.spec.ts +++ b/e2e/ui.spec.ts @@ -106,6 +106,31 @@ test("Volltextsuche findet Notizen", async ({ page }) => { 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 }) => { await page.keyboard.press("Control+k"); await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible(); diff --git a/src/app/components.css b/src/app/components.css index cedc800..e77cf9c 100644 --- a/src/app/components.css +++ b/src/app/components.css @@ -277,6 +277,21 @@ 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 { opacity: 0.5; cursor: not-allowed; @@ -335,6 +350,19 @@ 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 { color: rgba(148, 163, 184, 0.6); } @@ -416,4 +444,22 @@ .range::-moz-range-track { 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; + } + } } diff --git a/src/app/globals.css b/src/app/globals.css index be30d8f..0e46c33 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -38,6 +38,13 @@ Consolas, "DejaVu Sans Mono", monospace; --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 { @@ -63,7 +70,14 @@ font-family: var(--font-sans); -webkit-font-smoothing: antialiased; 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; + /* 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 { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index faf717d..a148980 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -42,6 +42,11 @@ export const viewport: Viewport = { themeColor: "#05060f", width: "device-width", 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({ diff --git a/src/app/page.tsx b/src/app/page.tsx index 826c7d9..29ad9fd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -52,13 +52,17 @@ export default async function DashboardPage() {

-
+ {/* 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. */} +
-
+ // Der Abstand nach unten beruecksichtigt die Home-Anzeige des iPhones; + // ohne das liegt die Leiste im installierten Betrieb darunter. +
+
{ids.length} ausgewählt @@ -122,8 +127,17 @@ export function BulkBar({ Tags - setTagsOpen(false)} align="start"> -
+ {/* Die Breite gehoert an den Popover, nicht an sein Kind: der Popover + setzt overflow-hidden, ein breiteres Kind wird schlicht + abgeschnitten – frueher um die Haelfte des Abbrechen-Knopfs. */} + setTagsOpen(false)} + align="start" + width={288} + > +
diff --git a/src/components/Heatmap.tsx b/src/components/Heatmap.tsx index ac10d9e..06bcda8 100644 --- a/src/components/Heatmap.tsx +++ b/src/components/Heatmap.tsx @@ -114,12 +114,18 @@ export function Heatmap({ calendar }: { calendar: CommitCalendar }) {

-
+
+ {/* + 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. + */} diff --git a/src/components/KanbanBoard.tsx b/src/components/KanbanBoard.tsx index 522edec..abe3c08 100644 --- a/src/components/KanbanBoard.tsx +++ b/src/components/KanbanBoard.tsx @@ -253,7 +253,11 @@ function Column({ // Eindeutiger Anker fuer Tests und Automatisierung. data-column={status} 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", )} > diff --git a/src/components/ProjectBoard.tsx b/src/components/ProjectBoard.tsx index 328a5ab..961eaf2 100644 --- a/src/components/ProjectBoard.tsx +++ b/src/components/ProjectBoard.tsx @@ -7,6 +7,7 @@ import { Database, Download, LayoutGrid, + List, ListFilter, NotebookPen, Plus, @@ -21,6 +22,7 @@ import { ImportDialog } from "@/components/ImportDialog"; import { KanbanBoard } from "@/components/KanbanBoard"; import { ProjectCard } from "@/components/ProjectCard"; import { ProjectDialog } from "@/components/ProjectDialog"; +import { ProjectList } from "@/components/ProjectList"; import { Popover } from "@/components/ui/Popover"; import { STATUS_META, STATUS_ORDER } from "@/lib/status"; import { cn } from "@/lib/utils"; @@ -53,9 +55,17 @@ function highlight(snippet: string): React.ReactNode[] { } 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 = { + 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. */ const ANSICHT_SCHLUESSEL = "nebula.board"; @@ -255,10 +265,14 @@ export function ProjectBoard({ return (
- {/* Steuerleiste */} -
-
-
+ {/* Steuerleiste. + Auf dem Telefon steht die Suche allein in der ersten Zeile und die + Werkzeuge darunter. Frueher lagen beide in einer Zeile, die nicht + umbrechen konnte – der Knopf "Neues Projekt" wurde dabei aus dem + Bild geschoben und die ganze Seite seitlich scrollbar. */} +
+
+
Sortierung + {/* min-w-0 ist hier entscheidend: ohne das weigert sich ein + Flex-Kind, unter seine Inhaltsbreite zu schrumpfen, und + drueckt stattdessen die Nachbarn hinaus. */} -
- - - -
- -
+ {/* Ansichtsumschalter: auf schmalen Anzeigen ueber die volle Breite, + damit die vier Knoepfe gross genug zum Treffen bleiben. */} +
+ {VIEWS.map((mode) => { + const { label, title, icon: Icon } = VIEW_META[mode]; + const active = view === mode; + return ( + + ); + })} +
+
@@ -471,6 +487,8 @@ export function ProjectBoard({ selection={selection} onToggleSelect={toggleSelect} /> + ) : view === "list" ? ( + ) : view === "grid" ? (
{visible.map((project, index) => ( diff --git a/src/components/ProjectHeader.tsx b/src/components/ProjectHeader.tsx index abf6a8a..706c90d 100644 --- a/src/components/ProjectHeader.tsx +++ b/src/components/ProjectHeader.tsx @@ -74,7 +74,7 @@ export function ProjectHeader({ return (
-
+ {/* 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. */} +
-
+
@@ -187,6 +192,7 @@ export function ProjectHeader({ className="btn btn-ghost px-2.5 py-1.5 text-xs" onClick={() => setTemplateOpen(true)} title="Als Vorlage sichern" + aria-label="Als Vorlage sichern" > diff --git a/src/components/ProjectList.tsx b/src/components/ProjectList.tsx new file mode 100644 index 0000000..b5f005f --- /dev/null +++ b/src/components/ProjectList.tsx @@ -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; + onToggleSelect?: (id: string, selected: boolean) => void; +}) { + return ( +
    + {projects.map((project) => ( + + ))} +
+ ); +} + +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 ( +
  • + {/* Die ganze Zeile ist der Link; was darueber bedienbar sein soll, + bekommt weiter unten die Zeigerereignisse zurueck. */} + + +
    + {onToggleSelect ? ( + 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. */} +
    +
  • + ); +} diff --git a/src/components/StatCard.tsx b/src/components/StatCard.tsx index 09e134f..91c9970 100644 --- a/src/components/StatCard.tsx +++ b/src/components/StatCard.tsx @@ -1,4 +1,5 @@ import type { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; interface StatCardProps { label: string; @@ -6,26 +7,33 @@ interface StatCardProps { hint?: string; icon: LucideIcon; 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 ( -
    +