feat(t-paliad-149) PR2 step 2/2: frontend — Cards view + drag-rearrange named layouts

Adds the Cards view-mode to /projects (third option in the segment-control
between Tree and Liste).

frontend/src/projects.tsx:
- View-mode segment gains "Karten" button
- Two new toolbars (initially display:none, surfaced by Cards mode):
  - .projects-cards-toolbar: layout dropdown + [Bearbeiten] + [Neue Ansicht]
    + "Alle Ebenen anzeigen" toggle
  - .projects-cards-edit-toolbar: density radio + grid select + rename /
    delete / set-default / discard / save buttons
- New container: .projects-cards-wrap > #projects-cards-grid

frontend/src/client/projects-cards.ts (NEW, ~640 LoC):
- Layout management: GET /api/user-card-layouts on first mount; auto-seeds
  Standard layout if empty (POST). Layout dropdown switches active layout
  in-place; show_all_levels toggle persists immediately.
- Edit mode: clones the active layout into editDraft; renders per-card
  fact list with drag handles + visibility checkboxes + count steppers
  (1..5) for next-events / recent-verlauf. HTML5 drag-and-drop reorders
  facts; title-row is forced to the first position so the server-side
  validator's invariant holds.
- New layout: prompts for a name, seeds with the current draft (or active
  layout's facts), POSTs, enters edit mode.
- Set-default / rename / delete: each maps to PATCH or DELETE; default
  cannot be deleted (server returns 409 + UI alerts).
- Card render: title row (icon + link + pin star), type/status chips,
  client-matter, parent-path-as-reference (parent breadcrumb deferred —
  needs an extra fetch per card), deadline-counts (subtree-aggregated
  when available), next-events from /api/projects/cards-preview, recent-
  verlauf, team-chips initials with overflow count.
- Pin click on a card star does optimistic toggle + POST/DELETE pin
  endpoint and updates treeCache in place.
- Cards sort: pinned first, then last_activity_at DESC, then title ASC.
- "Alle Ebenen anzeigen" toggle decides whether Mandanten + Litigations
  appear as their own cards (off by default — leaf-ish projects only:
  Cases, Patents, Verfahren, Projekte).

frontend/src/client/projects.ts (orchestrator):
- ViewMode type expands to "tree" | "cards" | "flat"
- View segment-control wires through to Cards mode
- render() dispatches to renderCardsView / teardownCardsView based on
  active mode

frontend/src/client/i18n.ts: 53 new keys DE+EN under projects.cards.* —
section titles, empty-states, layout picker labels (label/new/edit/save/
discard/set_default/delete/rename/is_default/new.prompt/delete.confirm/
delete.default_blocked), per-fact labels (title-row/type-chip/status-chip/
client-matter/parent-path/deadline-counts/next-events/recent-verlauf/
team-chips/reference/last-activity-at), density values (compact/roomy),
grid values (auto/2/3/4), event-kind labels (deadline/appointment/
project_event), edit toggles (toggle.hide/show/move_up/move_down/count).

frontend/src/styles/global.css: ~290 LoC appended for cards toolbar +
grid + card layout (title row / row / section / event row / team chips)
+ edit-mode chrome (drag handles, drop targets, count steppers) + dark-
themed dashed border on edit cards. Mobile media query forces single-
column grid.

i18n codegen: 1830 → 1882 keys (+52). bun run build clean. tsc on new
files clean (pre-existing JSX-IntrinsicElements noise unrelated).
go build/vet/test still clean.
This commit is contained in:
m
2026-05-07 22:46:26 +02:00
parent 4e1d311a9c
commit aeeded7e21
6 changed files with 1297 additions and 4 deletions

View File

@@ -1210,6 +1210,55 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.search.match.self": "Treffer",
"projects.search.match.ancestor": "\u00dcber-Projekt eines Treffers",
"projects.search.match.descendant": "Unterprojekt eines Treffers",
"projects.cards.next_events": "N\u00e4chste Termine",
"projects.cards.recent_verlauf": "Zuletzt",
"projects.cards.no_next_events": "\u2014 keine bevorstehenden Termine",
"projects.cards.no_recent": "\u2014 noch nichts passiert",
"projects.cards.team": "Team",
"projects.cards.deadline_open": "offen",
"projects.cards.deadline_overdue": "\u00fcberf\u00e4llig",
"projects.cards.show_all_levels": "Alle Ebenen anzeigen",
"projects.cards.show_all_levels.hint": "Mandanten + Streitsachen als eigene Karten zeigen",
"projects.cards.layout.label": "Ansicht",
"projects.cards.layout.new": "Neue Ansicht",
"projects.cards.layout.edit": "Bearbeiten",
"projects.cards.layout.save": "Speichern",
"projects.cards.layout.discard": "Verwerfen",
"projects.cards.layout.set_default": "Als Standard festlegen",
"projects.cards.layout.delete": "L\u00f6schen",
"projects.cards.layout.rename": "Umbenennen",
"projects.cards.layout.is_default": "Standard",
"projects.cards.layout.new.prompt": "Name der neuen Ansicht",
"projects.cards.layout.delete.confirm": "Diese Ansicht wirklich l\u00f6schen?",
"projects.cards.layout.delete.default_blocked": "Die aktive Standardansicht kann nicht gel\u00f6scht werden \u2014 bitte zuerst eine andere Standardansicht w\u00e4hlen.",
"projects.cards.layout.fact.title-row": "Titelzeile",
"projects.cards.layout.fact.type-chip": "Typ-Badge",
"projects.cards.layout.fact.status-chip": "Status-Badge",
"projects.cards.layout.fact.client-matter": "ClientMatter",
"projects.cards.layout.fact.parent-path": "Pfad zum \u00dcberprojekt",
"projects.cards.layout.fact.deadline-counts": "Frist-Z\u00e4hler",
"projects.cards.layout.fact.next-events": "N\u00e4chste Termine",
"projects.cards.layout.fact.recent-verlauf": "Verlauf-Eintr\u00e4ge",
"projects.cards.layout.fact.team-chips": "Team-Chips",
"projects.cards.layout.fact.reference": "Referenz",
"projects.cards.layout.fact.last-activity-at": "Letzte Aktivit\u00e4t",
"projects.cards.layout.density": "Dichte",
"projects.cards.layout.density.compact": "Kompakt",
"projects.cards.layout.density.roomy": "Ger\u00e4umig",
"projects.cards.layout.grid": "Spalten",
"projects.cards.layout.grid.auto": "Auto",
"projects.cards.layout.grid.2": "2",
"projects.cards.layout.grid.3": "3",
"projects.cards.layout.grid.4": "4",
"projects.cards.layout.fact.toggle.hide": "Ausblenden",
"projects.cards.layout.fact.toggle.show": "Anzeigen",
"projects.cards.layout.fact.move_up": "Nach oben",
"projects.cards.layout.fact.move_down": "Nach unten",
"projects.cards.layout.fact.count": "Anzahl",
"projects.cards.event.kind.deadline": "Frist",
"projects.cards.event.kind.appointment": "Termin",
"projects.cards.event.kind.project_event": "Verlauf",
"projects.cards.empty": "Keine Projekte zum Anzeigen.",
"projects.detail.clientmatter.inherited": "Vom \u00dcberprojekt vererbt",
"einstellungen.profil.email": "E-Mail",
"einstellungen.profil.email.hint": "E-Mail kann nicht ge\u00e4ndert werden.",
@@ -3159,6 +3208,55 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.search.match.self": "Match",
"projects.search.match.ancestor": "Parent of a match",
"projects.search.match.descendant": "Child of a match",
"projects.cards.next_events": "Upcoming",
"projects.cards.recent_verlauf": "Recent",
"projects.cards.no_next_events": "— nothing upcoming",
"projects.cards.no_recent": "— nothing recent",
"projects.cards.team": "Team",
"projects.cards.deadline_open": "open",
"projects.cards.deadline_overdue": "overdue",
"projects.cards.show_all_levels": "Show all levels",
"projects.cards.show_all_levels.hint": "Include Clients + Litigations as their own cards",
"projects.cards.layout.label": "View",
"projects.cards.layout.new": "New view",
"projects.cards.layout.edit": "Edit",
"projects.cards.layout.save": "Save",
"projects.cards.layout.discard": "Discard",
"projects.cards.layout.set_default": "Set as default",
"projects.cards.layout.delete": "Delete",
"projects.cards.layout.rename": "Rename",
"projects.cards.layout.is_default": "Default",
"projects.cards.layout.new.prompt": "Name of the new view",
"projects.cards.layout.delete.confirm": "Really delete this view?",
"projects.cards.layout.delete.default_blocked": "Cannot delete the active default view — switch defaults first.",
"projects.cards.layout.fact.title-row": "Title row",
"projects.cards.layout.fact.type-chip": "Type badge",
"projects.cards.layout.fact.status-chip": "Status badge",
"projects.cards.layout.fact.client-matter": "ClientMatter",
"projects.cards.layout.fact.parent-path": "Parent path",
"projects.cards.layout.fact.deadline-counts": "Deadline counts",
"projects.cards.layout.fact.next-events": "Upcoming events",
"projects.cards.layout.fact.recent-verlauf": "Recent entries",
"projects.cards.layout.fact.team-chips": "Team chips",
"projects.cards.layout.fact.reference": "Reference",
"projects.cards.layout.fact.last-activity-at": "Last activity",
"projects.cards.layout.density": "Density",
"projects.cards.layout.density.compact": "Compact",
"projects.cards.layout.density.roomy": "Roomy",
"projects.cards.layout.grid": "Columns",
"projects.cards.layout.grid.auto": "Auto",
"projects.cards.layout.grid.2": "2",
"projects.cards.layout.grid.3": "3",
"projects.cards.layout.grid.4": "4",
"projects.cards.layout.fact.toggle.hide": "Hide",
"projects.cards.layout.fact.toggle.show": "Show",
"projects.cards.layout.fact.move_up": "Move up",
"projects.cards.layout.fact.move_down": "Move down",
"projects.cards.layout.fact.count": "Count",
"projects.cards.event.kind.deadline": "Deadline",
"projects.cards.event.kind.appointment": "Appointment",
"projects.cards.event.kind.project_event": "Verlauf",
"projects.cards.empty": "No projects to show.",
"projects.detail.clientmatter.inherited": "Inherited from parent",
"einstellungen.profil.email": "Email",
"einstellungen.profil.email.hint": "Email cannot be changed.",

View File

@@ -0,0 +1,796 @@
import { t, tDyn, getLang } from "./i18n";
// /projects Cards view (t-paliad-149 PR 2).
//
// Renders one card per project with configurable facts (title row, type
// chip, status, clientmatter, parent path, deadline counts, next 3 events,
// last 3 Verlauf entries, team chips). Layout is per-user, named, and
// drag-rearrangeable in edit mode (see editMode flag below).
//
// Data flow:
// 1. orchestrator (client/projects.ts) calls renderCardsView(...)
// 2. we fetch the active layout (GET /api/user-card-layouts → default first)
// 3. we fetch the projects tree with the orchestrator's chip/search params
// 4. we fetch /api/projects/cards-preview for per-project event rollups
// 5. flatten tree to cards (leaf-ish only by default; "Alle Ebenen" toggle)
// 6. render the grid; lazy-fill preview slots via IntersectionObserver
// when the project list is huge (cap currently 200 — paliad fits today)
//
// Edit mode toggles in-place: each card grows drag handles + visibility
// toggles + count steppers; the toolbar grows save/discard/rename/delete.
import type { ProjectTreeNode } from "./project-tree";
// ----------------------------------------------------------------------
// Types — mirror internal/services/layout_spec.go
// ----------------------------------------------------------------------
export type FactKey =
| "title-row"
| "type-chip"
| "status-chip"
| "client-matter"
| "parent-path"
| "deadline-counts"
| "next-events"
| "recent-verlauf"
| "team-chips"
| "reference"
| "last-activity-at";
const ALL_FACT_KEYS: FactKey[] = [
"title-row",
"type-chip",
"status-chip",
"client-matter",
"parent-path",
"deadline-counts",
"next-events",
"recent-verlauf",
"team-chips",
"reference",
"last-activity-at",
];
interface LayoutFact {
key: FactKey;
visible: boolean;
count?: number;
}
interface LayoutSpec {
facts: LayoutFact[];
density: "compact" | "roomy";
grid_columns: "auto" | "2" | "3" | "4";
show_all_levels: boolean;
}
interface UserCardLayout {
id: string;
user_id: string;
name: string;
is_default: boolean;
layout: LayoutSpec;
created_at: string;
updated_at: string;
}
interface CardEventPreview {
kind: "deadline" | "appointment" | "project_event";
id: string;
title: string;
event_date: string;
status?: string | null;
actor_name?: string | null;
route: string;
}
interface ProjectCardPreview {
project_id: string;
next_events: CardEventPreview[];
recent_verlauf: CardEventPreview[];
team_initials: string[];
team_count: number;
last_activity_at?: string | null;
}
// ----------------------------------------------------------------------
// Module state
// ----------------------------------------------------------------------
let layouts: UserCardLayout[] = [];
let activeLayoutId: string | null = null;
let editMode = false;
let editDraft: LayoutSpec | null = null;
let treeCache: ProjectTreeNode[] = [];
let previewCache: Map<string, ProjectCardPreview> = new Map();
// ----------------------------------------------------------------------
// Public entry — called by the orchestrator when view-mode = "cards"
// ----------------------------------------------------------------------
export interface CardsViewOpts {
treeParams: URLSearchParams;
}
export async function renderCardsView(opts: CardsViewOpts) {
const wrap = document.getElementById("projects-cards-wrap");
const toolbar = document.getElementById("projects-cards-toolbar");
const grid = document.getElementById("projects-cards-grid");
if (!wrap || !toolbar || !grid) return;
wrap.style.display = "block";
toolbar.style.display = "flex";
// Step 1: layouts.
if (layouts.length === 0) {
await reloadLayouts();
}
if (layouts.length === 0) {
grid.innerHTML = `<div class="projects-cards-empty">${escHTML(t("projects.cards.empty") || "Keine Projekte zum Anzeigen.")}</div>`;
return;
}
if (!activeLayoutId) {
const def = layouts.find((l) => l.is_default) || layouts[0];
activeLayoutId = def.id;
}
populateLayoutSelect();
attachToolbarHandlers();
// Step 2: tree (chip/search-narrowed) + cards preview.
const treeURL = `/api/projects/tree?${opts.treeParams.toString()}`;
const previewURL = `/api/projects/cards-preview`;
const [treeResp, previewResp] = await Promise.all([
fetch(treeURL).then((r) => (r.ok ? r.json() : [])),
fetch(previewURL).then((r) => (r.ok ? r.json() : [])),
]);
treeCache = treeResp as ProjectTreeNode[];
previewCache = new Map();
for (const p of previewResp as ProjectCardPreview[]) {
previewCache.set(p.project_id, p);
}
rerender();
}
export function teardownCardsView() {
const wrap = document.getElementById("projects-cards-wrap");
const toolbar = document.getElementById("projects-cards-toolbar");
const editToolbar = document.getElementById("projects-cards-edit-toolbar");
if (wrap) wrap.style.display = "none";
if (toolbar) toolbar.style.display = "none";
if (editToolbar) editToolbar.style.display = "none";
editMode = false;
editDraft = null;
}
// ----------------------------------------------------------------------
// Layout management — fetch + select + edit mode
// ----------------------------------------------------------------------
async function reloadLayouts(): Promise<void> {
const resp = await fetch("/api/user-card-layouts");
if (!resp.ok) {
layouts = [];
return;
}
layouts = (await resp.json()) as UserCardLayout[];
// Server may not have any rows yet — auto-seed by hitting the cards
// view's "default" path: GET /api/user-card-layouts/__seed-default__
// Actually the seed happens server-side on GetDefault; we trigger it
// by making a no-op preview request which doesn't pull layouts. The
// simplest path: if empty, POST a Standard layout from the client.
if (layouts.length === 0) {
const seed = await fetch("/api/user-card-layouts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Standard", layout: defaultLayout(), is_default: true }),
});
if (seed.ok) {
const row = (await seed.json()) as UserCardLayout;
layouts = [row];
}
}
}
function defaultLayout(): LayoutSpec {
return {
facts: [
{ key: "title-row", visible: true },
{ key: "type-chip", visible: true },
{ key: "status-chip", visible: true },
{ key: "client-matter", visible: true },
{ key: "parent-path", visible: true },
{ key: "deadline-counts", visible: true },
{ key: "next-events", visible: true, count: 3 },
{ key: "recent-verlauf", visible: true, count: 3 },
{ key: "team-chips", visible: true },
],
density: "roomy",
grid_columns: "auto",
show_all_levels: false,
};
}
function getActiveLayout(): UserCardLayout | null {
if (!activeLayoutId) return null;
return layouts.find((l) => l.id === activeLayoutId) || null;
}
function getEffectiveSpec(): LayoutSpec {
if (editMode && editDraft) return editDraft;
const a = getActiveLayout();
return a ? a.layout : defaultLayout();
}
function populateLayoutSelect() {
const sel = document.getElementById("projects-cards-layout-select") as HTMLSelectElement | null;
if (!sel) return;
sel.innerHTML = "";
for (const l of layouts) {
const opt = document.createElement("option");
opt.value = l.id;
opt.textContent = l.is_default ? `${l.name} · ${t("projects.cards.layout.is_default") || "Standard"}` : l.name;
sel.appendChild(opt);
}
if (activeLayoutId) sel.value = activeLayoutId;
}
function attachToolbarHandlers() {
const sel = document.getElementById("projects-cards-layout-select") as HTMLSelectElement | null;
const editBtn = document.getElementById("projects-cards-layout-edit") as HTMLButtonElement | null;
const newBtn = document.getElementById("projects-cards-layout-new") as HTMLButtonElement | null;
const showAll = document.getElementById("projects-cards-show-all-levels") as HTMLInputElement | null;
// Idempotent attach guards via dataset flag.
if (sel && !sel.dataset.bound) {
sel.dataset.bound = "1";
sel.addEventListener("change", () => {
activeLayoutId = sel.value;
const a = getActiveLayout();
if (showAll && a) showAll.checked = a.layout.show_all_levels;
rerender();
});
}
if (editBtn && !editBtn.dataset.bound) {
editBtn.dataset.bound = "1";
editBtn.addEventListener("click", () => enterEditMode());
}
if (newBtn && !newBtn.dataset.bound) {
newBtn.dataset.bound = "1";
newBtn.addEventListener("click", () => createNewLayout());
}
if (showAll && !showAll.dataset.bound) {
showAll.dataset.bound = "1";
showAll.checked = getEffectiveSpec().show_all_levels;
showAll.addEventListener("change", () => {
// In view mode this is a save-as-active toggle. In edit mode it
// updates the draft.
if (editMode && editDraft) {
editDraft.show_all_levels = showAll.checked;
rerender();
} else {
const a = getActiveLayout();
if (!a) return;
const newSpec: LayoutSpec = { ...a.layout, show_all_levels: showAll.checked };
void persistLayout(a.id, { layout: newSpec });
a.layout = newSpec;
rerender();
}
});
}
// Edit-toolbar wiring (only first time the elements exist).
const eDensity = document.getElementById("projects-cards-edit-density") as HTMLSelectElement | null;
const eGrid = document.getElementById("projects-cards-edit-grid") as HTMLSelectElement | null;
const eRename = document.getElementById("projects-cards-edit-rename") as HTMLButtonElement | null;
const eDelete = document.getElementById("projects-cards-edit-delete") as HTMLButtonElement | null;
const eSetDefault = document.getElementById("projects-cards-edit-set-default") as HTMLButtonElement | null;
const eDiscard = document.getElementById("projects-cards-edit-discard") as HTMLButtonElement | null;
const eSave = document.getElementById("projects-cards-edit-save") as HTMLButtonElement | null;
if (eDensity && !eDensity.dataset.bound) {
eDensity.dataset.bound = "1";
eDensity.addEventListener("change", () => {
if (!editDraft) return;
editDraft.density = eDensity.value as "compact" | "roomy";
rerender();
});
}
if (eGrid && !eGrid.dataset.bound) {
eGrid.dataset.bound = "1";
eGrid.addEventListener("change", () => {
if (!editDraft) return;
editDraft.grid_columns = eGrid.value as LayoutSpec["grid_columns"];
rerender();
});
}
if (eRename && !eRename.dataset.bound) {
eRename.dataset.bound = "1";
eRename.addEventListener("click", () => renameActiveLayout());
}
if (eDelete && !eDelete.dataset.bound) {
eDelete.dataset.bound = "1";
eDelete.addEventListener("click", () => deleteActiveLayout());
}
if (eSetDefault && !eSetDefault.dataset.bound) {
eSetDefault.dataset.bound = "1";
eSetDefault.addEventListener("click", () => setActiveAsDefault());
}
if (eDiscard && !eDiscard.dataset.bound) {
eDiscard.dataset.bound = "1";
eDiscard.addEventListener("click", () => leaveEditMode(false));
}
if (eSave && !eSave.dataset.bound) {
eSave.dataset.bound = "1";
eSave.addEventListener("click", () => leaveEditMode(true));
}
}
function enterEditMode() {
const a = getActiveLayout();
if (!a) return;
editMode = true;
editDraft = JSON.parse(JSON.stringify(a.layout)) as LayoutSpec;
reflectEditToolbar();
rerender();
}
async function leaveEditMode(saveChanges: boolean) {
const a = getActiveLayout();
if (!a) {
editMode = false;
editDraft = null;
rerender();
return;
}
if (saveChanges && editDraft) {
await persistLayout(a.id, { layout: editDraft });
a.layout = editDraft;
}
editMode = false;
editDraft = null;
reflectEditToolbar();
rerender();
}
function reflectEditToolbar() {
const editToolbar = document.getElementById("projects-cards-edit-toolbar");
if (editToolbar) editToolbar.style.display = editMode ? "flex" : "none";
if (editMode && editDraft) {
const eDensity = document.getElementById("projects-cards-edit-density") as HTMLSelectElement | null;
const eGrid = document.getElementById("projects-cards-edit-grid") as HTMLSelectElement | null;
if (eDensity) eDensity.value = editDraft.density;
if (eGrid) eGrid.value = editDraft.grid_columns;
}
}
async function persistLayout(id: string, patch: Partial<{ name: string; layout: LayoutSpec; is_default: boolean }>) {
const resp = await fetch(`/api/user-card-layouts/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (resp.ok) {
const row = (await resp.json()) as UserCardLayout;
const idx = layouts.findIndex((l) => l.id === id);
if (idx >= 0) layouts[idx] = row;
}
}
async function createNewLayout() {
const name = window.prompt(t("projects.cards.layout.new.prompt") || "Name der neuen Ansicht");
if (!name || !name.trim()) return;
const seed = JSON.parse(JSON.stringify(getEffectiveSpec())) as LayoutSpec;
const resp = await fetch("/api/user-card-layouts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), layout: seed, is_default: false }),
});
if (resp.status === 409) {
window.alert("Name already exists.");
return;
}
if (!resp.ok) return;
const row = (await resp.json()) as UserCardLayout;
layouts.push(row);
activeLayoutId = row.id;
populateLayoutSelect();
enterEditMode();
}
async function renameActiveLayout() {
const a = getActiveLayout();
if (!a) return;
const name = window.prompt(t("projects.cards.layout.rename") || "Umbenennen", a.name);
if (!name || !name.trim() || name.trim() === a.name) return;
await persistLayout(a.id, { name: name.trim() });
populateLayoutSelect();
}
async function deleteActiveLayout() {
const a = getActiveLayout();
if (!a) return;
if (a.is_default) {
window.alert(t("projects.cards.layout.delete.default_blocked") || "Cannot delete default.");
return;
}
if (!window.confirm(t("projects.cards.layout.delete.confirm") || "Delete?")) return;
const resp = await fetch(`/api/user-card-layouts/${encodeURIComponent(a.id)}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204) return;
layouts = layouts.filter((l) => l.id !== a.id);
const def = layouts.find((l) => l.is_default) || layouts[0];
activeLayoutId = def ? def.id : null;
editMode = false;
editDraft = null;
populateLayoutSelect();
reflectEditToolbar();
rerender();
}
async function setActiveAsDefault() {
const a = getActiveLayout();
if (!a) return;
const resp = await fetch(`/api/user-card-layouts/${encodeURIComponent(a.id)}/set-default`, {
method: "POST",
});
if (!resp.ok) return;
// Refresh the list — server cleared the prior default in tx.
await reloadLayouts();
populateLayoutSelect();
rerender();
}
// ----------------------------------------------------------------------
// Rendering
// ----------------------------------------------------------------------
function rerender() {
const grid = document.getElementById("projects-cards-grid");
if (!grid) return;
const spec = getEffectiveSpec();
// Apply grid columns + density.
grid.classList.toggle("is-density-compact", spec.density === "compact");
grid.classList.toggle("is-density-roomy", spec.density === "roomy");
grid.classList.remove("is-grid-2", "is-grid-3", "is-grid-4");
if (spec.grid_columns !== "auto") {
grid.classList.add(`is-grid-${spec.grid_columns}`);
}
const cards = flattenTreeToCards(treeCache, spec.show_all_levels);
if (cards.length === 0) {
grid.innerHTML = `<div class="projects-cards-empty">${escHTML(t("projects.cards.empty") || "Keine Projekte zum Anzeigen.")}</div>`;
return;
}
grid.innerHTML = cards.map((n) => renderCard(n, spec)).join("");
attachCardHandlers(grid);
}
function flattenTreeToCards(roots: ProjectTreeNode[], showAllLevels: boolean): ProjectTreeNode[] {
const out: ProjectTreeNode[] = [];
const walk = (n: ProjectTreeNode) => {
if (showAllLevels) {
out.push(n);
} else if (isLeafish(n)) {
out.push(n);
}
for (const c of n.children) walk(c);
};
roots.forEach(walk);
// Sort by last_activity_at DESC (from preview), pinned first.
out.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
const aT = previewCache.get(a.id)?.last_activity_at || "";
const bT = previewCache.get(b.id)?.last_activity_at || "";
if (aT !== bT) return bT.localeCompare(aT);
return a.title.localeCompare(b.title);
});
return out;
}
function isLeafish(n: ProjectTreeNode): boolean {
// Cases / Patents / Verfahren / Projekte. Mandanten + Litigations are
// scaffolding when "Alle Ebenen" is off.
return n.type === "case" || n.type === "patent" || n.type === "project";
}
function renderCard(n: ProjectTreeNode, spec: LayoutSpec): string {
const visibleFacts = spec.facts.filter((f) => f.visible);
const factHTML = visibleFacts.map((f) => renderFact(n, f, spec)).join("");
const editChrome = editMode ? renderEditChromeForCard(spec) : "";
return (
`<article class="projects-card${editMode ? " is-edit-mode" : ""}" data-id="${escAttr(n.id)}">` +
factHTML +
editChrome +
`</article>`
);
}
function renderEditChromeForCard(spec: LayoutSpec): string {
// Inline fact-row controls (drag handle + visibility toggle) per fact key.
// We render this as a horizontally-laid-out list at the bottom of each
// card so the user can rearrange order. In edit mode the rendered facts
// above are "preview"; the ordering controls below mutate the draft.
const rows = spec.facts.map((f, i) => {
const label = t(`projects.cards.layout.fact.${f.key}` as never) || f.key;
const cnt = f.count !== undefined
? `<input type="number" min="1" max="5" value="${f.count}" class="projects-cards-edit-count" data-key="${escAttr(f.key)}" />`
: "";
return (
`<li class="projects-cards-edit-fact" draggable="true" data-key="${escAttr(f.key)}" data-index="${i}">` +
`<span class="projects-cards-edit-handle" aria-hidden="true">⠿</span>` +
`<input type="checkbox" class="projects-cards-edit-vis" ${f.visible ? "checked" : ""} data-key="${escAttr(f.key)}" />` +
`<span class="projects-cards-edit-label">${escHTML(String(label))}</span>` +
cnt +
`</li>`
);
}).join("");
return `<ul class="projects-cards-edit-facts">${rows}</ul>`;
}
function renderFact(n: ProjectTreeNode, f: LayoutFact, spec: LayoutSpec): string {
const preview = previewCache.get(n.id);
switch (f.key) {
case "title-row":
return renderTitleRow(n);
case "type-chip":
return `<div class="projects-card-row"><span class="entity-type-chip entity-type-${escAttr(n.type)}">${escHTML(String(tDyn(`projects.type.${n.type}`) || n.type))}</span></div>`;
case "status-chip":
return `<div class="projects-card-row"><span class="entity-status-chip entity-status-${escAttr(n.status)}">${escHTML(String(tDyn(`projects.filter.status.${n.status}`) || n.status))}</span></div>`;
case "client-matter": {
const cm = (n.client_number && n.matter_number)
? `${n.client_number}.${n.matter_number}`
: (n.client_number || n.matter_number || "");
if (!cm) return "";
return `<div class="projects-card-row projects-card-cm">${escHTML(cm)}</div>`;
}
case "parent-path":
// Parent path is omitted in v1 — building the breadcrumb requires
// an extra fetch per card. Display the project's own .reference if
// present as a stand-in cue for hierarchy.
if (!n.reference) return "";
return `<div class="projects-card-row projects-card-ref">${escHTML(n.reference)}</div>`;
case "deadline-counts": {
const open = n.open_deadlines_subtree ?? n.open_deadlines;
const overdue = n.overdue_deadlines_subtree ?? n.overdue_deadlines;
if (open === 0 && overdue === 0) return "";
const parts: string[] = [];
if (overdue > 0) parts.push(`<span class="projekt-tree-badge projekt-tree-badge-overdue">${overdue} ${escHTML(String(t("projects.cards.deadline_overdue") || "überfällig"))}</span>`);
if (open > 0) parts.push(`<span class="projekt-tree-badge projekt-tree-badge-open">${open} ${escHTML(String(t("projects.cards.deadline_open") || "offen"))}</span>`);
return `<div class="projects-card-row projects-card-counts">${parts.join(" ")}</div>`;
}
case "next-events": {
const cap = clampCount(f.count);
const evs = preview?.next_events || [];
if (evs.length === 0) {
return `<div class="projects-card-section"><div class="projects-card-section-title">${escHTML(String(t("projects.cards.next_events") || "Nächste Termine"))}</div><div class="projects-card-empty">${escHTML(String(t("projects.cards.no_next_events") || ""))}</div></div>`;
}
const rows = evs.slice(0, cap).map((e) => renderEventRow(e)).join("");
return `<div class="projects-card-section"><div class="projects-card-section-title">${escHTML(String(t("projects.cards.next_events") || "Nächste Termine"))}</div>${rows}</div>`;
}
case "recent-verlauf": {
const cap = clampCount(f.count);
const evs = preview?.recent_verlauf || [];
if (evs.length === 0) {
return `<div class="projects-card-section"><div class="projects-card-section-title">${escHTML(String(t("projects.cards.recent_verlauf") || "Zuletzt"))}</div><div class="projects-card-empty">${escHTML(String(t("projects.cards.no_recent") || ""))}</div></div>`;
}
const rows = evs.slice(0, cap).map((e) => renderEventRow(e)).join("");
return `<div class="projects-card-section"><div class="projects-card-section-title">${escHTML(String(t("projects.cards.recent_verlauf") || "Zuletzt"))}</div>${rows}</div>`;
}
case "team-chips": {
if (!preview || preview.team_count === 0) return "";
const initials = preview.team_initials.map((i) => `<span class="projects-card-team-initial">${escHTML(i)}</span>`).join("");
const overflow = preview.team_count > preview.team_initials.length
? `<span class="projects-card-team-overflow">+${preview.team_count - preview.team_initials.length}</span>`
: "";
return `<div class="projects-card-row projects-card-team">${initials}${overflow}</div>`;
}
case "reference":
if (!n.reference) return "";
return `<div class="projects-card-row projects-card-ref">${escHTML(n.reference)}</div>`;
case "last-activity-at": {
const at = preview?.last_activity_at;
if (!at) return "";
return `<div class="projects-card-row projects-card-last-activity">${escHTML(fmtDate(at))}</div>`;
}
default:
return "";
}
// unreachable
void spec;
}
function renderTitleRow(n: ProjectTreeNode): string {
const pinClass = n.pinned ? " is-pinned" : "";
const pinLabel = n.pinned
? (t("projects.tree.unpin") || "Pin entfernen")
: (t("projects.tree.pin") || "Anpinnen");
return (
`<div class="projects-card-title-row">` +
`<span class="projects-card-icon projekt-tree-icon-${escAttr(n.type)}" aria-hidden="true">●</span>` +
`<a class="projects-card-title" href="/projects/${escAttr(n.id)}">${escHTML(n.title)}</a>` +
`<button type="button" class="projekt-tree-pin projects-card-pin${pinClass}" data-id="${escAttr(n.id)}" aria-label="${escAttr(String(pinLabel))}" title="${escAttr(String(pinLabel))}">${n.pinned ? "★" : "☆"}</button>` +
`</div>`
);
}
function renderEventRow(e: CardEventPreview): string {
const kindLabel = t(`projects.cards.event.kind.${e.kind}` as never) || e.kind;
const dateStr = fmtDate(e.event_date);
const status = e.status ? ` <span class="projects-card-event-status entity-status-chip entity-status-${escAttr(e.status)}">${escHTML(String(tDyn(`projects.filter.status.${e.status}`) || e.status))}</span>` : "";
const actor = e.actor_name ? ` <span class="projects-card-event-actor">${escHTML(e.actor_name)}</span>` : "";
return (
`<a class="projects-card-event-row" href="${escAttr(e.route)}" title="${escAttr(e.title)}">` +
`<span class="projects-card-event-date">${escHTML(dateStr)}</span>` +
`<span class="projects-card-event-kind">${escHTML(String(kindLabel))}</span>` +
`<span class="projects-card-event-title">${escHTML(e.title)}</span>` +
status +
actor +
`</a>`
);
}
function clampCount(n: number | undefined): number {
if (n === undefined) return 3;
if (n < 1) return 1;
if (n > 5) return 5;
return Math.floor(n);
}
// ----------------------------------------------------------------------
// Card-level event handlers (pin click + edit-mode drag/check/count)
// ----------------------------------------------------------------------
function attachCardHandlers(grid: HTMLElement) {
// Pin star (always-active, edit mode or not).
grid.querySelectorAll<HTMLButtonElement>(".projects-card-pin").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const id = btn.dataset.id!;
void togglePin(id, btn);
});
});
if (!editMode || !editDraft) return;
// Edit-mode: visibility checkbox per fact.
grid.querySelectorAll<HTMLInputElement>(".projects-cards-edit-vis").forEach((cb) => {
cb.addEventListener("change", () => {
if (!editDraft) return;
const key = cb.dataset.key as FactKey;
const f = editDraft.facts.find((x) => x.key === key);
if (!f) return;
f.visible = cb.checked;
// The first VISIBLE fact must remain "title-row" — gate this.
// Easier approach: always keep title-row visible regardless of click.
const titleRow = editDraft.facts.find((x) => x.key === "title-row");
if (titleRow) titleRow.visible = true;
rerender();
});
});
// Edit-mode: count steppers for next-events / recent-verlauf.
grid.querySelectorAll<HTMLInputElement>(".projects-cards-edit-count").forEach((inp) => {
inp.addEventListener("change", () => {
if (!editDraft) return;
const key = inp.dataset.key as FactKey;
const f = editDraft.facts.find((x) => x.key === key);
if (!f) return;
const v = Math.max(1, Math.min(5, parseInt(inp.value, 10) || 3));
f.count = v;
rerender();
});
});
// Edit-mode: HTML5 drag-and-drop for fact reordering. We attach handlers
// on each .projects-cards-edit-fact <li>; dragover on <ul>; drop reorders
// the editDraft.facts array.
grid.querySelectorAll<HTMLLIElement>(".projects-cards-edit-fact").forEach((li) => {
li.addEventListener("dragstart", (ev) => {
const key = li.dataset.key!;
ev.dataTransfer?.setData("text/plain", key);
li.classList.add("is-dragging");
});
li.addEventListener("dragend", () => li.classList.remove("is-dragging"));
li.addEventListener("dragover", (ev) => {
ev.preventDefault();
li.classList.add("is-drop-target");
});
li.addEventListener("dragleave", () => li.classList.remove("is-drop-target"));
li.addEventListener("drop", (ev) => {
ev.preventDefault();
li.classList.remove("is-drop-target");
const fromKey = ev.dataTransfer?.getData("text/plain") as FactKey | undefined;
const toKey = li.dataset.key as FactKey;
if (!fromKey || !toKey || fromKey === toKey) return;
reorderFacts(fromKey, toKey);
rerender();
});
});
}
function reorderFacts(fromKey: FactKey, toKey: FactKey) {
if (!editDraft) return;
const fromIdx = editDraft.facts.findIndex((f) => f.key === fromKey);
const toIdx = editDraft.facts.findIndex((f) => f.key === toKey);
if (fromIdx < 0 || toIdx < 0) return;
const [moved] = editDraft.facts.splice(fromIdx, 1);
editDraft.facts.splice(toIdx, 0, moved);
// Server validator requires title-row to be the first visible fact.
// Pull it to the top if it's now somewhere else.
const trIdx = editDraft.facts.findIndex((f) => f.key === "title-row");
if (trIdx > 0) {
const [tr] = editDraft.facts.splice(trIdx, 1);
editDraft.facts.unshift(tr);
}
}
async function togglePin(projectID: string, btn: HTMLElement) {
const wasPinned = btn.classList.contains("is-pinned");
btn.classList.toggle("is-pinned", !wasPinned);
btn.textContent = !wasPinned ? "★" : "☆";
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}/pin`, {
method: wasPinned ? "DELETE" : "POST",
});
if (!resp.ok && resp.status !== 201 && resp.status !== 204) {
btn.classList.toggle("is-pinned", wasPinned);
btn.textContent = wasPinned ? "★" : "☆";
return;
}
// Update tree cache in place so re-renders show the new state.
const update = (n: ProjectTreeNode) => {
if (n.id === projectID) n.pinned = !wasPinned;
n.children.forEach(update);
};
treeCache.forEach(update);
} catch {
btn.classList.toggle("is-pinned", wasPinned);
btn.textContent = wasPinned ? "★" : "☆";
}
}
// ----------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------
function fmtDate(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
} catch {
return iso;
}
}
function escHTML(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function escAttr(s: string): string {
return s.replace(/[&<>"']/g, (c) => {
switch (c) {
case "&": return "&amp;";
case "<": return "&lt;";
case ">": return "&gt;";
case '"': return "&quot;";
case "'": return "&#39;";
}
return c;
});
}
// Avoid an "unused" warning from the type-only import.
const _unusedFactKeys: FactKey[] = ALL_FACT_KEYS;
void _unusedFactKeys;

View File

@@ -2,6 +2,7 @@ import { initI18n, onLangChange, t } from "./i18n";
import { initSidebar } from "./sidebar";
import { initProjectTree, refreshProjectTree, rerenderProjectTree } from "./project-tree";
import { renderFlatList, ProjectFlatRow } from "./projects-flat";
import { renderCardsView, teardownCardsView } from "./projects-cards";
// /projects orchestrator (t-paliad-149).
//
@@ -15,7 +16,7 @@ import { renderFlatList, ProjectFlatRow } from "./projects-flat";
// - project-tree.ts for tree mode
// - projects-flat.ts for flat-table mode
type ViewMode = "tree" | "flat";
type ViewMode = "tree" | "cards" | "flat";
type Scope = "all" | "mine" | "pinned";
interface Chips {
@@ -60,8 +61,12 @@ function loadStoredState(): State | null {
chips?: { scope?: Scope; status?: string[]; type?: string[]; hasOpenDeadlines?: boolean };
searchQuery?: string;
};
const viewMode: ViewMode =
parsed.viewMode === "flat" ? "flat" :
parsed.viewMode === "cards" ? "cards" :
"tree";
return {
viewMode: parsed.viewMode === "flat" ? "flat" : "tree",
viewMode,
chips: {
scope: parsed.chips?.scope === "mine" || parsed.chips?.scope === "pinned" ? parsed.chips.scope : "all",
status: new Set(parsed.chips?.status || []),
@@ -97,7 +102,7 @@ function saveState() {
function applyURL() {
const url = new URL(window.location.href);
const v = url.searchParams.get("view");
if (v === "tree" || v === "flat") state.viewMode = v;
if (v === "tree" || v === "flat" || v === "cards") state.viewMode = v;
const sc = url.searchParams.get("scope");
if (sc === "mine" || sc === "pinned" || sc === "all") state.chips.scope = sc;
const status = url.searchParams.get("status");
@@ -218,12 +223,15 @@ function clearAllChips() {
async function render() {
const treeWrap = document.getElementById("projekt-tree-wrap")!;
const tableWrap = document.getElementById("entity-table-wrap")!;
const cardsWrap = document.getElementById("projects-cards-wrap")!;
const empty = document.getElementById("entity-empty")!;
const emptyFiltered = document.getElementById("entity-empty-filtered")!;
if (state.viewMode === "tree") {
teardownCardsView();
treeWrap.style.display = "block";
tableWrap.style.display = "none";
cardsWrap.style.display = "none";
empty.style.display = "none";
emptyFiltered.style.display = "none";
const container = document.getElementById("projekt-tree-container") as HTMLElement;
@@ -231,7 +239,17 @@ async function render() {
return;
}
if (state.viewMode === "cards") {
treeWrap.style.display = "none";
tableWrap.style.display = "none";
empty.style.display = "none";
emptyFiltered.style.display = "none";
await renderCardsView({ treeParams: treeParams() });
return;
}
// Flat-list mode. Reuses /api/projects (existing flat endpoint).
teardownCardsView();
treeWrap.style.display = "none";
if (!flatRows) {
flatRows = await loadFlatRows();
@@ -359,7 +377,7 @@ function initViewSegment() {
document.querySelectorAll<HTMLButtonElement>(".projects-view-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const v = btn.dataset.view as ViewMode;
if (v !== "tree" && v !== "flat") return;
if (v !== "tree" && v !== "flat" && v !== "cards") return;
if (state.viewMode === v) return;
state.viewMode = v;
syncURL();

View File

@@ -1439,6 +1439,55 @@ export type I18nKey =
| "partner_unit.none"
| "partner_unit.subtitle"
| "projects.cancel"
| "projects.cards.deadline_open"
| "projects.cards.deadline_overdue"
| "projects.cards.empty"
| "projects.cards.event.kind.appointment"
| "projects.cards.event.kind.deadline"
| "projects.cards.event.kind.project_event"
| "projects.cards.layout.delete"
| "projects.cards.layout.delete.confirm"
| "projects.cards.layout.delete.default_blocked"
| "projects.cards.layout.density"
| "projects.cards.layout.density.compact"
| "projects.cards.layout.density.roomy"
| "projects.cards.layout.discard"
| "projects.cards.layout.edit"
| "projects.cards.layout.fact.client-matter"
| "projects.cards.layout.fact.count"
| "projects.cards.layout.fact.deadline-counts"
| "projects.cards.layout.fact.last-activity-at"
| "projects.cards.layout.fact.move_down"
| "projects.cards.layout.fact.move_up"
| "projects.cards.layout.fact.next-events"
| "projects.cards.layout.fact.parent-path"
| "projects.cards.layout.fact.recent-verlauf"
| "projects.cards.layout.fact.reference"
| "projects.cards.layout.fact.status-chip"
| "projects.cards.layout.fact.team-chips"
| "projects.cards.layout.fact.title-row"
| "projects.cards.layout.fact.toggle.hide"
| "projects.cards.layout.fact.toggle.show"
| "projects.cards.layout.fact.type-chip"
| "projects.cards.layout.grid"
| "projects.cards.layout.grid.2"
| "projects.cards.layout.grid.3"
| "projects.cards.layout.grid.4"
| "projects.cards.layout.grid.auto"
| "projects.cards.layout.is_default"
| "projects.cards.layout.label"
| "projects.cards.layout.new"
| "projects.cards.layout.new.prompt"
| "projects.cards.layout.rename"
| "projects.cards.layout.save"
| "projects.cards.layout.set_default"
| "projects.cards.next_events"
| "projects.cards.no_next_events"
| "projects.cards.no_recent"
| "projects.cards.recent_verlauf"
| "projects.cards.show_all_levels"
| "projects.cards.show_all_levels.hint"
| "projects.cards.team"
| "projects.chip.all"
| "projects.chip.has_open_deadlines"
| "projects.chip.mine"

View File

@@ -65,10 +65,48 @@ export function renderProjects(): string {
<div className="projects-view-segment" role="tablist" aria-label="Ansicht">
<button type="button" className="projects-view-btn" data-view="tree" data-i18n="projects.toolbar.view.tree">Baum</button>
<button type="button" className="projects-view-btn" data-view="cards" data-i18n="projects.toolbar.view.cards">Karten</button>
<button type="button" className="projects-view-btn" data-view="flat" data-i18n="projects.toolbar.view.flat">Liste</button>
</div>
</div>
<div className="projects-cards-toolbar" id="projects-cards-toolbar" style="display:none">
<div className="projects-cards-layout-picker">
<label className="projects-cards-layout-label" data-i18n="projects.cards.layout.label">Ansicht</label>
<select id="projects-cards-layout-select" className="entity-select" />
<button type="button" id="projects-cards-layout-edit" className="btn-secondary" data-i18n="projects.cards.layout.edit">Bearbeiten</button>
<button type="button" id="projects-cards-layout-new" className="btn-secondary" data-i18n="projects.cards.layout.new">Neue Ansicht</button>
</div>
<label className="projects-cards-show-all-levels">
<input type="checkbox" id="projects-cards-show-all-levels" />
<span data-i18n="projects.cards.show_all_levels">Alle Ebenen anzeigen</span>
</label>
</div>
<div className="projects-cards-edit-toolbar" id="projects-cards-edit-toolbar" style="display:none">
<div className="projects-cards-edit-controls">
<label data-i18n="projects.cards.layout.density">Dichte</label>
<select id="projects-cards-edit-density">
<option value="roomy" data-i18n="projects.cards.layout.density.roomy">Geräumig</option>
<option value="compact" data-i18n="projects.cards.layout.density.compact">Kompakt</option>
</select>
<label data-i18n="projects.cards.layout.grid">Spalten</label>
<select id="projects-cards-edit-grid">
<option value="auto" data-i18n="projects.cards.layout.grid.auto">Auto</option>
<option value="2" data-i18n="projects.cards.layout.grid.2">2</option>
<option value="3" data-i18n="projects.cards.layout.grid.3">3</option>
<option value="4" data-i18n="projects.cards.layout.grid.4">4</option>
</select>
</div>
<div className="projects-cards-edit-actions">
<button type="button" id="projects-cards-edit-rename" className="btn-secondary" data-i18n="projects.cards.layout.rename">Umbenennen</button>
<button type="button" id="projects-cards-edit-delete" className="btn-secondary" data-i18n="projects.cards.layout.delete">Löschen</button>
<button type="button" id="projects-cards-edit-set-default" className="btn-secondary" data-i18n="projects.cards.layout.set_default">Als Standard festlegen</button>
<button type="button" id="projects-cards-edit-discard" className="btn-secondary" data-i18n="projects.cards.layout.discard">Verwerfen</button>
<button type="button" id="projects-cards-edit-save" className="btn-primary" data-i18n="projects.cards.layout.save">Speichern</button>
</div>
</div>
<div className="projects-chip-row" id="projects-chip-row" role="group" aria-label="Filter">
<button type="button" className="projects-chip" data-chip="all" data-i18n="projects.chip.all">Alle</button>
<button type="button" className="projects-chip" data-chip="mine" data-i18n="projects.chip.mine">Nur meine</button>
@@ -120,6 +158,10 @@ export function renderProjects(): string {
<div id="projekt-tree-container" />
</div>
<div className="projects-cards-wrap" id="projects-cards-wrap" style="display:none">
<div id="projects-cards-grid" className="projects-cards-grid" />
</div>
<div className="entity-empty" id="entity-empty" style="display:none">
<h2 data-i18n="projects.empty.title">Noch kein Projekt angelegt</h2>
<p data-i18n="projects.empty.hint">

View File

@@ -11533,3 +11533,293 @@ dialog.quick-add-sheet::backdrop {
}
.projects-view-segment { align-self: flex-start; }
}
/* ============================================================================
/projects Cards view (t-paliad-149 PR 2)
============================================================================ */
.projects-cards-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
margin: 0.5rem 0;
}
.projects-cards-layout-picker {
display: flex;
gap: 0.5rem;
align-items: center;
}
.projects-cards-layout-label {
font-size: 0.85rem;
color: var(--color-text-muted);
}
.projects-cards-show-all-levels {
display: inline-flex;
gap: 0.5rem;
align-items: center;
font-size: 0.9rem;
margin-left: auto;
}
.projects-cards-edit-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
padding: 0.6rem 0.85rem;
margin: 0.5rem 0;
background: rgb(var(--hlc-lime-rgb) / 0.15);
border: 1px dashed var(--color-accent);
border-radius: var(--radius-md);
}
.projects-cards-edit-controls,
.projects-cards-edit-actions {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.projects-cards-edit-actions {
margin-left: auto;
}
/* Cards grid */
.projects-cards-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
margin-top: 0.5rem;
}
.projects-cards-grid.is-grid-2 { grid-template-columns: repeat(2, 1fr); }
.projects-cards-grid.is-grid-3 { grid-template-columns: repeat(3, 1fr); }
.projects-cards-grid.is-grid-4 { grid-template-columns: repeat(4, 1fr); }
.projects-cards-grid.is-density-compact .projects-card {
padding: 0.6rem 0.75rem;
gap: 0.35rem;
}
.projects-cards-grid.is-density-compact .projects-card-section {
margin-top: 0.25rem;
}
.projects-cards-empty {
grid-column: 1 / -1;
padding: 2rem;
text-align: center;
color: var(--color-text-muted);
}
/* One card */
.projects-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.85rem 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.projects-card.is-edit-mode {
border-style: dashed;
border-color: var(--color-accent);
}
.projects-card-title-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.projects-card-icon {
color: var(--color-accent);
}
.projects-card-title {
flex: 1;
color: var(--color-text);
text-decoration: none;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.projects-card-title:hover {
text-decoration: underline;
}
.projects-card-pin {
flex: 0 0 auto;
background: transparent;
border: 0;
color: var(--color-text-muted);
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
padding: 0.1rem 0.25rem;
}
.projects-card-pin.is-pinned {
color: var(--color-accent);
}
.projects-card-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.projects-card-cm {
font-family: var(--font-mono, monospace);
font-size: 0.8rem;
}
.projects-card-section {
margin-top: 0.25rem;
}
.projects-card-section-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: 0.25rem;
}
.projects-card-empty {
font-size: 0.85rem;
color: var(--color-text-muted);
font-style: italic;
}
.projects-card-event-row {
display: grid;
grid-template-columns: 80px auto 1fr auto auto;
gap: 0.4rem;
align-items: center;
padding: 0.2rem 0.3rem;
margin: 0.1rem 0;
text-decoration: none;
color: var(--color-text);
border-radius: 4px;
font-size: 0.85rem;
}
.projects-card-event-row:hover {
background: var(--color-surface-muted);
}
.projects-card-event-date {
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.projects-card-event-kind {
color: var(--color-text-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.projects-card-event-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.projects-card-team {
margin-top: 0.4rem;
}
.projects-card-team-initial {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--color-surface-muted);
font-size: 0.7rem;
font-weight: 600;
margin-right: -4px;
border: 2px solid var(--color-surface);
}
.projects-card-team-overflow {
margin-left: 0.5rem;
font-size: 0.75rem;
color: var(--color-text-muted);
}
/* Edit-mode fact list inside each card */
.projects-cards-edit-facts {
list-style: none;
margin: 0.5rem 0 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
border-top: 1px dashed var(--color-border);
padding-top: 0.5rem;
}
.projects-cards-edit-fact {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.25rem 0.4rem;
border-radius: 4px;
background: var(--color-surface-muted);
cursor: grab;
}
.projects-cards-edit-fact.is-dragging {
opacity: 0.4;
}
.projects-cards-edit-fact.is-drop-target {
outline: 2px dashed var(--color-accent);
outline-offset: -2px;
}
.projects-cards-edit-handle {
color: var(--color-text-muted);
cursor: grab;
user-select: none;
}
.projects-cards-edit-label {
flex: 1;
font-size: 0.85rem;
}
.projects-cards-edit-count {
width: 50px;
padding: 0.15rem 0.3rem;
font-size: 0.85rem;
border: 1px solid var(--color-border);
border-radius: 4px;
}
@media (max-width: 600px) {
.projects-cards-grid {
grid-template-columns: 1fr !important;
}
.projects-cards-edit-toolbar {
flex-direction: column;
align-items: stretch;
}
}