refactor(ui): update UI components, theme system and utilities

This commit is contained in:
ZhenYi 2026-05-12 13:05:28 +08:00
parent b384f92bbf
commit e86803d235
10 changed files with 1033 additions and 809 deletions

View File

@ -1,7 +1,6 @@
import { memo } from "react"; import { memo, useRef, useEffect } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize"; import rehypeSanitize from "rehype-sanitize";
interface MarkdownRendererProps { interface MarkdownRendererProps {
@ -9,12 +8,74 @@ interface MarkdownRendererProps {
className?: string; className?: string;
} }
/** Sanitize raw HTML: strip <script> and event handlers. */
function sanitizeHtml(raw: string): string {
return raw
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
}
/** Render raw HTML inside a Shadow DOM to scope CSS to this block only. */
function HtmlBlock({ html }: { html: string }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const shadow = el.shadowRoot || el.attachShadow({ mode: "open" });
shadow.innerHTML = sanitizeHtml(html);
}, [html]);
return <div ref={ref} className="my-2" />;
}
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className }: MarkdownRendererProps) { export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
return ( return (
<div className={className}> <div className={className}>
<style>{`
.markdown-table-wrapper table {
border-collapse: collapse;
width: 100%;
font-size: 14px;
}
.markdown-table-wrapper th,
.markdown-table-wrapper td {
border: 1px solid var(--border-default);
padding: 8px 12px;
text-align: left;
}
.markdown-table-wrapper th {
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
background-color: var(--surface-elevated);
}
.markdown-table-wrapper td {
color: var(--text-secondary);
}
.markdown-table-wrapper tr {
border-bottom: 1px solid var(--border-subtle);
}
.markdown-code-block {
background-color: var(--surface-elevated) !important;
border: 1px solid var(--border-default);
border-radius: 10px;
padding: 16px;
overflow-x: auto;
font-size: 13px;
line-height: 1.6;
}
.markdown-code-block code {
background: none !important;
border: none !important;
padding: 0 !important;
font-size: inherit;
color: var(--text-primary);
}
`}</style>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]} rehypePlugins={[rehypeSanitize]}
components={{ components={{
a: ({ href, children, ...props }) => ( a: ({ href, children, ...props }) => (
<a <a
@ -30,6 +91,45 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN
const safeAlt = alt || ""; const safeAlt = alt || "";
return <img src={src} alt={safeAlt} loading="lazy" {...props} />; return <img src={src} alt={safeAlt} loading="lazy" {...props} />;
}, },
table: ({ children }) => (
<div className="overflow-x-auto my-2 markdown-table-wrapper">
<table>{children}</table>
</div>
),
code: ({ children, className, ...props }) => {
const cls = Array.isArray(className) ? className.join(" ") : (className || "");
const match = /language-(\w+)/.exec(cls);
const isInline = !match;
// ````html` blocks: render inside Shadow DOM to scope CSS
if (match?.[1] === "html") {
const raw = typeof children === "string" ? children : "";
return <HtmlBlock html={raw} />;
}
if (isInline) {
return (
<code
className="px-1.5 py-0.5 rounded text-[13px]"
style={{
backgroundColor: "var(--surface-elevated)",
border: "0.5px solid var(--border-subtle)",
color: "var(--text-primary)",
}}
{...props}
>
{children}
</code>
);
}
return (
<pre className="markdown-code-block">
<code className={className} {...props}>
{children}
</code>
</pre>
);
},
}} }}
> >
{content} {content}

View File

@ -1,5 +1,3 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "radix-ui" import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({ function Collapsible({

View File

@ -1,5 +1,3 @@
"use client"
import * as React from "react" import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui" import { Dialog as DialogPrimitive } from "radix-ui"
@ -51,15 +49,13 @@ function DialogContent({
className, className,
children, children,
showCloseButton = true, showCloseButton = true,
showOverlay = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean; showCloseButton?: boolean
showOverlay?: boolean;
}) { }) {
return ( return (
<DialogPortal> <DialogPortal>
{showOverlay && <DialogOverlay />} <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(

View File

@ -1,5 +1,3 @@
"use client"
import * as React from "react" import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"

View File

@ -1,3 +1,5 @@
"use client"
import * as React from "react" import * as React from "react"
import { HoverCard as HoverCardPrimitive } from "radix-ui" import { HoverCard as HoverCardPrimitive } from "radix-ui"

View File

@ -1,3 +1,5 @@
"use client"
import * as React from "react" import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"

View File

@ -1,5 +1,3 @@
"use client"
import * as React from "react" import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui" import { Select as SelectPrimitive } from "radix-ui"

File diff suppressed because it is too large Load Diff

View File

@ -55,71 +55,71 @@
:root { :root {
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.13 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.13 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.13 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.13 0 0); --primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.96 0 0); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.13 0 0); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.96 0 0); --muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.45 0 0); --muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.92 0 0); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.13 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.55 0.22 25); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.88 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.95 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.50 0 0); --ring: oklch(0.708 0 0);
--chart-1: oklch(0.45 0 0); --chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.35 0 0); --chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.25 0 0); --chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.18 0 0); --chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.12 0 0); --chart-5: oklch(0.269 0 0);
--radius: 0.625rem; --radius: 0.625rem;
--sidebar: oklch(0.98 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.13 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.20 0 0); --sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.95 0 0); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.13 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.90 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.50 0 0); --sidebar-ring: oklch(0.708 0 0);
} }
.dark { .dark {
--background: oklch(0.13 0 0); --background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.18 0 0); --card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.18 0 0); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.75 0 0); --primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.13 0 0); --primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.25 0 0); --secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.28 0 0); --muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.65 0 0); --muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.25 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.20 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(0.90 0 0); --border: oklch(1 0 0 / 10%);
--input: oklch(0.92 0 0); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.55 0 0); --ring: oklch(0.556 0 0);
--chart-1: oklch(0.50 0 0); --chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.40 0 0); --chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.30 0 0); --chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.20 0 0); --chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.10 0 0); --chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.13 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.25 0 0); --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.20 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.92 0 0); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.55 0 0); --sidebar-ring: oklch(0.556 0 0);
} }
/* /*

View File

@ -5,29 +5,23 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
/** export function truncate(text: string, maxLen: number): string {
* Strips basic markdown syntax from a string. if (text.length <= maxLen) return text;
*/ return text.slice(0, maxLen).trimEnd() + "…";
export function stripMarkdown(text: string): string {
if (!text) return "";
return text
.replace(/^#+\s+/gm, "") // Headings
.replace(/(\*\*|__)(.*?)\1/g, "$2") // Bold
.replace(/(\*|_)(.*?)\1/g, "$2") // Italic
.replace(/\[(.*?)\]\(.*?\)/g, "$1") // Links
.replace(/`{1,3}(.*?)`{1,3}/g, "$1") // Code
.replace(/^[*-]\s+/gm, "") // List items
.replace(/^\s*>\s+/gm, "") // Blockquotes
.replace(/\|.*?\|/g, "") // Table cells
.replace(/[-:]{3,}/g, "") // Table dividers
.replace(/\n+/g, " ") // Newlines to spaces
.trim();
} }
/** export function stripMarkdown(text: string): string {
* Truncates a string to a maximum length and adds an ellipsis. return text
*/ .replace(/#{1,6}\s+/g, "")
export function truncate(text: string, maxLength: number): string { .replace(/(\*\*|__)(.*?)\1/g, "$2")
if (!text || text.length <= maxLength) return text; .replace(/(\*|_)(.*?)\1/g, "$2")
return text.substring(0, maxLength).trim() + "..."; .replace(/~~(.*?)~~/g, "$1")
.replace(/`{1,3}[^`]*`{1,3}/g, "")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/!\[[^\]]*\]\([^)]+\)/g, "")
.replace(/>\s+/g, "")
.replace(/^[-*+]\s+/gm, "")
.replace(/^\d+\.\s+/gm, "")
.replace(/\n{2,}/g, " ")
.trim();
} }