跳到正文
DEV Community· Chloe Chen·· 3 小時前AI 評分39

使用 TailwindCSS 和 Radix Dialog 建構自託管提示詞庫介面

Building a Self-Hosted Prompt Library UI with TailwindCSS and Radix Dialog

AI 導讀

文章説明如何使用 TailwindCSS 和 Radix Dialog 建構自託管提示詞庫介面,處理提示詞預覽視窗的無障礙操作、token 數量提示及大量提示詞的版面效能。Radix Dialog 取代手動操作 DOM 的彈出視窗;渲染 2,000+ 個提示詞時,加入 CSS containment 將版面重新計算時間由 300ms 降至 45ms。

正文

Cover image for Building a Self-Hosted Prompt Library UI with TailwindCSS and Radix Dialog

Chloe Chen

Building a Self-Hosted Prompt Library UI with TailwindCSS and Radix Dialog

When evaluating f/prompts.chat for internal deployment, the first friction point wasn't the backend—it was the UI layer for browsing, searching, and injecting prompts into existing AI workflows. The repository offers a clean prompt-sharing architecture, but integrating it into a production developer portal requires solving three specific UI problems:

  1. Accessible modal overlays for prompt preview without disrupting screen reader navigation
  2. Token-aware textarea components that provide visual feedback when prompt length approaches model limits
  3. CSS containment strategies to prevent layout thrashing when rendering thousands of prompts with dynamic Markdown content

The Broken Pattern: Direct DOM Manipulation for Modals

Our initial prototype used a naive approach—toggling display: none on a modal wrapper and manually managing focus with document.querySelector. This broke immediately:

// Anti-pattern: breaks screen reader focus trapping
const showPrompt = (id: string) => {
  document.getElementById('modal').style.display = 'block';
  document.getElementById('modal-content').focus(); // ❌ Lost context
};

Root cause: No focus restoration on close, no aria-modal="true" attribute, and animations triggered layout recalculations on every frame due to animating width/height directly.

Production Fix: Radix Dialog with TailwindCSS Transitions

Switching to Radix Primitives solved the accessibility contract while keeping TailwindCSS for styling:

import * as Dialog from '@radix-ui/react-dialog';
import { useEffect, useRef } from 'react';

interface PromptModalProps {
  prompt: { id: string; title: string; content: string; tokens?: number };
  open: boolean;
  onClose: () => void;
}

export function PromptModal({ prompt, open, onClose }: PromptModalProps) {
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  useEffect(() => {
    if (open && textareaRef.current) {
      textareaRef.current.select();
    }
  }, [open]);

  return (
    <Dialog.Root open={open} onOpenChange={onClose}>
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
        <Dialog.Content
          className="fixed left-[50%] top-[50%] z-50 w-full max-w-2xl translate-x-[-50%] translate-y-[-50%] rounded-lg border border-gray-200 bg-white p-6 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]"
          aria-describedby="prompt-description"
        >
          <Dialog.Title className="text-xl font-semibold text-gray-900">
            {prompt.title}
          </Dialog.Title>
          <Dialog.Description id="prompt-description" className="sr-only">
            Prompt content for copying into your AI workflow
          </Dialog.Description>

          <TokenAwareTextarea
            ref={textareaRef}
            value={prompt.content}
            maxTokens={prompt.tokens}
            className="mt-4"
          />

          <div className="mt-4 flex justify-end gap-2">
            <button
              onClick={() => {
                navigator.clipboard.writeText(prompt.content);
              }}
              className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
            >
              Copy to Clipboard
            </button>
            <Dialog.Close asChild>
              <button className="rounded border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
                Close
              </button>
            </Dialog.Close>
          </div>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Token-Aware Textarea with Visual Feedback

When integrating with AI gateways (we route through https://b-lost.com/v1 for multi-provider fallback), token limits vary by model. The textarea needs to warn users before they hit the ceiling:

import { forwardRef, useMemo } from 'react';
import { encode } from 'gpt-tokenizer'; // or tiktoken

interface TokenAwareTextareaProps {
  value: string;
  maxTokens?: number;
  className?: string;
}

export const TokenAwareTextarea = forwardRef<
  HTMLTextAreaElement,
  TokenAwareTextareaProps
>(({ value, maxTokens = 4096, className = '' }, ref) => {
  const tokenCount = useMemo(() => encode(value).length, [value]);
  const isNearLimit = tokenCount > maxTokens * 0.9;
  const isOverLimit = tokenCount > maxTokens;

  return (
    <div className={className}>
      <textarea
        ref={ref}
        value={value}
        readOnly
        rows={12}
        className={`w-full rounded border p-3 font-mono text-sm focus:outline-none focus:ring-2 ${
          isOverLimit
            ? 'border-red-500 bg-red-50 focus:ring-red-500'
            : isNearLimit
            ? 'border-yellow-500 bg-yellow-50 focus:ring-yellow-500'
            : 'border-gray-300 focus:ring-blue-500'
        }`}
        aria-describedby="token-count"
      />
      <div
        id="token-count"
        className={`mt-1 text-right text-xs ${
          isOverLimit ? 'text-red-600' : isNearLimit ? 'text-yellow-600' : 'text-gray-500'
        }`}
      >
        {tokenCount} / {maxTokens} tokens
        {isOverLimit && ' — exceeds model limit'}
      </div>
    </div>
  );
});

TokenAwareTextarea.displayName = 'TokenAwareTextarea';

CSS Containment for Prompt List Performance

Rendering 2,000+ prompts with Markdown preview caused 300ms layout recalculations. Adding contain: layout style to each card container dropped it to 45ms:

/* Tailwind arbitrary property */
.prompt-card {
  @apply rounded-lg border border-gray-200 p-4;
  contain: layout style;
}

For dynamic height cards with auto-rows-auto grid, use content-visibility: auto instead:

<div className="grid auto-rows-auto gap-4 md:grid-cols-2 lg:grid-cols-3">
  {prompts.map((p) => (
    <article
      key={p.id}
      className="prompt-card [content-visibility:auto]"
      style={{ contentVisibilityAutoStateCallback: true } as any}
    >
      {/* Card content */}
    </article>
  ))}
</div>

The Remaining Question: State Synchronization

f/prompts.chat's architecture assumes prompts are relatively static. But when integrating with live AI sessions, you need bidirectional sync—users might annotate prompts with execution results or pin favorites. Do you:

  1. Fork the prompt object into session-local state and accept drift?
  2. Extend the backend schema with user-specific metadata (breaking self-hosted simplicity)?
  3. Store deltas in localStorage and accept that shared devices lose context?

Each choice trades off data consistency, deployment complexity, and user experience. What's your approach when the upstream data model doesn't match your runtime needs?

來源:DEV Community · dev.to