lil update bro

This commit is contained in:
k
2026-09-21 11:24:16 +02:00
parent 739c14324a
commit d37f4efd69
6 changed files with 171 additions and 59 deletions
+2
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KisAssistant</title>
<meta name="description" content="KisAssistant — minimal private chat assistant" />
<link rel="icon" type="image/jpeg" href="/logo.jpg" />
<meta name="theme-color" content="#059669" />
</head>
<body>
<div id="root"></div>
+6 -8
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { Bot, Cpu, PanelLeftOpen, SendHorizontal, Share2, Sparkles, User as UserIcon } from "lucide-react";
import { Bot, Cpu, PanelLeftOpen, SendHorizontal, Share2, User as UserIcon } from "lucide-react";
import type { ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts";
import { Avatar, IconButton, Picker, type PickerGroup, Spinner } from "./ui.tsx";
import { Avatar, IconButton, LOGO_URL, Logo, Picker, type PickerGroup, Spinner } from "./ui.tsx";
import { Markdown } from "./Markdown.tsx";
import { cn } from "../lib/cn.ts";
@@ -9,7 +9,7 @@ function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; au
const isUser = msg.role === "user";
return (
<div className={cn("flex gap-3", isUser && "flex-row-reverse")}>
<Avatar name={authorName} url={isUser ? authorAvatar : undefined} size={32} />
<Avatar name={authorName} url={isUser ? authorAvatar : LOGO_URL} size={32} />
<div className={cn("min-w-0 max-w-[85%]", isUser && "flex flex-col items-end")}>
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
{isUser ? <UserIcon size={12} /> : <Bot size={12} />}
@@ -101,7 +101,7 @@ export function Chat({
</IconButton>
)}
<span className="hidden items-center gap-2 font-semibold sm:flex">
<Sparkles size={16} className="text-emerald-600 dark:text-emerald-400" />
<Logo size={24} className="rounded-lg" />
{conv?.title || "New chat"}
</span>
{conv?.topic && (
@@ -129,9 +129,7 @@ export function Chat({
<div className="mx-auto w-full max-w-3xl space-y-5">
{messages.length === 0 && !streaming && (
<div className="py-16 text-center">
<span className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-3xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-lg">
<Sparkles size={24} />
</span>
<Logo size={56} className="mx-auto mb-4 rounded-[1.3rem] shadow-lg shadow-red-900/10" />
<h2 className="text-2xl font-bold tracking-tight">How can I help, {user.displayName.split(" ")[0]}?</h2>
<p className="mt-1 text-sm opacity-60">Pick a model, then type below. Markdown is supported.</p>
</div>
@@ -141,7 +139,7 @@ export function Chat({
))}
{streaming !== "" && (
<div className="flex gap-3">
<Avatar name="KisAssistant" size={32} />
<Avatar name="KisAssistant" url={LOGO_URL} size={32} />
<div className="min-w-0 max-w-[85%]">
<div className="mb-1 text-xs opacity-60">KisAssistant</div>
<div className="rounded-3xl rounded-tl-lg border border-stone-200/70 bg-white px-5 py-3.5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
+58 -28
View File
@@ -1,8 +1,13 @@
import { useState } from "react";
import { KeyRound, Sparkles, User } from "lucide-react";
import { ArrowRight, KeyRound, ShieldCheck, User, Zap } from "lucide-react";
import { api, setToken } from "../lib/api.ts";
import type { User as UserType } from "../lib/types.ts";
import { Button, Field, Input, Spinner } from "./ui.tsx";
import { Button, Input, Logo, Spinner } from "./ui.tsx";
const PERKS = [
{ icon: ShieldCheck, text: "Private by design — no cookies, no tracking" },
{ icon: Zap, text: "Your own models, streamed in real time" },
];
export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
const [username, setUsername] = useState("admin");
@@ -27,42 +32,67 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
}
return (
<div className="grid min-h-full place-items-center bg-gradient-to-br from-stone-100 via-stone-50 to-emerald-50 p-6 dark:from-zinc-950 dark:via-zinc-950 dark:to-emerald-950/30">
<div className="w-full max-w-sm rounded-[2rem] border border-stone-200/70 bg-white/90 p-8 shadow-2xl backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/90">
<div className="mb-6 flex items-center gap-3">
<span className="grid h-12 w-12 place-items-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-lg">
<Sparkles size={22} />
</span>
<div>
<h1 className="text-xl font-bold tracking-tight">KisAssistant</h1>
<p className="text-sm opacity-60">Private assistant</p>
<div className="grid min-h-full place-items-center overflow-y-auto bg-gradient-to-br from-stone-100 via-stone-50 to-emerald-100/60 p-6 dark:from-zinc-950 dark:via-zinc-950 dark:to-emerald-950/40">
<div className="w-full max-w-sm">
<div className="rounded-[2rem] border border-white/60 bg-white/80 p-8 shadow-2xl shadow-emerald-900/5 backdrop-blur-xl dark:border-zinc-700/60 dark:bg-zinc-900/80">
<div className="mb-2 flex flex-col items-center text-center">
<Logo size={68} className="mb-4 rounded-[1.5rem] shadow-lg shadow-red-900/20" />
<h1 className="text-2xl font-bold tracking-tight">Welcome back</h1>
<p className="mt-1 text-sm opacity-60">Sign in to KisAssistant to continue</p>
</div>
</div>
<form onSubmit={submit} className="space-y-4">
<Field label="Username">
<form onSubmit={submit} className="mt-6 space-y-3">
<div className="relative">
<User size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 opacity-40" />
<Input className="pl-10" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" required />
</div>
</Field>
<Field label="Access key" hint="Admin key lives in the server .env; user keys are issued by the admin.">
<div className="relative">
<KeyRound size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 opacity-40" />
<User size={17} className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 text-stone-400 dark:text-zinc-500" />
<Input
className="pl-10"
variant="soft"
className="pl-12"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
placeholder="Username"
aria-label="Username"
required
/>
</div>
<div className="relative">
<KeyRound size={17} className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 text-stone-400 dark:text-zinc-500" />
<Input
variant="soft"
className="pl-12"
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
autoComplete="current-password"
placeholder="Access key"
aria-label="Access key"
required
/>
</div>
</Field>
{error && <p className="rounded-2xl bg-red-50 px-4 py-2.5 text-sm text-red-600 dark:bg-red-950/40 dark:text-red-400" role="alert">{error}</p>}
<Button type="submit" size="lg" className="w-full" disabled={busy}>
{busy ? <Spinner /> : "Unlock"}
</Button>
</form>
{error && (
<p className="rounded-full bg-red-500/10 px-5 py-2.5 text-center text-sm text-red-600 dark:text-red-400" role="alert">
{error}
</p>
)}
<Button type="submit" variant="gradient" size="lg" className="w-full" disabled={busy}>
{busy ? <Spinner /> : (<>Unlock <ArrowRight size={17} /></>)}
</Button>
</form>
<ul className="mt-6 space-y-2 border-t border-stone-200/70 pt-5 dark:border-zinc-800">
{PERKS.map((p) => (
<li key={p.text} className="flex items-center gap-2.5 text-[13px] opacity-70">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-emerald-600/10 text-emerald-600 dark:text-emerald-400">
<p.icon size={14} />
</span>
{p.text}
</li>
))}
</ul>
</div>
<p className="mt-4 text-center text-xs opacity-50">
Admin key lives in the server <code>.env</code>; user keys are issued by the admin.
</p>
</div>
</div>
);
+4 -6
View File
@@ -1,8 +1,8 @@
import { useEffect, useState } from "react";
import { Bot, Globe, Sparkles, User as UserIcon } from "lucide-react";
import { Bot, Globe, User as UserIcon } from "lucide-react";
import { api } from "../lib/api.ts";
import type { SharedChat } from "../lib/types.ts";
import { Avatar } from "./ui.tsx";
import { Avatar, LOGO_URL, Logo } from "./ui.tsx";
import { Markdown } from "./Markdown.tsx";
export function SharePage({ publicId }: { publicId: string }) {
@@ -19,9 +19,7 @@ export function SharePage({ publicId }: { publicId: string }) {
<div className="min-h-full">
<header className="border-b border-stone-200/70 bg-white/70 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/70">
<div className="mx-auto flex w-full max-w-3xl items-center gap-2.5 px-4 py-3">
<span className="grid h-9 w-9 place-items-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white">
<Sparkles size={17} />
</span>
<Logo size={36} className="rounded-2xl" />
<div className="min-w-0 flex-1">
<p className="truncate font-semibold">{chat?.title ?? "Shared chat"}</p>
{chat && (
@@ -41,7 +39,7 @@ export function SharePage({ publicId }: { publicId: string }) {
{!chat && !error && <p className="py-16 text-center text-sm opacity-60">Loading shared chat…</p>}
{chat?.messages.map((m, i) => (
<div key={i} className="flex gap-3">
<Avatar name={m.role === "user" ? chat.authorName : "KisAssistant"} size={32} />
<Avatar name={m.role === "user" ? chat.authorName : "KisAssistant"} url={m.role === "user" ? undefined : LOGO_URL} size={32} />
<div className="min-w-0 max-w-[90%] flex-1">
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
{m.role === "user" ? <UserIcon size={12} /> : <Bot size={12} />}
+83 -17
View File
@@ -1,11 +1,12 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Check, ChevronDown, Copy, Loader2, X, type LucideIcon } from "lucide-react";
import { cn } from "../lib/cn.ts";
/* ---------- Button ---------- */
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "ghost" | "danger";
variant?: "primary" | "gradient" | "secondary" | "ghost" | "danger";
size?: "sm" | "md" | "lg" | "icon";
};
@@ -19,6 +20,7 @@ export function Button({ variant = "primary", size = "md", className, ...props }
size === "lg" && "px-6 py-3 text-base",
size === "icon" && "h-9 w-9",
variant === "primary" && "bg-emerald-600 text-white shadow-sm hover:bg-emerald-500 dark:bg-emerald-500 dark:hover:bg-emerald-400 dark:text-zinc-950",
variant === "gradient" && "bg-gradient-to-r from-emerald-600 to-teal-500 text-white shadow-lg shadow-emerald-600/25 hover:from-emerald-500 hover:to-teal-400 dark:from-emerald-500 dark:to-teal-400 dark:text-zinc-950",
variant === "secondary" && "border border-stone-200 bg-white hover:bg-stone-50 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:bg-zinc-800",
variant === "ghost" && "hover:bg-stone-200/60 dark:hover:bg-zinc-800",
variant === "danger" && "bg-red-600 text-white hover:bg-red-500",
@@ -45,13 +47,17 @@ export function Field({ label, hint, children }: { label: string; hint?: string;
);
}
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
export function Input({ variant = "default", className, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { variant?: "default" | "soft" }) {
return (
<input
className={cn(
"w-full rounded-2xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none transition",
"placeholder:text-stone-400 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20",
"dark:border-zinc-700 dark:bg-zinc-900 dark:placeholder:text-zinc-500",
"w-full text-sm outline-none transition placeholder:text-stone-400 dark:placeholder:text-zinc-500",
variant === "default" &&
"rounded-2xl border border-stone-200 bg-white px-4 py-2.5 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 dark:border-zinc-700 dark:bg-zinc-900",
// soft: onboarding-style pill field, blends into rounded cards
variant === "soft" &&
"rounded-full border border-transparent bg-stone-100 px-5 py-3 focus:border-emerald-500/60 focus:bg-white focus:ring-4 focus:ring-emerald-500/15 dark:bg-zinc-800/80 dark:focus:bg-zinc-900",
className,
)}
{...props}
/>
@@ -95,21 +101,64 @@ export function Picker({
align?: "left" | "right";
}) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ top: 0, left: 0, width: 208 });
const btnRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const all: PickerGroup[] = groups ?? [{ group: "", options: options ?? [] }];
const selected = all.flatMap((g) => g.options).find((o) => o.value === value);
// Position the popover in viewport coords (rendered in a portal so no
// ancestor overflow — e.g. Modal's overflow-y-auto — can clip it).
// Re-measured live so the popover stays anchored to its field.
const updatePos = useCallback(() => {
const el = btnRef.current;
if (!el) return false;
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) return false; // not laid out yet
const width = Math.min(Math.max(r.width, 208), 288);
let left = align === "right" ? r.right - width : r.left;
left = Math.max(8, Math.min(left, window.innerWidth - width - 8));
const maxH = 288; // matches max-h-72
const below = r.bottom + 8;
const top = below + maxH > window.innerHeight && r.top - maxH - 8 > 8
? Math.max(8, r.top - maxH - 8) // flip above when there is no room below
: below;
setPos({ top, left, width });
return true;
}, [align]);
useLayoutEffect(() => {
if (!open) return;
updatePos();
// Second pass after paint: catches late layout shifts (fonts, images).
const raf = requestAnimationFrame(() => updatePos());
return () => cancelAnimationFrame(raf);
}, [open, updatePos]);
useEffect(() => {
if (!open) return;
const h = (e: KeyboardEvent) => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [open ]);
const onScroll = (e: Event) => {
if (popRef.current?.contains(e.target as Node)) return; // scrolling inside the list
updatePos(); // follow the anchor instead of closing
};
const onResize = () => updatePos();
window.addEventListener("keydown", onKey);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
};
}, [open, updatePos]);
return (
<div className={cn("relative", className)}>
<div className={className}>
<button
ref={btnRef}
type="button"
aria-label={ariaLabel}
aria-haspopup="listbox"
@@ -127,16 +176,15 @@ export function Picker({
<span className="truncate">{selected?.label ?? placeholder}</span>
<ChevronDown size={15} className={cn("shrink-0 opacity-50 transition-transform", open && "rotate-180")} />
</button>
{open && (
{open && createPortal(
<>
<div className="fixed inset-0 z-40 cursor-default" onClick={() => setOpen(false)} />
<div
ref={popRef}
role="listbox"
aria-label={ariaLabel}
className={cn(
"absolute top-full z-50 mt-2 max-h-72 min-w-52 max-w-72 overflow-y-auto rounded-2xl border border-stone-200 bg-white p-1.5 shadow-xl dark:border-zinc-700 dark:bg-zinc-900",
align === "right" ? "right-0" : "left-0",
)}
className="fixed z-50 max-h-72 overflow-y-auto rounded-2xl border border-stone-200 bg-white p-1.5 shadow-xl dark:border-zinc-700 dark:bg-zinc-900"
style={{ top: pos.top, left: pos.left, width: pos.width }}
>
{all.map((g) => (
<div key={g.group || "default"}>
@@ -172,7 +220,8 @@ export function Picker({
</div>
))}
</div>
</>
</>,
document.body,
)}
</div>
);
@@ -342,6 +391,23 @@ export function Avatar({ name, url, size = 36 }: { name: string; url?: string; s
);
}
/* ---------- Brand logo ---------- */
export const LOGO_URL = "/logo.jpg";
export function Logo({ size = 36, className }: { size?: number; className?: string }) {
return (
<img
src={LOGO_URL}
alt="KisAssistant logo"
width={size}
height={size}
className={cn("shrink-0 object-cover", className)}
style={{ width: size, height: size }}
/>
);
}
/* ---------- Misc ---------- */
export function Spinner({ size = 18 }: { size?: number }) {
+18
View File
@@ -64,3 +64,21 @@ body {
@keyframes blink {
50% { opacity: 0; }
}
/* Kill Chrome's yellow autofill background (login / password fields) */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 60px rgb(245 245 244) inset; /* stone-100, matches soft fields */
-webkit-text-fill-color: #1c1917; /* stone-900 */
caret-color: #1c1917;
}
.dark input:-webkit-autofill,
.dark input:-webkit-autofill:hover,
.dark input:-webkit-autofill:focus,
.dark input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 60px #27272a inset; /* zinc-800 */
-webkit-text-fill-color: #f4f4f5; /* zinc-100 */
caret-color: #f4f4f5;
}