From 88a51f45cbfd82af6ae9750d0c5ba760ba349ac6 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sun, 17 May 2026 16:38:18 +0800 Subject: [PATCH] feat(hooks): add AI chat and project presence hooks --- src/hooks/useAiChatQuery.ts | 17 +- src/hooks/useAuth.ts | 7 +- src/hooks/useIssueDetailQuery.ts | 6 +- src/hooks/useProjectPresenceQuery.ts | 15 +- src/hooks/useRepoDetailQuery.ts | 4 +- src/hooks/useRoomsQuery.ts | 34 +- src/hooks/useSkillsQuery.ts | 5 + src/hooks/useUserQuery.ts | 20 +- src/i18n/T.tsx | 203 ++++++ src/i18n/de.json | 831 +++++++++++++++++++++++ src/i18n/en.json | 977 +++++++++++++++++++++++++++ src/i18n/fr.json | 831 +++++++++++++++++++++++ src/i18n/jp.json | 831 +++++++++++++++++++++++ src/i18n/zh.json | 854 +++++++++++++++++++++++ src/lib/ir/parser.ts | 53 +- src/lib/ir/renderer.tsx | 189 +++--- src/lib/ir/sanitize.ts | 4 +- src/lib/ir/types.ts | 8 +- src/lib/utils.ts | 55 +- src/store/streaming.ts | 5 +- src/ws/bridge.ts | 204 +++--- src/ws/client.ts | 138 ++-- src/ws/hooks.ts | 301 ++++----- src/ws/index.ts | 91 ++- src/ws/store.ts | 88 +-- src/ws/subscription.ts | 106 +-- 26 files changed, 5269 insertions(+), 608 deletions(-) diff --git a/src/hooks/useAiChatQuery.ts b/src/hooks/useAiChatQuery.ts index 57e7427..1be7ec7 100644 --- a/src/hooks/useAiChatQuery.ts +++ b/src/hooks/useAiChatQuery.ts @@ -127,12 +127,13 @@ export function useChatStreamRunner(setIsStreaming?: (value: boolean) => void) { const queryClient = useQueryClient(); const streamingStore = useStreamingStore(); - return async (conversationId: string, messageId: string) => { + return async (conversationId: string, messageId: string, signal?: AbortSignal) => { streamingStore.clear(conversationId); setIsStreaming?.(true); let clearOnFinish = false; try { for await (const chunk of streamChat(conversationId, messageId)) { + if (signal?.aborted) break; if (chunk.type === "token") { streamingStore.append(conversationId, "token", String(chunk.data || ""), messageId); } else if (chunk.type === "thinking") { @@ -143,14 +144,22 @@ export function useChatStreamRunner(setIsStreaming?: (value: boolean) => void) { content: chunk.data?.display || chunk.data?.tool || "", toolName: chunk.data?.tool || "unknown", toolArgs: chunk.data?.args || {}, + children_id: chunk.children_id, }, messageId); } else if (chunk.type === "tool_result") { + const isSubAgent = chunk.data?.tool === "call_sub_agent"; + const toolName = chunk.data?.tool || "unknown"; + // For call_sub_agent, use the display field from metadata + const content = isSubAgent + ? (chunk.data?.display || "") + : (chunk.data?.result || chunk.data?.display || ""); streamingStore.addToolPart(conversationId, { type: "tool_result", - content: String(chunk.data?.result || chunk.data?.display || ""), - toolName: chunk.data?.tool || "unknown", - toolArgs: {}, + content, + toolName, toolStatus: chunk.data?.status || "ok", + children_id: chunk.children_id, + ...(isSubAgent && { subAgentOutput: chunk.data?.output, toolArgs: { role: chunk.data?.role, task: chunk.data?.task } }), }, messageId); } else if (chunk.type === "title") { queryClient.invalidateQueries({ queryKey: [CONVERSATIONS_KEY, conversationId] }); diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 6ce55df..5c3dfcb 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -1,6 +1,7 @@ import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"; import { apiAuthMe, apiAuthLogin, apiAuthLogout } from "@/client/api"; import type { ContextMe, LoginParams } from "@/client/model"; +import { applyLocalePreference } from "@/i18n/T"; const QUERY_KEY = "auth"; @@ -9,7 +10,11 @@ export function useCurrentUserQuery() { queryKey: [QUERY_KEY, "me"], queryFn: async (): Promise => { const res = await apiAuthMe(); - return (res.data?.data as ContextMe) ?? null; + const user = (res.data?.data as ContextMe) ?? null; + if (user?.language) { + applyLocalePreference(user.language); + } + return user; }, staleTime: 5 * 60 * 1000, // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/hooks/useIssueDetailQuery.ts b/src/hooks/useIssueDetailQuery.ts index 429016d..120d95e 100644 --- a/src/hooks/useIssueDetailQuery.ts +++ b/src/hooks/useIssueDetailQuery.ts @@ -28,7 +28,7 @@ export function useIssueDetailQuery({ queryKey: [ISSUE_DETAIL_QUERY_KEY, projectName, issueNumber], queryFn: async (): Promise => { const res = await issueGet(projectName, issueNumber); - return res.data.data!; + return res.data.data ?? null; }, enabled: !!projectName && !!issueNumber, }); @@ -59,7 +59,7 @@ export function useCreateCommentMutation() { }: IssueDetailParams & { body: string }) => { const req: IssueCommentCreateRequest = { body }; const res = await issueCommentCreate(projectName, issueNumber, req); - return res.data.data!; + return res.data.data ?? null; }, onSuccess: (_, variables) => { queryClient.invalidateQueries({ @@ -81,7 +81,7 @@ export function useUpdateCommentMutation() { }: IssueDetailParams & { commentId: number; body: string }) => { const req: IssueCommentUpdateRequest = { body }; const res = await issueCommentUpdate(projectName, issueNumber, commentId, req); - return res.data.data!; + return res.data.data ?? null; }, onSuccess: (_, variables) => { queryClient.invalidateQueries({ diff --git a/src/hooks/useProjectPresenceQuery.ts b/src/hooks/useProjectPresenceQuery.ts index 542930b..83087a4 100644 --- a/src/hooks/useProjectPresenceQuery.ts +++ b/src/hooks/useProjectPresenceQuery.ts @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { projectPresence } from "@/client/api"; import type { PresenceChanged, PresenceStatus } from "@/client/model"; @@ -35,13 +36,15 @@ export function useProjectPresenceQuery(projectId: string | undefined) { */ export function useProjectPresenceMap(projectId: string | undefined) { const query = useProjectPresenceQuery(projectId); - const presenceMap = new Map(); - - if (query.data) { - for (const p of query.data) { - presenceMap.set(p.user_id, p.effectiveStatus); + const presenceMap = useMemo(() => { + const map = new Map(); + if (query.data) { + for (const p of query.data) { + map.set(p.user_id, p.effectiveStatus); + } } - } + return map; + }, [query.data]); return { presenceMap, isLoading: query.isLoading, error: query.error }; } diff --git a/src/hooks/useRepoDetailQuery.ts b/src/hooks/useRepoDetailQuery.ts index 9d2fbf7..7202638 100644 --- a/src/hooks/useRepoDetailQuery.ts +++ b/src/hooks/useRepoDetailQuery.ts @@ -33,7 +33,9 @@ export function useRepoDetailQuery({ namespace, repo }: RepoParams) { return useQuery({ queryKey: [REPO_DETAIL_QUERY_KEY, namespace, repo], queryFn: async () => { - return { namespace, repo }; + const res = await projectRepos(namespace); + const items = res.data.data?.items ?? []; + return items.find((item) => item.repo_name === repo) ?? null; }, enabled: !!namespace && !!repo, }); diff --git a/src/hooks/useRoomsQuery.ts b/src/hooks/useRoomsQuery.ts index 878a9fa..60279a1 100644 --- a/src/hooks/useRoomsQuery.ts +++ b/src/hooks/useRoomsQuery.ts @@ -1,8 +1,8 @@ import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"; -import { roomList, categoryList, roomCreate, categoryCreate } from "@/client/api"; +import { roomList, categoryList, roomCreate, categoryCreate, categoryDelete, roomUpdate } from "@/client/api"; import type { RoomResponse, RoomCategoryResponse, RoomCreateRequest, RoomCategoryCreateRequest } from "@/client/model"; -const QUERY_KEY = "rooms"; +export const ROOMS_QUERY_KEY = "rooms"; export interface RoomWithState extends RoomResponse { isMuted?: boolean; @@ -18,7 +18,7 @@ export interface RoomCategory { export function useRoomsQuery(projectName: string | undefined) { return useQuery({ - queryKey: [QUERY_KEY, projectName], + queryKey: [ROOMS_QUERY_KEY, projectName], queryFn: async (): Promise<{ rooms: RoomWithState[]; categories: RoomCategory[] }> => { if (!projectName) return { rooms: [], categories: [] }; const [roomsRes, categoriesRes] = await Promise.all([ @@ -47,7 +47,7 @@ export function useRoomsQuery(projectName: string | undefined) { export function useInvalidateRooms() { const queryClient = useQueryClient(); return (projectName: string) => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, projectName] }); + queryClient.invalidateQueries({ queryKey: [ROOMS_QUERY_KEY, projectName] }); }; } @@ -80,3 +80,29 @@ export function useCreateCategoryMutation(projectName: string | undefined) { }, }); } + +export function useMoveRoomMutation(projectName: string | undefined) { + const invalidate = useInvalidateRooms(); + + return useMutation({ + mutationFn: async ({ roomId, category }: { roomId: string; category: string | null }) => { + await roomUpdate(roomId, { category }); + }, + onSuccess: () => { + if (projectName) invalidate(projectName); + }, + }); +} + +export function useDeleteCategoryMutation(projectName: string | undefined) { + const invalidate = useInvalidateRooms(); + + return useMutation({ + mutationFn: async (categoryId: string) => { + await categoryDelete(categoryId); + }, + onSuccess: () => { + if (projectName) invalidate(projectName); + }, + }); +} diff --git a/src/hooks/useSkillsQuery.ts b/src/hooks/useSkillsQuery.ts index 67d5681..9484b6b 100644 --- a/src/hooks/useSkillsQuery.ts +++ b/src/hooks/useSkillsQuery.ts @@ -1,6 +1,7 @@ import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"; import { skillList, skillGet, skillCreate, skillUpdate, skillDelete, skillScan } from "@/client/api"; import type { SkillResponse, CreateSkillRequest, UpdateSkillRequest } from "@/client/model"; +import { toast } from "sonner"; const QUERY_KEY = "skills"; @@ -92,6 +93,10 @@ export function useScanSkillsMutation(projectName: string | undefined) { }, onSuccess: () => { if (projectName) invalidate(projectName); + toast.success("Skill scan completed"); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Skill scan failed"); }, }); } diff --git a/src/hooks/useUserQuery.ts b/src/hooks/useUserQuery.ts index a1acf86..564be8e 100644 --- a/src/hooks/useUserQuery.ts +++ b/src/hooks/useUserQuery.ts @@ -103,7 +103,8 @@ any)?.data?.count ?? 0; }); } -export function useUserActivityQuery(username: string, page = 1, perPage = 20) { +export function useUserActivityQuery(username: string, page = 1, perPage = 20, opts?: { enabled?: boolean }) { + const enabled = opts?.enabled !== undefined ? opts.enabled && !!username : !!username; return useQuery({ queryKey: [USER_QUERY_KEY, username, "activity", { page, perPage }], queryFn: async (): Promise<{ items: UserActivityItem[]; total: number }> => { @@ -114,19 +115,20 @@ export function useUserActivityQuery(username: string, page = 1, perPage = 20) { total: Number(res.data?.data?.total ?? 0), }; }, - enabled: !!username, + enabled, staleTime: 2 * 60 * 1000, }); } -export function useUserStarsQuery(username: string) { +export function useUserStarsQuery(username: string, opts?: { enabled?: boolean }) { + const enabled = opts?.enabled !== undefined ? opts.enabled && !!username : !!username; return useQuery({ queryKey: [USER_QUERY_KEY, username, "stars"], queryFn: async (): Promise => { const res = await getUserStars(username); return res.data?.data ?? null; }, - enabled: !!username, + enabled, staleTime: 5 * 60 * 1000, }); } @@ -175,26 +177,28 @@ export function useUserHeatmapQuery(username: string, startDate?: string, endDat }); } -export function useUserFollowersQuery(username: string) { +export function useUserFollowersQuery(username: string, opts?: { enabled?: boolean }) { + const enabled = opts?.enabled !== undefined ? opts.enabled && !!username : !!username; return useQuery({ queryKey: [USER_QUERY_KEY, username, "followers"], queryFn: async (): Promise => { const res = await getSubscribers(username); return res.data?.data ?? []; }, - enabled: !!username, + enabled, staleTime: 5 * 60 * 1000, }); } -export function useUserFollowingQuery(username: string) { +export function useUserFollowingQuery(username: string, opts?: { enabled?: boolean }) { + const enabled = opts?.enabled !== undefined ? opts.enabled && !!username : !!username; return useQuery({ queryKey: [USER_QUERY_KEY, username, "following"], queryFn: async (): Promise => { const res = await getFollowingList(username); return res.data?.data ?? []; }, - enabled: !!username, + enabled, staleTime: 5 * 60 * 1000, }); } diff --git a/src/i18n/T.tsx b/src/i18n/T.tsx index e69de29..55a2cec 100644 --- a/src/i18n/T.tsx +++ b/src/i18n/T.tsx @@ -0,0 +1,203 @@ +import {useMemo, useSyncExternalStore} from "react"; +import en from "./en.json"; +import zh from "./zh.json"; +import jp from "./jp.json"; +import fr from "./fr.json"; +import de from "./de.json"; + +export type Locale = "en" | "zh" | "jp" | "fr" | "de"; +export type TranslationParams = Record; +type TranslationDictionary = { + [key: string]: string | TranslationDictionary; +}; + +const STORAGE_KEY = "locale"; +const DEFAULT_LOCALE: Locale = "en"; + +const translations: Record = { + en: en as TranslationDictionary, + zh: zh as TranslationDictionary, + jp: jp as TranslationDictionary, + fr: fr as TranslationDictionary, + de: de as TranslationDictionary, +}; + +const localeAliases: Record = { + cn: "zh", + "zh-cn": "zh", + "zh-hans": "zh", + "zh-sg": "zh", + ja: "jp", + "ja-jp": "jp", + jp: "jp", +}; + +export const locales: { value: Locale; label: string }[] = [ + {value: "en", label: "English"}, + {value: "zh", label: "\u7b80\u4f53\u4e2d\u6587"}, + {value: "jp", label: "\u65e5\u672c\u8a9e"}, + {value: "fr", label: "Fran\u00e7ais"}, + {value: "de", label: "Deutsch"}, +]; + +const listeners = new Set<() => void>(); + +function canUseBrowserApis(): boolean { + return typeof window !== "undefined" && typeof document !== "undefined"; +} + +function getNestedValue(obj: unknown, path: string): string | undefined { + const keys = path.split("."); + let value: unknown = obj; + + for (const key of keys) { + if (value && typeof value === "object" && key in value) { + value = (value as Record)[key]; + } else { + return undefined; + } + } + + return typeof value === "string" ? value : undefined; +} + + +export function normalizeLocale(value: unknown): Locale | null { + if (typeof value !== "string") return null; + + const normalized = value.trim().toLowerCase().replaceAll("_", "-"); + if (!normalized) return null; + if (normalized in translations) return normalized as Locale; + if (normalized in localeAliases) return localeAliases[normalized]; + + const language = normalized.split("-")[0]; + if (language in translations) return language as Locale; + if (language in localeAliases) return localeAliases[language]; + + return null; +} + +function getBrowserLocale(): Locale { + const candidates = navigator.languages?.length + ? navigator.languages + : [navigator.language]; + + for (const candidate of candidates) { + const locale = normalizeLocale(candidate); + if (locale) return locale; + } + + return DEFAULT_LOCALE; +} + +function getInitialLocale(): Locale { + if (!canUseBrowserApis()) return DEFAULT_LOCALE; + + try { + const stored = normalizeLocale(window.localStorage.getItem(STORAGE_KEY)); + if (stored) return stored; + } catch { + // Storage unavailable — fall through to browser locale. + } + + return getBrowserLocale(); +} + + +let currentLocale: Locale = getInitialLocale(); + +function emitLocaleChange(): void { + for (const listener of listeners) listener(); +} + +export function getCurrentLocale(): Locale { + return currentLocale; +} + +export function getLocaleFromStorage(): Locale { + if (!canUseBrowserApis()) return currentLocale; + try { + return normalizeLocale(window.localStorage.getItem(STORAGE_KEY)) ?? currentLocale; + } catch { + return currentLocale; + } +} + +export function setLocale(locale: Locale | string): Locale { + const nextLocale = normalizeLocale(locale) ?? DEFAULT_LOCALE; + + if (canUseBrowserApis()) { + document.documentElement.lang = nextLocale; + try { + window.localStorage.setItem(STORAGE_KEY, nextLocale); + } catch { + // Locale still updates in memory when storage is unavailable. + } + } + + if (currentLocale !== nextLocale) { + currentLocale = nextLocale; + emitLocaleChange(); + } + + return nextLocale; +} + +export function applyLocalePreference(locale: unknown): Locale { + const nextLocale = normalizeLocale(locale); + return nextLocale ? setLocale(nextLocale) : currentLocale; +} + +export function subscribeLocaleChange(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +// ─── Translation ─────────────────────────────────────────────────────────── + +// Single regex replaces all ${key} placeholders in one pass, +// avoiding repeated RegExp compilation and multiple string traversals. +const PARAM_PATTERN = /\$\{(\w+)\}/g; + +export function createT(locale?: Locale) { + return function translate(key: string, params?: TranslationParams): string { + let text = + getNestedValue(translations[locale ?? currentLocale], key) ?? + getNestedValue(translations[DEFAULT_LOCALE], key) ?? + key; + + if (params) { + text = text.replace(PARAM_PATTERN, (_, k) => String(params[k] ?? "")); + } + + return text; + }; +} + +export const t = createT(); + + +export function useLocale(): Locale { + return useSyncExternalStore(subscribeLocaleChange, getCurrentLocale, () => DEFAULT_LOCALE); +} + +export function useT() { + const locale = useLocale(); + return useMemo(() => createT(locale), [locale]); +} + +export {translations}; + + +if (canUseBrowserApis()) { + document.documentElement.lang = currentLocale; + window.addEventListener("storage", (event) => { + if (event.key !== STORAGE_KEY) return; + const nextLocale = normalizeLocale(event.newValue) ?? getBrowserLocale(); + if (currentLocale === nextLocale) return; + + currentLocale = nextLocale; + document.documentElement.lang = nextLocale; + emitLocaleChange(); + }); +} \ No newline at end of file diff --git a/src/i18n/de.json b/src/i18n/de.json index e69de29..1967ded 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -0,0 +1,831 @@ +{ + "auth": { + "login": { + "title": "Willkommen zurück!", + "subtitle": "Wir freuen uns, Sie wiederzusehen!", + "account": "Konto", + "account_required": "Bitte Benutzernamen eingeben", + "account_placeholder": "Benutzername oder E-Mail", + "password": "Passwort", + "password_required": "Bitte Passwort eingeben", + "verification": "Verifizierung", + "captcha_placeholder": "Code eingeben", + "captcha_required": "Bitte Code eingeben", + "captcha_title": "Zum Aktualisieren klicken", + "2fa_code": "2FA-Code", + "2fa_placeholder": "6-stelliger Code", + "forgot_password": "Passwort vergessen?", + "submit": "Anmelden", + "submit_loading": "Anmeldung läuft...", + "need_account": "Noch kein Konto?", + "register": "Registrieren", + "error": { + "failed_to_load_captcha": "Captcha konnte nicht geladen werden", + "two_factor_required": "Zwei-Faktor-Authentifizierung erforderlich", + "invalid_credentials": "Ungültiger Benutzername oder Passwort", + "login_failed": "Anmeldung fehlgeschlagen" + } + }, + "register": { + "title": "Konto erstellen", + "subtitle": "Registrieren Sie sich noch heute!", + "username": "Benutzername", + "username_placeholder": "Benutzernamen wählen", + "username_required": "Benutzername ist erforderlich", + "username_min_length": "Benutzername muss mindestens 3 Zeichen lang sein", + "username_pattern": "Benutzername darf nur Buchstaben, Zahlen, _ und - enthalten", + "email": "E-Mail", + "email_placeholder": "E-Mail eingeben", + "email_required": "E-Mail ist erforderlich", + "email_invalid": "Ungültige E-Mail-Adresse", + "password_placeholder": "Passwort erstellen", + "confirm_password": "Passwort bestätigen", + "confirm_password_placeholder": "Passwort bestätigen", + "confirm_password_required": "Bitte bestätigen Sie Ihr Passwort", + "password_min_length": "Passwort muss mindestens 8 Zeichen lang sein", + "captcha_placeholder": "Code eingeben", + "submit": "Konto erstellen", + "submit_loading": "Wird erstellt...", + "already_have_account": "Sie haben bereits ein Konto?", + "login": "Anmelden", + "passwords_not_match": "Passwörter stimmen nicht überein", + "user_exists": "Benutzername oder E-Mail existiert bereits", + "registration_failed": "Registrierung fehlgeschlagen" + }, + "forgot_password": { + "email_placeholder": "E-Mail eingeben", + "captcha_placeholder": "Code eingeben", + "submit": "Weiter", + "back_to_login": "Zurück zur Anmeldung" + }, + "reset_password": { + "new_password_placeholder": "Neues Passwort eingeben", + "confirm_password_placeholder": "Neues Passwort bestätigen", + "captcha_placeholder": "Code eingeben", + "submit": "Passwort zurücksetzen", + "back_to_login": "Zurück zur Anmeldung" + }, + "change_password": { + "title": "Passwort ändern", + "subtitle": "Passwort Ihres Kontos aktualisieren", + "current_password_placeholder": "Aktuelles Passwort eingeben", + "new_password_placeholder": "Neues Passwort eingeben", + "confirm_password_placeholder": "Neues Passwort bestätigen", + "captcha_placeholder": "Code eingeben", + "submit": "Passwort aktualisieren", + "back_to_login": "Zurück zur Anmeldung" + }, + "two_factor": { + "title": "Zwei-Faktor-Authentifizierung", + "enabled": "2FA ist derzeit aktiviert", + "disabled": "Eine zusätzliche Sicherheitsebene hinzufügen", + "description": "Die Zwei-Faktor-Authentifizierung fügt eine zusätzliche Sicherheitsebene hinzu, indem mehr als nur ein Passwort für die Anmeldung erforderlich ist.", + "enabled_message": "Die Zwei-Faktor-Authentifizierung ist derzeit für Ihr Konto aktiviert.", + "scan_qr_code": "QR-Code scannen", + "or_enter_manually": "Oder diesen Code manuell eingeben", + "verification_code": "Verifizierungscode", + "code_required": "Code ist erforderlich", + "code_placeholder": "6-stelligen Code eingeben", + "password_placeholder": "Passwort eingeben", + "submit": "Verifizieren", + "cancel": "Abbrechen", + "back": "Zurück", + "enable": "2FA aktivieren", + "disable": "2FA deaktivieren", + "disabling": "Deaktivierung...", + "error": { + "load_failed": "Fehler beim Laden des 2FA-Status", + "enable_failed": "2FA konnte nicht aktiviert werden", + "disable_failed": "2FA konnte nicht deaktiviert werden", + "invalid_code": "Ungültiger Verifizierungscode" + } + }, + "verify_email": { + "title": "E-Mail verifizieren" + } + }, + "common": { + "actions": { + "save": "Speichern", + "save_changes": "Änderungen speichern", + "cancel": "Abbrechen", + "delete": "Löschen", + "edit": "Bearbeiten", + "create": "Erstellen", + "submit": "Absenden", + "close": "Schließen", + "confirm": "Bestätigen", + "back": "Zurück", + "remove": "Entfernen", + "add": "Hinzufügen", + "retry": "Wiederholen", + "generate": "Generieren", + "loading": "Wird geladen..." + }, + "states": { + "loading": "Wird geladen...", + "no_results": "Keine Ergebnisse gefunden", + "error_occurred": "Ein Fehler ist aufgetreten" + }, + "placeholders": { + "search": "Suchen...", + "message": "Nachricht" + } + }, + "navigation": { + "search": "Suchen", + "search_shortcut": "Suchen (Ctrl+K)", + "favorites": "Favorisierte Nachrichten", + "no_channels": "Keine Kanäle" + }, + "settings": { + "access_keys": { + "title": "Persönliche Zugriffstokens", + "description": "Tokens, die Sie für den API-Zugriff generiert haben.", + "generate_button": "Neues Token generieren", + "token_name": "Token-Name", + "copy_warning": "Kopieren Sie Ihr persönliches Zugriffstoken jetzt. Sie können es nicht mehr sehen!", + "empty": "Keine persönlichen Zugriffstokens gefunden.", + "created_on": "Erstellt am", + "expires": "Läuft ab", + "generate_token": "Token generieren", + "messages": { + "name_required": "Bitte Token-Namen eingeben", + "created_success": "Token erstellt, bitte jetzt kopieren.", + "delete_success": "Token gelöscht", + "create_failed": "Erstellung fehlgeschlagen, bitte erneut versuchen", + "delete_failed": "Löschen fehlgeschlagen" + } + }, + "ssh_keys": { + "title": "SSH-Schlüssel", + "description": "SSH-Schlüssel für Git-Operationen verwalten", + "add_button": "Schlüssel hinzufügen", + "add_title": "Neuen SSH-Schlüssel hinzufügen", + "title_label": "Titel", + "public_key_label": "Öffentlicher Schlüssel", + "name_placeholder": "z.B. Persönlicher MacBook", + "key_placeholder": "ssh-rsa AAAAB3NzaC... user@host", + "empty_title": "Noch keine SSH-Schlüssel hinzugefügt", + "empty_desc": "Fügen Sie SSH-Schlüssel hinzu, um Git-Operationen sicher durchzuführen", + "verified": "Verifiziert", + "messages": { + "title_key_required": "Bitte geben Sie einen Titel und einen öffentlichen Schlüssel ein", + "add_success": "SSH-Schlüssel erfolgreich hinzugefügt", + "add_failed": "Hinzufügen fehlgeschlagen, überprüfen Sie das Schlüsselformat", + "delete_success": "SSH-Schlüssel gelöscht", + "delete_failed": "Löschen fehlgeschlagen", + "title_updated": "Titel aktualisiert", + "update_failed": "Aktualisierung fehlgeschlagen" + } + }, + "password": { + "title": "Passwort ändern", + "subtitle": "Schützen Sie Ihr Konto mit einem starken Passwort", + "current_password": "Aktuelles Passwort", + "current_password_placeholder": "Aktuelles Passwort eingeben", + "new_password": "Neues Passwort", + "new_password_placeholder": "Neues Passwort eingeben (mindestens 8 Zeichen)", + "confirm_password": "Neues Passwort bestätigen", + "confirm_password_placeholder": "Neues Passwort erneut eingeben", + "submit": "Passwort ändern", + "mismatch": "Passwörter stimmen nicht überein", + "min_length": "Passwort muss mindestens 8 Zeichen lang sein", + "change_success": "Passwort erfolgreich geändert", + "change_failed": "Passwortänderung fehlgeschlagen, überprüfen Sie Ihr aktuelles Passwort" + }, + "account": { + "title": "Mein Konto", + "display_name_placeholder": "Anzeigenamen festlegen", + "website_placeholder": "https://example.com", + "company_placeholder": "Organisation (optional)" + }, + "email": { + "title": "E-Mail", + "current_email": "Aktuelle E-Mail", + "new_email_placeholder": "new@example.com", + "current_password_placeholder": "Aktuelles Passwort eingeben" + }, + "billing": { + "title": "Abrechnung", + "personal_billing": "Persönliche Abrechnung", + "monthly_quota": "Monatliches Kontingent", + "monthly_usage": "Monatliche Nutzung", + "billing_errors": "Abrechnungsfehler", + "no_history": "Keine Abrechnungshistorie" + }, + "appearance": { + "title": "Erscheinungsbild", + "timezone_asia_shanghai": "Asien/Shanghai (UTC+8)", + "timezone_asia_tokyo": "Asien/Tokyo (UTC+9)", + "timezone_america_ny": "Amerika/New_York (UTC-5)", + "timezone_america_la": "Amerika/Los_Angeles (UTC-8)", + "timezone_europe_london": "Europa/London (UTC+0)" + }, + "notifications": { + "title": "Benachrichtigungen" + }, + "settings_nav": { + "user_settings": "Benutzereinstellungen", + "my_account": "Mein Konto", + "billing": "Abrechnung", + "appearance": "Erscheinungsbild", + "notifications": "Benachrichtigungen", + "push_settings": "Push-Einstellungen", + "security": "Sicherheit", + "change_password": "Passwort ändern", + "email": "E-Mail", + "ssh_keys": "SSH-Schlüssel", + "access_tokens": "Zugriffstokens" + }, + "my_account": { + "title": "Mein Konto", + "subtitle": "Verwalten Sie Ihre persönlichen Daten", + "avatar": "Avatar", + "upload_avatar": "Neues Avatar hochladen", + "remove": "Entfernen", + "avatar_hint": "Unterstützt JPG, PNG oder GIF. Max. 2 MB.", + "username": "Benutzername", + "display_name": "Anzeigename", + "display_name_placeholder": "Anzeigenamen festlegen", + "website": "Website", + "website_placeholder": "https://example.com", + "organization": "Organisation", + "org_placeholder": "Organisation (optional)", + "save_changes": "Änderungen speichern", + "load_failed": "Profil konnte nicht geladen werden", + "save_success": "Profil gespeichert", + "save_failed": "Speichern fehlgeschlagen, bitte erneut versuchen", + "avatar_size_error": "Bild muss kleiner als 2 MB sein", + "avatar_upload_success": "Avatar hochgeladen, bitte Änderungen speichern", + "avatar_upload_failed": "Avatar-Upload fehlgeschlagen" + }, + "email_page": { + "title": "E-Mail", + "subtitle": "Verwalten Sie Ihre E-Mail-Adresse", + "current_email": "Aktuelle E-Mail", + "no_email_set": "Keine E-Mail gesetzt", + "new_email": "Neue E-Mail", + "current_password": "Aktuelles Passwort (zur Verifizierung)", + "current_password_placeholder": "Aktuelles Passwort eingeben", + "save_button": "E-Mail ändern", + "load_failed": "E-Mail-Informationen konnten nicht geladen werden", + "fill_all_fields": "Bitte füllen Sie alle Felder aus", + "verification_sent": "Bestätigungs-E-Mail gesendet, bitte prüfen Sie Ihr neues Postfach", + "change_failed": "E-Mail-Änderung fehlgeschlagen, bitte Passwort prüfen" + }, + "notifications_page": { + "title": "Benachrichtigungen", + "subtitle": "Verwalten Sie Ihre Benachrichtigungseinstellungen", + "channels": "Benachrichtigungskanäle", + "email_notifications": "E-Mail-Benachrichtigungen", + "email_notifications_desc": "Benachrichtigungen per E-Mail erhalten", + "in_app_notifications": "In-App-Benachrichtigungen", + "in_app_notifications_desc": "Erinnerungen in der App erhalten", + "push_notifications": "Push-Benachrichtigungen", + "push_notifications_desc": "Benachrichtigungen über Browser-Push erhalten", + "digest_mode": "Zusammenfassungsmodus", + "instant": "Sofort", + "daily_digest": "Tägliche Zusammenfassung", + "weekly_digest": "Wöchentliche Zusammenfassung", + "off": "Aus", + "notification_types": "Benachrichtigungstypen", + "security_notifications": "Sicherheitsbenachrichtigungen", + "security_notifications_desc": "Kontosicherheitsbezogene Benachrichtigungen", + "product_updates": "Produkt-Updates", + "product_updates_desc": "Neue Funktionen und Produktupdate-Benachrichtigungen", + "marketing_emails": "Marketing-E-Mails", + "marketing_emails_desc": "Werbung und Angebote", + "do_not_disturb": "Nicht stören", + "enable_dnd": "Nicht stören aktivieren", + "enable_dnd_desc": "Keine Benachrichtigungen während eines bestimmten Zeitraums erhalten", + "save_button": "Änderungen speichern", + "load_failed": "Benachrichtigungseinstellungen konnten nicht geladen werden", + "save_success": "Benachrichtigungseinstellungen gespeichert", + "save_failed": "Speichern fehlgeschlagen, bitte erneut versuchen" + }, + "appearance_page": { + "title": "Erscheinungsbild", + "subtitle": "Passen Sie das Aussehen und die Sprache der App an", + "theme_scheme": "Themenschema", + "select_theme_scheme": "Themenschema auswählen", + "theme": "Thema", + "language": "Sprache", + "timezone": "Zeitzone", + "dark": "Dunkel", + "light": "Hell", + "system": "System", + "basic_settings": "Grundeinstellungen", + "custom": "Benutzerdefiniert", + "save_button": "Änderungen speichern", + "load_failed": "Einstellungen konnten nicht geladen werden", + "save_success": "Erscheinungseinstellungen gespeichert", + "save_failed": "Speichern fehlgeschlagen, bitte erneut versuchen" + }, + "billing": { + "title": "Abrechnung", + "personal_billing": "Persönliche Abrechnung", + "balance": "Guthaben", + "monthly_quota": "Monatliches Kontingent", + "monthly_usage": "Monatliche Nutzung", + "billing_errors": "Abrechnungsfehler", + "history": "Abrechnungshistorie", + "date": "Datum", + "reason": "Grund", + "amount": "Betrag", + "no_history": "Keine Abrechnungshistorie", + "load_failed": "Abrechnung konnte nicht geladen werden", + "insufficient_balance": "Unzureichendes Guthaben" + }, + "push": { + "title": "Push-Benachrichtigungen", + "subtitle": "Erhalten Sie Echtzeit-Benachrichtigungen, auch wenn die App geschlossen ist.", + "enable": "Push-Benachrichtigungen aktivieren", + "enable_desc": "Erhalten Sie Benachrichtigungen über Erwähnungen, Issues und Systemmeldungen.", + "filters_title": "Benachrichtigungsfilter", + "mentions": "Erwähnungen und Antworten", + "new_issues": "Neue Issues", + "system_updates": "System-Updates", + "coming_soon": "Detailliertere Steuerung in Kürze verfügbar.", + "not_supported": "Push-Benachrichtigungen werden nicht unterstützt", + "not_supported_desc": "Ihr Browser oder Ihre Umgebung unterstützt kein Web Push. Bitte verwenden Sie einen modernen Desktop-Browser.", + "load_failed": "Fehler beim Laden der Benachrichtigungseinstellungen", + "permission_denied": "Benachrichtigungsberechtigung vom Browser verweigert", + "update_failed": "Fehler beim Aktualisieren der Push-Einstellungen", + "saved": "Einstellungen erfolgreich gespeichert" + } + }, + "project": { + "layout": { + "expand_sidebar": "Seitenleiste erweitern", + "join_banner": { + "preview_mode": "Vorschaumodus", + "join_to_participate": "Werden Sie Mitglied dieses Projekts, um teilzunehmen und Tools zu nutzen.", + "read_only": "Dieses öffentliche Projekt ist schreibgeschützt, bis Sie beitreten.", + "join_to_use": "Sie müssen beitreten, bevor Sie Projektaktionen nutzen können.", + "apply_to_join": "Beitritt beantragen" + } + }, + "invitation": { + "title": "Projekteinladung", + "no_pending": "Keine ausstehenden Einladungen" + }, + "join": { + "title": "${project} beitreten", + "reason_placeholder": "Erklären Sie den Admins, warum Sie beitreten möchten.", + "cancel_request": "Anfrage abbrechen", + "join_without_reason": "Projekt beitreten", + "submit_request": "Beitrittsanfrage senden", + "default_desc": "Senden Sie eine Anfrage, um Projektmitglied zu werden.", + "already_member": "Sie sind bereits Mitglied dieses Projekts.", + "open_project": "Projekt öffnen", + "current_request": "Aktuelle Anfrage", + "submitted_on": "Eingereicht am", + "message": "Nachricht", + "message_desc": "Optional, aber nützlich für private oder genehmigungspflichtige Projekte.", + "submitted": "Beitrittsanfrage gesendet.", + "submit_failed": "Fehler beim Senden der Beitrittsanfrage.", + "cancelled": "Beitrittsanfrage storniert.", + "cancel_failed": "Fehler beim Stornieren der Beitrittsanfrage." + }, + "settings": { + "general": { + "title": "Projektidentität", + "project_name": "Projektname", + "project_name_placeholder": "z.B. my-awesome-project", + "display_name": "Anzeigename", + "display_name_placeholder": "z.B. Mein tolles Projekt", + "description": "Beschreibung", + "description_placeholder": "Beschreiben Sie das Projekt...", + "leave": "Dieses Projekt verlassen", + "leave_desc": "Sie verlieren den Zugriff auf alle Projektressourcen.", + "leave_btn": "Projekt verlassen", + "leave_confirm": "Möchten Sie dieses Projekt wirklich verlassen?", + "leave_success": "Sie haben das Projekt verlassen.", + "leave_failed": "Projekt konnte nicht verlassen werden." + }, + "billing": { + "title": "Aktuelle Abrechnung", + "monthly_quota": "Monatliches Kontingent", + "monthly_usage": "Monatliche Nutzung", + "billing_errors": "Abrechnungsfehler", + "no_history": "Keine Abrechnungshistorie" + }, + "access": { + "invite_member": "Mitglied einladen", + "join_settings": "Beitrittseinstellungen", + "pending_invitations": "Ausstehende Einladungen", + "join_requests": "Beitrittsanfragen", + "email_placeholder": "user@example.com", + "reason_placeholder": "Was möchten Sie fragen?", + "optional_reason": "Optionaler Grund, der in der Anfragehistorie angezeigt wird." + }, + "labels": { + "create_label": "Label erstellen", + "label_name_placeholder": "label-name", + "description_placeholder": "Beschreibung", + "no_labels": "Keine Labels" + }, + "members": { + "remove_confirm": "${username} aus diesem Projekt entfernen?", + "remove": "Entfernen" + }, + "danger_zone": { + "delete_project": "Dieses Projekt löschen", + "delete_button": "Projekt löschen" + } + }, + "repos": { + "title": "Repositories", + "subtitle": "Hosten und verwalten Sie den Quellcode Ihres Projekts", + "find_placeholder": "Repository finden...", + "no_repos": "Keine Repositories", + "no_repos_desc": "Erstellen Sie Ihr erstes Repository", + "create_new": "Neues Repository erstellen", + "create_new_desc": "Hosten Sie Ihren Code und arbeiten Sie zusammen", + "loading": "Repositories werden geladen...", + "load_failed": "Fehler beim Laden der Repositories", + "new_repo": "Neues Repo", + "type": "Typ", + "sort": "Sortieren", + "repository": "Repository", + "repositories": "Repositories", + "grid_view": "Rasteransicht", + "list_view": "Listenansicht", + "private": "Privat", + "public": "Öffentlich", + "no_description_default": "Keine Beschreibung angegeben.", + "never": "Nie", + "just_now": "Gerade eben", + "min_ago": "vor ${count} Min", + "hr_ago": "vor ${count} Std", + "day_ago": "vor ${count} Tagen" + }, + "repo": { + "settings": { + "title": "Repository-Identität", + "name_placeholder": "Repository-Name", + "description_placeholder": "Beschreiben Sie dieses Repository...", + "default_branch": "Standard-Branch", + "branch_placeholder": "main", + "desc": "Die grundlegenden Informationen des Repository aktualisieren" + }, + "code": "Code", + "commits": "Commits", + "pull_requests": "Pull Requests", + "branches": "Branches", + "tags": "Tags", + "settings_tab": "Einstellungen", + "loading": "Repository wird geladen...", + "load_failed": "Repository konnte nicht geladen werden", + "not_found": "Repository nicht gefunden" + }, + "branch_protection": { + "title": "Branch-Schutzregeln", + "branch_pattern": "Branch-Muster", + "required_approvals": "Erforderliche Genehmigungen", + "active_rules": "Aktive Regeln", + "no_rules": "Keine Branch-Schutzregeln konfiguriert", + "create_rule_hint": "Erstellen Sie oben eine Regel zum Schutz Ihrer Branches", + "add_rule": "Regel hinzufügen", + "no_fork_sync": "Keine Fork-Synchronisierung" + } + }, + "issues": { + "title": "Issues", + "subtitle": "Aufgaben und Bugs des Projekts verfolgen und verwalten", + "new_title": "Neues Issue erstellen", + "new_issue": "Neues Issue", + "search_placeholder": "Alle Issues durchsuchen...", + "sort": "Sortieren", + "label": "Label", + "assignee": "Zugewiesen", + "open": "Offen", + "closed": "Geschlossen", + "no_project": "Kein Projekt ausgewählt", + "select_project": "Wählen Sie ein Projekt aus, um seine Issues anzuzeigen", + "loading": "Issues werden geladen...", + "load_failed": "Fehler beim Laden der Issues", + "no_issues": "Keine ${tab} Issues", + "no_matches": "Keine Übereinstimmungen gefunden", + "all_caught_up": "Alles erledigt! Erstellen Sie ein Issue, um neue Aufgaben zu verfolgen.", + "try_adjusting": "Versuchen Sie, Ihre Suche oder Filter anzupassen.", + "create_first": "Erstellen Sie Ihr erstes Issue", + "brief_description_placeholder": "Beschreiben Sie das Problem kurz...", + "details_placeholder": "Erklären Sie Details, Reproduktionsschritte oder Anforderungen...", + "pro_tip": "Tipp", + "need_help": "Brauchen Sie Hilfe?", + "submit": "Neues Issue absenden", + "issue_title_placeholder": "Issue-Titel" + }, + "issue_detail": { + "loading": "Issue wird geladen...", + "no_description": "Keine Beschreibung angegeben.", + "no_comments": "Keine Kommentare. Starten Sie die Diskussion!", + "comment_placeholder": "Kommentar schreiben... (Markdown unterstützt)", + "edit": "Bearbeiten", + "close": "Schließen", + "delete": "Löschen", + "link_pr": "Pull Request verknüpfen", + "link_repo": "Repository verknüpfen", + "pull_requests": "Pull Requests", + "linked_repos": "Verknüpfte Repositories", + "no_assigned": "Niemand zugewiesen" + }, + "pulls": { + "title": "Pull Requests", + "project_pulls": "${project} Pull Requests", + "close": "Schließen", + "reopen": "Wieder öffnen", + "confirm_delete": "Bestätigen", + "delete": "Löschen", + "no_project": "Kein Projekt ausgewählt", + "select_project": "Wählen Sie ein Projekt aus, um Pull Requests anzuzeigen", + "loading": "Pull Requests werden geladen...", + "load_failed": "Fehler beim Laden der Pull Requests", + "no_prs": "Keine Pull Requests", + "create_hint": "Erstellen Sie einen Pull Request, um Änderungen vorzuschlagen" + }, + "skills": { + "title": "Skills", + "subtitle": "Projektfähigkeiten und KI-Skills", + "no_project": "Kein Projekt ausgewählt", + "select_project": "Wählen Sie ein Projekt aus, um seine Skills anzuzeigen", + "loading": "Skills werden geladen...", + "load_failed": "Fehler beim Laden der Skills", + "no_skills": "Keine Skills", + "no_skills_desc": "Erstellen oder scannen Sie Skills für KI-Unterstützung", + "create": "Erstellen", + "scan": "Scannen", + "scanning": "Scannen...", + "skill_name_placeholder": "my-skill", + "skill_display_name": "Mein Skill", + "skill_description_placeholder": "Was dieser Skill macht", + "skill_content_placeholder": "Skill-Inhalt oder Prompt...", + "delete": "Skill löschen", + "create_title": "Skill erstellen", + "create_desc": "Neuen KI-Skill zum Projekt hinzufügen." + }, + "skill_detail": { + "loading": "Skill wird geladen...", + "edit_title": "Skill bearbeiten", + "save_failed": "Speichern fehlgeschlagen" + }, + "board": { + "title": "Board", + "no_boards": "Keine Boards", + "no_boards_desc": "Erstellen Sie ein Kanban-Board zur Workflow-Verwaltung.", + "add_column": "Spalte hinzufügen", + "add_column_title": "Spalte hinzufügen", + "delete_board": "Board löschen", + "new_board_title": "Neues Board", + "board_name_placeholder": "z.B. Sprint-Planung", + "board_desc_placeholder": "Optionale Details...", + "column_name": "Spaltenname", + "column_name_placeholder": "z.B. In Bearbeitung", + "card_title_placeholder": "Was muss erledigt werden?", + "card_desc_placeholder": "Optionale Details...", + "card_detail_title": "Kartentitel", + "card_detail_desc_placeholder": "Detaillierte Beschreibung hinzufügen...", + "delete_card": "Karte löschen", + "delete_column": "Spalte löschen?", + "delete_card_confirm": "Diese Karte löschen?", + "empty_board": "Leeres Board", + "empty_board_hint": "Fügen Sie Ihre erste Spalte hinzu (z.B. Zu erledigen, In Bearbeitung, Erledigt), um die Arbeit zu verfolgen.", + "add_first_column": "Erste Spalte hinzufügen", + "add_card": "Karte hinzufügen", + "new_card": "Neue Karte", + "card_title": "Kartentitel", + "id": "ID", + "created": "Erstellt", + "updated": "Aktualisiert" + }, + "me": { + "title": "Mein Profil", + "recent_repos": "Letzte Repositories", + "top_projects": "Top-Projekte", + "latest_activity": "Letzte Aktivität", + "no_projects": "Keine Projekte gefunden", + "no_repos": "Keine Repositories gefunden", + "no_activity": "Keine kürzliche Aktivität", + "create_project": { + "title": "Neues Projekt erstellen", + "subtitle": "Starten Sie ein neues kollaboratives Projekt", + "name_placeholder": "z.B. my-awesome-team", + "org_placeholder": "z.B. Acme Corporation", + "desc_placeholder": "Worum geht es in diesem Projekt?", + "slug": "Projekt-Slug", + "slug_required": "Erforderlich", + "slug_hint": "In URLs verwendet. Nur Kleinbuchstaben, Zahlen und Bindestriche.", + "display_name": "Anzeigename", + "display_name_required": "Erforderlich", + "display_name_placeholder": "z.B. Acme Corporation", + "description": "Beschreibung", + "visibility": "Sichtbarkeit", + "public_project": "Öffentliches Projekt", + "public_desc": "Jeder kann das Projekt finden und beitreten", + "private_project": "Privates Projekt", + "private_desc": "Nur eingeladene Mitglieder können zugreifen", + "change": "Ändern", + "create_failed": "Projekt konnte nicht erstellt werden. Der Slug ist möglicherweise bereits vergeben.", + "create": "Projekt erstellen", + "cancel": "Abbrechen" + }, + "invitations": { + "pending": "Ausstehende Einladungen", + "no_pending": "Keine ausstehenden Einladungen", + "join_requests": "Beitrittsanfragen", + "no_requests": "Keine Beitrittsanfragen" + }, + "notifications": { + "no_notifications": "Keine Benachrichtigungen", + "no_unread": "Keine ungelesenen Benachrichtigungen", + "all_caught_up": "Alles erledigt" + }, + "followers": { + "no_followers": "Keine Follower" + }, + "following": { + "no_following": "Keine Abonnements" + }, + "user_not_found": "Benutzer nicht gefunden", + "please_login": "Bitte stellen Sie sicher, dass Sie angemeldet sind.", + "profile": { + "joined": "Beigetreten", + "projects": "Projekte", + "repos": "Repos", + "stars": "Sterne", + "followers": "Follower", + "follow": "Folgen", + "unfollow": "Entfolgen", + "contributions_in_year": "${count} Beiträge im letzten Jahr", + "contributions_on_date": "${count} Beiträge am ${date}", + "less": "Weniger", + "more": "Mehr" + } + }, + "explore": { + "title": "Projekte erkunden", + "subtitle": "Entdecken Sie öffentliche Projekte und Gemeinschaften", + "search_placeholder": "Projekte nach Name oder Beschreibung suchen...", + "no_projects": "Keine Projekte gefunden", + "no_description": "Keine Beschreibung", + "found": "gefunden", + "showing": "Auffindbare Projekte werden angezeigt", + "try_different": "Versuchen Sie einen anderen Suchbegriff", + "view_project": "Projekt ansehen" + }, + "chat": { + "conversations": { + "search_placeholder": "Unterhaltungen suchen...", + "no_conversations": "Noch keine Unterhaltungen", + "no_matching": "Keine passenden Unterhaltungen", + "new_chat": "Neuer Chat", + "chat_history": "Chat-Verlauf", + "start_new_chat": "Starten Sie einen neuen Chat, um mit KI zu erkunden.", + "try_different_search": "Versuchen Sie einen anderen Suchbegriff.", + "untitled_chat": "Unbenannter Chat", + "create_failed": "Chat konnte nicht erstellt werden" + }, + "model_selector": { + "search_placeholder": "Modelle suchen...", + "loading": "Wird geladen...", + "no_models": "Keine Modelle gefunden", + "please_select_model": "Bitte wählen Sie zuerst ein Modell" + }, + "header": { + "new_chat": "Neuer Chat", + "expand_history": "Chat-Verlauf erweitern", + "collapse_history": "Chat-Verlauf einklappen", + "streaming": "Streaming...", + "share": "Gespräch teilen", + "rename": "Umbenennen", + "more": "Mehr" + }, + "message_list": { + "welcome_title": "Wie kann ich Ihnen heute helfen?", + "welcome_desc": "Fragen Sie anything - Ich kann bei Code, Schreiben, Analyse und vielem mehr helfen.", + "new_response": "Neue Antwort", + "responding": "antwortet...", + "explain_code": "Code erklären oder Fehler beheben", + "summarize_doc": "Dokument zusammenfassen oder entwerfen", + "review_pr": "Pull Request oder Repo prüfen", + "brainstorm": "Ideen brainstormen oder Plan erstellen", + "jump_to_message": "Zu Ihrer Nachricht ${index} springen" + } + }, + "channel": { + "no_selected": "Kein Kanal ausgewählt", + "no_threads": "Noch keine Threads. Verwenden Sie das Nachrichten-Aktionsmenü, um einen zu starten.", + "no_messages": "Noch keine Nachrichten. Starten Sie die Konversation!", + "edit_history": "Bearbeitungshistorie", + "no_edit_history": "Keine Bearbeitungshistorie verfügbar.", + "no_pinned": "Noch keine angehefteten Nachrichten.", + "reply_placeholder": "Im Thread antworten...", + "beginning_of_channel": "Anfang des Kanals", + "scroll_up": "Nach oben scrollen, um ältere Nachrichten zu laden", + "new_message": "${count} neue Nachricht(en)" + }, + "search": { + "global_placeholder": "Projekte, Kanäle, Repositories, Boards, Issues suchen...", + "no_results": "Keine Ergebnisse gefunden.", + "no_messages": "Keine passenden Nachrichten.", + "results": "Suchergebnisse", + "jump_to": "Zu Projekten, Kanälen, Repositories, Boards und Issues springen.", + "messages": "Nachrichten", + "quick_switch": "Schnellwechsel", + "projects": "Projekte", + "my_home": "Mein Zuhause", + "my_home_desc": "Persönliche Übersicht öffnen", + "explore_projects": "Projekte erkunden", + "explore_projects_desc": "Öffentliche Projekte finden", + "chat": "Chat", + "chat_desc": "Persönlichen Chat öffnen", + "project_overview": "Projektübersicht", + "repositories": "Repositories", + "issues": "Issues", + "boards": "Boards", + "project_settings": "Projekteinstellungen", + "rooms_in": "Räume in ${project}", + "all_rooms": "Alle Räume in ${project}", + "messages_in": "Nachrichten in ${project}", + "messages_in_room": "Nachrichten in #${room}" + }, + "components": { + "message_input": { + "reply_preview": "Antwort/Bearbeitungs-Vorschau-Banner", + "add_emoji": "Emoji hinzufügen", + "placeholder": "Nachricht", + "replying": "Antworte auf Nachricht", + "editing": "Nachricht bearbeiten" + }, + "inline_comment": { + "reply_placeholder": "Antwort schreiben...", + "leave_comment": "Kommentar hinterlassen..." + }, + "merge_panel": { + "commit_message_placeholder": "Commit-Nachricht eingeben..." + }, + "ai_settings": { + "add_ai": "KI hinzufügen", + "no_ai": "Keine aktiven KI-Agenten in diesem Kanal", + "search_placeholder": "Modelle suchen...", + "system_prompt_placeholder": "Sie sind ein hilfreicher Assistent..." + }, + "room_settings": { + "name_placeholder": "z.B. general", + "topic_placeholder": "z.B. Ingenieurwesen", + "no_group": "Keine Gruppe", + "create_new_group": "Neue Gruppe erstellen", + "delete_room": "Kanal löschen" + }, + "command": { + "placeholder": "Befehl zum Ausführen suchen..." + }, + "pr": { + "create_merge_commit": "Merge-Commit erstellen", + "no_changes": "Keine Änderungen in diesem Pull Request.", + "confirm_merge": "Merge bestätigen" + }, + "commit": { + "back_to_commits": "Zurück zu Commits", + "no_changes": "Keine Änderungen in diesem Commit." + }, + "repo": { + "no_description": "Keine Beschreibung", + "no_description_default": "Keine Beschreibung angegeben." + }, + "branches": { + "no_branches": "Keine Branches", + "no_branches_desc": "Erstellen Sie Ihren ersten Branch zum Starten" + }, + "tags": { + "no_tags": "Keine Tags", + "no_tags_desc": "Erstellen Sie ein Tag, um einen bestimmten Punkt in der Historie zu markieren" + }, + "favorites": { + "no_favorites": "Noch keine favorisierten Nachrichten." + } + }, + "project_create": { + "repo_name": "Repository-Name", + "repo_name_placeholder": "z.B. core-api", + "repo_desc_placeholder": "Wofür ist dieses Repository?", + "group_name": "Neuer Gruppenname", + "channel_name": "Kanalname", + "channel_name_placeholder": "z.B. development", + "board_name": "Board-Name", + "board_name_placeholder": "z.B. Sprint-Planung", + "board_desc_placeholder": "Task-Board-Details...", + "skill_name": "Skill-Name", + "skill_name_placeholder": "z.B. Code-Reviewer", + "skill_desc_placeholder": "Was macht dieser KI-Skill?", + "email_placeholder": "user@example.com", + "no_group": "Keine Gruppe", + "create_new_group": "Neue Gruppe erstellen", + "create_repo": "Repository erstellen", + "create_channel": "Kanal erstellen", + "create_board": "Board erstellen", + "create_skill": "Skill erstellen" + } +} diff --git a/src/i18n/en.json b/src/i18n/en.json index e69de29..05b2fcd 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -0,0 +1,977 @@ +{ + "auth": { + "login": { + "title": "Welcome back!", + "subtitle": "We're so excited to see you again!", + "account": "Account", + "account_required": "Username is required", + "account_placeholder": "Username or email", + "password": "Password", + "password_required": "Password is required", + "verification": "Verification", + "captcha_placeholder": "Enter code", + "captcha_required": "Captcha is required", + "captcha_title": "Click to refresh", + "2fa_code": "2FA Code", + "2fa_placeholder": "6-digit code", + "forgot_password": "Forgot your password?", + "submit": "Log In", + "submit_loading": "Logging in...", + "need_account": "Need an account?", + "register": "Register", + "error": { + "failed_to_load_captcha": "Failed to load captcha", + "two_factor_required": "Two-factor authentication required", + "invalid_credentials": "Invalid username or password", + "login_failed": "Login failed" + } + }, + "register": { + "title": "Create an account", + "subtitle": "Join us today!", + "username": "Username", + "username_placeholder": "Choose a username", + "username_required": "Username is required", + "username_min_length": "Username must be at least 3 characters", + "username_pattern": "Username can only contain letters, numbers, _ and -", + "email": "Email", + "email_placeholder": "Enter your email", + "email_required": "Email is required", + "email_invalid": "Invalid email address", + "password_placeholder": "Create a password", + "confirm_password": "Confirm Password", + "confirm_password_placeholder": "Confirm your password", + "confirm_password_required": "Please confirm your password", + "password_min_length": "Password must be at least 8 characters", + "captcha_placeholder": "Enter code", + "submit": "Create Account", + "submit_loading": "Creating account...", + "already_have_account": "Already have an account?", + "login": "Login", + "passwords_not_match": "Passwords do not match", + "user_exists": "Username or email already exists", + "registration_failed": "Registration failed" + }, + "forgot_password": { + "title": "Reset your password", + "subtitle": "Enter your email and we'll send you a reset link", + "email_placeholder": "Enter your email", + "captcha_placeholder": "Enter code", + "submit": "Send Reset Link", + "sending": "Sending...", + "back_to_login": "Back to login", + "captcha_failed": "Captcha verification failed", + "send_failed": "Failed to send reset email", + "success_message": "If an account exists with that email, you will receive a password reset link shortly." + }, + "reset_password": { + "title": "Set new password", + "subtitle": "Choose a strong password for your account", + "new_password_placeholder": "Enter new password", + "confirm_password_placeholder": "Confirm new password", + "captcha_placeholder": "Enter code", + "submit": "Reset Password", + "resetting": "Resetting...", + "back_to_login": "Back to login", + "invalid_token": "Invalid or expired reset token", + "reset_failed": "Failed to reset password" + }, + "change_password": { + "title": "Change Password", + "subtitle": "Update your account password", + "current_password_placeholder": "Enter current password", + "new_password_placeholder": "Enter new password", + "confirm_password_placeholder": "Confirm new password", + "captcha_placeholder": "Enter code", + "submit": "Update Password", + "back_to_login": "Back to login" + }, + "two_factor": { + "title": "Two-Factor Authentication", + "enabled": "2FA is currently enabled", + "disabled": "Add an extra layer of security", + "description": "Two-factor authentication adds an additional layer of security to your account by requiring more than just a password to log in.", + "enabled_message": "Two-factor authentication is currently enabled on your account.", + "scan_qr_code": "Scan QR Code", + "or_enter_manually": "Or enter this code manually", + "verification_code": "Verification Code", + "code_required": "Code is required", + "code_placeholder": "Enter 6-digit code", + "password_placeholder": "Enter your password", + "submit": "Verify", + "cancel": "Cancel", + "back": "Back", + "enable": "Enable 2FA", + "disable": "Disable 2FA", + "disabling": "Disabling...", + "error": { + "load_failed": "Failed to load 2FA status", + "enable_failed": "Failed to enable 2FA", + "disable_failed": "Failed to disable 2FA", + "invalid_code": "Invalid verification code" + } + }, + "verify_email": { + "title": "Verify Email" + }, + "logout": "Log Out" + }, + "common": { + "actions": { + "save": "Save", + "save_changes": "Save Changes", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "create": "Create", + "submit": "Submit", + "close": "Close", + "confirm": "Confirm", + "back": "Back", + "remove": "Remove", + "add": "Add", + "retry": "Retry", + "generate": "Generate", + "loading": "Loading...", + "discard": "Discard" + }, + "states": { + "loading": "Loading...", + "no_results": "No results found", + "error_occurred": "An error occurred", + "error_title": "Something went wrong", + "error_message": "An unexpected error occurred." + }, + "placeholders": { + "search": "Search...", + "message": "Message" + }, + "unknown": "Unknown" + }, + "navigation": { + "search": "Search", + "search_shortcut": "Search (Ctrl+K)", + "favorites": "Favorite Messages", + "no_channels": "No channels", + "settings": "Settings" + }, + "settings": { + "access_keys": { + "title": "Personal Access Tokens", + "description": "Tokens you have generated that can be used to access the API.", + "generate_button": "Generate new token", + "token_name": "Token Name", + "copy_warning": "Make sure to copy your personal access token now. You won't be able to see it again!", + "empty": "No personal access tokens found.", + "created_on": "Created on", + "expires": "Expires", + "generate_token": "Generate token", + "messages": { + "name_required": "Please enter a token name", + "created_success": "Token created, please copy it now.", + "delete_success": "Token deleted", + "create_failed": "Creation failed, please try again", + "delete_failed": "Deletion failed" + } + }, + "ssh_keys": { + "title": "SSH Keys", + "description": "Manage SSH keys for Git operations", + "add_button": "Add key", + "add_title": "Add new SSH key", + "title_label": "Title", + "public_key_label": "Public Key", + "name_placeholder": "e.g. Personal MacBook", + "key_placeholder": "ssh-rsa AAAAB3NzaC... user@host", + "empty_title": "No SSH keys added yet", + "empty_desc": "Add SSH keys to securely perform Git operations", + "verified": "Verified", + "messages": { + "title_key_required": "Please enter a title and public key", + "add_success": "SSH key added successfully", + "add_failed": "Failed to add key, please check the key format", + "delete_success": "SSH key deleted", + "delete_failed": "Failed to delete key", + "title_updated": "Title updated", + "update_failed": "Failed to update" + } + }, + "password": { + "title": "Change Password", + "subtitle": "Protect your account with a strong password", + "current_password": "Current Password", + "current_password_placeholder": "Enter current password", + "new_password": "New Password", + "new_password_placeholder": "Enter new password (at least 8 characters)", + "confirm_password": "Confirm New Password", + "confirm_password_placeholder": "Re-enter new password", + "submit": "Change Password", + "mismatch": "Passwords do not match", + "min_length": "Password must be at least 8 characters", + "change_success": "Password changed successfully", + "change_failed": "Password change failed, please check your current password" + }, + "account": { + "title": "My Account", + "display_name_placeholder": "Set display name", + "website_placeholder": "https://example.com", + "company_placeholder": "Organization (optional)" + }, + "email": { + "title": "Email", + "current_email": "Current Email", + "new_email_placeholder": "new@example.com", + "current_password_placeholder": "Enter current password" + }, + "billing": { + "title": "Billing", + "personal_billing": "Personal Billing", + "balance": "Balance", + "monthly_quota": "Monthly Quota", + "monthly_usage": "Monthly Usage", + "billing_errors": "Billing Errors", + "history": "Billing History", + "date": "Date", + "reason": "Reason", + "amount": "Amount", + "no_history": "No billing history", + "load_failed": "Failed to load billing" + }, + "appearance": { + "title": "Appearance", + "timezone_asia_shanghai": "Asia/Shanghai (UTC+8)", + "timezone_asia_tokyo": "Asia/Tokyo (UTC+9)", + "timezone_america_ny": "America/New_York (UTC-5)", + "timezone_america_la": "America/Los_Angeles (UTC-8)", + "timezone_europe_london": "Europe/London (UTC+0)" + }, + "notifications": { + "title": "Notifications" + }, + "settings_nav": { + "user_settings": "User Settings", + "my_account": "My Account", + "billing": "Billing", + "appearance": "Appearance", + "notifications": "Notifications", + "push_settings": "Push Settings", + "security": "Security", + "change_password": "Change Password", + "email": "Email", + "ssh_keys": "SSH Keys", + "access_tokens": "Access Tokens" + }, + "my_account": { + "title": "My Account", + "subtitle": "Manage your personal information", + "avatar": "Avatar", + "upload_avatar": "Upload new avatar", + "remove": "Remove", + "avatar_hint": "Supports JPG, PNG or GIF. Max 2MB.", + "username": "Username", + "display_name": "Display Name", + "display_name_placeholder": "Set display name", + "website": "Website", + "website_placeholder": "https://example.com", + "organization": "Organization", + "org_placeholder": "Organization (optional)", + "save_changes": "Save Changes", + "load_failed": "Failed to load profile", + "save_success": "Profile saved", + "save_failed": "Failed to save, please try again", + "avatar_size_error": "Image must be less than 2MB", + "avatar_upload_success": "Avatar uploaded, please save changes", + "avatar_upload_failed": "Avatar upload failed" + }, + "email_page": { + "title": "Email", + "subtitle": "Manage your email address", + "current_email": "Current Email", + "no_email_set": "No email set", + "new_email": "New Email", + "current_password": "Current Password (for verification)", + "current_password_placeholder": "Enter current password", + "save_button": "Change Email", + "load_failed": "Failed to load email info", + "fill_all_fields": "Please fill in all fields", + "verification_sent": "Verification email sent, please check your new mailbox", + "change_failed": "Failed to change email, please check your password" + }, + "notifications_page": { + "title": "Notifications", + "subtitle": "Manage your notification preferences", + "channels": "Notification Channels", + "email_notifications": "Email Notifications", + "email_notifications_desc": "Receive notifications via email", + "in_app_notifications": "In-App Notifications", + "in_app_notifications_desc": "Receive notification reminders in the app", + "push_notifications": "Push Notifications", + "push_notifications_desc": "Receive notifications via browser push", + "digest_mode": "Digest Mode", + "instant": "Instant", + "daily_digest": "Daily Digest", + "weekly_digest": "Weekly Digest", + "off": "Off", + "notification_types": "Notification Types", + "security_notifications": "Security Notifications", + "security_notifications_desc": "Account security related notifications", + "product_updates": "Product Updates", + "product_updates_desc": "New features and product update notifications", + "marketing_emails": "Marketing Emails", + "marketing_emails_desc": "Promotional activities and offers", + "do_not_disturb": "Do Not Disturb", + "enable_dnd": "Enable Do Not Disturb", + "enable_dnd_desc": "Do not receive notifications during specified time period", + "save_button": "Save Changes", + "load_failed": "Failed to load notification preferences", + "save_success": "Notification settings saved", + "save_failed": "Failed to save, please try again" + }, + "appearance_page": { + "title": "Appearance", + "subtitle": "Customize the look and language of the app", + "theme_scheme": "Theme Scheme", + "select_theme_scheme": "Select theme scheme", + "theme": "Theme", + "language": "Language", + "timezone": "Timezone", + "dark": "Dark", + "light": "Light", + "system": "Follow System", + "save_button": "Save Changes", + "load_failed": "Failed to load preferences", + "save_success": "Appearance settings saved", + "save_failed": "Failed to save, please try again", + "basic_settings": "Basic Settings", + "custom": "Custom", + "theme_customization": { + "saved": "Saved", + "save_theme": "Save Theme", + "reset_default": "Reset to Default", + "reset_all": "Reset All", + "save_locally": "Changes are saved locally in browser" + } + }, + "billing": { + "title": "Billing", + "personal_billing": "Personal Billing", + "balance": "Balance", + "monthly_quota": "Monthly Quota", + "monthly_usage": "Monthly Usage", + "billing_errors": "Billing Errors", + "history": "Billing History", + "date": "Date", + "reason": "Reason", + "amount": "Amount", + "no_history": "No billing history", + "load_failed": "Failed to load billing", + "insufficient_balance": "Insufficient Balance" + }, + "push": { + "title": "Push Notifications", + "subtitle": "Receive real-time alerts even when the app is closed.", + "enable": "Enable Push Notifications", + "enable_desc": "Get notified of mentions, issues, and system alerts.", + "filters_title": "Notification Filters", + "mentions": "Mentions & Replies", + "new_issues": "New Issues", + "system_updates": "System Updates", + "coming_soon": "More granular controls coming soon.", + "not_supported": "Push notifications are not supported", + "not_supported_desc": "Your browser or environment doesn't support Web Push. Please use a modern desktop browser.", + "load_failed": "Failed to load notification settings", + "permission_denied": "Notification permission denied by browser", + "update_failed": "Failed to update push settings", + "saved": "Settings saved successfully" + } + }, + "project": { + "layout": { + "expand_sidebar": "Expand sidebar", + "join_banner": { + "preview_mode": "Preview mode", + "join_to_participate": "Join this project to participate and use project tools.", + "read_only": "This public project is read-only until you join.", + "join_to_use": "You need to join before using project actions.", + "apply_to_join": "Apply to join" + } + }, + "invitation": { + "title": "Project Invitation", + "no_pending": "No pending invitation", + "desc": "Accept or reject your invitation to join this project.", + "no_pending_desc": "This invitation may have already been processed. You can review all invitations from your profile.", + "view_invitations": "View invitations" + }, + "join": { + "title": "Join ${project}", + "reason_placeholder": "Tell the admins why you want to join.", + "cancel_request": "Cancel request", + "join_without_reason": "Join project", + "submit_request": "Submit join request", + "default_desc": "Submit a request to become a project member.", + "already_member": "You are already a member of this project.", + "open_project": "Open project", + "current_request": "Current request", + "submitted_on": "Submitted on", + "message": "Message", + "message_desc": "Optional, but useful for private or approval-required projects.", + "submitted": "Join request submitted.", + "submit_failed": "Failed to submit join request.", + "cancelled": "Join request cancelled.", + "cancel_failed": "Failed to cancel join request." + }, + "settings": { + "general": { + "title": "Project Identity", + "project_name": "Project Name", + "project_name_placeholder": "e.g. my-awesome-project", + "display_name": "Display Name", + "display_name_placeholder": "e.g. My Awesome Project", + "description": "Description", + "description_placeholder": "Tell people what this project is about...", + "desc": "Update your project's basic information and appearance", + "project_slug": "Project Slug", + "display_name_hint": "Shown in the UI and notifications", + "description": "Description", + "description_hint": "Brief description shown on the project page", + "copy_slug": "Copy slug", + "slug_hint": "Used in URLs — cannot contain spaces", + "avatar": "Project avatar", + "avatar_desc": "Click to upload · PNG, JPG or SVG · max 2 MB", + "updated": "Project settings updated successfully", + "update_failed": "Failed to update project settings", + "visibility": "Visibility", + "visibility_desc": "Control who can discover and view this project", + "visibility_changed": "Project is now ${visibility}", + "visibility_failed": "Failed to update visibility", + "public_desc": "Anyone on the internet can view this project and its code.", + "private_desc": "Only invited members can view this project and its code.", + "make_private": "Make Private", + "make_public": "Make Public", + "stats": "Project Stats", + "members": "Members", + "watchers": "Watchers", + "stars": "Stars", + "created": "Created", + "danger_zone": "Danger Zone", + "archive": "Archive this project", + "archive_desc": "Mark as read-only. Repository can be unarchived later.", + "archive_btn": "Archive", + "delete": "Delete this project", + "delete_desc": "Permanently remove this project and all its data. This cannot be undone.", + "delete_btn": "Delete project", + "leave": "Leave this project", + "leave_desc": "You will lose access to all project resources.", + "leave_btn": "Leave Project", + "leave_confirm": "Are you sure you want to leave this project?", + "leave_success": "You have left the project.", + "leave_failed": "Failed to leave the project." + }, + "billing": { + "title": "Current Billing", + "monthly_quota": "Monthly Quota", + "monthly_usage": "Monthly Usage", + "billing_errors": "Billing Errors", + "no_history": "No billing history" + }, + "access": { + "invite_member": "Invite Member", + "join_settings": "Join Settings", + "pending_invitations": "Pending Invitations", + "join_requests": "Join Requests", + "email_placeholder": "user@example.com", + "reason_placeholder": "What do you want to ask?", + "optional_reason": "Optional reason shown in request history." + }, + "labels": { + "create_label": "Create Label", + "label_name_placeholder": "label-name", + "description_placeholder": "description", + "no_labels": "No labels yet" + }, + "members": { + "remove_confirm": "Remove ${username} from this project?", + "remove": "Remove" + }, + "danger_zone": { + "delete_project": "Delete this project", + "delete_button": "Delete project" + } + }, + "repos": { + "title": "Repositories", + "subtitle": "Host and manage your project source code", + "find_placeholder": "Find a repository...", + "no_repos": "No repositories", + "no_repos_desc": "Get started by creating your first repository", + "create_new": "Create a new repository", + "create_new_desc": "Host your code and collaborate", + "loading": "Loading repositories...", + "load_failed": "Failed to load repositories", + "new_repo": "New repo", + "type": "Type", + "sort": "Sort", + "repository": "repository", + "repositories": "repositories", + "grid_view": "Grid view", + "list_view": "List view", + "private": "Private", + "public": "Public", + "no_description_default": "No description provided.", + "never": "Never", + "just_now": "Just now", + "min_ago": "${count}m ago", + "hr_ago": "${count}h ago", + "day_ago": "${count}d ago" + }, + "repo": { + "settings": { + "title": "Repository Identity", + "name_placeholder": "Repository name", + "description_placeholder": "Tell people what this repository is about...", + "default_branch": "Default Branch", + "branch_placeholder": "main", + "desc": "Update your repository's basic information" + }, + "code": "Code", + "commits": "Commits", + "pull_requests": "Pull Requests", + "branches": "Branches", + "tags": "Tags", + "settings_tab": "Settings", + "loading": "Loading repository...", + "load_failed": "Failed to load repository", + "not_found": "Repository not found" + }, + "branch_protection": { + "title": "Branch Protection Rules", + "branch_pattern": "Branch Pattern", + "required_approvals": "Required Approvals", + "active_rules": "Active Rules", + "no_rules": "No branch protection rules configured", + "create_rule_hint": "Create a rule above to protect your branches", + "add_rule": "Add Rule", + "no_fork_sync": "No fork sync" + }, + "issues": { + "title": "Issues", + "subtitle": "Track and manage project tasks and bugs", + "new_title": "Create New Issue", + "new_issue": "New issue", + "search_placeholder": "Search all issues...", + "sort": "Sort", + "label": "Label", + "assignee": "Assignee", + "open": "Open", + "closed": "Closed", + "no_project": "No project selected", + "select_project": "Select a project to view its issues", + "loading": "Loading issues...", + "load_failed": "Failed to load issues", + "no_issues": "No ${tab} issues", + "no_matches": "No matches found", + "all_caught_up": "You're all caught up! Create an issue to track new tasks.", + "try_adjusting": "Try adjusting your search or filters to find what you're looking for.", + "create_first": "Create your first issue", + "brief_description_placeholder": "Briefly describe the issue...", + "details_placeholder": "Explain the details, steps to reproduce, or requirements...", + "pro_tip": "Pro Tip", + "need_help": "Need Help?", + "submit": "Submit New Issue", + "issue_title_placeholder": "Issue Title", + "new_subtitle": "Open a new task or report a bug for this project", + "join_to_create": "Join this project before creating issues.", + "title_required": "Title is required", + "create_failed": "Failed to create issue. Please try again.", + "title_hint": "Keep it concise and descriptive", + "description": "Description (Optional)", + "markdown_supported": "Markdown is supported", + "characters": " characters", + "pro_tip_desc": "Use Markdown to format your code snippets, lists, and checklists to make the issue easier to read.", + "need_help_desc": "Check out our documentation on how to write effective issues and bug reports." + }, + "issue_detail": { + "loading": "Loading issue...", + "no_description": "No description provided.", + "no_comments": "No comments yet. Start the discussion!", + "comment_placeholder": "Write a comment... (Markdown supported)", + "edit": "Edit", + "close": "Close", + "delete": "Delete", + "link_pr": "Link a pull request...", + "link_repo": "Link a repo...", + "pull_requests": "Pull Requests", + "linked_repos": "Linked Repos", + "no_assigned": "No one assigned", + "assignees": "Assignees", + "assign_to_me": "Assign to me", + "suggestions": "Suggestions", + "labels": "Labels", + "apply_labels": "Apply labels", + "none_yet": "None yet", + "notifications": "Notifications", + "subscribe": "Subscribe", + "unsubscribe": "Unsubscribe", + "subscribers_count": " people subscribed", + "development": "Development", + "link_pr_title": "Link Pull Request", + "link_repo_title": "Link Repository", + "load_failed": "Failed to load issue", + "not_found": "Issue not found", + "delete_confirm": "Are you sure you want to delete this issue?", + "delete_comment_confirm": "Are you sure you want to delete this comment?", + "opened_on": "opened this issue on", + "comments": "comments", + "reopen": "Reopen", + "commented": "commented", + "commented_on": "commented on", + "edited": "• edited", + "conversation": "Conversation", + "write_comment": "Write a comment", + "comment_btn": "Comment" + }, + "pulls": { + "title": "Pull Requests", + "project_pulls": "${project} pull requests", + "close": "Close", + "reopen": "Reopen", + "confirm_delete": "Confirm", + "delete": "Delete", + "no_project": "No project selected", + "select_project": "Select a project to view its pull requests", + "loading": "Loading pull requests...", + "load_failed": "Failed to load pull requests", + "no_prs": "No pull requests", + "create_hint": "Create a pull request to propose changes" + }, + "skills": { + "title": "Skills", + "subtitle": "Project capabilities and AI skills", + "no_project": "No project selected", + "select_project": "Select a project to view its skills", + "loading": "Loading skills...", + "load_failed": "Failed to load skills", + "no_skills": "No skills", + "no_skills_desc": "Create or scan skills to enable AI assistance", + "create": "Create", + "scan": "Scan", + "scanning": "Scanning...", + "skill_name_placeholder": "my-skill", + "skill_display_name": "My Skill", + "skill_description_placeholder": "What this skill does", + "skill_content_placeholder": "Skill content or prompt...", + "delete": "Delete skill", + "create_title": "Create skill", + "create_desc": "Add a new AI skill to the project." + }, + "skill_detail": { + "loading": "Loading skill...", + "edit_title": "Edit skill", + "save_failed": "Save failed" + }, + "board": { + "title": "Board", + "no_boards": "No boards yet", + "no_boards_desc": "Create a Kanban board to start managing your workflow.", + "add_column": "Add Column", + "add_column_title": "Add Column", + "delete_board": "Delete Board", + "new_board_title": "New Board", + "board_name_placeholder": "e.g. Sprint Planning", + "board_desc_placeholder": "Optional details...", + "column_name": "Column Name", + "column_name_placeholder": "e.g. Doing", + "card_title_placeholder": "What needs to be done?", + "card_desc_placeholder": "Optional details...", + "card_detail_title": "Card Title", + "card_detail_desc_placeholder": "Add a more detailed description...", + "delete_card": "Delete Card", + "delete_column": "Delete column?", + "delete_card_confirm": "Delete this card?", + "empty_board": "Empty Board", + "empty_board_hint": "Add your first column (e.g. To Do, Doing, Done) to start tracking work.", + "add_first_column": "Add First Column", + "add_card": "Add card", + "new_card": "New Card", + "card_title": "Card Title", + "id": "ID", + "created": "Created", + "updated": "Updated" + } + }, + "me": { + "title": "My Profile", + "recent_repos": "Recent Repositories", + "top_projects": "Top Projects", + "latest_activity": "Latest Activity", + "no_projects": "No projects found", + "no_repos": "No repositories found", + "no_activity": "No recent activity", + "create_project": { + "title": "Create New Project", + "subtitle": "Start a new collaborative project", + "name_placeholder": "e.g. my-awesome-team", + "org_placeholder": "e.g. Acme Corporation", + "desc_placeholder": "What is this project about?", + "slug": "Project Slug", + "slug_required": "Required", + "slug_hint": "Used in URLs. Only lowercase letters, numbers, and dashes.", + "display_name": "Display Name", + "display_name_required": "Required", + "display_name_placeholder": "e.g. Acme Corporation", + "description": "Description", + "visibility": "Visibility", + "public_project": "Public Project", + "public_desc": "Anyone can discover and join", + "private_project": "Private Project", + "private_desc": "Only invited members can access", + "change": "Change", + "create_failed": "Failed to create project. The slug might already be taken.", + "create": "Create Project", + "cancel": "Cancel" + }, + "invitations": { + "title": "Invitations", + "subtitle": "Review project invitations and track your join requests.", + "pending": "Pending Invitations", + "pending_desc": "Invitations sent by project admins.", + "no_pending": "No pending invitations", + "no_pending_desc": "New project invitations will appear here.", + "join_requests": "Join Requests", + "join_requests_desc": "Your project membership applications.", + "no_requests": "No join requests", + "no_requests_desc": "Projects you apply to join will be tracked here.", + "invited_by": "Invited by", + "on": "on", + "accept": "Accept", + "reject": "Reject", + "submitted_on": "Submitted on", + "accepted": "Invitation accepted.", + "rejected": "Invitation rejected.", + "process_failed": "Failed to process invitation.", + "cancelled": "Join request cancelled.", + "cancel_failed": "Failed to cancel join request." + }, + "notifications": { + "no_notifications": "No notifications", + "no_unread": "No unread notifications", + "all_caught_up": "You're all caught up" + }, + "followers": { + "no_followers": "No followers yet" + }, + "following": { + "no_following": "No following" + }, + "user_not_found": "User not found", + "please_login": "Please make sure you are logged in.", + "profile": { + "joined": "Joined", + "projects": "Projects", + "repos": "Repos", + "stars": "Stars", + "followers": "Followers", + "follow": "Follow", + "unfollow": "Unfollow", + "contributions_in_year": "${count} contributions in the last year", + "contributions_on_date": "${count} contributions on ${date}", + "less": "Less", + "more": "More" + } + }, + "explore": { + "title": "Explore Projects", + "subtitle": "Discover public projects and communities", + "search_placeholder": "Search projects by name or description...", + "no_projects": "No projects found", + "no_description": "No description", + "found": "found", + "showing": "Showing discoverable projects", + "try_different": "Try a different search term", + "view_project": "View project" + }, + "chat": { + "conversations": { + "search_placeholder": "Search conversations...", + "no_conversations": "No conversations yet", + "no_matching": "No matching conversations", + "new_chat": "New Chat", + "chat_history": "Chat History", + "start_new_chat": "Start a new chat to begin exploring with AI.", + "try_different_search": "Try a different search term.", + "untitled_chat": "Untitled Chat", + "create_failed": "Failed to create conversation" + }, + "model_selector": { + "search_placeholder": "Search models...", + "loading": "Loading...", + "no_models": "No models found", + "please_select_model": "Please select a model first" + }, + "header": { + "new_chat": "New Chat", + "expand_history": "Expand Chat History", + "collapse_history": "Collapse Chat History", + "streaming": "Streaming...", + "share": "Share conversation", + "rename": "Rename", + "more": "More" + }, + "message_list": { + "welcome_title": "How can I help you today?", + "welcome_desc": "Ask anything - I can help with code, writing, analysis, and much more.", + "new_response": "New response", + "responding": "responding...", + "explain_code": "Explain a piece of code or debug an error", + "summarize_doc": "Summarize or draft a document", + "review_pr": "Review a pull request or repo", + "brainstorm": "Brainstorm ideas or outline a plan", + "jump_to_message": "Jump to your message ${index}" + } + }, + "channel": { + "no_selected": "No channel selected", + "no_threads": "No threads yet. Use the message action menu to start one.", + "no_messages": "No messages yet. Start the conversation!", + "edit_history": "Edit History", + "no_edit_history": "No edit history available.", + "no_pinned": "No pinned messages yet.", + "reply_placeholder": "Reply in thread...", + "beginning_of_channel": "Beginning of channel", + "scroll_up": "Scroll up to load older messages", + "new_message": "${count} new message", + "pinned_messages": "Pinned Messages", + "pinned_not_loaded": "Pinned message is not loaded in the current history window.", + "pinned": "Pinned", + "unpin": "Unpin" + }, + "search": { + "global_placeholder": "Search projects, rooms, repos, boards, issues...", + "no_results": "No results found.", + "no_messages": "No matching messages.", + "results": "Search Results", + "jump_to": "Jump to projects, rooms, repositories, boards, and issues.", + "messages": "Messages", + "quick_switch": "Quick Switch", + "projects": "Projects", + "my_home": "My Home", + "my_home_desc": "Open personal overview", + "explore_projects": "Explore Projects", + "explore_projects_desc": "Find public projects", + "chat": "Chat", + "chat_desc": "Open personal chat", + "project_overview": "Project Overview", + "repositories": "Repositories", + "issues": "Issues", + "boards": "Boards", + "project_settings": "Project Settings", + "rooms_in": "Rooms in ${project}", + "all_rooms": "All rooms in ${project}", + "messages_in": "Messages in ${project}", + "messages_in_room": "Messages in #${room}" + }, + "components": { + "message_input": { + "reply_preview": "Reply/Edit Preview Banner", + "add_emoji": "Add emoji", + "placeholder": "Message", + "replying": "Replying to message", + "editing": "Editing message" + }, + "inline_comment": { + "reply_placeholder": "Write a reply...", + "leave_comment": "Leave a comment..." + }, + "merge_panel": { + "commit_message_placeholder": "Enter a commit message..." + }, + "ai_settings": { + "add_ai": "Add AI", + "no_ai": "No AI agents active in this room", + "search_placeholder": "Search models...", + "system_prompt_placeholder": "You are a helpful assistant..." + }, + "room_settings": { + "name_placeholder": "e.g. general", + "topic_placeholder": "e.g. Engineering", + "no_group": "No group", + "create_new_group": "Create new group", + "delete_room": "Delete Room" + }, + "command": { + "placeholder": "Search for a command to run..." + }, + "pr": { + "create_merge_commit": "Create a merge commit", + "no_changes": "No changes in this pull request.", + "confirm_merge": "Confirm merge" + }, + "commit": { + "back_to_commits": "Back to commits", + "no_changes": "No changes in this commit." + }, + "repo": { + "no_description": "No description provided", + "no_description_default": "No description provided." + }, + "branches": { + "no_branches": "No branches", + "no_branches_desc": "Create your first branch to get started" + }, + "tags": { + "no_tags": "No tags", + "no_tags_desc": "Create a tag to mark a specific point in history" + }, + "favorites": { + "no_favorites": "No favorite messages yet." + } + }, + "project_create": { + "repo_name": "Repository Name", + "repo_name_placeholder": "e.g. core-api", + "repo_desc_placeholder": "What is this repo for?", + "group_name": "New Group Name", + "channel_name": "Channel Name", + "channel_name_placeholder": "e.g. development", + "board_name": "Board Name", + "board_name_placeholder": "e.g. Sprint Planning", + "board_desc_placeholder": "Task board details...", + "skill_name": "Skill Name", + "skill_name_placeholder": "e.g. Code Reviewer", + "skill_desc_placeholder": "What does this AI skill do?", + "email_placeholder": "user@example.com", + "no_group": "No group", + "create_new_group": "Create new group", + "create_repo": "Create Repository", + "create_channel": "Create Channel", + "create_board": "Create Board", + "create_skill": "Create Skill", + "quick_start": "Quick Start", + "repo_tab": "Repo", + "channel_tab": "Channel", + "board_tab": "Board", + "skill_tab": "Skill", + "invite_tab": "Invite", + "description_optional": "Description (Optional)", + "private_desc": "Only members can see", + "public_desc": "Visible to everyone", + "group": "Group", + "group_name_placeholder": "e.g. Engineering", + "enter_group_name": "Enter a group name.", + "public_channel": "Public Channel", + "private_channel": "Private Channel", + "public_channel_desc": "Everyone in project can join", + "private_channel_desc": "Only invited members", + "invite_email": "Invite by Email", + "role": "Role", + "member_role": "Member", + "admin_role": "Admin", + "send_invitation": "Send Invitation", + "create_failed_repo": "Failed to create repository.", + "create_failed_channel": "Failed to create channel.", + "create_failed_board": "Failed to create board.", + "create_failed_skill": "Failed to create skill.", + "create_failed_invite": "Failed to send invitation." + } +} diff --git a/src/i18n/fr.json b/src/i18n/fr.json index e69de29..8432e53 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -0,0 +1,831 @@ +{ + "auth": { + "login": { + "title": "Bon retour !", + "subtitle": "Nous sommes ravis de vous revoir !", + "account": "Compte", + "account_required": "Veuillez saisir le nom d'utilisateur", + "account_placeholder": "Nom d'utilisateur ou e-mail", + "password": "Mot de passe", + "password_required": "Veuillez saisir le mot de passe", + "verification": "Vérification", + "captcha_placeholder": "Entrez le code", + "captcha_required": "Veuillez saisir le code", + "captcha_title": "Cliquez pour actualiser", + "2fa_code": "Code 2FA", + "2fa_placeholder": "code à 6 chiffres", + "forgot_password": "Mot de passe oublié ?", + "submit": "Se connecter", + "submit_loading": "Connexion en cours...", + "need_account": "Pas encore de compte ?", + "register": "S'inscrire", + "error": { + "failed_to_load_captcha": "Échec du chargement du captcha", + "two_factor_required": "Authentification à deux facteurs requise", + "invalid_credentials": "Nom d'utilisateur ou mot de passe invalide", + "login_failed": "Échec de la connexion" + } + }, + "register": { + "title": "Créer un compte", + "subtitle": "Rejoignez-nous aujourd'hui !", + "username": "Nom d'utilisateur", + "username_placeholder": "Choisissez un nom d'utilisateur", + "username_required": "Le nom d'utilisateur est requis", + "username_min_length": "Le nom d'utilisateur doit comporter au moins 3 caractères", + "username_pattern": "Le nom d'utilisateur ne peut contenir que des lettres, des chiffres, _ et -", + "email": "E-mail", + "email_placeholder": "Entrez votre e-mail", + "email_required": "L'e-mail est requis", + "email_invalid": "Adresse e-mail invalide", + "password_placeholder": "Créez un mot de passe", + "confirm_password": "Confirmer le mot de passe", + "confirm_password_placeholder": "Confirmez votre mot de passe", + "confirm_password_required": "Veuillez confirmer votre mot de passe", + "password_min_length": "Le mot de passe doit comporter au moins 8 caractères", + "captcha_placeholder": "Entrez le code", + "submit": "Créer un compte", + "submit_loading": "Création en cours...", + "already_have_account": "Vous avez déjà un compte ?", + "login": "Se connecter", + "passwords_not_match": "Les mots de passe ne correspondent pas", + "user_exists": "Le nom d'utilisateur ou l'e-mail existe déjà", + "registration_failed": "Échec de l'inscription" + }, + "forgot_password": { + "email_placeholder": "Entrez votre e-mail", + "captcha_placeholder": "Entrez le code", + "submit": "Continuer", + "back_to_login": "Retour à la connexion" + }, + "reset_password": { + "new_password_placeholder": "Entrez le nouveau mot de passe", + "confirm_password_placeholder": "Confirmez le nouveau mot de passe", + "captcha_placeholder": "Entrez le code", + "submit": "Réinitialiser le mot de passe", + "back_to_login": "Retour à la connexion" + }, + "change_password": { + "title": "Changer le mot de passe", + "subtitle": "Mettez à jour le mot de passe de votre compte", + "current_password_placeholder": "Entrez le mot de passe actuel", + "new_password_placeholder": "Entrez le nouveau mot de passe", + "confirm_password_placeholder": "Confirmez le nouveau mot de passe", + "captcha_placeholder": "Entrez le code", + "submit": "Mettre à jour le mot de passe", + "back_to_login": "Retour à la connexion" + }, + "two_factor": { + "title": "Authentification à deux facteurs", + "enabled": "La 2FA est actuellement activée", + "disabled": "Ajoutez une couche de sécurité supplémentaire", + "description": "L'authentification à deux facteurs ajoute une couche de sécurité supplémentaire à votre compte en exigeant plus qu'un simple mot de passe pour vous connecter.", + "enabled_message": "L'authentification à deux facteurs est actuellement activée sur votre compte.", + "scan_qr_code": "Scanner le code QR", + "or_enter_manually": "Ou entrez ce code manuellement", + "verification_code": "Code de vérification", + "code_required": "Le code est requis", + "code_placeholder": "Entrez le code à 6 chiffres", + "password_placeholder": "Entrez votre mot de passe", + "submit": "Vérifier", + "cancel": "Annuler", + "back": "Retour", + "enable": "Activer la 2FA", + "disable": "Désactiver la 2FA", + "disabling": "Désactivation...", + "error": { + "load_failed": "Échec du chargement du statut 2FA", + "enable_failed": "Échec de l'activation de la 2FA", + "disable_failed": "Échec de la désactivation de la 2FA", + "invalid_code": "Code de vérification invalide" + } + }, + "verify_email": { + "title": "Vérifier l'e-mail" + } + }, + "common": { + "actions": { + "save": "Enregistrer", + "save_changes": "Enregistrer les modifications", + "cancel": "Annuler", + "delete": "Supprimer", + "edit": "Modifier", + "create": "Créer", + "submit": "Soumettre", + "close": "Fermer", + "confirm": "Confirmer", + "back": "Retour", + "remove": "Supprimer", + "add": "Ajouter", + "retry": "Réessayer", + "generate": "Générer", + "loading": "Chargement..." + }, + "states": { + "loading": "Chargement...", + "no_results": "Aucun résultat trouvé", + "error_occurred": "Une erreur s'est produite" + }, + "placeholders": { + "search": "Rechercher...", + "message": "Message" + } + }, + "navigation": { + "search": "Rechercher", + "search_shortcut": "Rechercher (Ctrl+K)", + "favorites": "Messages favoris", + "no_channels": "Aucune chaîne" + }, + "settings": { + "access_keys": { + "title": "Jetons d'accès personnels", + "description": "Jetons que vous avez générés pour accéder à l'API.", + "generate_button": "Générer un nouveau jeton", + "token_name": "Nom du jeton", + "copy_warning": "Assurez-vous de copier votre jeton d'accès personnel maintenant. Vous ne pourrez plus le voir !", + "empty": "Aucun jeton d'accès personnel trouvé.", + "created_on": "Créé le", + "expires": "Expire", + "generate_token": "Générer le jeton", + "messages": { + "name_required": "Veuillez saisir le nom du jeton", + "created_success": "Jeton créé, veuillez le copier rapidement.", + "delete_success": "Jeton supprimé", + "create_failed": "Échec de la création, veuillez réessayer", + "delete_failed": "Échec de la suppression" + } + }, + "ssh_keys": { + "title": "Clés SSH", + "description": "Gérer les clés SSH pour les opérations Git", + "add_button": "Ajouter une clé", + "add_title": "Ajouter une nouvelle clé SSH", + "title_label": "Titre", + "public_key_label": "Clé publique", + "name_placeholder": "Ex: MacBook personnel", + "key_placeholder": "ssh-rsa AAAAB3NzaC... user@host", + "empty_title": "Aucune clé SSH ajoutée", + "empty_desc": "Ajoutez des clés SSH pour effectuer des opérations Git en toute sécurité", + "verified": "Vérifié", + "messages": { + "title_key_required": "Veuillez saisir un titre et une clé publique", + "add_success": "Clé SSH ajoutée avec succès", + "add_failed": "Échec de l'ajout, vérifiez le format de la clé", + "delete_success": "Clé SSH supprimée", + "delete_failed": "Échec de la suppression", + "title_updated": "Titre mis à jour", + "update_failed": "Échec de la mise à jour" + } + }, + "password": { + "title": "Changer le mot de passe", + "subtitle": "Protégez votre compte avec un mot de passe fort", + "current_password": "Mot de passe actuel", + "current_password_placeholder": "Entrez le mot de passe actuel", + "new_password": "Nouveau mot de passe", + "new_password_placeholder": "Entrez le nouveau mot de passe (8 caractères minimum)", + "confirm_password": "Confirmer le nouveau mot de passe", + "confirm_password_placeholder": "Ressaisissez le nouveau mot de passe", + "submit": "Changer le mot de passe", + "mismatch": "Les mots de passe ne correspondent pas", + "min_length": "Le mot de passe doit comporter au moins 8 caractères", + "change_success": "Mot de passe modifié avec succès", + "change_failed": "Échec de la modification, vérifiez votre mot de passe actuel" + }, + "account": { + "title": "Mon compte", + "display_name_placeholder": "Définir le nom d'affichage", + "website_placeholder": "https://example.com", + "company_placeholder": "Organisation (facultatif)" + }, + "email": { + "title": "E-mail", + "current_email": "E-mail actuel", + "new_email_placeholder": "new@example.com", + "current_password_placeholder": "Entrez le mot de passe actuel" + }, + "billing": { + "title": "Facturation", + "personal_billing": "Facturation personnelle", + "monthly_quota": "Quota mensuel", + "monthly_usage": "Utilisation mensuelle", + "billing_errors": "Erreurs de facturation", + "no_history": "Aucun historique de facturation" + }, + "appearance": { + "title": "Apparence", + "timezone_asia_shanghai": "Asie/Shanghai (UTC+8)", + "timezone_asia_tokyo": "Asie/Tokyo (UTC+9)", + "timezone_america_ny": "Amérique/New_York (UTC-5)", + "timezone_america_la": "Amérique/Los_Angeles (UTC-8)", + "timezone_europe_london": "Europe/London (UTC+0)" + }, + "notifications": { + "title": "Notifications" + }, + "settings_nav": { + "user_settings": "Paramètres utilisateur", + "my_account": "Mon compte", + "billing": "Facturation", + "appearance": "Apparence", + "notifications": "Notifications", + "push_settings": "Paramètres push", + "security": "Sécurité", + "change_password": "Modifier le mot de passe", + "email": "E-mail", + "ssh_keys": "Clés SSH", + "access_tokens": "Jetons d'accès" + }, + "my_account": { + "title": "Mon compte", + "subtitle": "Gérez vos informations personnelles", + "avatar": "Avatar", + "upload_avatar": "Télécharger un nouvel avatar", + "remove": "Supprimer", + "avatar_hint": "Prend en charge JPG, PNG ou GIF. Max 2 Mo.", + "username": "Nom d'utilisateur", + "display_name": "Nom d'affichage", + "display_name_placeholder": "Définir le nom d'affichage", + "website": "Site web", + "website_placeholder": "https://example.com", + "organization": "Organisation", + "org_placeholder": "Organisation (facultatif)", + "save_changes": "Enregistrer les modifications", + "load_failed": "Échec du chargement du profil", + "save_success": "Profil enregistré", + "save_failed": "Échec de l'enregistrement, veuillez réessayer", + "avatar_size_error": "L'image doit être inférieure à 2 Mo", + "avatar_upload_success": "Avatar téléchargé, veuillez enregistrer les modifications", + "avatar_upload_failed": "Échec du téléchargement de l'avatar" + }, + "email_page": { + "title": "E-mail", + "subtitle": "Gérez votre adresse e-mail", + "current_email": "E-mail actuel", + "no_email_set": "Aucun e-mail défini", + "new_email": "Nouvel e-mail", + "current_password": "Mot de passe actuel (pour vérification)", + "current_password_placeholder": "Entrez le mot de passe actuel", + "save_button": "Changer d'e-mail", + "load_failed": "Échec du chargement des informations e-mail", + "fill_all_fields": "Veuillez remplir tous les champs", + "verification_sent": "E-mail de vérification envoyé, veuillez vérifier votre nouvelle boîte", + "change_failed": "Échec du changement d'e-mail, veuillez vérifier votre mot de passe" + }, + "notifications_page": { + "title": "Notifications", + "subtitle": "Gérez vos préférences de notification", + "channels": "Canaux de notification", + "email_notifications": "Notifications par e-mail", + "email_notifications_desc": "Recevoir des notifications par e-mail", + "in_app_notifications": "Notifications dans l'application", + "in_app_notifications_desc": "Recevoir des rappels dans l'application", + "push_notifications": "Notifications push", + "push_notifications_desc": "Recevoir des notifications par push du navigateur", + "digest_mode": "Mode résumé", + "instant": "Instantané", + "daily_digest": "Résumé quotidien", + "weekly_digest": "Résumé hebdomadaire", + "off": "Désactivé", + "notification_types": "Types de notification", + "security_notifications": "Notifications de sécurité", + "security_notifications_desc": "Notifications liées à la sécurité du compte", + "product_updates": "Mises à jour produit", + "product_updates_desc": "Nouvelles fonctionnalités et notifications de mise à jour", + "marketing_emails": "E-mails marketing", + "marketing_emails_desc": "Promotions et offres", + "do_not_disturb": "Ne pas déranger", + "enable_dnd": "Activer Ne pas déranger", + "enable_dnd_desc": "Ne pas recevoir de notifications pendant une période donnée", + "save_button": "Enregistrer les modifications", + "load_failed": "Échec du chargement des préférences de notification", + "save_success": "Paramètres de notification enregistrés", + "save_failed": "Échec de l'enregistrement, veuillez réessayer" + }, + "appearance_page": { + "title": "Apparence", + "subtitle": "Personnalisez l'apparence et la langue de l'application", + "theme_scheme": "Schéma de thème", + "select_theme_scheme": "Sélectionner le schéma de thème", + "theme": "Thème", + "language": "Langue", + "timezone": "Fuseau horaire", + "dark": "Sombre", + "light": "Clair", + "system": "Système", + "basic_settings": "Paramètres de base", + "custom": "Personnalisé", + "save_button": "Enregistrer les modifications", + "load_failed": "Échec du chargement des préférences", + "save_success": "Paramètres d'apparence enregistrés", + "save_failed": "Échec de l'enregistrement, veuillez réessayer" + }, + "billing": { + "title": "Facturation", + "personal_billing": "Facturation personnelle", + "balance": "Solde", + "monthly_quota": "Quota mensuel", + "monthly_usage": "Utilisation mensuelle", + "billing_errors": "Erreurs de facturation", + "history": "Historique de facturation", + "date": "Date", + "reason": "Raison", + "amount": "Montant", + "no_history": "Aucun historique de facturation", + "load_failed": "Échec du chargement de la facturation", + "insufficient_balance": "Solde insuffisant" + }, + "push": { + "title": "Notifications push", + "subtitle": "Recevez des alertes en temps réel même lorsque lapplication est fermée.", + "enable": "Activer les notifications push", + "enable_desc": "Soyez averti des mentions, des issues et des alertes système.", + "filters_title": "Filtres de notification", + "mentions": "Mentions et réponses", + "new_issues": "Nouveaux issues", + "system_updates": "Mises à jour système", + "coming_soon": "Contrôles plus granulaires à venir.", + "not_supported": "Les notifications push ne sont pas prises en charge", + "not_supported_desc": "Votre navigateur ou environnement ne prend pas en charge Web Push. Veuillez utiliser un navigateur de bureau moderne.", + "load_failed": "Échec du chargement des paramètres de notification", + "permission_denied": "Permission de notification refusée par le navigateur", + "update_failed": "Échec de la mise à jour des paramètres push", + "saved": "Paramètres enregistrés avec succès" + } + }, + "project": { + "layout": { + "expand_sidebar": "Développer la barre latérale", + "join_banner": { + "preview_mode": "Mode aperçu", + "join_to_participate": "Rejoignez ce projet pour participer et utiliser les outils.", + "read_only": "Ce projet public est en lecture seule jusqu'à ce que vous rejoigniez.", + "join_to_use": "Vous devez rejoindre avant d'utiliser les actions du projet.", + "apply_to_join": "Demander à rejoindre" + } + }, + "invitation": { + "title": "Invitation au projet", + "no_pending": "Aucune invitation en attente" + }, + "join": { + "title": "Rejoindre ${project}", + "reason_placeholder": "Expliquez aux administrateurs pourquoi vous souhaitez rejoindre.", + "cancel_request": "Annuler la demande", + "join_without_reason": "Rejoindre le projet", + "submit_request": "Soumettre la demande d'adhésion", + "default_desc": "Soumettez une demande pour devenir membre du projet.", + "already_member": "Vous êtes déjà membre de ce projet.", + "open_project": "Ouvrir le projet", + "current_request": "Demande actuelle", + "submitted_on": "Soumis le", + "message": "Message", + "message_desc": "Optionnel, mais utile pour les projets privés ou nécessitant une approbation.", + "submitted": "Demande dadhésion soumise.", + "submit_failed": "Échec de la soumission de la demande.", + "cancelled": "Demande dadhésion annulée.", + "cancel_failed": "Échec de lannulation de la demande." + }, + "settings": { + "general": { + "title": "Identité du projet", + "project_name": "Nom du projet", + "project_name_placeholder": "Ex: my-awesome-project", + "display_name": "Nom d'affichage", + "display_name_placeholder": "Ex: Mon Super Projet", + "description": "Description", + "description_placeholder": "Parlez aux gens de ce projet...", + "leave": "Quitter ce projet", + "leave_desc": "Vous perdrez accès à toutes les ressources du projet.", + "leave_btn": "Quitter le projet", + "leave_confirm": "Voulez-vous vraiment quitter ce projet ?", + "leave_success": "Vous avez quitté le projet.", + "leave_failed": "Impossible de quitter le projet." + }, + "billing": { + "title": "Facturation actuelle", + "monthly_quota": "Quota mensuel", + "monthly_usage": "Utilisation mensuelle", + "billing_errors": "Erreurs de facturation", + "no_history": "Aucun historique de facturation" + }, + "access": { + "invite_member": "Inviter un membre", + "join_settings": "Paramètres d'adhésion", + "pending_invitations": "Invitations en attente", + "join_requests": "Demandes d'adhésion", + "email_placeholder": "user@example.com", + "reason_placeholder": "Que voulez-vous demander ?", + "optional_reason": "Raison optionnelle affichée dans l'historique des demandes." + }, + "labels": { + "create_label": "Créer une étiquette", + "label_name_placeholder": "label-name", + "description_placeholder": "description", + "no_labels": "Aucune étiquette" + }, + "members": { + "remove_confirm": "Supprimer ${username} de ce projet ?", + "remove": "Supprimer" + }, + "danger_zone": { + "delete_project": "Supprimer ce projet", + "delete_button": "Supprimer le projet" + } + }, + "repos": { + "title": "Dépôts", + "subtitle": "Hébergez et gérez le code source de votre projet", + "find_placeholder": "Trouver un dépôt...", + "no_repos": "Aucun dépôt", + "no_repos_desc": "Créez votre premier dépôt", + "create_new": "Créer un nouveau dépôt", + "create_new_desc": "Hébergez votre code et collaborez", + "loading": "Chargement des dépôts...", + "load_failed": "Échec du chargement des dépôts", + "new_repo": "Nouveau dépôt", + "type": "Type", + "sort": "Trier", + "repository": "dépôt", + "repositories": "dépôts", + "grid_view": "Vue grille", + "list_view": "Vue liste", + "private": "Privé", + "public": "Public", + "no_description_default": "Aucune description fournie.", + "never": "Jamais", + "just_now": "À l'instant", + "min_ago": "il y a ${count} min", + "hr_ago": "il y a ${count} h", + "day_ago": "il y a ${count} j" + }, + "repo": { + "settings": { + "title": "Identité du dépôt", + "name_placeholder": "Nom du dépôt", + "description_placeholder": "Parlez aux gens de ce dépôt...", + "default_branch": "Branche par défaut", + "branch_placeholder": "main", + "desc": "Mettre à jour les informations de base du dépôt" + }, + "code": "Code", + "commits": "Commits", + "pull_requests": "Pull Requests", + "branches": "Branches", + "tags": "Tags", + "settings_tab": "Paramètres", + "loading": "Chargement du dépôt...", + "load_failed": "Échec du chargement du dépôt", + "not_found": "Dépôt introuvable" + }, + "branch_protection": { + "title": "Règles de protection des branches", + "branch_pattern": "Motif de branche", + "required_approvals": "Approbations requises", + "active_rules": "Règles actives", + "no_rules": "Aucune règle de protection configurée", + "create_rule_hint": "Créez une règle ci-dessus pour protéger vos branches", + "add_rule": "Ajouter une règle", + "no_fork_sync": "Pas de synchronisation fork" + } + }, + "issues": { + "title": "Issues", + "subtitle": "Suivez et gérez les tâches et les bugs du projet", + "new_title": "Créer un nouvel issue", + "new_issue": "Nouvel issue", + "search_placeholder": "Rechercher tous les issues...", + "sort": "Trier", + "label": "Étiquette", + "assignee": "Assigné", + "open": "Ouvert", + "closed": "Fermé", + "no_project": "Aucun projet sélectionné", + "select_project": "Sélectionnez un projet pour voir ses issues", + "loading": "Chargement des issues...", + "load_failed": "Échec du chargement des issues", + "no_issues": "Aucun issue ${tab}", + "no_matches": "Aucun résultat trouvé", + "all_caught_up": "Tout est à jour ! Créez un issue pour suivre les nouvelles tâches.", + "try_adjusting": "Essayez d'ajuster votre recherche ou vos filtres.", + "create_first": "Créez votre premier issue", + "brief_description_placeholder": "Décrivez brièvement le problème...", + "details_placeholder": "Expliquez les détails, les étapes de reproduction ou les exigences...", + "pro_tip": "Conseil", + "need_help": "Besoin d'aide ?", + "submit": "Soumettre le nouvel issue", + "issue_title_placeholder": "Titre de l'issue" + }, + "issue_detail": { + "loading": "Chargement de l'issue...", + "no_description": "Aucune description fournie.", + "no_comments": "Aucun commentaire. Commencez la discussion !", + "comment_placeholder": "Écrire un commentaire... (Markdown pris en charge)", + "edit": "Modifier", + "close": "Fermer", + "delete": "Supprimer", + "link_pr": "Lier une Pull Request", + "link_repo": "Lier un dépôt", + "pull_requests": "Pull Requests", + "linked_repos": "Dépôts liés", + "no_assigned": "Personne assigné" + }, + "pulls": { + "title": "Pull Requests", + "project_pulls": "Pull requests ${project}", + "close": "Fermer", + "reopen": "Rouvrir", + "confirm_delete": "Confirmer", + "delete": "Supprimer", + "no_project": "Aucun projet sélectionné", + "select_project": "Sélectionnez un projet pour voir ses pull requests", + "loading": "Chargement des pull requests...", + "load_failed": "Échec du chargement des pull requests", + "no_prs": "Aucune pull request", + "create_hint": "Créez une pull request pour proposer des modifications" + }, + "skills": { + "title": "Compétences", + "subtitle": "Capacités du projet et compétences IA", + "no_project": "Aucun projet sélectionné", + "select_project": "Sélectionnez un projet pour voir ses compétences", + "loading": "Chargement des compétences...", + "load_failed": "Échec du chargement des compétences", + "no_skills": "Aucune compétence", + "no_skills_desc": "Créez ou scannez des compétences pour activer l'assistance IA", + "create": "Créer", + "scan": "Scanner", + "scanning": "Scan en cours...", + "skill_name_placeholder": "my-skill", + "skill_display_name": "Ma compétence", + "skill_description_placeholder": "Ce que fait cette compétence", + "skill_content_placeholder": "Contenu ou prompt de la compétence...", + "delete": "Supprimer la compétence", + "create_title": "Créer une compétence", + "create_desc": "Ajoutez une nouvelle compétence IA au projet." + }, + "skill_detail": { + "loading": "Chargement de la compétence...", + "edit_title": "Modifier la compétence", + "save_failed": "Échec de l'enregistrement" + }, + "board": { + "title": "Tableau", + "no_boards": "Aucun tableau", + "no_boards_desc": "Créez un tableau Kanban pour gérer votre flux de travail.", + "add_column": "Ajouter une colonne", + "add_column_title": "Ajouter une colonne", + "delete_board": "Supprimer le tableau", + "new_board_title": "Nouveau tableau", + "board_name_placeholder": "Ex: Planification Sprint", + "board_desc_placeholder": "Détails (optionnel)...", + "column_name": "Nom de la colonne", + "column_name_placeholder": "Ex: En cours", + "card_title_placeholder": "Que faut-il faire ?", + "card_desc_placeholder": "Détails (optionnel)...", + "card_detail_title": "Titre de la carte", + "card_detail_desc_placeholder": "Ajoutez une description plus détaillée...", + "delete_card": "Supprimer la carte", + "delete_column": "Supprimer cette colonne ?", + "delete_card_confirm": "Supprimer cette carte ?", + "empty_board": "Tableau vide", + "empty_board_hint": "Ajoutez votre première colonne (ex: À faire, En cours, Terminé) pour commencer le suivi.", + "add_first_column": "Ajouter la première colonne", + "add_card": "Ajouter une carte", + "new_card": "Nouvelle carte", + "card_title": "Titre de la carte", + "id": "ID", + "created": "Créé", + "updated": "Mis à jour" + }, + "me": { + "title": "Mon profil", + "recent_repos": "Dépôts récents", + "top_projects": "Projets populaires", + "latest_activity": "Activité récente", + "no_projects": "Aucun projet trouvé", + "no_repos": "Aucun dépôt trouvé", + "no_activity": "Aucune activité récente", + "create_project": { + "title": "Créer un nouveau projet", + "subtitle": "Démarrer un nouveau projet collaboratif", + "name_placeholder": "Ex: my-awesome-team", + "org_placeholder": "Ex: Acme Corporation", + "desc_placeholder": "Quel est ce projet ?", + "slug": "Slug du projet", + "slug_required": "Obligatoire", + "slug_hint": "Utilisé dans les URL. Minuscules, chiffres et tirets uniquement.", + "display_name": "Nom d'affichage", + "display_name_required": "Obligatoire", + "display_name_placeholder": "Ex: Acme Corporation", + "description": "Description", + "visibility": "Visibilité", + "public_project": "Projet public", + "public_desc": "Tout le monde peut découvrir et rejoindre", + "private_project": "Projet privé", + "private_desc": "Seuls les membres invités peuvent accéder", + "change": "Modifier", + "create_failed": "Échec de la création du projet. Le slug est peut-être déjà pris.", + "create": "Créer le projet", + "cancel": "Annuler" + }, + "invitations": { + "pending": "Invitations en attente", + "no_pending": "Aucune invitation en attente", + "join_requests": "Demandes d'adhésion", + "no_requests": "Aucune demande d'adhésion" + }, + "notifications": { + "no_notifications": "Aucune notification", + "no_unread": "Aucune notification non lue", + "all_caught_up": "Tout est à jour" + }, + "followers": { + "no_followers": "Aucun follower" + }, + "following": { + "no_following": "Aucun abonnement" + }, + "user_not_found": "Utilisateur non trouvé", + "please_login": "Assurez-vous dêtre connecté.", + "profile": { + "joined": "Inscrit", + "projects": "Projets", + "repos": "Dépôts", + "stars": "Étoiles", + "followers": "Abonnés", + "follow": "Suivre", + "unfollow": "Ne plus suivre", + "contributions_in_year": "${count} contributions cette année", + "contributions_on_date": "${count} contributions le ${date}", + "less": "Moins", + "more": "Plus" + } + }, + "explore": { + "title": "Explorer les projets", + "subtitle": "Découvrez des projets publics et des communautés", + "search_placeholder": "Rechercher des projets par nom ou description...", + "no_projects": "Aucun projet trouvé", + "no_description": "Pas de description", + "found": "trouvé(s)", + "showing": "Affichage des projets découvrables", + "try_different": "Essayez un autre terme de recherche", + "view_project": "Voir le projet" + }, + "chat": { + "conversations": { + "search_placeholder": "Rechercher des conversations...", + "no_conversations": "Aucune conversation", + "no_matching": "Aucune conversation correspondante", + "new_chat": "Nouveau chat", + "chat_history": "Historique du chat", + "start_new_chat": "Démarrez une nouvelle conversation pour explorer avec l'IA.", + "try_different_search": "Essayez un autre terme de recherche.", + "untitled_chat": "Chat sans titre", + "create_failed": "Échec de la création de la conversation" + }, + "model_selector": { + "search_placeholder": "Rechercher des modèles...", + "loading": "Chargement...", + "no_models": "Aucun modèle trouvé", + "please_select_model": "Veuillez d'abord sélectionner un modèle" + }, + "header": { + "new_chat": "Nouveau chat", + "expand_history": "Développer l'historique", + "collapse_history": "Réduire l'historique", + "streaming": "Streaming...", + "share": "Partager la conversation", + "rename": "Renommer", + "more": "Plus" + }, + "message_list": { + "welcome_title": "Comment puis-je vous aider aujourd'hui?", + "welcome_desc": "Posez n'importe quelle question - Je peux aider avec du code, de l'écriture, de l'analyse, et bien plus.", + "new_response": "Nouvelle réponse", + "responding": "en train de répondre...", + "explain_code": "Expliquer du code ou déboguer une erreur", + "summarize_doc": "Résumer ou rédiger un document", + "review_pr": "Examiner une pull request ou un dépôt", + "brainstorm": "Brainstormer des idées ou élaborer un plan", + "jump_to_message": "Aller à votre message ${index}" + } + }, + "channel": { + "no_selected": "Aucune chaîne sélectionnée", + "no_threads": "Aucun fil encore. Utilisez le menu d'action du message pour en démarrer un.", + "no_messages": "Aucun message. Commencez la conversation !", + "edit_history": "Historique des modifications", + "no_edit_history": "Aucun historique de modification disponible.", + "no_pinned": "Aucun message épinglé.", + "reply_placeholder": "Répondre au fil...", + "beginning_of_channel": "Début de la chaîne", + "scroll_up": "Faites défiler vers le haut pour charger les messages plus anciens", + "new_message": "${count} nouveau(x) message(s)" + }, + "search": { + "global_placeholder": "Rechercher projets, chaînes, dépôts, tableaux, issues...", + "no_results": "Aucun résultat.", + "no_messages": "Aucun message correspondant.", + "results": "Résultats de recherche", + "jump_to": "Accédez aux projets, chaînes, dépôts, tableaux et issues.", + "messages": "Messages", + "quick_switch": "Bascule rapide", + "projects": "Projets", + "my_home": "Mon espace", + "my_home_desc": "Ouvrir l'aperçu personnel", + "explore_projects": "Explorer les projets", + "explore_projects_desc": "Trouver des projets publics", + "chat": "Chat", + "chat_desc": "Ouvrir le chat personnel", + "project_overview": "Aperçu du projet", + "repositories": "Dépôts", + "issues": "Issues", + "boards": "Tableaux", + "project_settings": "Paramètres du projet", + "rooms_in": "Salons dans ${project}", + "all_rooms": "Tous les salons dans ${project}", + "messages_in": "Messages dans ${project}", + "messages_in_room": "Messages dans #${room}" + }, + "components": { + "message_input": { + "reply_preview": "Bannière d'aperçu réponse/modification", + "add_emoji": "Ajouter un emoji", + "placeholder": "Message", + "replying": "Répondre au message", + "editing": "Modification du message" + }, + "inline_comment": { + "reply_placeholder": "Écrire une réponse...", + "leave_comment": "Laisser un commentaire..." + }, + "merge_panel": { + "commit_message_placeholder": "Entrez un message de commit..." + }, + "ai_settings": { + "add_ai": "Ajouter IA", + "no_ai": "Aucun agent IA actif dans cette chaîne", + "search_placeholder": "Rechercher des modèles...", + "system_prompt_placeholder": "Vous êtes un assistant utile..." + }, + "room_settings": { + "name_placeholder": "Ex: général", + "topic_placeholder": "Ex: Ingénierie", + "no_group": "Aucun groupe", + "create_new_group": "Créer un nouveau groupe", + "delete_room": "Supprimer la chaîne" + }, + "command": { + "placeholder": "Rechercher une commande à exécuter..." + }, + "pr": { + "create_merge_commit": "Créer un commit de fusion", + "no_changes": "Aucune modification dans cette Pull Request.", + "confirm_merge": "Confirmer la fusion" + }, + "commit": { + "back_to_commits": "Retour aux commits", + "no_changes": "Aucune modification dans ce commit." + }, + "repo": { + "no_description": "Aucune description", + "no_description_default": "Aucune description fournie." + }, + "branches": { + "no_branches": "Aucune branche", + "no_branches_desc": "Créez votre première branche pour commencer" + }, + "tags": { + "no_tags": "Aucune étiquette", + "no_tags_desc": "Créez une étiquette pour marquer un point spécifique dans l'historique" + }, + "favorites": { + "no_favorites": "Aucun message favori pour le moment." + } + }, + "project_create": { + "repo_name": "Nom du dépôt", + "repo_name_placeholder": "Ex: core-api", + "repo_desc_placeholder": "À quoi sert ce dépôt ?", + "group_name": "Nom du nouveau groupe", + "channel_name": "Nom de la chaîne", + "channel_name_placeholder": "Ex: development", + "board_name": "Nom du tableau", + "board_name_placeholder": "Ex: Planification Sprint", + "board_desc_placeholder": "Détails du tableau de tâches...", + "skill_name": "Nom de la compétence", + "skill_name_placeholder": "Ex: Réviseur de code", + "skill_desc_placeholder": "Que fait cette compétence IA ?", + "email_placeholder": "user@example.com", + "no_group": "Aucun groupe", + "create_new_group": "Créer un nouveau groupe", + "create_repo": "Créer un dépôt", + "create_channel": "Créer une chaîne", + "create_board": "Créer un tableau", + "create_skill": "Créer une compétence" + } +} diff --git a/src/i18n/jp.json b/src/i18n/jp.json index e69de29..23a0364 100644 --- a/src/i18n/jp.json +++ b/src/i18n/jp.json @@ -0,0 +1,831 @@ +{ + "auth": { + "login": { + "title": "おかえりなさい!", + "subtitle": "また会えてとても嬉しいです!", + "account": "アカウント", + "account_required": "ユーザー名を入力してください", + "account_placeholder": "ユーザー名またはメールアドレス", + "password": "パスワード", + "password_required": "パスワードを入力してください", + "verification": "認証", + "captcha_placeholder": "コードを入力", + "captcha_required": "コードを入力してください", + "captcha_title": "クリックで更新", + "2fa_code": "2FAコード", + "2fa_placeholder": "6桁の数字", + "forgot_password": "パスワードをお忘れですか?", + "submit": "ログイン", + "submit_loading": "ログイン中...", + "need_account": "アカウントをお持ちでない方?", + "register": "登録", + "error": { + "failed_to_load_captcha": "キャプチャの読み込みに失敗しました", + "two_factor_required": "二要素認証が必要です", + "invalid_credentials": "ユーザー名またはパスワードが無効です", + "login_failed": "ログインに失敗しました" + } + }, + "register": { + "title": "アカウントを作成", + "subtitle": "今すぐ参加!", + "username": "ユーザー名", + "username_placeholder": "ユーザー名を選択", + "username_required": "ユーザー名を入力してください", + "username_min_length": "ユーザー名は3文字以上である必要があります", + "username_pattern": "ユーザー名には文字、数字、アンダースコア、ハイフンのみ使用できます", + "email": "メールアドレス", + "email_placeholder": "メールアドレスを入力", + "email_required": "メールアドレスを入力してください", + "email_invalid": "メールアドレスの形式が無効です", + "password_placeholder": "パスワードを作成", + "confirm_password": "パスワードの確認", + "confirm_password_placeholder": "パスワードを再入力", + "confirm_password_required": "パスワードの確認を入力してください", + "password_min_length": "パスワードは8文字以上である必要があります", + "captcha_placeholder": "コードを入力", + "submit": "アカウントを作成", + "submit_loading": "作成中...", + "already_have_account": "すでにアカウントをお持ちですか?", + "login": "ログイン", + "passwords_not_match": "パスワードが一致しません", + "user_exists": "ユーザー名またはメールアドレスは既に存在します", + "registration_failed": "登録に失敗しました" + }, + "forgot_password": { + "email_placeholder": "メールアドレスを入力", + "captcha_placeholder": "コードを入力", + "submit": "続行", + "back_to_login": "ログインに戻る" + }, + "reset_password": { + "new_password_placeholder": "新しいパスワードを入力", + "confirm_password_placeholder": "新しいパスワードを確認", + "captcha_placeholder": "コードを入力", + "submit": "パスワードをリセット", + "back_to_login": "ログインに戻る" + }, + "change_password": { + "title": "パスワードを変更", + "subtitle": "アカウントのパスワードを更新", + "current_password_placeholder": "現在のパスワードを入力", + "new_password_placeholder": "新しいパスワードを入力", + "confirm_password_placeholder": "新しいパスワードを確認", + "captcha_placeholder": "コードを入力", + "submit": "パスワードを更新", + "back_to_login": "ログインに戻る" + }, + "two_factor": { + "title": "二要素認証", + "enabled": "2FAは有効です", + "disabled": "セキュリティの層を追加", + "description": "二要素認証は、パスワードだけではログインできないようにすることで、アカウントにさらなるセキュリティの層を追加します。", + "enabled_message": "アカウントで二要素認証が有効になっています。", + "scan_qr_code": "QRコードをスキャン", + "or_enter_manually": "またはこのコードを手動で入力", + "verification_code": "確認コード", + "code_required": "コードを入力してください", + "code_placeholder": "6桁のコードを入力", + "password_placeholder": "パスワードを入力", + "submit": "確認", + "cancel": "キャンセル", + "back": "戻る", + "enable": "2FAを有効にする", + "disable": "2FAを無効にする", + "disabling": "無効化中...", + "error": { + "load_failed": "2FAステータスの読み込みに失敗しました", + "enable_failed": "2FAを有効にできませんでした", + "disable_failed": "2FAを無効にできませんでした", + "invalid_code": "確認コードが無効です" + } + }, + "verify_email": { + "title": "メールアドレスの確認" + } + }, + "common": { + "actions": { + "save": "保存", + "save_changes": "変更を保存", + "cancel": "キャンセル", + "delete": "削除", + "edit": "編集", + "create": "作成", + "submit": "送信", + "close": "閉じる", + "confirm": "確認", + "back": "戻る", + "remove": "削除", + "add": "追加", + "retry": "再試行", + "generate": "生成", + "loading": "読み込み中..." + }, + "states": { + "loading": "読み込み中...", + "no_results": "結果が見つかりません", + "error_occurred": "エラーが発生しました" + }, + "placeholders": { + "search": "検索...", + "message": "メッセージ" + } + }, + "navigation": { + "search": "検索", + "search_shortcut": "検索 (Ctrl+K)", + "favorites": "お気に入りのメッセージ", + "no_channels": "チャンネルがありません" + }, + "settings": { + "access_keys": { + "title": "パーソナルアクセストークン", + "description": "APIにアクセスするために生成したトークンです。", + "generate_button": "新規トークン生成", + "token_name": "トークン名", + "copy_warning": "今すぐパーソナルアクセストークンをコピーしてください。もう一度表示することはできません!", + "empty": "パーソナルアクセストークンが見つかりません。", + "created_on": "作成日", + "expires": "有効期限", + "generate_token": "トークンを生成", + "messages": { + "name_required": "トークン名を入力してください", + "created_success": "トークンを作成しました。今すぐコピーしてください。", + "delete_success": "トークンを削除しました", + "create_failed": "作成に失敗しました。再試行してください", + "delete_failed": "削除に失敗しました" + } + }, + "ssh_keys": { + "title": "SSH キー", + "description": "Git操作用のSSHキーを管理", + "add_button": "キーを追加", + "add_title": "新しいSSHキーを追加", + "title_label": "タイトル", + "public_key_label": "公開鍵", + "name_placeholder": "例:個人のMacBook", + "key_placeholder": "ssh-rsa AAAAB3NzaC... user@host", + "empty_title": "SSHキーがまだ追加されていません", + "empty_desc": "SSHキーを追加して安全にGit操作を実行", + "verified": "検証済み", + "messages": { + "title_key_required": "タイトルと公開鍵を入力してください", + "add_success": "SSHキーを追加しました", + "add_failed": "追加に失敗しました。鍵の形式を確認してください", + "delete_success": "SSHキーを削除しました", + "delete_failed": "削除に失敗しました", + "title_updated": "タイトルを更新しました", + "update_failed": "更新に失敗しました" + } + }, + "password": { + "title": "パスワードを変更", + "subtitle": "アカウントを強力なパスワードで保護", + "current_password": "現在のパスワード", + "current_password_placeholder": "現在のパスワードを入力", + "new_password": "新しいパスワード", + "new_password_placeholder": "新しいパスワードを入力(8文字以上)", + "confirm_password": "新しいパスワードを確認", + "confirm_password_placeholder": "新しいパスワードを再入力", + "submit": "パスワードを変更", + "mismatch": "パスワードが一致しません", + "min_length": "パスワードは8文字以上である必要があります", + "change_success": "パスワードを変更しました", + "change_failed": "パスワードの変更に失敗しました。現在のパスワードを確認してください" + }, + "account": { + "title": "マイアカウント", + "display_name_placeholder": "表示名を設定", + "website_placeholder": "https://example.com", + "company_placeholder": "所属組織(任意)" + }, + "email": { + "title": "メールアドレス", + "current_email": "現在のメールアドレス", + "new_email_placeholder": "new@example.com", + "current_password_placeholder": "現在のパスワードを入力" + }, + "billing": { + "title": "請求", + "personal_billing": "個人請求", + "monthly_quota": "月間配额", + "monthly_usage": "今月の使用量", + "billing_errors": "請求エラー", + "no_history": "請求履歴がありません" + }, + "appearance": { + "title": "外観", + "timezone_asia_shanghai": "アジア/上海 (UTC+8)", + "timezone_asia_tokyo": "アジア/東京 (UTC+9)", + "timezone_america_ny": "アメリカ/ニューヨーク (UTC-5)", + "timezone_america_la": "アメリカ/ロサンゼルス (UTC-8)", + "timezone_europe_london": "ヨーロッパ/ロンドン (UTC+0)" + }, + "notifications": { + "title": "通知" + }, + "settings_nav": { + "user_settings": "ユーザー設定", + "my_account": "マイアカウント", + "billing": "請求", + "appearance": "外観", + "notifications": "通知", + "push_settings": "プッシュ設定", + "security": "セキュリティ", + "change_password": "パスワード変更", + "email": "メールアドレス", + "ssh_keys": "SSHキー", + "access_tokens": "アクセストークン" + }, + "my_account": { + "title": "マイアカウント", + "subtitle": "個人情報を管理", + "avatar": "アバター", + "upload_avatar": "新しいアバターをアップロード", + "remove": "削除", + "avatar_hint": "JPG、PNG、GIFに対応。最大2MB。", + "username": "ユーザー名", + "display_name": "表示名", + "display_name_placeholder": "表示名を設定", + "website": "ウェブサイト", + "website_placeholder": "https://example.com", + "organization": "組織", + "org_placeholder": "所属組織(任意)", + "save_changes": "変更を保存", + "load_failed": "プロフィールの読み込みに失敗しました", + "save_success": "プロフィールが保存されました", + "save_failed": "保存に失敗しました。もう一度お試しください", + "avatar_size_error": "画像サイズは2MB以下にしてください", + "avatar_upload_success": "アバターがアップロードされました。変更を保存してください", + "avatar_upload_failed": "アバターのアップロードに失敗しました" + }, + "email_page": { + "title": "メールアドレス", + "subtitle": "メールアドレスを管理", + "current_email": "現在のメールアドレス", + "no_email_set": "メールアドレス未設定", + "new_email": "新しいメールアドレス", + "current_password": "現在のパスワード(確認用)", + "current_password_placeholder": "現在のパスワードを入力", + "save_button": "メールアドレスを変更", + "load_failed": "メールアドレスの読み込みに失敗しました", + "fill_all_fields": "すべてのフィールドを入力してください", + "verification_sent": "確認メールが送信されました。新しいメールをご確認ください", + "change_failed": "メールアドレスの変更に失敗しました。パスワードを確認してください" + }, + "notifications_page": { + "title": "通知", + "subtitle": "通知設定を管理", + "channels": "通知チャンネル", + "email_notifications": "メール通知", + "email_notifications_desc": "メールで通知を受け取る", + "in_app_notifications": "アプリ内通知", + "in_app_notifications_desc": "アプリ内で通知を受け取る", + "push_notifications": "プッシュ通知", + "push_notifications_desc": "ブラウザのプッシュで通知を受け取る", + "digest_mode": "まとめ通知モード", + "instant": "即時", + "daily_digest": "日次まとめ", + "weekly_digest": "週次まとめ", + "off": "オフ", + "notification_types": "通知タイプ", + "security_notifications": "セキュリティ通知", + "security_notifications_desc": "アカウントセキュリティ関連の通知", + "product_updates": "製品アップデート", + "product_updates_desc": "新機能と製品アップデートのお知らせ", + "marketing_emails": "マーケティングメール", + "marketing_emails_desc": "キャンペーンとオファー情報", + "do_not_disturb": "おやすみモード", + "enable_dnd": "おやすみモードを有効にする", + "enable_dnd_desc": "指定した時間帯に通知を受け取らない", + "save_button": "変更を保存", + "load_failed": "通知設定の読み込みに失敗しました", + "save_success": "通知設定が保存されました", + "save_failed": "保存に失敗しました。もう一度お試しください" + }, + "appearance_page": { + "title": "外観", + "subtitle": "アプリの外観と言語をカスタマイズ", + "theme_scheme": "テーマスキーム", + "select_theme_scheme": "テーマスキームを選択", + "theme": "テーマ", + "language": "言語", + "timezone": "タイムゾーン", + "dark": "ダーク", + "light": "ライト", + "system": "システムに従う", + "basic_settings": "基本設定", + "custom": "カスタマイズ", + "save_button": "変更を保存", + "load_failed": "設定の読み込みに失敗しました", + "save_success": "外観設定が保存されました", + "save_failed": "保存に失敗しました。もう一度お試しください" + }, + "billing": { + "title": "請求", + "personal_billing": "個人請求", + "balance": "残高", + "monthly_quota": "月間配额", + "monthly_usage": "今月の使用量", + "billing_errors": "請求エラー", + "history": "請求履歴", + "date": "日付", + "reason": "理由", + "amount": "金額", + "no_history": "請求履歴がありません", + "load_failed": "請求の読み込みに失敗しました", + "insufficient_balance": "残高不足" + }, + "push": { + "title": "プッシュ通知", + "subtitle": "アプリが閉じていてもリアルタイムアラートを受信。", + "enable": "プッシュ通知を有効にする", + "enable_desc": "メンション、イシュー、システムアラートの通知を受け取る。", + "filters_title": "通知フィルター", + "mentions": "メンションと返信", + "new_issues": "新規イシュー", + "system_updates": "システムアップデート", + "coming_soon": "より細かい制御は近日公開予定。", + "not_supported": "プッシュ通知はサポートされていません", + "not_supported_desc": "お使いのブラウザまたは環境はWeb Pushをサポートしていません。最新のデスクトップブラウザをご利用ください。", + "load_failed": "通知設定の読み込みに失敗しました", + "permission_denied": "ブラウザによって通知権限が拒否されました", + "update_failed": "プッシュ設定の更新に失敗しました", + "saved": "設定を保存しました" + } + }, + "project": { + "layout": { + "expand_sidebar": "サイドバーを展開", + "join_banner": { + "preview_mode": "プレビューモード", + "join_to_participate": "参加してプロジェクトツールを利用しましょう。", + "read_only": "このパブリックプロジェクトは、参加するまでは読み取り専用です。", + "join_to_use": "プロジェクトアクションを使用するには参加が必要です。", + "apply_to_join": "参加を申請" + } + }, + "invitation": { + "title": "プロジェクト招待", + "no_pending": "保留中の招待はありません" + }, + "join": { + "title": "${project} に参加", + "reason_placeholder": "管理者への参加理由を告げてください。", + "cancel_request": "リクエストをキャンセル", + "join_without_reason": "プロジェクトに参加", + "submit_request": "参加リクエストを送信", + "default_desc": "リクエストを送信してプロジェクトメンバーになる。", + "already_member": "あなたはすでにこのプロジェクトのメンバーです。", + "open_project": "プロジェクトを開く", + "current_request": "現在のリクエスト", + "submitted_on": "提出日", + "message": "メッセージ", + "message_desc": "任意ですが、プライベートプロジェクトや承認が必要なプロジェクトに役立ちます。", + "submitted": "参加リクエストを送信しました。", + "submit_failed": "参加リクエストの送信に失敗しました。", + "cancelled": "参加リクエストをキャンセルしました。", + "cancel_failed": "参加リクエストのキャンセルに失敗しました。" + }, + "settings": { + "general": { + "title": "プロジェクト情報", + "project_name": "プロジェクト名", + "project_name_placeholder": "例:my-awesome-project", + "display_name": "表示名", + "display_name_placeholder": "例:My Awesome Project", + "description": "説明", + "description_placeholder": "このプロジェクトについて説明してください...", + "leave": "このプロジェクトを退出", + "leave_desc": "すべてのプロジェクトリソースへのアクセスを失います。", + "leave_btn": "プロジェクトを退出", + "leave_confirm": "このプロジェクトを退出してもよろしいですか?", + "leave_success": "プロジェクトを退出しました。", + "leave_failed": "プロジェクトの退出に失敗しました。" + }, + "billing": { + "title": "現在の請求", + "monthly_quota": "月間配额", + "monthly_usage": "今月の使用量", + "billing_errors": "請求エラー", + "no_history": "請求履歴がありません" + }, + "access": { + "invite_member": "メンバーを招待", + "join_settings": "参加設定", + "pending_invitations": "保留中の招待", + "join_requests": "参加リクエスト", + "email_placeholder": "user@example.com", + "reason_placeholder": "何を聞きたいですか?", + "optional_reason": "リクエスト履歴に表示される任意の説明。" + }, + "labels": { + "create_label": "ラベルを作成", + "label_name_placeholder": "label-name", + "description_placeholder": "説明", + "no_labels": "ラベルがありません" + }, + "members": { + "remove_confirm": "${username} をプロジェクトから削除しますか?", + "remove": "削除" + }, + "danger_zone": { + "delete_project": "このプロジェクトを削除", + "delete_button": "プロジェクトを削除" + } + }, + "repos": { + "title": "リポジトリ", + "subtitle": "プロジェクトのソースコードをホスト・管理", + "find_placeholder": "リポジトリを検索...", + "no_repos": "リポジトリがありません", + "no_repos_desc": "最初のリポジトリを作成して始めましょう", + "create_new": "新規リポジトリを作成", + "create_new_desc": "コードをホストしてコラボレーション", + "loading": "リポジトリを読み込み中...", + "load_failed": "リポジトリの読み込みに失敗しました", + "new_repo": "新規リポジトリ", + "type": "タイプ", + "sort": "並び替え", + "repository": "リポジトリ", + "repositories": "リポジトリ", + "grid_view": "グリッド表示", + "list_view": "リスト表示", + "private": "プライベート", + "public": "パブリック", + "no_description_default": "説明は提供されていません。", + "never": "なし", + "just_now": "たった今", + "min_ago": "${count}分前", + "hr_ago": "${count}時間前", + "day_ago": "${count}日前" + }, + "repo": { + "settings": { + "title": "リポジトリ情報", + "name_placeholder": "リポジトリ名", + "description_placeholder": "このリポジトリについて説明してください...", + "default_branch": "デフォルトブランチ", + "branch_placeholder": "main", + "desc": "リポジトリの基本情報を更新" + }, + "code": "コード", + "commits": "コミット", + "pull_requests": "Pull Requests", + "branches": "ブランチ", + "tags": "タグ", + "settings_tab": "設定", + "loading": "リポジトリを読み込み中...", + "load_failed": "リポジトリの読み込みに失敗しました", + "not_found": "リポジトリが見つかりません" + }, + "branch_protection": { + "title": "ブランチ保護ルール", + "branch_pattern": "ブランチパターン", + "required_approvals": "必要な承認数", + "active_rules": "有効なルール", + "no_rules": "ブランチ保護ルールが設定されていません", + "create_rule_hint": "上記でルールを作成してブランチを保護してください", + "add_rule": "ルールを追加", + "no_fork_sync": "フォーク同期なし" + } + }, + "issues": { + "title": "イシュー", + "subtitle": "プロジェクトのタスクとバグを追跡・管理", + "new_title": "新規イシューを作成", + "new_issue": "新規イシュー", + "search_placeholder": "すべてのイシューを検索...", + "sort": "並び替え", + "label": "ラベル", + "assignee": "担当者", + "open": "オープン", + "closed": "クローズ", + "no_project": "プロジェクトが選択されていません", + "select_project": "イシューを表示するプロジェクトを選択してください", + "loading": "イシューを読み込み中...", + "load_failed": "イシューの読み込みに失敗しました", + "no_issues": "${tab} イシューはありません", + "no_matches": "一致するものが見つかりません", + "all_caught_up": "すべて完了しました!イシューを作成して新しいタスクを追跡しましょう。", + "try_adjusting": "検索条件やフィルターを調整してみてください。", + "create_first": "最初のイシューを作成", + "brief_description_placeholder": "問題を簡単に説明...", + "details_placeholder": "詳細、再現手順、要件を説明...", + "pro_tip": "ヒント", + "need_help": "ヘルプが必要ですか?", + "submit": "新規イシューを送信", + "issue_title_placeholder": "イシュータイトル" + }, + "issue_detail": { + "loading": "イシューを読み込み中...", + "no_description": "説明がありません。", + "no_comments": "コメントがありません。議論を始めましょう!", + "comment_placeholder": "コメントを書く... (Markdown対応)", + "edit": "編集", + "close": "閉じる", + "delete": "削除", + "link_pr": "Pull Requestをリンク", + "link_repo": "リポジトリをリンク", + "pull_requests": "Pull Requests", + "linked_repos": "リンクされたリポジトリ", + "no_assigned": "未割り当て" + }, + "pulls": { + "title": "Pull Requests", + "project_pulls": "${project} のプルリクエスト", + "close": "閉じる", + "reopen": "再開", + "confirm_delete": "確認", + "delete": "削除", + "no_project": "プロジェクトが選択されていません", + "select_project": "プルリクエストを表示するプロジェクトを選択してください", + "loading": "プルリクエストを読み込み中...", + "load_failed": "プルリクエストの読み込みに失敗しました", + "no_prs": "Pull Requestsがありません", + "create_hint": "変更を提案するためにPull Requestを作成" + }, + "skills": { + "title": "スキル", + "subtitle": "プロジェクト機能とAIスキル", + "no_project": "プロジェクトが選択されていません", + "select_project": "スキルを表示するプロジェクトを選択してください", + "loading": "スキルを読み込み中...", + "load_failed": "スキルの読み込みに失敗しました", + "no_skills": "スキルがありません", + "no_skills_desc": "AI支援を有効にするためにスキルを作成またはスキャン", + "create": "作成", + "scan": "スキャン", + "scanning": "スキャン中...", + "skill_name_placeholder": "my-skill", + "skill_display_name": "私のスキル", + "skill_description_placeholder": "このスキルの説明", + "skill_content_placeholder": "スキル内容やプロンプト...", + "delete": "スキルを削除", + "create_title": "スキルを作成", + "create_desc": "プロジェクトに新しいAIスキルを追加します。" + }, + "skill_detail": { + "loading": "スキルを読み込み中...", + "edit_title": "スキルを編集", + "save_failed": "保存に失敗しました" + }, + "board": { + "title": "ボード", + "no_boards": "ボードがありません", + "no_boards_desc": "Kanbanボードを作成してワークフロー管理を始めましょう。", + "add_column": "列を追加", + "add_column_title": "列を追加", + "delete_board": "ボードを削除", + "new_board_title": "新規ボード", + "board_name_placeholder": "例:スプリント計画", + "board_desc_placeholder": "説明(任意)...", + "column_name": "列名", + "column_name_placeholder": "例:進行中", + "card_title_placeholder": "何をする必要がありますか?", + "card_desc_placeholder": "説明(任意)...", + "card_detail_title": "カードタイトル", + "card_detail_desc_placeholder": "詳細な説明を追加...", + "delete_card": "カードを削除", + "delete_column": "この列を削除しますか?", + "delete_card_confirm": "このカードを削除しますか?", + "empty_board": "空のボード", + "empty_board_hint": "最初の列を追加してください(例:To Do、進行中、完了)して作業の追跡を開始します。", + "add_first_column": "最初の列を追加", + "add_card": "カードを追加", + "new_card": "新しいカード", + "card_title": "カードタイトル", + "id": "ID", + "created": "作成日時", + "updated": "更新日時" + }, + "me": { + "title": "マイページ", + "recent_repos": "最近のリポジトリ", + "top_projects": "トッププロジェクト", + "latest_activity": "最近のアクティビティ", + "no_projects": "プロジェクトが見つかりません", + "no_repos": "リポジトリが見つかりません", + "no_activity": "最近のアクティビティはありません", + "create_project": { + "title": "新規プロジェクトを作成", + "subtitle": "新しいコラボレーションプロジェクトを開始", + "name_placeholder": "例:my-awesome-team", + "org_placeholder": "例:Acme Corporation", + "desc_placeholder": "このプロジェクトは何ですか?", + "slug": "プロジェクトスラッグ", + "slug_required": "必須", + "slug_hint": "URLに使用。小文字、数字、ハイフンのみ。", + "display_name": "表示名", + "display_name_required": "必須", + "display_name_placeholder": "例:Acme Corporation", + "description": "説明", + "visibility": "公開設定", + "public_project": "公開プロジェクト", + "public_desc": "誰でも発見して参加できます", + "private_project": "非公開プロジェクト", + "private_desc": "招待されたメンバーのみアクセス可能", + "change": "変更", + "create_failed": "プロジェクトを作成できませんでした。スラッグが既に取得されている可能性があります。", + "create": "プロジェクトを作成", + "cancel": "キャンセル" + }, + "invitations": { + "pending": "保留中の招待", + "no_pending": "保留中の招待はありません", + "join_requests": "参加リクエスト", + "no_requests": "参加リクエストはありません" + }, + "notifications": { + "no_notifications": "通知はありません", + "no_unread": "未読の通知はありません", + "all_caught_up": "すべて完了しました" + }, + "followers": { + "no_followers": "フォロワーはいません" + }, + "following": { + "no_following": "フォロー中はいません" + }, + "user_not_found": "ユーザーが見つかりません", + "please_login": "ログインしていることを確認してください。", + "profile": { + "joined": "参加日", + "projects": "プロジェクト", + "repos": "リポジトリ", + "stars": "スター", + "followers": "フォロワー", + "follow": "フォロー", + "unfollow": "フォロー解除", + "contributions_in_year": "過去1年で${count}件のコントリビューション", + "contributions_on_date": "${count}件のコントリビューション(${date})", + "less": "少", + "more": "多" + } + }, + "explore": { + "title": "プロジェクトを探索", + "subtitle": "パブリックプロジェクトとコミュニティを発見", + "search_placeholder": "プロジェクトを名前または説明で検索...", + "no_projects": "プロジェクトが見つかりません", + "no_description": "説明なし", + "found": "見つかりました", + "showing": "発見可能なプロジェクトを表示中", + "try_different": "別の検索語を試してください", + "view_project": "プロジェクトを表示" + }, + "chat": { + "conversations": { + "search_placeholder": "会話を検索...", + "no_conversations": "会話はまだありません", + "no_matching": "一致する会話はありません", + "new_chat": "新しいチャット", + "chat_history": "チャット履歴", + "start_new_chat": "新しいチャットを開始してAIと探索しましょう。", + "try_different_search": "別の検索語を試してください。", + "untitled_chat": "無題のチャット", + "create_failed": "チャットの作成に失敗しました" + }, + "model_selector": { + "search_placeholder": "モデルを検索...", + "loading": "読み込み中...", + "no_models": "モデルが見つかりません", + "please_select_model": "まずモデルを選択してください" + }, + "header": { + "new_chat": "新しいチャット", + "expand_history": "チャット履歴を展開", + "collapse_history": "チャット履歴を折りたたむ", + "streaming": "ストリーミング...", + "share": "会話を共有", + "rename": "名前変更", + "more": "もっと" + }, + "message_list": { + "welcome_title": "今日はどのようにお手伝いしましょうか?", + "welcome_desc": "どのようなことでも質問してください — コード、執筆、分析など、お手伝いできます。", + "new_response": "新しい返答", + "responding": "返答中...", + "explain_code": "コードを説明するか、エラーをデバッグ", + "summarize_doc": "ドキュメントを要約または作成", + "review_pr": "Pull Request またはリポジトリをレビュー", + "brainstorm": "アイデアをブレインストーミングまたは計画を立案", + "jump_to_message": "あなたのメッセージ ${index} にジャンプ" + } + }, + "channel": { + "no_selected": "チャンネルが選択されていません", + "no_threads": "スレッドはまだありません。メッセージアクションメニューから開始してください。", + "no_messages": "メッセージはまだありません。会話を始めましょう!", + "edit_history": "編集履歴", + "no_edit_history": "編集履歴はありません。", + "no_pinned": "ピン留めされたメッセージはまだありません。", + "reply_placeholder": "スレッドに返信...", + "beginning_of_channel": "チャンネルの開始", + "scroll_up": "上にスクロールして古いメッセージを読み込む", + "new_message": "${count} 件の新しいメッセージ" + }, + "search": { + "global_placeholder": "プロジェクト、チャンネル、リポジトリ、ボード、イシューを検索...", + "no_results": "結果が見つかりません。", + "no_messages": "一致するメッセージはありません。", + "results": "検索結果", + "jump_to": "プロジェクト、チャンネル、リポジトリ、ボード、イシューにジャンプ。", + "messages": "メッセージ", + "quick_switch": "クイック切替", + "projects": "プロジェクト", + "my_home": "マイホーム", + "my_home_desc": "個人概要を開く", + "explore_projects": "プロジェクトを探索", + "explore_projects_desc": "パブリックプロジェクトを探す", + "chat": "チャット", + "chat_desc": "パーソナルチャットを開く", + "project_overview": "プロジェクト概要", + "repositories": "リポジトリ", + "issues": "イシュー", + "boards": "ボード", + "project_settings": "プロジェクト設定", + "rooms_in": "${project} 内のルーム", + "all_rooms": "${project} 内のすべてのルーム", + "messages_in": "${project} 内のメッセージ", + "messages_in_room": "#${room} 内のメッセージ" + }, + "components": { + "message_input": { + "reply_preview": "返信/編集プレビューバナー", + "add_emoji": "絵文字を追加", + "placeholder": "メッセージ", + "replying": "メッセージに返信", + "editing": "メッセージを編集中" + }, + "inline_comment": { + "reply_placeholder": "返信を書く...", + "leave_comment": "コメントを残す..." + }, + "merge_panel": { + "commit_message_placeholder": "コミットメッセージを入力..." + }, + "ai_settings": { + "add_ai": "AIを追加", + "no_ai": "このチャンネルにはアクティブなAIエージェントがありません", + "search_placeholder": "モデルを検索...", + "system_prompt_placeholder": "あなたは有帮助なアシスタントです..." + }, + "room_settings": { + "name_placeholder": "例:general", + "topic_placeholder": "例:エンジニアリング", + "no_group": "グループなし", + "create_new_group": "新規グループを作成", + "delete_room": "部屋を削除" + }, + "command": { + "placeholder": "実行するコマンドを検索..." + }, + "pr": { + "create_merge_commit": "マージコミットを作成", + "no_changes": "このPull Requestには変更がありません。", + "confirm_merge": "マージを確認" + }, + "commit": { + "back_to_commits": "コミットに戻る", + "no_changes": "このコミットには変更がありません。" + }, + "repo": { + "no_description": "説明なし", + "no_description_default": "説明は提供されていません。" + }, + "branches": { + "no_branches": "ブランチがありません", + "no_branches_desc": "最初のブランチを作成して始めましょう" + }, + "tags": { + "no_tags": "タグがありません", + "no_tags_desc": "履歴の特定のポイントをマークするタグを作成" + }, + "favorites": { + "no_favorites": "お気に入りのメッセージはまだありません。" + } + }, + "project_create": { + "repo_name": "リポジトリ名", + "repo_name_placeholder": "例:core-api", + "repo_desc_placeholder": "このリポジトリは何用ですか?", + "group_name": "新規グループ名", + "channel_name": "チャンネル名", + "channel_name_placeholder": "例:development", + "board_name": "ボード名", + "board_name_placeholder": "例:スプリント計画", + "board_desc_placeholder": "タスクボードの詳細...", + "skill_name": "スキル名", + "skill_name_placeholder": "例:コードレビュアー", + "skill_desc_placeholder": "このAIスキルの役割は何ですか?", + "email_placeholder": "user@example.com", + "no_group": "グループなし", + "create_new_group": "新規グループを作成", + "create_repo": "リポジトリを作成", + "create_channel": "チャンネルを作成", + "create_board": "ボードを作成", + "create_skill": "スキルを作成" + } +} diff --git a/src/i18n/zh.json b/src/i18n/zh.json index e69de29..687d864 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -0,0 +1,854 @@ +{ + "auth": { + "login": { + "title": "欢迎回来!", + "subtitle": "很高兴再次见到你!", + "account": "账户", + "account_required": "请输入用户名", + "account_placeholder": "用户名或邮箱", + "password": "密码", + "password_required": "请输入密码", + "verification": "验证码", + "captcha_placeholder": "输入验证码", + "captcha_required": "请输入验证码", + "captcha_title": "点击刷新", + "2fa_code": "两步验证码", + "2fa_placeholder": "6位数字", + "forgot_password": "忘记密码?", + "submit": "登录", + "submit_loading": "登录中...", + "need_account": "还没有账户?", + "register": "注册", + "error": { + "failed_to_load_captcha": "验证码加载失败", + "two_factor_required": "需要两步验证", + "invalid_credentials": "用户名或密码错误", + "login_failed": "登录失败" + } + }, + "register": { + "title": "创建账户", + "subtitle": "立即加入!", + "username": "用户名", + "username_placeholder": "选择用户名", + "username_required": "请输入用户名", + "username_min_length": "用户名至少需要3个字符", + "username_pattern": "用户名只能包含字母、数字、下划线和连字符", + "email": "邮箱", + "email_placeholder": "输入邮箱", + "email_required": "请输入邮箱", + "email_invalid": "邮箱格式无效", + "password_placeholder": "创建密码", + "confirm_password": "确认密码", + "confirm_password_placeholder": "确认密码", + "confirm_password_required": "请确认密码", + "password_min_length": "密码至少需要8个字符", + "captcha_placeholder": "输入验证码", + "submit": "创建账户", + "submit_loading": "正在创建...", + "already_have_account": "已有账户?", + "login": "登录", + "passwords_not_match": "两次输入的密码不一致", + "user_exists": "用户名或邮箱已存在", + "registration_failed": "注册失败" + }, + "forgot_password": { + "email_placeholder": "输入邮箱", + "captcha_placeholder": "输入验证码", + "submit": "继续", + "back_to_login": "返回登录" + }, + "reset_password": { + "new_password_placeholder": "输入新密码", + "confirm_password_placeholder": "确认新密码", + "captcha_placeholder": "输入验证码", + "submit": "重置密码", + "back_to_login": "返回登录" + }, + "change_password": { + "title": "修改密码", + "subtitle": "更新您的账户密码", + "current_password_placeholder": "输入当前密码", + "new_password_placeholder": "输入新密码", + "confirm_password_placeholder": "确认新密码", + "captcha_placeholder": "输入验证码", + "submit": "更新密码", + "back_to_login": "返回登录" + }, + "two_factor": { + "title": "两步验证", + "enabled": "已启用两步验证", + "disabled": "添加额外的安全层", + "description": "两步验证通过要求除了密码之外的内容来登录,为您的账户增加了额外的安全层。", + "enabled_message": "您的账户已启用两步验证。", + "scan_qr_code": "扫描二维码", + "or_enter_manually": "或手动输入此代码", + "verification_code": "验证码", + "code_required": "请输入验证码", + "code_placeholder": "输入6位数字", + "password_placeholder": "输入密码", + "submit": "验证", + "cancel": "取消", + "back": "返回", + "enable": "启用两步验证", + "disable": "禁用两步验证", + "disabling": "正在禁用...", + "error": { + "load_failed": "加载两步验证状态失败", + "enable_failed": "启用两步验证失败", + "disable_failed": "禁用两步验证失败", + "invalid_code": "验证码无效" + } + }, + "verify_email": { + "title": "验证邮箱" + } + }, + "common": { + "actions": { + "save": "保存", + "save_changes": "保存更改", + "cancel": "取消", + "delete": "删除", + "edit": "编辑", + "create": "创建", + "submit": "提交", + "close": "关闭", + "confirm": "确认", + "back": "返回", + "remove": "移除", + "add": "添加", + "retry": "重试", + "generate": "生成", + "loading": "加载中..." + }, + "states": { + "loading": "加载中...", + "no_results": "未找到结果", + "error_occurred": "发生错误" + }, + "placeholders": { + "search": "搜索...", + "message": "消息" + }, + "unknown": "未知" + }, + "navigation": { + "search": "搜索", + "search_shortcut": "搜索 (Ctrl+K)", + "favorites": "收藏消息", + "no_channels": "暂无频道" + }, + "settings": { + "access_keys": { + "title": "个人访问令牌", + "description": "您已生成的用于访问API的令牌。", + "generate_button": "生成新令牌", + "token_name": "令牌名称", + "copy_warning": "请务必现在复制您的个人访问令牌。您将无法再次查看!", + "empty": "未找到个人访问令牌。", + "created_on": "创建于", + "expires": "过期时间", + "generate_token": "生成令牌", + "messages": { + "name_required": "请输入Token名称", + "created_success": "Token创建成功,请尽快复制。", + "delete_success": "Token已删除", + "create_failed": "创建失败,请重试", + "delete_failed": "删除失败" + } + }, + "ssh_keys": { + "title": "SSH 密钥", + "description": "管理用于 Git 操作的 SSH 密钥", + "add_button": "添加密钥", + "add_title": "添加新的 SSH 密钥", + "title_label": "标题", + "public_key_label": "公钥", + "name_placeholder": "例如:个人 MacBook", + "key_placeholder": "ssh-rsa AAAAB3NzaC... user@host", + "empty_title": "还没有添加 SSH 密钥", + "empty_desc": "添加 SSH 密钥以安全地执行 Git 操作", + "verified": "已验证", + "messages": { + "title_key_required": "请填写标题和公钥", + "add_success": "SSH 密钥添加成功", + "add_failed": "添加失败,请检查公钥格式", + "delete_success": "SSH 密钥已删除", + "delete_failed": "删除失败", + "title_updated": "标题已更新", + "update_failed": "更新失败" + } + }, + "password": { + "title": "修改密码", + "subtitle": "为了保护你的账户安全,请设置强密码", + "current_password": "当前密码", + "current_password_placeholder": "输入当前密码", + "new_password": "新密码", + "new_password_placeholder": "输入新密码(至少8位)", + "confirm_password": "确认新密码", + "confirm_password_placeholder": "再次输入新密码", + "submit": "修改密码", + "mismatch": "两次输入的新密码不一致", + "min_length": "新密码长度至少为8位", + "change_success": "密码修改成功", + "change_failed": "密码修改失败,请检查当前密码是否正确" + }, + "account": { + "title": "我的账户", + "display_name_placeholder": "设置显示名称", + "website_placeholder": "https://example.com", + "company_placeholder": "所属组织(可选)" + }, + "email": { + "title": "邮箱", + "current_email": "当前邮箱", + "new_email_placeholder": "new@example.com", + "current_password_placeholder": "输入当前密码" + }, + "billing": { + "title": "账单", + "personal_billing": "个人账单", + "balance": "余额", + "monthly_quota": "月度配额", + "monthly_usage": "本月使用", + "billing_errors": "账单错误", + "history": "账单历史", + "date": "日期", + "reason": "原因", + "amount": "金额", + "no_history": "暂无账单记录", + "load_failed": "加载账单失败" + }, + "appearance": { + "title": "外观", + "timezone_asia_shanghai": "亚洲/上海 (UTC+8)", + "timezone_asia_tokyo": "亚洲/东京 (UTC+9)", + "timezone_america_ny": "美洲/纽约 (UTC-5)", + "timezone_america_la": "美洲/洛杉矶 (UTC-8)", + "timezone_europe_london": "欧洲/伦敦 (UTC+0)" + }, + "notifications": { + "title": "通知" + }, + "settings_nav": { + "user_settings": "用户设置", + "my_account": "我的账户", + "billing": "账单", + "appearance": "外观", + "notifications": "通知", + "push_settings": "推送设置", + "security": "安全", + "change_password": "修改密码", + "email": "邮箱", + "ssh_keys": "SSH 密钥", + "access_tokens": "访问令牌" + }, + "my_account": { + "title": "我的账户", + "subtitle": "管理你的个人信息", + "avatar": "头像", + "upload_avatar": "上传新头像", + "remove": "移除", + "avatar_hint": "支持 JPG, PNG 或 GIF. 最大 2MB.", + "username": "用户名", + "display_name": "显示名称", + "display_name_placeholder": "设置显示名称", + "website": "网站", + "website_placeholder": "https://example.com", + "organization": "组织", + "org_placeholder": "所属组织(可选)", + "save_changes": "保存更改", + "load_failed": "加载个人信息失败", + "save_success": "个人信息已保存", + "save_failed": "保存失败,请重试", + "avatar_size_error": "图片大小不能超过 2MB", + "avatar_upload_success": "头像上传成功,请保存更改", + "avatar_upload_failed": "头像上传失败" + }, + "email_page": { + "title": "邮箱", + "subtitle": "管理你的邮箱地址", + "current_email": "当前邮箱", + "no_email_set": "未设置邮箱", + "new_email": "新邮箱", + "current_password": "当前密码(验证身份)", + "current_password_placeholder": "输入当前密码", + "save_button": "更改邮箱", + "load_failed": "加载邮箱信息失败", + "fill_all_fields": "请填写所有字段", + "verification_sent": "验证邮件已发送,请检查新邮箱", + "change_failed": "修改失败,请检查密码是否正确" + }, + "notifications_page": { + "title": "通知", + "subtitle": "管理你的通知偏好", + "channels": "通知渠道", + "email_notifications": "邮件通知", + "email_notifications_desc": "通过邮件接收通知", + "in_app_notifications": "应用内通知", + "in_app_notifications_desc": "在应用内接收通知提醒", + "push_notifications": "推送通知", + "push_notifications_desc": "通过浏览器推送接收通知", + "digest_mode": "摘要模式", + "instant": "即时", + "daily_digest": "每日摘要", + "weekly_digest": "每周摘要", + "off": "关闭", + "notification_types": "通知类型", + "security_notifications": "安全通知", + "security_notifications_desc": "账户安全相关的通知", + "product_updates": "产品更新", + "product_updates_desc": "新功能和产品更新通知", + "marketing_emails": "营销邮件", + "marketing_emails_desc": "促销活动和优惠信息", + "do_not_disturb": "免打扰", + "enable_dnd": "启用免打扰", + "enable_dnd_desc": "在指定时间段内不接收通知", + "save_button": "保存更改", + "load_failed": "加载通知偏好失败", + "save_success": "通知设置已保存", + "save_failed": "保存失败,请重试" + }, + "appearance_page": { + "title": "外观", + "subtitle": "自定义应用的外观和语言", + "theme_scheme": "主题方案", + "select_theme_scheme": "选择主题方案", + "theme": "主题", + "language": "语言", + "timezone": "时区", + "dark": "深色", + "light": "浅色", + "system": "跟随系统", + "basic_settings": "基础设置", + "custom": "自定义", + "save_button": "保存更改", + "load_failed": "加载偏好设置失败", + "save_success": "外观设置已保存", + "save_failed": "保存失败,请重试" + }, + "billing": { + "title": "账单", + "personal_billing": "个人账单", + "balance": "余额", + "monthly_quota": "月度配额", + "monthly_usage": "本月使用", + "billing_errors": "账单错误", + "history": "账单历史", + "date": "日期", + "reason": "原因", + "amount": "金额", + "no_history": "暂无账单记录", + "load_failed": "加载账单失败", + "insufficient_balance": "余额不足" + }, + "push": { + "title": "推送通知", + "subtitle": "即使在应用关闭时也能接收实时提醒。", + "enable": "启用推送通知", + "enable_desc": "接收提及、issues 和系统提醒的通知。", + "filters_title": "通知过滤器", + "mentions": "提及和回复", + "new_issues": "新 Issues", + "system_updates": "系统更新", + "coming_soon": "更精细的控制即将推出。", + "not_supported": "不支持推送通知", + "not_supported_desc": "您的浏览器或环境不支持 Web Push。请使用现代桌面浏览器。", + "load_failed": "加载通知设置失败", + "permission_denied": "浏览器拒绝了通知权限", + "update_failed": "更新推送设置失败", + "saved": "设置已保存" + } + }, + "project": { + "layout": { + "expand_sidebar": "展开侧边栏", + "join_banner": { + "preview_mode": "预览模式", + "join_to_participate": "加入此项目以参与并使用项目工具。", + "read_only": "此公共项目在加入前为只读状态。", + "join_to_use": "你需要先加入才能使用项目操作。", + "apply_to_join": "申请加入" + } + }, + "invitation": { + "title": "项目邀请", + "no_pending": "没有待处理的邀请" + }, + "join": { + "title": "加入 ${project}", + "reason_placeholder": "告诉管理员您想加入的原因。", + "cancel_request": "取消请求", + "join_without_reason": "加入项目", + "submit_request": "提交加入请求", + "default_desc": "提交请求以成为项目成员。", + "already_member": "您已经是此项目的成员。", + "open_project": "打开项目", + "current_request": "当前请求", + "submitted_on": "提交于", + "message": "消息", + "message_desc": "可选,但对于私有或需要审批的项目很有用。", + "submitted": "加入请求已提交。", + "submit_failed": "提交加入请求失败。", + "cancelled": "加入请求已取消。", + "cancel_failed": "取消加入请求失败。" + }, + "settings": { + "general": { + "title": "项目信息", + "project_name": "项目名称", + "project_name_placeholder": "例如:my-awesome-project", + "display_name": "显示名称", + "display_name_placeholder": "例如:My Awesome Project", + "description": "描述", + "description_placeholder": "向大家介绍这个项目...", + "leave": "退出此项目", + "leave_desc": "您将失去对所有项目资源的访问权限。", + "leave_btn": "退出项目", + "leave_confirm": "确定要退出此项目吗?", + "leave_success": "您已退出该项目。", + "leave_failed": "退出项目失败。" + }, + "billing": { + "title": "当前账单", + "monthly_quota": "月度配额", + "monthly_usage": "本月使用", + "billing_errors": "账单错误", + "no_history": "暂无账单记录" + }, + "access": { + "invite_member": "邀请成员", + "join_settings": "加入设置", + "pending_invitations": "待处理邀请", + "join_requests": "加入请求", + "email_placeholder": "user@example.com", + "reason_placeholder": "您想咨询什么?", + "optional_reason": "可选原因,将显示在请求历史中。" + }, + "labels": { + "create_label": "创建标签", + "label_name_placeholder": "label-name", + "description_placeholder": "描述", + "no_labels": "暂无标签" + }, + "members": { + "remove_confirm": "确定要从项目中移除 ${username} 吗?", + "remove": "移除" + }, + "danger_zone": { + "delete_project": "删除此项目", + "delete_button": "删除项目" + } + }, + "repos": { + "title": "仓库", + "subtitle": "托管和管理项目源代码", + "find_placeholder": "查找仓库...", + "no_repos": "暂无仓库", + "no_repos_desc": "创建您的第一个仓库", + "create_new": "创建新仓库", + "create_new_desc": "托管代码并协作", + "loading": "正在加载仓库...", + "load_failed": "加载仓库失败", + "new_repo": "新建仓库", + "type": "类型", + "sort": "排序", + "repository": "仓库", + "repositories": "仓库", + "grid_view": "网格视图", + "list_view": "列表视图", + "private": "私有", + "public": "公开", + "no_description_default": "未提供描述。", + "never": "从未", + "just_now": "刚刚", + "min_ago": "${count}分钟前", + "hr_ago": "${count}小时前", + "day_ago": "${count}天前" + }, + "repo": { + "settings": { + "title": "仓库信息", + "name_placeholder": "仓库名称", + "description_placeholder": "向大家介绍这个仓库...", + "default_branch": "默认分支", + "branch_placeholder": "main", + "desc": "更新仓库的基本信息" + }, + "code": "代码", + "commits": "提交", + "pull_requests": "Pull Requests", + "branches": "分支", + "tags": "标签", + "settings_tab": "设置", + "loading": "正在加载仓库...", + "load_failed": "加载仓库失败", + "not_found": "仓库未找到" + }, + "branch_protection": { + "title": "分支保护规则", + "branch_pattern": "分支模式", + "required_approvals": "要求审批数", + "active_rules": "生效规则", + "no_rules": "未配置分支保护规则", + "create_rule_hint": "在上方创建规则以保护您的分支", + "add_rule": "添加规则", + "no_fork_sync": "无 fork 同步" + } + }, + "issues": { + "title": "Issues", + "subtitle": "跟踪和管理项目任务与缺陷", + "new_title": "创建新 Issue", + "new_issue": "新建 Issue", + "search_placeholder": "搜索所有 issues...", + "sort": "排序", + "label": "标签", + "assignee": "负责人", + "open": "开放", + "closed": "已关闭", + "no_project": "未选择项目", + "select_project": "选择一个项目以查看其 issues", + "loading": "正在加载 issues...", + "load_failed": "加载 issues 失败", + "no_issues": "暂无 ${tab} issues", + "no_matches": "未找到匹配项", + "all_caught_up": "已处理完毕!创建一个 issue 来跟踪新任务。", + "try_adjusting": "尝试调整搜索或筛选条件。", + "create_first": "创建您的第一个 issue", + "brief_description_placeholder": "简要描述问题...", + "details_placeholder": "详细说明、复现步骤或需求...", + "pro_tip": "提示", + "need_help": "需要帮助?", + "submit": "提交新 Issue", + "issue_title_placeholder": "Issue 标题" + }, + "issue_detail": { + "loading": "加载 issue 中...", + "no_description": "暂无描述。", + "no_comments": "暂无评论。开始讨论吧!", + "comment_placeholder": "写评论... (支持 Markdown)", + "edit": "编辑", + "close": "关闭", + "delete": "删除", + "link_pr": "关联 Pull Request", + "link_repo": "关联仓库", + "pull_requests": "Pull Requests", + "linked_repos": "关联的仓库", + "no_assigned": "未分配" + }, + "pulls": { + "title": "Pull Requests", + "project_pulls": "${project} 的 Pull Requests", + "close": "关闭", + "reopen": "重新打开", + "confirm_delete": "确认", + "delete": "删除", + "no_project": "未选择项目", + "select_project": "选择一个项目以查看其 Pull Requests", + "loading": "正在加载 Pull Requests...", + "load_failed": "加载 Pull Requests 失败", + "no_prs": "暂无 Pull Requests", + "create_hint": "创建 Pull Request 来提议更改" + }, + "skills": { + "title": "技能", + "subtitle": "项目能力和 AI 技能", + "no_project": "未选择项目", + "select_project": "选择一个项目以查看其技能", + "loading": "正在加载技能...", + "load_failed": "加载技能失败", + "no_skills": "暂无技能", + "no_skills_desc": "创建或扫描技能以启用 AI 辅助", + "create": "创建", + "scan": "扫描", + "scanning": "扫描中...", + "skill_name_placeholder": "my-skill", + "skill_display_name": "我的技能", + "skill_description_placeholder": "这个技能的作用", + "skill_content_placeholder": "技能内容或提示...", + "delete": "删除技能", + "create_title": "创建技能", + "create_desc": "添加新的 AI 技能到项目中。" + }, + "skill_detail": { + "loading": "加载技能中...", + "edit_title": "编辑技能", + "save_failed": "保存失败" + }, + "board": { + "title": "看板", + "no_boards": "暂无看板", + "no_boards_desc": "创建看板来开始管理工作流程。", + "add_column": "添加列", + "add_column_title": "添加列", + "delete_board": "删除看板", + "new_board_title": "新建看板", + "board_name_placeholder": "例如:冲刺计划", + "board_desc_placeholder": "可选描述...", + "column_name": "列名称", + "column_name_placeholder": "例如:进行中", + "card_title_placeholder": "需要完成什么?", + "card_desc_placeholder": "可选描述...", + "card_detail_title": "卡片标题", + "card_detail_desc_placeholder": "添加更详细的描述...", + "delete_card": "删除卡片", + "delete_column": "删除此列?", + "delete_card_confirm": "删除此卡片?", + "empty_board": "空白看板", + "empty_board_hint": "添加您的第一列(例如:待办、进行中、已完成)来开始管理工作。", + "add_first_column": "添加首列", + "add_card": "添加卡片", + "new_card": "新卡片", + "card_title": "卡片标题", + "id": "编号", + "created": "创建时间", + "updated": "更新时间" + }, + "me": { + "title": "我的主页", + "recent_repos": "最近仓库", + "top_projects": "热门项目", + "latest_activity": "最近活动", + "no_projects": "未找到项目", + "no_repos": "未找到仓库", + "no_activity": "暂无最近活动", + "create_project": { + "title": "创建新项目", + "subtitle": "开始一个新的协作项目", + "name_placeholder": "例如:my-awesome-team", + "org_placeholder": "例如:Acme Corporation", + "desc_placeholder": "这个项目是关于什么的?", + "slug": "项目 Slug", + "slug_required": "必填", + "slug_hint": "用于 URL,只能包含小写字母、数字和连字符。", + "display_name": "显示名称", + "display_name_required": "必填", + "display_name_placeholder": "例如:Acme Corporation", + "description": "描述", + "visibility": "可见性", + "public_project": "公开项目", + "public_desc": "任何人都可以发现和加入", + "private_project": "私有项目", + "private_desc": "只有受邀成员可以访问", + "change": "更改", + "create_failed": "创建项目失败。Slug 可能已被使用。", + "create": "创建项目", + "cancel": "取消" + }, + "invitations": { + "title": "邀请", + "subtitle": "查看项目邀请并跟踪加入请求。", + "pending": "待处理邀请", + "pending_desc": "由项目管理员发送的邀请。", + "no_pending": "没有待处理的邀请", + "no_pending_desc": "新的项目邀请将显示在这里。", + "join_requests": "加入请求", + "join_requests_desc": "您的项目成员申请。", + "no_requests": "没有加入请求", + "no_requests_desc": "您申请加入的项目将在这里跟踪。", + "invited_by": "邀请人", + "on": "于", + "accept": "接受", + "reject": "拒绝", + "submitted_on": "提交于", + "accepted": "邀请已接受。", + "rejected": "邀请已拒绝。", + "process_failed": "处理邀请失败。", + "cancelled": "加入请求已取消。", + "cancel_failed": "取消加入请求失败。" + }, + "notifications": { + "no_notifications": "暂无通知", + "no_unread": "没有未读通知", + "all_caught_up": "已处理完毕" + }, + "followers": { + "no_followers": "暂无关注者" + }, + "following": { + "no_following": "暂无关注" + }, + "user_not_found": "未找到用户", + "please_login": "请确保您已登录。", + "profile": { + "joined": "加入于", + "projects": "项目", + "repos": "仓库", + "stars": "星标", + "followers": "关注者", + "follow": "关注", + "unfollow": "取消关注", + "contributions_in_year": "去年贡献了 ${count} 次", + "contributions_on_date": "${count} 次贡献于 ${date}", + "less": "少", + "more": "多" + } + }, + "explore": { + "title": "探索项目", + "subtitle": "发现公共项目和社区", + "search_placeholder": "按名称或描述搜索项目...", + "no_projects": "未找到项目", + "no_description": "暂无描述", + "found": "已找到", + "showing": "显示可发现的项目", + "try_different": "尝试其他搜索词", + "view_project": "查看项目" + }, + "chat": { + "conversations": { + "search_placeholder": "搜索对话...", + "no_conversations": "暂无对话", + "no_matching": "没有匹配的对话", + "new_chat": "新对话", + "chat_history": "对话历史", + "start_new_chat": "开始新对话来探索 AI。", + "try_different_search": "尝试其他搜索词。", + "untitled_chat": "无标题对话", + "create_failed": "创建对话失败" + }, + "model_selector": { + "search_placeholder": "搜索模型...", + "loading": "加载中...", + "no_models": "未找到模型", + "please_select_model": "请先选择一个模型" + }, + "header": { + "new_chat": "新对话", + "expand_history": "展开对话历史", + "collapse_history": "收起对话历史", + "streaming": "生成中...", + "share": "分享对话", + "rename": "重命名", + "more": "更多" + }, + "message_list": { + "welcome_title": "有什么我可以帮你的吗?", + "welcome_desc": "你可以问我任何问题——代码、写作、分析等,我都能帮忙。", + "new_response": "新回复", + "responding": "回复中...", + "explain_code": "解释一段代码或调试错误", + "summarize_doc": "总结或起草文档", + "review_pr": "审查 Pull Request 或仓库", + "brainstorm": "头脑风暴或制定计划", + "jump_to_message": "跳转到你的第 ${index} 条消息" + } + }, + "channel": { + "no_selected": "未选择频道", + "no_threads": "暂无讨论串。使用消息操作菜单开始一个。", + "no_messages": "暂无消息。开始对话吧!", + "edit_history": "编辑历史", + "no_edit_history": "暂无编辑历史。", + "no_pinned": "暂无置顶消息。", + "reply_placeholder": "在讨论串中回复...", + "beginning_of_channel": "频道开始", + "scroll_up": "向上滚动以加载更早的消息", + "new_message": "${count} 条新消息" + }, + "search": { + "global_placeholder": "搜索项目、频道、仓库、看板、issues...", + "no_results": "未找到结果。", + "no_messages": "没有匹配的消息。", + "results": "搜索结果", + "jump_to": "跳转到项目、频道、仓库、看板和 issues。", + "messages": "消息", + "quick_switch": "快速切换", + "projects": "项目", + "my_home": "我的主页", + "my_home_desc": "打开个人概览", + "explore_projects": "探索项目", + "explore_projects_desc": "查找公共项目", + "chat": "聊天", + "chat_desc": "打开个人聊天", + "project_overview": "项目概览", + "repositories": "仓库", + "issues": "Issues", + "boards": "看板", + "project_settings": "项目设置", + "rooms_in": "${project} 中的频道", + "all_rooms": "${project} 中的所有频道", + "messages_in": "${project} 中的消息", + "messages_in_room": "#${room} 中的消息" + }, + "components": { + "message_input": { + "reply_preview": "回复/编辑预览横幅", + "add_emoji": "添加表情", + "placeholder": "消息", + "replying": "正在回复消息", + "editing": "正在编辑消息" + }, + "inline_comment": { + "reply_placeholder": "写回复...", + "leave_comment": "发表评论..." + }, + "merge_panel": { + "commit_message_placeholder": "输入提交信息..." + }, + "ai_settings": { + "add_ai": "添加 AI", + "no_ai": "此频道中没有活跃的 AI 代理", + "search_placeholder": "搜索模型...", + "system_prompt_placeholder": "你是一个有用的助手..." + }, + "room_settings": { + "name_placeholder": "例如:general", + "topic_placeholder": "例如:工程部", + "no_group": "无分组", + "create_new_group": "创建新分组", + "delete_room": "删除频道" + }, + "command": { + "placeholder": "搜索要运行的命令..." + }, + "pr": { + "create_merge_commit": "创建合并提交", + "no_changes": "此 Pull Request 中没有更改。", + "confirm_merge": "确认合并" + }, + "commit": { + "back_to_commits": "返回提交", + "no_changes": "此提交中没有更改。" + }, + "repo": { + "no_description": "暂无描述", + "no_description_default": "未提供描述。" + }, + "branches": { + "no_branches": "暂无分支", + "no_branches_desc": "创建您的第一个分支以开始使用" + }, + "tags": { + "no_tags": "暂无标签", + "no_tags_desc": "创建标签以标记历史中的特定点" + }, + "favorites": { + "no_favorites": "暂无收藏消息。" + } + }, + "project_create": { + "repo_name": "仓库名称", + "repo_name_placeholder": "例如:core-api", + "repo_desc_placeholder": "这个仓库是做什么用的?", + "group_name": "新分组名称", + "channel_name": "频道名称", + "channel_name_placeholder": "例如:development", + "board_name": "看板名称", + "board_name_placeholder": "例如:冲刺计划", + "board_desc_placeholder": "任务看板详情...", + "skill_name": "技能名称", + "skill_name_placeholder": "例如:代码审查者", + "skill_desc_placeholder": "这个 AI 技能是做什么的?", + "email_placeholder": "user@example.com", + "no_group": "无分组", + "create_new_group": "创建新分组", + "create_repo": "创建仓库", + "create_channel": "创建频道", + "create_board": "创建看板", + "create_skill": "创建技能" + } +} diff --git a/src/lib/ir/parser.ts b/src/lib/ir/parser.ts index eecbb63..722f933 100644 --- a/src/lib/ir/parser.ts +++ b/src/lib/ir/parser.ts @@ -34,6 +34,7 @@ interface PatternHit { function findNextPattern(content: string, fromIndex: number): PatternHit | null { let earliest: PatternHit | null = null; + let earliestStart = Number.POSITIVE_INFINITY; // Scan mentions MENTION_RE.lastIndex = fromIndex; @@ -45,7 +46,10 @@ function findNextPattern(content: string, fromIndex: number): PatternHit | null end: mentionMatch.index + mentionMatch[0].length, data: { entityType: mentionMatch[1], entityId: mentionMatch[2], entityLabel: mentionMatch[3] }, }; - if (!earliest || candidate.start < earliest.start) earliest = candidate; + if (candidate.start < earliestStart) { + earliest = candidate; + earliestStart = candidate.start; + } } // Scan fenced code blocks @@ -58,7 +62,10 @@ function findNextPattern(content: string, fromIndex: number): PatternHit | null end: codeMatch.index + codeMatch[0].length, data: { language: codeMatch[1], content: codeMatch[2] }, }; - if (!earliest || candidate.start < earliest.start) earliest = candidate; + if (candidate.start < earliestStart) { + earliest = candidate; + earliestStart = candidate.start; + } } return earliest; @@ -230,6 +237,44 @@ export function parseContentBlocks(raw: unknown): IrContentBlock[] { // Legacy format — content is a string, parse into IrNode[] const content = typeof obj.content === "string" ? obj.content : ""; + + // Handle tool_call/tool_result roles — parse JSON content directly into IR nodes + if (role === "tool_call") { + try { + const parsed = JSON.parse(content); + const node: IrToolCallNode = { + id: `ir-tool-${++nodeIdCounter}`, + type: "tool_call", + tool: parsed.tool || parsed.display || "unknown", + args: parsed.args || parsed.metadata || {}, + children_id: parsed.children_id, + }; + blocks.push({ role, nodes: [node] }); + } catch { + blocks.push({ role, nodes: extractIrNodes(content) }); + } + continue; + } + if (role === "tool_result") { + try { + const parsed = JSON.parse(content); + const node: IrToolResultNode = { + id: `ir-tool-${++nodeIdCounter}`, + type: "tool_result", + tool: parsed.tool || "unknown", + status: parsed.status === "error" ? "error" : parsed.status === "stopped" ? "stopped" : "ok", + content: parsed.output || parsed.result || content, + children_id: parsed.children_id, + role: parsed.role, + task: parsed.task, + }; + blocks.push({ role, nodes: [node] }); + } catch { + blocks.push({ role, nodes: extractIrNodes(content) }); + } + continue; + } + const normalized = role === "thinking" ? normalizeThinking(content) : normalizeAnswer(content); blocks.push({ role, nodes: extractIrNodes(normalized) }); } @@ -253,7 +298,7 @@ export function extractAnswerText(blocks: IrContentBlock[]): string { .filter((b) => b.role !== "thinking") .flatMap((b) => b.nodes.map((n) => { if (n.type === 'text') return (n as IrTextNode).content; - if (n.type === 'mention') return (n as IrMentionNode).entityLabel; + if (n.type === 'mention') return (n as IrMentionNode).entity_label; if (n.type === 'code_block') return `\n\`\`\`${(n as IrCodeBlockNode).language}\n${(n as IrCodeBlockNode).content}\n\`\`\`\n`; if (n.type === 'html_fragment') return `\n\`\`\`html\n${(n as IrHtmlFragmentNode).content}\n\`\`\`\n`; return ''; @@ -266,7 +311,7 @@ export function extractFullText(blocks: IrContentBlock[]): string { return blocks.map((b) => { const text = b.nodes.map((n) => { if (n.type === 'text') return (n as IrTextNode).content; - if (n.type === 'mention') return (n as IrMentionNode).entityLabel; + if (n.type === 'mention') return (n as IrMentionNode).entity_label; return ''; }).join(""); if (b.role === "thinking") return `[Thinking]\n${text}\n[/Thinking]`; diff --git a/src/lib/ir/renderer.tsx b/src/lib/ir/renderer.tsx index a38c374..1d51c5b 100644 --- a/src/lib/ir/renderer.tsx +++ b/src/lib/ir/renderer.tsx @@ -1,109 +1,108 @@ -import { memo } from "react"; +import {memo} from "react"; import type { + IrCodeBlockNode, + IrDataCardNode, + IrHtmlFragmentNode, + IrMentionNode, + IrMermaidNode, IrNode, IrTextNode, - IrMentionNode, - IrCodeBlockNode, - IrHtmlFragmentNode, - IrMermaidNode, - IrDataCardNode, } from "@/lib/ir/types"; -import { MarkdownRenderer } from "@/components/ui/MarkdownRenderer"; -import { MentionChipRenderer } from "@/components/ir/MentionChipRenderer"; -import { MermaidRenderer } from "@/components/ir/MermaidRenderer"; -import { DataCardRenderer } from "@/components/ir/DataCardRenderer"; +import {MarkdownRenderer} from "@/components/ui/MarkdownRenderer"; +import {MentionChipRenderer} from "@/components/ir/MentionChipRenderer"; +import {MermaidRenderer} from "@/components/ir/MermaidRenderer"; +import {DataCardRenderer} from "@/components/ir/DataCardRenderer"; interface IrRendererProps { - nodes: IrNode[]; - className?: string; + nodes: IrNode[]; + className?: string; } /** Dispatch IrNode[] to specialized renderer components. * Text and code go through MarkdownRenderer so every fenced code language, * including html, uses the same collapsed/code-panel flow. */ -export const IrRenderer = memo(function IrRenderer({ nodes, className }: IrRendererProps) { - if (!nodes.length) return null; +export const IrRenderer = memo(function IrRenderer({nodes, className}: IrRendererProps) { + if (!nodes.length) return null; - const allText = nodes.every((n) => n.type === "text"); - if (allText) { - const combined = nodes.map((n) => (n as IrTextNode).content).join(""); - return ; - } + const allText = nodes.every((n) => n.type === "text"); + if (allText) { + const combined = nodes.map((n) => (n as IrTextNode).content).join(""); + return ; + } - return ( -
- {nodes.map((node) => { - switch (node.type) { - case "text": - return ( - - ); - case "mention": - return ( - - ); - case "code_block": { - const cb = node as IrCodeBlockNode; - return ( - - ); - } - case "html_fragment": { - const hf = node as IrHtmlFragmentNode; - return ( - - ); - } - case "mermaid": - return ( - - ); - case "data_card": { - const dc = node as IrDataCardNode; - return ( - - ); - } - case "chart": - case "executable_code": - return ( -
- [{node.type} renderer pending] -
- ); - default: - return null; - } - })} -
- ); + return ( +
+ {nodes.map((node) => { + switch (node.type) { + case "text": + return ( + + ); + case "mention": + return ( + + ); + case "code_block": { + const cb = node as IrCodeBlockNode; + return ( + + ); + } + case "html_fragment": { + const hf = node as IrHtmlFragmentNode; + return ( + + ); + } + case "mermaid": + return ( + + ); + case "data_card": { + const dc = node as IrDataCardNode; + return ( + + ); + } + case "chart": + return ( +
+ [{node.type} renderer pending] +
+ ); + default: + return null; + } + })} +
+ ); }); diff --git a/src/lib/ir/sanitize.ts b/src/lib/ir/sanitize.ts index 4068379..d730031 100644 --- a/src/lib/ir/sanitize.ts +++ b/src/lib/ir/sanitize.ts @@ -74,7 +74,7 @@ export const semanticSchema: Schema = { function walkHastElements(node: HastNode, callback: (el: HastElement) => void): void { if (node.type === 'element') callback(node as HastElement); - if (node.children) { + if (node.type === 'element' || node.type === 'root') { for (const child of node.children) { walkHastElements(child, callback); } @@ -97,4 +97,4 @@ export function rehypeFilterClasses() { }); return tree; }; -} \ No newline at end of file +} diff --git a/src/lib/ir/types.ts b/src/lib/ir/types.ts index f516e69..bfd1898 100644 --- a/src/lib/ir/types.ts +++ b/src/lib/ir/types.ts @@ -102,13 +102,17 @@ export interface IrToolCallNode extends IrNodeBase { type: 'tool_call'; tool: string; args: Record; + children_id?: string; } export interface IrToolResultNode extends IrNodeBase { type: 'tool_result'; tool: string; - status: 'ok' | 'error'; + status: 'ok' | 'stopped' | 'error'; content: string; + children_id?: string; + role?: string; + task?: string; } export interface IrMentionNode extends IrNodeBase { @@ -200,4 +204,4 @@ export interface RenderNode { component?: string; // component name for interactive nodes data?: Record; props?: Record; -} \ No newline at end of file +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index cb19003..419447c 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,27 +1,44 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import {type ClassValue, clsx} from "clsx" +import {twMerge} from "tailwind-merge" export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)) } export function truncate(text: string, maxLen: number): string { - if (text.length <= maxLen) return text; - return text.slice(0, maxLen).trimEnd() + "…"; + if (text.length <= maxLen) return text; + return text.slice(0, maxLen).trimEnd() + "…"; } -export function stripMarkdown(text: string): string { - return text - .replace(/#{1,6}\s+/g, "") - .replace(/(\*\*|__)(.*?)\1/g, "$2") - .replace(/(\*|_)(.*?)\1/g, "$2") - .replace(/~~(.*?)~~/g, "$1") - .replace(/`{1,3}[^`]*`{1,3}/g, "") - .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") - .replace(/!\[[^\]]*\]\([^)]+\)/g, "") - .replace(/>\s+/g, "") - .replace(/^[-*+]\s+/gm, "") - .replace(/^\d+\.\s+/gm, "") - .replace(/\n{2,}/g, " ") - .trim(); +export function isMacOS(): boolean { + return navigator.platform.toUpperCase().indexOf("MAC") >= 0 || + navigator.userAgent.toUpperCase().indexOf("MAC") >= 0; +} + +export function modKey(): string { + return isMacOS() ? "⌘" : "Ctrl"; +} + +export function altKey(): string { + return isMacOS() ? "⌥" : "Alt"; +} + +export function shortcutLabel(keys: string[]): string { + return keys.join(isMacOS() ? "" : "+"); +} + +export const stripMarkdown = (text: string): string => { + return text + .replace(/#{1,6}\s+/g, "") + .replace(/(\*\*|__)(.*?)\1/g, "$2") + .replace(/(\*|_)(.*?)\1/g, "$2") + .replace(/~~(.*?)~~/g, "$1") + .replace(/`{1,3}[^`]*`{1,3}/g, "") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/!\[[^\]]*\]\([^)]+\)/g, "") + .replace(/>\s+/g, "") + .replace(/^[-*+]\s+/gm, "") + .replace(/^\d+\.\s+/gm, "") + .replace(/\n{2,}/g, " ") + .trim(); } diff --git a/src/store/streaming.ts b/src/store/streaming.ts index 9811796..766047f 100644 --- a/src/store/streaming.ts +++ b/src/store/streaming.ts @@ -19,7 +19,10 @@ export interface StreamPart { /** Tool call metadata */ toolName?: string; toolArgs?: Record; - toolStatus?: "ok" | "error"; + toolStatus?: "ok" | "stopped" | "error"; + children_id?: string; + /** Sub-agent delegation output — full text returned by call_sub_agent */ + subAgentOutput?: string; /** Extracted IR nodes for structured rendering. * Computed from content — text segments may contain mentions * or other patterns that IrRenderer dispatches to dedicated components. diff --git a/src/ws/bridge.ts b/src/ws/bridge.ts index e146c02..55f7d98 100644 --- a/src/ws/bridge.ts +++ b/src/ws/bridge.ts @@ -9,138 +9,132 @@ * - Rust → raw WS tagged JSON → bridge parses `type` field → emits Socket.IO event * - Socket.IO event → bridge wraps with `type` field → sends raw WS tagged JSON */ -import { createServer } from 'http'; -import { Server as IoServer } from 'socket.io'; -import type { WsOutEvent, WsOutEventName } from './types/outbound'; -import type { WsInMessage, WsInEventName } from './types/inbound'; -import { DEFAULT_WS_PATH } from './constants'; +import {createServer} from 'http'; +import {Server as IoServer} from 'socket.io'; +import type {WsInEventName, WsInMessage, WsOutEvent, WsOutEventName} from '@/ws/types'; +import {DEFAULT_WS_PATH} from './constants'; const RUST_BACKEND_URL = process.env.RUST_BACKEND_URL || 'http://localhost:8080'; const BRIDGE_PORT = parseInt(process.env.WS_BRIDGE_PORT || '3001', 10); -// ── HTTP server + Socket.IO ───────────────────────────── const httpServer = createServer(); const ioServer = new IoServer(httpServer, { - cors: { origin: '*', methods: ['GET', 'POST'] }, - transports: ['websocket'], + cors: {origin: '*', methods: ['GET', 'POST']}, + transports: ['websocket'], }); // ── Auth: validate Socket.IO connection via backend WS token ── ioServer.use(async (socket, next) => { - const token = socket.handshake.auth.token; - if (!token) { - // Try to fetch a token from the backend using the user's session cookie - // This requires the frontend to pass the auth token - return next(new Error('no token provided')); - } - - // Validate token against Rust backend - try { - const res = await fetch(`${RUST_BACKEND_URL}/api/ws/token`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - }); - if (res.ok) { - const body = await res.json(); - socket.data.userId = body.data?.user_id || body.user_id; - return next(); + const token = socket.handshake.auth.token; + if (!token) { + // Try to fetch a token from the backend using the user's session cookie + // This requires the frontend to pass the auth token + return next(new Error('no token provided')); + } + + // Validate token against Rust backend + try { + const res = await fetch(`${RUST_BACKEND_URL}/api/ws/token`, { + method: 'POST', + headers: {Authorization: `Bearer ${token}`}, + }); + if (res.ok) { + const body = await res.json(); + socket.data.userId = body.data?.user_id || body.user_id; + return next(); + } + return next(new Error('token validation failed')); + } catch { + return next(new Error('backend unreachable')); } - return next(new Error('token validation failed')); - } catch { - return next(new Error('backend unreachable')); - } }); -// ── Per-connection raw WS to Rust backend ─────────────── ioServer.on('connection', (ioSocket) => { - const userId = ioSocket.data.userId as string; - console.log(`[bridge] Socket.IO client connected: ${userId}`); + const userId = ioSocket.data.userId as string; + console.log(`[bridge] Socket.IO client connected: ${userId}`); - // Connect to Rust backend via raw WebSocket - const wsUrl = `${RUST_BACKEND_URL.replace('http', 'ws')}${DEFAULT_WS_PATH}?token=${encodeURIComponent(ioSocket.handshake.auth.token)}`; - const rawWs = new WebSocket(wsUrl); + // Connect to Rust backend via raw WebSocket + const wsUrl = `${RUST_BACKEND_URL.replace('http', 'ws')}${DEFAULT_WS_PATH}?token=${encodeURIComponent(ioSocket.handshake.auth.token)}`; + const rawWs = new WebSocket(wsUrl); - // ── Rust → Socket.IO ──────────────────────────────── - rawWs.onmessage = (ev) => { - try { - const event = JSON.parse(ev.data as string) as WsOutEvent; - const eventName: WsOutEventName = event.type; + rawWs.onmessage = (ev) => { + try { + const event = JSON.parse(ev.data as string) as WsOutEvent; + const eventName: WsOutEventName = event.type; - // Forward to Socket.IO client - // Remove the `type` field from payload (Socket.IO event name replaces it) - const payload = { ...event }; - delete (payload as Record).type; - ioSocket.emit(eventName, payload); - } catch { - // Raw WS ping/pong — forward as Socket.IO ping event - if ((ev.data as string).trim() === '{"type":"pong"}') { - ioSocket.emit('pong', {}); - } + // Forward to Socket.IO client + // Remove the `type` field from payload (Socket.IO event name replaces it) + const payload = {...event}; + delete (payload as Record).type; + ioSocket.emit(eventName, payload); + } catch { + // Raw WS ping/pong — forward as Socket.IO ping event + if ((ev.data as string).trim() === '{"type":"pong"}') { + ioSocket.emit('pong', {}); + } + } + }; + + rawWs.onclose = () => { + console.log(`[bridge] Raw WS closed for user: ${userId}`); + ioSocket.disconnect(true); + }; + + rawWs.onerror = () => { + console.error(`[bridge] Raw WS error for user: ${userId}`); + }; + + // Listen for all inbound event names and forward as tagged JSON + const inboundEvents: WsInEventName[] = [ + 'subscribe', 'unsubscribe', + 'typing_start', 'typing_stop', 'read_receipt', + 'message_list', 'message_create', 'message_update', 'message_revoke', + 'room_get', 'room_create', 'room_update', 'room_delete', + 'category_create', 'category_update', 'category_delete', + 'access_grant', 'access_revoke', + 'state_set_read_seq', 'state_update_dnd', + 'reaction_add', 'reaction_remove', + 'thread_create', 'thread_resolve', 'thread_archive', + 'pin_add', 'pin_remove', + 'draft_save', 'draft_clear', 'search', + 'notification_mark_read', 'notification_mark_all_read', 'notification_archive', + 'presence_update', 'custom_status_update', + 'invite_create', 'invite_accept', 'invite_revoke', + 'ban_create', 'ban_remove', + 'voice_join', 'voice_leave', 'voice_mute', 'voice_deaf', 'screen_share', + 'ai_list', 'ai_upsert', 'ai_delete', 'ai_stop', + ]; + + for (const eventName of inboundEvents) { + ioSocket.on(eventName, (data: Record) => { + const msg: WsInMessage = {type: eventName, ...data} as WsInMessage; + if (rawWs.readyState === WebSocket.OPEN) { + rawWs.send(JSON.stringify(msg)); + } + }); } - }; - rawWs.onclose = () => { - console.log(`[bridge] Raw WS closed for user: ${userId}`); - ioSocket.disconnect(true); - }; - - rawWs.onerror = () => { - console.error(`[bridge] Raw WS error for user: ${userId}`); - }; - - // ── Socket.IO → Rust ──────────────────────────────── - // Listen for all inbound event names and forward as tagged JSON - const inboundEvents: WsInEventName[] = [ - 'subscribe', 'unsubscribe', - 'typing_start', 'typing_stop', 'read_receipt', - 'message_list', 'message_create', 'message_update', 'message_revoke', - 'room_get', 'room_create', 'room_update', 'room_delete', - 'category_create', 'category_update', 'category_delete', - 'access_grant', 'access_revoke', - 'state_set_read_seq', 'state_update_dnd', - 'reaction_add', 'reaction_remove', - 'thread_create', 'thread_resolve', 'thread_archive', - 'pin_add', 'pin_remove', - 'draft_save', 'draft_clear', 'search', - 'notification_mark_read', 'notification_mark_all_read', 'notification_archive', - 'presence_update', 'custom_status_update', - 'invite_create', 'invite_accept', 'invite_revoke', - 'ban_create', 'ban_remove', - 'voice_join', 'voice_leave', 'voice_mute', 'voice_deaf', 'screen_share', - 'ai_list', 'ai_upsert', 'ai_delete', 'ai_stop', - ]; - - for (const eventName of inboundEvents) { - ioSocket.on(eventName, (data: Record) => { - const msg: WsInMessage = { type: eventName, ...data } as WsInMessage; - if (rawWs.readyState === WebSocket.OPEN) { - rawWs.send(JSON.stringify(msg)); - } + // Special: Socket.IO ping → Rust WS ping + ioSocket.on('ping', () => { + if (rawWs.readyState === WebSocket.OPEN) { + rawWs.send(JSON.stringify({type: 'ping'})); + } }); - } - // Special: Socket.IO ping → Rust WS ping - ioSocket.on('ping', () => { - if (rawWs.readyState === WebSocket.OPEN) { - rawWs.send(JSON.stringify({ type: 'ping' })); - } - }); - - // ── Cleanup ────────────────────────────────────────── - ioSocket.on('disconnect', () => { - console.log(`[bridge] Socket.IO client disconnected: ${userId}`); - if (rawWs.readyState === WebSocket.OPEN) { - rawWs.close(); - } - }); + ioSocket.on('disconnect', () => { + console.log(`[bridge] Socket.IO client disconnected: ${userId}`); + if (rawWs.readyState === WebSocket.OPEN) { + rawWs.close(); + } + }); }); // ── Start ─────────────────────────────────────────────── httpServer.listen(BRIDGE_PORT, () => { - console.log(`[bridge] Socket.IO bridge server running on port ${BRIDGE_PORT}`); - console.log(`[bridge] Connecting to Rust backend at ${RUST_BACKEND_URL}`); + console.log(`[bridge] Socket.IO bridge server running on port ${BRIDGE_PORT}`); + console.log(`[bridge] Connecting to Rust backend at ${RUST_BACKEND_URL}`); }); \ No newline at end of file diff --git a/src/ws/client.ts b/src/ws/client.ts index ac7d5ff..1ba6bc4 100644 --- a/src/ws/client.ts +++ b/src/ws/client.ts @@ -19,15 +19,20 @@ import {ReconnectManager} from './reconnect'; import {DedupManager} from './dedup'; import {fetchWsToken, invalidateWsToken} from './auth'; import { - CLIENT_PING_INTERVAL_MS, - DEFAULT_WS_PATH, - HEARTBEAT_TIMEOUT_MS, - MAX_TEXT_MESSAGE_LEN, - WS_PROTOCOL_VERSION, + CLIENT_PING_INTERVAL_MS, + DEFAULT_WS_PATH, + HEARTBEAT_TIMEOUT_MS, + MAX_TEXT_MESSAGE_LEN, + WS_PROTOCOL_VERSION, } from './constants'; -import type {WsOutEvent, WsOutEventName, WsMessageStreamChunkEvent} from './types/outbound'; -import type {WsInEventName, WsInMessage} from './types/inbound'; -import type {RoomId} from './types/core'; +import type { + RoomId, + WsInEventName, + WsInMessage, + WsMessageStreamChunkEvent, + WsOutEvent, + WsOutEventName +} from '@/ws/types'; export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'; @@ -73,7 +78,6 @@ export class WsClient { /** Max offline queue size — prevents unbounded memory growth. */ private readonly OFFLINE_QUEUE_MAX = 100; - // ── Request-response ──────────────────────────────────── private pendingAcks = new Map void; reject: (err: Error) => void; @@ -82,7 +86,6 @@ export class WsClient { private readonly ACK_TIMEOUT_MS = 15_000; private protocolChecked = false; - // ── Token management ──────────────────────────────────── constructor(config: WsClientConfig = {}) { this.wsUrl = config.url ?? ''; @@ -97,7 +100,6 @@ export class WsClient { this.reconnect.setCallback(() => this.doConnect()); - // ── Visibility & network recovery ─────────────── this.handleVisibilityChange = () => this.onVisibilityChange(); this.handleOnline = () => this.onNetworkOnline(); document.addEventListener('visibilitychange', this.handleVisibilityChange); @@ -108,7 +110,6 @@ export class WsClient { this.wsToken = token; } - // ── URL helpers ───────────────────────────────────────── getToken(): string | null { return this.wsToken; @@ -118,7 +119,6 @@ export class WsClient { return this.backendUrl; } - // ── Connection lifecycle ──────────────────────────────── async connect(): Promise { if (this.status === 'connected' || this.status === 'connecting') return; @@ -218,7 +218,6 @@ export class WsClient { return Promise.reject(new Error('WebSocket not connected')); } - // ── Ping ─────────────────────────────────────────────── onStatusChange(cb: (status: ConnectionStatus) => void): () => void { this.statusListeners.add(cb); @@ -229,7 +228,6 @@ export class WsClient { return this.status; } - // ── Protocol version ─────────────────────────────────── on(name: N, cb: (data: Extract) => void): this { this.emitter.on(name, cb); @@ -241,7 +239,6 @@ export class WsClient { return this; } - // ── Sending ──────────────────────────────────────────── off(name: N, cb: (data: Extract) => void): this { this.emitter.off(name, cb); @@ -262,8 +259,6 @@ export class WsClient { this.subscriptions.subscribe(roomId); } - // ── Receiving ───────────────────────────────────────── - leaveRoom(roomId: RoomId): void { this.subscriptions.unsubscribe(roomId); } @@ -272,7 +267,6 @@ export class WsClient { return this.subscriptions.getSubscribedRooms().includes(roomId); } - // ── Status ────────────────────────────────────────────── getSubscribedRooms(): RoomId[] { return this.subscriptions.getSubscribedRooms(); @@ -286,7 +280,6 @@ export class WsClient { this.emit('typing_stop', {room: roomId}); } - // ── Public typed API ───────────────────────────────────── sendReadReceipt(roomId: RoomId, lastReadSeq: number) { this.emit('read_receipt', {room: roomId, last_read_seq: lastReadSeq}); @@ -314,7 +307,6 @@ export class WsClient { this.emit('message_revoke', {message: messageId}); } - // ── Room subscriptions ────────────────────────────────── addReaction(roomId: RoomId, messageId: string, emoji: string) { this.emit('reaction_add', {room: roomId, message: messageId, emoji}); @@ -336,7 +328,6 @@ export class WsClient { this.emit('notification_archive', {id}); } - // ── Room operations ───────────────────────────────────── saveDraft(roomId: RoomId, content: string) { this.emit('draft_save', {room: roomId, content}); @@ -346,7 +337,6 @@ export class WsClient { this.emit('draft_clear', {room: roomId}); } - // ── Room Management ───────────────────────────────────── createRoom(project: string, name: string, isPublic: boolean, category?: string) { this.emit('room_create', {project, room_name: name, public: isPublic, category: category ?? null}); @@ -360,7 +350,6 @@ export class WsClient { this.emit('room_delete', {room: roomId}); } - // ── Category Management ───────────────────────────────── createCategory(project: string, name: string, position?: number) { this.emit('category_create', {project, name, position: position ?? null}); @@ -374,7 +363,6 @@ export class WsClient { this.emit('category_delete', {id}); } - // ── Thread operations ─────────────────────────────────── resolveThread(threadId: string) { this.emit('thread_resolve', {thread_id: threadId}); @@ -384,7 +372,6 @@ export class WsClient { this.emit('thread_archive', {thread_id: threadId}); } - // ── AI operations ─────────────────────────────────────── getAiList(roomId: RoomId) { return this.emitWithAck('ai_list', {room: roomId}); @@ -410,11 +397,10 @@ export class WsClient { } getUserSummary(username: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any -httpFallback?: () => Promise) { - return this.emitWithAck('user_summary', { username }, httpFallback); + httpFallback?: () => Promise) { + return this.emitWithAck('user_summary', {username}, httpFallback); } - // ── Search ────────────────────────────────────────────── search(query: string, opts?: { room?: RoomId; @@ -428,26 +414,75 @@ httpFallback?: () => Promise) { return this.emitWithAck('search', {q: query, ...opts}); } - - // ── Additional Coverage ───────────────────────────────── - getRoom(roomId: string) { return this.emitWithAck('room_get', { room: roomId }); } - setStateReadSeq(roomId: string, lastReadSeq: number) { this.emit('state_set_read_seq', { room: roomId, last_read_seq: lastReadSeq }); } - grantAccess(roomId: string, targetUserId: string, role: string) { this.emit('access_grant', { room: roomId, user_id: targetUserId, role }); } - revokeAccess(roomId: string, targetUserId: string) { this.emit('access_revoke', { room: roomId, user_id: targetUserId }); } - updatePresence(status: string) { this.emit('presence_update', { status }); } - updateCustomStatus(emoji: string, text: string, expiresAt?: string) { this.emit('custom_status_update', { emoji, text, expires_at: expiresAt ?? null }); } + + getRoom(roomId: string) { + return this.emitWithAck('room_get', {room: roomId}); + } + + setStateReadSeq(roomId: string, lastReadSeq: number) { + this.emit('state_set_read_seq', {room: roomId, last_read_seq: lastReadSeq}); + } + + grantAccess(roomId: string, targetUserId: string, role: string) { + this.emit('access_grant', {room: roomId, user_id: targetUserId, role}); + } + + revokeAccess(roomId: string, targetUserId: string) { + this.emit('access_revoke', {room: roomId, user_id: targetUserId}); + } + + updatePresence(status: string) { + this.emit('presence_update', {status}); + } + + updateCustomStatus(emoji: string, text: string, expiresAt?: string) { + this.emit('custom_status_update', {emoji, text, expires_at: expiresAt ?? null}); + } + createInvite(roomId: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any -opts?: any) { return this.emitWithAck('invite_create', { room: roomId, ...opts }); } - acceptInvite(inviteId: string) { return this.emitWithAck('invite_accept', { invite_id: inviteId }); } - revokeInvite(inviteId: string) { this.emit('invite_revoke', { invite_id: inviteId }); } - banUser(roomId: string, userId: string, reason?: string) { this.emit('ban_create', { room: roomId, user_id: userId, reason: reason ?? null }); } - unbanUser(roomId: string, userId: string) { this.emit('ban_remove', { room: roomId, user_id: userId }); } - createThread(roomId: string, parentSeq: number) { return this.emitWithAck('thread_create', { room: roomId, parent_seq: parentSeq }); } - joinVoice(roomId: string) { this.emit('voice_join', { room: roomId }); } - leaveVoice(roomId: string) { this.emit('voice_leave', { room: roomId }); } - muteVoice(roomId: string, muted: boolean) { this.emit('voice_mute', { room: roomId, muted }); } - deafVoice(roomId: string, deafened: boolean) { this.emit('voice_deaf', { room: roomId, deafened }); } - screenShare(roomId: string, start: boolean) { this.emit('screen_share', { room: roomId, start }); } + opts?: any) { + return this.emitWithAck('invite_create', {room: roomId, ...opts}); + } + + acceptInvite(inviteId: string) { + return this.emitWithAck('invite_accept', {invite_id: inviteId}); + } + + revokeInvite(inviteId: string) { + this.emit('invite_revoke', {invite_id: inviteId}); + } + + banUser(roomId: string, userId: string, reason?: string) { + this.emit('ban_create', {room: roomId, user_id: userId, reason: reason ?? null}); + } + + unbanUser(roomId: string, userId: string) { + this.emit('ban_remove', {room: roomId, user_id: userId}); + } + + createThread(roomId: string, parentSeq: number) { + return this.emitWithAck('thread_create', {room: roomId, parent_seq: parentSeq}); + } + + joinVoice(roomId: string) { + this.emit('voice_join', {room: roomId}); + } + + leaveVoice(roomId: string) { + this.emit('voice_leave', {room: roomId}); + } + + muteVoice(roomId: string, muted: boolean) { + this.emit('voice_mute', {room: roomId, muted}); + } + + deafVoice(roomId: string, deafened: boolean) { + this.emit('voice_deaf', {room: roomId, deafened}); + } + + screenShare(roomId: string, start: boolean) { + this.emit('screen_share', {room: roomId, start}); + } destroy(): void { if (this.handleVisibilityChange) { @@ -679,7 +714,7 @@ opts?: any) { return this.emitWithAck('invite_create', { room: roomId, ...opts } if (innerType) { const innerEvent = {type: innerType, ...respEvent.data} as WsOutEvent; this.emitter.emit(innerType as // eslint-disable-next-line @typescript-eslint/no-explicit-any -any, innerEvent); + any, innerEvent); } return; } @@ -734,7 +769,10 @@ any, innerEvent); // Reset lastHeartbeat to avoid false timeout on next ping check, // then send an immediate ping to verify the connection is alive. this.lastHeartbeat = Date.now(); - try { this.emit('ping', {}); } catch { /* ws may be dead */ } + try { + this.emit('ping', {}); + } catch { /* ws may be dead */ + } return; } diff --git a/src/ws/hooks.ts b/src/ws/hooks.ts index cfa9152..7527ba8 100644 --- a/src/ws/hooks.ts +++ b/src/ws/hooks.ts @@ -2,12 +2,11 @@ * React hooks for WebSocket — typed event subscription, * room management, typing indicators, and connection status. */ -import { useEffect, useRef, useCallback, useState } from 'react'; -import { WsClient } from './client'; -import type { ConnectionStatus, WsClientConfig } from './client'; -import { useWsStore } from './store'; -import type { WsOutEventName, WsOutEvent } from './types/outbound'; -import type { RoomId } from './types/core'; +import {useCallback, useEffect, useRef, useState} from 'react'; +import type {ConnectionStatus, WsClientConfig} from './client'; +import {WsClient} from './client'; +import {useWsStore} from './store'; +import type {RoomId, WsOutEvent, WsOutEventName} from '@/ws/types'; /** Global singleton WsClient instance. */ let globalClient: WsClient | null = null; @@ -17,232 +16,226 @@ let clientId = 0; /** Get the global WsClient instance, or null if not initialized. */ export function getWsClient(): WsClient | null { - return globalClient; + return globalClient; } /** Get the current client identity counter — changes when client is re-initialized. */ export function getWsClientId(): number { - return clientId; + return clientId; } /** Initialize the global WsClient with config. */ export function initWsClient(config: WsClientConfig): WsClient { - if (globalClient) globalClient.destroy(); - globalClient = new WsClient(config); - clientId++; + if (globalClient) globalClient.destroy(); + globalClient = new WsClient(config); + clientId++; - // Bridge status changes to Zustand store - globalClient.onStatusChange((status) => { - useWsStore.getState().setStatus(status); - if (status === 'reconnecting') { - useWsStore.getState().setReconnecting(true); - } else { - useWsStore.getState().setReconnecting(false); - } - if (status === 'connected') { - useWsStore.getState().setLastError(null); - } - }); + // Bridge status changes to Zustand store + globalClient.onStatusChange((status) => { + useWsStore.getState().setStatus(status); + if (status === 'reconnecting') { + useWsStore.getState().setReconnecting(true); + } else { + useWsStore.getState().setReconnecting(false); + } + if (status === 'connected') { + useWsStore.getState().setLastError(null); + } + }); - // Bridge error events to store - globalClient.on('error', (event) => { - useWsStore.getState().setLastError(event.message); - }); + // Bridge error events to store + globalClient.on('error', (event) => { + useWsStore.getState().setLastError(event.message); + }); - return globalClient; + return globalClient; } -// ── Initialization hook ───────────────────────────────── export interface UseWsInitOptions { - url: string; - backendUrl?: string; - mode?: 'socketio' | 'raw-ws'; - autoReconnect?: boolean; - token?: string; - connectOnMount?: boolean; + url: string; + backendUrl?: string; + mode?: 'socketio' | 'raw-ws'; + autoReconnect?: boolean; + token?: string; + connectOnMount?: boolean; } /** Initialize and optionally auto-connect the WsClient on mount. */ export function useWsInit(options: UseWsInitOptions): WsClient { - const [client] = useState(() => initWsClient(options)); + const [client] = useState(() => initWsClient(options)); - useEffect(() => { - if (options.connectOnMount !== false) { - client.connect(); - } - return () => { - client.disconnect(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + useEffect(() => { + if (options.connectOnMount !== false) { + client.connect(); + } + return () => { + client.disconnect(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - return client; + return client; } -// ── Connection status hook ────────────────────────────── /** Reactive connection status — updates via Zustand. */ export function useWsStatus(): ConnectionStatus { - return useWsStore((s) => s.status); + return useWsStore((s) => s.status); } /** Whether the client is connected. */ export function useWsConnected(): boolean { - return useWsStore((s) => s.status === 'connected'); + return useWsStore((s) => s.status === 'connected'); } /** Last error from the server. */ export function useWsError(): string | null { - return useWsStore((s) => s.lastError); + return useWsStore((s) => s.lastError); } -// ── Typed event subscription hook ─────────────────────── /** Subscribe to a typed outbound event. Auto-cleanup on unmount. Re-registers on client re-init. */ export function useWsEvent( - eventName: N, - callback: (data: Extract) => void, + eventName: N, + callback: (data: Extract) => void, ): void { - const cbRef = useRef(callback); - useEffect(() => { - cbRef.current = callback; - }); + const cbRef = useRef(callback); + useEffect(() => { + cbRef.current = callback; + }); - useEffect(() => { - const client = getWsClient(); - if (!client) return; - const handler = (data: Extract) => cbRef.current(data); - client.on(eventName, handler); - return () => { client.off(eventName, handler); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [eventName, clientId]); + useEffect(() => { + const client = getWsClient(); + if (!client) return; + const handler = (data: Extract) => cbRef.current(data); + client.on(eventName, handler); + return () => { + client.off(eventName, handler); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [eventName, clientId]); } -// ── Room subscription hook ────────────────────────────── - /** Subscribe to a room on mount, keep subscription alive on unmount/room-change. */ export function useRoomSubscription(roomId: RoomId | null): void { - useEffect(() => { - if (!roomId) return; - const client = getWsClient(); - if (!client) return; - client.joinRoom(roomId); - useWsStore.getState().addSubscribedRoom(roomId); - return () => { - // Cleanup: unsubscribe and sync Zustand store on unmount - client.leaveRoom(roomId); - useWsStore.getState().removeSubscribedRoom(roomId); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [roomId, clientId]); + useEffect(() => { + if (!roomId) return; + const client = getWsClient(); + if (!client) return; + client.joinRoom(roomId); + useWsStore.getState().addSubscribedRoom(roomId); + return () => { + // Cleanup: unsubscribe and sync Zustand store on unmount + client.leaveRoom(roomId); + useWsStore.getState().removeSubscribedRoom(roomId); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [roomId, clientId]); } /** Get the set of currently subscribed rooms (reactive). */ export function useSubscribedRooms(): Set { - return useWsStore((s) => s.subscribedRooms); + return useWsStore((s) => s.subscribedRooms); } -// ── Typing indicator hook ─────────────────────────────── export interface TypingUser { - user_id: string; - username: string; - avatar_url: string | null; + user_id: string; + username: string; + avatar_url: string | null; } /** Track typing users in a room. Clears on unmount. */ export function useTypingIndicator(roomId: RoomId | null): TypingUser[] { - const [typingUsers, setTypingUsers] = useState>(new Map()); + const [typingUsers, setTypingUsers] = useState>(new Map()); - useWsEvent('typing_start', (event) => { - if (event.room_id === roomId && roomId) { - const data = event.data; - setTypingUsers((prev) => { - const next = new Map(prev); - next.set(data.user, { user_id: data.user, username: data.username, avatar_url: data.avatar_url ?? null }); - return next; - }); - } - }); + useWsEvent('typing_start', (event) => { + if (event.room_id === roomId && roomId) { + const data = event.data; + setTypingUsers((prev) => { + const next = new Map(prev); + next.set(data.user, {user_id: data.user, username: data.username, avatar_url: data.avatar_url ?? null}); + return next; + }); + } + }); - useWsEvent('typing_stop', (event) => { - if (event.room_id === roomId && roomId) { - setTypingUsers((prev) => { - const next = new Map(prev); - next.delete(event.data.user); - return next; - }); - } - }); + useWsEvent('typing_stop', (event) => { + if (event.room_id === roomId && roomId) { + setTypingUsers((prev) => { + const next = new Map(prev); + next.delete(event.data.user); + return next; + }); + } + }); - return [...typingUsers.values()]; + return [...typingUsers.values()]; } -// ── Notification count hook ───────────────────────────── /** Track unread notification count via notify_created events. */ export function useNotificationCount(): number { - const [count, setCount] = useState(0); + const [count, setCount] = useState(0); - useWsEvent('notify_created', () => { - setCount((c) => c + 1); - }); + useWsEvent('notify_created', () => { + setCount((c) => c + 1); + }); - useWsEvent('notify_read', () => { - setCount((c) => Math.max(0, c - 1)); - }); + useWsEvent('notify_read', () => { + setCount((c) => Math.max(0, c - 1)); + }); - return count; + return count; } -// ── Convenience message sender hook ───────────────────── /** Returns stable send functions bound to a room. */ export function useRoomSender(roomId: RoomId | null) { - const client = getWsClient(); + const client = getWsClient(); - const sendMessage = useCallback( - (content: string, opts?: { content_type?: string; thread?: string; in_reply_to?: string }) => { - if (!roomId || !client) return; - client.sendMessage(roomId, content, opts); - }, - [roomId, client], - ); + const sendMessage = useCallback( + (content: string, opts?: { content_type?: string; thread?: string; in_reply_to?: string }) => { + if (!roomId || !client) return; + client.sendMessage(roomId, content, opts); + }, + [roomId, client], + ); - const sendTypingStart = useCallback(() => { - if (!roomId || !client) return; - client.sendTypingStart(roomId); - }, [roomId, client]); + const sendTypingStart = useCallback(() => { + if (!roomId || !client) return; + client.sendTypingStart(roomId); + }, [roomId, client]); - const sendTypingStop = useCallback(() => { - if (!roomId || !client) return; - client.sendTypingStop(roomId); - }, [roomId, client]); + const sendTypingStop = useCallback(() => { + if (!roomId || !client) return; + client.sendTypingStop(roomId); + }, [roomId, client]); - const sendReadReceipt = useCallback( - (lastReadSeq: number) => { - if (!roomId || !client) return; - client.sendReadReceipt(roomId, lastReadSeq); - }, - [roomId, client], - ); + const sendReadReceipt = useCallback( + (lastReadSeq: number) => { + if (!roomId || !client) return; + client.sendReadReceipt(roomId, lastReadSeq); + }, + [roomId, client], + ); - const addReaction = useCallback( - (messageId: string, emoji: string) => { - if (!roomId || !client) return; - client.addReaction(roomId, messageId, emoji); - }, - [roomId, client], - ); + const addReaction = useCallback( + (messageId: string, emoji: string) => { + if (!roomId || !client) return; + client.addReaction(roomId, messageId, emoji); + }, + [roomId, client], + ); - const removeReaction = useCallback( - (messageId: string, emoji: string) => { - if (!roomId || !client) return; - client.removeReaction(roomId, messageId, emoji); - }, - [roomId, client], - ); + const removeReaction = useCallback( + (messageId: string, emoji: string) => { + if (!roomId || !client) return; + client.removeReaction(roomId, messageId, emoji); + }, + [roomId, client], + ); - return { sendMessage, sendTypingStart, sendTypingStop, sendReadReceipt, addReaction, removeReaction }; + return {sendMessage, sendTypingStart, sendTypingStop, sendReadReceipt, addReaction, removeReaction}; } \ No newline at end of file diff --git a/src/ws/index.ts b/src/ws/index.ts index 3391e88..709a61d 100644 --- a/src/ws/index.ts +++ b/src/ws/index.ts @@ -10,61 +10,48 @@ * - Bridge: Socket.IO server bridge (Node.js side) */ -// ── Client ────────────────────────────────────────────── -export { WsClient } from './client'; -export type { ConnectionStatus, WsClientConfig } from './client'; - -// ── Hooks ─────────────────────────────────────────────── +export {WsClient} from './client'; +export type {ConnectionStatus, WsClientConfig} from './client'; export { - initWsClient, - getWsClient, - useWsInit, - useWsStatus, - useWsConnected, - useWsError, - useWsEvent, - useRoomSubscription, - useSubscribedRooms, - useTypingIndicator, - useNotificationCount, - useRoomSender, + initWsClient, + getWsClient, + useWsInit, + useWsStatus, + useWsConnected, + useWsError, + useWsEvent, + useRoomSubscription, + useSubscribedRooms, + useTypingIndicator, + useNotificationCount, + useRoomSender, } from './hooks'; -export type { UseWsInitOptions, TypingUser } from './hooks'; - -// ── Store ─────────────────────────────────────────────── -export { useWsStore } from './store'; -export type { WsState, WsActions } from './store'; - -// ── Auth ──────────────────────────────────────────────── -export { fetchWsToken, invalidateWsToken } from './auth'; -export type { WsTokenResponse } from './auth'; - -// ── Constants ─────────────────────────────────────────── +export type {UseWsInitOptions, TypingUser} from './hooks'; +export {useWsStore} from './store'; +export type {WsState, WsActions} from './store'; +export {fetchWsToken, invalidateWsToken} from './auth'; +export type {WsTokenResponse} from './auth'; export { - HEARTBEAT_INTERVAL_MS, - HEARTBEAT_TIMEOUT_MS, - MAX_IDLE_TIMEOUT_MS, - CLIENT_PING_INTERVAL_MS, - MAX_MESSAGES_PER_SECOND, - MAX_TEXT_MESSAGE_LEN, - DEDUP_WINDOW_MS, - RECONNECT_BASE_DELAY_MS, - RECONNECT_MAX_DELAY_MS, - RECONNECT_MAX_ATTEMPTS, - DEFAULT_WS_PATH, - WS_TOKEN_ENDPOINT, - WS_TOKEN_TTL_SECONDS, - SSE_AI_STREAM_PATTERN, + HEARTBEAT_INTERVAL_MS, + HEARTBEAT_TIMEOUT_MS, + MAX_IDLE_TIMEOUT_MS, + CLIENT_PING_INTERVAL_MS, + MAX_MESSAGES_PER_SECOND, + MAX_TEXT_MESSAGE_LEN, + DEDUP_WINDOW_MS, + RECONNECT_BASE_DELAY_MS, + RECONNECT_MAX_DELAY_MS, + RECONNECT_MAX_ATTEMPTS, + DEFAULT_WS_PATH, + WS_TOKEN_ENDPOINT, + WS_TOKEN_TTL_SECONDS, + SSE_AI_STREAM_PATTERN, } from './constants'; -// ── Infrastructure ────────────────────────────────────── -export { TypedEventEmitter } from './emitter'; -export { RoomSubscriptionManager } from './subscription'; -export { ReconnectManager } from './reconnect'; -export type { ReconnectState } from './reconnect'; -export { DedupManager } from './dedup'; - -export { WS_PROTOCOL_VERSION } from './constants'; - -// ── Types ─────────────────────────────────────────────── +export {TypedEventEmitter} from './emitter'; +export {RoomSubscriptionManager} from './subscription'; +export {ReconnectManager} from './reconnect'; +export type {ReconnectState} from './reconnect'; +export {DedupManager} from './dedup'; +export {WS_PROTOCOL_VERSION} from './constants'; export type * from './types'; \ No newline at end of file diff --git a/src/ws/store.ts b/src/ws/store.ts index e4390c2..9dcd680 100644 --- a/src/ws/store.ts +++ b/src/ws/store.ts @@ -2,65 +2,65 @@ * Zustand store for WebSocket connection state — provides reactive * connection status and room subscription state to React components. */ -import { create } from 'zustand'; -import type { ConnectionStatus } from './client'; -import type { RoomId } from './types/core'; +import {create} from 'zustand'; +import type {ConnectionStatus} from './client'; +import type {RoomId} from '@/ws/types'; export interface WsState { - /** Current connection status. */ - status: ConnectionStatus; - /** Set of currently subscribed room IDs. */ - subscribedRooms: Set; - /** Last error received from the server. */ - lastError: string | null; - /** Whether the client is currently reconnecting. */ - isReconnecting: boolean; - /** Reconnection attempt count. */ - reconnectAttempt: number; + /** Current connection status. */ + status: ConnectionStatus; + /** Set of currently subscribed room IDs. */ + subscribedRooms: Set; + /** Last error received from the server. */ + lastError: string | null; + /** Whether the client is currently reconnecting. */ + isReconnecting: boolean; + /** Reconnection attempt count. */ + reconnectAttempt: number; } export interface WsActions { - setStatus: (status: ConnectionStatus) => void; - addSubscribedRoom: (roomId: RoomId) => void; - removeSubscribedRoom: (roomId: RoomId) => void; - setLastError: (error: string | null) => void; - setReconnecting: (isReconnecting: boolean, attempt?: number) => void; - reset: () => void; + setStatus: (status: ConnectionStatus) => void; + addSubscribedRoom: (roomId: RoomId) => void; + removeSubscribedRoom: (roomId: RoomId) => void; + setLastError: (error: string | null) => void; + setReconnecting: (isReconnecting: boolean, attempt?: number) => void; + reset: () => void; } const initialState: WsState = { - status: 'disconnected', - subscribedRooms: new Set(), - lastError: null, - isReconnecting: false, - reconnectAttempt: 0, + status: 'disconnected', + subscribedRooms: new Set(), + lastError: null, + isReconnecting: false, + reconnectAttempt: 0, }; export const useWsStore = create((set) => ({ - ...initialState, + ...initialState, - setStatus: (status) => set({ status }), + setStatus: (status) => set({status}), - addSubscribedRoom: (roomId) => - set((state) => { - if (state.subscribedRooms.has(roomId)) return state; // no change, skip re-render - const rooms = new Set(state.subscribedRooms); - rooms.add(roomId); - return { subscribedRooms: rooms }; - }), + addSubscribedRoom: (roomId) => + set((state) => { + if (state.subscribedRooms.has(roomId)) return state; // no change, skip re-render + const rooms = new Set(state.subscribedRooms); + rooms.add(roomId); + return {subscribedRooms: rooms}; + }), - removeSubscribedRoom: (roomId) => - set((state) => { - if (!state.subscribedRooms.has(roomId)) return state; // no change, skip re-render - const rooms = new Set(state.subscribedRooms); - rooms.delete(roomId); - return { subscribedRooms: rooms }; - }), + removeSubscribedRoom: (roomId) => + set((state) => { + if (!state.subscribedRooms.has(roomId)) return state; // no change, skip re-render + const rooms = new Set(state.subscribedRooms); + rooms.delete(roomId); + return {subscribedRooms: rooms}; + }), - setLastError: (error) => set({ lastError: error }), + setLastError: (error) => set({lastError: error}), - setReconnecting: (isReconnecting, attempt = 0) => - set({ isReconnecting, reconnectAttempt: attempt }), + setReconnecting: (isReconnecting, attempt = 0) => + set({isReconnecting, reconnectAttempt: attempt}), - reset: () => set(initialState), + reset: () => set(initialState), })); \ No newline at end of file diff --git a/src/ws/subscription.ts b/src/ws/subscription.ts index c9af862..1438588 100644 --- a/src/ws/subscription.ts +++ b/src/ws/subscription.ts @@ -2,67 +2,67 @@ * Room subscription manager — tracks active room subscriptions, * auto-subscribes on reconnect, and unsubscribes on disconnect. */ -import type { RoomId } from './types/core'; +import type {RoomId} from '@/ws/types'; export class RoomSubscriptionManager { - private subscriptions: Set = new Set(); - private onSubscribe: ((roomId: RoomId) => void) | null = null; - private onUnsubscribe: ((roomId: RoomId) => void) | null = null; + private subscriptions: Set = new Set(); + private onSubscribe: ((roomId: RoomId) => void) | null = null; + private onUnsubscribe: ((roomId: RoomId) => void) | null = null; - /** Register callbacks that fire when a room is subscribed/unsubscribed. */ - setCallbacks( - onSubscribe: (roomId: RoomId) => void, - onUnsubscribe: (roomId: RoomId) => void, - ): void { - this.onSubscribe = onSubscribe; - this.onUnsubscribe = onUnsubscribe; - } - - /** Subscribe to a room — sends WsInMessage.subscribe and tracks locally. */ - subscribe(roomId: RoomId): void { - if (this.subscriptions.has(roomId)) return; - this.subscriptions.add(roomId); - this.onSubscribe?.(roomId); - } - - /** Unsubscribe from a room — sends WsInMessage.unsubscribe and removes locally. */ - unsubscribe(roomId: RoomId): void { - if (!this.subscriptions.has(roomId)) return; - this.subscriptions.delete(roomId); - this.onUnsubscribe?.(roomId); - } - - /** Re-subscribe all rooms (used after reconnect). */ - resubscribeAll(): void { - for (const roomId of this.subscriptions) { - this.onSubscribe?.(roomId); + /** Get subscription count. */ + get count(): number { + return this.subscriptions.size; } - } - /** Unsubscribe all rooms (used on disconnect). */ - unsubscribeAll(): void { - for (const roomId of this.subscriptions) { - this.onUnsubscribe?.(roomId); + /** Register callbacks that fire when a room is subscribed/unsubscribed. */ + setCallbacks( + onSubscribe: (roomId: RoomId) => void, + onUnsubscribe: (roomId: RoomId) => void, + ): void { + this.onSubscribe = onSubscribe; + this.onUnsubscribe = onUnsubscribe; } - } - /** Check if currently subscribed to a room. */ - isSubscribed(roomId: RoomId): boolean { - return this.subscriptions.has(roomId); - } + /** Subscribe to a room — sends WsInMessage.subscribe and tracks locally. */ + subscribe(roomId: RoomId): void { + if (this.subscriptions.has(roomId)) return; + this.subscriptions.add(roomId); + this.onSubscribe?.(roomId); + } - /** Get all currently subscribed room IDs. */ - getSubscribedRooms(): RoomId[] { - return [...this.subscriptions]; - } + /** Unsubscribe from a room — sends WsInMessage.unsubscribe and removes locally. */ + unsubscribe(roomId: RoomId): void { + if (!this.subscriptions.has(roomId)) return; + this.subscriptions.delete(roomId); + this.onUnsubscribe?.(roomId); + } - /** Get subscription count. */ - get count(): number { - return this.subscriptions.size; - } + /** Re-subscribe all rooms (used after reconnect). */ + resubscribeAll(): void { + for (const roomId of this.subscriptions) { + this.onSubscribe?.(roomId); + } + } - /** Clear all subscriptions (without sending unsubscribe messages). */ - clear(): void { - this.subscriptions.clear(); - } + /** Unsubscribe all rooms (used on disconnect). */ + unsubscribeAll(): void { + for (const roomId of this.subscriptions) { + this.onUnsubscribe?.(roomId); + } + } + + /** Check if currently subscribed to a room. */ + isSubscribed(roomId: RoomId): boolean { + return this.subscriptions.has(roomId); + } + + /** Get all currently subscribed room IDs. */ + getSubscribedRooms(): RoomId[] { + return [...this.subscriptions]; + } + + /** Clear all subscriptions (without sending unsubscribe messages). */ + clear(): void { + this.subscriptions.clear(); + } } \ No newline at end of file