diff --git a/src/app/error.tsx b/src/app/error.tsx
new file mode 100644
index 0000000..3165b34
--- /dev/null
+++ b/src/app/error.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { useEffect } from "react";
+import { AlertTriangle, RotateCcw } from "lucide-react";
+
+export default function GlobalError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ useEffect(() => {
+ console.error(error);
+ }, [error]);
+
+ const isDbError =
+ /database|prisma|ECONNREFUSED|P1001|does not exist/i.test(error.message ?? "");
+
+ return (
+
+
+
+
+
Da ist etwas schiefgelaufen
+
{error.message}
+
+ {isDbError ? (
+
+
Sieht nach einem Datenbankproblem aus.
+
+ -
+ Laeuft PostgreSQL?
docker compose up -d db
+
+ -
+ Stimmt
DATABASE_URL in der{" "}
+ .env?
+
+ -
+ Migrationen eingespielt?
npm run db:migrate
+
+
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/app/globals.css b/src/app/globals.css
new file mode 100644
index 0000000..c71ec96
--- /dev/null
+++ b/src/app/globals.css
@@ -0,0 +1,365 @@
+@import "tailwindcss";
+
+/* ─────────────────────────────────────────────────────────────
+ Nebula – Designsystem
+ Dark Glassmorphism mit Neon-Akzenten.
+ ───────────────────────────────────────────────────────────── */
+
+@theme {
+ --color-void: #05060f;
+ --color-void-soft: #0a0c1a;
+ --color-haze: #12142b;
+
+ --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
+ "Helvetica Neue", Arial, "Noto Sans", sans-serif;
+ --font-mono: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo,
+ Consolas, "DejaVu Sans Mono", monospace;
+
+ --radius-card: 1.25rem;
+}
+
+:root {
+ color-scheme: dark;
+ --glass-bg: rgba(255, 255, 255, 0.035);
+ --glass-border: rgba(255, 255, 255, 0.09);
+ --glass-highlight: rgba(255, 255, 255, 0.16);
+}
+
+* {
+ border-color: var(--glass-border);
+}
+
+html,
+body {
+ min-height: 100%;
+}
+
+body {
+ background-color: var(--color-void);
+ color: #e6e8f5;
+ font-family: var(--font-sans);
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+ overflow-x: hidden;
+}
+
+/* ── Hintergrund: langsam driftende Nebel ───────────────────── */
+
+.aurora {
+ position: fixed;
+ inset: 0;
+ z-index: -2;
+ overflow: hidden;
+ pointer-events: none;
+}
+
+.aurora::after {
+ /* feines Rauschen, damit die Verlaeufe nicht bandieren */
+ content: "";
+ position: absolute;
+ inset: 0;
+ opacity: 0.35;
+ background-image: radial-gradient(rgba(255, 255, 255, 0.06) 1px, transparent 1px);
+ background-size: 3px 3px;
+ mix-blend-mode: overlay;
+}
+
+.aurora-blob {
+ position: absolute;
+ border-radius: 9999px;
+ filter: blur(90px);
+ opacity: 0.5;
+ will-change: transform;
+}
+
+.aurora-blob--one {
+ width: 46rem;
+ height: 46rem;
+ top: -18rem;
+ left: -12rem;
+ background: radial-gradient(circle at 30% 30%, #7c3aed, transparent 65%);
+ animation: drift-a 26s ease-in-out infinite alternate;
+}
+
+.aurora-blob--two {
+ width: 40rem;
+ height: 40rem;
+ top: -10rem;
+ right: -14rem;
+ background: radial-gradient(circle at 60% 40%, #0ea5e9, transparent 65%);
+ animation: drift-b 32s ease-in-out infinite alternate;
+}
+
+.aurora-blob--three {
+ width: 38rem;
+ height: 38rem;
+ bottom: -20rem;
+ left: 28%;
+ background: radial-gradient(circle at 50% 50%, #db2777, transparent 68%);
+ animation: drift-c 38s ease-in-out infinite alternate;
+ opacity: 0.35;
+}
+
+.aurora-grid {
+ position: fixed;
+ inset: 0;
+ z-index: -1;
+ pointer-events: none;
+ background-image:
+ linear-gradient(rgba(255, 255, 255, 0.028) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(255, 255, 255, 0.028) 1px, transparent 1px);
+ background-size: 64px 64px;
+ mask-image: radial-gradient(ellipse 100% 70% at 50% 0%, #000 40%, transparent 100%);
+}
+
+@keyframes drift-a {
+ from { transform: translate3d(0, 0, 0) scale(1); }
+ to { transform: translate3d(6rem, 4rem, 0) scale(1.12); }
+}
+@keyframes drift-b {
+ from { transform: translate3d(0, 0, 0) scale(1.05); }
+ to { transform: translate3d(-7rem, 5rem, 0) scale(0.95); }
+}
+@keyframes drift-c {
+ from { transform: translate3d(0, 0, 0) scale(0.95); }
+ to { transform: translate3d(4rem, -6rem, 0) scale(1.1); }
+}
+
+/* ── Glasflaechen ───────────────────────────────────────────── */
+
+.glass {
+ position: relative;
+ background: var(--glass-bg);
+ border: 1px solid var(--glass-border);
+ backdrop-filter: blur(18px) saturate(140%);
+ -webkit-backdrop-filter: blur(18px) saturate(140%);
+ border-radius: var(--radius-card);
+ box-shadow:
+ 0 1px 0 0 var(--glass-highlight) inset,
+ 0 24px 60px -30px rgba(0, 0, 0, 0.9);
+}
+
+.glass-soft {
+ background: rgba(255, 255, 255, 0.025);
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border-radius: 0.9rem;
+}
+
+/* Karte mit Akzent-Glow. --accent-from/--accent-to setzt die Komponente. */
+.card-glow {
+ transition:
+ transform 0.35s cubic-bezier(0.22, 1, 0.36, 1),
+ box-shadow 0.35s ease,
+ border-color 0.35s ease;
+}
+
+.card-glow::before {
+ content: "";
+ position: absolute;
+ inset: -1px;
+ border-radius: inherit;
+ padding: 1px;
+ background: linear-gradient(135deg, var(--accent-from, #8b5cf6), var(--accent-to, #d946ef));
+ -webkit-mask:
+ linear-gradient(#000 0 0) content-box,
+ linear-gradient(#000 0 0);
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ opacity: 0;
+ transition: opacity 0.35s ease;
+ pointer-events: none;
+}
+
+.card-glow:hover {
+ transform: translateY(-4px);
+ box-shadow:
+ 0 1px 0 0 var(--glass-highlight) inset,
+ 0 30px 70px -30px rgba(0, 0, 0, 0.95),
+ 0 0 46px -12px var(--accent-from, #8b5cf6);
+}
+
+.card-glow:hover::before {
+ opacity: 0.85;
+}
+
+/* ── Text & Akzente ─────────────────────────────────────────── */
+
+.text-gradient {
+ background: linear-gradient(120deg, #c4b5fd 0%, #67e8f9 45%, #f0abfc 100%);
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+}
+
+.accent-bar {
+ background: linear-gradient(90deg, var(--accent-from, #8b5cf6), var(--accent-to, #d946ef));
+}
+
+.accent-dot {
+ box-shadow: 0 0 12px 1px currentColor;
+}
+
+/* ── Buttons ────────────────────────────────────────────────── */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ border-radius: 0.75rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ padding: 0.55rem 1rem;
+ transition: all 0.2s ease;
+ cursor: pointer;
+ white-space: nowrap;
+ border: 1px solid transparent;
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: linear-gradient(120deg, #7c3aed, #c026d3);
+ color: #fff;
+ box-shadow: 0 10px 30px -12px rgba(168, 85, 247, 0.9);
+}
+
+.btn-primary:hover:not(:disabled) {
+ filter: brightness(1.12);
+ box-shadow: 0 14px 36px -12px rgba(168, 85, 247, 1);
+}
+
+.btn-ghost {
+ background: rgba(255, 255, 255, 0.04);
+ border-color: rgba(255, 255, 255, 0.1);
+ color: #cbd5f5;
+}
+
+.btn-ghost:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.09);
+ color: #fff;
+}
+
+.btn-danger {
+ background: rgba(244, 63, 94, 0.12);
+ border-color: rgba(244, 63, 94, 0.35);
+ color: #fda4af;
+}
+
+.btn-danger:hover:not(:disabled) {
+ background: rgba(244, 63, 94, 0.22);
+ color: #fff;
+}
+
+/* ── Formularfelder ─────────────────────────────────────────── */
+
+.field {
+ width: 100%;
+ border-radius: 0.75rem;
+ background: rgba(8, 10, 24, 0.6);
+ border: 1px solid rgba(255, 255, 255, 0.09);
+ padding: 0.6rem 0.85rem;
+ font-size: 0.875rem;
+ color: #e6e8f5;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+.field::placeholder {
+ color: rgba(148, 163, 184, 0.6);
+}
+
+.field:focus {
+ outline: none;
+ border-color: rgba(167, 139, 250, 0.6);
+ box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.18);
+}
+
+.field-label {
+ display: block;
+ font-size: 0.7rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: rgba(148, 163, 184, 0.85);
+ margin-bottom: 0.4rem;
+}
+
+select.field option {
+ background: #0a0c1a;
+ color: #e6e8f5;
+}
+
+/* ── Sonstiges ──────────────────────────────────────────────── */
+
+.shimmer {
+ background: linear-gradient(
+ 100deg,
+ rgba(255, 255, 255, 0) 20%,
+ rgba(255, 255, 255, 0.12) 50%,
+ rgba(255, 255, 255, 0) 80%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 2.2s linear infinite;
+}
+
+@keyframes shimmer {
+ from { background-position: 200% 0; }
+ to { background-position: -200% 0; }
+}
+
+.fade-up {
+ animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
+}
+
+@keyframes fade-up {
+ from { opacity: 0; transform: translateY(12px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.pulse-ring {
+ animation: pulse-ring 2.4s ease-out infinite;
+}
+
+@keyframes pulse-ring {
+ 0% { box-shadow: 0 0 0 0 currentColor; opacity: 0.7; }
+ 70% { box-shadow: 0 0 0 8px transparent; opacity: 0; }
+ 100% { box-shadow: 0 0 0 0 transparent; opacity: 0; }
+}
+
+::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: rgba(148, 163, 184, 0.22);
+ border-radius: 9999px;
+ border: 2px solid transparent;
+ background-clip: content-box;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: rgba(167, 139, 250, 0.5);
+ background-clip: content-box;
+}
+
+::selection {
+ background: rgba(139, 92, 246, 0.35);
+ color: #fff;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.001ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.001ms !important;
+ }
+}
diff --git a/src/app/icon.svg b/src/app/icon.svg
new file mode 100644
index 0000000..b9e3353
--- /dev/null
+++ b/src/app/icon.svg
@@ -0,0 +1,15 @@
+
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
new file mode 100644
index 0000000..cfe5be9
--- /dev/null
+++ b/src/app/layout.tsx
@@ -0,0 +1,51 @@
+import type { Metadata, Viewport } from "next";
+import { TopNav } from "@/components/TopNav";
+import "./globals.css";
+
+export const metadata: Metadata = {
+ title: {
+ default: "Nebula – Vibe Coding Dashboard",
+ template: "%s · Nebula",
+ },
+ description:
+ "Uebersicht ueber alle Vibe-Coding Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.",
+ applicationName: "Nebula",
+};
+
+export const viewport: Viewport = {
+ themeColor: "#05060f",
+ width: "device-width",
+ initialScale: 1,
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{ children: React.ReactNode }>) {
+ return (
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
new file mode 100644
index 0000000..b54dc11
--- /dev/null
+++ b/src/app/not-found.tsx
@@ -0,0 +1,19 @@
+import Link from "next/link";
+import { Compass } from "lucide-react";
+
+export default function NotFound() {
+ return (
+
+
+
+
+
Nichts gefunden
+
+ Diese Seite existiert nicht (mehr). Vielleicht wurde das Projekt geloescht.
+
+
+ Zurueck zum Dashboard
+
+
+ );
+}
diff --git a/src/components/TopNav.tsx b/src/components/TopNav.tsx
new file mode 100644
index 0000000..6832606
--- /dev/null
+++ b/src/components/TopNav.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { GitBranch, LayoutDashboard, Sparkles } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+const LINKS = [
+ { href: "/", label: "Dashboard", icon: LayoutDashboard },
+ { href: "/connections", label: "Git-Verbindungen", icon: GitBranch },
+];
+
+export function TopNav() {
+ const pathname = usePathname();
+
+ return (
+
+
+
+
+
+
+
+
+ Nebula
+
+
+ Vibe Coding
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx
new file mode 100644
index 0000000..89a1bc5
--- /dev/null
+++ b/src/components/ui/Modal.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import { useEffect } from "react";
+import { X } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+interface ModalProps {
+ open: boolean;
+ title: string;
+ description?: string;
+ onClose: () => void;
+ children: React.ReactNode;
+ footer?: React.ReactNode;
+ width?: "md" | "lg" | "xl";
+}
+
+export function Modal({
+ open,
+ title,
+ description,
+ onClose,
+ children,
+ footer,
+ width = "lg",
+}: ModalProps) {
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") onClose();
+ };
+ document.addEventListener("keydown", onKey);
+ const previous = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.removeEventListener("keydown", onKey);
+ document.body.style.overflow = previous;
+ };
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ return (
+
+
+
+
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+
+
+
+ {children}
+
+ {footer ?
{footer}
: null}
+
+
+ );
+}