diff --git a/scripts/i18n-check.mjs b/scripts/i18n-check.mjs index ca73b23..699ae9e 100644 --- a/scripts/i18n-check.mjs +++ b/scripts/i18n-check.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * Validates that all locale translation files have identical key structures. - * Compares zh-CN and es against the en baseline for every namespace. + * Compares all locale folders (except en) against the en baseline for every namespace. * * Usage: node scripts/i18n-check.mjs */ @@ -11,7 +11,6 @@ import path from "node:path"; const LOCALES_DIR = path.resolve("src/i18n/locales"); const BASE_LOCALE = "en"; -const COMPARE_LOCALES = ["zh-CN", "es", "tr", "ko-KR"]; function getKeys(obj, prefix = "") { const keys = []; @@ -34,12 +33,19 @@ const namespaces = fs .filter((f) => f.endsWith(".json")) .map((f) => f.replace(".json", "")); +const compareLocales = fs + .readdirSync(LOCALES_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((locale) => locale !== BASE_LOCALE) + .sort((a, b) => a.localeCompare(b)); + for (const namespace of namespaces) { const basePath = path.join(baseDir, `${namespace}.json`); const baseData = JSON.parse(fs.readFileSync(basePath, "utf-8")); const baseKeys = getKeys(baseData); - for (const locale of COMPARE_LOCALES) { + for (const locale of compareLocales) { const localePath = path.join(LOCALES_DIR, locale, `${namespace}.json`); if (!fs.existsSync(localePath)) { @@ -77,6 +83,6 @@ if (hasErrors) { process.exit(1); } else { console.log( - `i18n check PASSED — all ${COMPARE_LOCALES.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`, + `i18n check PASSED — all ${compareLocales.length} locales match ${BASE_LOCALE} across ${namespaces.length} namespaces.`, ); } diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index ff68c3d..132fa0a 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -6,3 +6,78 @@ .electronNoDrag { -webkit-app-region: no-drag; } + +.languageMenuScroll { + max-height: 16rem; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + touch-action: pan-y; + -webkit-overflow-scrolling: touch; +} + +.languageMenuScroll::-webkit-scrollbar { + width: 8px; +} + +.languageMenuScroll::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.04); + border-radius: 999px; +} + +.languageMenuScroll::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0.2)); + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.15); +} + +.languageMenuScroll::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.3)); +} + +.languageMenuContainer { + position: relative; + z-index: 20; +} + +.languageMenuPanel { + position: fixed; + right: 0; + top: 0; + width: 12rem; + padding: 0.375rem; + border-radius: 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.14); + background: linear-gradient(160deg, rgba(28, 29, 42, 0.98), rgba(18, 19, 28, 0.98)); + box-shadow: 0 20px 45px rgba(0, 0, 0, 0.55); + backdrop-filter: blur(14px); + pointer-events: auto; + box-sizing: border-box; +} + +.languageMenuItem { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.625rem; + border-radius: 0.5rem; + font-size: 11px; + color: rgba(255, 255, 255, 0.88); + background: transparent; + border: 0; + cursor: pointer; + transition: background-color 120ms ease, color 120ms ease; +} + +.languageMenuItem:hover, +.languageMenuItem:focus-visible { + background: rgba(255, 255, 255, 0.1); + color: #ffffff; + outline: none; +} + +.languageMenuItemActive { + background: rgba(255, 255, 255, 0.12); + color: #ffffff; +} diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 137b28c..2914584 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,5 +1,6 @@ import { Check, ChevronDown, Languages } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; @@ -18,8 +19,7 @@ import { } from "react-icons/md"; import { RxDragHandleDots2 } from "react-icons/rx"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; -import { SUPPORTED_LOCALES } from "@/i18n/config"; -import { getLocaleName } from "@/i18n/loader"; +import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useCameraDevices } from "../../hooks/useCameraDevices"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; @@ -28,12 +28,6 @@ import { requestCameraAccess } from "../../lib/requestCameraAccess"; import { formatTimePadded } from "../../utils/timeUtils"; import { AudioLevelMeter } from "../ui/audio-level-meter"; import { Button } from "../ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "../ui/dropdown-menu"; import { Tooltip } from "../ui/tooltip"; import styles from "./LaunchWindow.module.css"; @@ -83,6 +77,7 @@ const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-cen export function LaunchWindow() { const t = useScopedT("launch"); + const availableLocales = getAvailableLocales(); const { locale, setLocale, @@ -123,6 +118,18 @@ export function LaunchWindow() { const [isWebcamHovered, setIsWebcamHovered] = useState(false); const [isWebcamFocused, setIsWebcamFocused] = useState(false); const webcamExpanded = isWebcamHovered || isWebcamFocused; + const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false); + const languageTriggerRef = useRef(null); + const languageMenuPanelRef = useRef(null); + const [languageMenuStyle, setLanguageMenuStyle] = useState<{ + right: number; + top: number; + maxHeight: number; + }>({ + right: 12, + top: 12, + maxHeight: 240, + }); const { devices: micDevices, @@ -176,6 +183,71 @@ export function LaunchWindow() { }); }, []); + useEffect(() => { + if (!isLanguageMenuOpen) return; + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as Node; + const clickedTrigger = languageTriggerRef.current?.contains(target); + const clickedMenu = languageMenuPanelRef.current?.contains(target); + if (!clickedTrigger && !clickedMenu) { + setIsLanguageMenuOpen(false); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsLanguageMenuOpen(false); + } + }; + + window.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("keydown", handleEscape); + + return () => { + window.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("keydown", handleEscape); + }; + }, [isLanguageMenuOpen]); + + useEffect(() => { + if (!isLanguageMenuOpen || !languageTriggerRef.current) return; + + const updatePosition = () => { + if (!languageTriggerRef.current) return; + const rect = languageTriggerRef.current.getBoundingClientRect(); + const gap = 8; + const viewportPadding = 8; + const availableHeight = Math.max(80, rect.top - viewportPadding - gap); + const top = Math.max(viewportPadding, rect.top - gap - availableHeight); + + setLanguageMenuStyle({ + right: Math.max(viewportPadding, window.innerWidth - rect.right), + top, + maxHeight: availableHeight, + }); + }; + + updatePosition(); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + + return () => { + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + }; + }, [isLanguageMenuOpen]); + + useEffect(() => { + if (!isLanguageMenuOpen || !languageMenuPanelRef.current) return; + const id = requestAnimationFrame(() => { + if (languageMenuPanelRef.current) { + languageMenuPanelRef.current.scrollTop = 0; + } + }); + return () => cancelAnimationFrame(id); + }, [isLanguageMenuOpen]); + const [selectedSource, setSelectedSource] = useState("Screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); @@ -537,41 +609,60 @@ export function LaunchWindow() { {/* Right sidebar controls */}
- - - - - - + +
+ + {isLanguageMenuOpen + ? createPortal( +
event.stopPropagation()} > - {getLocaleName(loc)} - {loc === locale ? : null} - - ))} - - + {availableLocales.map((loc) => ( + + ))} +
, + document.body, + ) + : null} {/* Window controls */}
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index c15187d..f4dd29f 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -54,9 +54,11 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam const DropdownMenuContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - + React.ComponentPropsWithoutRef & { + portalled?: boolean; + } +>(({ className, sideOffset = 4, portalled = true, ...props }, ref) => { + const content = ( - -)); + ); + + if (!portalled) { + return content; + } + + return {content}; +}); DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; const DropdownMenuItem = React.forwardRef< diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 0321f43..47c5668 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -14,8 +14,8 @@ import { import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory"; -import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config"; -import { getLocaleName } from "@/i18n/loader"; +import { type Locale } from "@/i18n/config"; +import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; import { calculateOutputDimensions, type ExportFormat, @@ -154,6 +154,7 @@ export default function VideoEditor() { const { shortcuts, isMac } = useShortcuts(); const t = useScopedT("editor"); const ts = useScopedT("settings"); + const availableLocales = getAvailableLocales(); const { locale, setLocale } = useI18n(); const nextAnnotationIdRef = useRef(1); @@ -1707,7 +1708,7 @@ export default function VideoEditor() { className="bg-transparent text-[11px] font-medium outline-none cursor-pointer appearance-none pr-1" style={{ color: "inherit" }} > - {SUPPORTED_LOCALES.map((loc) => ( + {availableLocales.map((loc) => ( diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index 84640ea..1056749 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -8,14 +8,8 @@ import { useRef, useState, } from "react"; -import { - DEFAULT_LOCALE, - type I18nNamespace, - LOCALE_STORAGE_KEY, - type Locale, - SUPPORTED_LOCALES, -} from "@/i18n/config"; -import { translate } from "@/i18n/loader"; +import { DEFAULT_LOCALE, type I18nNamespace, LOCALE_STORAGE_KEY, type Locale } from "@/i18n/config"; +import { getAvailableLocales, translate } from "@/i18n/loader"; type TranslateVars = Record; @@ -48,11 +42,12 @@ export function useScopedT(namespace: I18nNamespace) { } function isSupportedLocale(value: string): value is Locale { - return (SUPPORTED_LOCALES as readonly string[]).includes(value); + return getAvailableLocales().includes(value); } function getSupportedSystemLocale(): Locale | null { if (typeof navigator === "undefined") return null; + const availableLocales = getAvailableLocales(); const candidates = Array.isArray(navigator.languages) && navigator.languages.length > 0 @@ -63,7 +58,7 @@ function getSupportedSystemLocale(): Locale | null { if (!candidate) continue; if (isSupportedLocale(candidate)) return candidate; - const exactMatch = SUPPORTED_LOCALES.find( + const exactMatch = availableLocales.find( (locale) => locale.toLowerCase() === candidate.toLowerCase(), ); if (exactMatch) return exactMatch; @@ -71,9 +66,9 @@ function getSupportedSystemLocale(): Locale | null { const baseLanguage = candidate.split("-")[0]?.toLowerCase(); if (!baseLanguage) continue; - if (baseLanguage === "zh") return "zh-CN"; + if (baseLanguage === "zh" && availableLocales.includes("zh-CN")) return "zh-CN"; - const baseMatch = SUPPORTED_LOCALES.find((locale) => locale.toLowerCase() === baseLanguage); + const baseMatch = availableLocales.find((locale) => locale.toLowerCase() === baseLanguage); if (baseMatch) return baseMatch; } diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 0933569..507aa4d 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -1,5 +1,4 @@ export const DEFAULT_LOCALE = "en" as const; -export const SUPPORTED_LOCALES = ["en", "zh-CN", "es", "fr", "tr", "ko-KR"] as const; export const I18N_NAMESPACES = [ "common", "dialogs", @@ -10,7 +9,7 @@ export const I18N_NAMESPACES = [ "timeline", ] as const; -export type Locale = (typeof SUPPORTED_LOCALES)[number]; +export type Locale = string; export type I18nNamespace = (typeof I18N_NAMESPACES)[number]; export const LOCALE_STORAGE_KEY = "openscreen-locale"; diff --git a/src/i18n/loader.ts b/src/i18n/loader.ts index 4736db8..36d8eb6 100644 --- a/src/i18n/loader.ts +++ b/src/i18n/loader.ts @@ -1,6 +1,10 @@ -import { DEFAULT_LOCALE, type I18nNamespace, type Locale } from "./config"; +import { DEFAULT_LOCALE, I18N_NAMESPACES, type I18nNamespace, type Locale } from "./config"; type MessageMap = Record; +type LocaleValidationError = { + locale: string; + missingNamespaces: I18nNamespace[]; +}; const modules = import.meta.glob("./locales/**/*.json", { eager: true }) as Record< string, @@ -18,6 +22,62 @@ for (const [path, mod] of Object.entries(modules)) { messages[locale][namespace] = mod.default; } +const REQUIRED_NAMESPACES = new Set(I18N_NAMESPACES); + +const localeValidationErrors: LocaleValidationError[] = Object.keys(messages) + .map((locale) => { + const localeMessages = messages[locale] ?? {}; + const missingNamespaces = I18N_NAMESPACES.filter((namespace) => !localeMessages[namespace]); + return { + locale, + missingNamespaces, + }; + }) + .filter((entry) => entry.missingNamespaces.length > 0); + +const invalidLocales = new Set(localeValidationErrors.map((entry) => entry.locale)); + +const availableLocales = Object.keys(messages) + .filter((locale) => REQUIRED_NAMESPACES.size > 0 && hasRequiredNamespaces(messages[locale])) + .filter((locale) => !invalidLocales.has(locale)) + .sort((a, b) => { + if (a === DEFAULT_LOCALE) return -1; + if (b === DEFAULT_LOCALE) return 1; + return a.localeCompare(b); + }); + +if (localeValidationErrors.length > 0) { + console.error("[i18n] Incomplete locale folders were excluded:"); + for (const entry of localeValidationErrors) { + console.error( + `[i18n] ${entry.locale}: missing ${entry.missingNamespaces.map((ns) => `${ns}.json`).join(", ")}`, + ); + } +} + +function hasRequiredNamespaces(localeMessages: Record | undefined): boolean { + if (!localeMessages) return false; + for (const namespace of REQUIRED_NAMESPACES) { + if (!localeMessages[namespace]) return false; + } + return true; +} + +function isAvailableLocale(locale: string): locale is Locale { + return availableLocales.includes(locale); +} + +export function getAvailableLocales(): Locale[] { + if (availableLocales.length === 0) { + return [DEFAULT_LOCALE]; + } + return availableLocales; +} + +export function getLocaleValidationErrors(): LocaleValidationError[] { + return localeValidationErrors; +} + function getMessageValue(obj: unknown, dotPath: string): string | undefined { const keys = dotPath.split("."); let current: unknown = obj; @@ -34,15 +94,18 @@ function interpolate(str: string, vars?: Record): strin } export function getMessages(locale: Locale, namespace: I18nNamespace): MessageMap { - return messages[locale]?.[namespace] ?? {}; + const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE; + return messages[resolvedLocale]?.[namespace] ?? {}; } export function getLocaleName(locale: Locale): string { - return getMessageValue(messages[locale]?.common, "locale.name") ?? locale; + const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE; + return getMessageValue(messages[resolvedLocale]?.common, "locale.name") ?? locale; } export function getLocaleShort(locale: Locale): string { - return getMessageValue(messages[locale]?.common, "locale.short") ?? locale; + const resolvedLocale = isAvailableLocale(locale) ? locale : DEFAULT_LOCALE; + return getMessageValue(messages[resolvedLocale]?.common, "locale.short") ?? locale; } export function translate( @@ -52,8 +115,10 @@ export function translate( vars?: Record, ): string { const value = - getMessageValue(messages[locale]?.[namespace], key) ?? - getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key); + getMessageValue( + messages[isAvailableLocale(locale) ? locale : DEFAULT_LOCALE]?.[namespace], + key, + ) ?? getMessageValue(messages[DEFAULT_LOCALE]?.[namespace], key); if (value == null) return `${namespace}.${key}`; return interpolate(value, vars);