Initial commit: everythingbutwork MVP
Internes Project-Management-Tool für ToolCTRL — ergänzend zu Plane (Tickets) und Outline (Doku). Umfasst Puls-Übersicht, Tagesrhythmus (Lock-in + Reflexion), Bereichs-Detail-Views (Entwicklung/Vertrieb/Marketing), Pflicht-Minimum-Check und Blocker-Lifecycle gemäß Projektmanagement-Leitfaden v3.0. Stack: pnpm-Monorepo · Vite + React 18 + Tailwind · Fastify + tRPC v11 + Drizzle ORM · Postgres 16 · Authentik OIDC (Phase 2, Dev-Auto-Login aktiv). Planungs-Artefakte unter _bmad-output/planning-artifacts/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
4f33509a0f
131 files changed
+34339
No files matched your search
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { trpc } from '../../lib/trpc';
|
||||
import { voice } from '../../lib/voice';
|
||||
import { Button } from '../ui/Button';
|
||||
|
||||
export function BlockerDialog({ onClose }: { onClose: () => void }) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const utils = trpc.useUtils();
|
||||
const mutation = trpc.blocker.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast(voice.toast.blockerOpened);
|
||||
await utils.puls.overview.invalidate();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
mutation.mutate({ title: title.trim(), description: description.trim() || undefined });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-xl font-semibold">Neuer Blocker</h2>
|
||||
<form onSubmit={submit} className="mt-4 space-y-3">
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Was klemmt?</span>
|
||||
<input
|
||||
required
|
||||
ref={(el) => {
|
||||
if (el && !title) el.focus();
|
||||
}}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={200}
|
||||
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Mehr Kontext (optional)</span>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
Erstellen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Outlet, useNavigate } from 'react-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { TopNav } from './TopNav';
|
||||
import { useCurrentUser } from '../../hooks/useCurrentUser';
|
||||
import { BlockerDialog } from '../blocker/BlockerDialog';
|
||||
|
||||
const SHORTCUT_MAP: Record<string, string> = {
|
||||
p: '/puls',
|
||||
h: '/heute',
|
||||
b: '/bereich',
|
||||
e: '/bereich/entwicklung',
|
||||
v: '/bereich/vertrieb',
|
||||
m: '/bereich/marketing',
|
||||
a: '/archiv',
|
||||
};
|
||||
|
||||
export function AppShell() {
|
||||
const me = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
const [blockerOpen, setBlockerOpen] = useState(false);
|
||||
|
||||
// Dev-Mode: bei fehlender Session sofort in den Auto-Login. Auf prod hingegen
|
||||
// zeigen wir die Login-Aufforderung mit dem Authentik-Link.
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV && me === null) {
|
||||
// Kleiner Delay, damit der useQuery sicher fertig ist und wir nicht
|
||||
// mehrmals redirecten.
|
||||
const t = setTimeout(() => {
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.replace('/api/auth/dev-login');
|
||||
}
|
||||
}, 150);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
return undefined;
|
||||
}, [me]);
|
||||
|
||||
// Keyboard-Shortcuts: `g <key>` → Navigation, `n` → Neuer Blocker.
|
||||
useEffect(() => {
|
||||
let waitingForSecond = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function reset() {
|
||||
waitingForSecond = false;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (e.key === '?' && !waitingForSecond) {
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(
|
||||
'Shortcuts:\n g p — Puls\n g b — Bereich\n g e/v/m — Entwicklung/Vertrieb/Marketing\n g h — Heute\n g a — Archiv\n n — Neuer Blocker\n ? — diese Hilfe',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e.key.toLowerCase() === 'n' && !waitingForSecond) {
|
||||
setBlockerOpen(true);
|
||||
return;
|
||||
}
|
||||
if (!waitingForSecond && e.key.toLowerCase() === 'g') {
|
||||
waitingForSecond = true;
|
||||
timer = setTimeout(reset, 1500);
|
||||
return;
|
||||
}
|
||||
if (waitingForSecond) {
|
||||
const target = SHORTCUT_MAP[e.key.toLowerCase()];
|
||||
if (target) navigate(target);
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
reset();
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-bg)]">
|
||||
<a href="#main" className="skip-link">
|
||||
Zum Hauptinhalt springen
|
||||
</a>
|
||||
<TopNav />
|
||||
<main id="main" className="mx-auto max-w-[1280px] px-6 py-8">
|
||||
{me ? <Outlet /> : <NotLoggedInHint />}
|
||||
</main>
|
||||
{blockerOpen && <BlockerDialog onClose={() => setBlockerOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotLoggedInHint() {
|
||||
return (
|
||||
<div className="mx-auto mt-16 max-w-md rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] p-8 text-center shadow-[var(--shadow-sm)]">
|
||||
<p className="text-[var(--color-ink-muted)]">Du bist nicht angemeldet.</p>
|
||||
<a
|
||||
href="/api/auth/login"
|
||||
className="mt-4 inline-flex h-9 items-center rounded-[var(--radius-md)] bg-[var(--color-brand)] px-4 text-sm font-medium text-[var(--color-brand-fg)] hover:bg-black/85"
|
||||
>
|
||||
Über Authentik anmelden
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { NavLink, useLocation } from 'react-router';
|
||||
import { clsx } from 'clsx';
|
||||
import { Flame } from 'lucide-react';
|
||||
import { ToolCtrlLogo } from '../ui/ToolCtrlLogo';
|
||||
import { voice } from '../../lib/voice';
|
||||
import { useCurrentUser } from '../../hooks/useCurrentUser';
|
||||
|
||||
const TABS = [
|
||||
{ to: '/puls', label: 'Puls', shortcut: 'g p', matchPrefix: '/puls' },
|
||||
{ to: '/bereich', label: 'Bereich', shortcut: 'g b', matchPrefix: '/bereich' },
|
||||
{ to: '/heute', label: 'Heute', shortcut: 'g h', matchPrefix: '/heute' },
|
||||
{ to: '/archiv', label: 'Archiv', shortcut: 'g a', matchPrefix: '/archiv' },
|
||||
] as const;
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function TopNav() {
|
||||
const me = useCurrentUser();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<header
|
||||
className="sticky top-0 z-30 bg-[var(--color-bg)] border-b border-[var(--color-border)]"
|
||||
role="banner"
|
||||
>
|
||||
<nav
|
||||
className="mx-auto flex h-14 max-w-[1280px] items-center gap-2 px-6"
|
||||
aria-label="Hauptnavigation"
|
||||
>
|
||||
<NavLink to="/puls" className="flex items-center gap-1.5 font-bold text-[var(--color-ink)]">
|
||||
<span>{voice.appName}</span>
|
||||
<ToolCtrlLogo size={18} />
|
||||
</NavLink>
|
||||
|
||||
<ul className="flex items-center gap-0.5 ml-6">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = location.pathname.startsWith(tab.matchPrefix);
|
||||
return (
|
||||
<li key={tab.to}>
|
||||
<NavLink
|
||||
to={tab.to}
|
||||
className={clsx(
|
||||
'inline-flex h-9 items-center rounded-[var(--radius-md)] px-3 text-sm font-medium',
|
||||
'text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:bg-black/5',
|
||||
isActive &&
|
||||
'text-[var(--color-ink)] border-b-2 border-[var(--color-ink)] rounded-b-none',
|
||||
)}
|
||||
aria-keyshortcuts={tab.shortcut}
|
||||
>
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{me ? (
|
||||
<>
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium',
|
||||
me.streak > 0
|
||||
? 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]'
|
||||
: 'bg-black/5 text-[var(--color-ink-muted)]',
|
||||
)}
|
||||
title={`Aktuelle Update-Serie: ${me.streak} ${me.streak === 1 ? 'Tag' : 'Tage'}`}
|
||||
>
|
||||
<Flame size={12} fill={me.streak > 0 ? 'currentColor' : 'none'} />
|
||||
<span className="font-mono tabular-nums">{me.streak}</span>
|
||||
</span>
|
||||
<div
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--color-brand)] text-xs font-semibold text-[var(--color-brand-fg)]"
|
||||
aria-label={me.name}
|
||||
title={me.name}
|
||||
>
|
||||
{getInitials(me.name)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<a
|
||||
href="/api/auth/login"
|
||||
className="text-sm font-medium text-[var(--color-ink)] hover:underline"
|
||||
>
|
||||
Login
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'destructive';
|
||||
type Size = 'sm' | 'md' | 'lg';
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
const base =
|
||||
'inline-flex items-center justify-center gap-2 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variantStyles: Record<Variant, string> = {
|
||||
primary: 'bg-[var(--color-brand)] text-[var(--color-brand-fg)] hover:bg-black/85',
|
||||
secondary: 'border border-[var(--color-ink)] text-[var(--color-ink)] hover:bg-black/5',
|
||||
ghost: 'text-[var(--color-ink)] hover:bg-black/5',
|
||||
destructive: 'bg-[var(--color-ampel-red-fg)] text-white hover:bg-red-800',
|
||||
};
|
||||
|
||||
const sizeStyles: Record<Size, string> = {
|
||||
sm: 'h-7 px-3 text-xs rounded-[var(--radius-md)]',
|
||||
md: 'h-9 px-4 text-sm rounded-[var(--radius-md)]',
|
||||
lg: 'h-11 px-6 text-base rounded-[var(--radius-md)]',
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ variant = 'primary', size = 'md', className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={clsx(base, variantStyles[variant], sizeStyles[size], className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { clsx } from 'clsx';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
function Root({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)]',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={clsx('px-6 pt-6 pb-3', className)} {...rest} />;
|
||||
}
|
||||
|
||||
function Body({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={clsx('px-6 py-3', className)} {...rest} />;
|
||||
}
|
||||
|
||||
function Footer({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={clsx('px-6 pt-3 pb-6 border-t border-[var(--color-border)]', className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Card = { Root, Header, Body, Footer };
|
||||
@@ -0,0 +1,28 @@
|
||||
// macOS-Ctrl-Caret als Brand-Akzent. Skalierbar via currentColor.
|
||||
|
||||
export function KeyCaret({
|
||||
size = 16,
|
||||
className,
|
||||
'aria-hidden': ariaHidden = true,
|
||||
}: {
|
||||
size?: number;
|
||||
className?: string;
|
||||
'aria-hidden'?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.6}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden={ariaHidden}
|
||||
>
|
||||
<polyline points="3 10 8 5 13 10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export function SkeletonCard({ className, lines = 3 }: { className?: string; lines?: number }) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-lg)] p-6 animate-pulse',
|
||||
className,
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<div className="h-4 w-1/3 bg-[var(--color-border)] rounded" />
|
||||
<div className="mt-3 space-y-2">
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-3 bg-[var(--color-border)] rounded"
|
||||
style={{ width: `${100 - i * 12}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Brand-Emblem (das Tool-Ctrl-Tastenkappensymbol). Ersetzt den früher
|
||||
// verwendeten Caret als Marken-Anker im Logo-Lockup und in Streaks.
|
||||
|
||||
export function ToolCtrlLogo({
|
||||
size = 16,
|
||||
className,
|
||||
'aria-hidden': ariaHidden = true,
|
||||
alt = 'ToolCTRL',
|
||||
}: {
|
||||
size?: number;
|
||||
className?: string;
|
||||
'aria-hidden'?: boolean;
|
||||
alt?: string;
|
||||
}) {
|
||||
return (
|
||||
<img
|
||||
src="/toolctrl-emblem.svg"
|
||||
width={size}
|
||||
height={(size * 678) / 713}
|
||||
className={className}
|
||||
aria-hidden={ariaHidden}
|
||||
alt={ariaHidden ? '' : alt}
|
||||
draggable={false}
|
||||
style={{ display: 'inline-block' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user