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
-
-
+ {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 (
+

+ );
+}
+
/* ---------- 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;
+}