Initial portal commit: landing + 9 AI-powered apps

Apps:
- dwg-rooms: extract room numbers from DWG/DXF
- dwg-counting: count symbols in PDF drawings (OpenCV template matching)
- contract-check: review PDF contracts against a checklist (Claude vision + Tesseract OCR fallback)
- email-drafter: bullet notes → polished Czech/English business emails
- invoice-extractor: PDF/image invoice → structured data → Excel
- translator: Czech-first translator across 19 languages with tone control
- vv-check: find inconsistent unit prices across VV sheets in one workbook
- vv-compare: diff original vs new VV files (changes / added / removed)
- feature-request: portal users submit ideas + sample files

Infrastructure:
- LiteLLM gateway with per-app virtual keys + budgets
- Langfuse observability
- Geist font, shared theme, cross-subdomain back link + theme sync via cookie/URL
- Caddy reverse proxy on *.klas.chat
This commit is contained in:
Ondřej Glaser
2026-05-13 15:25:04 +02:00
commit 48cef99257
139 changed files with 20171 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

BIN
landing/src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

155
landing/src/app/globals.css Normal file
View File

@@ -0,0 +1,155 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
/* Dify / Untitled UI palette */
--color-bg-primary: #ffffff;
--color-bg-secondary: #f9fafb;
--color-bg-tertiary: #f2f4f7;
--color-bg-section: #f6f7f9;
--color-text-primary: #101828;
--color-text-secondary: #354052;
--color-text-tertiary: #676f83;
--color-text-quaternary: #98a2b2;
--color-text-disabled: #d0d5dc;
--color-border-subtle: rgb(16 24 40 / 0.04);
--color-border-default: rgb(16 24 40 / 0.08);
--color-border-hover: rgb(16 24 40 / 0.14);
--color-border-strong: #d0d5dc;
--color-card: #ffffff;
--color-card-hover: #f9fafb;
/* Dify primary blue */
--color-primary-50: #eff4ff;
--color-primary-100: #d1e0ff;
--color-primary-200: #b2caff;
--color-primary-500: #2970ff;
--color-primary-600: #155aef;
--color-primary-700: #004aeb;
--color-primary-800: #00359e;
--color-primary: var(--color-primary-600);
--color-primary-hover: var(--color-primary-700);
--color-primary-foreground: #ffffff;
/* Accent palette for tile icons (also re-declared in :root below so
Tailwind v4 doesn't tree-shake them — they're only used via inline
style in tile.tsx, which Tailwind's purger can't see). */
--color-accent-blue: #155aef;
--color-accent-indigo: #444ce7;
--color-accent-violet: #7a5af8;
--color-accent-cyan: #0ba5ec;
--color-accent-emerald: #17b26a;
--color-accent-amber: #f79009;
--color-accent-rose: #f04438;
--color-accent-gray: #475467;
--color-destructive: #d92d20;
--color-destructive-bg: #fef3f2;
--color-destructive-border: #fda29b;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--shadow-card: 0 1px 2px rgb(16 24 40 / 0.05);
--shadow-card-hover: 0 12px 24px -8px rgb(16 24 40 / 0.12), 0 4px 8px -4px rgb(16 24 40 / 0.06);
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace;
}
/* Re-declare accents in :root so they survive Tailwind v4's purge of unused
@theme variables. Used by inline styles in tile.tsx (color-mix). */
:root {
--color-accent-blue: #155aef;
--color-accent-indigo: #444ce7;
--color-accent-violet: #7a5af8;
--color-accent-cyan: #0ba5ec;
--color-accent-emerald: #17b26a;
--color-accent-amber: #f79009;
--color-accent-rose: #f04438;
--color-accent-gray: #475467;
}
.dark {
--color-bg-primary: #14181f;
--color-bg-secondary: #1a1f29;
--color-bg-tertiary: #232936;
--color-bg-section: #181d26;
--color-text-primary: #f5f7fa;
--color-text-secondary: #c8ccd5;
--color-text-tertiary: #98a2b2;
--color-text-quaternary: #676f83;
--color-text-disabled: #4a525e;
--color-border-subtle: rgb(255 255 255 / 0.05);
--color-border-default: rgb(255 255 255 / 0.08);
--color-border-hover: rgb(255 255 255 / 0.14);
--color-border-strong: #354052;
--color-card: #1a1f29;
--color-card-hover: #232936;
--color-primary: var(--color-primary-500);
--color-primary-hover: #4187ff;
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.4);
--shadow-card-hover: 0 12px 24px -8px rgb(0 0 0 / 0.5), 0 4px 8px -4px rgb(0 0 0 / 0.3);
}
html,
body {
height: 100%;
}
body {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Dify-style typography utilities */
.system-2xs-medium-uppercase {
font-size: 10px;
line-height: 14px;
font-weight: 500;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.system-xs-regular {
font-size: 12px;
line-height: 16px;
font-weight: 400;
}
.system-xs-medium {
font-size: 12px;
line-height: 16px;
font-weight: 500;
}
.system-sm-medium {
font-size: 14px;
line-height: 20px;
font-weight: 500;
}
.system-sm-semibold {
font-size: 14px;
line-height: 20px;
font-weight: 600;
}
/* Scrollbar polish */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
scrollbar-width: none;
}

View File

@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin", "latin-ext"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin", "latin-ext"],
});
export const metadata: Metadata = {
title: "Colsys AI",
description: "Interní AI portál společnosti Colsys.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="cs"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
suppressHydrationWarning
>
<body className="min-h-full flex flex-col">
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,114 @@
import { redirect } from "next/navigation";
import { auth, signIn } from "@/auth";
import { Brand } from "@/components/brand";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type SearchParams = Promise<{ callbackUrl?: string; error?: string }>;
export default async function LoginPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const { callbackUrl, error } = await searchParams;
const session = await auth();
if (session?.user) {
redirect(callbackUrl || "/");
}
const hasEntra =
!!process.env.AUTH_MICROSOFT_ENTRA_ID_ID &&
!!process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET;
const hasDevPassword = !!process.env.DEV_PORTAL_PASSWORD;
return (
<div className="flex min-h-full flex-col bg-[var(--color-bg-secondary)]">
<div className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-sm">
<div className="mb-6 flex justify-center">
<Brand size="lg" />
</div>
<div className="rounded-xl border-[0.5px] border-[var(--color-border-default)] bg-[var(--color-card)] p-7 shadow-[var(--shadow-card)]">
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
Přihlášení
</h1>
<p className="system-xs-regular mt-1 text-[var(--color-text-tertiary)]">
Pokračujte přihlášením přes firemní účet.
</p>
{error ? (
<div className="mt-5 rounded-lg border-[0.5px] border-[var(--color-destructive-border)] bg-[var(--color-destructive-bg)] px-3 py-2.5 text-xs text-[var(--color-destructive)]">
Přihlášení se nezdařilo. Zkuste to prosím znovu.
</div>
) : null}
{hasEntra ? (
<form
className="mt-6"
action={async () => {
"use server";
await signIn("microsoft-entra-id", {
redirectTo: callbackUrl || "/",
});
}}
>
<Button type="submit" className="w-full" size="lg">
Pokračovat přes Microsoft
</Button>
</form>
) : null}
{hasEntra && hasDevPassword ? (
<div className="my-6 flex items-center gap-3 text-xs text-[var(--color-text-quaternary)]">
<span className="h-px flex-1 bg-[var(--color-border-default)]" />
nebo
<span className="h-px flex-1 bg-[var(--color-border-default)]" />
</div>
) : null}
{hasDevPassword ? (
<form
className="mt-6 flex flex-col gap-3"
action={async (formData: FormData) => {
"use server";
await signIn("dev", {
password: formData.get("password"),
redirectTo: callbackUrl || "/",
});
}}
>
<label className="system-xs-medium text-[var(--color-text-secondary)]">
Vývojový přístup
</label>
<Input
name="password"
type="password"
placeholder="Přístupové heslo"
required
autoFocus
/>
<Button type="submit" variant="secondary" size="lg">
Přihlásit se heslem
</Button>
</form>
) : null}
{!hasEntra && !hasDevPassword ? (
<p className="mt-6 rounded-lg bg-[var(--color-bg-tertiary)] p-4 text-sm text-[var(--color-text-tertiary)]">
Není nakonfigurován žádný poskytovatel přihlášení. Nastavte{" "}
<code className="font-mono text-xs">DEV_PORTAL_PASSWORD</code>{" "}
nebo Microsoft Entra v prostředí.
</p>
) : null}
</div>
<p className="mt-6 text-center text-xs text-[var(--color-text-quaternary)]">
Interní AI portál společnosti Colsys
</p>
</div>
</div>
</div>
);
}

33
landing/src/app/page.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { Header } from "@/components/header";
import { TileGrid } from "@/components/tile-grid";
import apps from "@/data/apps.json";
import type { AppTile } from "@/components/tile";
export default function Home() {
return (
<div className="flex min-h-full flex-col bg-[var(--color-bg-secondary)]">
<Header />
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-7 px-4 py-8 sm:px-8 sm:py-10">
<section className="flex flex-col gap-2">
<span className="system-2xs-medium-uppercase text-[var(--color-primary)]">
Interní AI portál
</span>
<h1 className="text-2xl font-semibold tracking-tight text-[var(--color-text-primary)] sm:text-3xl">
Vaše AI nástroje na jednom místě
</h1>
<p className="max-w-2xl text-sm text-[var(--color-text-tertiary)] sm:text-base">
Vyberte si průvodce úlohou, nebo zahajte volný chat. Vše běží na
firemních klíčích s rozpočty, sledováním nákladů a bez exportu
vašich dat ven.
</p>
</section>
<TileGrid apps={apps as AppTile[]} />
</main>
<footer className="mx-auto w-full max-w-7xl px-4 pb-10 pt-2 text-xs text-[var(--color-text-quaternary)] sm:px-8">
Nevkládejte do AI nástrojů hesla ani neredaktované osobní či citlivé
údaje.
</footer>
</div>
);
}