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,166 @@
"use client";
import * as Icons from "lucide-react";
import Link from "next/link";
import { ArrowUpRight } from "lucide-react";
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
export type AppTile = {
id: string;
title: string;
description: string;
category: string;
icon: string;
accent: string;
href: string;
featured?: boolean;
/** Open in a new tab. Defaults to false — task-style apps replace the
current tab so the browser back button works naturally. Set true for
long-running sessions like the chat tile. */
newTab?: boolean;
/** Visual kind. "tool" (default) is a normal app tile. "cta" renders
a dashed-border invitation card visually distinct from the tools. */
kind?: "tool" | "cta";
};
const accentVar = (accent: string) => `var(--color-accent-${accent})`;
export function Tile({ app }: { app: AppTile }) {
const { resolvedTheme } = useTheme();
const LucideIcon =
(Icons[app.icon as keyof typeof Icons] as React.ComponentType<{
className?: string;
}>) ?? Icons.Sparkles;
const isExternal = app.href.startsWith("http");
const isDisabled = app.href === "#";
// Append current theme to internal-app URLs so they pick up the same scheme.
const isInternalApp = isExternal && app.href.includes(".klas.chat");
const hrefWithTheme = (() => {
if (!isInternalApp || !resolvedTheme) return app.href;
try {
const u = new URL(app.href);
u.searchParams.set("theme", resolvedTheme);
return u.toString();
} catch {
return app.href;
}
})();
const isCta = app.kind === "cta";
const content = isCta ? (
// ─── CTA / "request a new tool" — visually distinct ───
<div
className="group relative flex h-full flex-col items-center justify-center gap-3 overflow-hidden rounded-lg border border-dashed border-[var(--color-border-hover)] bg-transparent p-6 text-center transition-all duration-200 ease-in-out hover:border-solid hover:bg-[var(--color-card-hover)]"
style={{ borderColor: `color-mix(in srgb, ${accentVar(app.accent)} 35%, var(--color-border-hover))` }}
>
<div
className="flex h-12 w-12 items-center justify-center rounded-full"
style={{
background: `color-mix(in srgb, ${accentVar(app.accent)} 12%, transparent)`,
color: accentVar(app.accent),
}}
>
<LucideIcon className="h-6 w-6" />
</div>
<div>
<h3 className="system-sm-semibold text-[var(--color-text-primary)]">
{app.title}
</h3>
<p className="system-xs-regular mt-1.5 text-[var(--color-text-tertiary)]">
{app.description}
</p>
</div>
</div>
) : (
// ─── Standard tool tile ───
<div
className={cn(
"group relative flex h-full flex-col overflow-hidden rounded-lg border-[0.5px] border-[var(--color-border-default)] bg-[var(--color-card)] shadow-[var(--shadow-card)] transition-all duration-200 ease-in-out",
!isDisabled &&
"hover:bg-[var(--color-card-hover)] hover:shadow-[var(--shadow-card-hover)] hover:border-[var(--color-border-hover)]",
isDisabled && "opacity-70",
)}
>
<div className="flex items-start gap-3 px-[14px] pt-[14px] pb-3">
<div
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[10px]"
style={{
background: `color-mix(in srgb, ${accentVar(app.accent)} 12%, transparent)`,
color: accentVar(app.accent),
}}
>
<LucideIcon className="h-5 w-5" />
</div>
<div className="min-w-0 grow py-px">
<div className="flex items-start justify-between gap-2">
<h3 className="system-sm-semibold truncate text-[var(--color-text-secondary)]">
{app.title}
</h3>
{!isDisabled && (
<ArrowUpRight className="mt-0.5 h-4 w-4 shrink-0 text-[var(--color-text-quaternary)] opacity-0 transition-all group-hover:opacity-100" />
)}
</div>
<div className="system-2xs-medium-uppercase mt-0.5 truncate text-[var(--color-text-tertiary)]">
{app.category}
</div>
</div>
</div>
<div className="px-[14px] pb-3">
<p className="system-xs-regular line-clamp-3 text-[var(--color-text-tertiary)]">
{app.description}
</p>
</div>
{isDisabled && (
<div className="px-[14px] pb-[14px]">
<span className="system-2xs-medium-uppercase inline-flex items-center rounded-full bg-[var(--color-bg-tertiary)] px-2 py-1 text-[var(--color-text-tertiary)]">
Brzy k dispozici
</span>
</div>
)}
{!isDisabled && app.featured && (
<div className="px-[14px] pb-[14px]">
<span
className="system-2xs-medium-uppercase inline-flex items-center rounded-full px-2 py-1 text-[var(--color-primary-foreground)]"
style={{ background: accentVar(app.accent) }}
>
Doporučeno
</span>
</div>
)}
</div>
);
if (isDisabled) {
return <div className="h-full">{content}</div>;
}
if (isExternal) {
const inNewTab = app.newTab === true;
return (
<a
href={hrefWithTheme}
target={inNewTab ? "_blank" : "_self"}
rel={inNewTab ? "noopener noreferrer" : undefined}
className="h-full rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-bg-secondary)]"
>
{content}
</a>
);
}
return (
<Link
href={app.href}
className="h-full rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-bg-secondary)]"
>
{content}
</Link>
);
}