From d37f4efd69806874bb058a3a381b9562b5b18ebb Mon Sep 17 00:00:00 2001 From: Anais Saraiva Date: Mon, 21 Sep 2026 11:24:16 +0200 Subject: [PATCH] lil update bro --- frontend/index.html | 2 + frontend/src/components/Chat.tsx | 14 ++-- frontend/src/components/Login.tsx | 86 ++++++++++++++-------- frontend/src/components/SharePage.tsx | 10 ++- frontend/src/components/ui.tsx | 100 +++++++++++++++++++++----- frontend/src/index.css | 18 +++++ 6 files changed, 171 insertions(+), 59 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 2e98a17..761a663 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,8 @@ KisAssistant + +
diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 20e69d8..3f64534 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -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 (
- +
{isUser ? : } @@ -101,7 +101,7 @@ export function Chat({ )} - + {conv?.title || "New chat"} {conv?.topic && ( @@ -129,9 +129,7 @@ export function Chat({
{messages.length === 0 && !streaming && (
- - - +

How can I help, {user.displayName.split(" ")[0]}?

Pick a model, then type below. Markdown is supported.

@@ -141,7 +139,7 @@ export function Chat({ ))} {streaming !== "" && (
- +
KisAssistant
diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index 4b33b3d..2481f96 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -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 ( -
-
-
- - - -
-

KisAssistant

-

Private assistant

+
+
+
+
+ +

Welcome back

+

Sign in to KisAssistant to continue

-
-
- + +
- - setUsername(e.target.value)} autoComplete="username" required /> -
-
- -
- + setUsername(e.target.value)} + autoComplete="username" + placeholder="Username" + aria-label="Username" + required + /> +
+
+ + setKey(e.target.value)} autoComplete="current-password" + placeholder="Access key" + aria-label="Access key" required />
-
- {error &&

{error}

} - -
+ {error && ( +

+ {error} +

+ )} + + + +
    + {PERKS.map((p) => ( +
  • + + + + {p.text} +
  • + ))} +
+
+

+ Admin key lives in the server .env; user keys are issued by the admin. +

); diff --git a/frontend/src/components/SharePage.tsx b/frontend/src/components/SharePage.tsx index 6165d69..13cc449 100644 --- a/frontend/src/components/SharePage.tsx +++ b/frontend/src/components/SharePage.tsx @@ -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 }) {
- - - +

{chat?.title ?? "Shared chat"}

{chat && ( @@ -41,7 +39,7 @@ export function SharePage({ publicId }: { publicId: string }) { {!chat && !error &&

Loading shared chat…

} {chat?.messages.map((m, i) => (
- +
{m.role === "user" ? : } diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx index 014460c..e681414 100644 --- a/frontend/src/components/ui.tsx +++ b/frontend/src/components/ui.tsx @@ -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 & { - 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) { +export function Input({ variant = "default", className, ...props }: React.InputHTMLAttributes & { variant?: "default" | "soft" }) { return ( @@ -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(null); + const popRef = useRef(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 ( -
+
- {open && ( + {open && createPortal( <>
setOpen(false)} />
{all.map((g) => (
@@ -172,7 +220,8 @@ export function Picker({
))}
- + , + document.body, )}
); @@ -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 ( + KisAssistant logo + ); +} + /* ---------- Misc ---------- */ export function Spinner({ size = 18 }: { size?: number }) { diff --git a/frontend/src/index.css b/frontend/src/index.css index a291501..2fdabc9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; +}