GenCN UI

I18n Text

GencnUI-powered i18n text component with automatic language detection and translation to your target language.

Try out the component below to see how it automatically detects and translates text to different languages.

Loading preview...
"use client";

import { GencnUII18nText } from "@/registry/new-york/gencn-ui/items/i18n/gencn-ui-i18n-text";
import { Badge } from "@/components/ui/badge";
import { Info, Languages } from "lucide-react";
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import * as React from "react";

export function GencnUII18nTextExample() {
  const [showTranslated, setShowTranslated] = React.useState(false);
  return (
    <div className="space-y-4">
      <div className="bg-muted/50 rounded-lg border p-4">
        <div className="flex items-start gap-2">
          <Info className="text-muted-foreground mt-0.5 h-4 w-4" />
          <div className="flex-1 space-y-1">
            <p className="text-sm font-medium">Hybrid LLM Support</p>
            <p className="text-muted-foreground text-sm">
              This component automatically uses the Chrome Translator API when
              available, and falls back to server-side LLM translation when the
              Translator API is not supported or unavailable. This ensures
              translations work in all browsers and environments.
            </p>
            <Badge variant="secondary" className="mt-2">
              Server fallback enabled via GencnUIProvider
            </Badge>
          </div>
        </div>
      </div>

      <div className="space-y-4">
        <div className="space-y-2">
          <h3 className="text-sm font-medium">Basic Usage (Inline Auto)</h3>
          <p className="text-muted-foreground text-sm">
            The component automatically translates and displays the text:{" "}
            <GencnUII18nText
              text="Hello, how are you today? I hope you are doing well and having a great day."
              language="es"
            />
          </p>
        </div>

        <div className="space-y-2">
          <h3 className="text-sm font-medium">With Tooltip (On Hover)</h3>
          <p className="text-muted-foreground text-sm">
            Wrap the component in a tooltip to show translation on hover:
            <Tooltip>
              <TooltipTrigger asChild>
                <span className="ml-1 inline-flex cursor-help items-center underline decoration-dashed decoration-1 underline-offset-4">
                  mouse over me or click to see the translation
                </span>
              </TooltipTrigger>
              <TooltipContent>
                <GencnUII18nText
                  text="Hello, how are you today? I hope you are doing well and having a great day."
                  language="de"
                />
              </TooltipContent>
            </Tooltip>
          </p>
        </div>

        <div className="space-y-2">
          <h3 className="text-sm font-medium">With Manual Toggle</h3>
          <p className="text-muted-foreground text-sm">
            Add a button to manually toggle between original and translated
            text:
            <span className="ml-1 inline-flex items-center">
              {showTranslated ? (
                <GencnUII18nText
                  text="Hello, how are you today? I hope you are doing well and having a great day."
                  language="es"
                />
              ) : (
                "Hello, how are you today? I hope you are doing well and having a great day."
              )}
              <Button
                variant="outline"
                aria-label={showTranslated ? "Show original" : "Translate"}
                className="bg-primary text-primary-foreground ml-2 inline-flex h-6 w-6 items-center justify-center rounded-full text-xs shadow hover:opacity-90"
                onClick={() => setShowTranslated(!showTranslated)}
              >
                <Languages className="h-4 w-4" />
              </Button>
            </span>
          </p>
        </div>
      </div>
    </div>
  );
}

Server API

Path:
/api/translate
Source:
import { google, createGoogleGenerativeAI } from "@ai-sdk/google";
import { streamText } from "ai";

// Allow streaming responses up to 30 seconds
export const maxDuration = 30;

interface TranslateRequest {
  prompt?: string;
  text?: string;
  context?: string;
  options?: {
    tone?: string;
    format?: string;
    length?: string;
    sourceLanguage?: string;
    targetLanguage?: string;
  };
  streaming?: boolean;
  LLM_API_KEY?: string;
}

function buildSystemPrompt(): string {
  return `You are a translation assistant. Translate the given text accurately, preserving the meaning, tone, and style of the original text. The target language may be specified in the text itself.`;
}

export async function POST(req: Request) {
  try {
    const request: TranslateRequest = await req.json();
    const {
      context,
      streaming = true,
      LLM_API_KEY,
    } = request;

    const text = request.text || "";
    const systemPrompt = buildSystemPrompt();

    // Add context if provided
    const fullUserPrompt = context ? `${context}\n\n${text}` : text;

    if (!fullUserPrompt.trim()) {
      return new Response(
        JSON.stringify({ error: "Text is required" }),
        {
          status: 400,
          headers: { "Content-Type": "application/json" },
        }
      );
    }

    // Create provider instance with manual API key if provided, otherwise use default
    const googleProvider = LLM_API_KEY
      ? createGoogleGenerativeAI({ apiKey: LLM_API_KEY })
      : google;

    const result = streamText({
      model: googleProvider("gemini-2.5-flash-lite"),
      system: systemPrompt,
      prompt: fullUserPrompt,
      maxOutputTokens: 2000,
    });

    if (streaming) {
      // Use toTextStreamResponse for streaming text
      return result.toTextStreamResponse();
    } else {
      // For non-streaming, collect all chunks and return
      const text = await result.text;
      return new Response(JSON.stringify({ text }), {
        headers: { "Content-Type": "application/json" },
      });
    }
  } catch (error) {
    console.error("Translate API error:", error);
    return new Response(
      JSON.stringify({
        error: "Internal server error",
        message: (error as Error).message,
      }),
      {
        status: 500,
        headers: { "Content-Type": "application/json" },
      }
    );
  }
}

Installation

Setup Required: Make sure you have configured components.json first. See the installation guide for setup instructions.

npx shadcn@latest add @gencn-ui/gencn-ui-i18n-text
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { useTranslator } from "@/registry/new-york/gencn-ui/items/shared/hooks/internal/use-gencn-ui-translator";

export interface GencnUII18nTextProps {
  text: string;
  language: string;
  autoTranslate?: boolean; // If false, only translate when ensureTranslation is called manually
}

export function GencnUII18nText({
  text,
  language,
  autoTranslate = true,
}: GencnUII18nTextProps) {
  const [translatedText, setTranslatedText] = useState<string | null>("L");
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [error, setError] = useState<string | null>(null);
  const [isMounted, setIsMounted] = useState<boolean>(false);
  const [sourceLanguage, setSourceLanguage] = useState<string | undefined>(
    undefined
  );
  const abortRef = useRef<AbortController | null>(null);

  // Use translator hook for translation business logic and language detection
  const { translate, getTranslationCache, detectLanguage } = useTranslator();

  // Mark component as mounted (client-side only)
  useEffect(() => {
    setIsMounted(true);
  }, []);

  useEffect(() => {
    setTranslatedText(null);
    setError(null);
    setSourceLanguage(undefined);
    if (abortRef.current) {
      abortRef.current.abort();
      abortRef.current = null;
    }
  }, [text, language]);

  // Get current cache value for this specific translation
  const currentCacheValue = getTranslationCache(text, language, sourceLanguage);

  // React to cache changes for this specific translation
  useEffect(() => {
    if (!isMounted) return;

    if (currentCacheValue === "L") {
      setIsLoading(true);
      setTranslatedText(null);
    } else if (currentCacheValue && currentCacheValue !== "L") {
      setIsLoading(false);
      setTranslatedText(currentCacheValue);
    } else if (currentCacheValue === null) {
      // Cache was deleted (e.g., after error), ensure we show original text
      setIsLoading(false);
      setTranslatedText(null);
    }
  }, [currentCacheValue, isMounted]);

  // Track if we've initiated translation to prevent duplicate calls
  const translationInitiatedRef = useRef<string | null>(null);

  const ensureTranslation = useCallback(async () => {
    // Only proceed if component is mounted (client-side)
    if (!isMounted) {
      return null;
    }

    // Create a unique key for this translation request
    const requestKey = `${text}|${language}|${sourceLanguage || "auto"}`;

    // If we've already initiated this exact translation, don't call again
    if (translationInitiatedRef.current === requestKey) {
      return null;
    }

    // Check cache FIRST - before any API calls
    const cached = getTranslationCache(text, language, sourceLanguage);
    if (cached && cached !== "L") {
      // Already have translation in cache, use it
      setTranslatedText(cached);
      setIsLoading(false);
      return cached;
    }

    // If already loading in cache, just wait (another component is handling it)
    if (cached === "L") {
      setIsLoading(true);
      translationInitiatedRef.current = requestKey;
      return null; // Will be updated via cache reactive effect
    }

    // Check if we already have a translation in local state
    if (translatedText !== null) return translatedText;

    // Mark that we're initiating this translation
    translationInitiatedRef.current = requestKey;
    setIsLoading(true);
    setError(null);
    const controller = new AbortController();
    abortRef.current = controller;

    try {
      // Detect source language if not already detected
      let detectedSourceLanguage = sourceLanguage;
      if (detectedSourceLanguage === undefined) {
        try {
          const detected = await detectLanguage(text);
          if (Array.isArray(detected) && detected.length > 0) {
            detectedSourceLanguage = (detected[0] as any)
              .detectedLanguage as string;
            setSourceLanguage(detectedSourceLanguage);

            // Check cache again with detected source language
            const cachedWithSource = getTranslationCache(
              text,
              language,
              detectedSourceLanguage
            );
            if (cachedWithSource && cachedWithSource !== "L") {
              setTranslatedText(cachedWithSource);
              setIsLoading(false);
              translationInitiatedRef.current = null; // Reset since we got it from cache
              return cachedWithSource;
            }
          }
        } catch {
          // Language detection failed, continue without source language
        }
      }

      // Check if aborted before calling translate
      if (controller.signal.aborted) {
        translationInitiatedRef.current = null;
        return null;
      }

      // Use provider's translate function via hook - it handles caching internally
      // This will check cache again and mark as "L" if not cached
      const result = await translate(text, language, {
        sourceLanguage: detectedSourceLanguage,
      });

      // Only set the result if the signal wasn't aborted
      if (!controller.signal.aborted) {
        // Accept result even if empty string (might be valid translation)
        if (result !== null && result !== undefined) {
          setTranslatedText(result);
          setIsLoading(false);
          translationInitiatedRef.current = null; // Reset after successful translation
          return result;
        }
        // If result is null/undefined, treat as error
        if (!error) {
          setError("Translation returned empty result");
        }
        translationInitiatedRef.current = null;
        return null;
      }
      translationInitiatedRef.current = null;
      return null;
    } catch (e) {
      translationInitiatedRef.current = null;
      const msg = (e as Error).message || "Failed to translate";
      // Don't set error for aborted translations - this is expected behavior
      if (!msg.includes("aborted") && !controller.signal.aborted) {
        // Filter out generic validation errors that might be misleading
        const filteredMsg = msg.includes("expected pattern")
          ? "Translation failed. Please try again."
          : msg;
        setError(filteredMsg);
      }
      return null;
    } finally {
      // Only clear loading if not aborted (to avoid race conditions)
      if (!controller.signal.aborted) {
        setIsLoading(false);
      }
      if (abortRef.current === controller) {
        abortRef.current = null;
      }
    }
  }, [
    text,
    language,
    sourceLanguage,
    translatedText,
    translate,
    detectLanguage,
    isMounted,
    error,
  ]);

  // Reset translation initiated flag when text/language changes
  useEffect(() => {
    translationInitiatedRef.current = null;
  }, [text, language]);

  useEffect(() => {
    // Only auto-translate after component is mounted if autoTranslate is enabled
    if (isMounted && autoTranslate) {
      void ensureTranslation();
    }
  }, [isMounted, autoTranslate, ensureTranslation]);

  const content = translatedText ?? (isLoading ? "Translating…" : text);

  return (
    <span className="inline-flex items-center">
      <span>{content}</span>
    </span>
  );
}

Component API

Usage Patterns

The component automatically translates and displays text inline. For different display patterns (tooltip on hover, manual toggle), wrap the component in your own UI elements as shown in the examples above.