devist 0.15.0

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
import { useAuth } from "@/contexts/AuthContext";
import { type Lang, type TKey, dict } from "@/i18n/dict";
import { supabase } from "@/lib/supabase";
import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from "react";

const STORAGE_KEY = "devist.lang";

type I18nState = {
  lang: Lang;
  setLang: (l: Lang) => void;
  t: (key: TKey, vars?: Record<string, string | number>) => string;
};

const I18nContext = createContext<I18nState | null>(null);

const SUPPORTED: readonly Lang[] = ["en", "ko"];
function isLang(v: unknown): v is Lang {
  return typeof v === "string" && (SUPPORTED as readonly string[]).includes(v);
}

function detectInitial(): Lang {
  try {
    const saved = localStorage.getItem(STORAGE_KEY);
    if (isLang(saved)) return saved;
  } catch {
    // localStorage unavailable
  }
  if (typeof navigator !== "undefined") {
    const nav = navigator.language?.toLowerCase() ?? "";
    if (nav.startsWith("ko")) return "ko";
  }
  return "en";
}

function interpolate(s: string, vars?: Record<string, string | number>): string {
  if (!vars) return s;
  return s.replace(/\{(\w+)\}/g, (_, k) =>
    vars[k] === undefined ? `{${k}}` : String(vars[k]),
  );
}

export function I18nProvider({ children }: { children: ReactNode }) {
  const { session } = useAuth();
  const [lang, setLangState] = useState<Lang>(detectInitial);

  // When the session arrives or changes, prefer the user's stored
  // preference (auth.users.user_metadata.lang). If they have one,
  // override local state. If they don't but we have a local choice,
  // back-fill it to user_metadata so it carries to other devices.
  useEffect(() => {
    if (!session?.user) return;
    const remote = session.user.user_metadata?.lang;
    if (isLang(remote)) {
      if (remote !== lang) {
        setLangState(remote);
      }
    } else if (supabase) {
      // Back-fill the user's metadata with the locally-detected lang.
      supabase.auth.updateUser({ data: { lang } }).catch(() => {
        // Non-fatal; user can still use the app.
      });
    }
    // We intentionally only react to session.user.id changes — `lang`
    // changes are handled by setLang below, which writes through.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [session?.user?.id]);

  // Persist locally + reflect on <html lang>.
  useEffect(() => {
    try {
      localStorage.setItem(STORAGE_KEY, lang);
    } catch {
      // ignore
    }
    document.documentElement.lang = lang;
  }, [lang]);

  const setLang = useCallback(
    (l: Lang) => {
      setLangState(l);
      // Write-through to user metadata so other devices sync.
      if (session?.user && supabase) {
        supabase.auth.updateUser({ data: { lang: l } }).catch(() => {
          // Non-fatal.
        });
      }
    },
    [session?.user],
  );

  const t = useCallback(
    (key: TKey, vars?: Record<string, string | number>) => {
      const raw = dict[lang][key] ?? dict.en[key] ?? key;
      return interpolate(raw, vars);
    },
    [lang],
  );

  return (
    <I18nContext.Provider value={{ lang, setLang, t }}>{children}</I18nContext.Provider>
  );
}

export function useI18n() {
  const ctx = useContext(I18nContext);
  if (!ctx) throw new Error("useI18n must be used inside I18nProvider");
  return ctx;
}