audiorouter-dashboard 0.2.0

HTTP/SSE dashboard API and static-file host for audiorouter
import { useMemo, useState } from "react";

interface Props {
  toml: string;
}

export function TomlPreview({ toml }: Props) {
  const [copied, setCopied] = useState(false);
  const tokens = useMemo(() => tokenizeToml(toml), [toml]);

  const handleCopy = () => {
    void navigator.clipboard.writeText(toml);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  return (
    <div className="relative h-full min-h-0">
      <button
        type="button"
        onClick={handleCopy}
        aria-label={copied ? "コピーしました" : "TOML をコピー"}
        title={copied ? "Copied" : "Copy"}
        className="absolute top-2 right-2 z-10 flex h-7 w-7 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-card)]/90 text-xs text-[var(--color-muted-foreground)] shadow-sm backdrop-blur transition hover:bg-[var(--color-muted)] hover:text-[var(--color-foreground)]"
      >
        {copied ? <CheckIcon /> : <CopyIcon />}
      </button>
      <pre className="h-full overflow-auto rounded-md bg-[var(--color-background)] p-3 pr-12 font-mono text-xs leading-relaxed">
        {tokens.map((t, i) => (
          <span key={i} style={{ color: COLOR_MAP[t.type] }}>
            {t.text}
          </span>
        ))}
      </pre>
    </div>
  );
}

// ── TOML tokenizer ────────────────────────────────────────
type TokenType =
  | "table"
  | "key"
  | "string"
  | "number"
  | "boolean"
  | "punctuation"
  | "comment"
  | "text";

interface Token {
  text: string;
  type: TokenType;
}

const COLOR_MAP: Record<TokenType, string> = {
  table: "var(--color-ar-border)", // Cyan — section headers
  key: "var(--color-ar-route)", // LightBlue — keys
  string: "var(--color-ar-in)", // Green — string values
  number: "var(--color-ar-gain)", // Yellow — numeric values
  boolean: "var(--color-ar-out)", // Magenta — booleans
  punctuation: "var(--color-muted-foreground)", // dim — = [ ] ,
  comment: "var(--color-muted-foreground)", // dim — comments
  text: "var(--color-foreground)", // default
};

function tokenizeToml(toml: string): Token[] {
  const lines = toml.split("\n");
  const tokens: Token[] = [];
  for (let li = 0; li < lines.length; li++) {
    tokens.push(...tokenizeLine(lines[li]));
    if (li < lines.length - 1) {
      tokens.push({ text: "\n", type: "text" });
    }
  }
  return tokens;
}

function tokenizeLine(line: string): Token[] {
  const tokens: Token[] = [];
  let i = 0;

  while (i < line.length) {
    const rest = line.slice(i);

    // Whitespace
    const ws = rest.match(/^\s+/);
    if (ws) {
      tokens.push({ text: ws[0], type: "text" });
      i += ws[0].length;
      continue;
    }

    // Comment
    if (rest.startsWith("#")) {
      tokens.push({ text: rest, type: "comment" });
      break;
    }

    // Table header: [section] or [[array]]
    const table = rest.match(/^\[+\s*[\w.-]+\s*\]+/);
    if (table) {
      tokens.push({ text: table[0], type: "table" });
      i += table[0].length;
      continue;
    }

    // String ("..." or '...')
    const str = rest.match(/^"(?:\\.|[^"\\])*"|^'(?:[^'])*'/);
    if (str) {
      tokens.push({ text: str[0], type: "string" });
      i += str[0].length;
      continue;
    }

    // Boolean
    const bool = rest.match(/^(?:true|false)\b/);
    if (bool) {
      tokens.push({ text: bool[0], type: "boolean" });
      i += bool[0].length;
      continue;
    }

    // Key (bare word followed by '=')
    const key = rest.match(/^[\w-]+(?=\s*=)/);
    if (key) {
      tokens.push({ text: key[0], type: "key" });
      i += key[0].length;
      continue;
    }

    // Number
    const num = rest.match(/^[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/);
    if (num) {
      tokens.push({ text: num[0], type: "number" });
      i += num[0].length;
      continue;
    }

    // Punctuation
    if ("=[],".includes(rest[0])) {
      tokens.push({ text: rest[0], type: "punctuation" });
      i += 1;
      continue;
    }

    // Fallback: single character
    tokens.push({ text: rest[0], type: "text" });
    i += 1;
  }

  return tokens;
}

function CopyIcon() {
  return (
    <svg
      aria-hidden="true"
      className="h-3.5 w-3.5"
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="1.5"
    >
      <rect x="5" y="5" width="8" height="8" rx="1.5" />
      <path d="M3 10.5V4.5A1.5 1.5 0 0 1 4.5 3H10.5" />
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg
      aria-hidden="true"
      className="h-3.5 w-3.5"
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth="1.8"
    >
      <path d="M3.5 8.5L6.5 11.5L12.5 4.5" />
    </svg>
  );
}