lil update bro
This commit is contained in:
@@ -5,6 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>KisAssistant</title>
|
<title>KisAssistant</title>
|
||||||
<meta name="description" content="KisAssistant — minimal private chat assistant" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
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 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 { Markdown } from "./Markdown.tsx";
|
||||||
import { cn } from "../lib/cn.ts";
|
import { cn } from "../lib/cn.ts";
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; au
|
|||||||
const isUser = msg.role === "user";
|
const isUser = msg.role === "user";
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex gap-3", isUser && "flex-row-reverse")}>
|
<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={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">
|
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
|
||||||
{isUser ? <UserIcon size={12} /> : <Bot size={12} />}
|
{isUser ? <UserIcon size={12} /> : <Bot size={12} />}
|
||||||
@@ -101,7 +101,7 @@ export function Chat({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
<span className="hidden items-center gap-2 font-semibold sm:flex">
|
<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"}
|
{conv?.title || "New chat"}
|
||||||
</span>
|
</span>
|
||||||
{conv?.topic && (
|
{conv?.topic && (
|
||||||
@@ -129,9 +129,7 @@ export function Chat({
|
|||||||
<div className="mx-auto w-full max-w-3xl space-y-5">
|
<div className="mx-auto w-full max-w-3xl space-y-5">
|
||||||
{messages.length === 0 && !streaming && (
|
{messages.length === 0 && !streaming && (
|
||||||
<div className="py-16 text-center">
|
<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">
|
<Logo size={56} className="mx-auto mb-4 rounded-[1.3rem] shadow-lg shadow-red-900/10" />
|
||||||
<Sparkles size={24} />
|
|
||||||
</span>
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">How can I help, {user.displayName.split(" ")[0]}?</h2>
|
<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>
|
<p className="mt-1 text-sm opacity-60">Pick a model, then type below. Markdown is supported.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,7 +139,7 @@ export function Chat({
|
|||||||
))}
|
))}
|
||||||
{streaming !== "" && (
|
{streaming !== "" && (
|
||||||
<div className="flex gap-3">
|
<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="min-w-0 max-w-[85%]">
|
||||||
<div className="mb-1 text-xs opacity-60">KisAssistant</div>
|
<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">
|
<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">
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { useState } from "react";
|
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 { api, setToken } from "../lib/api.ts";
|
||||||
import type { User as UserType } from "../lib/types.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 }) {
|
export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
||||||
const [username, setUsername] = useState("admin");
|
const [username, setUsername] = useState("admin");
|
||||||
@@ -27,42 +32,67 @@ export function Login({ onLogin }: { onLogin: (user: UserType) => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="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 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="w-full max-w-sm">
|
||||||
<div className="mb-6 flex items-center gap-3">
|
<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">
|
||||||
<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">
|
<div className="mb-2 flex flex-col items-center text-center">
|
||||||
<Sparkles size={22} />
|
<Logo size={68} className="mb-4 rounded-[1.5rem] shadow-lg shadow-red-900/20" />
|
||||||
</span>
|
<h1 className="text-2xl font-bold tracking-tight">Welcome back</h1>
|
||||||
<div>
|
<p className="mt-1 text-sm opacity-60">Sign in to KisAssistant to continue</p>
|
||||||
<h1 className="text-xl font-bold tracking-tight">KisAssistant</h1>
|
|
||||||
<p className="text-sm opacity-60">Private assistant</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<form onSubmit={submit} className="space-y-4">
|
<form onSubmit={submit} className="mt-6 space-y-3">
|
||||||
<Field label="Username">
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User 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" 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" />
|
|
||||||
<Input
|
<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"
|
type="password"
|
||||||
value={key}
|
value={key}
|
||||||
onChange={(e) => setKey(e.target.value)}
|
onChange={(e) => setKey(e.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
placeholder="Access key"
|
||||||
|
aria-label="Access key"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
{error && (
|
||||||
{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>}
|
<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">
|
||||||
<Button type="submit" size="lg" className="w-full" disabled={busy}>
|
{error}
|
||||||
{busy ? <Spinner /> : "Unlock"}
|
</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" variant="gradient" size="lg" className="w-full" disabled={busy}>
|
||||||
|
{busy ? <Spinner /> : (<>Unlock <ArrowRight size={17} /></>)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 { api } from "../lib/api.ts";
|
||||||
import type { SharedChat } from "../lib/types.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";
|
import { Markdown } from "./Markdown.tsx";
|
||||||
|
|
||||||
export function SharePage({ publicId }: { publicId: string }) {
|
export function SharePage({ publicId }: { publicId: string }) {
|
||||||
@@ -19,9 +19,7 @@ export function SharePage({ publicId }: { publicId: string }) {
|
|||||||
<div className="min-h-full">
|
<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">
|
<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">
|
<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">
|
<Logo size={36} className="rounded-2xl" />
|
||||||
<Sparkles size={17} />
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate font-semibold">{chat?.title ?? "Shared chat"}</p>
|
<p className="truncate font-semibold">{chat?.title ?? "Shared chat"}</p>
|
||||||
{chat && (
|
{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 && !error && <p className="py-16 text-center text-sm opacity-60">Loading shared chat…</p>}
|
||||||
{chat?.messages.map((m, i) => (
|
{chat?.messages.map((m, i) => (
|
||||||
<div key={i} className="flex gap-3">
|
<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="min-w-0 max-w-[90%] flex-1">
|
||||||
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
|
<div className="mb-1 flex items-center gap-1.5 text-xs opacity-60">
|
||||||
{m.role === "user" ? <UserIcon size={12} /> : <Bot size={12} />}
|
{m.role === "user" ? <UserIcon size={12} /> : <Bot size={12} />}
|
||||||
|
|||||||
@@ -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 { Check, ChevronDown, Copy, Loader2, X, type LucideIcon } from "lucide-react";
|
||||||
import { cn } from "../lib/cn.ts";
|
import { cn } from "../lib/cn.ts";
|
||||||
|
|
||||||
/* ---------- Button ---------- */
|
/* ---------- Button ---------- */
|
||||||
|
|
||||||
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
variant?: "primary" | "gradient" | "secondary" | "ghost" | "danger";
|
||||||
size?: "sm" | "md" | "lg" | "icon";
|
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 === "lg" && "px-6 py-3 text-base",
|
||||||
size === "icon" && "h-9 w-9",
|
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 === "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 === "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 === "ghost" && "hover:bg-stone-200/60 dark:hover:bg-zinc-800",
|
||||||
variant === "danger" && "bg-red-600 text-white hover:bg-red-500",
|
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 (
|
return (
|
||||||
<input
|
<input
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full rounded-2xl border border-stone-200 bg-white px-4 py-2.5 text-sm outline-none transition",
|
"w-full text-sm outline-none transition placeholder:text-stone-400 dark:placeholder:text-zinc-500",
|
||||||
"placeholder:text-stone-400 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20",
|
variant === "default" &&
|
||||||
"dark:border-zinc-700 dark:bg-zinc-900 dark:placeholder:text-zinc-500",
|
"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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -95,21 +101,64 @@ export function Picker({
|
|||||||
align?: "left" | "right";
|
align?: "left" | "right";
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
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 all: PickerGroup[] = groups ?? [{ group: "", options: options ?? [] }];
|
||||||
const selected = all.flatMap((g) => g.options).find((o) => o.value === value);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const h = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") setOpen(false);
|
if (e.key === "Escape") setOpen(false);
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", h);
|
const onScroll = (e: Event) => {
|
||||||
return () => window.removeEventListener("keydown", h);
|
if (popRef.current?.contains(e.target as Node)) return; // scrolling inside the list
|
||||||
}, [open ]);
|
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 (
|
return (
|
||||||
<div className={cn("relative", className)}>
|
<div className={className}>
|
||||||
<button
|
<button
|
||||||
|
ref={btnRef}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
@@ -127,16 +176,15 @@ export function Picker({
|
|||||||
<span className="truncate">{selected?.label ?? placeholder}</span>
|
<span className="truncate">{selected?.label ?? placeholder}</span>
|
||||||
<ChevronDown size={15} className={cn("shrink-0 opacity-50 transition-transform", open && "rotate-180")} />
|
<ChevronDown size={15} className={cn("shrink-0 opacity-50 transition-transform", open && "rotate-180")} />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && createPortal(
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40 cursor-default" onClick={() => setOpen(false)} />
|
<div className="fixed inset-0 z-40 cursor-default" onClick={() => setOpen(false)} />
|
||||||
<div
|
<div
|
||||||
|
ref={popRef}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
className={cn(
|
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"
|
||||||
"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",
|
style={{ top: pos.top, left: pos.left, width: pos.width }}
|
||||||
align === "right" ? "right-0" : "left-0",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{all.map((g) => (
|
{all.map((g) => (
|
||||||
<div key={g.group || "default"}>
|
<div key={g.group || "default"}>
|
||||||
@@ -172,7 +220,8 @@ export function Picker({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>,
|
||||||
|
document.body,
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 ---------- */
|
/* ---------- Misc ---------- */
|
||||||
|
|
||||||
export function Spinner({ size = 18 }: { size?: number }) {
|
export function Spinner({ size = 18 }: { size?: number }) {
|
||||||
|
|||||||
@@ -64,3 +64,21 @@ body {
|
|||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
50% { opacity: 0; }
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user