From 581eb292c82f45d3f747a1d9e531ede64d07671f Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 7 Jun 2026 23:59:36 -0500 Subject: [PATCH] Refactor: extract js/core and js/i18n packages --- js/components/package.json | 10 +- .../src/components/chat/chat-message.tsx | 2 +- .../src/components/dashboard/problems.tsx | 3 +- .../stream-notification/pin-notification.tsx | 2 +- .../src/components/vod/like-button.tsx | 6 +- .../src/components/vod/vod-comments.tsx | 5 +- js/components/src/i18n/i18n-loader.native.ts | 31 +-- js/components/src/i18n/i18next-config.ts | 158 +++-------- js/components/src/i18n/index.ts | 22 +- js/components/src/index.tsx | 8 +- .../src/livestream-provider/index.tsx | 8 +- .../src/livestream-provider/websocket.tsx | 2 +- .../src/livestream-store/context.tsx | 5 +- .../livestream-store/{chat.tsx => hooks.tsx} | 239 +---------------- js/components/src/livestream-store/index.tsx | 49 +++- .../src/livestream-store/stream-key.tsx | 15 +- .../{livestream-store.tsx => use-store.tsx} | 47 +--- .../src/streamplace-store/moderation.tsx | 2 +- js/components/src/vod-store/hooks.tsx | 110 ++++++++ js/components/src/vod-store/index.tsx | 12 + .../src/vod-store/vod-interactions.tsx | 173 ------------ js/components/tsconfig.json | 4 +- js/core/package.json | 33 +++ js/core/src/index.ts | 15 ++ js/core/src/lib/browser.ts | 27 ++ js/{components => core}/src/lib/facet.ts | 0 js/core/src/lib/format-handle.ts | 24 ++ js/core/src/lib/platform.ts | 35 +++ js/core/src/lib/system-messages.ts | 186 +++++++++++++ js/core/src/livestream-store/chat-reducer.ts | 226 ++++++++++++++++ js/core/src/livestream-store/index.tsx | 9 + .../src/livestream-store/problems.ts} | 2 +- .../src/livestream-store/state.ts} | 0 js/core/src/livestream-store/store.ts | 39 +++ .../livestream-store/websocket-consumer.ts} | 7 +- js/core/src/vod-store/index.ts | 3 + js/core/src/vod-store/vod-interactions.ts | 121 +++++++++ js/core/tsconfig.json | 14 + js/{components => i18n}/i18next.config.js | 2 +- .../locales/en-US/common.ftl | 0 .../locales/en-US/settings.ftl | 0 .../locales/es-ES/common.ftl | 0 .../locales/es-ES/settings.ftl | 0 .../locales/fr-FR/common.ftl | 0 .../locales/fr-FR/settings.ftl | 0 js/{components => i18n}/locales/manifest.json | 0 .../locales/pt-BR/common.ftl | 0 .../locales/pt-BR/settings.ftl | 0 .../locales/ro-RO/common.ftl | 0 .../locales/ro-RO/settings.ftl | 0 .../locales/zh-Hans/common.ftl | 0 .../locales/zh-Hans/settings.ftl | 0 .../locales/zh-Hant/common.ftl | 0 .../locales/zh-Hant/settings.ftl | 0 js/i18n/package.json | 26 ++ js/i18n/public/locales/en-US/common.json | 46 ++++ js/i18n/public/locales/en-US/settings.json | 241 +++++++++++++++++ js/i18n/public/locales/es-ES/common.json | 46 ++++ js/i18n/public/locales/es-ES/settings.json | 246 ++++++++++++++++++ js/i18n/public/locales/fr-FR/common.json | 46 ++++ js/i18n/public/locales/fr-FR/settings.json | 246 ++++++++++++++++++ js/i18n/public/locales/pt-BR/common.json | 46 ++++ js/i18n/public/locales/pt-BR/settings.json | 246 ++++++++++++++++++ js/i18n/public/locales/ro-RO/common.json | 46 ++++ js/i18n/public/locales/ro-RO/settings.json | 206 +++++++++++++++ js/i18n/public/locales/zh-Hans/common.json | 46 ++++ js/i18n/public/locales/zh-Hans/settings.json | 241 +++++++++++++++++ js/i18n/public/locales/zh-Hant/common.json | 46 ++++ js/i18n/public/locales/zh-Hant/settings.json | 246 ++++++++++++++++++ .../scripts/compile-translations.js | 47 ++-- .../scripts/migrate-i18n.js | 0 js/i18n/src/config.ts | 75 ++++++ js/i18n/src/index.ts | 53 ++++ js/i18n/src/locale.ts | 98 +++++++ js/i18n/src/resources.ts | 37 +++ js/i18n/tsconfig.json | 13 + 76 files changed, 3345 insertions(+), 654 deletions(-) rename js/components/src/livestream-store/{chat.tsx => hooks.tsx} (58%) rename js/components/src/livestream-store/{livestream-store.tsx => use-store.tsx} (65%) create mode 100644 js/components/src/vod-store/hooks.tsx create mode 100644 js/components/src/vod-store/index.tsx delete mode 100644 js/components/src/vod-store/vod-interactions.tsx create mode 100644 js/core/package.json create mode 100644 js/core/src/index.ts create mode 100644 js/core/src/lib/browser.ts rename js/{components => core}/src/lib/facet.ts (100%) create mode 100644 js/core/src/lib/format-handle.ts create mode 100644 js/core/src/lib/platform.ts create mode 100644 js/core/src/lib/system-messages.ts create mode 100644 js/core/src/livestream-store/chat-reducer.ts create mode 100644 js/core/src/livestream-store/index.tsx rename js/{components/src/livestream-store/problems.tsx => core/src/livestream-store/problems.ts} (98%) rename js/{components/src/livestream-store/livestream-state.tsx => core/src/livestream-store/state.ts} (100%) create mode 100644 js/core/src/livestream-store/store.ts rename js/{components/src/livestream-store/websocket-consumer.tsx => core/src/livestream-store/websocket-consumer.ts} (96%) create mode 100644 js/core/src/vod-store/index.ts create mode 100644 js/core/src/vod-store/vod-interactions.ts create mode 100644 js/core/tsconfig.json rename js/{components => i18n}/i18next.config.js (93%) rename js/{components => i18n}/locales/en-US/common.ftl (100%) rename js/{components => i18n}/locales/en-US/settings.ftl (100%) rename js/{components => i18n}/locales/es-ES/common.ftl (100%) rename js/{components => i18n}/locales/es-ES/settings.ftl (100%) rename js/{components => i18n}/locales/fr-FR/common.ftl (100%) rename js/{components => i18n}/locales/fr-FR/settings.ftl (100%) rename js/{components => i18n}/locales/manifest.json (100%) rename js/{components => i18n}/locales/pt-BR/common.ftl (100%) rename js/{components => i18n}/locales/pt-BR/settings.ftl (100%) rename js/{components => i18n}/locales/ro-RO/common.ftl (100%) rename js/{components => i18n}/locales/ro-RO/settings.ftl (100%) rename js/{components => i18n}/locales/zh-Hans/common.ftl (100%) rename js/{components => i18n}/locales/zh-Hans/settings.ftl (100%) rename js/{components => i18n}/locales/zh-Hant/common.ftl (100%) rename js/{components => i18n}/locales/zh-Hant/settings.ftl (100%) create mode 100644 js/i18n/package.json create mode 100644 js/i18n/public/locales/en-US/common.json create mode 100644 js/i18n/public/locales/en-US/settings.json create mode 100644 js/i18n/public/locales/es-ES/common.json create mode 100644 js/i18n/public/locales/es-ES/settings.json create mode 100644 js/i18n/public/locales/fr-FR/common.json create mode 100644 js/i18n/public/locales/fr-FR/settings.json create mode 100644 js/i18n/public/locales/pt-BR/common.json create mode 100644 js/i18n/public/locales/pt-BR/settings.json create mode 100644 js/i18n/public/locales/ro-RO/common.json create mode 100644 js/i18n/public/locales/ro-RO/settings.json create mode 100644 js/i18n/public/locales/zh-Hans/common.json create mode 100644 js/i18n/public/locales/zh-Hans/settings.json create mode 100644 js/i18n/public/locales/zh-Hant/common.json create mode 100644 js/i18n/public/locales/zh-Hant/settings.json rename js/{components => i18n}/scripts/compile-translations.js (88%) rename js/{components => i18n}/scripts/migrate-i18n.js (100%) create mode 100644 js/i18n/src/config.ts create mode 100644 js/i18n/src/index.ts create mode 100644 js/i18n/src/locale.ts create mode 100644 js/i18n/src/resources.ts create mode 100644 js/i18n/tsconfig.json diff --git a/js/components/package.json b/js/components/package.json index cb30272e..30619b36 100644 --- a/js/components/package.json +++ b/js/components/package.json @@ -18,11 +18,8 @@ "author": "Streamplace", "license": "MIT", "devDependencies": { - "@fluent/syntax": "^0.19.0", "@types/react-dom": "^19.2.3", "@types/sdp-transform": "^2.15.0", - "i18next-cli": "^1.32.0", - "nodemon": "^3.1.10", "tsup": "^8.5.0" }, "dependencies": { @@ -62,6 +59,8 @@ "react-native-svg": "^15.15.3", "react-native-webrtc": "git+https://github.com/streamplace/react-native-webrtc.git#74fa32266e3a2fee180f5e01bb8753af2a92d9d3", "react-use-websocket": "^4.13.0", + "@streamplace/core": "workspace:*", + "@streamplace/i18n": "workspace:*", "streamplace": "workspace:*", "viem": "^2.21.44", "zustand": "^5.0.5" @@ -78,9 +77,6 @@ "scripts": { "build": "tsc", "start": "tsc --watch --preserveWatchOutput", - "prepare": "node scripts/compile-translations.js && tsc", - "i18n:compile": "node scripts/compile-translations.js", - "i18n:watch": "nodemon --watch 'locales/**/*.ftl' --exec 'node scripts/compile-translations.js'", - "i18n:extract": "i18next-cli extract && node scripts/migrate-i18n.js" + "prepare": "tsc" } } diff --git a/js/components/src/components/chat/chat-message.tsx b/js/components/src/components/chat/chat-message.tsx index 30007b39..d2b1fde1 100644 --- a/js/components/src/components/chat/chat-message.tsx +++ b/js/components/src/components/chat/chat-message.tsx @@ -3,10 +3,10 @@ import { Link, Mention, } from "@atproto/api/dist/client/types/app/bsky/richtext/facet"; +import { Facet, RichtextSegment, segmentize } from "@streamplace/core"; import { memo, useCallback } from "react"; import { Linking, Platform, View } from "react-native"; import { ChatMessageViewHydrated } from "streamplace"; -import { Facet, RichtextSegment, segmentize } from "../../lib/facet"; import { borders, flex, gap, ml, mr, opacity, pl } from "../../lib/theme/atoms"; import { formatHandleWithAt } from "../../utils/format-handle"; import { atoms, colors, layout } from "../ui"; diff --git a/js/components/src/components/dashboard/problems.tsx b/js/components/src/components/dashboard/problems.tsx index 76309ddc..ee7e8c7f 100644 --- a/js/components/src/components/dashboard/problems.tsx +++ b/js/components/src/components/dashboard/problems.tsx @@ -7,8 +7,7 @@ import { } from "lucide-react-native"; import { forwardRef, useImperativeHandle, useState } from "react"; import { Linking, Pressable, View } from "react-native"; -import { useLivestreamStore } from "../../livestream-store"; -import { LivestreamProblem } from "../../livestream-store/livestream-state"; +import { LivestreamProblem, useLivestreamStore } from "../../livestream-store"; import * as zero from "../../ui"; import { Button, Text } from "../ui"; diff --git a/js/components/src/components/stream-notification/pin-notification.tsx b/js/components/src/components/stream-notification/pin-notification.tsx index a56766b3..21bfc184 100644 --- a/js/components/src/components/stream-notification/pin-notification.tsx +++ b/js/components/src/components/stream-notification/pin-notification.tsx @@ -1,3 +1,4 @@ +import { RichtextSegment, segmentize } from "@streamplace/core"; import { EyeOff, Pin, X } from "lucide-react-native"; import { useEffect, useState } from "react"; import { Linking, Pressable, View } from "react-native"; @@ -9,7 +10,6 @@ import { useTheme, zero, } from "../../"; -import { RichtextSegment, segmentize } from "../../lib/facet"; import { formatHandleWithAt } from "../../utils/format-handle"; const getRgbColor = (color?: PlaceStreamChatProfile.Color) => diff --git a/js/components/src/components/vod/like-button.tsx b/js/components/src/components/vod/like-button.tsx index b669e5f6..e8c3ef28 100644 --- a/js/components/src/components/vod/like-button.tsx +++ b/js/components/src/components/vod/like-button.tsx @@ -8,11 +8,7 @@ import Animated, { } from "react-native-reanimated"; import { useDID } from "../../streamplace-store/streamplace-store"; import { gap, layout, useTheme } from "../../ui"; -import { - useCreateLike, - useDeleteLike, - useGetLikes, -} from "../../vod-store/vod-interactions"; +import { useCreateLike, useDeleteLike, useGetLikes } from "../../vod-store"; import { Text } from "../ui/text"; export function LikeButton({ subjectUri }: { subjectUri: string }) { diff --git a/js/components/src/components/vod/vod-comments.tsx b/js/components/src/components/vod/vod-comments.tsx index 1d7b99de..9eda62f3 100644 --- a/js/components/src/components/vod/vod-comments.tsx +++ b/js/components/src/components/vod/vod-comments.tsx @@ -19,10 +19,7 @@ import { r, useTheme, } from "../../ui"; -import { - useCreateVodComment, - useGetVodComments, -} from "../../vod-store/vod-interactions"; +import { useCreateVodComment, useGetVodComments } from "../../vod-store"; import { Text } from "../ui/text"; import { Textarea } from "../ui/textarea"; diff --git a/js/components/src/i18n/i18n-loader.native.ts b/js/components/src/i18n/i18n-loader.native.ts index b90d0b95..6d417e2a 100644 --- a/js/components/src/i18n/i18n-loader.native.ts +++ b/js/components/src/i18n/i18n-loader.native.ts @@ -1,21 +1,22 @@ // Native translation loader - imports translations directly for bundling // Metro will use this file for React Native builds -// Import all translations directly so they're bundled into the app -import enUSCommon from "../../public/locales/en-US/common.json"; -import enUSSettings from "../../public/locales/en-US/settings.json"; -import esESCommon from "../../public/locales/es-ES/common.json"; -import esESSettings from "../../public/locales/es-ES/settings.json"; -import frFRCommon from "../../public/locales/fr-FR/common.json"; -import frFRSettings from "../../public/locales/fr-FR/settings.json"; -import ptBRCommon from "../../public/locales/pt-BR/common.json"; -import ptBRSettings from "../../public/locales/pt-BR/settings.json"; -import roROCommon from "../../public/locales/ro-RO/common.json"; -import roROSettings from "../../public/locales/ro-RO/settings.json"; -import zhHansCommon from "../../public/locales/zh-Hans/common.json"; -import zhHansSettings from "../../public/locales/zh-Hans/settings.json"; -import zhHantCommon from "../../public/locales/zh-Hant/common.json"; -import zhHantSettings from "../../public/locales/zh-Hant/settings.json"; +import { + enUSCommon, + enUSSettings, + esESCommon, + esESSettings, + frFRCommon, + frFRSettings, + ptBRCommon, + ptBRSettings, + roROCommon, + roROSettings, + zhHansCommon, + zhHansSettings, + zhHantCommon, + zhHantSettings, +} from "@streamplace/i18n"; const translationMap: Record = { "en-US/common": enUSCommon, diff --git a/js/components/src/i18n/i18next-config.ts b/js/components/src/i18n/i18next-config.ts index 98840e14..7f174ea3 100644 --- a/js/components/src/i18n/i18next-config.ts +++ b/js/components/src/i18n/i18next-config.ts @@ -1,5 +1,6 @@ -// TypeScript i18next configuration with Fluent and manifest integration -// modified from https://github.com/inaturalist/iNaturalistReactNative/blob/main/src/i18n/initI18next.js +// i18next configuration for React Native / Expo +// Uses @streamplace/i18n for manifest, config, and locale utilities. +// This file adds React plugin + platform-specific loaders + storage persistence. import i18next from "i18next"; import Fluent from "i18next-fluent"; @@ -7,17 +8,18 @@ import resourcesToBackend from "i18next-resources-to-backend"; import "intl-pluralrules"; import { initReactI18next } from "react-i18next"; -// Import our manifest directly to avoid circular dependency -import manifest from "../../locales/manifest.json"; +import { + getLocaleFromSystemLocale as baseGetLocaleFromSystemLocale, + createI18nextConfig, + manifest, + STORAGE_KEY, +} from "@streamplace/i18n"; import storage from "../storage"; -const LOCALE_STORAGE_KEY = "@streamplace/locale"; - // Try to import expo-localization, but make it optional let Localization: typeof import("expo-localization") | null = null; try { const localizationModule = require("expo-localization"); - // Handle both default and named exports Localization = localizationModule.default ? localizationModule.default : localizationModule; @@ -25,45 +27,24 @@ try { // expo-localization not available, will use browser/fallback detection } -function cleanLocaleName(locale: string): string { - return locale.replace("_", "-").replace(/@.*/, ""); -} - +/** + * Detect the system locale using expo-localization (if available), + * falling back to the platform-agnostic detection from @streamplace/i18n. + */ export function getLocaleFromSystemLocale(): string { - let systemLocale = "en"; - - // Try to get locale from expo-localization if available if (Localization && typeof Localization.getLocales === "function") { try { const locales = Localization.getLocales(); - systemLocale = locales?.[0]?.languageTag || "en"; + const deviceLocale = locales?.[0]?.languageTag; + if (deviceLocale) { + return baseGetLocaleFromSystemLocale(deviceLocale); + } } catch (error) { console.warn("Failed to get locales from expo-localization:", error); } - } else if (typeof navigator !== "undefined" && navigator.language) { - // Fallback to browser navigator.language - systemLocale = navigator.language; - } - - const candidateLocale = cleanLocaleName(systemLocale); - - // Check if the full locale is supported (e.g., "en-US") - if (manifest.supportedLocales.includes(candidateLocale)) { - return candidateLocale; - } - - // Check if the language part is supported (e.g., "en" from "en-GB") - const lang = candidateLocale.split("-")[0]; - const matchingLocale = manifest.supportedLocales.find((locale) => - locale.startsWith(lang + "-"), - ); - - if (matchingLocale) { - return matchingLocale; } - // Fall back to default locale from manifest - return manifest.fallbackChain[0]; + return baseGetLocaleFromSystemLocale(); } // Cache for the current locale to avoid async lookups @@ -74,7 +55,7 @@ export async function getCurrentLocale(): Promise { return cachedLocale; } - const stored = await storage.getItem(LOCALE_STORAGE_KEY); + const stored = await storage.getItem(STORAGE_KEY); if (stored && manifest.supportedLocales.includes(stored)) { cachedLocale = stored; return stored; @@ -90,63 +71,12 @@ export function getCurrentLocaleSync(): string { return cachedLocale || getLocaleFromSystemLocale(); } -// Enhanced fallback logic using manifest -function getFallbackChain(code: string): string[] { - const fallbacks: string[] = []; - - if (!code) return manifest.fallbackChain; - - // Regional fallbacks - if (code.match(/^es-/)) { - fallbacks.push("es-ES"); // Spanish fallback - } else if (code.match(/^fr-/)) { - fallbacks.push("fr-FR"); // French fallback - } else if (code.match(/^pt-/)) { - fallbacks.push("pt-BR"); // Portuguese fallback - } else if (code.match(/^zh-/)) { - fallbacks.push("zh-Hant"); // Chinese fallback - } - - // Add manifest fallback chain - return [...fallbacks, ...manifest.fallbackChain]; -} - // Use sync version for initial config - will be updated when storage loads const LOCALE = getCurrentLocaleSync(); -export const I18NEXT_CONFIG = { - lng: LOCALE, - ns: ["common", "settings"], // Common should be first as it's most frequently used - defaultNS: "common", - interpolation: { - escapeValue: false, // React already safes from XSS - }, - react: { - useSuspense: false, // Prevent Android crashes - }, - i18nFormat: { - fluentBundleOptions: { - useIsolating: false, - functions: { - VOWORCON: ([txt]: [string]) => - "aeiou".indexOf(txt[0].toLowerCase()) >= 0 ? "vow" : "con", - JOIN: (args: string[], opts: { separator?: string } = {}) => - args - .filter(Boolean) - .filter((s) => typeof s === "string") - .join(opts.separator || ""), - }, - }, - }, - load: "currentOnly", - cleanCode: true, - fallbackLng: getFallbackChain, - supportedLngs: [...manifest.supportedLocales], - debug: process.env.NODE_ENV === "development", -}; +export const I18NEXT_CONFIG = createI18nextConfig({ lng: LOCALE }); // Import platform-specific translation loader -// Metro will use i18n-loader.native.ts for React Native, i18n-loader.ts for web import { loadTranslationData as platformLoadTranslationData } from "./i18n-loader"; // Translation loading function with error handling @@ -155,19 +85,13 @@ async function loadTranslationData( namespace: string, ): Promise { try { - const translations = await platformLoadTranslationData(locale, namespace); - return translations; + return await platformLoadTranslationData(locale, namespace); } catch (error: any) { console.error( `Failed to load ${namespace} translations for ${locale}:`, error, ); - // Return minimal fallback - return { - loading: "Loading...", - error: "Error", - cancel: "Cancel", - }; + return { loading: "Loading...", error: "Error", cancel: "Cancel" }; } } @@ -177,12 +101,8 @@ let initPromise: Promise | null = null; export default async function initI18next( config: any = {}, ): Promise { - // Return existing promise if already initializing - if (initPromise) { - return initPromise; - } + if (initPromise) return initPromise; - // Load stored locale from storage first const storedLocale = await getCurrentLocale(); const finalConfig = { @@ -196,7 +116,6 @@ export default async function initI18next( .use(Fluent) .use( resourcesToBackend((locale: string, namespace: string, callback: any) => { - // Load translations using our manifest-based namespace system loadTranslationData(locale, namespace) .then((translations) => callback(null, translations)) .catch((error) => callback(error, null)); @@ -204,11 +123,11 @@ export default async function initI18next( ) .init(finalConfig) .then(() => { - // Automatically persist language changes to storage + // Persist language changes to storage i18next.on("languageChanged", (lng) => { if (lng && manifest.supportedLocales.includes(lng)) { cachedLocale = lng; - storage.setItem(LOCALE_STORAGE_KEY, lng); + storage.setItem(STORAGE_KEY, lng); } }); return i18next; @@ -219,32 +138,21 @@ export default async function initI18next( // Utility functions for language management export async function changeLanguage(locale: string): Promise { - // Storage is handled automatically via languageChanged event await i18next.changeLanguage(locale); } export function getCurrentLanguage(): string { - // Return the cached locale preference, not i18next's resolved language - // i18next.language may return just "zh" instead of "zh-Hant" return getCurrentLocaleSync(); } -export function getSupportedLocales(): string[] { - return [...manifest.supportedLocales]; -} - -export function getLanguageInfo(locale: string): any { - return manifest.languages[locale] || null; -} - -export function isLocaleSupported(locale: string): boolean { - return manifest.supportedLocales.includes(locale); -} +// Re-export pure utilities from @streamplace/i18n for backwards compat +export { + getFallbackChain, + getLanguageInfo, + getSupportedLocales, + isLocaleSupported, +} from "@streamplace/i18n"; -// Auto-initialize i18next on module load -// This ensures the instance is ready when used in providers -initI18next().catch((error) => { - console.error("Failed to auto-initialize i18n:", error); -}); +export { manifest }; export { i18next }; diff --git a/js/components/src/i18n/index.ts b/js/components/src/i18n/index.ts index 21a54e8b..1b8e952c 100644 --- a/js/components/src/i18n/index.ts +++ b/js/components/src/i18n/index.ts @@ -1,5 +1,5 @@ -// Simple i18n infrastructure exports for Streamplace components -// Re-exports i18next tools for app-level customization +// i18n infrastructure exports for Streamplace components +// Re-exports from @streamplace/i18n + React-specific bindings // Core i18next exports export { default as i18n } from "i18next"; @@ -23,22 +23,28 @@ export { default as Fluent } from "i18next-fluent"; // Basic provider components for consistent setup export { I18nProvider } from "./provider"; -// Bootstrap configuration and utilities +// Bootstrap configuration and utilities (local — adds React + storage) export { I18NEXT_CONFIG, changeLanguage, getCurrentLanguage, getCurrentLocale, - getLanguageInfo, + getCurrentLocaleSync, getLocaleFromSystemLocale, - getSupportedLocales, i18next, default as initI18next, - isLocaleSupported, } from "./i18next-config"; -// Manifest data -export { default as manifest } from "../../locales/manifest.json"; +// Re-export pure utilities from @streamplace/i18n +export { + STORAGE_KEY, + createI18nextConfig, + getFallbackChain, + getLanguageInfo, + getSupportedLocales, + isLocaleSupported, + manifest, +} from "@streamplace/i18n"; // TypeScript types export type { diff --git a/js/components/src/index.tsx b/js/components/src/index.tsx index 3ff10fed..c4737628 100644 --- a/js/components/src/index.tsx +++ b/js/components/src/index.tsx @@ -2,6 +2,9 @@ import "./crypto-polyfill"; export * from "./livestream-provider"; +// React layer for the livestream store (hooks, context). The pure +// state/factory/reducer is in @streamplace/core and re-exported through +// this local barrel. export * from "./livestream-store"; export * from "./player-store"; export * from "./streamplace-provider"; @@ -88,4 +91,7 @@ export * from "./components/vod/vod-comments"; export * from "./components/vod/vod-mobile-metadata"; export * from "./components/vod/vod-player"; export * from "./components/vod/vod-section"; -export * from "./vod-store/vod-interactions"; +// React layer for vod-store (React hooks). Pure VOD interaction +// functions are in @streamplace/core and re-exported through this +// local barrel. +export * from "./vod-store"; diff --git a/js/components/src/livestream-provider/index.tsx b/js/components/src/livestream-provider/index.tsx index 255c4bfc..83d5097b 100644 --- a/js/components/src/livestream-provider/index.tsx +++ b/js/components/src/livestream-provider/index.tsx @@ -1,15 +1,15 @@ +import { makeLivestreamStore } from "@streamplace/core"; import React, { useContext, useEffect, useRef } from "react"; import { useAvatars } from "../hooks"; import { deleteTeleport } from "../lib/slash-commands/teleport"; import { StreamNotifications } from "../lib/stream-notifications"; +import { LivestreamContext } from "../livestream-store/context"; +import { useUnpinChatMessage } from "../livestream-store/hooks"; import { - LivestreamContext, getStoreFromContext, - makeLivestreamStore, useLivestreamStore, usePinnedComment, - useUnpinChatMessage, -} from "../livestream-store"; +} from "../livestream-store/use-store"; import { useDID, usePDSAgent } from "../streamplace-store"; import { useLivestreamWebsocket } from "./websocket"; diff --git a/js/components/src/livestream-provider/websocket.tsx b/js/components/src/livestream-provider/websocket.tsx index cc763f80..f4d1d85b 100644 --- a/js/components/src/livestream-provider/websocket.tsx +++ b/js/components/src/livestream-provider/websocket.tsx @@ -1,6 +1,6 @@ import { useRef } from "react"; import useWebSocket from "react-use-websocket"; -import { useHandleWebsocketMessages } from "../livestream-store"; +import { useHandleWebsocketMessages } from "../livestream-store/use-store"; import { useUrl } from "../streamplace-store"; export function useLivestreamWebsocket(src: string) { diff --git a/js/components/src/livestream-store/context.tsx b/js/components/src/livestream-store/context.tsx index a51708e2..c1ed4dfe 100644 --- a/js/components/src/livestream-store/context.tsx +++ b/js/components/src/livestream-store/context.tsx @@ -1,5 +1,8 @@ +// React Context for the livestream store. Lives in @streamplace/components +// because it's a React-specific binding; the store itself is in +// @streamplace/core. +import type { LivestreamStore } from "@streamplace/core"; import { createContext } from "react"; -import { LivestreamStore } from "../livestream-store/livestream-store"; type LivestreamContextType = { store: LivestreamStore; diff --git a/js/components/src/livestream-store/chat.tsx b/js/components/src/livestream-store/hooks.tsx similarity index 58% rename from js/components/src/livestream-store/chat.tsx rename to js/components/src/livestream-store/hooks.tsx index f85d1ebc..5d1a8039 100644 --- a/js/components/src/livestream-store/chat.tsx +++ b/js/components/src/livestream-store/hooks.tsx @@ -1,14 +1,19 @@ +// React hooks for chat/moderation state. The pure reducer and types live +// in @streamplace/core; these hooks bind them to the LivestreamStore and +// to React lifecycle. import { ComAtprotoModerationCreateReport, RichText } from "@atproto/api"; -import { useCallback } from "react"; import { - ChatMessageViewHydrated, - PlaceStreamChatMessage, - PlaceStreamDefs, -} from "streamplace"; + type LivestreamState, + type NewChatMessage, + reduceChat, +} from "@streamplace/core"; +import { useCallback } from "react"; +import { ChatMessageViewHydrated, PlaceStreamChatMessage } from "streamplace"; import { useChatProfile, useDID, useHandle } from "../streamplace-store"; import { usePDSAgent } from "../streamplace-store/xrpc"; -import { LivestreamState } from "./livestream-state"; -import { getStoreFromContext, useLivestreamStore } from "./livestream-store"; +import { getStoreFromContext, useLivestreamStore } from "./use-store"; + +export { NewChatMessage }; export const useReplyToMessage = () => useLivestreamStore((state) => state.replyToMessage); @@ -70,14 +75,6 @@ export const useAddPendingHide = () => { ); }; -export type NewChatMessage = { - text: string; - reply?: { - cid: string; - uri: string; - }; -}; - export const useCreateChatMessage = () => { const pdsAgent = usePDSAgent(); const store = getStoreFromContext(); @@ -217,216 +214,6 @@ export const useAddSystemMessage = () => { ); }; -const buildSortedChatList = ( - chatIndex: { [key: string]: ChatMessageViewHydrated }, - existingChatList: ChatMessageViewHydrated[], - newMessages: { key: string; message: ChatMessageViewHydrated }[], - removedKeys: Set, -): ChatMessageViewHydrated[] => { - const sortedKeys = Object.keys(chatIndex).sort((a, b) => { - const aTime = parseInt(a.split("-")[0], 10); - const bTime = parseInt(b.split("-")[0], 10); - return bTime - aTime; - }); - return sortedKeys - .map((key) => chatIndex[key]) - .filter((msg) => !removedKeys.has(msg.uri)); -}; - -const profileIsDifferent = ( - newProfile: ChatMessageViewHydrated["chatProfile"], - oldProfile: ChatMessageViewHydrated["chatProfile"], -) => { - if (!oldProfile) { - return true; - } - if (!newProfile) { - return false; - } - if (!oldProfile.color) { - return true; - } - if (!newProfile.color) { - // idk. shouldn't happen. - return false; - } - const { red: newRed, green: newGreen, blue: newBlue } = newProfile.color; - const { red: oldRed, green: oldGreen, blue: oldBlue } = oldProfile.color; - return newRed !== oldRed || newGreen !== oldGreen || newBlue !== oldBlue; -}; - -export const reduceChatIncremental = ( - state: LivestreamState, - newMessages: ChatMessageViewHydrated[], - blocks: PlaceStreamDefs.BlockView[], - hideUris: string[] = [], -): LivestreamState => { - if ( - newMessages.length === 0 && - blocks.length === 0 && - hideUris.length === 0 - ) { - return state; - } - - const newChatIndex = { ...state.chatIndex }; - const newAuthors = { ...state.authors }; - let hasChanges = false; - const removedKeys = new Set(); - - for (const msg of newMessages) { - if (msg.deleted) { - hasChanges = true; - // find and remove the message from the index - for (const [key, message] of Object.entries(newChatIndex)) { - if (message.uri === msg.uri) { - delete newChatIndex[key]; - removedKeys.add(key); - } - } - } - } - newMessages = newMessages.filter((msg) => msg.deleted !== true); - - // handle blocks - if (blocks.length > 0) { - const blockedDIDs = new Set(blocks.map((block) => block.record.subject)); - for (const [key, message] of Object.entries(newChatIndex)) { - if (blockedDIDs.has(message.author.did)) { - delete newChatIndex[key]; - removedKeys.add(key); - hasChanges = true; - } - } - } - - if (hideUris.length > 0) { - for (const [key, message] of Object.entries(newChatIndex)) { - if (hideUris.includes(message.uri)) { - delete newChatIndex[key]; - removedKeys.add(key); - hasChanges = true; - } - } - } - - const messagesToAdd: { key: string; message: ChatMessageViewHydrated }[] = []; - - for (const message of newMessages) { - // don't worry about messages that will be hidden - if (state.pendingHides.includes(message.uri)) { - continue; - } - - const date = new Date(message.record.createdAt); - const key = `${date.getTime()}-${message.uri}`; - - // only change the ref if the profile is different to avoid re-renders elsewhere - if ( - profileIsDifferent(message.chatProfile, newAuthors[message.author.did]) - ) { - newAuthors[message.author.did] = message.chatProfile; - } - - // skip messages we already have - if (newChatIndex[key] && newChatIndex[key].uri === message.uri) { - continue; - } - - // if we have a local message, replace it with the new one - if (!message.uri.startsWith("local-")) { - const existingLocalKey = Object.keys(newChatIndex).find((k) => { - const msg = newChatIndex[k]; - return ( - msg.uri.startsWith("local-") && - msg.record.text === message.record.text && - msg.author.did === message.author.did && - Math.abs(new Date(msg.record.createdAt).getTime() - date.getTime()) < - 10000 // Within 10 seconds - ); - }); - - if (existingLocalKey) { - delete newChatIndex[existingLocalKey]; - removedKeys.add(existingLocalKey); - hasChanges = true; - } - } - - // add reply info - let processedMessage = message; - if (message.record.reply) { - const reply = message.record.reply as { - parent?: { uri: string; cid: string }; - root?: { uri: string; cid: string }; - }; - - const parentUri = reply?.parent?.uri || reply?.root?.uri; - if (parentUri) { - const parentMsgKey = Object.keys(newChatIndex).find( - (k) => newChatIndex[k].uri === parentUri, - ); - - if (parentMsgKey) { - const parentMsg = newChatIndex[parentMsgKey]; - // Don't allow replies to system messages - if (parentMsg.author.did !== "did:sys:system") { - processedMessage = { - ...message, - replyTo: { - $type: "place.stream.chat.defs#messageView", - cid: parentMsg.cid, - uri: parentMsg.uri, - author: parentMsg.author, - record: parentMsg.record, - chatProfile: parentMsg.chatProfile, - indexedAt: parentMsg.indexedAt, - }, - }; - } - } - } - } - - messagesToAdd.push({ key, message: processedMessage }); - hasChanges = true; - } - - // Add new messages to index - for (const { key, message } of messagesToAdd) { - newChatIndex[key] = message; - } - - // only rebuild if we have changes - if (!hasChanges) { - return state; - } - - // Build the new sorted chat list efficiently - const newChatList = buildSortedChatList( - newChatIndex, - state.chat, - messagesToAdd, - removedKeys, - ); - - // Clean up pendingHides - remove URIs that we've now processed - let newPendingHides = state.pendingHides; - if (hideUris.length > 0) { - newPendingHides = state.pendingHides.filter( - (uri) => !hideUris.includes(uri), - ); - } - - return { - ...state, - authors: newAuthors, - chatIndex: newChatIndex, - chat: newChatList, - pendingHides: newPendingHides, - }; -}; - export const useSubmitReport = () => { const pdsAgent = usePDSAgent(); const userDID = useDID(); @@ -486,8 +273,6 @@ export const useReportChatMessage = () => { ); }; -export const reduceChat = reduceChatIncremental; - export const usePinChatMessage = () => { const agent = usePDSAgent(); const store = getStoreFromContext(); diff --git a/js/components/src/livestream-store/index.tsx b/js/components/src/livestream-store/index.tsx index 0e138296..4925564f 100644 --- a/js/components/src/livestream-store/index.tsx +++ b/js/components/src/livestream-store/index.tsx @@ -1,4 +1,45 @@ -export * from "./chat"; -export * from "./context"; -export * from "./livestream-store"; -export * from "./stream-key"; +// React layer for the livestream store. Re-exports the pure types/factories +// from @streamplace/core and the React-specific hooks from local files. +export { + LivestreamProblem, + LivestreamState, + LivestreamStore, + findProblems, + handleWebSocketMessages, + makeLivestreamStore, + reduceChat, +} from "@streamplace/core"; +export { LivestreamContext } from "./context"; +export { + NewChatMessage, + useAddPendingHide, + useAddSystemMessage, + useBadgeSlots, + useChatDraft, + useCreateChatMessage, + useDeleteChatMessage, + usePendingHides, + usePinChatMessage, + useReplyToMessage, + useReportChatMessage, + useSetBadgeSlots, + useSetChatDraft, + useSetReplyToMessage, + useSubmitReport, + useUnpinChatMessage, +} from "./hooks"; +export { useStreamKey } from "./stream-key"; +export { + getStoreFromContext, + useChat, + useHandleWebsocketMessages, + useLivestream, + useLivestreamStore, + useLivestreamStoreOptional, + usePinnedComment, + useProfile, + useRecentSegments, + useRenditions, + useSegment, + useViewers, +} from "./use-store"; diff --git a/js/components/src/livestream-store/stream-key.tsx b/js/components/src/livestream-store/stream-key.tsx index 90aa96a9..2af9554f 100644 --- a/js/components/src/livestream-store/stream-key.tsx +++ b/js/components/src/livestream-store/stream-key.tsx @@ -1,14 +1,15 @@ -// Just to be 100% sure it's imported successfully -import "../crypto-polyfill"; +// React-specific stream key generation flow. Uses useEffect/useState +// to generate a Secp256k1 key, post it to the user's PDS, and cache the +// result. Pure key/crypto utilities live in @streamplace/core. +import "@streamplace/components/src/crypto-polyfill"; import { bytesToMultibase, Secp256k1Keypair } from "@atproto/crypto"; +import { getBrowserName, getPlatform } from "@streamplace/core"; import { useEffect, useState } from "react"; -import { Platform } from "react-native"; import { PlaceStreamKey } from "streamplace"; import { privateKeyToAccount } from "viem/accounts"; -import { getBrowserName } from "../lib/browser"; import { usePDSAgent } from "../streamplace-store/xrpc"; -import { useLivestreamStore } from "./livestream-store"; +import { useLivestreamStore } from "./use-store"; export const useStreamKey = (): { streamKey: { @@ -53,9 +54,9 @@ export const useStreamKey = (): { address: account.address.toLowerCase(), }; - let platform: string = Platform.OS; + let platform: string = getPlatform(); if ( - Platform.OS === "web" && + getPlatform() === "web" && typeof window !== "undefined" && window.navigator ) { diff --git a/js/components/src/livestream-store/livestream-store.tsx b/js/components/src/livestream-store/use-store.tsx similarity index 65% rename from js/components/src/livestream-store/livestream-store.tsx rename to js/components/src/livestream-store/use-store.tsx index f29306b1..ed7db7b4 100644 --- a/js/components/src/livestream-store/livestream-store.tsx +++ b/js/components/src/livestream-store/use-store.tsx @@ -1,41 +1,18 @@ +// React bindings for the vanilla LivestreamStore in @streamplace/core. +// useLivestreamStore subscribes a component to a slice of state and +// re-renders on changes. useLivestreamStoreOptional never throws when +// there's no provider above (useful for mode-generic hooks like useTitle). +import { + handleWebSocketMessages, + makeLivestreamStore, + type LivestreamState, + type LivestreamStore, +} from "@streamplace/core"; import { useContext } from "react"; -import { createStore, StoreApi, useStore } from "zustand"; +import { useStore } from "zustand"; import { LivestreamContext } from "./context"; -import { LivestreamState } from "./livestream-state"; -import { handleWebSocketMessages } from "./websocket-consumer"; -export type LivestreamStore = StoreApi; - -export const makeLivestreamStore = (): StoreApi => { - return createStore()((set) => ({ - profile: null, - chatIndex: {}, - chat: [], - livestream: null, - viewers: null, - pendingHides: [], - segment: null, - renditions: [], - replyToMessage: null, - chatDraft: "", - badgeSlots: null, - streamKey: null, - setStreamKey: (sk) => set({ streamKey: sk }), - authors: {}, - recentSegments: [], - problems: [], - activeTeleport: null, - activeTeleportUri: null, - setActiveTeleportUri: (uri) => set({ activeTeleportUri: uri }), - websocketConnected: false, - hasReceivedSegment: false, - pinnedComment: null, - moderationPermissions: [], - setModerationPermissions: (perms) => set({ moderationPermissions: perms }), - localLivestreamURI: null, - setLocalLivestreamURI: (uri) => set({ localLivestreamURI: uri }), - })); -}; +export { LivestreamStore, makeLivestreamStore }; export function getStoreFromContext(): LivestreamStore { const context = useContext(LivestreamContext); diff --git a/js/components/src/streamplace-store/moderation.tsx b/js/components/src/streamplace-store/moderation.tsx index 34f071f9..f9945747 100644 --- a/js/components/src/streamplace-store/moderation.tsx +++ b/js/components/src/streamplace-store/moderation.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { PlaceStreamModerationPermission } from "streamplace"; -import { useLivestreamStore } from "../livestream-store/livestream-store"; +import { useLivestreamStore } from "../livestream-store"; import { usePDSAgent } from "./xrpc"; export interface ModerationPermissions { diff --git a/js/components/src/vod-store/hooks.tsx b/js/components/src/vod-store/hooks.tsx new file mode 100644 index 00000000..75df20f7 --- /dev/null +++ b/js/components/src/vod-store/hooks.tsx @@ -0,0 +1,110 @@ +// React hook wrappers for the pure VOD interaction functions in +// @streamplace/core. The hooks pull pdsAgent and userDID from the +// streamplace store and call the pure functions. +import { + createLike as createLikeCore, + createVodComment as createVodCommentCore, + deleteLike as deleteLikeCore, + deleteVodComment as deleteVodCommentCore, + getLikeCount as getLikeCountCore, + getLikes as getLikesCore, + getVodComments as getVodCommentsCore, +} from "@streamplace/core"; +import { useCallback } from "react"; +import { useDID } from "../streamplace-store"; +import { usePDSAgent } from "../streamplace-store/xrpc"; + +export const useCreateVodComment = () => { + const pdsAgent = usePDSAgent(); + const userDID = useDID(); + return useCallback( + (params: { text: string; video: string }) => { + if (!pdsAgent || !userDID) { + throw new Error("No PDS agent or user DID found"); + } + return createVodCommentCore(pdsAgent, userDID, params); + }, + [pdsAgent, userDID], + ); +}; + +export const useDeleteVodComment = () => { + const pdsAgent = usePDSAgent(); + const userDID = useDID(); + return useCallback( + (uri: string) => { + if (!pdsAgent || !userDID) { + throw new Error("No PDS agent or user DID found"); + } + return deleteVodCommentCore(pdsAgent, userDID, uri); + }, + [pdsAgent, userDID], + ); +}; + +export const useCreateLike = () => { + const pdsAgent = usePDSAgent(); + const userDID = useDID(); + return useCallback( + (subject: string) => { + if (!pdsAgent || !userDID) { + throw new Error("No PDS agent or user DID found"); + } + return createLikeCore(pdsAgent, userDID, subject); + }, + [pdsAgent, userDID], + ); +}; + +export const useDeleteLike = () => { + const pdsAgent = usePDSAgent(); + const userDID = useDID(); + return useCallback( + (uri: string) => { + if (!pdsAgent || !userDID) { + throw new Error("No PDS agent or user DID found"); + } + return deleteLikeCore(pdsAgent, userDID, uri); + }, + [pdsAgent, userDID], + ); +}; + +export const useGetVodComments = () => { + const pdsAgent = usePDSAgent(); + return useCallback( + (video: string, limit?: number, cursor?: string) => { + if (!pdsAgent) { + throw new Error("No PDS agent found"); + } + return getVodCommentsCore(pdsAgent, video, limit, cursor); + }, + [pdsAgent], + ); +}; + +export const useGetLikes = () => { + const pdsAgent = usePDSAgent(); + return useCallback( + (subject: string, limit?: number, cursor?: string) => { + if (!pdsAgent) { + throw new Error("No PDS agent found"); + } + return getLikesCore(pdsAgent, subject, limit, cursor); + }, + [pdsAgent], + ); +}; + +export const useLikeCount = () => { + const pdsAgent = usePDSAgent(); + return useCallback( + (subject: string) => { + if (!pdsAgent) { + throw new Error("No PDS agent found"); + } + return getLikeCountCore(pdsAgent, subject); + }, + [pdsAgent], + ); +}; diff --git a/js/components/src/vod-store/index.tsx b/js/components/src/vod-store/index.tsx new file mode 100644 index 00000000..91d33273 --- /dev/null +++ b/js/components/src/vod-store/index.tsx @@ -0,0 +1,12 @@ +// React layer for VOD interactions. Re-exports the pure types from +// @streamplace/core and the React hooks from ./hooks. +export type { VodCommentHydrated } from "@streamplace/core"; +export { + useCreateLike, + useCreateVodComment, + useDeleteLike, + useDeleteVodComment, + useGetLikes, + useGetVodComments, + useLikeCount, +} from "./hooks"; diff --git a/js/components/src/vod-store/vod-interactions.tsx b/js/components/src/vod-store/vod-interactions.tsx deleted file mode 100644 index 6a79e414..00000000 --- a/js/components/src/vod-store/vod-interactions.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { useCallback } from "react"; -import { - PlaceStreamGetLikes, - PlaceStreamLike, - PlaceStreamVodComment, - PlaceStreamVodDefs, - PlaceStreamVodGetComments, -} from "streamplace"; -import { useDID } from "../streamplace-store/streamplace-store"; -import { usePDSAgent } from "../streamplace-store/xrpc"; - -export const useCreateVodComment = () => { - const pdsAgent = usePDSAgent(); - const userDID = useDID(); - - return useCallback( - async (params: { text: string; video: string }) => { - if (!pdsAgent || !userDID) { - throw new Error("No PDS agent or user DID found"); - } - - const record: PlaceStreamVodComment.Record = { - $type: "place.stream.vod.comment", - text: params.text, - createdAt: new Date().toISOString(), - video: params.video, - }; - - return await pdsAgent.com.atproto.repo.createRecord({ - repo: userDID, - collection: "place.stream.vod.comment", - record, - }); - }, - [pdsAgent, userDID], - ); -}; - -export const useDeleteVodComment = () => { - const pdsAgent = usePDSAgent(); - const userDID = useDID(); - - return useCallback( - async (uri: string) => { - if (!pdsAgent || !userDID) { - throw new Error("No PDS agent or user DID found"); - } - const rkey = uri.split("/").pop(); - if (!rkey) throw new Error("No rkey found"); - return await pdsAgent.com.atproto.repo.deleteRecord({ - repo: userDID, - collection: "place.stream.vod.comment", - rkey, - }); - }, - [pdsAgent, userDID], - ); -}; - -export const useCreateLike = () => { - const pdsAgent = usePDSAgent(); - const userDID = useDID(); - - return useCallback( - async (subject: string) => { - if (!pdsAgent || !userDID) { - throw new Error("No PDS agent or user DID found"); - } - - const record: PlaceStreamLike.Record = { - $type: "place.stream.like", - subject, - createdAt: new Date().toISOString(), - }; - - return await pdsAgent.com.atproto.repo.createRecord({ - repo: userDID, - collection: "place.stream.like", - record, - }); - }, - [pdsAgent, userDID], - ); -}; - -export const useDeleteLike = () => { - const pdsAgent = usePDSAgent(); - const userDID = useDID(); - - return useCallback( - async (uri: string) => { - if (!pdsAgent || !userDID) { - throw new Error("No PDS agent or user DID found"); - } - const rkey = uri.split("/").pop(); - if (!rkey) throw new Error("No rkey found"); - return await pdsAgent.com.atproto.repo.deleteRecord({ - repo: userDID, - collection: "place.stream.like", - rkey, - }); - }, - [pdsAgent, userDID], - ); -}; - -export const useGetVodComments = () => { - const pdsAgent = usePDSAgent(); - - return useCallback( - async ( - video: string, - limit?: number, - cursor?: string, - ): Promise => { - if (!pdsAgent) { - throw new Error("No PDS agent found"); - } - const res = await pdsAgent.place.stream.vod.getComments({ - video, - limit, - cursor, - }); - return res.data; - }, - [pdsAgent], - ); -}; - -export const useGetLikes = () => { - const pdsAgent = usePDSAgent(); - - return useCallback( - async ( - subject: string, - limit?: number, - cursor?: string, - ): Promise => { - if (!pdsAgent) { - throw new Error("No PDS agent found"); - } - const res = await pdsAgent.place.stream.getLikes({ - subject, - limit, - cursor, - }); - return res.data; - }, - [pdsAgent], - ); -}; - -export const useLikeCount = () => { - const pdsAgent = usePDSAgent(); - - return useCallback( - async (subject: string): Promise => { - if (!pdsAgent) { - throw new Error("No PDS agent found"); - } - const res = await pdsAgent.place.stream.getLikes({ - subject, - limit: 1, - }); - return res.data.count; - }, - [pdsAgent], - ); -}; - -export type VodCommentHydrated = PlaceStreamVodDefs.CommentView & { - record: PlaceStreamVodComment.Record; -}; diff --git a/js/components/tsconfig.json b/js/components/tsconfig.json index 0757c172..115101ce 100644 --- a/js/components/tsconfig.json +++ b/js/components/tsconfig.json @@ -6,8 +6,8 @@ "jsx": "react-jsx", "module": "commonjs", "noEmit": false, - "declaration": true, - "declarationMap": true, + "declaration": false, + "declarationMap": false, "sourceMap": true, "resolveJsonModule": true }, diff --git a/js/core/package.json b/js/core/package.json new file mode 100644 index 00000000..f7472046 --- /dev/null +++ b/js/core/package.json @@ -0,0 +1,33 @@ +{ + "name": "@streamplace/core", + "version": "0.0.0", + "private": true, + "description": "Streamplace platform-agnostic core (data layer, providers, i18n, theme tokens, hooks). No React Native or Expo dependencies.", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "check": "bash -c 'set -euo pipefail;export OUT=$(mktemp -d); npx tsc -p . --outDir $OUT; rm -rf $OUT'", + "test": "echo \"no tests yet\" && exit 0" + }, + "dependencies": { + "@atproto/api": "^0.19.3", + "@streamplace/components": "workspace:*", + "react": "*", + "streamplace": "workspace:*", + "zustand": "^5.0.5" + }, + "peerDependencies": { + "react": "*" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } +} diff --git a/js/core/src/index.ts b/js/core/src/index.ts new file mode 100644 index 00000000..b071bbb2 --- /dev/null +++ b/js/core/src/index.ts @@ -0,0 +1,15 @@ +// Public API of @streamplace/core. +// Only platform-agnostic code lives here. No react-native, no expo-*. +// +// During the extraction phase, some modules still pull a couple of +// "leaves" from @streamplace/components (e.g. useDID, usePDSAgent). +// Those will move here in subsequent refactors. Anything imported from +// @streamplace/components in this file is by design temporary. +export * from "./vod-store"; + +export * from "./livestream-store"; + +export * from "./lib/browser"; +export * from "./lib/facet"; +export { getPlatform } from "./lib/platform"; +export type { CorePlatform } from "./lib/platform"; diff --git a/js/core/src/lib/browser.ts b/js/core/src/lib/browser.ts new file mode 100644 index 00000000..b0a852db --- /dev/null +++ b/js/core/src/lib/browser.ts @@ -0,0 +1,27 @@ +export function getBrowserName(userAgent: string) { + // The order matters here, and this may report false positives for unlisted browsers. + + if (userAgent.includes("Firefox")) { + // "Mozilla/5.0 (X11; Linux i686; rv:104.0) Gecko/20100101 Firefox/104.0" + return "Firefox"; + } else if (userAgent.includes("SamsungBrowser")) { + // "Mozilla/5.0 (Linux; Android 9; SAMSUNG SM-G955F Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/9.4 Chrome/67.0.3396.87 Mobile Safari/537.36" + return "Samsung Internet"; + } else if (userAgent.includes("Opera") || userAgent.includes("OPR")) { + // "Mozilla/5.0 (Macintosh; Intel Mac OS X 12_5_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36 OPR/90.0.4480.54" + return "Opera"; + } else if (userAgent.includes("Edge")) { + // "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299" + return "Edge (Legacy)"; + } else if (userAgent.includes("Edg")) { + // "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36 Edg/104.0.1293.70" + return "Edge (Chromium)"; + } else if (userAgent.includes("Chrome")) { + // "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36" + return "Chrome"; + } else if (userAgent.includes("Safari")) { + // "Mozilla/5.0 (iPhone; CPU iPhone OS 15_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6 Mobile/15E148 Safari/604.1" + return "Safari"; + } + return "unknown"; +} diff --git a/js/components/src/lib/facet.ts b/js/core/src/lib/facet.ts similarity index 100% rename from js/components/src/lib/facet.ts rename to js/core/src/lib/facet.ts diff --git a/js/core/src/lib/format-handle.ts b/js/core/src/lib/format-handle.ts new file mode 100644 index 00000000..13a5f7e8 --- /dev/null +++ b/js/core/src/lib/format-handle.ts @@ -0,0 +1,24 @@ +import { AppBskyActorDefs } from "@atproto/api"; + +/** + * formats a user's handle for display, falling back to DID if handle is invalid + */ +export function formatHandle( + profile: Pick, + prefix: string = "", +): string { + if (profile.handle === "handle.invalid") { + return profile.did; + } + return prefix + profile.handle; +} + +/** + * convenience function for formatting a user's handle with @ prefix for display, + * falling back to DID if handle is invalid + */ +export function formatHandleWithAt( + profile: Pick, +): string { + return formatHandle(profile, "@"); +} diff --git a/js/core/src/lib/platform.ts b/js/core/src/lib/platform.ts new file mode 100644 index 00000000..ef0882dd --- /dev/null +++ b/js/core/src/lib/platform.ts @@ -0,0 +1,35 @@ +// Platform detection helper. Pure JS, no react-native / expo deps. +// +// Replaces the `Platform.OS` API from react-native for use in +// @streamplace/core code. Use the core package's `getPlatform()` in +// platform-agnostic code; only fall back to react-native's `Platform` in +// UI code that lives in @streamplace/components. + +export type CorePlatform = + | "ios" + | "android" + | "web" + | "macos" + | "windows" + | "linux" + | "electron" + | "node" + | "unknown"; + +export function getPlatform(): CorePlatform { + if (typeof navigator === "undefined" && typeof process !== "undefined") { + if ((process as any).versions?.electron) return "electron"; + if ((process as any).versions?.node) return "node"; + } + + if (typeof navigator !== "undefined") { + const ua = (navigator as any).userAgent ?? ""; + if (/iPhone|iPad|iPod/i.test(ua)) return "ios"; + if (/Android/i.test(ua)) return "android"; + if (/Macintosh/i.test(ua)) return "macos"; + if (/Windows/i.test(ua)) return "windows"; + if (/Linux/i.test(ua)) return "linux"; + } + + return "unknown"; +} diff --git a/js/core/src/lib/system-messages.ts b/js/core/src/lib/system-messages.ts new file mode 100644 index 00000000..2c48ce44 --- /dev/null +++ b/js/core/src/lib/system-messages.ts @@ -0,0 +1,186 @@ +import { ChatMessageViewHydrated } from "streamplace"; + +export enum SystemMessageType { + stream_start = "stream_start", + stream_end = "stream_end", + notification = "notification", + command_error = "command_error", +} + +export interface SystemMessageMetadata { + username?: string; + action?: string; + count?: number; + duration?: string; + reason?: string; + streamerName?: string; +} + +/** + * Creates a system message with the proper structure + * @param type The type of system message + * @param text The message text + * @param metadata Optional metadata for the message + * @returns A properly formatted ChatMessageViewHydrated object + */ +let systemMessageCounter = 0; + +export const createSystemMessage = ( + type: SystemMessageType, + text: string, + metadata?: SystemMessageMetadata, + date: Date = new Date(), +): ChatMessageViewHydrated => { + const now = date; + const uniqueId = `${now.getTime()}-${systemMessageCounter++}`; + + return { + uri: `at://did:sys:system/place.stream.chat.message/${uniqueId}`, + cid: `system-${uniqueId}`, + author: { + did: "did:sys:system", + handle: type, // Use handle to specify the type of system message + }, + record: { + text, + createdAt: now.toISOString(), + streamer: "system", + $type: "place.stream.chat.message", + }, + indexedAt: now.toISOString(), + chatProfile: { + color: { red: 128, green: 128, blue: 128 }, // Gray color for system messages + $type: "place.stream.chat.profile", + }, + }; +}; + +/** + * System message factory functions for common scenarios + */ +export const SystemMessages = { + streamStart: (streamerName: string): ChatMessageViewHydrated => + createSystemMessage( + SystemMessageType.stream_start, + `Now streaming - ${streamerName}`, + { + streamerName, + }, + ), + + // technically, streams can't 'end' on Streamplace + // possibly we could use deleting or editing streams (`endedAt` param) for this? + streamEnd: (duration?: string): ChatMessageViewHydrated => + createSystemMessage( + SystemMessageType.stream_end, + duration ? `Stream has ended. Duration: ${duration}` : "Stream has ended", + { duration }, + ), + + teleportArrival: ( + streamerName: string, + streamerDid: string, + count: number, + chatProfile?: any, + ): ChatMessageViewHydrated => { + const text = + count > 0 + ? `${count} viewer${count !== 1 ? "s" : ""} teleported from ${streamerName}'s stream! Say hi!` + : `Someone teleported from ${streamerName}'s stream! Say hi!`; + const message = createSystemMessage(SystemMessageType.notification, text, { + streamerName, + count, + }); + + // create a mention facet for the streamer name so it gets colored using existing mention rendering + if (chatProfile && streamerDid) { + const nameStart = text.indexOf(streamerName); + + // encode byte positions + const encoder = new TextEncoder(); + const byteStart = encoder.encode(text.substring(0, nameStart)).length; + const byteEnd = byteStart + encoder.encode(streamerName).length; + + message.record.facets = [ + { + index: { + byteStart, + byteEnd, + }, + features: [ + { + $type: "app.bsky.richtext.facet#mention", + did: streamerDid, + }, + ], + }, + ]; + } + + return message; + }, + + notification: (message: string): ChatMessageViewHydrated => + createSystemMessage(SystemMessageType.notification, message), + + commandError: (message: string): ChatMessageViewHydrated => + createSystemMessage(SystemMessageType.command_error, message), +}; + +/** + * Checks if a message is a system message + * @param message The message to check + * @returns True if the message is a system message + */ +export const isSystemMessage = (message: ChatMessageViewHydrated): boolean => { + return message.author.did === "did:sys:system"; +}; + +/** + * Gets the system message type from a message + * @param message The message to check + * @returns The system message type or null if not a system message + */ +export const getSystemMessageType = ( + message: ChatMessageViewHydrated, +): SystemMessageType | null => { + if (!isSystemMessage(message)) { + return null; + } + return message.author.handle as SystemMessageType; +}; + +/** + * Parses metadata from a system message based on its type + * @param message The system message to parse + * @returns The parsed metadata + */ +export const parseSystemMessageMetadata = ( + message: ChatMessageViewHydrated, +): SystemMessageMetadata => { + const metadata: SystemMessageMetadata = {}; + const type = getSystemMessageType(message); + const text = message.record.text; + + if (!type) return metadata; + + switch (type) { + case "stream_end": { + const durationMatch = text.match(/Duration:\s*(\d+:\d+(?::\d+)?)/); + if (durationMatch) { + metadata.duration = durationMatch[1]; + } + break; + } + + case "stream_start": { + const streamerMatch = text.match(/^(.+?)\s+is now live!/); + if (streamerMatch) { + metadata.streamerName = streamerMatch[1]; + } + break; + } + } + + return metadata; +}; diff --git a/js/core/src/livestream-store/chat-reducer.ts b/js/core/src/livestream-store/chat-reducer.ts new file mode 100644 index 00000000..9b554cbb --- /dev/null +++ b/js/core/src/livestream-store/chat-reducer.ts @@ -0,0 +1,226 @@ +// Pure chat reducer. No React imports. +// +// Takes a LivestreamState plus a delta (new messages, blocks, hidden URIs) +// and returns a new state. Used by the websocket consumer, the React hooks +// (in @streamplace/components), and any other consumer that wants to apply +// chat updates to a LivestreamStore. +import { ChatMessageViewHydrated, PlaceStreamDefs } from "streamplace"; +import { LivestreamState } from "./state"; + +export type NewChatMessage = { + text: string; + reply?: { + cid: string; + uri: string; + }; +}; + +const buildSortedChatList = ( + chatIndex: { [key: string]: ChatMessageViewHydrated }, + _existingChatList: ChatMessageViewHydrated[], + _newMessages: { key: string; message: ChatMessageViewHydrated }[], + removedKeys: Set, +): ChatMessageViewHydrated[] => { + const sortedKeys = Object.keys(chatIndex).sort((a, b) => { + const aTime = parseInt(a.split("-")[0], 10); + const bTime = parseInt(b.split("-")[0], 10); + return bTime - aTime; + }); + return sortedKeys + .map((key) => chatIndex[key]) + .filter((msg) => !removedKeys.has(msg.uri)); +}; + +const profileIsDifferent = ( + newProfile: ChatMessageViewHydrated["chatProfile"], + oldProfile: ChatMessageViewHydrated["chatProfile"], +) => { + if (!oldProfile) { + return true; + } + if (!newProfile) { + return false; + } + if (!oldProfile.color) { + return true; + } + if (!newProfile.color) { + // idk. shouldn't happen. + return false; + } + const { red: newRed, green: newGreen, blue: newBlue } = newProfile.color; + const { red: oldRed, green: oldGreen, blue: oldBlue } = oldProfile.color; + return newRed !== oldRed || newGreen !== oldGreen || newBlue !== oldBlue; +}; + +export const reduceChat = ( + state: LivestreamState, + newMessages: ChatMessageViewHydrated[], + blocks: PlaceStreamDefs.BlockView[], + hideUris: string[] = [], +): LivestreamState => { + if ( + newMessages.length === 0 && + blocks.length === 0 && + hideUris.length === 0 + ) { + return state; + } + + const newChatIndex = { ...state.chatIndex }; + const newAuthors = { ...state.authors }; + let hasChanges = false; + const removedKeys = new Set(); + + for (const msg of newMessages) { + if (msg.deleted) { + hasChanges = true; + // find and remove the message from the index + for (const [key, message] of Object.entries(newChatIndex)) { + if (message.uri === msg.uri) { + delete newChatIndex[key]; + removedKeys.add(key); + } + } + } + } + newMessages = newMessages.filter((msg) => msg.deleted !== true); + + // handle blocks + if (blocks.length > 0) { + const blockedDIDs = new Set(blocks.map((block) => block.record.subject)); + for (const [key, message] of Object.entries(newChatIndex)) { + if (blockedDIDs.has(message.author.did)) { + delete newChatIndex[key]; + removedKeys.add(key); + hasChanges = true; + } + } + } + + if (hideUris.length > 0) { + for (const [key, message] of Object.entries(newChatIndex)) { + if (hideUris.includes(message.uri)) { + delete newChatIndex[key]; + removedKeys.add(key); + hasChanges = true; + } + } + } + + const messagesToAdd: { key: string; message: ChatMessageViewHydrated }[] = []; + + for (const message of newMessages) { + // don't worry about messages that will be hidden + if (state.pendingHides.includes(message.uri)) { + continue; + } + + const date = new Date(message.record.createdAt); + const key = `${date.getTime()}-${message.uri}`; + + // only change the ref if the profile is different to avoid re-renders elsewhere + if ( + profileIsDifferent(message.chatProfile, newAuthors[message.author.did]) + ) { + newAuthors[message.author.did] = message.chatProfile; + } + + // skip messages we already have + if (newChatIndex[key] && newChatIndex[key].uri === message.uri) { + continue; + } + + // if we have a local message, replace it with the new one + if (!message.uri.startsWith("local-")) { + const existingLocalKey = Object.keys(newChatIndex).find((k) => { + const msg = newChatIndex[k]; + return ( + msg.uri.startsWith("local-") && + msg.record.text === message.record.text && + msg.author.did === message.author.did && + Math.abs(new Date(msg.record.createdAt).getTime() - date.getTime()) < + 10000 // Within 10 seconds + ); + }); + + if (existingLocalKey) { + delete newChatIndex[existingLocalKey]; + removedKeys.add(existingLocalKey); + hasChanges = true; + } + } + + // add reply info + let processedMessage = message; + if (message.record.reply) { + const reply = message.record.reply as { + parent?: { uri: string; cid: string }; + root?: { uri: string; cid: string }; + }; + + const parentUri = reply?.parent?.uri || reply?.root?.uri; + if (parentUri) { + const parentMsgKey = Object.keys(newChatIndex).find( + (k) => newChatIndex[k].uri === parentUri, + ); + + if (parentMsgKey) { + const parentMsg = newChatIndex[parentMsgKey]; + // Don't allow replies to system messages + if (parentMsg.author.did !== "did:sys:system") { + processedMessage = { + ...message, + replyTo: { + $type: "place.stream.chat.defs#messageView", + cid: parentMsg.cid, + uri: parentMsg.uri, + author: parentMsg.author, + record: parentMsg.record, + chatProfile: parentMsg.chatProfile, + indexedAt: parentMsg.indexedAt, + }, + }; + } + } + } + } + + messagesToAdd.push({ key, message: processedMessage }); + hasChanges = true; + } + + // Add new messages to index + for (const { key, message } of messagesToAdd) { + newChatIndex[key] = message; + } + + // only rebuild if we have changes + if (!hasChanges) { + return state; + } + + // Build the new sorted chat list efficiently + const newChatList = buildSortedChatList( + newChatIndex, + state.chat, + messagesToAdd, + removedKeys, + ); + + // Clean up pendingHides - remove URIs that we've now processed + let newPendingHides = state.pendingHides; + if (hideUris.length > 0) { + newPendingHides = state.pendingHides.filter( + (uri) => !hideUris.includes(uri), + ); + } + + return { + ...state, + authors: newAuthors, + chatIndex: newChatIndex, + chat: newChatList, + pendingHides: newPendingHides, + }; +}; diff --git a/js/core/src/livestream-store/index.tsx b/js/core/src/livestream-store/index.tsx new file mode 100644 index 00000000..bf502f06 --- /dev/null +++ b/js/core/src/livestream-store/index.tsx @@ -0,0 +1,9 @@ +// Public surface of the livestream-store module in @streamplace/core. +// Contains only platform-agnostic, React-free code: state, factory, +// reducers, and pure utilities. The React hooks and context live in +// @streamplace/components. +export * from "./chat-reducer"; +export * from "./problems"; +export * from "./state"; +export * from "./store"; +export * from "./websocket-consumer"; diff --git a/js/components/src/livestream-store/problems.tsx b/js/core/src/livestream-store/problems.ts similarity index 98% rename from js/components/src/livestream-store/problems.tsx rename to js/core/src/livestream-store/problems.ts index bcce311e..8f608a42 100644 --- a/js/components/src/livestream-store/problems.tsx +++ b/js/core/src/livestream-store/problems.ts @@ -1,5 +1,5 @@ import { PlaceStreamSegment } from "streamplace"; -import { LivestreamProblem } from "./livestream-state"; +import { LivestreamProblem } from "./state"; const VARIANCE_THRESHOLD = 0.5; const DURATION_THRESHOLD = 5000000000; // 5s in ns diff --git a/js/components/src/livestream-store/livestream-state.tsx b/js/core/src/livestream-store/state.ts similarity index 100% rename from js/components/src/livestream-store/livestream-state.tsx rename to js/core/src/livestream-store/state.ts diff --git a/js/core/src/livestream-store/store.ts b/js/core/src/livestream-store/store.ts new file mode 100644 index 00000000..c3644402 --- /dev/null +++ b/js/core/src/livestream-store/store.ts @@ -0,0 +1,39 @@ +// Vanilla Zustand store for the livestream state. No React imports. +// +// The React bindings (`useLivestreamStore`, `useLivestreamStoreOptional`, +// `getStoreFromContext`, etc.) live in @streamplace/components. +import { createStore, StoreApi } from "zustand"; +import { LivestreamState } from "./state"; + +export type LivestreamStore = StoreApi; + +export const makeLivestreamStore = (): StoreApi => { + return createStore()((set) => ({ + profile: null, + chatIndex: {}, + chat: [], + livestream: null, + viewers: null, + pendingHides: [], + segment: null, + renditions: [], + replyToMessage: null, + chatDraft: "", + badgeSlots: null, + streamKey: null, + setStreamKey: (sk) => set({ streamKey: sk }), + authors: {}, + recentSegments: [], + problems: [], + activeTeleport: null, + activeTeleportUri: null, + setActiveTeleportUri: (uri) => set({ activeTeleportUri: uri }), + websocketConnected: false, + hasReceivedSegment: false, + pinnedComment: null, + moderationPermissions: [], + setModerationPermissions: (perms) => set({ moderationPermissions: perms }), + localLivestreamURI: null, + setLocalLivestreamURI: (uri) => set({ localLivestreamURI: uri }), + })); +}; diff --git a/js/components/src/livestream-store/websocket-consumer.tsx b/js/core/src/livestream-store/websocket-consumer.ts similarity index 96% rename from js/components/src/livestream-store/websocket-consumer.tsx rename to js/core/src/livestream-store/websocket-consumer.ts index e6cd02be..4b8ead61 100644 --- a/js/components/src/livestream-store/websocket-consumer.tsx +++ b/js/core/src/livestream-store/websocket-consumer.ts @@ -11,11 +11,11 @@ import { PlaceStreamLiveTeleport, PlaceStreamSegment, } from "streamplace"; +import { formatHandleWithAt } from "../lib/format-handle"; import { SystemMessages } from "../lib/system-messages"; -import { formatHandleWithAt } from "../utils/format-handle"; -import { reduceChat } from "./chat"; -import { LivestreamState } from "./livestream-state"; +import { reduceChat } from "./chat-reducer"; import { findProblems } from "./problems"; +import { LivestreamState } from "./state"; const MAX_RECENT_SEGMENTS = 10; @@ -24,6 +24,7 @@ export const handleWebSocketMessages = ( messages: any[], ): LivestreamState => { for (let message of messages) { + console.log("Received WebSocket message:", message); if (message.$type === "place.stream.error") { // Dedupe by code: the server re-emits the same error on every offending // segment (e.g. a stream that keeps reconnecting over the bitrate limit), diff --git a/js/core/src/vod-store/index.ts b/js/core/src/vod-store/index.ts new file mode 100644 index 00000000..37c81c62 --- /dev/null +++ b/js/core/src/vod-store/index.ts @@ -0,0 +1,3 @@ +// Public surface of the vod-store module in @streamplace/core. +// Re-exports the platform-agnostic VOD interaction hooks. +export * from "./vod-interactions"; diff --git a/js/core/src/vod-store/vod-interactions.ts b/js/core/src/vod-store/vod-interactions.ts new file mode 100644 index 00000000..254f842d --- /dev/null +++ b/js/core/src/vod-store/vod-interactions.ts @@ -0,0 +1,121 @@ +// Pure VOD interaction functions. No React imports. +// +// Each function takes a PDS agent and the user's DID as explicit +// arguments. The React hook wrappers (useCreateVodComment, etc.) live +// in @streamplace/components. +import { + PlaceStreamGetLikes, + PlaceStreamLike, + PlaceStreamVodComment, + PlaceStreamVodDefs, + PlaceStreamVodGetComments, + StreamplaceAgent, +} from "streamplace"; + +export type VodCommentHydrated = PlaceStreamVodDefs.CommentView & { + record: PlaceStreamVodComment.Record; +}; + +export async function createVodComment( + pdsAgent: StreamplaceAgent, + userDID: string, + params: { text: string; video: string }, +) { + const record: PlaceStreamVodComment.Record = { + $type: "place.stream.vod.comment", + text: params.text, + createdAt: new Date().toISOString(), + video: params.video, + }; + + return await pdsAgent.com.atproto.repo.createRecord({ + repo: userDID, + collection: "place.stream.vod.comment", + record, + }); +} + +export async function deleteVodComment( + pdsAgent: StreamplaceAgent, + userDID: string, + uri: string, +) { + const rkey = uri.split("/").pop(); + if (!rkey) throw new Error("No rkey found"); + return await pdsAgent.com.atproto.repo.deleteRecord({ + repo: userDID, + collection: "place.stream.vod.comment", + rkey, + }); +} + +export async function createLike( + pdsAgent: StreamplaceAgent, + userDID: string, + subject: string, +) { + const record: PlaceStreamLike.Record = { + $type: "place.stream.like", + subject, + createdAt: new Date().toISOString(), + }; + + return await pdsAgent.com.atproto.repo.createRecord({ + repo: userDID, + collection: "place.stream.like", + record, + }); +} + +export async function deleteLike( + pdsAgent: StreamplaceAgent, + userDID: string, + uri: string, +) { + const rkey = uri.split("/").pop(); + if (!rkey) throw new Error("No rkey found"); + return await pdsAgent.com.atproto.repo.deleteRecord({ + repo: userDID, + collection: "place.stream.like", + rkey, + }); +} + +export async function getVodComments( + pdsAgent: StreamplaceAgent, + video: string, + limit?: number, + cursor?: string, +): Promise { + const res = await pdsAgent.place.stream.vod.getComments({ + video, + limit, + cursor, + }); + return res.data; +} + +export async function getLikes( + pdsAgent: StreamplaceAgent, + subject: string, + limit?: number, + cursor?: string, +): Promise { + const res = await pdsAgent.place.stream.getLikes({ + subject, + limit, + cursor, + }); + return res.data; +} + +export async function getLikeCount( + pdsAgent: StreamplaceAgent, + subject: string, +): Promise { + const res = await pdsAgent.place.stream.getLikes({ + subject, + limit: 1, + }); + return res.data.count; +} diff --git a/js/core/tsconfig.json b/js/core/tsconfig.json new file mode 100644 index 00000000..70f5fd4d --- /dev/null +++ b/js/core/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../app/tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "jsx": "react-jsx", + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["./src"] +} diff --git a/js/components/i18next.config.js b/js/i18n/i18next.config.js similarity index 93% rename from js/components/i18next.config.js rename to js/i18n/i18next.config.js index 93c56572..2be30bc5 100644 --- a/js/components/i18next.config.js +++ b/js/i18n/i18next.config.js @@ -8,7 +8,7 @@ export default defineConfig({ locales: ["en-US", "es-ES", "fr-FR", "pt-BR", "zh-Hant"], extract: { input: [ - "src/**/*.{js,jsx,ts,tsx}", + "../components/src/**/*.{js,jsx,ts,tsx}", "../app/src/**/*.{js,jsx,ts,tsx}", "../app/components/**/*.{js,jsx,ts,tsx}", ], diff --git a/js/components/locales/en-US/common.ftl b/js/i18n/locales/en-US/common.ftl similarity index 100% rename from js/components/locales/en-US/common.ftl rename to js/i18n/locales/en-US/common.ftl diff --git a/js/components/locales/en-US/settings.ftl b/js/i18n/locales/en-US/settings.ftl similarity index 100% rename from js/components/locales/en-US/settings.ftl rename to js/i18n/locales/en-US/settings.ftl diff --git a/js/components/locales/es-ES/common.ftl b/js/i18n/locales/es-ES/common.ftl similarity index 100% rename from js/components/locales/es-ES/common.ftl rename to js/i18n/locales/es-ES/common.ftl diff --git a/js/components/locales/es-ES/settings.ftl b/js/i18n/locales/es-ES/settings.ftl similarity index 100% rename from js/components/locales/es-ES/settings.ftl rename to js/i18n/locales/es-ES/settings.ftl diff --git a/js/components/locales/fr-FR/common.ftl b/js/i18n/locales/fr-FR/common.ftl similarity index 100% rename from js/components/locales/fr-FR/common.ftl rename to js/i18n/locales/fr-FR/common.ftl diff --git a/js/components/locales/fr-FR/settings.ftl b/js/i18n/locales/fr-FR/settings.ftl similarity index 100% rename from js/components/locales/fr-FR/settings.ftl rename to js/i18n/locales/fr-FR/settings.ftl diff --git a/js/components/locales/manifest.json b/js/i18n/locales/manifest.json similarity index 100% rename from js/components/locales/manifest.json rename to js/i18n/locales/manifest.json diff --git a/js/components/locales/pt-BR/common.ftl b/js/i18n/locales/pt-BR/common.ftl similarity index 100% rename from js/components/locales/pt-BR/common.ftl rename to js/i18n/locales/pt-BR/common.ftl diff --git a/js/components/locales/pt-BR/settings.ftl b/js/i18n/locales/pt-BR/settings.ftl similarity index 100% rename from js/components/locales/pt-BR/settings.ftl rename to js/i18n/locales/pt-BR/settings.ftl diff --git a/js/components/locales/ro-RO/common.ftl b/js/i18n/locales/ro-RO/common.ftl similarity index 100% rename from js/components/locales/ro-RO/common.ftl rename to js/i18n/locales/ro-RO/common.ftl diff --git a/js/components/locales/ro-RO/settings.ftl b/js/i18n/locales/ro-RO/settings.ftl similarity index 100% rename from js/components/locales/ro-RO/settings.ftl rename to js/i18n/locales/ro-RO/settings.ftl diff --git a/js/components/locales/zh-Hans/common.ftl b/js/i18n/locales/zh-Hans/common.ftl similarity index 100% rename from js/components/locales/zh-Hans/common.ftl rename to js/i18n/locales/zh-Hans/common.ftl diff --git a/js/components/locales/zh-Hans/settings.ftl b/js/i18n/locales/zh-Hans/settings.ftl similarity index 100% rename from js/components/locales/zh-Hans/settings.ftl rename to js/i18n/locales/zh-Hans/settings.ftl diff --git a/js/components/locales/zh-Hant/common.ftl b/js/i18n/locales/zh-Hant/common.ftl similarity index 100% rename from js/components/locales/zh-Hant/common.ftl rename to js/i18n/locales/zh-Hant/common.ftl diff --git a/js/components/locales/zh-Hant/settings.ftl b/js/i18n/locales/zh-Hant/settings.ftl similarity index 100% rename from js/components/locales/zh-Hant/settings.ftl rename to js/i18n/locales/zh-Hant/settings.ftl diff --git a/js/i18n/package.json b/js/i18n/package.json new file mode 100644 index 00000000..03342dfa --- /dev/null +++ b/js/i18n/package.json @@ -0,0 +1,26 @@ +{ + "name": "@streamplace/i18n", + "version": "0.0.0", + "private": true, + "description": "Streamplace i18n: locale data, manifest, Fluent sources, compiled translations, and platform-agnostic config.", + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "check": "bash -c 'set -euo pipefail;export OUT=$(mktemp -d); npx tsc -p . --outDir $OUT; rm -rf $OUT'", + "compile": "node scripts/compile-translations.js", + "compile:watch": "nodemon --watch 'locales/**/*.ftl' --exec 'node scripts/compile-translations.js'", + "extract": "i18next-cli extract && node scripts/migrate-i18n.js", + "test": "echo \"no tests yet\" && exit 0" + }, + "dependencies": { + "i18next": "^25.4.2", + "i18next-fluent": "^2.0.0", + "i18next-resources-to-backend": "^1.2.1", + "intl-pluralrules": "^2.0.1" + }, + "devDependencies": { + "@fluent/syntax": "^0.19.0", + "i18next-cli": "^1.32.0", + "nodemon": "^3.1.10" + } +} diff --git a/js/i18n/public/locales/en-US/common.json b/js/i18n/public/locales/en-US/common.json new file mode 100644 index 00000000..411845c8 --- /dev/null +++ b/js/i18n/public/locales/en-US/common.json @@ -0,0 +1,46 @@ +{ + "loading": "Loading...", + "error": "Error", + "cancel": "Cancel", + "confirm": "Confirm", + "close": "Close", + "open": "Open", + "ok": "OK", + "yes": "Yes", + "no": "No", + "continue": "Continue", + "back": "Back", + "next": "Next", + "finish": "Finish", + "save": "Save", + "delete": "Delete", + "edit": "Edit", + "create": "Create", + "update": "Update", + "refresh": "Refresh", + "success": "Success", + "warning": "Warning", + "info": "Information", + "search-placeholder": "Search...", + "message-input": "Enter your message...", + "please-log-in-to-access-this-page": "Please log in to access this page", + "go-to-settings": "Go to Settings", + "go-back": "Go Back", + "welcome-user": "Welcome, {$username}!", + "notification-count": "{$count ->\n [0] No notifications\n [1] One notification\n *[other] {$count} notifications\n }", + "user-offline": "user is offline", + "user-offline-message": "{$source ->\n [streamer] Looks like <1>@{$handle} is offline, but they recommend checking out:\n *[default] Looks like <1>@{$handle} is offline, but we recommend checking out:\n }", + "user-offline-no-recommendations": "Looks like <1>@{$handle} is offline right now.\nCheck back later.", + "streaming-title": "streaming {$title}", + "viewer-count": "{$count ->\n [0] 0 viewers\n [1] 1 viewer\n *[other] {$count} viewers\n }", + "pds-selector-title": "New to the Atmosphere?", + "pds-selector-description": "You'll need to select a PDS (Personal Data Server) to access apps on the Atmosphere, such as Bluesky, Tangled, and Spark.", + "pds-selector-custom-label": "Another PDS", + "pds-selector-custom-description": "Enter your own PDS host URL", + "pds-selector-custom-url-label": "Custom PDS URL", + "pds-selector-custom-url-placeholder": "https://pds.example.com", + "pds-selector-learn-more": "Learn more about self-hosting", + "pds-selector-info": "Each host has their own policies and reliability standards. Your ATProto data lives on the host you choose and you can migrate later. Note: Streamplace has its own moderation rules - you can be banned from Streamplace regardless of which host you choose.", + "pds-selector-read-policies": "Read {$label}'s Terms of Service and Privacy Policy before continuing.", + "pds-selector-handle-policy-checkbox": "I have read and agree to the handle policy" +} diff --git a/js/i18n/public/locales/en-US/settings.json b/js/i18n/public/locales/en-US/settings.json new file mode 100644 index 00000000..67aadc0b --- /dev/null +++ b/js/i18n/public/locales/en-US/settings.json @@ -0,0 +1,241 @@ +{ + "app-version": "Streamplace v{$version}", + "download-new-update": "Download New Update", + "check-for-updates": "Check for Updates", + "bundled-runtype": "Bundled", + "ota-runtype": "Over-the-Air (OTA)", + "recovery-runtype": "Recovery Mode", + "modal-latest-version": "You are using the latest version.", + "modal-no-update-available": "You are on the latest version of Streamplace, hooray!", + "modal-update-available-title": "Update Available", + "modal-update-available-description": "A new version of Streamplace is ready to download", + "modal-update-failed": "Update check failed. You may need to update the app through the {$store}.", + "modal-update-failed-title": "Update Failed", + "modal-update-failed-description": "Update check failed. You may need to update the app through the {$store}.", + "button-reload-app-on-update": "Apply Update (will reload app)", + "use-custom-node": "Use Custom Node", + "default-url": "Default: {$url}", + "enter-custom-node-url": "Enter custom node URL", + "save-button": "Save", + "language-selection": "Language", + "language-selection-description": "Choose your preferred language", + "input-search-languages": "Search languages...", + "help-translate": "Help us translate Streamplace", + "help-translate-description": "We're looking for volunteers to help translate Streamplace into more languages. If you're interested, please reach out to us on Discord or GitHub!", + "currently-translating": "Translations are on the way", + "currently-translating-description": "Some parts of the app may look incomplete. Thank you for your patience!", + "debug-recording-title": "Allow {$host} to record your livestream for debugging and improving the service", + "debug-recording-description": "Optional", + "manage-keys": "Manage Keys", + "settings-title": "Settings", + "error": "Error", + "about": "About", + "account": "Account", + "advanced": "Advanced", + "danmu": "Danmu", + "developer": "Developer", + "languages": "Languages", + "privacy-security": "Privacy & Security", + "streaming": "Streaming", + "cancel": "Cancel", + "create": "Create", + "delete": "Delete", + "refresh": "Refresh", + "sign-in": "Sign In", + "update": "Update", + "log-out": "Log out", + "optional": "optional", + "account-greeting": "Hey, @{$handle}.", + "edit-profile-bluesky": "Edit profile (on Bluesky)", + "change-name-color": "Change name color", + "key-management": "Key Management", + "key-manager": "Key Manager", + "your-stream-pubkeys": "Your Stream Public Keys", + "no-keys": "No keys configured", + "pubkey-description": "Public keys are paired with stream keys (used in streaming software) to sign and verify your stream", + "keys-count": "{$count ->\n [one] {$count} key\n *[other] {$count} keys\n }", + "backup": "Backup", + "backup-enabled": "S3 Backup", + "backup-enabled-description": "Automatically backup recordings to S3-compatible storage", + "backup-connection-url": "Connection URL", + "backup-connection-url-placeholder": "Must be a valid S3 URL in the format s3+https://ACCESS_KEY:SECRET_KEY@region.endpoi.nt/bucket", + "backup-endpoint": "Endpoint", + "backup-endpoint-placeholder": "s3.example.com", + "backup-bucket": "Bucket", + "backup-bucket-placeholder": "my-backup-bucket", + "backup-access-key": "Access Key", + "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", + "backup-secret-key": "Secret Key", + "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "backup-region": "Region", + "backup-region-placeholder": "us-east-1", + "backup-test-connection": "Test Connection", + "backup-testing-connection": "Testing connection...", + "backup-connection-successful": "Connection successful", + "backup-connection-failed": "Failed to connect", + "show-password-in-url": "Show password in URL", + "show-password-in-url-description": "Display the secret key in the connection URL (if input)", + "requested-seconds-per-segment": "Seconds per segment", + "requested-seconds-per-segment-description": "Set the seconds per segment you wish to request the server use", + "recommendations": "Recommendations", + "manage-recommendations": "Manage Recommendations", + "recommendations-to-others": "Recommendations to Others", + "recommendations-description": "Share up to 8 streamers you recommend to your viewers", + "no-recommendations-yet": "No recommendations configured yet", + "add-recommendation": "Add Recommendation", + "streamer-did": "Streamer DID", + "recommendations-count": "{$count ->\n [one] {$count} recommendation\n *[other] {$count} recommendations\n }", + "webhooks": "Webhooks", + "webhook-integrations": "Webhook Integrations", + "webhook-integrations-description": "Connect external services to receive real-time updates about your streams", + "create-webhook": "Create Webhook", + "edit-webhook": "Edit Webhook", + "delete-webhook": "Delete Webhook", + "no-webhooks-yet": "No webhooks configured yet", + "failed-load-webhooks": "Failed to load webhooks", + "webhook-will-no-longer-receive-events": "This webhook will no longer receive events", + "create-first-webhook-description": "Create your first webhook to start receiving stream events", + "example-captain-hook": "Captain Hook", + "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} webhooks\n }", + "activates-on": "Activates on:", + "events": "Events", + "events-livestream": "Livestream Events", + "events-chat": "Chat Events", + "untitled-webhook": "Untitled Webhook", + "inactive": "Inactive", + "active": "Active", + "multistream": "Multistreaming", + "multistream-targets": "Multistream Targets", + "multistream-description": "Automatically push your Streamplace livestreams to other streaming services like Twitch or YouTube.", + "create-multistream-target": "Create Multistream Target", + "untitled-multistream-target": "Untitled Target", + "failed-load-multistream-targets": "Failed to load multistream targets. Please try again.", + "failed-toggle-multistream-target": "Failed to toggle multistream target. Please try again.", + "failed-delete-multistream-target": "Failed to delete multistream target. Please try again.", + "no-multistream-targets-yet": "No targets yet!", + "multistream-targets-count": "{$count ->\n [one] {$count} target\n *[other] {$count} targets\n }", + "multistream-delete-target-confirmation": "Are you sure you want to delete \"{$target}\"?", + "this-action-cannot-be-undone": "This action cannot be undone.", + "rtmp-target-name": "RTMP Target", + "rtmp-target-url": "RTMP URL", + "rtmp-target-name-placeholder": "My Multistream Target", + "multistream-create-target": "Create Target", + "multistream-edit-target": "Edit Target", + "created": "created", + "status": "status", + "debug-recording": "Debug Recording", + "danmu-enabled": "Enable Danmu", + "danmu-enabled-description": "Display live chat messages as floating comments on your screen", + "danmu-opacity": "Opacity", + "danmu-speed": "Speed", + "danmu-lane-count": "Lane Count", + "danmu-max-messages": "Max Messages", + "app-version-description": "No updates are available at this time", + "confirm-delete": "Are you sure you want to delete this?", + "action-cannot-be-undone": "This action cannot be undone", + "name-optional": "Name (optional)", + "deleting": "Deleting...", + "saving": "Saving...", + "go-to-dashboard": "Go to Dashboard", + "need-setup-live-dashboard": "Need to set up streaming first? Visit the live dashboard", + "no-languages-found": "No languages found", + "branding": "Branding", + "branding-admin": "Branding Administration", + "branding-admin-description": "Customize your Streamplace instance. Note that settings may take a few hours to propagate.", + "branding-login-required": "Please log in to manage branding", + "branding-configuration": "Configuration", + "branding-text-settings": "Text Settings", + "branding-colors": "Colors", + "branding-legal-links": "Legal Links", + "branding-images": "Images", + "branding-broadcaster-did": "Broadcaster DID", + "branding-broadcaster-did-description": "Leave empty to use server default", + "branding-site-title": "Site Title", + "branding-site-title-placeholder": "Enter new site title", + "branding-site-description": "Site Description", + "branding-site-description-placeholder": "Enter site description", + "branding-default-streamer": "Default Streamer", + "branding-default-streamer-none": "None", + "branding-default-streamer-placeholder": "did:plc:...", + "branding-clear-default-streamer": "Clear Default Streamer", + "branding-primary-color": "Primary Color", + "branding-primary-color-placeholder": "#6366f1", + "branding-accent-color": "Accent Color", + "branding-accent-color-placeholder": "#8b5cf6", + "branding-main-logo": "Main Logo", + "branding-main-logo-description": "SVG, PNG, or JPEG (max 500KB)", + "branding-favicon": "Favicon", + "branding-favicon-description": "SVG, PNG, or ICO (max 100KB)", + "branding-sidebar-bg": "Sidebar Background Image", + "branding-sidebar-bg-description": "SVG, PNG, or JPEG (max 500kb) - appears aligned to bottom of sidebar, full width. Upload an image with opacity for best results, as there is not currently a separate opacity option.", + "branding-current": "Current: {$value}", + "branding-dimensions": "{$height} x {$width}", + "branding-upload-logo": "Upload Logo", + "branding-delete-logo": "Delete Logo", + "branding-upload-favicon": "Upload Favicon", + "branding-delete-favicon": "Delete Favicon", + "branding-upload-background": "Upload Background", + "branding-delete-background": "Delete Background", + "branding-web-only": "Image uploads are only available on web.", + "refresh-branding": "Refresh branding assets", + "branding-add-legal-link": "Add Legal Link", + "branding-edit-legal-link": "Edit Legal Link", + "branding-legal-link-text-placeholder": "Link text (e.g., Privacy Policy)", + "branding-legal-link-url-placeholder": "URL (e.g., https://example.com/privacy)", + "add": "Add", + "edit": "Edit", + "branding-not-authenticated": "Please log in first", + "branding-empty-value": "Please enter a value", + "branding-update-success": "{$key} updated successfully", + "branding-upload-success": "{$key} uploaded successfully", + "branding-delete-success": "{$key} deleted successfully", + "branding-upload-failed": "Failed to upload", + "branding-delete-failed": "Failed to delete", + "branding-not-available": "File uploads are only available on web", + "node-legal-documents": "Broadcaster-specific Documents", + "backup-save": "Save backup settings", + "backup-saving": "Saving backup settings...", + "backup-secret-key-set-placeholder": "(Password already set)", + "backup-error-invalid-endpoint": "Must be a valid domain name", + "backup-error-invalid-bucket": "Must contain only lowercase letters, numbers, dots, and hyphens", + "backup-error-invalid-segment-duration": "Must be between 1 and 60 seconds", + "backup-error-load-failed": "Failed to load storage settings", + "backup-error-update-failed": "Failed to update backup status", + "backup-error-save-failed": "Failed to save storage settings", + "backup-error-missing-secret": "Cannot update S3 settings without the secret key. Please re-enter it.", + "backup-segment-duration-placeholder": "6", + "badges": "Badges", + "badges-cosmetic-section": "Cosmetic Badges", + "badges-empty-state": "You haven't been issued any badges yet.", + "badges-failed-load": "Failed to load badges", + "badges-failed-update": "Failed to update badge selection", + "badges-issued-by": "Issued by {$issuer}", + "badges-streamer-section": "Streamer Badges", + "issue-badges": "Issue Badges", + "issue-badges-back-to-definitions": "Badge Definitions", + "issue-badges-badge-name": "Badge Name", + "issue-badges-badge-name-placeholder": "e.g. VIP, Supporter", + "issue-badges-badge-type": "Badge Type", + "issue-badges-choose-image": "Choose Image", + "issue-badges-create-definition": "Create Badge Definition", + "issue-badges-create-definition-description": "Define a new badge type that can be issued to viewers", + "issue-badges-create-definition-subtitle": "Define a new badge type", + "issue-badges-definition-created": "Badge definition created", + "issue-badges-description-optional": "Description (optional)", + "issue-badges-description-placeholder": "Optional description for this badge", + "issue-badges-failed-create": "Failed to create badge definition", + "issue-badges-failed-issue": "Failed to issue badge", + "issue-badges-failed-load": "Failed to load badge definitions", + "issue-badges-image-optional": "Image (optional)", + "issue-badges-image-too-large": "Image must be under 256KB", + "issue-badges-image-web-only": "Image upload is only available on web", + "issue-badges-issue-badge": "Issue Badge", + "issue-badges-issue-badge-description": "Issue the \"{$name}\" badge to a viewer by their DID", + "issue-badges-issued": "Badge issued", + "issue-badges-issued-to": "Issued to {$did}", + "issue-badges-manage-description": "Create badge definitions and issue them to viewers", + "issue-badges-recipient-did": "Recipient DID", + "issue-badges-recipient-did-placeholder": "did:plc:...", + "issue-badges-tap-to-issue": "Tap to issue", + "issue-badges-your-definitions": "Your Badge Definitions" +} diff --git a/js/i18n/public/locales/es-ES/common.json b/js/i18n/public/locales/es-ES/common.json new file mode 100644 index 00000000..153dc7a0 --- /dev/null +++ b/js/i18n/public/locales/es-ES/common.json @@ -0,0 +1,46 @@ +{ + "loading": "Cargando...", + "error": "Error", + "cancel": "Cancelar", + "confirm": "Confirmar", + "close": "Cerrar", + "open": "Abrir", + "ok": "OK", + "yes": "Sí", + "no": "No", + "continue": "Continuar", + "back": "Atrás", + "next": "Siguiente", + "finish": "Finalizar", + "save": "Guardar", + "delete": "Eliminar", + "edit": "Editar", + "create": "Crear", + "update": "Actualizar", + "refresh": "Actualizar", + "success": "Éxito", + "warning": "Advertencia", + "info": "Información", + "search-placeholder": "Buscar...", + "message-input": "Escribe tu mensaje...", + "please-log-in-to-access-this-page": "Por favor, inicia sesión para acceder a esta página", + "go-to-settings": "Ir a Configuración", + "go-back": "Volver", + "welcome-user": "¡Bienvenido, {$username}!", + "notification-count": "{$count ->\n [0] Sin notificaciones\n [1] Una notificación\n *[other] {$count} notificaciones\n }", + "user-offline": "usuario desconectado", + "user-offline-message": "{$source ->\n [streamer] Parece que <1>@{$handle} está desconectado, pero ellos recomiendan ver:\n *[default] Parece que <1>@{$handle} está desconectado, pero te recomendamos ver:\n }", + "user-offline-no-recommendations": "Parece que <1>@{$handle} está desconectado ahora mismo.\nVuelve más tarde.", + "streaming-title": "transmitiendo {$title}", + "viewer-count": "{$count ->\n [0] 0 espectadores\n [1] 1 espectador\n *[other] {$count} espectadores\n }", + "pds-selector-title": "¿Nuevo en Atmosphere?", + "pds-selector-description": "Necesitarás seleccionar un PDS (Servidor de Datos Personal) para acceder a apps en Atmosphere, como Bluesky, Tangled y Spark.", + "pds-selector-custom-label": "Otro PDS", + "pds-selector-custom-description": "Introduce la URL de tu propio servidor PDS", + "pds-selector-custom-url-label": "URL de PDS personalizado", + "pds-selector-custom-url-placeholder": "https://pds.ejemplo.com", + "pds-selector-learn-more": "Más información sobre el autoalojamiento", + "pds-selector-info": "Cada servidor tiene sus propias políticas y estándares de fiabilidad. Tus datos ATProto viven en el servidor que elijas y puedes migrar más tarde. Nota: Streamplace tiene sus propias reglas de moderación; puedes ser expulsado de Streamplace independientemente del servidor que elijas.", + "pds-selector-read-policies": "Lee los Términos de Servicio y la Política de Privacidad de {$label} antes de continuar.", + "pds-selector-handle-policy-checkbox": "He leído y acepto la política de identificadores" +} diff --git a/js/i18n/public/locales/es-ES/settings.json b/js/i18n/public/locales/es-ES/settings.json new file mode 100644 index 00000000..128c6029 --- /dev/null +++ b/js/i18n/public/locales/es-ES/settings.json @@ -0,0 +1,246 @@ +{ + "app-version": "Streamplace v{$version}", + "download-new-update": "Descargar Nueva Actualización", + "check-for-updates": "Buscar Actualizaciones", + "bundled-runtype": "Empaquetado", + "ota-runtype": "Over-the-Air (OTA)", + "recovery-runtype": "Modo de Recuperación", + "modal-latest-version": "Estás usando la última versión.", + "modal-no-update-available": "¡Tienes la versión más reciente de Streamplace, genial!", + "modal-update-available-title": "Actualización Disponible", + "modal-update-available-description": "Una nueva versión de Streamplace está lista para descargar", + "modal-update-failed": "La búsqueda de actualizaciones falló. Es posible que necesites actualizar\nla aplicación a través de {$store}.", + "modal-update-failed-title": "Actualización Falló", + "modal-update-failed-description": "La búsqueda de actualizaciones falló. Es posible que necesites actualizar\nla aplicación a través de {$store}.", + "button-reload-app-on-update": "Aplicar Actualización (recargará la aplicación)", + "use-custom-node": "Usar Nodo Personalizado", + "default-url": "Predeterminado: {$url}", + "enter-custom-node-url": "Introduce la URL del nodo personalizado", + "save-button": "Guardar", + "language-selection": "Idioma", + "language-selection-description": "Elige tu idioma preferido", + "help-translate": "Ayúdanos a traducir Streamplace", + "help-translate-description": "Buscamos voluntarios para ayudar a traducir Streamplace a más idiomas. Si te interesa, contáctanos en Discord o GitHub!", + "currently-translating": "Las traducciones están en camino", + "currently-translating-description": "Algunas partes de la aplicación pueden verse incompletas. ¡Gracias por tu paciencia!", + "debug-recording-title": "Permitir que {$host} grabe tu retransmisión en directo para depuración y mejora del servicio", + "debug-recording-description": "Opcional", + "input-search-languages": "Buscar idiomas...", + "manage-keys": "Gestionar Claves", + "settings-title": "Configuración", + "loading": "Cargando...", + "error": "Error", + "cancel": "Cancelar", + "confirm": "Confirmar", + "welcome-user": "¡Bienvenido, {$username}!", + "notification-count": "{$count ->\n [0] Sin notificaciones\n [1] Una notificación\n *[other] {$count} notificaciones\n }", + "search-placeholder": "Buscar...", + "message-input": "Introduce tu mensaje...", + "success": "Éxito", + "warning": "Aviso", + "info": "Información", + "close": "Cerrar", + "open": "Abrir", + "delete": "Eliminar", + "edit": "Editar", + "create": "Crear", + "update": "Actualizar", + "refresh": "Actualizar", + "save": "Guardar", + "cancel-button": "Cancelar", + "ok": "Aceptar", + "yes": "Sí", + "no": "No", + "continue": "Continuar", + "back": "Volver", + "next": "Siguiente", + "finish": "Finalizar", + "about": "Acerca de", + "account": "Cuenta", + "advanced": "Avanzado", + "danmu": "Danmu", + "developer": "Desarrollador", + "languages": "Idiomas", + "privacy-security": "Privacidad y Seguridad", + "streaming": "Transmisión", + "sign-in": "Iniciar Sesión", + "log-out": "Cerrar sesión", + "account-greeting": "Hola, @{$handle}.", + "edit-profile-bluesky": "Editar perfil (en Bluesky)", + "change-name-color": "Cambiar color de nombre", + "key-management": "Gestión de Claves", + "key-manager": "Gestor de Claves", + "your-stream-pubkeys": "Tus Claves Públicas de Transmisión", + "no-keys": "No hay claves configuradas", + "pubkey-description": "Las claves públicas se emparejan con claves de transmisión (usadas en software de transmitiendo) para firmar y verificar tu transmisión", + "keys-count": "{$count ->\n [one] {$count} clave\n [many] {$count} claves\n *[other] {$count} claves\n }", + "webhooks": "Webhooks", + "webhook-integrations": "Integraciones de Webhook", + "webhook-integrations-description": "Conecta servicios externos para recibir actualizaciones en tiempo real sobre tus transmisiones", + "create-webhook": "Crear Webhook", + "edit-webhook": "Editar Webhook", + "delete-webhook": "Eliminar Webhook", + "no-webhooks-yet": "Aún no hay webhooks configurados", + "failed-load-webhooks": "Error al cargar webhooks", + "webhook-will-no-longer-receive-events": "Este webhook ya no recibirá eventos", + "create-first-webhook-description": "Crea tu primer webhook para empezar a recibir eventos de transmisión", + "example-captain-hook": "Capitán Garfio", + "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} webhooks\n }", + "activates-on": "Activa en:", + "events-livestream": "Eventos de Transmisión en Directo", + "events-chat": "Eventos de Chat", + "untitled-webhook": "Webhook Sin Título", + "inactive": "Inactivo", + "debug-recording": "Grabación de Depuración", + "danmu-enabled": "Habilitar Danmu", + "danmu-enabled-description": "Muestra mensajes de chat en vivo como comentarios flotantes en tu pantalla", + "danmu-opacity": "Opacidad", + "danmu-speed": "Velocidad", + "danmu-lane-count": "Número de Carriles", + "danmu-max-messages": "Mensajes Máximos", + "app-version-description": "Información de la versión actual", + "confirm-delete": "¿Estás seguro de que quieres eliminar esto?", + "action-cannot-be-undone": "Esta acción no se puede deshacer", + "name-optional": "Nombre (opcional)", + "deleting": "Eliminando...", + "saving": "Guardando...", + "go-to-dashboard": "Ir al Panel", + "need-setup-live-dashboard": "Primero necesitas configurar una transmisión en directo en el panel", + "no-languages-found": "No se encontraron idiomas", + "backup-save": "Guardar ajustes de copia de seguridad", + "backup-saving": "Guardando ajustes de copia de seguridad...", + "backup-secret-key-set-placeholder": "(Contraseña ya establecida)", + "backup-error-invalid-endpoint": "Debe ser un nombre de dominio válido", + "backup-error-invalid-bucket": "Debe contener solo letras minúsculas, números, puntos y guiones", + "backup-error-invalid-segment-duration": "Debe estar entre 1 y 60 segundos", + "backup-error-load-failed": "Error al cargar los ajustes de almacenamiento", + "backup-error-update-failed": "Error al actualizar el estado de la copia de seguridad", + "backup-error-save-failed": "Error al guardar los ajustes de almacenamiento", + "backup-error-missing-secret": "No se pueden actualizar los ajustes de S3 sin la clave secreta. Por favor, vuelva a introducirla.", + "backup-segment-duration-placeholder": "6", + "backup": "Copia de seguridad", + "backup-enabled": "S3 Backup", + "backup-enabled-description": "Respaldar automáticamente las grabaciones en almacenamiento compatible con S3", + "backup-connection-url": "URL de conexión", + "backup-connection-url-placeholder": "s3+https://ACCESS_KEY:SECRET_KEY@s3.example.com/bucket", + "backup-endpoint": "Punto de conexión", + "backup-endpoint-placeholder": "s3.example.com", + "backup-bucket": "Contenedor", + "backup-bucket-placeholder": "my-backup-bucket", + "backup-access-key": "Clave de acceso", + "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", + "backup-secret-key": "Clave secreta", + "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "show-password-in-url": "Mostrar contraseña en la URL", + "show-password-in-url-description": "Mostrar la clave secreta en la URL de conexión (si se introduce)", + "recommendations-to-others": "Recomendaciones a otros", + "recommendations-description": "Comparte hasta 8 streamers que recomiendas a tus espectadores", + "no-recommendations-yet": "No hay recomendaciones configuradas todavía", + "multistream": "Multi-transmisión", + "multistream-targets": "Objetivos de multi-transmisión", + "multistream-description": "Envía automáticamente tus transmisiones en directo de Streamplace a otros servicios de streaming como Twitch o YouTube.", + "create-multistream-target": "Crear objetivo de multi-transmisión", + "untitled-multistream-target": "Objetivo sin título", + "failed-load-multistream-targets": "Error al cargar los objetivos de multi-transmisión. Por favor, inténtalo de nuevo.", + "failed-toggle-multistream-target": "Error al cambiar el estado del objetivo de multi-transmisión. Por favor, inténtalo de nuevo.", + "no-multistream-targets-yet": "¡Aún no hay objetivos!", + "multistream-targets-count": "{$count ->\n [one] {$count} objetivo\n [many] {$count} objetivos\n *[other] {$count} objetivos\n }", + "multistream-delete-target-confirmation": "¿Estás seguro de que quieres eliminar \"{$target}\"?", + "this-action-cannot-be-undone": "Esta acción no se puede deshacer.", + "rtmp-target-name": "Objetivo RTMP", + "rtmp-target-url": "URL de RTMP", + "rtmp-target-name-placeholder": "Mi objetivo de multi-transmisión", + "multistream-create-target": "Crear objetivo", + "multistream-edit-target": "Editar objetivo", + "created": "creado", + "status": "estado", + "branding": "Branding", + "branding-admin": "Administración de branding", + "branding-admin-description": "Personaliza tu instancia de Streamplace. Ten en cuenta que la configuración puede tardar varias horas en propagarse.", + "branding-login-required": "Por favor, inicia sesión para gestionar el branding", + "branding-configuration": "Configuración", + "branding-text-settings": "Configuración de texto", + "branding-colors": "Colores", + "branding-legal-links": "Vínculos legales", + "branding-images": "Imágenes", + "branding-broadcaster-did": "DID del radiodifusor", + "branding-broadcaster-did-description": "Dejar vacío para usar el valor predeterminado del servidor", + "branding-site-title": "Título del sitio", + "branding-site-title-placeholder": "Introduce un nuevo título de sitio", + "branding-site-description": "Descripción del sitio", + "branding-site-description-placeholder": "Introduce la descripción del sitio", + "branding-default-streamer": "Streamer predeterminado", + "branding-default-streamer-none": "Ninguno", + "branding-default-streamer-placeholder": "did:plc:...", + "branding-clear-default-streamer": "Quitar streamer predeterminado", + "branding-primary-color": "Color principal", + "branding-primary-color-placeholder": "#6366f1", + "branding-accent-color": "Color de acento", + "branding-accent-color-placeholder": "#8b5cf6", + "branding-main-logo": "Logo principal", + "branding-main-logo-description": "SVG, PNG o JPEG (máx. 500KB)", + "branding-favicon": "Favicon", + "branding-favicon-description": "SVG, PNG o ICO (máx. 100KB)", + "branding-sidebar-bg": "Imagen de fondo de la barra lateral", + "branding-sidebar-bg-description": "SVG, PNG o JPEG (máx. 500kb) - aparece alineada en la parte inferior de la barra lateral, a todo el ancho. Sube una imagen con opacidad para mejores resultados, ya que actualmente no hay una opción de opacidad separada.", + "branding-current": "Actual: {$value}", + "branding-dimensions": "{$height} x {$width}", + "branding-upload-logo": "Subir logo", + "branding-delete-logo": "Eliminar logo", + "branding-upload-favicon": "Subir favicon", + "branding-delete-favicon": "Eliminar favicon", + "branding-upload-background": "Subir imagen de fondo", + "branding-delete-background": "Eliminar imagen de fondo", + "branding-web-only": "La subida de imágenes solo está disponible en la web.", + "refresh-branding": "Actualizar recursos de branding", + "branding-add-legal-link": "Agregar vínculo legal", + "branding-edit-legal-link": "Editar vínculo legal", + "branding-legal-link-text-placeholder": "Texto del vínculo (ej., Política de Privacidad)", + "branding-legal-link-url-placeholder": "URL (ej., https://ejemplo.com/privacidad)", + "add": "Agregar", + "active": "Activo", + "optional": "opcional", + "branding-not-authenticated": "Por favor, inicia sesión primero", + "branding-empty-value": "Por favor, introduce un valor", + "branding-update-success": "{$key} actualizado correctamente", + "branding-upload-success": "{$key} subido correctamente", + "branding-delete-success": "{$key} eliminado correctamente", + "branding-upload-failed": "Error al subir", + "branding-delete-failed": "Error al eliminar", + "branding-not-available": "La subida de archivos solo está disponible en la web", + "node-legal-documents": "Documentos específicos del radiodifusor", + "badges": "Insignias", + "badges-cosmetic-section": "Insignias cosméticas", + "badges-empty-state": "Aún no te han otorgado ninguna insignia.", + "badges-failed-load": "Error al cargar las insignias", + "badges-failed-update": "Error al actualizar la selección de insignias", + "badges-issued-by": "Otorgada por {$issuer}", + "badges-streamer-section": "Insignias del streamer", + "issue-badges": "Emitir insignias", + "issue-badges-back-to-definitions": "Definiciones de insignias", + "issue-badges-badge-name": "Nombre de la insignia", + "issue-badges-badge-name-placeholder": "ej. VIP, Colaborador", + "issue-badges-badge-type": "Tipo de insignia", + "issue-badges-choose-image": "Elegir imagen", + "issue-badges-create-definition": "Crear definición de insignia", + "issue-badges-create-definition-description": "Define un nuevo tipo de insignia que se puede otorgar a los espectadores", + "issue-badges-create-definition-subtitle": "Define un nuevo tipo de insignia", + "issue-badges-definition-created": "Definición de insignia creada", + "issue-badges-description-optional": "Descripción (opcional)", + "issue-badges-description-placeholder": "Descripción opcional para esta insignia", + "issue-badges-failed-create": "Error al crear la definición de insignia", + "issue-badges-failed-issue": "Error al emitir la insignia", + "issue-badges-failed-load": "Error al cargar las definiciones de insignias", + "issue-badges-image-optional": "Imagen (opcional)", + "issue-badges-image-too-large": "La imagen debe ser menor de 256KB", + "issue-badges-image-web-only": "La subida de imágenes solo está disponible en la web", + "issue-badges-issue-badge": "Emitir insignia", + "issue-badges-issue-badge-description": "Emite la insignia \"{$name}\" a un espectador mediante su DID", + "issue-badges-issued": "Insignia emitida", + "issue-badges-issued-to": "Emitida a {$did}", + "issue-badges-manage-description": "Crea definiciones de insignias y emítelas a los espectadores", + "issue-badges-recipient-did": "DID del destinatario", + "issue-badges-recipient-did-placeholder": "did:plc:...", + "issue-badges-tap-to-issue": "Toca para emitir", + "issue-badges-your-definitions": "Tus definiciones de insignias" +} diff --git a/js/i18n/public/locales/fr-FR/common.json b/js/i18n/public/locales/fr-FR/common.json new file mode 100644 index 00000000..339d534c --- /dev/null +++ b/js/i18n/public/locales/fr-FR/common.json @@ -0,0 +1,46 @@ +{ + "loading": "Chargement...", + "error": "Erreur", + "cancel": "Annuler", + "confirm": "Confirmer", + "close": "Fermer", + "open": "Ouvrir", + "ok": "OK", + "yes": "Oui", + "no": "Non", + "continue": "Continuer", + "back": "Retour", + "next": "Suivant", + "finish": "Terminer", + "save": "Enregistrer", + "delete": "Supprimer", + "edit": "Modifier", + "create": "Créer", + "update": "Mettre à jour", + "refresh": "Actualiser", + "success": "Succès", + "warning": "Avertissement", + "info": "Information", + "search-placeholder": "Rechercher...", + "message-input": "Entrez votre message...", + "please-log-in-to-access-this-page": "Veuillez vous connecter pour accéder à cette page", + "go-to-settings": "Aller aux Paramètres", + "go-back": "Retour", + "welcome-user": "Bienvenue, {$username} !", + "notification-count": "{$count ->\n [0] Aucune notification\n [1] Une notification\n *[other] {$count} notifications\n }", + "user-offline": "utilisateur hors ligne", + "user-offline-message": "{$source ->\n [streamer] On dirait que <1>@{$handle} est hors ligne, mais ils recommandent de regarder :\n *[default] On dirait que <1>@{$handle} est hors ligne, mais nous recommandons de regarder :\n }", + "user-offline-no-recommendations": "On dirait que <1>@{$handle} est hors ligne maintenant.\nRevenez plus tard.", + "streaming-title": "diffusion de {$title}", + "viewer-count": "{$count ->\n [0] 0 spectateurs\n [1] 1 spectateur\n *[other] {$count} spectateurs\n }", + "pds-selector-title": "Nouveau sur Atmosphere ?", + "pds-selector-description": "Vous devrez sélectionner un PDS (Serveur de Données Personnel) pour accéder aux applications sur Atmosphere, comme Bluesky, Tangled et Spark.", + "pds-selector-custom-label": "Un autre PDS", + "pds-selector-custom-description": "Saisissez l'URL de votre propre hôte PDS", + "pds-selector-custom-url-label": "URL PDS personnalisée", + "pds-selector-custom-url-placeholder": "https://pds.exemple.com", + "pds-selector-learn-more": "En savoir plus sur l'auto-hébergement", + "pds-selector-info": "Chaque hôte a ses propres politiques et normes de fiabilité. Vos données ATProto se trouvent sur l'hôte que vous choisissez et vous pouvez migrer ultérieurement. Remarque : Streamplace a ses propres règles de modération — vous pouvez être banni de Streamplace quel que soit l'hôte choisi.", + "pds-selector-read-policies": "Lisez les Conditions d'utilisation et la Politique de confidentialité de {$label} avant de continuer.", + "pds-selector-handle-policy-checkbox": "J'ai lu et j'accepte la politique des identifiants" +} diff --git a/js/i18n/public/locales/fr-FR/settings.json b/js/i18n/public/locales/fr-FR/settings.json new file mode 100644 index 00000000..693dae2b --- /dev/null +++ b/js/i18n/public/locales/fr-FR/settings.json @@ -0,0 +1,246 @@ +{ + "app-version": "Streamplace v{$version}", + "download-new-update": "Télécharger la nouvelle mise à jour", + "check-for-updates": "Vérifier les mises à jour", + "bundled-runtype": "Inclus", + "ota-runtype": "Over-the-Air (OTA)", + "recovery-runtype": "Mode de récupération", + "modal-latest-version": "Vous utilisez la dernière version.", + "modal-no-update-available": "Vous avez la dernière version de Streamplace, génial !", + "modal-update-available-title": "Mise à jour disponible", + "modal-update-available-description": "Une nouvelle version de Streamplace est prête à télécharger", + "modal-update-failed": "La vérification des mises à jour a échoué. Vous devrez peut-être mettre à jour l'application via {$store}.", + "modal-update-failed-title": "Échec de la mise à jour", + "modal-update-failed-description": "La vérification des mises à jour a échoué. Vous devrez peut-être mettre à jour l'application via {$store}.", + "button-reload-app-on-update": "Appliquer la mise à jour (l'application va se recharger)", + "use-custom-node": "Utiliser un nœud personnalisé", + "default-url": "Par défaut : {$url}", + "enter-custom-node-url": "Saisir l'URL du nœud personnalisé", + "save-button": "Enregistrer", + "language-selection": "Langue", + "language-selection-description": "Choisissez votre langue préférée", + "help-translate": "Aidez-nous à traduire Streamplace", + "help-translate-description": "Nous recherchons des bénévoles pour aider à traduire Streamplace dans plus de langues. Si vous êtes intéressé, contactez-nous sur Discord ou GitHub !", + "currently-translating": "Les traductions sont en cours", + "currently-translating-description": "Certaines parties de l'application peuvent sembler incomplètes. Merci pour votre patience !", + "debug-recording-title": "Autoriser {$host} à enregistrer votre diffusion en direct pour le débogage et l'amélioration du service", + "debug-recording-description": "Optionnel", + "input-search-languages": "Rechercher des langues...", + "manage-keys": "Gérer les Clés", + "settings-title": "Paramètres", + "loading": "Chargement...", + "error": "Erreur", + "cancel": "Annuler", + "confirm": "Confirmer", + "welcome-user": "Bienvenue, {$username} !", + "notification-count": "{$count ->\n [0] Aucune notification\n [1] Une notification\n *[other] {$count} notifications\n }", + "search-placeholder": "Rechercher...", + "message-input": "Saisissez votre message...", + "success": "Succès", + "warning": "Attention", + "info": "Information", + "close": "Fermer", + "open": "Ouvrir", + "delete": "Supprimer", + "edit": "Modifier", + "create": "Créer", + "update": "Mettre à jour", + "refresh": "Actualiser", + "save": "Enregistrer", + "cancel-button": "Annuler", + "ok": "OK", + "yes": "Oui", + "no": "Non", + "continue": "Continuer", + "back": "Retour", + "next": "Suivant", + "finish": "Terminer", + "about": "À propos", + "account": "Compte", + "advanced": "Avancé", + "danmu": "Danmu", + "developer": "Développeur", + "languages": "Langues", + "privacy-security": "Confidentialité et Sécurité", + "streaming": "Diffusion", + "sign-in": "Se connecter", + "log-out": "Se déconnecter", + "account-greeting": "Salut, @{$handle}.", + "edit-profile-bluesky": "Modifier le profil (sur Bluesky)", + "change-name-color": "Changer la couleur du nom", + "key-management": "Gestion des Clés", + "key-manager": "Gestionnaire de Clés", + "your-stream-pubkeys": "Vos Clés Publiques de Diffusion", + "no-keys": "Aucune clé configurée", + "pubkey-description": "Les clés publiques sont associées aux clés de diffusion (utilisées dans le logiciel de streaming) pour signer et vérifier votre diffusion", + "keys-count": "{$count ->\n [one] {$count} clé\n [many] {$count} clés\n *[other] {$count} clés\n }", + "webhooks": "Webhooks", + "webhook-integrations": "Intégrations de Webhook", + "webhook-integrations-description": "Connectez des services externes pour recevoir des mises à jour en temps réel sur vos diffusions", + "create-webhook": "Créer un Webhook", + "edit-webhook": "Modifier le Webhook", + "delete-webhook": "Supprimer le Webhook", + "no-webhooks-yet": "Aucun webhook configuré pour le moment", + "failed-load-webhooks": "Échec du chargement des webhooks", + "webhook-will-no-longer-receive-events": "Ce webhook ne recevra plus d'événements", + "create-first-webhook-description": "Créez votre premier webhook pour commencer à recevoir des événements de diffusion", + "example-captain-hook": "Capitaine Crochet", + "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} webhooks\n }", + "activates-on": "S'active sur :", + "events-livestream": "Événements de Diffusion en Direct", + "events-chat": "Événements de Chat", + "untitled-webhook": "Webhook Sans Titre", + "inactive": "Inactif", + "debug-recording": "Enregistrement de Débogage", + "danmu-enabled": "Activer Danmu", + "danmu-enabled-description": "Afficher les messages de chat en direct sous forme de commentaires flottants sur votre écran", + "danmu-opacity": "Opacité", + "danmu-speed": "Vitesse", + "danmu-lane-count": "Nombre de Voies", + "danmu-max-messages": "Messages Maximum", + "app-version-description": "Informations sur la version actuelle", + "confirm-delete": "Êtes-vous sûr de vouloir supprimer ceci ?", + "action-cannot-be-undone": "Cette action ne peut pas être annulée", + "name-optional": "Nom (optionnel)", + "deleting": "Suppression...", + "saving": "Enregistrement...", + "go-to-dashboard": "Aller au Tableau de Bord", + "need-setup-live-dashboard": "Vous devez d'abord configurer une diffusion en direct dans le tableau de bord", + "no-languages-found": "Aucune langue trouvée", + "backup-save": "Enregistrer les paramètres de sauvegarde", + "backup-saving": "Enregistrement des paramètres de sauvegarde...", + "backup-secret-key-set-placeholder": "(Mot de passe déjà défini)", + "backup-error-invalid-endpoint": "Doit être un nom de domaine valide", + "backup-error-invalid-bucket": "Doit contenir uniquement des lettres minuscules, des chiffres, des points et des traits d'union", + "backup-error-invalid-segment-duration": "Doit être compris entre 1 et 60 secondes", + "backup-error-load-failed": "Échec du chargement des paramètres de stockage", + "backup-error-update-failed": "Échec de la mise à jour de l'état de la sauvegarde", + "backup-error-save-failed": "Échec de l'enregistrement des paramètres de stockage", + "backup-error-missing-secret": "Impossible de mettre à jour les paramètres S3 sans la clé secrète. Veuillez la saisir à nouveau.", + "backup-segment-duration-placeholder": "6", + "backup": "Sauvegarde", + "backup-enabled": "S3 Backup", + "backup-enabled-description": "Sauvegarder automatiquement les enregistrements dans un stockage compatible S3", + "backup-connection-url": "URL de connexion", + "backup-connection-url-placeholder": "s3+https://ACCESS_KEY:SECRET_KEY@s3.example.com/bucket", + "backup-endpoint": "Point de terminaison", + "backup-endpoint-placeholder": "s3.example.com", + "backup-bucket": "Compartiment", + "backup-bucket-placeholder": "my-backup-bucket", + "backup-access-key": "Clé d'accès", + "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", + "backup-secret-key": "Clé secrète", + "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "show-password-in-url": "Afficher le mot de passe dans l'URL", + "show-password-in-url-description": "Afficher la clé secrète dans l'URL de connexion (si saisie)", + "recommendations-to-others": "Recommandations aux autres", + "recommendations-description": "Partagez jusqu'à 8 streameurs que vous recommandez à vos spectateurs", + "no-recommendations-yet": "Aucune recommandation configurée pour le moment", + "multistream": "Multidiffusion", + "multistream-targets": "Cibles de multidiffusion", + "multistream-description": "Envoyez automatiquement vos diffusions en direct Streamplace vers d'autres services comme Twitch ou YouTube.", + "create-multistream-target": "Créer une cible de multidiffusion", + "untitled-multistream-target": "Cible sans titre", + "failed-load-multistream-targets": "Échec du chargement des cibles de multidiffusion. Veuillez réessayer.", + "failed-toggle-multistream-target": "Échec du basculement de la cible de multidiffusion. Veuillez réessayer.", + "no-multistream-targets-yet": "Aucune cible pour le moment !", + "multistream-targets-count": "{$count ->\n [one] {$count} cible\n [many] {$count} cibles\n *[other] {$count} cibles\n }", + "multistream-delete-target-confirmation": "Êtes-vous sûr de vouloir supprimer \"{$target}\" ?", + "this-action-cannot-be-undone": "Cette action ne peut pas être annulée.", + "rtmp-target-name": "Cible RTMP", + "rtmp-target-url": "URL RTMP", + "rtmp-target-name-placeholder": "Ma cible de multidiffusion", + "multistream-create-target": "Créer la cible", + "multistream-edit-target": "Modifier la cible", + "created": "créé", + "status": "statut", + "branding": "Image de marque", + "branding-admin": "Administration de l'image de marque", + "branding-admin-description": "Personnalisez votre instance Streamplace. Notez que les paramètres peuvent prendre quelques heures à se propager.", + "branding-login-required": "Veuillez vous connecter pour gérer l'image de marque", + "branding-configuration": "Configuration", + "branding-text-settings": "Paramètres de texte", + "branding-colors": "Couleurs", + "branding-legal-links": "Liens légaux", + "branding-images": "Images", + "branding-broadcaster-did": "DID du diffuseur", + "branding-broadcaster-did-description": "Laisser vide pour utiliser la valeur par défaut du serveur", + "branding-site-title": "Titre du site", + "branding-site-title-placeholder": "Saisir un nouveau titre de site", + "branding-site-description": "Description du site", + "branding-site-description-placeholder": "Saisir la description du site", + "branding-default-streamer": "Streameur par défaut", + "branding-default-streamer-none": "Aucun", + "branding-default-streamer-placeholder": "did:plc:...", + "branding-clear-default-streamer": "Effacer le streameur par défaut", + "branding-primary-color": "Couleur principale", + "branding-primary-color-placeholder": "#6366f1", + "branding-accent-color": "Couleur d'accent", + "branding-accent-color-placeholder": "#8b5cf6", + "branding-main-logo": "Logo principal", + "branding-main-logo-description": "SVG, PNG ou JPEG (max 500 Ko)", + "branding-favicon": "Favicon", + "branding-favicon-description": "SVG, PNG ou ICO (max 100 Ko)", + "branding-sidebar-bg": "Image d'arrière-plan de la barre latérale", + "branding-sidebar-bg-description": "SVG, PNG ou JPEG (max 500 ko) - apparaît aligné en bas de la barre latérale, pleine largeur. Téléchargez une image avec opacité pour de meilleurs résultats, car il n'existe pas actuellement d'option d'opacité séparée.", + "branding-current": "Actuel : {$value}", + "branding-dimensions": "{$height} x {$width}", + "branding-upload-logo": "Télécharger le logo", + "branding-delete-logo": "Supprimer le logo", + "branding-upload-favicon": "Télécharger le favicon", + "branding-delete-favicon": "Supprimer le favicon", + "branding-upload-background": "Télécharger l'arrière-plan", + "branding-delete-background": "Supprimer l'arrière-plan", + "branding-web-only": "Les téléchargements d'images ne sont disponibles que sur le web.", + "refresh-branding": "Actualiser les ressources de l'image de marque", + "branding-add-legal-link": "Ajouter un lien légal", + "branding-edit-legal-link": "Modifier le lien légal", + "branding-legal-link-text-placeholder": "Texte du lien (ex., Politique de confidentialité)", + "branding-legal-link-url-placeholder": "URL (ex., https://exemple.com/confidentialite)", + "add": "Ajouter", + "active": "Actif", + "optional": "optionnel", + "branding-not-authenticated": "Veuillez d'abord vous connecter", + "branding-empty-value": "Veuillez saisir une valeur", + "branding-update-success": "{$key} mis à jour avec succès", + "branding-upload-success": "{$key} téléchargé avec succès", + "branding-delete-success": "{$key} supprimé avec succès", + "branding-upload-failed": "Échec du téléchargement", + "branding-delete-failed": "Échec de la suppression", + "branding-not-available": "Les téléchargements de fichiers ne sont disponibles que sur le web", + "node-legal-documents": "Documents spécifiques au diffuseur", + "badges": "Badges", + "badges-cosmetic-section": "Badges cosmétiques", + "badges-empty-state": "Vous n'avez pas encore reçu de badges.", + "badges-failed-load": "Échec du chargement des badges", + "badges-failed-update": "Échec de la mise à jour de la sélection de badges", + "badges-issued-by": "Délivré par {$issuer}", + "badges-streamer-section": "Badges du streameur", + "issue-badges": "Délivrer des badges", + "issue-badges-back-to-definitions": "Définitions de badges", + "issue-badges-badge-name": "Nom du badge", + "issue-badges-badge-name-placeholder": "ex. VIP, Supporter", + "issue-badges-badge-type": "Type de badge", + "issue-badges-choose-image": "Choisir une image", + "issue-badges-create-definition": "Créer une définition de badge", + "issue-badges-create-definition-description": "Définissez un nouveau type de badge qui peut être délivré aux spectateurs", + "issue-badges-create-definition-subtitle": "Définir un nouveau type de badge", + "issue-badges-definition-created": "Définition de badge créée", + "issue-badges-description-optional": "Description (optionnelle)", + "issue-badges-description-placeholder": "Description optionnelle pour ce badge", + "issue-badges-failed-create": "Échec de la création de la définition de badge", + "issue-badges-failed-issue": "Échec de la délivrance du badge", + "issue-badges-failed-load": "Échec du chargement des définitions de badges", + "issue-badges-image-optional": "Image (optionnelle)", + "issue-badges-image-too-large": "L'image doit faire moins de 256 Ko", + "issue-badges-image-web-only": "Le téléchargement d'images n'est disponible que sur le web", + "issue-badges-issue-badge": "Délivrer un badge", + "issue-badges-issue-badge-description": "Délivrez le badge \"{$name}\" à un spectateur par son DID", + "issue-badges-issued": "Badge délivré", + "issue-badges-issued-to": "Délivré à {$did}", + "issue-badges-manage-description": "Créez des définitions de badges et délivrez-les aux spectateurs", + "issue-badges-recipient-did": "DID du destinataire", + "issue-badges-recipient-did-placeholder": "did:plc:...", + "issue-badges-tap-to-issue": "Appuyer pour délivrer", + "issue-badges-your-definitions": "Vos définitions de badges" +} diff --git a/js/i18n/public/locales/pt-BR/common.json b/js/i18n/public/locales/pt-BR/common.json new file mode 100644 index 00000000..2c35c532 --- /dev/null +++ b/js/i18n/public/locales/pt-BR/common.json @@ -0,0 +1,46 @@ +{ + "loading": "Carregando...", + "error": "Erro", + "cancel": "Cancelar", + "confirm": "Confirmar", + "close": "Fechar", + "open": "Abrir", + "ok": "OK", + "yes": "Sim", + "no": "Não", + "continue": "Continuar", + "back": "Voltar", + "next": "Próximo", + "finish": "Concluir", + "save": "Salvar", + "delete": "Excluir", + "edit": "Editar", + "create": "Criar", + "update": "Atualizar", + "refresh": "Atualizar", + "success": "Sucesso", + "warning": "Aviso", + "info": "Informação", + "search-placeholder": "Pesquisar...", + "message-input": "Digite sua mensagem...", + "please-log-in-to-access-this-page": "Por favor, faça login para acessar esta página", + "go-to-settings": "Ir para Configurações", + "go-back": "Voltar", + "welcome-user": "Bem-vindo, {$username}!", + "notification-count": "{$count ->\n [0] Nenhuma notificação\n [1] Uma notificação\n *[other] {$count} notificações\n }", + "user-offline": "usuário offline", + "user-offline-message": "{$source ->\n [streamer] Parece que <1>@{$handle} está offline, mas eles recomendam assistir:\n *[default] Parece que <1>@{$handle} está offline, mas recomendamos assistir:\n }", + "user-offline-no-recommendations": "Parece que <1>@{$handle} está offline agora.\nVolte mais tarde.", + "streaming-title": "transmitindo {$title}", + "viewer-count": "{$count ->\n [0] 0 espectadores\n [1] 1 espectador\n *[other] {$count} espectadores\n }", + "pds-selector-title": "Novo no Atmosphere?", + "pds-selector-description": "Você precisará selecionar um PDS (Servidor de Dados Pessoal) para acessar apps no Atmosphere, como Bluesky, Tangled e Spark.", + "pds-selector-custom-label": "Outro PDS", + "pds-selector-custom-description": "Digite a URL do seu próprio host PDS", + "pds-selector-custom-url-label": "URL do PDS personalizado", + "pds-selector-custom-url-placeholder": "https://pds.exemplo.com", + "pds-selector-learn-more": "Saiba mais sobre auto-hospedagem", + "pds-selector-info": "Cada host tem suas próprias políticas e padrões de confiabilidade. Seus dados ATProto ficam no host que você escolher e você pode migrar depois. Nota: O Streamplace tem suas próprias regras de moderação — você pode ser banido do Streamplace independentemente do host escolhido.", + "pds-selector-read-policies": "Leia os Termos de Serviço e a Política de Privacidade de {$label} antes de continuar.", + "pds-selector-handle-policy-checkbox": "Li e concordo com a política de identificadores" +} diff --git a/js/i18n/public/locales/pt-BR/settings.json b/js/i18n/public/locales/pt-BR/settings.json new file mode 100644 index 00000000..e9c6b706 --- /dev/null +++ b/js/i18n/public/locales/pt-BR/settings.json @@ -0,0 +1,246 @@ +{ + "app-version": "Streamplace v{$version}", + "download-new-update": "Baixar Nova Atualização", + "check-for-updates": "Verificar Atualizações", + "bundled-runtype": "Empacotado", + "ota-runtype": "Over-the-Air (OTA)", + "recovery-runtype": "Modo de Recuperação", + "modal-latest-version": "Você está usando a versão mais recente.", + "modal-no-update-available": "Você está na versão mais recente do Streamplace, eba!", + "modal-update-available-title": "Atualização Disponível", + "modal-update-available-description": "Uma nova versão do Streamplace está pronta para download", + "modal-update-failed": "A verificação de atualizações falhou. Você pode precisar atualizar o aplicativo através do {$store}.", + "modal-update-failed-title": "Atualização Falhou", + "modal-update-failed-description": "A verificação de atualizações falhou. Você pode precisar atualizar o aplicativo através do {$store}.", + "button-reload-app-on-update": "Aplicar Atualização (o aplicativo será recarregado)", + "use-custom-node": "Usar Nó Personalizado", + "default-url": "Padrão: {$url}", + "enter-custom-node-url": "Digite a URL do nó personalizado", + "save-button": "Salvar", + "language-selection": "Idioma", + "language-selection-description": "Escolha seu idioma preferido", + "help-translate": "Ajude-nos a traduzir o Streamplace", + "help-translate-description": "Estamos procurando voluntários para ajudar a traduzir o Streamplace para mais idiomas. Se você estiver interessado, entre em contato conosco no Discord ou GitHub!", + "currently-translating": "As traduções estão a caminho", + "currently-translating-description": "Algumas partes do aplicativo podem parecer incompletas. Obrigado pela sua paciência!", + "debug-recording-title": "Permitir que {$host} grave sua transmissão ao vivo para depuração e melhoria do serviço", + "debug-recording-description": "Opcional", + "input-search-languages": "Pesquisar idiomas...", + "manage-keys": "Gerenciar Chaves", + "settings-title": "Configurações", + "loading": "Carregando...", + "error": "Erro", + "cancel": "Cancelar", + "confirm": "Confirmar", + "welcome-user": "Bem-vindo, {$username}!", + "notification-count": "{$count ->\n [0] Nenhuma notificação\n [1] Uma notificação\n *[other] {$count} notificações\n }", + "search-placeholder": "Pesquisar...", + "message-input": "Digite sua mensagem...", + "success": "Sucesso", + "warning": "Aviso", + "info": "Informação", + "close": "Fechar", + "open": "Abrir", + "delete": "Excluir", + "edit": "Editar", + "create": "Criar", + "update": "Atualizar", + "refresh": "Atualizar", + "save": "Salvar", + "cancel-button": "Cancelar", + "ok": "OK", + "yes": "Sim", + "no": "Não", + "continue": "Continuar", + "back": "Voltar", + "next": "Próximo", + "finish": "Finalizar", + "about": "Sobre", + "account": "Conta", + "advanced": "Avançado", + "danmu": "Danmu", + "developer": "Desenvolvedor", + "languages": "Idiomas", + "privacy-security": "Privacidade e Segurança", + "streaming": "Transmissão", + "sign-in": "Entrar", + "log-out": "Sair", + "account-greeting": "Olá, @{$handle}.", + "edit-profile-bluesky": "Editar perfil (no Bluesky)", + "change-name-color": "Mudar cor do nome", + "key-management": "Gerenciamento de Chaves", + "key-manager": "Gerenciador de Chaves", + "your-stream-pubkeys": "Suas Chaves Públicas de Transmissão", + "no-keys": "Nenhuma chave configurada", + "pubkey-description": "Chaves públicas são emparelhadas com chaves de transmissão (usadas em software de transmitindo) para assinar e verificar sua transmissão", + "keys-count": "{$count ->\n [one] {$count} chave\n [many] {$count} chaves\n *[other] {$count} chaves\n }", + "webhooks": "Webhooks", + "webhook-integrations": "Integrações de Webhook", + "webhook-integrations-description": "Conecte serviços externos para receber atualizações em tempo real sobre suas transmissões", + "create-webhook": "Criar Webhook", + "edit-webhook": "Editar Webhook", + "delete-webhook": "Excluir Webhook", + "no-webhooks-yet": "Nenhum webhook configurado ainda", + "failed-load-webhooks": "Falha ao carregar webhooks", + "webhook-will-no-longer-receive-events": "Este webhook não receberá mais eventos", + "create-first-webhook-description": "Crie seu primeiro webhook para começar a receber eventos de transmissão", + "example-captain-hook": "Capitão Gancho", + "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} webhooks\n }", + "activates-on": "Ativa em:", + "events-livestream": "Eventos de Transmissão ao Vivo", + "events-chat": "Eventos de Chat", + "untitled-webhook": "Webhook Sem Título", + "inactive": "Inativo", + "debug-recording": "Gravação de Depuração", + "danmu-enabled": "Ativar Danmu", + "danmu-enabled-description": "Exibir mensagens de chat ao vivo como comentários flutuantes na sua tela", + "danmu-opacity": "Opacidade", + "danmu-speed": "Velocidade", + "danmu-lane-count": "Número de Faixas", + "danmu-max-messages": "Mensagens Máximas", + "app-version-description": "Informações da versão atual", + "confirm-delete": "Tem certeza de que deseja excluir isso?", + "action-cannot-be-undone": "Esta ação não pode ser desfeita", + "name-optional": "Nome (opcional)", + "deleting": "Excluindo...", + "saving": "Salvando...", + "go-to-dashboard": "Ir para o Painel", + "need-setup-live-dashboard": "Você precisa configurar uma transmissão ao vivo no painel primeiro", + "no-languages-found": "Nenhum idioma encontrado", + "backup-save": "Salvar configurações de backup", + "backup-saving": "Salvando configurações de backup...", + "backup-secret-key-set-placeholder": "(Senha já definida)", + "backup-error-invalid-endpoint": "Deve ser um nome de domínio válido", + "backup-error-invalid-bucket": "Deve conter apenas letras minúsculas, números, pontos e hifens", + "backup-error-invalid-segment-duration": "Deve estar entre 1 e 60 segundos", + "backup-error-load-failed": "Falha ao carregar as configurações de armazenamento", + "backup-error-update-failed": "Falha ao atualizar o status do backup", + "backup-error-save-failed": "Falha ao salvar as configurações de armazenamento", + "backup-error-missing-secret": "Não é possível atualizar as configurações do S3 sem a chave secreta. Por favor, insira-a novamente.", + "backup-segment-duration-placeholder": "6", + "backup": "Backup", + "backup-enabled": "S3 Backup", + "backup-enabled-description": "Fazer backup automaticamente das gravações em armazenamento compatível com S3", + "backup-connection-url": "URL de conexão", + "backup-connection-url-placeholder": "s3+https://ACCESS_KEY:SECRET_KEY@s3.example.com/bucket", + "backup-endpoint": "Endpoint", + "backup-endpoint-placeholder": "s3.example.com", + "backup-bucket": "Bucket", + "backup-bucket-placeholder": "my-backup-bucket", + "backup-access-key": "Chave de acesso", + "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", + "backup-secret-key": "Chave secreta", + "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "show-password-in-url": "Mostrar senha na URL", + "show-password-in-url-description": "Exibir a chave secreta na URL de conexão (se inserida)", + "recommendations-to-others": "Recomendações para outros", + "recommendations-description": "Compartilhe até 8 streamers que você recomenda aos seus espectadores", + "no-recommendations-yet": "Nenhuma recomendação configurada ainda", + "multistream": "Multitransmissão", + "multistream-targets": "Destinos de multitransmissão", + "multistream-description": "Envie automaticamente suas transmissões ao vivo do Streamplace para outros serviços como Twitch ou YouTube.", + "create-multistream-target": "Criar destino de multitransmissão", + "untitled-multistream-target": "Destino sem título", + "failed-load-multistream-targets": "Falha ao carregar os destinos de multitransmissão. Por favor, tente novamente.", + "failed-toggle-multistream-target": "Falha ao alternar o destino de multitransmissão. Por favor, tente novamente.", + "no-multistream-targets-yet": "Nenhum destino ainda!", + "multistream-targets-count": "{$count ->\n [one] {$count} destino\n [many] {$count} destinos\n *[other] {$count} destinos\n }", + "multistream-delete-target-confirmation": "Tem certeza de que deseja excluir \"{$target}\"?", + "this-action-cannot-be-undone": "Esta ação não pode ser desfeita.", + "rtmp-target-name": "Destino RTMP", + "rtmp-target-url": "URL RTMP", + "rtmp-target-name-placeholder": "Meu destino de multitransmissão", + "multistream-create-target": "Criar destino", + "multistream-edit-target": "Editar destino", + "created": "criado", + "status": "status", + "branding": "Identidade visual", + "branding-admin": "Administração de identidade visual", + "branding-admin-description": "Personalize sua instância do Streamplace. As configurações podem levar algumas horas para se propagar.", + "branding-login-required": "Faça login para gerenciar a identidade visual", + "branding-configuration": "Configuração", + "branding-text-settings": "Configurações de texto", + "branding-colors": "Cores", + "branding-legal-links": "Links legais", + "branding-images": "Imagens", + "branding-broadcaster-did": "DID do transmissor", + "branding-broadcaster-did-description": "Deixe vazio para usar o padrão do servidor", + "branding-site-title": "Título do site", + "branding-site-title-placeholder": "Digite o novo título do site", + "branding-site-description": "Descrição do site", + "branding-site-description-placeholder": "Digite a descrição do site", + "branding-default-streamer": "Streamer padrão", + "branding-default-streamer-none": "Nenhum", + "branding-default-streamer-placeholder": "did:plc:...", + "branding-clear-default-streamer": "Limpar streamer padrão", + "branding-primary-color": "Cor principal", + "branding-primary-color-placeholder": "#6366f1", + "branding-accent-color": "Cor de destaque", + "branding-accent-color-placeholder": "#8b5cf6", + "branding-main-logo": "Logo principal", + "branding-main-logo-description": "SVG, PNG ou JPEG (máx. 500KB)", + "branding-favicon": "Favicon", + "branding-favicon-description": "SVG, PNG ou ICO (máx. 100KB)", + "branding-sidebar-bg": "Imagem de fundo da barra lateral", + "branding-sidebar-bg-description": "SVG, PNG ou JPEG (máx. 500kb) - aparece alinhada na parte inferior da barra lateral, largura total. Faça upload de uma imagem com opacidade para melhores resultados, pois atualmente não há uma opção de opacidade separada.", + "branding-current": "Atual: {$value}", + "branding-dimensions": "{$height} x {$width}", + "branding-upload-logo": "Enviar logo", + "branding-delete-logo": "Excluir logo", + "branding-upload-favicon": "Enviar favicon", + "branding-delete-favicon": "Excluir favicon", + "branding-upload-background": "Enviar plano de fundo", + "branding-delete-background": "Excluir plano de fundo", + "branding-web-only": "O upload de imagens só está disponível na web.", + "refresh-branding": "Atualizar recursos de identidade visual", + "branding-add-legal-link": "Adicionar link legal", + "branding-edit-legal-link": "Editar link legal", + "branding-legal-link-text-placeholder": "Texto do link (ex., Política de Privacidade)", + "branding-legal-link-url-placeholder": "URL (ex., https://exemplo.com/privacidade)", + "add": "Adicionar", + "active": "Ativo", + "optional": "opcional", + "branding-not-authenticated": "Faça login primeiro", + "branding-empty-value": "Por favor, insira um valor", + "branding-update-success": "{$key} atualizado com sucesso", + "branding-upload-success": "{$key} enviado com sucesso", + "branding-delete-success": "{$key} excluído com sucesso", + "branding-upload-failed": "Falha ao enviar", + "branding-delete-failed": "Falha ao excluir", + "branding-not-available": "O upload de arquivos só está disponível na web", + "node-legal-documents": "Documentos específicos do transmissor", + "badges": "Emblemas", + "badges-cosmetic-section": "Emblemas cosméticos", + "badges-empty-state": "Você ainda não recebeu nenhum emblema.", + "badges-failed-load": "Falha ao carregar emblemas", + "badges-failed-update": "Falha ao atualizar a seleção de emblemas", + "badges-issued-by": "Emitido por {$issuer}", + "badges-streamer-section": "Emblemas do streamer", + "issue-badges": "Emitir emblemas", + "issue-badges-back-to-definitions": "Definições de emblemas", + "issue-badges-badge-name": "Nome do emblema", + "issue-badges-badge-name-placeholder": "ex. VIP, Apoiador", + "issue-badges-badge-type": "Tipo de emblema", + "issue-badges-choose-image": "Escolher imagem", + "issue-badges-create-definition": "Criar definição de emblema", + "issue-badges-create-definition-description": "Defina um novo tipo de emblema que pode ser emitido para espectadores", + "issue-badges-create-definition-subtitle": "Defina um novo tipo de emblema", + "issue-badges-definition-created": "Definição de emblema criada", + "issue-badges-description-optional": "Descrição (opcional)", + "issue-badges-description-placeholder": "Descrição opcional para este emblema", + "issue-badges-failed-create": "Falha ao criar a definição de emblema", + "issue-badges-failed-issue": "Falha ao emitir o emblema", + "issue-badges-failed-load": "Falha ao carregar as definições de emblemas", + "issue-badges-image-optional": "Imagem (opcional)", + "issue-badges-image-too-large": "A imagem deve ter menos de 256KB", + "issue-badges-image-web-only": "O upload de imagens só está disponível na web", + "issue-badges-issue-badge": "Emitir emblema", + "issue-badges-issue-badge-description": "Emita o emblema \"{$name}\" para um espectador pelo seu DID", + "issue-badges-issued": "Emblema emitido", + "issue-badges-issued-to": "Emitido para {$did}", + "issue-badges-manage-description": "Crie definições de emblemas e emita-os para espectadores", + "issue-badges-recipient-did": "DID do destinatário", + "issue-badges-recipient-did-placeholder": "did:plc:...", + "issue-badges-tap-to-issue": "Toque para emitir", + "issue-badges-your-definitions": "Suas definições de emblemas" +} diff --git a/js/i18n/public/locales/ro-RO/common.json b/js/i18n/public/locales/ro-RO/common.json new file mode 100644 index 00000000..a0f1f276 --- /dev/null +++ b/js/i18n/public/locales/ro-RO/common.json @@ -0,0 +1,46 @@ +{ + "loading": "Se încarcă...", + "error": "Eroare", + "cancel": "Anulare", + "confirm": "Confirmare", + "close": "Închidere", + "open": "Deschidere", + "ok": "OK", + "yes": "Da", + "no": "Nu", + "continue": "Continuare", + "back": "Înapoi", + "next": "Următorul", + "finish": "Finalizare", + "save": "Salvare", + "delete": "Ștergere", + "edit": "Editare", + "create": "Creare", + "update": "Actualizare", + "refresh": "Reîmprospătare", + "success": "Succes", + "warning": "Avertisment", + "info": "Informație", + "search-placeholder": "Căutare...", + "message-input": "Introduceți-vă mesajul...", + "please-log-in-to-access-this-page": "Vă rugăm să vă conectați pentru a accesa această pagină", + "go-to-settings": "Mergeți la Setări", + "go-back": "Mergeți înapoi", + "welcome-user": "Bun venit, {$username}!", + "notification-count": "{$count ->\n [0] Nici o notificare\n [1] O notificare\n *[other] {$count} (de) notificări\n }", + "user-offline": "utilizatorul este offline", + "user-offline-message": "{$source ->\n [streamer] Se pare că <1>@{$handle} este offline, dar recomandă să vizionați:\n *[default] Se pare că <1>@{$handle} este offline, dar vă recomandăm să vizionați:\n }", + "user-offline-no-recommendations": "Se pare că <1>@{$handle} este offline acum.\nReveniți mai târziu.", + "streaming-title": "transmisie în direct {$title}", + "viewer-count": "{$count ->\n [0] 0 vizionări\n [1] 1 vizionare\n *[other] {$count} (de) vizionări\n }", + "pds-selector-title": "Nou pe Atmosphere?", + "pds-selector-description": "Va trebui să selectați un PDS (Personal Data Server) pentru a accesa aplicațiile de pe Atmosphere, cum ar fi Bluesky, Tangled și Spark.", + "pds-selector-custom-label": "Alt PDS", + "pds-selector-custom-description": "Introduceți adresa URL a propriei gazde PDS", + "pds-selector-custom-url-label": "URL PDS personalizat", + "pds-selector-custom-url-placeholder": "https://pds.exemplu.ro", + "pds-selector-learn-more": "Aflați mai multe despre găzduirea proprie", + "pds-selector-info": "Fiecare gazdă are propriile politici și standarde de fiabilitate. Datele dvs. ATProto se află pe gazda pe care o alegeți și le puteți migra ulterior. Notă: Streamplace are propriile reguli de moderare - accesul dvs. poate fi interzis pe Streamplace indiferent de gazda pe care o alegeți.", + "pds-selector-read-policies": "Citiți Termenii și condițiile și Politica de confidențialitate ale {$label} înainte de a continua.", + "pds-selector-handle-policy-checkbox": "Am citit și sunt de acord cu politica de gestionare" +} diff --git a/js/i18n/public/locales/ro-RO/settings.json b/js/i18n/public/locales/ro-RO/settings.json new file mode 100644 index 00000000..0d25901d --- /dev/null +++ b/js/i18n/public/locales/ro-RO/settings.json @@ -0,0 +1,206 @@ +{ + "app-version": "Streamplace v{$version}", + "download-new-update": "Descărcați noua actualizare", + "check-for-updates": "Căutați actualizări", + "bundled-runtype": "Bundled", + "ota-runtype": "Over-the-Air (OTA)", + "recovery-runtype": "Mod de recuperare", + "modal-latest-version": "Aveți cea mai recentă versiune.", + "modal-no-update-available": "Aveți cea mai recentă versiune de Streamplace, ura!", + "modal-update-available-title": "Actualizare disponibilă", + "modal-update-available-description": "O nouă versiune de Streamplace este gata de descărcare", + "modal-update-failed": "Căutarea actualizării a eșuat. Este posibil să fie nevoie să actualizați aplicația prin {$store}.", + "modal-update-failed-title": "Actualizare eșuată", + "modal-update-failed-description": "Căutarea actualizării a eșuat. Este posibil să fie nevoie să actualizați aplicația prin {$store}.", + "button-reload-app-on-update": "Aplicați actualizarea (va reîncărca aplicația)", + "use-custom-node": "Utilizați nodul personalizat", + "default-url": "Default: {$url}", + "enter-custom-node-url": "Introduceți adresa URL a nodului personalizat", + "save-button": "Salvare", + "language-selection": "Limbă", + "language-selection-description": "Alegeți-vă limba preferată", + "input-search-languages": "Căutați limbi...", + "help-translate": "Ajutați-ne să traducem Streamplace", + "help-translate-description": "Căutăm voluntari care să ne ajute la traducerea Streamplace în mai multe limbi. Dacă sunteți interesați, vă rugăm să ne contactați pe Discord sau GitHub!", + "currently-translating": "Traducerile sunt pe drum", + "currently-translating-description": "Este posibil ca unele părți ale aplicației să pară incomplete. Vă mulțumim pentru răbdare!", + "debug-recording-title": "Permiteți-i lui {$host} să înregistreze transmisiunea live pentru depanare și îmbunătățirea serviciului", + "debug-recording-description": "Opțional", + "manage-keys": "Gestionare chei", + "settings-title": "Setări", + "about": "Despre", + "account": "Cont", + "advanced": "Complex", + "danmu": "Danmu", + "developer": "Dezvoltator", + "languages": "Limbi", + "privacy-security": "Confidențialitate și securitate", + "streaming": "Streaming", + "cancel": "Anulare", + "create": "Creare", + "delete": "Ștergere", + "refresh": "Reîmprospătare", + "sign-in": "Conectare", + "update": "Actualizare", + "log-out": "Deconectare", + "optional": "opțional", + "account-greeting": "Hei, @{$handle}.", + "edit-profile-bluesky": "Editare profil (pe Bluesky)", + "change-name-color": "Schimbați culoarea numelui", + "key-management": "Gestionare chei", + "key-manager": "Manager cheie", + "your-stream-pubkeys": "Cheile publice ale transmisiunii dvs.", + "no-keys": "Nicio cheie configurată", + "pubkey-description": "Cheile publice sunt asociate cu cheile de transmisiune (utilizate în software-ul de streaming) pentru a semna și verifica transmisiunea.", + "keys-count": "{$count ->\n [one] {$count} cheie\n *[other] {$count} chei\n }", + "recommendations": "Recomandări", + "manage-recommendations": "Gestionați recomandările", + "recommendations-to-others": "Recomandări pentru Alții", + "recommendations-description": "Distribuie până la 8 streameri pe care îi recomandați spectatorilor dvs.", + "no-recommendations-yet": "Nu au fost configurate recomandări încă", + "add-recommendation": "Adăugare recomandare", + "streamer-did": "DID Streamer", + "recommendations-count": "{$count ->\n [one] {$count} recomandare\n *[other] {$count} (de) recomandări\n }", + "webhooks": "Webhook-uri", + "webhook-integrations": "Integrari Webhook", + "webhook-integrations-description": "Conectați servicii externe pentru a primi actualizări în timp real despre transmisiunile dvs.", + "create-webhook": "Creare Webhook", + "edit-webhook": "Editare Webhook", + "delete-webhook": "Ștergere Webhook", + "no-webhooks-yet": "Nu sunt încă configurate webhook-uri", + "failed-load-webhooks": "Nu s-au putut încărca webhook-urile", + "webhook-will-no-longer-receive-events": "Acest webhook nu va mai primi evenimente", + "create-first-webhook-description": "Creați-vă primul webhook pentru a începe să primiți evenimente de transmisiune", + "example-captain-hook": "Captain Hook", + "webhooks-count": "{$count ->\n [one] {$count} webhook\n *[other] {$count} (de) webhook-uri\n }", + "activates-on": "Se activează la:", + "events": "Evenimente", + "events-livestream": "Evenimente în transmisiuni", + "events-chat": "Evenimente de conversașie", + "untitled-webhook": "Webhook fără titlu", + "inactive": "Inactiv", + "active": "Activ", + "multistream": "Multi-transmisiune", + "multistream-targets": "Ținte multi-transmisiune", + "multistream-description": "Distribuie automat transmisiunile tale live Streamplace către alte servicii de streaming precum Twitch sau YouTube.", + "create-multistream-target": "Creare țintă multi-transmisiune", + "untitled-multistream-target": "Țintă fără titlu", + "failed-load-multistream-targets": "Nu s-au putut încărca țintele multi-transmisiune. Vă rugăm să încercați din nou.", + "failed-toggle-multistream-target": "Nu s-a putut comuta ținta multi-transmisiune. Vă rugăm să încercați din nou.", + "failed-delete-multistream-target": "Ștergerea țintei multi-transmisiune nu a reușit. Încercați din nou.", + "no-multistream-targets-yet": "Nu există încă ținte!", + "multistream-targets-count": "{$count ->\n [one] {$count} țintă\n *[other] {$count} (de) ținte\n }", + "multistream-delete-target-confirmation": "Sunteți sigur că vreți să ștergeți „{$target}”?", + "this-action-cannot-be-undone": "Această acțiune nu poate fi anulată.", + "rtmp-target-name": "Țintă RTMP", + "rtmp-target-url": "URL RTMP", + "rtmp-target-name-placeholder": "Ținta mea transmisiune", + "multistream-create-target": "Creare țintă", + "multistream-edit-target": "Editare țintă", + "created": "creată", + "status": "stare", + "debug-recording": "Depanare înregistrare", + "danmu-enabled": "Activare Danmu", + "danmu-enabled-description": "Afișați mesajele din chatul live ca comentarii plutitoare pe ecran", + "danmu-opacity": "Opacitate", + "danmu-speed": "Viteză", + "danmu-lane-count": "Număr de benzi", + "danmu-max-messages": "Mesaje maxime", + "app-version-description": "Nu sunt disponibile actualizări în acest moment", + "confirm-delete": "Sigur vreți să ștergeți asta?", + "action-cannot-be-undone": "Această acțiune nu poate fi anulată", + "name-optional": "Nume (opțional)", + "deleting": "Se șterge...", + "saving": "Se salvează...", + "go-to-dashboard": "Mergeți la tabloul de bord", + "need-setup-live-dashboard": "Trebuie să configurați mai întâi transmisiunile? Vizitați tabloul de bord al transmisiunilor", + "no-languages-found": "Nu s-au găsit limbi", + "branding": "Branding", + "branding-admin": "Administrarea brandingului", + "branding-admin-description": "Personalizați-vă instanța Streamplace. Rețineți că propagarea setărilor poate dura câteva ore.", + "branding-login-required": "Vă rugăm să vă conectați pentru a gestiona brandingul", + "branding-configuration": "Configurare", + "branding-text-settings": "Setări text", + "branding-colors": "Culori", + "branding-legal-links": "Link-uri legale", + "branding-images": "Imagini", + "branding-broadcaster-did": "DID radiodifuzor", + "branding-broadcaster-did-description": "Lăsați gol pentru a utiliza valorile implicite ale serverului", + "branding-site-title": "Titlul site-ului", + "branding-site-title-placeholder": "Introduceți un nou titlu de site", + "branding-site-description": "Descrierea site-ului", + "branding-site-description-placeholder": "Introduceți descrierea site-ului", + "branding-default-streamer": "Streamer implicit", + "branding-default-streamer-none": "Nimic", + "branding-default-streamer-placeholder": "did:plc:...", + "branding-clear-default-streamer": "Ștergeți Streamerul implicit", + "branding-primary-color": "Culoare primară", + "branding-primary-color-placeholder": "#6366f1", + "branding-accent-color": "Culoare de accent", + "branding-accent-color-placeholder": "#8b5cf6", + "branding-main-logo": "Logo principal", + "branding-main-logo-description": "SVG, PNG, or JPEG (max 500KB)", + "branding-favicon": "Favicon", + "branding-favicon-description": "SVG, PNG, sau ICO (maxim 100KB)", + "branding-sidebar-bg": "Imagine de fundal din bara laterală", + "branding-sidebar-bg-description": "SVG, PNG sau JPEG (maxim 500kb) - apare aliniat la partea de jos a barei laterale, pe lățime completă. Încărcați o imagine cu opacitate pentru cele mai bune rezultate, deoarece în prezent nu există o opțiune separată de opacitate.", + "branding-current": "Current: {$value}", + "branding-dimensions": "{$height} x {$width}", + "branding-upload-logo": "Încărcare logo", + "branding-delete-logo": "Ștergere logo", + "branding-upload-favicon": "Încărcare Favicon", + "branding-delete-favicon": "Ștergere Favicon", + "branding-upload-background": "Încărcare imagine de fundal", + "branding-delete-background": "Ștergere imagine de fundal", + "branding-web-only": "Încărcarea imaginilor este disponibilă doar pe web.", + "refresh-branding": "Actualizați elementele de branding", + "branding-add-legal-link": "Adăugați link legal", + "branding-edit-legal-link": "Editați linkul legal", + "branding-legal-link-text-placeholder": "Textul linkului (de exemplu, Politica de confidențialitate)", + "branding-legal-link-url-placeholder": "Adresa URL (de ex., https://example.com/privacy)", + "add": "Adăugare", + "edit": "Editare", + "branding-not-authenticated": "Vă rugăm să vă conectați mai întâi", + "branding-empty-value": "Vă rugăm să introduceți o valoare", + "branding-update-success": "{$key} a fost actualizat cu succes", + "branding-upload-success": "{$key} a fost încărcat cu succes", + "branding-delete-success": "{$key} a fost șters cu succes", + "branding-upload-failed": "Încărcare eșuată", + "branding-delete-failed": "Ștergere eșuată", + "branding-not-available": "Încărcarea de fișiere este disponibilă doar pe web.", + "node-legal-documents": "Documente specifice radiodifuzorului", + "badges": "Insigne", + "badges-cosmetic-section": "Insigne cosmetice", + "badges-empty-state": "Nu ți-a fost acordată nicio insignă încă.", + "badges-failed-load": "Nu s-au putut încărca insignele", + "badges-failed-update": "Nu s-a putut actualiza selecția de insigne", + "badges-issued-by": "Acordată de {$issuer}", + "badges-streamer-section": "Insigne de la streameri", + "issue-badges": "Emitere insigne", + "issue-badges-back-to-definitions": "Definiții insigne", + "issue-badges-badge-name": "Numele insignei", + "issue-badges-badge-name-placeholder": "ex. VIP, Susținător", + "issue-badges-badge-type": "Tipul insignei", + "issue-badges-choose-image": "Alegeți imaginea", + "issue-badges-create-definition": "Creare definiție insignă", + "issue-badges-create-definition-description": "Definiți un nou tip de insignă care poate fi acordată spectatorilor", + "issue-badges-create-definition-subtitle": "Definiți un nou tip de insignă", + "issue-badges-definition-created": "Definiție insignă creată", + "issue-badges-description-optional": "Descriere (opțional)", + "issue-badges-description-placeholder": "Descriere opțională pentru această insignă", + "issue-badges-failed-create": "Nu s-a putut crea definiția insignei", + "issue-badges-failed-issue": "Nu s-a putut emite insigna", + "issue-badges-failed-load": "Nu s-au putut încărca definițiile insignelor", + "issue-badges-image-optional": "Imagine (opțional)", + "issue-badges-image-too-large": "Imaginea trebuie să fie mai mică de 256KB", + "issue-badges-image-web-only": "Încărcarea imaginilor este disponibilă doar pe web", + "issue-badges-issue-badge": "Emite insignă", + "issue-badges-issue-badge-description": "Emite insigna „{$name}\" unui spectator prin DID-ul său", + "issue-badges-issued": "Insignă emisă", + "issue-badges-issued-to": "Emisă pentru {$did}", + "issue-badges-manage-description": "Creați definiții de insigne și emiteți-le spectatorilor", + "issue-badges-recipient-did": "DID destinatar", + "issue-badges-recipient-did-placeholder": "did:plc:...", + "issue-badges-tap-to-issue": "Atingeți pentru a emite", + "issue-badges-your-definitions": "Definițiile dvs. de insigne" +} diff --git a/js/i18n/public/locales/zh-Hans/common.json b/js/i18n/public/locales/zh-Hans/common.json new file mode 100644 index 00000000..8cf1e39b --- /dev/null +++ b/js/i18n/public/locales/zh-Hans/common.json @@ -0,0 +1,46 @@ +{ + "loading": "正在加载...", + "error": "错误", + "cancel": "取消", + "confirm": "确认", + "close": "关闭", + "open": "开启", + "ok": "确定", + "yes": "是", + "no": "否", + "continue": "继续", + "back": "返回", + "next": "下一步", + "finish": "完成", + "save": "保存", + "delete": "删除", + "edit": "编辑", + "create": "创建", + "update": "更新", + "refresh": "刷新", + "success": "成功", + "warning": "警告", + "info": "信息", + "search-placeholder": "搜索...", + "message-input": "输入您的消息...", + "please-log-in-to-access-this-page": "请登录以访问此页面", + "go-to-settings": "前往设置", + "go-back": "返回", + "welcome-user": "欢迎,{$username}!", + "notification-count": "{$count ->\n [0] 无通知\n *[other] {$count} 则通知\n }", + "user-offline": "用户离线", + "user-offline-message": "{$source ->\n [streamer] 看起来 <1>@{$handle} 离线了,但他们推荐观看:\n *[default] 看起来 <1>@{$handle} 离线了,但我们推荐观看:\n }", + "user-offline-no-recommendations": "看起来 <1>@{$handle} 离线了。\n请稍后再来看看。", + "streaming-title": "正在直播 {$title}", + "viewer-count": "{$count} 位观众", + "pds-selector-title": "刚接触 Atmosphere 吗?", + "pds-selector-description": "您需要选择一个 PDS (个人数据服务器) 来访问 Atmosphere 上的应用程序,例如 Bluesky、Tangled 和 Spark。", + "pds-selector-custom-label": "另一个 PDS", + "pds-selector-custom-description": "请输入您自己的 PDS 主机网址", + "pds-selector-custom-url-label": "自定义 PDS 网址", + "pds-selector-custom-url-placeholder": "https://pds.example.com", + "pds-selector-learn-more": "了解有关自组主机的更多信息", + "pds-selector-info": "每个主机都有自己的政策和可靠性标准。您的 ATProto 数据存储在您选择的主机上,您可以以后进行迁移。注意:Streamplace 有自己的审核规则——无论您选择哪个主机,都可能会被 Streamplace 禁止使用。", + "pds-selector-read-policies": "在继续之前,请阅读 {$label} 的服务条款和隐私政策。", + "pds-selector-handle-policy-checkbox": "我已阅读并同意处理政策" +} diff --git a/js/i18n/public/locales/zh-Hans/settings.json b/js/i18n/public/locales/zh-Hans/settings.json new file mode 100644 index 00000000..cb1c7a67 --- /dev/null +++ b/js/i18n/public/locales/zh-Hans/settings.json @@ -0,0 +1,241 @@ +{ + "app-version": "Streamplace v{$version}", + "download-new-update": "下载新更新", + "check-for-updates": "检查更新", + "bundled-runtype": "捆绑版", + "ota-runtype": "空中下载 (OTA)", + "recovery-runtype": "复原模式", + "modal-latest-version": "您正在使用最新版本。", + "modal-no-update-available": "您已经在使用最新版本的 Streamplace,太棒了!", + "modal-update-available-title": "有可用更新", + "modal-update-available-description": "新版本的 Streamplace 已准备好下载", + "modal-update-failed": "更新检查失败。您可能需要透过 {$store} 更新应用程序。", + "modal-update-failed-title": "更新失败", + "modal-update-failed-description": "更新检查失败。您可能需要透过 {$store} 更新应用程序。", + "button-reload-app-on-update": "套用更新 (将重新加载应用程序)", + "use-custom-node": "使用自定义节点", + "default-url": "默认:{$url}", + "enter-custom-node-url": "输入自定义节点网址", + "save-button": "保存", + "language-selection": "语言", + "language-selection-description": "选择您偏好的语言", + "input-search-languages": "搜索语言...", + "help-translate": "帮助我们翻译 Streamplace", + "help-translate-description": "我们正在寻找志愿者协助将 Streamplace 翻译成更多语言。如果您有兴趣,请在 Discord 或 GitHub 上与我们联系!", + "currently-translating": "翻译正在进行中", + "currently-translating-description": "应用程序的某些部分可能看起来不完整。感谢您的耐心等待!", + "debug-recording-title": "允许 {$host} 录制您的直播串流以进行除错和服务改善", + "debug-recording-description": "可选项", + "manage-keys": "管理密钥", + "settings-title": "设置", + "error": "错误", + "about": "关于", + "account": "帐户", + "advanced": "高级", + "danmu": "弹幕", + "developer": "开发者", + "languages": "语言", + "privacy-security": "隐私与安全", + "streaming": "串流", + "cancel": "取消", + "create": "创建", + "delete": "删除", + "refresh": "刷新", + "sign-in": "登录", + "update": "更新", + "log-out": "登出", + "optional": "选填", + "account-greeting": "嗨,@{$handle}。", + "edit-profile-bluesky": "在 Bluesky 编辑个人数据", + "change-name-color": "更改名称颜色", + "key-management": "密钥管理", + "key-manager": "密钥管理器", + "your-stream-pubkeys": "您的串流公开密钥", + "no-keys": "尚未设置密钥", + "pubkey-description": "公开密钥与串流密钥 (用于串流软件) 配对以签署和验证您的串流", + "keys-count": "{$count} 个密钥", + "backup": "备份", + "backup-enabled": "S3 备份", + "backup-enabled-description": "自动将录影备份到兼容 S3 的存储", + "backup-connection-url": "连接网址", + "backup-connection-url-placeholder": "必须是格式为 s3+https://ACCESS_KEY:SECRET_KEY@region.endpoi.nt/bucket 的有效 S3 网址", + "backup-endpoint": "终端", + "backup-endpoint-placeholder": "s3.example.com", + "backup-bucket": "存储桶", + "backup-bucket-placeholder": "my-backup-bucket", + "backup-access-key": "访问密钥", + "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", + "backup-secret-key": "秘密密钥", + "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "backup-region": "地区", + "backup-region-placeholder": "us-east-1", + "backup-test-connection": "测试连接", + "backup-testing-connection": "正在测试连接...", + "backup-connection-successful": "连接成功", + "backup-connection-failed": "连接失败", + "show-password-in-url": "在网址中显示密码", + "show-password-in-url-description": "在连接网址中显示秘密密钥 (若有)", + "requested-seconds-per-segment": "每段秒数", + "requested-seconds-per-segment-description": "设置每段秒数,以请求服务器使用", + "recommendations": "推荐主播", + "manage-recommendations": "管理推荐主播", + "recommendations-to-others": "向他人推荐主播", + "recommendations-description": "向您的观众推荐最多 8 位主播", + "no-recommendations-yet": "尚未配置推荐", + "add-recommendation": "添加推荐", + "streamer-did": "主播 DID", + "recommendations-count": "{$count} 位推荐主播", + "webhooks": "Webhooks", + "webhook-integrations": "Webhook 集成", + "webhook-integrations-description": "连接外部服务以即时接收有关您串流的更新", + "create-webhook": "创建 Webhook", + "edit-webhook": "编辑 Webhook", + "delete-webhook": "删除 Webhook", + "no-webhooks-yet": "尚未设置 Webhook", + "failed-load-webhooks": "加载 Webhook 失败", + "webhook-will-no-longer-receive-events": "此 Webhook 将不再接收事件", + "create-first-webhook-description": "创建您的第一个 Webhook 以开始接收串流事件", + "example-captain-hook": "Hook 船长", + "webhooks-count": "{$count} 个 Webhook", + "activates-on": "触发于:", + "events": "事件", + "events-livestream": "直播串流事件", + "events-chat": "聊天事件", + "untitled-webhook": "未命名的 Webhook", + "inactive": "停用", + "active": "活动", + "multistream": "多重串流", + "multistream-targets": "多重串流目标", + "multistream-description": "自动将您的 Streamplace 直播推送到 Twitch 或 YouTube 等其他直播平台。", + "create-multistream-target": "创建多重串流目标", + "untitled-multistream-target": "未命名目标", + "failed-load-multistream-targets": "加载多重串流目标失败。请重试。", + "failed-toggle-multistream-target": "切换多重串流目标失败。请重试。", + "failed-delete-multistream-target": "删除多重串流目标失败。请重试。", + "no-multistream-targets-yet": "还没有目标!", + "multistream-targets-count": "{$count} 个目标", + "multistream-delete-target-confirmation": "您确定要删除“{$target}”吗?", + "this-action-cannot-be-undone": "此操作无法撤销。", + "rtmp-target-name": "RTMP 目标", + "rtmp-target-url": "RTMP 网址", + "rtmp-target-name-placeholder": "我的多重串流目标", + "multistream-create-target": "创建目标", + "multistream-edit-target": "编辑目标", + "created": "创建于", + "status": "状态", + "debug-recording": "调试录制", + "danmu-enabled": "启用弹幕", + "danmu-enabled-description": "将即时聊天消息以浮动评论的形式显示在您的屏幕上", + "danmu-opacity": "不透明度", + "danmu-speed": "速度", + "danmu-lane-count": "轨道数量", + "danmu-max-messages": "最大消息数", + "app-version-description": "当前没有可用的更新", + "confirm-delete": "您确定要删除吗?", + "action-cannot-be-undone": "此操作无法撤销", + "name-optional": "名称 (选填)", + "deleting": "正在删除...", + "saving": "正在保存...", + "go-to-dashboard": "前往仪表板", + "need-setup-live-dashboard": "需要先设置串流吗?请访问直播仪表板", + "no-languages-found": "找不到语言", + "branding": "品牌", + "branding-admin": "品牌管理", + "branding-admin-description": "自定义您的 Streamplace 实例。请注意,设置可能需要几小时才能生效。", + "branding-login-required": "请登录以管理品牌", + "branding-configuration": "配置", + "branding-text-settings": "文字设置", + "branding-colors": "颜色", + "branding-legal-links": "法律链接", + "branding-images": "图像", + "branding-broadcaster-did": "主播 DID", + "branding-broadcaster-did-description": "留空以使用服务器默认值", + "branding-site-title": "网站标题", + "branding-site-title-placeholder": "输入新网站标题", + "branding-site-description": "网站描述", + "branding-site-description-placeholder": "输入网站描述", + "branding-default-streamer": "默认主播", + "branding-default-streamer-none": "无", + "branding-default-streamer-placeholder": "did:plc:...", + "branding-clear-default-streamer": "清除默认主播", + "branding-primary-color": "首要颜色", + "branding-primary-color-placeholder": "#6366f1", + "branding-accent-color": "强调色", + "branding-accent-color-placeholder": "#8b5cf6", + "branding-main-logo": "主标志", + "branding-main-logo-description": "SVG、PNG 或 JPEG (最大 500KB)", + "branding-favicon": "网站图标", + "branding-favicon-description": "SVG、PNG 或 ICO (最大 100KB)", + "branding-sidebar-bg": "侧边栏背景图片", + "branding-sidebar-bg-description": "SVG、PNG 或 JPEG (最大 500KB)——显示在侧边栏底部,全宽显示。为了获得最佳效果,请上传一张带有透明度的图片,因为目前没有单独的透明度选项。", + "branding-current": "当前:{$value}", + "branding-dimensions": "{$height} x {$width}", + "branding-upload-logo": "上传标志", + "branding-delete-logo": "删除标志", + "branding-upload-favicon": "上传网站图标", + "branding-delete-favicon": "删除网站图标", + "branding-upload-background": "上传背景", + "branding-delete-background": "删除背景", + "branding-web-only": "图像上传仅在网页端可用。", + "refresh-branding": "更新品牌资源", + "branding-add-legal-link": "添加法律链接", + "branding-edit-legal-link": "编辑法律链接", + "branding-legal-link-text-placeholder": "链接文本 (例如:隐私政策)", + "branding-legal-link-url-placeholder": "网址 (例如:https://example.com/privacy)", + "add": "添加", + "edit": "编辑", + "branding-not-authenticated": "请先登录", + "branding-empty-value": "请输入一个数值", + "branding-update-success": "{$key} 更新成功", + "branding-upload-success": "{$key} 上传成功", + "branding-delete-success": "{$key} 删除成功", + "branding-upload-failed": "上传失败", + "branding-delete-failed": "删除失败", + "branding-not-available": "文件上传仅在网页版上可用", + "node-legal-documents": "主播专属文档", + "backup-save": "保存备份设置", + "backup-saving": "正在保存备份设置...", + "backup-secret-key-set-placeholder": "(密码已设置)", + "backup-error-invalid-endpoint": "必须是有效的域名", + "backup-error-invalid-bucket": "必须只包含小写字母、数字、点和连字符", + "backup-error-invalid-segment-duration": "必须在 1 到 60 秒之间", + "backup-error-load-failed": "加载存储设置失败", + "backup-error-update-failed": "更新备份状态失败", + "backup-error-save-failed": "保存存储设置失败", + "backup-error-missing-secret": "没有秘密密钥时无法更新 S3 设置。请重新输入。", + "backup-segment-duration-placeholder": "6", + "badges": "徽章", + "badges-cosmetic-section": "装饰性徽章", + "badges-empty-state": "您还没有获得任何徽章。", + "badges-failed-load": "加载徽章失败", + "badges-failed-update": "更新徽章选择失败", + "badges-issued-by": "由 {$issuer} 颁发", + "badges-streamer-section": "主播徽章", + "issue-badges": "颁发徽章", + "issue-badges-back-to-definitions": "徽章定义", + "issue-badges-badge-name": "徽章名称", + "issue-badges-badge-name-placeholder": "例如 VIP、支持者", + "issue-badges-badge-type": "徽章类型", + "issue-badges-choose-image": "选择图片", + "issue-badges-create-definition": "创建徽章定义", + "issue-badges-create-definition-description": "定义可颁发给观众的新徽章类型", + "issue-badges-create-definition-subtitle": "定义新徽章类型", + "issue-badges-definition-created": "徽章定义已创建", + "issue-badges-description-optional": "描述(可选)", + "issue-badges-description-placeholder": "此徽章的可选描述", + "issue-badges-failed-create": "创建徽章定义失败", + "issue-badges-failed-issue": "颁发徽章失败", + "issue-badges-failed-load": "加载徽章定义失败", + "issue-badges-image-optional": "图片(可选)", + "issue-badges-image-too-large": "图片必须小于 256KB", + "issue-badges-image-web-only": "图片上传仅在网页版可用", + "issue-badges-issue-badge": "颁发徽章", + "issue-badges-issue-badge-description": "通过 DID 将\"{$name}\"徽章颁发给观众", + "issue-badges-issued": "徽章已颁发", + "issue-badges-issued-to": "已颁发给 {$did}", + "issue-badges-manage-description": "创建徽章定义并颁发给观众", + "issue-badges-recipient-did": "收件人 DID", + "issue-badges-recipient-did-placeholder": "did:plc:...", + "issue-badges-tap-to-issue": "点击颁发", + "issue-badges-your-definitions": "您的徽章定义" +} diff --git a/js/i18n/public/locales/zh-Hant/common.json b/js/i18n/public/locales/zh-Hant/common.json new file mode 100644 index 00000000..e1e1614f --- /dev/null +++ b/js/i18n/public/locales/zh-Hant/common.json @@ -0,0 +1,46 @@ +{ + "loading": "載入中...", + "error": "錯誤", + "cancel": "取消", + "confirm": "確認", + "close": "關閉", + "open": "開啟", + "ok": "確定", + "yes": "是", + "no": "否", + "continue": "繼續", + "back": "返回", + "next": "下一步", + "finish": "完成", + "save": "儲存", + "delete": "刪除", + "edit": "編輯", + "create": "建立", + "update": "更新", + "refresh": "重新整理", + "success": "成功", + "warning": "警告", + "info": "資訊", + "search-placeholder": "搜尋...", + "message-input": "輸入您的訊息...", + "please-log-in-to-access-this-page": "請登入以存取此頁面", + "go-to-settings": "前往設定", + "go-back": "返回", + "welcome-user": "歡迎,{$username}!", + "notification-count": "{$count ->\n [0] 無通知\n [1] 一則通知\n *[other] {$count} 則通知\n }", + "user-offline": "使用者離線", + "user-offline-message": "{$source ->\n [streamer] 看起來 <1>@{$handle} 離線 了,但他們推薦觀看:\n *[default] 看起來 <1>@{$handle} 離線 了,但我們推薦觀看:\n }", + "user-offline-no-recommendations": "看起來 <1>@{$handle} 離線 了。\n請稍後再來看看。", + "streaming-title": "正在直播 {$title}", + "viewer-count": "{$count ->\n [0] 0 位觀眾\n [1] 1 位觀眾\n *[other] {$count} 位觀眾\n }", + "pds-selector-title": "初次接觸 Atmosphere?", + "pds-selector-description": "您需要選擇一個 PDS(個人資料伺服器)來存取 Atmosphere 上的應用程式,例如 Bluesky、Tangled 和 Spark。", + "pds-selector-custom-label": "其他 PDS", + "pds-selector-custom-description": "輸入您自己的 PDS 主機網址", + "pds-selector-custom-url-label": "自訂 PDS 網址", + "pds-selector-custom-url-placeholder": "https://pds.example.com", + "pds-selector-learn-more": "了解更多關於自行架設的資訊", + "pds-selector-info": "每個主機都有自己的政策和可靠性標準。您的 ATProto 資料存放在您選擇的主機上,您可以之後進行遷移。注意:Streamplace 有自己的審核規則——無論您選擇哪個主機,都可能被 Streamplace 封禁。", + "pds-selector-read-policies": "在繼續之前,請閱讀 {$label} 的服務條款和隱私政策。", + "pds-selector-handle-policy-checkbox": "我已閱讀並同意識別碼政策" +} diff --git a/js/i18n/public/locales/zh-Hant/settings.json b/js/i18n/public/locales/zh-Hant/settings.json new file mode 100644 index 00000000..29fedd1e --- /dev/null +++ b/js/i18n/public/locales/zh-Hant/settings.json @@ -0,0 +1,246 @@ +{ + "app-version": "播家 v{$version}", + "download-new-update": "下載新更新", + "check-for-updates": "檢查更新", + "bundled-runtype": "捆綁版", + "ota-runtype": "空中下載 (OTA)", + "recovery-runtype": "復原模式", + "modal-latest-version": "您正在使用最新版本。", + "modal-no-update-available": "您已經在使用最新版本的直播地,太棒了!", + "modal-update-available-title": "有可用更新", + "modal-update-available-description": "新版本的直播地已準備好下載", + "modal-update-failed": "更新檢查失敗。您可能需要透過 {$store} 更新應用程式。", + "modal-update-failed-title": "更新失敗", + "modal-update-failed-description": "更新檢查失敗。您可能需要透過 {$store} 更新應用程式。", + "button-reload-app-on-update": "套用更新 (將重新載入應用程式)", + "use-custom-node": "使用自訂節點", + "default-url": "預設:{$url}", + "enter-custom-node-url": "輸入自訂節點網址", + "save-button": "儲存", + "language-selection": "語言", + "language-selection-description": "選擇您偏好的語言", + "help-translate": "幫助我們翻譯播家", + "help-translate-description": "我們正在尋找志願者協助將播家翻譯成更多語言。如果您有興趣,請在 Discord 或 GitHub 上與我們聯繫!", + "currently-translating": "翻譯正在進行中", + "currently-translating-description": "應用程式的某些部分可能看起來不完整。感謝您的耐心等待!", + "debug-recording-title": "允許 {$host} 錄製您的直播串流以進行除錯和服務改善", + "debug-recording-description": "可選項目", + "input-search-languages": "搜尋語言...", + "manage-keys": "管理金鑰", + "settings-title": "設定", + "loading": "載入中...", + "error": "錯誤", + "cancel": "取消", + "confirm": "確認", + "welcome-user": "歡迎,{$username}!", + "notification-count": "{$count ->\n [0] 無通知\n [1] 一則通知\n *[other] {$count} 則通知\n }", + "search-placeholder": "搜尋...", + "message-input": "請輸入您的訊息...", + "success": "成功", + "warning": "警告", + "info": "資訊", + "close": "關閉", + "open": "開啟", + "delete": "刪除", + "edit": "編輯", + "create": "建立", + "update": "更新", + "refresh": "重新整理", + "save": "儲存", + "cancel-button": "取消", + "ok": "確定", + "yes": "是", + "no": "否", + "continue": "繼續", + "back": "返回", + "next": "下一步", + "finish": "完成", + "about": "關於", + "account": "帳戶", + "advanced": "進階", + "danmu": "彈幕", + "developer": "開發者", + "languages": "語言", + "privacy-security": "隱私與安全", + "streaming": "串流", + "sign-in": "登入", + "log-out": "登出", + "account-greeting": "嗨,@{$handle}。", + "edit-profile-bluesky": "編輯個人資料(在 Bluesky)", + "change-name-color": "變更名稱顏色", + "key-management": "金鑰管理", + "key-manager": "金鑰管理器", + "your-stream-pubkeys": "您的串流公開金鑰", + "no-keys": "尚未設定金鑰", + "pubkey-description": "公開金鑰與串流金鑰(用於串流軟體)配對以簽署和驗證您的串流", + "keys-count": "{$count} 個金鑰", + "webhooks": "Webhooks", + "webhook-integrations": "Webhook 整合", + "webhook-integrations-description": "連接外部服務以即時接收有關您串流的更新", + "create-webhook": "建立 Webhook", + "edit-webhook": "編輯 Webhook", + "delete-webhook": "刪除 Webhook", + "no-webhooks-yet": "尚未設定 webhook", + "failed-load-webhooks": "載入 webhook 失敗", + "webhook-will-no-longer-receive-events": "此 webhook 將不再接收事件", + "create-first-webhook-description": "建立您的第一個 webhook 以開始接收串流事件", + "example-captain-hook": "虎克船長", + "webhooks-count": "{$count ->\n *[other] {$count} 個 webhook\n }", + "activates-on": "觸發於:", + "events-livestream": "直播串流事件", + "events-chat": "聊天事件", + "untitled-webhook": "未命名的 Webhook", + "inactive": "停用", + "debug-recording": "除錯錄製", + "danmu-enabled": "啟用彈幕", + "danmu-enabled-description": "將即時聊天訊息以浮動評論的形式顯示在您的螢幕上", + "danmu-opacity": "不透明度", + "danmu-speed": "速度", + "danmu-lane-count": "軌道數量", + "danmu-max-messages": "最大訊息數", + "app-version-description": "目前版本資訊", + "confirm-delete": "您確定要刪除嗎?", + "action-cannot-be-undone": "此動作無法復原", + "name-optional": "名稱(選填)", + "deleting": "刪除中...", + "saving": "儲存中...", + "go-to-dashboard": "前往控制台", + "need-setup-live-dashboard": "您需要先在控制台中設定直播串流", + "no-languages-found": "找不到語言", + "backup-save": "儲存備份設定", + "backup-saving": "正在儲存備份設定...", + "backup-secret-key-set-placeholder": "(密碼已設定)", + "backup-error-invalid-endpoint": "必須是有效的網域名稱", + "backup-error-invalid-bucket": "只能包含小寫字母、數字、點和連字號", + "backup-error-invalid-segment-duration": "必須介於 1 到 60 秒之間", + "backup-error-load-failed": "無法載入儲存設定", + "backup-error-update-failed": "更新備份狀態失敗", + "backup-error-save-failed": "儲存設定失敗", + "backup-error-missing-secret": "無法在沒有金鑰的情況下更新 S3 設定。請重新輸入。", + "backup-segment-duration-placeholder": "6", + "backup": "備份", + "backup-enabled": "S3 備份", + "backup-enabled-description": "自動將錄影備份到相容 S3 的儲存空間", + "backup-connection-url": "連線網址", + "backup-connection-url-placeholder": "s3+https://ACCESS_KEY:SECRET_KEY@s3.example.com/bucket", + "backup-endpoint": "端點", + "backup-endpoint-placeholder": "s3.example.com", + "backup-bucket": "儲存桶", + "backup-bucket-placeholder": "my-backup-bucket", + "backup-access-key": "存取金鑰", + "backup-access-key-placeholder": "AKIAIOSFODNN7EXAMPLE", + "backup-secret-key": "秘密金鑰", + "backup-secret-key-placeholder": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "show-password-in-url": "在網址中顯示密碼", + "show-password-in-url-description": "在連線網址中顯示秘密金鑰(若已輸入)", + "recommendations-to-others": "向他人推薦主播", + "recommendations-description": "向您的觀眾推薦最多 8 位主播", + "no-recommendations-yet": "尚未設定推薦", + "multistream": "多重串流", + "multistream-targets": "多重串流目標", + "multistream-description": "自動將您的 Streamplace 直播推送到 Twitch 或 YouTube 等其他直播平台。", + "create-multistream-target": "建立多重串流目標", + "untitled-multistream-target": "未命名目標", + "failed-load-multistream-targets": "載入多重串流目標失敗。請重試。", + "failed-toggle-multistream-target": "切換多重串流目標失敗。請重試。", + "no-multistream-targets-yet": "還沒有目標!", + "multistream-targets-count": "{$count} 個目標", + "multistream-delete-target-confirmation": "您確定要刪除「{$target}」嗎?", + "this-action-cannot-be-undone": "此動作無法復原。", + "rtmp-target-name": "RTMP 目標", + "rtmp-target-url": "RTMP 網址", + "rtmp-target-name-placeholder": "我的多重串流目標", + "multistream-create-target": "建立目標", + "multistream-edit-target": "編輯目標", + "created": "建立於", + "status": "狀態", + "branding": "品牌識別", + "branding-admin": "品牌識別管理", + "branding-admin-description": "自訂您的 Streamplace 執行個體。請注意,設定可能需要幾小時才能生效。", + "branding-login-required": "請登入以管理品牌識別", + "branding-configuration": "設定", + "branding-text-settings": "文字設定", + "branding-colors": "顏色", + "branding-legal-links": "法律連結", + "branding-images": "圖像", + "branding-broadcaster-did": "主播 DID", + "branding-broadcaster-did-description": "留空以使用伺服器預設值", + "branding-site-title": "網站標題", + "branding-site-title-placeholder": "輸入新網站標題", + "branding-site-description": "網站描述", + "branding-site-description-placeholder": "輸入網站描述", + "branding-default-streamer": "預設主播", + "branding-default-streamer-none": "無", + "branding-default-streamer-placeholder": "did:plc:...", + "branding-clear-default-streamer": "清除預設主播", + "branding-primary-color": "主要顏色", + "branding-primary-color-placeholder": "#6366f1", + "branding-accent-color": "強調色", + "branding-accent-color-placeholder": "#8b5cf6", + "branding-main-logo": "主標誌", + "branding-main-logo-description": "SVG、PNG 或 JPEG(最大 500KB)", + "branding-favicon": "網站圖示", + "branding-favicon-description": "SVG、PNG 或 ICO(最大 100KB)", + "branding-sidebar-bg": "側邊欄背景圖片", + "branding-sidebar-bg-description": "SVG、PNG 或 JPEG(最大 500KB)——顯示在側邊欄底部,全寬顯示。為了最佳效果,請上傳帶有透明度的圖片,因為目前沒有單獨的透明度選項。", + "branding-current": "目前:{$value}", + "branding-dimensions": "{$height} x {$width}", + "branding-upload-logo": "上傳標誌", + "branding-delete-logo": "刪除標誌", + "branding-upload-favicon": "上傳網站圖示", + "branding-delete-favicon": "刪除網站圖示", + "branding-upload-background": "上傳背景", + "branding-delete-background": "刪除背景", + "branding-web-only": "圖片上傳僅在網頁版可用。", + "refresh-branding": "更新品牌識別資源", + "branding-add-legal-link": "新增法律連結", + "branding-edit-legal-link": "編輯法律連結", + "branding-legal-link-text-placeholder": "連結文字(例如:隱私政策)", + "branding-legal-link-url-placeholder": "網址(例如:https://example.com/privacy)", + "add": "新增", + "active": "啟用", + "optional": "選填", + "branding-not-authenticated": "請先登入", + "branding-empty-value": "請輸入一個值", + "branding-update-success": "{$key} 更新成功", + "branding-upload-success": "{$key} 上傳成功", + "branding-delete-success": "{$key} 刪除成功", + "branding-upload-failed": "上傳失敗", + "branding-delete-failed": "刪除失敗", + "branding-not-available": "檔案上傳僅在網頁版可用", + "node-legal-documents": "主播專屬文件", + "badges": "徽章", + "badges-cosmetic-section": "裝飾性徽章", + "badges-empty-state": "您尚未獲得任何徽章。", + "badges-failed-load": "載入徽章失敗", + "badges-failed-update": "更新徽章選擇失敗", + "badges-issued-by": "由 {$issuer} 頒發", + "badges-streamer-section": "主播徽章", + "issue-badges": "頒發徽章", + "issue-badges-back-to-definitions": "徽章定義", + "issue-badges-badge-name": "徽章名稱", + "issue-badges-badge-name-placeholder": "例如 VIP、支持者", + "issue-badges-badge-type": "徽章類型", + "issue-badges-choose-image": "選擇圖片", + "issue-badges-create-definition": "建立徽章定義", + "issue-badges-create-definition-description": "定義可頒發給觀眾的新徽章類型", + "issue-badges-create-definition-subtitle": "定義新徽章類型", + "issue-badges-definition-created": "徽章定義已建立", + "issue-badges-description-optional": "描述(選填)", + "issue-badges-description-placeholder": "此徽章的選填描述", + "issue-badges-failed-create": "建立徽章定義失敗", + "issue-badges-failed-issue": "頒發徽章失敗", + "issue-badges-failed-load": "載入徽章定義失敗", + "issue-badges-image-optional": "圖片(選填)", + "issue-badges-image-too-large": "圖片必須小於 256KB", + "issue-badges-image-web-only": "圖片上傳僅在網頁版可用", + "issue-badges-issue-badge": "頒發徽章", + "issue-badges-issue-badge-description": "透過 DID 將「{$name}」徽章頒發給觀眾", + "issue-badges-issued": "徽章已頒發", + "issue-badges-issued-to": "已頒發給 {$did}", + "issue-badges-manage-description": "建立徽章定義並頒發給觀眾", + "issue-badges-recipient-did": "收件者 DID", + "issue-badges-recipient-did-placeholder": "did:plc:...", + "issue-badges-tap-to-issue": "點擊頒發", + "issue-badges-your-definitions": "您的徽章定義" +} diff --git a/js/components/scripts/compile-translations.js b/js/i18n/scripts/compile-translations.js similarity index 88% rename from js/components/scripts/compile-translations.js rename to js/i18n/scripts/compile-translations.js index 36591949..3814253c 100644 --- a/js/components/scripts/compile-translations.js +++ b/js/i18n/scripts/compile-translations.js @@ -219,36 +219,33 @@ function compileTranslations() { } /** - * Copy compiled translations to app/public/locales + * Copy compiled translations to app/public/locales and web/public/locales */ -function copyToApp() { - const appPublicLocales = path.join( - __dirname, - "..", - "..", - "app", - "public", - "locales", - ); - - console.log("\n📋 Copying translations to app..."); +function copyToConsumers() { + const targets = [ + path.join(__dirname, "..", "..", "app", "public", "locales"), + path.join(__dirname, "..", "..", "web", "public", "locales"), + ]; + + for (const target of targets) { + const targetDir = path.dirname(target); + if (!fs.existsSync(targetDir)) continue; + + console.log( + `\n📋 Copying translations to ${path.relative(process.cwd(), target)}...`, + ); + + if (fs.existsSync(target)) { + fs.rmSync(target, { recursive: true, force: true }); + } - // Remove old locales directory in app - if (fs.existsSync(appPublicLocales)) { - fs.rmSync(appPublicLocales, { recursive: true, force: true }); + fs.cpSync(LOCALES_OUTPUT_DIR, target, { recursive: true }); + console.log(`✅ Copied to ${path.relative(process.cwd(), target)}`); } - - // Copy compiled locales to app - fs.cpSync(LOCALES_OUTPUT_DIR, appPublicLocales, { recursive: true }); - - console.log(`✅ Copied to ${path.relative(process.cwd(), appPublicLocales)}`); } // Run the compilation compileTranslations(); -// Copy to app if it exists -const appPath = path.join(__dirname, "..", "..", "app"); -if (fs.existsSync(appPath)) { - copyToApp(); -} +// Copy to consumers if they exist +copyToConsumers(); diff --git a/js/components/scripts/migrate-i18n.js b/js/i18n/scripts/migrate-i18n.js similarity index 100% rename from js/components/scripts/migrate-i18n.js rename to js/i18n/scripts/migrate-i18n.js diff --git a/js/i18n/src/config.ts b/js/i18n/src/config.ts new file mode 100644 index 00000000..efa6586e --- /dev/null +++ b/js/i18n/src/config.ts @@ -0,0 +1,75 @@ +/** + * Base i18next configuration — no React plugin, no platform-specific loaders. + * + * Consumers (js/components, js/web) should: + * 1. Call createI18nextConfig() to get the base config + * 2. Add their own plugins (initReactI18next, Fluent, resourcesToBackend) + * 3. Wire up platform-specific translation loaders + * 4. Call i18next.init(config) + */ + +import manifest from "../locales/manifest.json"; +import { getFallbackChain } from "./locale"; + +/** Storage key for persisting the user's locale preference. */ +export const STORAGE_KEY = "@streamplace/locale"; + +/** i18next namespace names. */ +export const NAMESPACES = ["common", "settings"] as const; + +/** Default namespace used when none is specified. */ +export const DEFAULT_NAMESPACE = "common"; + +export interface I18nextConfigOptions { + /** Override the initial language. Defaults to the manifest fallback (en-US). */ + lng?: string; + /** Enable i18next debug logging. Defaults to NODE_ENV === "development". */ + debug?: boolean; + /** Any additional i18next init options (merged last, can override). */ + [key: string]: any; +} + +/** + * Create a base i18next configuration object. + * + * The returned object is ready for `i18next.init()` after the consumer + * adds their own plugins (React, Fluent, loaders, etc.). + */ +export function createI18nextConfig(options?: I18nextConfigOptions) { + const { lng, debug, ...rest } = options ?? {}; + + return { + lng: lng ?? manifest.fallbackChain[0], + ns: [...NAMESPACES], + defaultNS: DEFAULT_NAMESPACE, + interpolation: { + escapeValue: false, // React already escapes + }, + react: { + useSuspense: false, // Prevent Android crashes + }, + i18nFormat: { + fluentBundleOptions: { + useIsolating: false, + functions: { + VOWORCON: ([txt]: [string]) => + "aeiou".indexOf(txt[0].toLowerCase()) >= 0 ? "vow" : "con", + JOIN: (args: string[], opts: { separator?: string } = {}) => + args + .filter(Boolean) + .filter((s) => typeof s === "string") + .join(opts.separator || ""), + }, + }, + }, + load: "currentOnly", + cleanCode: true, + fallbackLng: getFallbackChain, + supportedLngs: [...manifest.supportedLocales], + debug: + debug ?? + (typeof process !== "undefined" && + process.env?.NODE_ENV === "development"), + ...rest, + }; +} diff --git a/js/i18n/src/index.ts b/js/i18n/src/index.ts new file mode 100644 index 00000000..3c8caf41 --- /dev/null +++ b/js/i18n/src/index.ts @@ -0,0 +1,53 @@ +/** + * @streamplace/i18n — platform-agnostic i18n data, config, and utilities. + * + * This package contains: + * - Locale manifest and metadata + * - Fluent (.ftl) source files and compiled JSON translations + * - Pure locale detection / fallback utilities + * - Base i18next configuration factory + * + * It does NOT contain React hooks, providers, or platform-specific loaders. + * Those live in @streamplace/components (React Native) or the web app. + */ + +// Manifest data +export { default as manifest } from "../locales/manifest.json"; + +// Locale utilities +export { + cleanLocaleName, + getFallbackChain, + getLanguageInfo, + getLocaleFromSystemLocale, + getSupportedLocales, + isLocaleSupported, +} from "./locale"; +export type { LanguageInfo } from "./locale"; + +// Config factory +export { + DEFAULT_NAMESPACE, + NAMESPACES, + STORAGE_KEY, + createI18nextConfig, +} from "./config"; +export type { I18nextConfigOptions } from "./config"; + +// Bundled locale resources (for native/SSR loaders) +export { + enUSCommon, + enUSSettings, + esESCommon, + esESSettings, + frFRCommon, + frFRSettings, + ptBRCommon, + ptBRSettings, + roROCommon, + roROSettings, + zhHansCommon, + zhHansSettings, + zhHantCommon, + zhHantSettings, +} from "./resources"; diff --git a/js/i18n/src/locale.ts b/js/i18n/src/locale.ts new file mode 100644 index 00000000..6e344ca6 --- /dev/null +++ b/js/i18n/src/locale.ts @@ -0,0 +1,98 @@ +/** + * Platform-agnostic locale utilities. + * No React, no platform-specific APIs — pure data + logic. + */ + +import manifest from "../locales/manifest.json"; + +export interface LanguageInfo { + code: string; + name: string; + nativeName: string; + flag: string; +} + +/** + * Normalize a locale string: underscores → dashes, strip @ modifiers. + */ +export function cleanLocaleName(locale: string): string { + return locale.replace("_", "-").replace(/@.*/, ""); +} + +/** + * Build a fallback chain for a given locale code. + * Regional variants (e.g. pt-PT) fall back to their base locale (pt-BR), + * then to the manifest's fallback chain (typically en-US). + */ +export function getFallbackChain(code: string): string[] { + const fallbacks: string[] = []; + + if (!code) return [...manifest.fallbackChain]; + + if (code.match(/^es-/)) { + fallbacks.push("es-ES"); + } else if (code.match(/^fr-/)) { + fallbacks.push("fr-FR"); + } else if (code.match(/^pt-/)) { + fallbacks.push("pt-BR"); + } else if (code.match(/^zh-/)) { + fallbacks.push("zh-Hant"); + } + + return [...fallbacks, ...manifest.fallbackChain]; +} + +/** + * Get all supported locale codes from the manifest. + */ +export function getSupportedLocales(): string[] { + return [...manifest.supportedLocales]; +} + +/** + * Get language metadata for a locale code, or null if unsupported. + */ +export function getLanguageInfo(locale: string): LanguageInfo | null { + return (manifest.languages as Record)[locale] || null; +} + +/** + * Check whether a locale code is in the supported set. + */ +export function isLocaleSupported(locale: string): boolean { + return manifest.supportedLocales.includes(locale); +} + +/** + * Detect the best-matching locale from a system locale string. + * + * Platform-agnostic: passes through the `override` if provided, + * otherwise falls back to `navigator.language` (web) or "en". + * + * For React Native with expo-localization, call with the detected locale: + * getLocaleFromSystemLocale(deviceLocale) + */ +export function getLocaleFromSystemLocale(override?: string): string { + let systemLocale = override || "en"; + + if (!override && typeof navigator !== "undefined" && navigator.language) { + systemLocale = navigator.language; + } + + const candidate = cleanLocaleName(systemLocale); + + // Exact match (e.g. "en-US") + if (manifest.supportedLocales.includes(candidate)) { + return candidate; + } + + // Language-prefix match (e.g. "en-GB" → "en-US") + const lang = candidate.split("-")[0]; + const matching = manifest.supportedLocales.find((l) => + l.startsWith(lang + "-"), + ); + if (matching) return matching; + + // Fall back to default + return manifest.fallbackChain[0]; +} diff --git a/js/i18n/src/resources.ts b/js/i18n/src/resources.ts new file mode 100644 index 00000000..5917d52e --- /dev/null +++ b/js/i18n/src/resources.ts @@ -0,0 +1,37 @@ +/** + * Bundled locale resources for native/SSR consumption. + * + * React Native (Metro) statically imports JSON so it's bundled into the app. + * For web, use the HTTP loader with the JSON files served from public/locales/. + * + * This module re-exports every compiled translation JSON so that + * `@streamplace/i18n/resources` can be imported by platform-specific loaders. + */ + +// en-US +export { default as enUSCommon } from "../public/locales/en-US/common.json"; +export { default as enUSSettings } from "../public/locales/en-US/settings.json"; + +// es-ES +export { default as esESCommon } from "../public/locales/es-ES/common.json"; +export { default as esESSettings } from "../public/locales/es-ES/settings.json"; + +// fr-FR +export { default as frFRCommon } from "../public/locales/fr-FR/common.json"; +export { default as frFRSettings } from "../public/locales/fr-FR/settings.json"; + +// pt-BR +export { default as ptBRCommon } from "../public/locales/pt-BR/common.json"; +export { default as ptBRSettings } from "../public/locales/pt-BR/settings.json"; + +// ro-RO +export { default as roROCommon } from "../public/locales/ro-RO/common.json"; +export { default as roROSettings } from "../public/locales/ro-RO/settings.json"; + +// zh-Hans +export { default as zhHansCommon } from "../public/locales/zh-Hans/common.json"; +export { default as zhHansSettings } from "../public/locales/zh-Hans/settings.json"; + +// zh-Hant +export { default as zhHantCommon } from "../public/locales/zh-Hant/common.json"; +export { default as zhHantSettings } from "../public/locales/zh-Hant/settings.json"; diff --git a/js/i18n/tsconfig.json b/js/i18n/tsconfig.json new file mode 100644 index 00000000..88084fe9 --- /dev/null +++ b/js/i18n/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../app/tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist", + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["./src", "./locales"] +} -- 2.51.2