167 lines
5.9 KiB
TypeScript
167 lines
5.9 KiB
TypeScript
"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="mt-auto 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>
|
|
);
|
|
}
|