diff --git a/app/globals.css b/app/globals.css index a6c1aa7..226c508 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,132 +1,304 @@ @import "tailwindcss"; -/* @plugin "tailwindcss-animate"; */ - @custom-variant dark (&:where(.dark, .dark *)); :root { - /* --- Light Mode (Indigo/Slate) --- */ - --background: #ffffff; - --foreground: #0f172a; /* Slate 900 */ - - --card: #ffffff; - --card-foreground: #0f172a; - - --popover: #ffffff; - --popover-foreground: #0f172a; - - /* Indigo 600 */ - --primary: #4f46e5; + color-scheme: light; + --background: #f2f5f9; + --foreground: #172033; + --card: rgba(255, 255, 255, 0.72); + --card-strong: rgba(255, 255, 255, 0.92); + --card-foreground: #172033; + --popover: #fbfcff; + --popover-foreground: #172033; + --primary: #3568e8; + --primary-strong: #2454ca; --primary-foreground: #ffffff; - - /* Slate 100 */ - --secondary: #f1f5f9; - --secondary-foreground: #0f172a; - - /* Slate 100 */ - --muted: #f1f5f9; - --muted-foreground: #64748b; /* Slate 500 */ - - /* Slate 100 */ - --accent: #f1f5f9; - --accent-foreground: #0f172a; - - /* Red 500 */ - --destructive: #ef4444; + --secondary: rgba(255, 255, 255, 0.58); + --secondary-foreground: #344158; + --muted: rgba(231, 236, 244, 0.72); + --muted-foreground: #68758a; + --accent: rgba(222, 232, 255, 0.76); + --accent-foreground: #2454ca; + --destructive: #d13f45; --destructive-foreground: #ffffff; - - /* Slate 200 */ - --border: #e2e8f0; - --input: #e2e8f0; - - /* Indigo 600 */ - --ring: #4f46e5; - - --radius: 0.5rem; + --destructive-soft: rgba(254, 226, 226, 0.76); + --success: #16845b; + --success-soft: rgba(209, 250, 229, 0.72); + --warning: #a75f08; + --warning-soft: rgba(254, 243, 199, 0.76); + --border: rgba(103, 120, 145, 0.16); + --border-strong: rgba(103, 120, 145, 0.26); + --input: rgba(255, 255, 255, 0.68); + --ring: rgba(53, 104, 232, 0.24); + --glass-border: rgba(255, 255, 255, 0.76); + --glass-highlight: rgba(255, 255, 255, 0.82); + --glass-shadow: rgba(33, 48, 76, 0.09); + --primary-glow: rgba(128, 171, 255, 0.24); + --secondary-glow: rgba(182, 225, 215, 0.2); + --radius-control: 0.75rem; + --radius-card: 1.5rem; } -/* Dark Mode Override */ .dark { - /* --- Dark Mode (Real Black Base) --- */ - --background: #000000; - --foreground: #ededed; - - --card: #000000; - --card-foreground: #ededed; - - --popover: #000000; - --popover-foreground: #ededed; - - /* Indigo 400 */ - --primary: #818cf8; - --primary-foreground: #ffffff; - - /* Zinc 900 */ - --secondary: #18181b; - --secondary-foreground: #ededed; - - /* Zinc 900 */ - --muted: #18181b; - --muted-foreground: #a1a1aa; /* Zinc 400 */ - - /* Zinc 900 */ - --accent: #18181b; - --accent-foreground: #ededed; - - /* Red 900 */ - --destructive: #7f1d1d; - --destructive-foreground: #ededed; - - /* Zinc 800 */ - --border: #27272a; - --input: #27272a; - - /* Indigo 500 */ - --ring: #6366f1; + color-scheme: dark; + --background: #0b111c; + --foreground: #e8edf5; + --card: rgba(22, 31, 45, 0.76); + --card-strong: rgba(27, 38, 54, 0.94); + --card-foreground: #e8edf5; + --popover: #192333; + --popover-foreground: #e8edf5; + --primary: #7da2ff; + --primary-strong: #a7beff; + --primary-foreground: #0d1b36; + --secondary: rgba(30, 42, 59, 0.72); + --secondary-foreground: #dbe4f2; + --muted: rgba(30, 42, 59, 0.66); + --muted-foreground: #9baac0; + --accent: rgba(63, 91, 153, 0.34); + --accent-foreground: #b9ccff; + --destructive: #ff8589; + --destructive-foreground: #34070a; + --destructive-soft: rgba(127, 29, 29, 0.34); + --success: #71d5aa; + --success-soft: rgba(6, 78, 59, 0.35); + --warning: #f0b85c; + --warning-soft: rgba(120, 53, 15, 0.32); + --border: rgba(151, 169, 195, 0.18); + --border-strong: rgba(151, 169, 195, 0.3); + --input: rgba(12, 20, 32, 0.56); + --ring: rgba(125, 162, 255, 0.28); + --glass-border: rgba(170, 190, 220, 0.15); + --glass-highlight: rgba(255, 255, 255, 0.06); + --glass-shadow: rgba(0, 0, 0, 0.34); + --primary-glow: rgba(48, 91, 180, 0.2); + --secondary-glow: rgba(38, 116, 103, 0.13); } -/* Map CSS variables to Tailwind Utility Classes */ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --color-card: var(--card); --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); - --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); - - --radius-lg: var(--radius); - --radius-md: calc(var(--radius) - 2px); - --radius-sm: calc(var(--radius) - 4px); - - /* Fonts (Optional/Example) */ - --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; - --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace; + --radius-lg: var(--radius-control); + --radius-md: calc(var(--radius-control) - 0.125rem); + --radius-sm: calc(var(--radius-control) - 0.25rem); + --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: SFMono-Regular, Consolas, "Liberation Mono", monospace; } -/* Base styles */ -body { +* { + border-color: var(--border); +} + +html { + min-width: 20rem; + scroll-behavior: smooth; background: var(--background); + font: 100%/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-optical-sizing: auto; +} + +body { + min-height: 100vh; + color: var(--foreground); + background: + radial-gradient(circle at 8% -4%, var(--primary-glow), transparent 31rem), + radial-gradient(circle at 92% 4%, var(--secondary-glow), transparent 29rem), + var(--background); + background-attachment: fixed; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + transition: color 220ms ease, background-color 220ms ease; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button:not(:disabled), +[role="button"]:not([aria-disabled="true"]) { + cursor: pointer; +} + +::selection { + color: var(--foreground); + background: var(--accent); +} + +.glass-surface { + border: 1px solid var(--glass-border); + background: var(--card); + box-shadow: + inset 0 1px 0 var(--glass-highlight), + 0 20px 60px var(--glass-shadow); + backdrop-filter: blur(24px) saturate(150%); + -webkit-backdrop-filter: blur(24px) saturate(150%); +} + +.glass-popover { + border: 1px solid var(--glass-border); + background: var(--popover); + box-shadow: + inset 0 1px 0 var(--glass-highlight), + 0 18px 48px var(--glass-shadow); + backdrop-filter: blur(28px) saturate(160%); + -webkit-backdrop-filter: blur(28px) saturate(160%); +} + +.control-surface { + border: 1px solid var(--border); + background: var(--input); + box-shadow: 0 1px 2px rgba(22, 32, 51, 0.04); +} + +.focus-ring:focus-visible, +.ui-input:focus-visible, +.ui-button:focus-visible { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 4px var(--ring); +} + +.ui-input { + min-height: 2.75rem; + width: 100%; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--input); color: var(--foreground); - font-family: var(--font-sans); -} \ No newline at end of file + padding: 0.625rem 0.75rem; + box-shadow: 0 1px 2px rgba(22, 32, 51, 0.04); + transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease; +} + +.ui-input::placeholder { + color: var(--muted-foreground); +} + +.ui-input:focus { + border-color: var(--primary); + background: var(--card-strong); + outline: none; + box-shadow: 0 0 0 4px var(--ring); +} + +.ui-button { + min-height: 2.5rem; + border-radius: var(--radius-control); + font-size: 0.875rem; + font-weight: 650; + letter-spacing: -0.01em; + transition: transform 100ms ease-out, color 140ms ease, background-color 140ms ease, + border-color 140ms ease, box-shadow 140ms ease, opacity 140ms ease; + -webkit-tap-highlight-color: transparent; +} + +.ui-button:active:not(:disabled) { + transform: scale(0.97); +} + +.ui-button:disabled { + cursor: not-allowed; + opacity: 0.45; + box-shadow: none; +} + +.materialize { + animation: materialize 180ms cubic-bezier(0.2, 0.8, 0.2, 1) both; + transform-origin: var(--material-origin, center top); +} + +@keyframes materialize { + from { + opacity: 0; + transform: translateY(-0.25rem) scale(0.98); + filter: blur(4px); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0); + } +} + +.subtle-scrollbar { + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + + .ui-button:active:not(:disabled) { + transform: none; + } +} + +@media (prefers-reduced-transparency: reduce) { + .glass-surface, + .glass-popover { + background: var(--card-strong); + backdrop-filter: none; + -webkit-backdrop-filter: none; + } +} + +@media (prefers-contrast: more) { + .glass-surface, + .glass-popover, + .control-surface, + .ui-input { + border-color: currentColor; + background: var(--card-strong); + } +} + +@media print { + @page { + size: landscape; + margin: 10mm; + } + + body { + color: #111827; + background: #ffffff; + } + + .print\:hidden { + display: none !important; + } + + .glass-surface { + border: 0; + background: #ffffff; + box-shadow: none; + backdrop-filter: none; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index e362350..b62b37f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,22 +1,11 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "@/components/ThemeProvider"; import { DialogProvider } from "@/components/DialogProvider"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "FSR Protokoll Editor", + description: "Sitzungsprotokolle gemeinsam, strukturiert und sicher erfassen.", }; export default function RootLayout({ @@ -26,9 +15,7 @@ export default function RootLayout({ }>) { return ( - + void; }; -const fieldClassName = - "w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-border dark:bg-zinc-950 dark:text-foreground dark:placeholder:text-muted-foreground dark:focus:border-primary dark:focus:ring-indigo-950"; +const fieldClassName = "ui-input text-sm"; export function CommandArgumentForm({ command, onSubmit, onCancel }: Props) { const initialState = useMemo(() => createInitialFormState(command), [command]); @@ -53,12 +53,10 @@ export function CommandArgumentForm({ command, onSubmit, onCancel }: Props) { const firstFocusableArgumentName = command.arguments[0]?.name ?? null; return ( -
-
-

- /{command.name} -

-

+ +

+ /{command.name} +

{command.description}

@@ -70,11 +68,9 @@ export function CommandArgumentForm({ command, onSubmit, onCancel }: Props) { return (
- - {argument.label} - + {argument.label} {argument.description && ( - + {argument.description} )} @@ -131,19 +127,19 @@ export function CommandArgumentForm({ command, onSubmit, onCancel }: Props) {
- - +
); @@ -164,11 +160,9 @@ function ScalarField({ }) { return ( ); } diff --git a/components/CommandArgumentListInput.tsx b/components/CommandArgumentListInput.tsx index 4785281..9fc83ad 100644 --- a/components/CommandArgumentListInput.tsx +++ b/components/CommandArgumentListInput.tsx @@ -8,9 +8,9 @@ import { listErrorKey, type CommandArgumentFormErrors, } from "@/common/commandArguments"; +import { Button, FieldLabel } from "@/components/ui/Primitives"; -const fieldClassName = - "w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-border dark:bg-zinc-950 dark:text-foreground dark:placeholder:text-muted-foreground dark:focus:border-primary dark:focus:ring-indigo-950"; +const fieldClassName = "ui-input text-sm"; type Props = { schema: ListCommandArgumentSchema; @@ -62,27 +62,29 @@ export function CommandArgumentListInput({ return (
{errors[schema.name] && ( - {errors[schema.name]} + {errors[schema.name]} )} {items.map((item, itemIndex) => (
- + {itemLabel} {itemIndex + 1} {canRemove && ( - + )}
@@ -106,14 +108,16 @@ export function CommandArgumentListInput({ ))} {canAdd && ( - + )}
); @@ -134,9 +138,7 @@ function ListField({ }) { return ( ); } diff --git a/components/CommandSuggestions.tsx b/components/CommandSuggestions.tsx index 34154f8..bd19f66 100644 --- a/components/CommandSuggestions.tsx +++ b/components/CommandSuggestions.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { createPortal } from "react-dom"; import { Terminal } from "lucide-react"; import type { RegisteredSessionCommand } from "@/common/sessionCommands"; @@ -25,16 +26,16 @@ export function CommandSuggestions({ }: Props) { if (!isOpen || commands.length === 0) return null; - return ( + const palette = (
-
+
{commands.map((command, index) => { const isActive = index === activeIndex; return ( @@ -45,18 +46,18 @@ export function CommandSuggestions({ event.preventDefault(); onPick(command); }} - className={`w-full px-3 py-2 text-left flex items-start gap-3 transition-colors ${ + className={`focus-ring flex min-h-12 w-full items-start gap-3 rounded-lg px-3 py-2 text-left transition-colors ${ isActive - ? "bg-indigo-50 dark:bg-indigo-950/40 text-indigo-700 dark:text-indigo-200" - : "text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-zinc-800" + ? "bg-accent text-accent-foreground" + : "text-secondary-foreground hover:bg-muted" }`} > - + /{command.name} - + {command.description} @@ -66,4 +67,10 @@ export function CommandSuggestions({
); + + if (position === "fixed" && typeof document !== "undefined") { + return createPortal(palette, document.body); + } + + return palette; } diff --git a/components/DialogProvider.tsx b/components/DialogProvider.tsx index aa9f92e..a06e3b6 100644 --- a/components/DialogProvider.tsx +++ b/components/DialogProvider.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useCallback } from "react"; import { Modal } from "@/components/Modal"; +import { Button } from "@/components/ui/Primitives"; type DialogType = "alert" | "confirm" | "prompt"; @@ -143,7 +144,7 @@ export const DialogProvider = ({ children }: { children: React.ReactNode }) => { width="sm" >
-

+

{dialog.message}

@@ -151,7 +152,7 @@ export const DialogProvider = ({ children }: { children: React.ReactNode }) => { setInputValue(e.target.value)} onKeyDown={handleKeyDown} @@ -161,23 +162,19 @@ export const DialogProvider = ({ children }: { children: React.ReactNode }) => {
{dialog.type !== "alert" && ( - + )} - +
diff --git a/components/Header.tsx b/components/Header.tsx index 5a43816..32ff29e 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,6 +1,14 @@ import React from "react"; -import { FileText, Clipboard, Upload, Save, RotateCcw, Send } from "lucide-react"; +import { + Clipboard, + Download, + FileText, + RotateCcw, + Send, + Upload, +} from "lucide-react"; import { ThemeToggle } from "./ThemeToggle"; +import { Button } from "@/components/ui/Primitives"; interface HeaderProps { handlePasteFromClipboard: () => void; @@ -22,32 +30,43 @@ export const Header = ({ resetProtocol, }: HeaderProps) => { return ( -
-
-
-
- +
+
+
+
+ +
+
+

+ FSR Informatik +

+

+ Protokoll Editor +

-

- FSR Protokoll Editor -

-
+ +
- - + + + Einfügen + - - + - + + + An Discord +
diff --git a/components/MemberSuggestionDropdown.tsx b/components/MemberSuggestionDropdown.tsx index a490ce7..7e98b84 100644 --- a/components/MemberSuggestionDropdown.tsx +++ b/components/MemberSuggestionDropdown.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { createPortal } from "react-dom"; import { Member } from "@/common/types"; import { MemberSuggestionList } from "@/components/MemberSuggestionList"; @@ -27,13 +28,13 @@ export function MemberSuggestionDropdown({ }: Props) { if (!isOpen || members.length === 0) return null; - return ( + const dropdown = (
); + + if (position === "fixed" && typeof document !== "undefined") { + return createPortal(dropdown, document.body); + } + + return dropdown; } diff --git a/components/MemberSuggestionList.tsx b/components/MemberSuggestionList.tsx index d16a9dc..842191d 100644 --- a/components/MemberSuggestionList.tsx +++ b/components/MemberSuggestionList.tsx @@ -35,14 +35,14 @@ export function MemberSuggestionList({ e.preventDefault(); onPick(member); }} - className={`w-full text-left px-3 py-2 text-md text-slate-700 dark:text-foreground hover:bg-indigo-50 dark:hover:bg-zinc-800 flex justify-between items-center ${ - idx === activeIndex ? "bg-indigo-50 dark:bg-zinc-800" : "" + className={`focus-ring flex min-h-11 w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm text-secondary-foreground transition-colors hover:bg-muted ${ + idx === activeIndex ? "bg-accent text-accent-foreground" : "" }`} >
{member.name} {member.aliases && member.aliases.length > 0 && ( - + {member.aliases.join(", ")} )} diff --git a/components/MetaSection.tsx b/components/MetaSection.tsx index 756bd2b..e434dd3 100644 --- a/components/MetaSection.tsx +++ b/components/MetaSection.tsx @@ -1,7 +1,8 @@ import React from "react"; -import { Users, Calendar } from "lucide-react"; +import { CalendarDays, Clock3, MapPin, PenLine, Users } from "lucide-react"; import { TagInput } from "./TagInput"; import { Member } from "@/common/types"; +import { Button, Eyebrow, FieldLabel, Surface } from "@/components/ui/Primitives"; interface MetaSectionProps { fsrMembers: string[]; @@ -12,16 +13,78 @@ interface MetaSectionProps { availableAssocMembers: Member[]; protocolant: string[]; setProtocolant: (val: string[]) => void; - meta: { Date: string; Start: string; Ende: string; Location: string; Room: string }; - setMeta: (val: { Date: string; Start: string; Ende: string; Location: string; Room: string }) => void; + meta: { + Date: string; + Start: string; + Ende: string; + Location: string; + Room: string; + }; + setMeta: (val: { + Date: string; + Start: string; + Ende: string; + Location: string; + Room: string; + }) => void; } const getCurrentTimeString = (): string => { const now = new Date(); - const hours = now.getHours().toString().padStart(2, "0"); - const minutes = now.getMinutes().toString().padStart(2, "0"); - const seconds = now.getSeconds().toString().padStart(2, "0"); - return `${hours}:${minutes}:${seconds}`; + return [now.getHours(), now.getMinutes(), now.getSeconds()] + .map((part) => part.toString().padStart(2, "0")) + .join(":"); +}; + +function PanelTitle({ + icon, + title, + description, +}: { + icon: React.ReactNode; + title: string; + description: string; +}) { + return ( +
+
+ {icon} +
+
+

+ {title} +

+

+ {description} +

+
+
+ ); +} + +function TextField({ + label, + value, + onChange, + placeholder, +}: { + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; +}) { + return ( + + ); } export const MetaSection = ({ @@ -37,122 +100,125 @@ export const MetaSection = ({ setMeta, }: MetaSectionProps) => { return ( -
-
-

- Anwesenheit +
+
+

+ Sitzungsdaten

- -
-
-

- Details -

-
- - setMeta({ ...meta, Date: e.target.value })} - className="w-full p-2 rounded-lg bg-slate-50 dark:bg-zinc-900 text-slate-900 dark:text-foreground border border-slate-200 dark:border-border focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500" + +
+ + } + title="Anwesenheit" + description="Mitglieder, Gäste und Protokollführung" /> -
-
-
- - {console.log(e.target.value); setMeta({ ...meta, Start: e.target.value })}} - className="w-full p-2 rounded-lg bg-slate-50 dark:bg-zinc-900 text-slate-900 dark:text-foreground border border-slate-200 dark:border-border focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500" +
+ -
-
- - setMeta({ ...meta, Ende: e.target.value })} - className="w-full p-2 rounded-lg bg-slate-50 dark:bg-zinc-900 text-slate-900 dark:text-foreground border border-slate-200 dark:border-border focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500" + +
-
-
- - -
-
- - setMeta({ ...meta, Location: e.target.value })} - className="w-full p-2 rounded-lg bg-slate-50 dark:bg-zinc-900 text-slate-900 dark:text-foreground border border-slate-200 dark:border-border focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500" - /> + - - setMeta({ ...meta, Room: e.target.value })} - className="w-full p-2 rounded-lg bg-slate-50 dark:bg-zinc-900 text-slate-900 dark:text-foreground border border-slate-200 dark:border-border focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500" + + } + title="Termin & Ort" + description="Zeitliche und räumliche Angaben" /> -
+
+ + +
+ {(["Start", "Ende"] as const).map((field) => ( +
+ + + + + + setMeta({ ...meta, [field]: event.target.value }) + } + className="ui-input font-mono text-sm" + /> +
+ ))} +
+ +
+
+
+ +
+ setMeta({ ...meta, Location })} + placeholder="z. B. Institut" + /> +
+
+
+ +
+ setMeta({ ...meta, Room })} + placeholder="z. B. 0.70" + /> +
+
+
+
); diff --git a/components/Modal.tsx b/components/Modal.tsx index 908aad0..dc6bc64 100644 --- a/components/Modal.tsx +++ b/components/Modal.tsx @@ -1,7 +1,9 @@ "use client"; import React, { useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; import { X } from "lucide-react"; +import { Button } from "@/components/ui/Primitives"; interface ModalProps { isOpen: boolean; @@ -19,6 +21,7 @@ export const Modal = ({ width = "md", }: ModalProps) => { const modalRef = useRef(null); + const titleId = React.useId(); useEffect(() => { const handleEscape = (e: KeyboardEvent) => { @@ -26,17 +29,25 @@ export const Modal = ({ }; if (isOpen) { + const previousOverflow = document.body.style.overflow; + const previousFocus = document.activeElement as HTMLElement | null; document.addEventListener("keydown", handleEscape); document.body.style.overflow = "hidden"; + requestAnimationFrame(() => modalRef.current?.focus()); + + return () => { + document.removeEventListener("keydown", handleEscape); + document.body.style.overflow = previousOverflow; + previousFocus?.focus(); + }; } return () => { document.removeEventListener("keydown", handleEscape); - document.body.style.overflow = "unset"; }; }, [isOpen, onClose]); - if (!isOpen) return null; + if (!isOpen || typeof document === "undefined") return null; const widthClasses = { sm: "max-w-sm", @@ -45,27 +56,38 @@ export const Modal = ({ xl: "max-w-xl", }; - return ( -
+ return createPortal( +
{ + if (event.target === event.currentTarget) onClose(); + }} + >
-
-

+
+

{title}

- + +
-
{children}
+
{children}

-
+
, + document.body ); }; diff --git a/components/ProtocolEditor.tsx b/components/ProtocolEditor.tsx index 1ff6a92..bfe7556 100644 --- a/components/ProtocolEditor.tsx +++ b/components/ProtocolEditor.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { Plus } from "lucide-react"; +import { AtSign, FilePlus2, ListChecks, Plus, Terminal } from "lucide-react"; import { DndContext, closestCenter, @@ -19,8 +19,15 @@ import { import { useProtocol } from "@/hooks/useProtocol"; import { Header } from "@/components/Header"; import { MetaSection } from "@/components/MetaSection"; +import { SessionOutline } from "@/components/SessionOutline"; import { SortableSessionItem } from "@/components/SortableSessionItem"; import { Member } from "@/common/types"; +import { + Badge, + Button, + SectionHeading, + Surface, +} from "@/components/ui/Primitives"; interface ProtocolEditorProps { availableFsrMembers: Member[]; @@ -78,8 +85,13 @@ export function ProtocolEditor({ }) ); + const pointCount = React.useMemo( + () => sessionItems.reduce((total, item) => total + item.points.length, 0), + [sessionItems] + ); + return ( -
+
-
+
- {/* Meeting Content */} -
-
-

- Sitzungsinhalte -

- -
+
+ + +
+ +
+ + +
+ {/*
+ + + {sessionItems.length}{" "} + {sessionItems.length === 1 ? "Thema" : "Themen"} + + {pointCount} Punkte + + + Personen erwähnen + + + Befehle einfügen + + +
*/} +
- -
- item.id)} - strategy={verticalListSortingStrategy} - > - {sessionItems.map((item) => ( - - ))} - + +
+ item.id)} + strategy={verticalListSortingStrategy} + > + {sessionItems.map((item, index) => ( + + ))} + - {sessionItems.length === 0 && ( -
-

- Keine Themen vorhanden. Füge ein Thema hinzu oder importiere - ein Protokoll. -

-
- )} -
-
-
+ {sessionItems.length === 0 && ( + +
+ +
+

+ Bereit für die Sitzung +

+

+ Lege das erste Thema an oder importiere ein vorhandenes + YAML-Protokoll über die Werkzeugleiste. +

+ +
+ )} +
+ +
+
); diff --git a/components/SessionOutline.tsx b/components/SessionOutline.tsx new file mode 100644 index 0000000..26825be --- /dev/null +++ b/components/SessionOutline.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { FileText, ListTree } from "lucide-react"; + +import { SessionItem } from "@/common/types"; +import { Badge, Eyebrow, Surface } from "@/components/ui/Primitives"; + +interface SessionOutlineProps { + sessionItems: SessionItem[]; +} + +const SHOULD_SHOW_POINT_LABELS = false; + +const getPointLabel = (point: string, index: number) => { + const label = point.trim().replace(/\s+/g, " "); + return label || `Punkt ${index + 1}`; +}; + +export function SessionOutline({ sessionItems }: SessionOutlineProps) { + return ( + +
+
+
+ Navigation +

+ + Gliederung +

+
+ {/* + {sessionItems.length} + */} +
+
+ + +
+ ); +} diff --git a/components/SortableSessionItem.tsx b/components/SortableSessionItem.tsx index a9bf0c6..64642a0 100644 --- a/components/SortableSessionItem.tsx +++ b/components/SortableSessionItem.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import { GripVertical, Trash2, Plus } from "lucide-react"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { Member, SessionItem } from "@/common/types"; @@ -15,9 +14,12 @@ import { CommandSuggestions } from "@/components/CommandSuggestions"; import { MemberSuggestions } from "@/components/MemberSuggestions"; import { Modal } from "@/components/Modal"; import { useSuggestionNavigation } from "@/hooks/useSuggestionNavigation"; +import { SessionTopicHeader } from "@/components/session/SessionTopicHeader"; +import { SessionPointList } from "@/components/session/SessionPointList"; interface SortableSessionItemProps { item: SessionItem; + position: number; memberSuggestions: Member[]; updateTopicTitle: (id: string, val: string) => void; removeTopic: (id: string) => void; @@ -51,6 +53,7 @@ type CommandState = { export const SortableSessionItem = ({ item, + position, memberSuggestions, updateTopicTitle, removeTopic, @@ -68,10 +71,11 @@ export const SortableSessionItem = ({ } = useSortable({ id: item.id }); const style = { - transform: CSS.Transform.toString(transform), + transform: CSS.Translate.toString(transform), transition, - zIndex: isDragging ? 50 : "auto", // Bring dragged item to front - opacity: isDragging ? 0.9 : 1, + zIndex: isDragging ? 30 : "auto", + opacity: isDragging ? 0.78 : 1, + willChange: isDragging ? "transform" : undefined, }; const topicRef = useRef(null); @@ -407,10 +411,13 @@ export const SortableSessionItem = ({ return (
{command.selectedCommand && commandRequiresArguments(command.selectedCommand) && ( @@ -476,89 +483,36 @@ export const SortableSessionItem = ({ )} - {/* Topic Header */} -
- {/* Drag Handle */} -
- -
- - { - updateTopicTitle(item.id, e.target.value); - updateMentionFromInput({ type: "topic" }, e.currentTarget, e.target.value); - updateCommandFromInput({ type: "topic" }, e.currentTarget, e.target.value); - }} - className="flex-1 bg-transparent text-lg font-semibold text-slate-800 dark:text-foreground placeholder-slate-400 dark:placeholder-muted-foreground outline-none focus:underline decoration-indigo-300 dark:decoration-indigo-700 underline-offset-4" - placeholder="Thema Titel..." - onKeyDown={onEditorKeyDown} // Stop DND from interfering with typing - onBlur={() => setTimeout(() => closeMention(), 80)} - /> - -
- - {/* Bullet Points */} -
- {item.points.map((point, idx) => ( -
-
-