Add initial Portal version
This commit is contained in:
5
src/app/[locale]/admin/page.tsx
Normal file
5
src/app/[locale]/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminTenantsClient from "@/components/admin/AdminTenantsClient";
|
||||
|
||||
export default function AdminPage() {
|
||||
return <AdminTenantsClient />;
|
||||
}
|
||||
5
src/app/[locale]/dashboard/page.tsx
Normal file
5
src/app/[locale]/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import DashboardClient from "@/components/dashboard/DashboardClient";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardClient />;
|
||||
}
|
||||
43
src/app/[locale]/layout.tsx
Normal file
43
src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages } from "next-intl/server";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { notFound } from "next/navigation";
|
||||
import { NavShell } from "@/components/layout/nav-shell";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
|
||||
if (!routing.locales.includes(locale as any)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<html lang={locale} className="dark">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PieCed Portal</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="PieCed IT — Multi-tenant AI assistant platform"
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen bg-surface-0 text-text-primary antialiased">
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<NavShell>{children}</NavShell>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
60
src/app/[locale]/login/page.tsx
Normal file
60
src/app/[locale]/login/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations("login");
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-surface-0">
|
||||
{/* Background grid pattern */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.04]"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(var(--color-accent) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--color-accent) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: "60px 60px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-full max-w-sm px-5 animate-in">
|
||||
{/* Logo mark */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="relative h-12 w-12">
|
||||
<div className="absolute inset-0 rounded-lg bg-accent/15" />
|
||||
<div className="absolute inset-[5px] rounded-md bg-accent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-1 rounded-2xl border border-border p-8 shadow-2xl shadow-black/40">
|
||||
<h1 className="font-display text-xl font-semibold text-center mb-1">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm text-center mb-8">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => signIn("zitadel", { callbackUrl: "/dashboard" })}
|
||||
className="
|
||||
w-full py-3 px-4 rounded-lg font-medium text-sm
|
||||
bg-accent text-surface-0 cursor-pointer
|
||||
hover:bg-accent-dim active:scale-[0.98]
|
||||
transition-all duration-150
|
||||
shadow-lg shadow-accent/20
|
||||
"
|
||||
>
|
||||
{t("button")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-text-muted text-[11px] mt-6 tracking-wide uppercase">
|
||||
{t("footer")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/app/[locale]/page.tsx
Normal file
5
src/app/[locale]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
5
src/app/[locale]/tenants/[name]/page.tsx
Normal file
5
src/app/[locale]/tenants/[name]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import TenantDetailClient from "@/components/tenants/TenantDetailClient";
|
||||
|
||||
export default function TenantDetailPage() {
|
||||
return <TenantDetailClient />;
|
||||
}
|
||||
2
src/app/api/auth/[...nextauth]/route.ts
Normal file
2
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
export const { GET, POST } = handlers;
|
||||
12
src/app/api/packages/route.ts
Normal file
12
src/app/api/packages/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { PACKAGE_CATALOG } from "@/lib/packages";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json(PACKAGE_CATALOG);
|
||||
}
|
||||
95
src/app/api/tenants/[name]/route.ts
Normal file
95
src/app/api/tenants/[name]/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||
|
||||
function isPlatformRole(roles: string[]): boolean {
|
||||
return roles.some((r) =>
|
||||
["platform_admin", "platform_operator"].includes(r)
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { name } = await params;
|
||||
const { orgId, roles } = session.user as any;
|
||||
|
||||
try {
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Scope check: non-platform users can only see their own org's tenants
|
||||
if (
|
||||
!isPlatformRole(roles || []) &&
|
||||
tenant.metadata?.labels?.["zitadel-org-id"] !== orgId
|
||||
) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json(tenant);
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "K8s API error", detail: e.message },
|
||||
{ status: e.statusCode || 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { name } = await params;
|
||||
const { orgId, roles } = session.user as any;
|
||||
const body = await req.json();
|
||||
const userRoles = roles || [];
|
||||
|
||||
// Only owner or platform roles can patch
|
||||
if (!isPlatformRole(userRoles) && !userRoles.includes("owner")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Ownership check
|
||||
const existing = await getTenant(name);
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
if (
|
||||
!isPlatformRole(userRoles) &&
|
||||
existing.metadata?.labels?.["zitadel-org-id"] !== orgId
|
||||
) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Build partial spec — only allow specific fields
|
||||
const specPatch: Record<string, any> = {};
|
||||
if (body.packages !== undefined) specPatch.packages = body.packages;
|
||||
if (body.workspaceFiles !== undefined)
|
||||
specPatch.workspaceFiles = body.workspaceFiles;
|
||||
if (body.displayName !== undefined)
|
||||
specPatch.displayName = body.displayName;
|
||||
if (body.agentName !== undefined) specPatch.agentName = body.agentName;
|
||||
|
||||
const updated = await patchTenantSpec(name, specPatch);
|
||||
return NextResponse.json(updated);
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "Patch failed", detail: e.message },
|
||||
{ status: e.statusCode || 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
100
src/app/api/tenants/[name]/secrets/route.ts
Normal file
100
src/app/api/tenants/[name]/secrets/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getTenant } from "@/lib/k8s";
|
||||
import { writePackageSecrets } from "@/lib/openbao";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { name } = await params;
|
||||
const { orgId, roles } = session.user as any;
|
||||
const userRoles = roles || [];
|
||||
|
||||
const isPlatform = userRoles.some((r: string) =>
|
||||
["platform_admin", "platform_operator"].includes(r)
|
||||
);
|
||||
|
||||
if (!isPlatform && !userRoles.includes("owner")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { packageId, secrets } = body as {
|
||||
packageId: string;
|
||||
secrets: Record<string, string>;
|
||||
};
|
||||
|
||||
if (!packageId || !secrets || typeof secrets !== "object") {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing packageId or secrets" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate package exists and requires secrets
|
||||
const pkgDef = getPackageDef(packageId);
|
||||
if (!pkgDef) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unknown package" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (!pkgDef.requiresSecrets) {
|
||||
return NextResponse.json(
|
||||
{ error: "Package does not require secrets" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify all required secret keys are present
|
||||
const requiredKeys = (pkgDef.secrets || []).map((s) => s.key);
|
||||
const missingKeys = requiredKeys.filter((k) => !secrets[k]?.trim());
|
||||
if (missingKeys.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Missing required secrets: ${missingKeys.join(", ")}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify tenant ownership
|
||||
try {
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant) {
|
||||
return NextResponse.json(
|
||||
{ error: "Tenant not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (
|
||||
!isPlatform &&
|
||||
tenant.metadata?.labels?.["zitadel-org-id"] !== orgId
|
||||
) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "Tenant lookup failed" },
|
||||
{ status: e.statusCode || 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Write to OpenBao
|
||||
try {
|
||||
await writePackageSecrets(name, packageId, secrets);
|
||||
} catch (err: any) {
|
||||
console.error("OpenBao write error:", err.message);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to store secrets" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
56
src/app/api/tenants/route.ts
Normal file
56
src/app/api/tenants/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listTenants, getTenant, createTenant } from "@/lib/k8s";
|
||||
import type { PiecedTenantSpec } from "@/types";
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const tenants = await listTenants();
|
||||
|
||||
if (user.isPlatform) {
|
||||
return NextResponse.json(tenants);
|
||||
}
|
||||
|
||||
// Customers see only their own tenant
|
||||
const own = tenants.filter(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
return NextResponse.json(own);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await getSessionUser();
|
||||
if (!user)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!user.isPlatform && !user.roles.includes("owner")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = (await request.json()) as {
|
||||
name: string;
|
||||
spec: PiecedTenantSpec;
|
||||
};
|
||||
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(body.name) || body.name.length > 63) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid tenant name: lowercase alphanumeric and hyphens, 2-63 chars" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await getTenant(body.name);
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "Tenant already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const tenant = await createTenant(body.name, body.spec, {
|
||||
"pieced.ch/zitadel-org-id": user.orgId,
|
||||
});
|
||||
return NextResponse.json(tenant, { status: 201 });
|
||||
}
|
||||
107
src/app/api/usage/route.ts
Normal file
107
src/app/api/usage/route.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getTeamInfo, getTeamSpendLogs } from "@/lib/litellm";
|
||||
|
||||
// Pricing constants (CHF)
|
||||
const INPUT_RATE = 3; // CHF per MTok
|
||||
const OUTPUT_RATE = 15; // CHF per MTok
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { orgId } = session.user as any;
|
||||
if (!orgId) {
|
||||
return NextResponse.json(
|
||||
{ error: "No org context" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// The LiteLLM team_id maps to the tenant name, which is derived from orgId
|
||||
// Convention: team_id = "pieced-{orgId}" or looked up from the tenant CR
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const teamId = searchParams.get("teamId");
|
||||
|
||||
if (!teamId) {
|
||||
return NextResponse.json(
|
||||
{ error: "teamId query param required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Current period info
|
||||
const teamInfo = await getTeamInfo(teamId);
|
||||
|
||||
// Historical spend logs (last 30 days)
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
|
||||
const spendLogs = await getTeamSpendLogs(
|
||||
teamId,
|
||||
startDate.toISOString().split("T")[0],
|
||||
endDate.toISOString().split("T")[0]
|
||||
);
|
||||
|
||||
// Calculate CHF costs from token counts
|
||||
const dailyUsage = (spendLogs || []).map((day: any) => ({
|
||||
date: day.date || day.day,
|
||||
inputTokens: day.prompt_tokens || 0,
|
||||
outputTokens: day.completion_tokens || 0,
|
||||
inputCostCHF:
|
||||
((day.prompt_tokens || 0) / 1_000_000) * INPUT_RATE,
|
||||
outputCostCHF:
|
||||
((day.completion_tokens || 0) / 1_000_000) * OUTPUT_RATE,
|
||||
totalCostCHF:
|
||||
((day.prompt_tokens || 0) / 1_000_000) * INPUT_RATE +
|
||||
((day.completion_tokens || 0) / 1_000_000) * OUTPUT_RATE,
|
||||
}));
|
||||
|
||||
// Totals for current period
|
||||
const totalInputTokens = dailyUsage.reduce(
|
||||
(s: number, d: any) => s + d.inputTokens,
|
||||
0
|
||||
);
|
||||
const totalOutputTokens = dailyUsage.reduce(
|
||||
(s: number, d: any) => s + d.outputTokens,
|
||||
0
|
||||
);
|
||||
const totalCostCHF = dailyUsage.reduce(
|
||||
(s: number, d: any) => s + d.totalCostCHF,
|
||||
0
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
teamId,
|
||||
currentPeriod: {
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
inputCostCHF: (totalInputTokens / 1_000_000) * INPUT_RATE,
|
||||
outputCostCHF: (totalOutputTokens / 1_000_000) * OUTPUT_RATE,
|
||||
totalCostCHF,
|
||||
},
|
||||
budget: {
|
||||
maxBudget: teamInfo?.max_budget ?? null,
|
||||
spend: teamInfo?.spend ?? 0,
|
||||
remaining: teamInfo?.max_budget
|
||||
? teamInfo.max_budget - (teamInfo.spend ?? 0)
|
||||
: null,
|
||||
},
|
||||
rateLimits: {
|
||||
rpm: teamInfo?.rpm_limit ?? null,
|
||||
tpm: teamInfo?.tpm_limit ?? null,
|
||||
},
|
||||
dailyUsage,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("Usage fetch error:", err.message);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch usage data" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
104
src/app/globals.css
Normal file
104
src/app/globals.css
Normal file
@@ -0,0 +1,104 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap");
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Design direction: Swiss Industrial
|
||||
* Dark neutral base, sharp teal accent, geometric precision.
|
||||
* Outfit for headings (geometric, characterful), system for body.
|
||||
* No purple gradients, no Inter, no AI slop.
|
||||
*/
|
||||
|
||||
@theme {
|
||||
--color-surface-0: #0a0c10;
|
||||
--color-surface-1: #12151c;
|
||||
--color-surface-2: #1a1e28;
|
||||
--color-surface-3: #242a36;
|
||||
|
||||
--color-text-primary: #e8ecf4;
|
||||
--color-text-secondary: #8892a4;
|
||||
--color-text-muted: #565e6e;
|
||||
|
||||
--color-accent: #00d4aa;
|
||||
--color-accent-dim: #00b892;
|
||||
--color-accent-glow: #00d4aa26;
|
||||
|
||||
--color-border: #1e2330;
|
||||
--color-border-active: #2c3344;
|
||||
|
||||
--color-success: #34d399;
|
||||
--color-warning: #fbbf24;
|
||||
--color-error: #f87171;
|
||||
|
||||
--font-display: "Outfit", system-ui, sans-serif;
|
||||
--font-body: "Outfit", system-ui, sans-serif;
|
||||
--font-mono: "IBM Plex Mono", monospace;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--color-surface-0);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Utility: subtle noise texture overlay */
|
||||
.bg-noise::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.03;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Accent underline for headings */
|
||||
.accent-rule {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.accent-rule::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
left: 0;
|
||||
width: 32px;
|
||||
height: 2px;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Card hover lift */
|
||||
.card-interactive {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.card-interactive:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
border-color: var(--color-border-active);
|
||||
}
|
||||
|
||||
/* Status dot pulse */
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.status-pulse {
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Staggered fade-in for page content */
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-in {
|
||||
animation: fade-up 0.4s ease-out both;
|
||||
}
|
||||
.animate-in-delay-1 { animation-delay: 0.05s; }
|
||||
.animate-in-delay-2 { animation-delay: 0.1s; }
|
||||
.animate-in-delay-3 { animation-delay: 0.15s; }
|
||||
9
src/app/layout.tsx
Normal file
9
src/app/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import "./globals.css";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return children;
|
||||
}
|
||||
Reference in New Issue
Block a user