From 2dab720e55718f2e58e09419654ae679d3324622 Mon Sep 17 00:00:00 2001 From: Anais Saraiva Date: Thu, 24 Sep 2026 13:06:10 +0200 Subject: [PATCH] new components --- frontend/src/components/Chat.tsx | 7 +- frontend/src/components/Markdown.tsx | 15 +- frontend/src/components/dialogs.tsx | 229 ++++++++++++++++++++++++--- frontend/src/components/ui.tsx | 25 ++- frontend/src/lib/accent.ts | 38 ++++- frontend/src/lib/i18n.ts | 6 + prompt_v2 | 8 + 7 files changed, 296 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 803c06f..6adddf0 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { Archive, ArchiveRestore, BookOpen, Bot, Check, Copy, Cpu, FileText, Flag, Menu, Mic, PanelLeftOpen, Paperclip, RotateCcw, ScanText, Search, SendHorizontal, Share2, Square, User as UserIcon, Volume2, VolumeX, X } from "lucide-react"; import type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts"; import { api, ApiError, type ReportReason } from "../lib/api.ts"; -import { AssistantAvatar, Avatar, Button, Field, FlowerMark, IconButton, Modal, Picker, type PickerGroup, Spinner } from "./ui.tsx"; +import { AssistantAvatar, Avatar, Button, copyText, Field, FlowerMark, IconButton, Modal, Picker, type PickerGroup, Spinner } from "./ui.tsx"; import { Markdown } from "./Markdown.tsx"; import { UserMessageContent } from "./MessageContent.tsx"; import { cn } from "../lib/cn.ts"; @@ -17,10 +17,11 @@ function MessageBubble({ msg, index, authorAvatar, authorName, onReport, reports const isUser = msg.role === "user"; const [copied, setCopied] = useState(false); function copy() { - navigator.clipboard.writeText(msg.content).then(() => { + copyText(msg.content).then((ok) => { + if (!ok) return; setCopied(true); setTimeout(() => setCopied(false), 1500); - }).catch(() => {}); + }); } function searchWeb() { window.open(`https://duckduckgo.com/?q=${encodeURIComponent(msg.content.slice(0, 400))}`, "_blank", "noopener,noreferrer"); diff --git a/frontend/src/components/Markdown.tsx b/frontend/src/components/Markdown.tsx index 54115e6..535bffc 100644 --- a/frontend/src/components/Markdown.tsx +++ b/frontend/src/components/Markdown.tsx @@ -4,6 +4,16 @@ import remarkGfm from "remark-gfm"; import { CopyButton } from "./ui.tsx"; import { highlightCode } from "../lib/highlight.tsx"; +/** Raw source text of a react-markdown element (children may be split). */ +export function extractCodeText(children: unknown): string { + if (children === null || children === undefined) return ""; + if (typeof children === "string" || typeof children === "number") return String(children); + if (Array.isArray(children)) return children.map(extractCodeText).join(""); + const props = (children as { props?: { children?: unknown } })?.props; + if (props && typeof props === "object" && "children" in props) return extractCodeText(props.children); + return ""; +} + function CodeBlock({ code, lang }: { code: string; lang: string }) { const nodes = useMemo(() => highlightCode(code, lang), [code, lang]); return ( @@ -31,9 +41,10 @@ export function Markdown({ text }: { text: string }) { // react-markdown nests inside
; render our block instead.
             const child = Array.isArray(children) ? children[0] : children;
             const props = (child as { props?: { className?: string; children?: unknown } })?.props;
-            const className = props?.className || "";
+            if (!props || typeof props !== "object") return 
{children}
; + const className = typeof props.className === "string" ? props.className : ""; const lang = className.replace("language-", ""); - const code = String(props?.children ?? "").replace(/\n$/, ""); + const code = extractCodeText(props.children).replace(/\n$/, ""); return ; }, code({ children }) { diff --git a/frontend/src/components/dialogs.tsx b/frontend/src/components/dialogs.tsx index 97bc074..2296eeb 100644 --- a/frontend/src/components/dialogs.tsx +++ b/frontend/src/components/dialogs.tsx @@ -1,9 +1,9 @@ -import { useEffect, useRef, useState } from "react"; -import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Mic, Palette, Pencil, Play, Plug, ScanText, Search, Settings2, Share2, ShieldCheck, Sparkles, Square, Tag, Trash2, Unplug, User as UserIcon, Volume2, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Mic, Palette, Pencil, Pipette, Play, Plug, ScanText, Search, Settings2, Share2, ShieldCheck, Sparkles, Square, Tag, Trash2, Unplug, User as UserIcon, Volume2, X } from "lucide-react"; import { api, type UserProvider, type VoicePreview } from "../lib/api.ts"; import { pushToast } from "../lib/toasts.ts"; import { cn } from "../lib/cn.ts"; -import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.ts"; +import { ACCENT_PRESETS, applyAccent, currentAccent, hexToHsv, hsvToHex, previewAccent, type AccentState } from "../lib/accent.ts"; import { detectLang, getVoicePrefs, listTtsVoices, OPENAI_STATIC_VOICES, pickVoice, playText, setVoicePrefs, stopPlayback, ttsSupported, type VoicePrefs } from "../lib/voice.ts"; import { FEATURES, setFeature, useFeatures } from "../lib/features.ts"; import type { Conversation, FilePreview, User } from "../lib/types.ts"; @@ -794,6 +794,7 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v function AccentSection() { const { t } = useT(); const [state, setState] = useState(() => currentAccent()); + const [pickerOpen, setPickerOpen] = useState(false); function pick(presetId: string) { const next = { ...state, presetId }; @@ -802,13 +803,6 @@ function AccentSection() { pushToast(t("toast.accentChanged"), { icon: "accent", tag: "accent" }); } - function pickCustom(hex: string) { - const next = { presetId: "custom", customHex: hex }; - setState(next); - previewAccent(hex); - pushToast(t("toast.accentChanged"), { icon: "accent", tag: "accent" }); - } - return (
{t("settings.accent")} @@ -830,26 +824,213 @@ function AccentSection() { style={{ backgroundColor: p.base }} /> ))} -
+ {pickerOpen && ( + p.id === state.presetId)?.base ?? "#10b981")} + onPreview={(hex) => { + setState({ presetId: "custom", customHex: hex }); + previewAccent(hex); + }} + onCommit={(hex) => { + const next = { presetId: "custom", customHex: hex }; + setState(next); + applyAccent(next); + pushToast(t("toast.accentChanged"), { icon: "accent", tag: "accent" }); + }} + /> + )} + + ); +} + +/* ---------- Custom accent picker: SV square + hue slider + hex field ---------- */ + +function AccentCustomPanel({ initialHex, onPreview, onCommit }: { + initialHex: string; + onPreview: (hex: string) => void; + onCommit: (hex: string) => void; +}) { + const { t } = useT(); + const start = hexToHsv(initialHex) ?? { h: 160, s: 84, v: 73 }; + const [h, setH] = useState(start.h); + const [s, setS] = useState(start.s); + const [v, setV] = useState(start.v); + const [hexInput, setHexInput] = useState(() => hsvToHex(start.h, start.s, start.v)); + const [hexError, setHexError] = useState(false); + const [drag, setDrag] = useState(null); + const svRef = useRef(null); + const hueRef = useRef(null); + // Synchronous mirror (pointerup fires before setState flushes). + const hsvRef = useRef({ h: start.h, s: start.s, v: start.v }); + + const currentHex = useMemo(() => hsvToHex(h, s, v), [h, s, v]); + + function applyHsv(nh: number, ns: number, nv: number) { + const c = { + h: Math.min(359.99, Math.max(0, nh)), + s: Math.min(100, Math.max(0, ns)), + v: Math.min(100, Math.max(0, nv)), + }; + hsvRef.current = c; + setH(c.h); + setS(c.s); + setV(c.v); + const hex = hsvToHex(c.h, c.s, c.v); + setHexInput(hex); + setHexError(false); + onPreview(hex); + } + + function posToSv(e: React.PointerEvent): void { + const el = svRef.current; + if (!el) return; + const r = el.getBoundingClientRect(); + applyHsv(hsvRef.current.h, ((e.clientX - r.left) / r.width) * 100, (1 - (e.clientY - r.top) / r.height) * 100); + } + + function posToHue(e: React.PointerEvent): void { + const el = hueRef.current; + if (!el) return; + const r = el.getBoundingClientRect(); + const nh = ((e.clientX - r.left) / r.width) * 360; + const c = hsvRef.current; + applyHsv(nh, c.s, c.v); + } + + function begin(kind: "sv" | "hue", e: React.PointerEvent) { + (e.target as HTMLElement).setPointerCapture?.(e.pointerId); + setDrag(kind); + if (kind === "sv") posToSv(e); + else posToHue(e); + } + + function commitHex(raw: string) { + const m = /^#?([0-9a-fA-F]{6})$/.exec(raw.trim()); + if (!m) { + setHexError(true); + setHexInput(currentHex); + return; + } + const hex = `#${m[1]!.toLowerCase()}`; + const c = hexToHsv(hex); + if (c) { + hsvRef.current = c; + setH(c.h); + setS(c.s); + setV(c.v); + setHexInput(hex); + setHexError(false); + onPreview(hex); + onCommit(hex); + } + } + + return ( +
+
begin("sv", e)} + onPointerMove={(e) => { if (drag === "sv") posToSv(e); }} + onPointerUp={() => { if (drag === "sv") { setDrag(null); onCommit(hsvToHex(hsvRef.current.h, hsvRef.current.s, hsvRef.current.v)); } }} + onPointerCancel={() => setDrag(null)} + onKeyDown={(e) => { + const step = e.shiftKey ? 10 : 2; + const c = hsvRef.current; + if (e.key === "ArrowLeft") { applyHsv(c.h, c.s - step, c.v); onCommit(hsvToHex(Math.min(359.99, Math.max(0, c.h)), Math.min(100, Math.max(0, c.s - step)), c.v)); } + else if (e.key === "ArrowRight") { applyHsv(c.h, c.s + step, c.v); onCommit(hsvToHex(Math.min(359.99, Math.max(0, c.h)), Math.min(100, Math.max(0, c.s + step)), c.v)); } + else if (e.key === "ArrowUp") { applyHsv(c.h, c.s, c.v + step); onCommit(hsvToHex(Math.min(359.99, Math.max(0, c.h)), c.s, Math.min(100, Math.max(0, c.v + step)))); } + else if (e.key === "ArrowDown") { applyHsv(c.h, c.s, c.v - step); onCommit(hsvToHex(Math.min(359.99, Math.max(0, c.h)), c.s, Math.min(100, Math.max(0, c.v - step)))); } + else return; + e.preventDefault(); + }} + className="relative h-40 w-full cursor-crosshair touch-none select-none overflow-hidden rounded-2xl" + style={{ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${h}, 100%, 50%))` }} + > + +
+
begin("hue", e)} + onPointerMove={(e) => { if (drag === "hue") posToHue(e); }} + onPointerUp={() => { if (drag === "hue") { setDrag(null); onCommit(hsvToHex(hsvRef.current.h, hsvRef.current.s, hsvRef.current.v)); } }} + onPointerCancel={() => setDrag(null)} + onKeyDown={(e) => { + if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; + const nh = hsvRef.current.h + (e.key === "ArrowRight" ? 4 : -4); + const c = hsvRef.current; + applyHsv(nh, c.s, c.v); + onCommit(hsvToHex(nh, c.s, c.v)); + e.preventDefault(); + }} + className="relative h-3 w-full cursor-pointer touch-none select-none rounded-full" + style={{ background: "linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)" }} + > + +
+
+ + + { + setHexInput(e.target.value); + const m = /^#?([0-9a-fA-F]{6})$/.exec(e.target.value.trim()); + if (m) { + const hex = `#${m[1]!.toLowerCase()}`; + const c = hexToHsv(hex); + if (c) { + hsvRef.current = c; + setH(c.h); + setS(c.s); + setV(c.v); + setHexError(false); + onPreview(hex); + } + } else { + setHexError(e.target.value.trim().length > 0); + } + }} + onBlur={(e) => commitHex(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") commitHex((e.target as HTMLInputElement).value); }} + maxLength={7} + spellCheck={false} + autoComplete="off" + className={cn("font-mono uppercase", hexError && "!border-red-500")} /> - +
); diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx index 6563c1a..b59a58b 100644 --- a/frontend/src/components/ui.tsx +++ b/frontend/src/components/ui.tsx @@ -633,6 +633,28 @@ export function Toggle({ ); } +/** Clipboard write with legacy fallback (non-secure contexts lack the async API). */ +export async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + try { + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(ta); + return ok; + } catch { + return false; + } + } +} + export function CopyButton({ text, label }: { text: string; label?: string }) { const { t } = useT(); const [done, setDone] = useState(false); @@ -640,7 +662,8 @@ export function CopyButton({ text, label }: { text: string; label?: string }) {