From c004ba26e6e2f1ad7dead3391849b9ed0504d724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 16:55:42 +0900 Subject: [PATCH 01/54] =?UTF-8?q?feat(i18n):=20=ED=99=88=EA=B3=BC=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=ED=99=94=EB=A9=B4=20=ED=95=9C=EA=B5=AD?= =?UTF-8?q?=EC=96=B4=20=EB=B2=88=EC=97=AD=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/i18n/src/locales/ko-KR.json | 182 +++++++++++++-------------- 1 file changed, 91 insertions(+), 91 deletions(-) diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index d6b07979a..8d6a98de0 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -798,31 +798,31 @@ }, "homeScene": { "onDeck": { - "title": "Your next read", + "title": "다음에 읽을 책", "emptyState": { - "heading": "No books on deck", - "message": "Your next recommended books will show up here" + "heading": "읽을 예정인 책이 없습니다", + "message": "다음으로 추천할 책이 여기에 표시됩니다" } }, "continueReading": { - "title": "Continue reading", + "title": "이어 읽기", "emptyState": { - "heading": "Nothing to show", - "message": "Any in-progress books you start will show up here" + "heading": "표시할 항목이 없습니다", + "message": "읽기 시작한 책이 여기에 표시됩니다" } }, "recentlyAddedSeries": { - "title": "Recently added series", + "title": "최근 추가된 시리즈", "emptyState": { - "heading": "No series to display", - "message": "Any series you add to your libraries will show up here" + "heading": "표시할 시리즈가 없습니다", + "message": "라이브러리에 추가한 시리즈가 여기에 표시됩니다" } }, "recentlyAddedBooks": { - "title": "Recently added books", + "title": "최근 추가된 책", "emptyState": { - "heading": "Nothing to show", - "message": "Any recent books you add will show up here" + "heading": "표시할 항목이 없습니다", + "message": "최근 추가한 책이 여기에 표시됩니다" } } }, @@ -1382,82 +1382,82 @@ }, "settingsScene": { "navigation": { - "general": "General", - "logs": "Logs", - "server": "Server", - "jobs": "Jobs and Configuration", - "users": "User Management", - "desktop": "Desktop" + "general": "일반", + "logs": "로그", + "server": "서버", + "jobs": "작업 및 구성", + "users": "사용자 관리", + "desktop": "데스크톱" }, "sidebar": { "personal": { - "account": "Account", - "appearance": "Preferences", - "api keys": "API keys", - "reader": "Reader", - "desktop": "Desktop", - "label": "Personal" + "account": "계정", + "appearance": "환경설정", + "api keys": "API 키", + "reader": "리더", + "desktop": "데스크톱", + "label": "개인" }, "management": { - "general": "General", - "logs": "Logs", - "users": "Users", - "jobs": "Jobs", - "access": "Access", - "email": "Email", - "metadata": "Metadata", - "notifications": "Notifications", - "tags": "Tags", - "label": "Management" + "general": "일반", + "logs": "로그", + "users": "사용자", + "jobs": "작업", + "access": "접근 제어", + "email": "이메일", + "metadata": "메타데이터", + "notifications": "알림", + "tags": "태그", + "label": "관리" } }, "app/account": { - "helmet": "Account settings", - "title": "Account settings", - "description": "Settings related to your account", + "helmet": "계정 설정", + "title": "계정 설정", + "description": "계정 관련 설정", "sections": { "account": { "validation": { - "invalidUrl": "Please enter a valid URL", - "missingUsername": "Username is required" + "invalidUrl": "올바른 URL을 입력하세요", + "missingUsername": "사용자 이름을 입력하세요" }, "labels": { - "username": "Username", - "password": "Password", - "activeChangesPrompt": "You have unsaved changes", + "username": "사용자 이름", + "password": "비밀번호", + "activeChangesPrompt": "저장하지 않은 변경사항이 있습니다", "uselessForm": { - "title": "You lack permissions to change these settings", - "description": "Contact your administrator if you want to make changes to these settings" + "title": "이 설정을 변경할 권한이 없습니다", + "description": "설정을 변경하려면 관리자에게 문의하세요" }, - "insufficientPermission": "You do not have permission to change this" + "insufficientPermission": "이 항목을 변경할 권한이 없습니다" }, "errors": { - "updateFailed": "A server error occurred while updating your profile. Please try again." + "updateFailed": "프로필을 업데이트하는 중 서버 오류가 발생했습니다. 다시 시도하세요." }, "avatarPicker": { - "label": "Avatar", - "heading": "Upload avatar", - "subtitle": "You can upload any image to use as your avatar for social features in Stump", - "dropzone": "Drop an image or click to select", - "changeImage": "Change Image", - "removeImage": "Remove Image", - "removeSelection": "Remove uploaded image", - "uploadFailed": "Failed to upload avatar", - "removeFailed": "Failed to remove avatar" + "label": "아바타", + "heading": "아바타 업로드", + "subtitle": "Stump의 소셜 기능에서 사용할 이미지를 아바타로 업로드할 수 있습니다", + "dropzone": "이미지를 끌어다 놓거나 클릭하여 선택하세요", + "changeImage": "이미지 변경", + "removeImage": "이미지 제거", + "removeSelection": "업로드한 이미지 제거", + "uploadFailed": "아바타를 업로드하지 못했습니다", + "removeFailed": "아바타를 제거하지 못했습니다" }, "buttons": { - "confirm": "Save Changes" + "confirm": "변경사항 저장" } }, "locale": { "localeSelector": { - "label": "Language" + "label": "언어" }, - "heading": "Language and locale", + "heading": "언어 및 지역", "subtitle": [ - "Stump supports multiple languages and locales, set your preferences below. Please consider helping improve the quality of", - "Stump's translations", - "if you're able to!" + "Stump는 여러 언어와 지역을 지원합니다. 아래에서 환경에 맞게 설정할 수 있습니다. 가능하다면", + "Stump 번역 개선에", + "참여해 주세요!" ] } } @@ -2534,15 +2534,15 @@ "diskUsage": "Disk Usage" }, "noLibraries": { - "heading": "Nothing to see here", - "messagePrefix": "You can't see what you don't have", + "heading": "표시할 내용이 없습니다", + "messagePrefix": "없는 항목은 볼 수 없습니다", "message": { - "create": "Create a library to get started", - "wait": "Once libraries are created, they'll be displayed here" + "create": "먼저 라이브러리를 만들어 보세요", + "wait": "라이브러리가 생성되면 여기에 표시됩니다" }, "links": { - "create": "Create library", - "settings": "Go to settings" + "create": "라이브러리 만들기", + "settings": "설정으로 이동" } }, "pagination": { @@ -2568,34 +2568,34 @@ }, "sidebar": { "buttons": { - "home": "Home", - "libraries": "Libraries", - "books": "Explore", - "bookClubs": "Book clubs", - "createLibrary": "Create library", - "noLibraries": "No libraries", - "createBookClub": "Create book club", - "noBookClubs": "No book clubs", - "settings": "Settings", - "themeToggle": "Toggle Theme", - "goForward": "Go Forward", - "goBack": "Go Back", - "smartlists": "Smart lists", - "noSmartlists": "No smart lists", - "createSmartlist": "Create smart list", - "seeAll": "See all" + "home": "홈", + "libraries": "라이브러리", + "books": "탐색", + "bookClubs": "북클럽", + "createLibrary": "라이브러리 만들기", + "noLibraries": "라이브러리 없음", + "createBookClub": "북클럽 만들기", + "noBookClubs": "북클럽 없음", + "settings": "설정", + "themeToggle": "테마 전환", + "goForward": "앞으로", + "goBack": "뒤로", + "smartlists": "스마트 목록", + "noSmartlists": "스마트 목록 없음", + "createSmartlist": "스마트 목록 만들기", + "seeAll": "모두 보기" }, "libraryOptions": { - "scanLibrary": "Scan", - "fileExplorer": "Files", - "manageLibrary": "Manage", - "deleteLibrary": "Delete" + "scanLibrary": "스캔", + "fileExplorer": "파일", + "manageLibrary": "관리", + "deleteLibrary": "삭제" }, "versionInformation": { - "heading": "Version Information", - "semVer": "Semantic Version", - "commitHash": "Commit Hash", - "buildDate": "Build Date" + "heading": "버전 정보", + "semVer": "시맨틱 버전", + "commitHash": "커밋 해시", + "buildDate": "빌드 날짜" } }, "search": { From f78ea6327bd40fc255bfef7b11eb354b3c1f9f97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:00:58 +0900 Subject: [PATCH 02/54] =?UTF-8?q?feat(i18n):=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=99=80=20=EA=B6=8C=ED=95=9C=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EB=B2=88=EC=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/i18n/src/locales/ko-KR.json | 424 +++++++++++++-------------- 1 file changed, 212 insertions(+), 212 deletions(-) diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 8d6a98de0..3a893033f 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -1,38 +1,38 @@ { "underConstruction": { - "heading": "This is awkward", - "message": "You've stumbled upon a page that's still being developed. I'd love to show it to you, but it isn't quite ready yet.", - "homeLink": "Go Home", - "githubLink": "View the GitHub issue" + "heading": "아직 준비 중입니다", + "message": "이 페이지는 현재 개발 중입니다. 조금만 기다려 주세요.", + "homeLink": "홈으로 이동", + "githubLink": "GitHub 이슈 보기" }, "authScene": { - "claimHeading": "Initialize your server", - "claimText": "This Stump server is not initialized. Use the form below to create your account. Once created, you will have full access to all server features.", + "claimHeading": "서버 초기화", + "claimText": "이 Stump 서버는 아직 초기화되지 않았습니다. 아래 양식에서 계정을 만들면 서버의 모든 기능을 사용할 수 있습니다.", "form": { "validation": { - "missingUsername": "Username is required", - "missingPassword": "Password is required", - "usernameAlreadyExists": "Username already exists" + "missingUsername": "사용자 이름을 입력하세요", + "missingPassword": "비밀번호를 입력하세요", + "usernameAlreadyExists": "이미 사용 중인 사용자 이름입니다" }, "labels": { - "username": "Username", - "password": "암호" + "username": "사용자 이름", + "password": "비밀번호" }, "buttons": { "createAccount": "계정 생성", "login": "로그인", - "loginWithOidc": "Log in with OIDC", - "createAccountWithOidc": "Create Account with OIDC" + "loginWithOidc": "OIDC로 로그인", + "createAccountWithOidc": "OIDC로 계정 만들기" } }, "toasts": { "loggingIn": "로그인 중...", "loggedIn": "다시 오신 것을 환영합니다!", - "loggedInFirstTime": "Welcome!", - "registering": "Registering...", - "registered": "Registered!", - "loginFailed": "Login failed. Please try again", - "registrationFailed": "Registration failed. Please try again" + "loggedInFirstTime": "환영합니다!", + "registering": "계정을 만드는 중...", + "registered": "계정을 만들었습니다!", + "loginFailed": "로그인하지 못했습니다. 다시 시도하세요", + "registrationFailed": "계정을 만들지 못했습니다. 다시 시도하세요" } }, "adminLibrarySidebar": { @@ -552,24 +552,24 @@ "fileExplorer": { "uploadModal": { "title": { - "books": "Upload books", - "series": "Upload series" + "books": "책 업로드", + "series": "시리즈 업로드" }, "description": { - "books": "Add books directly to the current path in the file explorer", - "series": "Add a series directly to the current path in the file explorer" + "books": "파일 탐색기의 현재 경로에 책을 바로 추가합니다", + "series": "파일 탐색기의 현재 경로에 시리즈를 바로 추가합니다" }, "seriesDisclaimer": [ - "It must be a ", - "zipped file", - "containing the folder which should be the series, with all the books inside" + "모든 책이 들어 있는 시리즈 폴더를", + "ZIP 파일로 압축해", + "업로드해야 합니다" ], "dropzone": { - "books": "Drag and drop books here", - "series": "Drag and drop a zip file here", - "alt": "Or click to browse your computer for books to upload" + "books": "책을 여기에 끌어다 놓으세요", + "series": "ZIP 파일을 여기에 끌어다 놓으세요", + "alt": "또는 클릭하여 업로드할 책을 선택하세요" }, - "addedFiles": "Added files" + "addedFiles": "추가된 파일" } }, "libraryHeader": { @@ -788,12 +788,12 @@ } }, "errorScene": { - "heading": "lol, oops", - "message": "An error occurred:", + "heading": "문제가 발생했습니다", + "message": "다음 오류가 발생했습니다:", "buttons": { - "report": "Report Bug", - "copy": "Copy Error Details", - "goHome": "Go Home" + "report": "버그 신고", + "copy": "오류 정보 복사", + "goHome": "홈으로 이동" } }, "homeScene": { @@ -1196,33 +1196,33 @@ }, "createOrUpdateApiKey": { "validation": { - "futureDate": "Expiration date must be in the future" + "futureDate": "만료일은 현재보다 이후여야 합니다" }, "fields": { "name": { - "label": "Name", - "placeholder": "Koreader Sync" + "label": "이름", + "placeholder": "KoReader 동기화" }, "expiration": { - "label": "Expiration", - "placeholder": "Never" + "label": "만료 기간", + "placeholder": "만료되지 않음" }, "expiresAt": { - "label": "Expires at", - "placeholder": "Never" + "label": "만료일", + "placeholder": "만료되지 않음" }, "permissions": { "inherit": { - "label": "Inherit permissions", - "description": "The same permissions as your account" + "label": "권한 상속", + "description": "내 계정과 같은 권한을 사용합니다" }, "explicit": { - "label": "Explicit permissions", - "description": "Choose the permissions this key should have" + "label": "권한 직접 지정", + "description": "이 키에 부여할 권한을 선택합니다" } } }, - "inheritDisclaimer": "This API key will inherit your full permissions, which means it can do anything you can do. Be careful with this setting" + "inheritDisclaimer": "이 API 키는 내 모든 권한을 상속하므로 내 계정으로 할 수 있는 모든 작업을 수행할 수 있습니다. 신중하게 설정하세요" }, "userSmartListsScene": { "heading": "Smart lists", @@ -2526,7 +2526,7 @@ } }, "jobOverlay": { - "backupHeading": "Job in Progress" + "backupHeading": "작업 진행 중" }, "libraryStats": { "seriesCount": "Total Series", @@ -2547,23 +2547,23 @@ }, "pagination": { "buttons": { - "next": "Next", - "previous": "Previous" + "next": "다음", + "previous": "이전" }, "popover": { - "heading": "Go to page", + "heading": "페이지로 이동", "buttons": { - "confirm": "Go", - "cancel": "Cancel" + "confirm": "이동", + "cancel": "취소" } } }, "signOutModal": { - "title": "Sign out", - "message": "Are you sure you want to sign out?", + "title": "로그아웃", + "message": "로그아웃하시겠습니까?", "buttons": { - "cancel": "Cancel", - "signOut": "Sign out" + "cancel": "취소", + "signOut": "로그아웃" } }, "sidebar": { @@ -2599,274 +2599,274 @@ } }, "search": { - "placeholder": "Search" + "placeholder": "검색" }, "serverSOS": { - "heading": "Server Unavailable", + "heading": "서버에 연결할 수 없습니다", "desktop": { - "message": "A network error occurred that suggests your Stump server is currently unavailable. Please ensure that it is running and accessible from this device.\nIf your server URL has changed, you can update it using the form below" + "message": "네트워크 오류로 Stump 서버에 연결할 수 없습니다. 서버가 실행 중이며 이 기기에서 접근할 수 있는지 확인하세요.\n서버 URL이 변경되었다면 아래 양식에서 수정할 수 있습니다" }, "web": { - "message": "A network error occurred that suggests your Stump server is currently unavailable. Please ensure that it is running and accessible from this device" + "message": "네트워크 오류로 Stump 서버에 연결할 수 없습니다. 서버가 실행 중이며 이 기기에서 접근할 수 있는지 확인하세요" }, - "reconnected": "Reconnected to server! Redirecting...", - "reconnectionFailed": "Something went wrong!" + "reconnected": "서버에 다시 연결했습니다. 이동 중...", + "reconnectionFailed": "서버에 다시 연결하지 못했습니다" }, "serverStatusOverlay": { - "heading": "Server isn't connected", + "heading": "서버가 연결되지 않았습니다", "message": [ - "Please check your internet connection", - "Click here", - "to change your server URL" + "인터넷 연결을 확인하세요", + "여기를 눌러", + "서버 URL을 변경할 수 있습니다" ] }, "slidingList": { - "empty": "No items to display", + "empty": "표시할 항목이 없습니다", "buttons": { - "next": "Seek Ahead", - "previous": "Seek Backwards" + "next": "앞으로 이동", + "previous": "뒤로 이동" } }, "tagSelect": { - "placeholder": "Select or create tag", - "placeholderNoTags": "No tags available" + "placeholder": "태그 선택 또는 만들기", + "placeholderNoTags": "사용 가능한 태그가 없습니다" }, "thumbnailDropdown": { - "label": "Edit thumbnail", + "label": "썸네일 편집", "options": { - "selectFromBooks": "Select from books", - "uploadImage": "Upload image" + "selectFromBooks": "책에서 선택", + "uploadImage": "이미지 업로드" }, "uploadImage": { - "emptyState": "A preview of your image will appear here", - "prompt": "Drop image here or click to select", - "remove": "Remove image" + "emptyState": "이미지 미리보기가 여기에 표시됩니다", + "prompt": "이미지를 끌어다 놓거나 클릭하여 선택하세요", + "remove": "이미지 제거" } }, "common": { - "add": "Add", - "andXMoreTrailing": "...and {{count}} more", - "cancel": "Cancel", - "clear": "Clear", - "confirm": "Confirm", - "created": "Created", - "delete": "Delete", + "add": "추가", + "andXMoreTrailing": "...외 {{count}}개", + "cancel": "취소", + "clear": "지우기", + "confirm": "확인", + "created": "생성됨", + "delete": "삭제", "deleteConfirmation": { - "title": "Delete {{type}}", - "description": "Are you sure you want to delete this {{type}}? This action cannot be undone.", - "confirm": "Delete {{type}}", - "typeToConfirm": "Type {{name}} to confirm:" + "title": "{{type}} 삭제", + "description": "이 {{type}}을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "confirm": "{{type}} 삭제", + "typeToConfirm": "확인하려면 {{name}}을(를) 입력하세요:" }, "experimentalDisclaimer": { - "title": "This feature is experimental", - "description": "It might be missing features, have bugs, or not work at all. Use at your own discretion and report any issues you encounter" - }, - "enter": "Enter", - "save": "Save", - "send": "Send", - "saveChanges": "Save changes", - "noChanges": "No changes", - "create": "Create", - "edit": "Edit", - "info": "Info", + "title": "실험 기능입니다", + "description": "일부 기능이 없거나 오류가 발생하고 제대로 작동하지 않을 수 있습니다. 신중하게 사용하고 문제가 발생하면 신고해 주세요" + }, + "enter": "입력", + "save": "저장", + "send": "보내기", + "saveChanges": "변경사항 저장", + "noChanges": "변경사항 없음", + "create": "만들기", + "edit": "편집", + "info": "정보", "infoSheetStats": { - "series": "Series", - "inProgress": "In Progress", - "completedBooks": "Completed", - "readingTime": "Reading Time", - "size": "Size" - }, - "update": "Update", - "upload": "Upload", - "uploading": "Uploading", - "uploadingEllipsis": "Uploading...", - "unimplemented": "This functionality is not yet implemented! Check back later", - "upNext": "Up next", - "unknown": "Unknown", - "lastUpdated": "Last updated", - "limitedFunctionality": "This is not yet fully implemented and is lacking some features. Check back later", - "logIn": "Log in", - "login": "Login", - "seeError": "See error", - "remove": "Remove", - "goBack": "Go back", - "goToServers": "Go to servers", - "notReady": "This feature is not ready", - "notUsedYet": "Not used yet", - "never": "Never", - "scan": "Scan", - "somethingWentWrong": "Something went wrong", - "back": "Back", - "continue": "Continue", - "book": "Book", - "series": "Series", + "series": "시리즈", + "inProgress": "읽는 중", + "completedBooks": "완독", + "readingTime": "읽은 시간", + "size": "크기" + }, + "update": "업데이트", + "upload": "업로드", + "uploading": "업로드 중", + "uploadingEllipsis": "업로드 중...", + "unimplemented": "아직 구현되지 않은 기능입니다. 나중에 다시 확인해 주세요", + "upNext": "다음 항목", + "unknown": "알 수 없음", + "lastUpdated": "마지막 업데이트", + "limitedFunctionality": "아직 완전히 구현되지 않아 일부 기능이 제공되지 않습니다. 나중에 다시 확인해 주세요", + "logIn": "로그인", + "login": "로그인", + "seeError": "오류 보기", + "remove": "제거", + "goBack": "뒤로 이동", + "goToServers": "서버 목록으로 이동", + "notReady": "아직 준비되지 않은 기능입니다", + "notUsedYet": "아직 사용되지 않음", + "never": "없음", + "scan": "스캔", + "somethingWentWrong": "문제가 발생했습니다", + "back": "뒤로", + "continue": "계속", + "book": "책", + "series": "시리즈", "fileUpload": { - "someRejected": "Some files were rejected. Please check the file types and try again", - "tooManyFiles": "You have selected too many files", - "noValidFiles": "No valid files were selected" + "someRejected": "일부 파일을 추가하지 못했습니다. 파일 형식을 확인하고 다시 시도하세요", + "tooManyFiles": "선택한 파일이 너무 많습니다", + "noValidFiles": "올바른 파일이 선택되지 않았습니다" }, "unknownError": "알 수 없는 오류가 발생했습니다" }, "userPermissions": { "ACCESS_API_KEYS": { - "label": "API keys", - "description": "Allows the user to manage their own API keys" + "label": "API 키 접근", + "description": "자신의 API 키를 관리할 수 있습니다" }, "ACCESS_BOOK_CLUB": { - "label": "Book Clubs", - "description": "Allows the user to access book club features, including viewing and joining book clubs" + "label": "북클럽 접근", + "description": "북클럽을 보고 가입하는 등 북클럽 기능을 사용할 수 있습니다" }, "ACCESS_KOREADER_SYNC": { - "label": "KoReader sync", - "description": "Allows the user to access the KoReader sync integration" + "label": "KoReader 동기화 접근", + "description": "KoReader 동기화 연동을 사용할 수 있습니다" }, "ACCESS_KOBO_SYNC": { - "label": "Kobo sync", - "description": "Allows the user to access the Kobo sync integration" + "label": "Kobo 동기화 접근", + "description": "Kobo 동기화 연동을 사용할 수 있습니다" }, "ACCESS_SMART_LIST": { - "label": "Smart Lists", - "description": "Allows the user to access smart list features, including creating and managing smart lists" + "label": "스마트 목록 접근", + "description": "스마트 목록을 만들고 관리하는 등 스마트 목록 기능을 사용할 수 있습니다" }, "CHANGE_PASSWORD": { - "label": "Change password", - "description": "Allows the user to change their own password" + "label": "비밀번호 변경", + "description": "자신의 비밀번호를 변경할 수 있습니다" }, "CHANGE_USERNAME": { - "label": "Change username", - "description": "Allows the user to change their own username" + "label": "사용자 이름 변경", + "description": "자신의 사용자 이름을 변경할 수 있습니다" }, "CHANGE_AVATAR": { - "label": "Change avatar", - "description": "Allows the user to change their own avatar" + "label": "아바타 변경", + "description": "자신의 아바타를 변경할 수 있습니다" }, "CREATE_BOOK_CLUB": { - "label": "Create book clubs", - "description": "Allows the user to create new book clubs" + "label": "북클럽 만들기", + "description": "새 북클럽을 만들 수 있습니다" }, "CREATE_LIBRARY": { - "label": "Create libraries", - "description": "Allows the user to create new libraries" + "label": "라이브러리 만들기", + "description": "새 라이브러리를 만들 수 있습니다" }, "CREATE_NOTIFIER": { - "label": "Create notifiers", - "description": "Allows the user to create new notification systems" + "label": "알림 서비스 만들기", + "description": "새 알림 서비스를 만들 수 있습니다" }, "DELETE_LIBRARY": { - "label": "Delete libraries", - "description": "Allows the user to delete existing libraries" + "label": "라이브러리 삭제", + "description": "기존 라이브러리를 삭제할 수 있습니다" }, "DELETE_NOTIFIER": { - "label": "Delete notifiers", - "description": "Allows the user to delete notification systems" + "label": "알림 서비스 삭제", + "description": "알림 서비스를 삭제할 수 있습니다" }, "DOWNLOAD_FILE": { - "label": "Download files", - "description": "Allows the user to download files from the server" + "label": "파일 다운로드", + "description": "서버에서 파일을 다운로드할 수 있습니다" }, "EDIT_LIBRARY": { - "label": "Edit libraries", - "description": "Allows the user to modify existing libraries" + "label": "라이브러리 편집", + "description": "기존 라이브러리를 수정할 수 있습니다" }, "EMAILER_CREATE": { - "label": "Create emailer", - "description": "Allows the user to create email configuration" + "label": "이메일 전송 설정 만들기", + "description": "이메일 전송 설정을 만들 수 있습니다" }, "EMAILER_MANAGE": { - "label": "Manage emailer", - "description": "Allows the user to manage email configuration and settings" + "label": "이메일 전송 설정 관리", + "description": "이메일 전송 구성과 설정을 관리할 수 있습니다" }, "EMAILER_READ": { - "label": "Read emailer", - "description": "Allows the user to view email configuration" + "label": "이메일 전송 설정 보기", + "description": "이메일 전송 구성을 볼 수 있습니다" }, "EMAIL_ARBITRARY_SEND": { - "label": "Send arbitrary emails", - "description": "Allows the user to send custom emails to any address" + "label": "임의 이메일 전송", + "description": "원하는 주소로 직접 작성한 이메일을 보낼 수 있습니다" }, "EMAIL_SEND": { - "label": "Send emails", - "description": "Allows the user to send emails through the system" + "label": "이메일 전송", + "description": "시스템을 통해 이메일을 보낼 수 있습니다" }, "FILE_EXPLORER": { - "label": "File explorer", - "description": "Allows the user to browse and explore the server filesystem" + "label": "파일 탐색기", + "description": "서버 파일 시스템을 탐색할 수 있습니다" }, "MANAGE_LIBRARY": { - "label": "Manage libraries", - "description": "Allows the user to manage library settings and configuration" + "label": "라이브러리 관리", + "description": "라이브러리 설정과 구성을 관리할 수 있습니다" }, "MANAGE_NOTIFIER": { - "label": "Manage notifiers", - "description": "Allows the user to manage notification system settings" + "label": "알림 서비스 관리", + "description": "알림 서비스 설정을 관리할 수 있습니다" }, "MANAGE_SERVER": { - "label": "Manage server", - "description": "Allows the user to manage server configuration and settings" + "label": "서버 관리", + "description": "서버 구성과 설정을 관리할 수 있습니다" }, "MANAGE_USERS": { - "label": "Manage users", - "description": "Allows the user to create, edit, and delete user accounts" + "label": "사용자 관리", + "description": "사용자 계정을 만들고 편집하거나 삭제할 수 있습니다" }, "READ_JOBS": { - "label": "Read jobs", - "description": "Allows the user to view system jobs and their status" + "label": "작업 보기", + "description": "시스템 작업과 상태를 볼 수 있습니다" }, "READ_NOTIFIER": { - "label": "Read notifiers", - "description": "Allows the user to view notification system configuration" + "label": "알림 서비스 보기", + "description": "알림 서비스 구성을 볼 수 있습니다" }, "READ_PERSISTED_LOGS": { - "label": "Read persisted logs", - "description": "Allows the user to view stored system logs" + "label": "저장된 로그 보기", + "description": "저장된 시스템 로그를 볼 수 있습니다" }, "READ_SYSTEM_LOGS": { - "label": "Read system logs", - "description": "Allows the user to view real-time system logs" + "label": "시스템 로그 보기", + "description": "실시간 시스템 로그를 볼 수 있습니다" }, "READ_USERS": { - "label": "Read users", - "description": "Allows the user to view user account information" + "label": "사용자 보기", + "description": "사용자 계정 정보를 볼 수 있습니다" }, "SCAN_LIBRARY": { - "label": "Scan libraries", - "description": "Allows the user to trigger library scans to discover new content" + "label": "라이브러리 스캔", + "description": "새 콘텐츠를 찾는 라이브러리 스캔을 실행할 수 있습니다" }, "UPLOAD_FILE": { - "label": "Upload files", - "description": "Allows the user to upload files to the server" + "label": "파일 업로드", + "description": "서버에 파일을 업로드할 수 있습니다" }, "MANAGE_JOBS": { - "label": "Manage jobs", - "description": "Allows the user to manage any queued jobs" + "label": "작업 관리", + "description": "대기 중인 작업을 관리할 수 있습니다" }, "EDIT_METADATA": { - "label": "Edit metadata", - "description": "Allows the user to edit metadata for books and series" + "label": "메타데이터 편집", + "description": "책과 시리즈의 메타데이터를 편집할 수 있습니다" }, "WRITE_BACK_METADATA": { - "label": "Write back metadata", - "description": "Allows the user to write metadata changes back to the source files" + "label": "메타데이터 원본 반영", + "description": "메타데이터 변경사항을 원본 파일에 다시 기록할 수 있습니다" }, "EDIT_THUMBNAILS": { - "label": "Edit thumbnails", - "description": "Allows the user to edit thumbnails for books and series" + "label": "썸네일 편집", + "description": "책과 시리즈의 썸네일을 편집할 수 있습니다" }, "METADATA_PROVIDER_READ": { - "label": "Read metadata providers", - "description": "Allows the user to view configured external metadata providers" + "label": "메타데이터 공급자 보기", + "description": "구성된 외부 메타데이터 공급자를 볼 수 있습니다" }, "METADATA_PROVIDER_MANAGE": { - "label": "Manage metadata providers", - "description": "Allows the user to configure external metadata providers" + "label": "메타데이터 공급자 관리", + "description": "외부 메타데이터 공급자를 구성할 수 있습니다" }, "METADATA_FETCH_RECORD_READ": { - "label": "Read metadata matches", - "description": "Allows the user to view metadata fetch results awaiting review" + "label": "메타데이터 일치 결과 보기", + "description": "검토 대기 중인 메타데이터 조회 결과를 볼 수 있습니다" }, "METADATA_FETCH_RECORD_MANAGE": { - "label": "Manage metadata matches", - "description": "Allows the user to search external providers for a book and accept or reject candidate matches" + "label": "메타데이터 일치 결과 관리", + "description": "외부 공급자에서 책을 검색하고 일치 후보를 수락하거나 거절할 수 있습니다" } }, "mobileApp": { From b03712762ea0596f8b1e1a98ac80e2e7812eec1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:06:08 +0900 Subject: [PATCH 03/54] =?UTF-8?q?feat(i18n):=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=20=EC=83=9D=EC=84=B1=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/i18n/src/locales/ko-KR.json | 316 ++++++++++++++------------- 1 file changed, 165 insertions(+), 151 deletions(-) diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 3a893033f..eac11e90f 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -37,9 +37,9 @@ }, "adminLibrarySidebar": { "libraryConfiguration": { - "heading": "Library Configuration", - "subtitleCreating": "A preview of your new library will be shown below", - "subtitleEditing": "A preview of your library edits will be shown below" + "heading": "라이브러리 구성", + "subtitleCreating": "새 라이브러리의 미리보기가 아래에 표시됩니다", + "subtitleEditing": "라이브러리 변경 사항의 미리보기가 아래에 표시됩니다" } }, "bookManagementScene": { @@ -265,287 +265,301 @@ "createOrUpdateLibraryForm": { "fields": { "name": { - "label": "Name", - "description": "A friendly name for your library", - "placeholder": "My Library" + "label": "이름", + "description": "라이브러리를 구분할 이름", + "placeholder": "내 라이브러리" }, "path": { - "label": "Path", - "description": "The path to the library as it exists on your server", + "label": "경로", + "description": "서버에 있는 라이브러리의 경로", "placeholder": "/path/to/library" }, "description": { - "label": "Description", - "description": "A short description of your library", - "placeholder": "A library of all my favorite books" + "label": "설명", + "description": "라이브러리에 대한 짧은 설명", + "placeholder": "좋아하는 책을 모아 둔 라이브러리" }, "tags": { - "label": "Tags", - "description": "Used to categorize your library or enforce access rules" + "label": "태그", + "description": "라이브러리를 분류하거나 접근 규칙을 적용할 때 사용합니다" + }, + "libraryType": { + "label": "라이브러리 콘텐츠", + "description": "이 라이브러리에 포함된 콘텐츠 유형", + "options": { + "COMIC": "코믹스", + "MANGA": "망가", + "BOOK": "도서", + "LIGHT_NOVEL": "라이트 노벨", + "MANHWA": "한국 만화", + "WEB_NOVEL": "웹소설", + "WEBTOON": "웹툰", + "MIXED": "혼합" + } }, "defaultLibraryView": { - "label": "Default library view", - "description": "Configure which tab is shown by default when opening this library", + "label": "기본 라이브러리 보기", + "description": "라이브러리를 열 때 기본으로 표시할 탭을 설정합니다", "hideSeriesView": { - "label": "Hide series tab", - "description": "Hide the series tab from the library navigation" + "label": "시리즈 탭 숨기기", + "description": "라이브러리 탐색 메뉴에서 시리즈 탭을 숨깁니다" }, "skipBookOverview": { - "label": "Skip book overview", - "description": "Open the reader directly when selecting books in this library" + "label": "책 개요 건너뛰기", + "description": "이 라이브러리에서 책을 선택하면 바로 리더를 엽니다" }, "options": { "series": { - "label": "Series", - "description": "Show the series tab by default" + "label": "시리즈", + "description": "시리즈 탭을 기본으로 표시합니다" }, "books": { - "label": "Books", - "description": "Show the books tab by default" + "label": "책", + "description": "책 탭을 기본으로 표시합니다" } } }, "readingSettings": { "section": { - "heading": "Reading settings", - "description": "Default reader settings to use for books" + "heading": "읽기 설정", + "description": "책에 적용할 기본 리더 설정" }, "imageScaling": { - "label": "Image scaling", - "description": "How the image-based reader scales images to the page" + "label": "이미지 크기 조정", + "description": "이미지 기반 리더에서 페이지에 맞게 이미지 크기를 조정하는 방식" }, "readingMode": { - "label": "Default reader", - "description": "The default reader to use for image-based books" + "label": "기본 리더", + "description": "이미지 기반 책에 사용할 기본 리더" }, "readingDirection": { - "label": "Reading direction", - "description": "Which direction the reader progresses through pages" + "label": "읽기 방향", + "description": "리더에서 페이지를 넘기는 방향" } }, "thumbnailConfig": { "section": { "heading": { - "create": "Thumbnail configuration", - "update": "Generation" + "create": "썸네일 구성", + "update": "썸네일 생성" }, - "description": "Improve loading times while browsing your library by generating thumbnails for your books", - "disabled": "No additional configuration is required when thumbnail generation is disabled" + "description": "책의 썸네일을 생성하여 라이브러리를 탐색할 때 로딩 시간을 줄입니다", + "disabled": "썸네일 생성을 사용하지 않으면 추가 설정이 필요하지 않습니다" }, "enableSwitch": { - "label": "Enabled", - "description": "Generate thumbnail images for this library" + "label": "사용", + "description": "이 라이브러리의 썸네일 이미지를 생성합니다" }, "resizeMethod": { - "label": "Resize Method", + "label": "크기 조정 방식", "options": { - "scaleEvenlyByFactor": "Evenly Scale", - "exact": "Exact Size", - "scaleDimension": "Auto Scaled", - "none": "None" + "scaleEvenlyByFactor": "비율로 균등 조정", + "exact": "정확한 크기", + "scaleDimension": "한쪽 기준 자동 조정", + "none": "조정 안 함" } }, "scaleEvenlyByFactor": { - "description": "Scale the thumbnail by a factor of the original size. E.g., 0.65 will result in a thumbnail that is 65% of the original size", + "description": "원본 크기에 비례해 썸네일 크기를 조정합니다. 예를 들어 0.65로 설정하면 원본 크기의 65%인 썸네일이 생성됩니다", "factor": { - "label": "Scale Factor" + "label": "크기 비율" } }, "exact": { - "description": "Resize the thumbnail to an exact size in pixels", + "description": "썸네일을 지정한 픽셀 크기로 조정합니다", "width": { - "label": "Width" + "label": "너비" }, "height": { - "label": "Height" + "label": "높이" } }, "scaleDimension": { - "label": "Auto Scaled", - "description": "Set either the width or height of the thumbnail and auto-scale the other to keep the original aspect ratio", + "label": "한쪽 기준 자동 조정", + "description": "썸네일의 너비 또는 높이를 지정하고, 원본 비율을 유지하도록 다른 쪽을 자동 조정합니다", "dimension": { - "label": "Dimension", + "label": "기준 치수", "options": { - "width": "Width", - "height": "Height" + "width": "너비", + "height": "높이" } }, "size": { - "label": "Size" + "label": "크기" } }, "format": { - "label": "Image format", - "description": "The format of the generated thumbnail images" + "label": "이미지 형식", + "description": "생성할 썸네일 이미지의 형식" }, "quality": { - "label": "Quality", - "description": "The quality of the generated thumbnail images, between 1 and 100" + "label": "품질", + "description": "생성할 썸네일 이미지의 품질(1~100)" } }, "ignoreRules": { "section": { - "heading": "Ignore rules", - "description": "Define glob patterns to ignore certain files or directories during a scan" + "heading": "무시 규칙", + "description": "스캔할 때 특정 파일이나 디렉터리를 제외할 글롭 패턴을 설정합니다" }, - "noRules": "No ignore rules have been configured", - "save": "Save changes", + "noRules": "설정된 무시 규칙이 없습니다", + "save": "변경 사항 저장", "addRule": { - "label": "New rule", - "description": "Glob pattern to ignore files or directories", - "addButton": "Add rule", + "label": "새 규칙", + "description": "파일이나 디렉터리를 제외할 글롭 패턴", + "addButton": "규칙 추가", "ignoreParents": { - "title": "Ignore all parent directories", - "label": "Ignore parents" + "title": "모든 상위 디렉터리를 제외합니다", + "label": "상위 디렉터리 제외" }, "ignoreSubdirs": { - "title": "Ignore all subdirectories", - "label": "Ignore subdirectories" + "title": "모든 하위 디렉터리를 제외합니다", + "label": "하위 디렉터리 제외" } }, - "invalidGlob": "Invalid glob pattern", - "duplicateGlob": "Duplicate of an existing rule", + "invalidGlob": "유효하지 않은 글롭 패턴입니다", + "duplicateGlob": "기존 규칙과 중복됩니다", "lockConfig": { - "lock": "Lock configuration", - "unlock": "Unlock configuration" + "lock": "구성 잠그기", + "unlock": "구성 잠금 해제" }, - "editRule": "Edit rule", - "deleteRule": "Delete rule", - "cancelEdit": "Cancel edit", - "confirmEdit": "Confirm edit" + "editRule": "규칙 편집", + "deleteRule": "규칙 삭제", + "cancelEdit": "편집 취소", + "confirmEdit": "편집 확인" }, "convertOptions": { "section": { - "heading": "File conversion", - "description": "Stump is capable of converting certain file formats to more compatible ones" + "heading": "파일 변환", + "description": "Stump는 일부 파일 형식을 호환성이 더 높은 형식으로 변환할 수 있습니다" }, "rarToZip": { - "label": "Convert RAR to ZIP", - "description": "Convert RAR/CBR files to ZIP/CBZ files during a scan" + "label": "RAR를 ZIP으로 변환", + "description": "스캔할 때 RAR/CBR 파일을 ZIP/CBZ 파일로 변환합니다" }, "deleteRarAfter": { - "label": "Delete RAR after conversion", - "description": "Delete the original RAR/CBR file after conversion. This is irreversible" + "label": "변환 후 RAR 삭제", + "description": "변환 후 원본 RAR/CBR 파일을 삭제합니다. 이 작업은 되돌릴 수 없습니다" } }, "scannerFeatures": { "section": { - "heading": "Optional processing", - "description": "Opt-in processing features for the scanner", - "disclaimer": "These features involve additional file reads which may slow down the scan process" + "heading": "선택적 처리", + "description": "필요에 따라 사용할 수 있는 스캐너 처리 기능", + "disclaimer": "이 기능은 파일을 추가로 읽기 때문에 스캔 속도가 느려질 수 있습니다" }, "processMetadata": { - "label": "Process metadata", - "description": "Extract any metadata from library files and store the results in the database" + "label": "메타데이터 처리", + "description": "라이브러리 파일에서 메타데이터를 추출하여 데이터베이스에 저장합니다" }, "watch": { - "label": "Watch", - "description": "Watch the library directory for changes and update the database accordingly" + "label": "변경 감시", + "description": "라이브러리 디렉터리의 변경 사항을 감시하고 데이터베이스를 갱신합니다" }, "generateFileHashes": { - "label": "Generate file hashes", - "description": "Generate a hash for each file in the library to detect duplicates" + "label": "파일 해시 생성", + "description": "중복 파일을 찾을 수 있도록 라이브러리의 각 파일에 대한 해시를 생성합니다" }, "koreaderHashes": { - "label": "KoReader-compatible hashes", - "description": "If using KoReader, generate additional hashes in the format it expects" + "label": "KoReader 호환 해시", + "description": "KoReader를 사용하는 경우 해당 형식에 맞는 추가 해시를 생성합니다" } }, "libraryPattern": { "section": { - "heading": "Organization", - "description": "Identify which pattern most closely matches the organization of your library", + "heading": "구성 방식", + "description": "라이브러리의 폴더 구성과 가장 비슷한 패턴을 선택합니다", "docs": [ - "Not sure which to choose? Click", - "here", - "for more information.", - "You cannot change this setting later" + "무엇을 선택해야 할지 모르겠다면", + "여기", + "에서 자세히 알아보세요.", + "이 설정은 나중에 변경할 수 없습니다" ], - "disabled": "You cannot change this setting" + "disabled": "이 설정은 변경할 수 없습니다" }, "options": { - "label": "Library pattern", - "description": "The setting which determines how Stump will organize your library", + "label": "라이브러리 패턴", + "description": "Stump에서 라이브러리를 구성하는 방식을 결정합니다", "collectionPriority": { - "label": "Collection priority", - "description": "Each folder at the top-most level of the library root is a single series" + "label": "컬렉션 우선", + "description": "라이브러리 루트의 최상위 폴더 하나를 각각 하나의 시리즈로 처리합니다" }, "seriesPriority": { - "label": "Series priority", - "description": "Each folder at any level recursively is a single series" + "label": "시리즈 우선", + "description": "모든 단계의 각 폴더를 재귀적으로 하나의 시리즈로 처리합니다" } } } }, "scan": { "label": { - "create": "Scan after creation", - "update": "Scan after updates" + "create": "만든 후 스캔", + "update": "수정 후 스캔" }, - "description": "Index the library files to populate your database" + "description": "라이브러리 파일을 색인하여 데이터베이스에 추가합니다" }, "errors": { - "failedToUpdate": "Failed to update library", - "failedToScan": "Failed to scan library" + "failedToUpdate": "라이브러리를 수정하지 못했습니다", + "failedToScan": "라이브러리를 스캔하지 못했습니다" } }, "createLibraryScene": { - "heading": "Create New Library", - "subtitle": "Libraries are used to group your books together. If you want a refresh on how they work, check out the", - "subtitleLink": "relevant documentation", + "heading": "새 라이브러리 만들기", + "subtitle": "라이브러리는 책을 한곳에 모아 관리하는 데 사용합니다. 사용 방법을 다시 확인하려면", + "subtitleLink": "관련 문서 보기", "form": { - "stepLabel": "Step", + "stepLabel": "단계", "steps": [ { - "label": "Basics", - "heading": "Basic Information", - "description": "The details of this library, including its name, path, and description" + "label": "기본 정보", + "heading": "기본 정보", + "description": "이름, 경로, 설명 등 라이브러리의 기본 정보" }, { - "label": "Scanning", - "heading": "Scanning", - "description": "Settings related to how Stump scans this library" + "label": "스캔", + "heading": "스캔", + "description": "Stump가 이 라이브러리를 스캔하는 방법에 대한 설정" }, { - "label": "Thumbnails", - "heading": "Thumbnails", - "description": "The options for if and how Stump generates thumbnails for this library" + "label": "썸네일", + "heading": "썸네일", + "description": "이 라이브러리의 썸네일 생성 여부와 생성 방식" }, { - "label": "Review", - "heading": "Review", - "description": "Review your library settings before creating it" + "label": "검토", + "heading": "검토", + "description": "라이브러리를 만들기 전에 설정을 검토합니다" } ], "review": { - "yes": "Yes", - "no": "No", - "none": "None", + "yes": "예", + "no": "아니요", + "none": "없음", "labels": { - "generateThumbnails": "Generate thumbnails", - "processThumbnailColors": "Process thumbnail colors", - "mode": "Mode", - "format": "Format", - "quality": "Quality", - "name": "Name", - "path": "Path", - "description": "Description", - "tags": "Tags", - "pattern": "Pattern", - "ignoreRules": "Ignore rules", - "convertRar": "Convert RAR/CBR", - "deleteConversions": "Delete converted files", - "watch": "Watch directory for changes", - "processMetadata": "Process metadata", - "generateFileHashes": "Generate file hashes", - "generateKoreaderHashes": "Generate KoReader hashes", - "scaleEvenlyByFactor": "Evenly Scale", - "exact": "Exact Size", - "scaleDimension": "Explicitly Sized" + "generateThumbnails": "썸네일 생성", + "processThumbnailColors": "썸네일 색상 처리", + "mode": "방식", + "format": "형식", + "quality": "품질", + "name": "이름", + "path": "경로", + "description": "설명", + "tags": "태그", + "pattern": "패턴", + "ignoreRules": "무시 규칙", + "convertRar": "RAR/CBR 변환", + "deleteConversions": "변환된 파일 삭제", + "watch": "디렉터리 변경 감시", + "processMetadata": "메타데이터 처리", + "generateFileHashes": "파일 해시 생성", + "generateKoreaderHashes": "KoReader 해시 생성", + "scaleEvenlyByFactor": "비율로 균등 조정", + "exact": "정확한 크기", + "scaleDimension": "한쪽 기준 자동 조정" } }, "buttons": { - "confirm": "Create Library" + "confirm": "라이브러리 만들기" } } }, From 8e153490ed982979020c43a5887d3c13f07f0e36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:08:39 +0900 Subject: [PATCH 04/54] =?UTF-8?q?feat(i18n):=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=20=EA=B4=80=EB=A6=AC=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/i18n/src/locales/ko-KR.json | 230 +++++++++++++-------------- 1 file changed, 115 insertions(+), 115 deletions(-) diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index eac11e90f..c180f319d 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -588,214 +588,214 @@ }, "libraryHeader": { "tabs": { - "books": "Books", - "series": "Series", - "files": "Files" + "books": "책", + "series": "시리즈", + "files": "파일" } }, "librarySettingsScene": { - "heading": "Manage Library", - "subtitle": "Manage your library here. If you want a refresh on these settings, check out the", - "subtitleLink": "relevant documentation", + "heading": "라이브러리 설정", + "subtitle": "여기에서 라이브러리를 관리할 수 있습니다. 설정에 대한 설명은", + "subtitleLink": "관련 문서 보기", "sidebar": { - "basics": "Basics", + "basics": "기본 정보", "configuration": { - "general": "General", - "reading": "Reading", - "scanning": "Scanning", - "thumbnails": "Thumbnails", - "ignore-rules": "Ignore Rules", - "label": "Options" + "general": "일반", + "reading": "읽기", + "scanning": "스캔", + "thumbnails": "썸네일", + "ignore-rules": "무시 규칙", + "label": "구성" }, "integrations": { - "label": "Integrations", - "analysis": "Analysis", - "metadata": "Metadata" + "label": "연동", + "analysis": "분석", + "metadata": "메타데이터" }, "danger-zone": { - "access-control": "Access Control", - "delete": "Deletion", - "label": "Danger Zone" + "access-control": "접근 제어", + "delete": "삭제", + "label": "위험 영역" } }, "basics": { - "title": "Basic Information", - "description": "The details of this library, including its name, path, and description", + "title": "기본 정보", + "description": "이름, 경로, 설명 등 라이브러리의 기본 정보", "form": { - "submit": "Update library" + "submit": "라이브러리 수정" } }, "options/reading": { - "title": "Reading", - "description": "The default reader settings to use for books in this library. Any book can override these settings" + "title": "읽기", + "description": "이 라이브러리의 책에 사용할 기본 리더 설정입니다. 책마다 이 설정을 다르게 지정할 수 있습니다" }, "options/scanning": { - "title": "Scanning", - "description": "Settings related to how Stump scans this library", + "title": "스캔", + "description": "Stump가 이 라이브러리를 스캔하는 방법에 대한 설정", "sections": { "configureScan": { - "heading": "Configure scan", - "description": "A scan with additional options for more fine-grained control", + "heading": "스캔 구성", + "description": "세부 동작을 제어할 수 있는 추가 옵션으로 스캔합니다", "form": { - "alert": "These will override corresponding library-wide settings", + "alert": "여기에서 선택한 항목은 라이브러리 전체의 해당 설정보다 우선합니다", "options": { "forceRebuild": { - "label": "Force rebuild", - "description": "Rebuild every book in the library on disk", - "alert": "This will rebuild every book in the library from scratch, including metadata and hashes" + "label": "강제로 다시 구성", + "description": "디스크에 있는 라이브러리의 모든 책을 다시 구성합니다", + "alert": "메타데이터와 해시를 포함하여 라이브러리의 모든 책을 처음부터 다시 구성합니다" }, "custom": { - "label": "Custom", - "description": "Customize the scan configuration", + "label": "사용자 지정", + "description": "스캔 구성을 직접 설정합니다", "validation": { - "noSelection": "At least one option must be enabled" + "noSelection": "옵션을 하나 이상 사용해야 합니다" } }, "rebuildMeta": { - "label": "Rebuild metadata", - "description": "Rebuild metadata for books in the library" + "label": "메타데이터 다시 구성", + "description": "라이브러리의 책 메타데이터를 다시 구성합니다" }, "regenHashes": { - "label": "Regenerate hashes", - "description": "Recreate hashes for books in the library" + "label": "해시 다시 생성", + "description": "라이브러리의 책 해시를 다시 생성합니다" } } } }, "defaultScan": { - "heading": "Default scan", - "description": "A standard scan to index your library for new content" + "heading": "기본 스캔", + "description": "새 콘텐츠를 찾고 라이브러리를 색인하는 표준 스캔" }, "history": { - "heading": "Scan history", - "description": "The history of all scans recorded for this library", - "empty": "There are no scans recorded for this library", - "runScan": "Run scan", - "inspect": "See details", + "heading": "스캔 기록", + "description": "이 라이브러리에서 실행한 모든 스캔 기록", + "empty": "이 라이브러리의 스캔 기록이 없습니다", + "runScan": "스캔 실행", + "inspect": "상세 보기", "inspector": { - "title": "Scan record", - "description": "A detailed view of this scan record", + "title": "스캔 기록", + "description": "이 스캔 기록의 상세 정보", "fields": { - "library": "Library", - "date": "Date", - "config": "Config", - "jobOutput": "Job output", - "logs": "Logs" + "library": "라이브러리", + "date": "날짜", + "config": "구성", + "jobOutput": "작업 출력", + "logs": "로그" }, - "logsPresent": "Logs were persisted for this job", - "seeLogs": "See logs" + "logsPresent": "이 작업의 로그가 저장되었습니다", + "seeLogs": "로그 보기" }, "table": { "columns": { - "timestamp": "Scanned at", - "config": "Config" + "timestamp": "스캔 일시", + "config": "구성" }, - "default": "Default", - "custom": "Custom" + "default": "기본", + "custom": "사용자 지정" }, - "clearHistory": "Delete scan history" + "clearHistory": "스캔 기록 삭제" } } }, "options/thumbnails": { - "title": "Thumbnails", - "description": "Settings and management options for thumbnails in this library", + "title": "썸네일", + "description": "이 라이브러리의 썸네일 설정 및 관리 옵션", "sections": { "management": { - "heading": "Management", - "description": "Manage the existing thumbnails for this library", + "heading": "썸네일 관리", + "description": "이 라이브러리의 기존 썸네일을 관리합니다", "thumbnailSelector": {}, "deleteThumbnails": {} } } }, "integrations/analysis": { - "title": "Analysis", - "description": "Analyze the contents of this library to identify potential issues or improvements", + "title": "분석", + "description": "라이브러리의 콘텐츠를 분석하여 잠재적인 문제나 개선 사항을 찾습니다", "sections": { "analyzeBooks": { - "heading": "Analyze books", - "description": "Analyze the books in this library to attach additional metadata in the database, such as page dimensions for image-based books", - "disclaimerTitle": "This process is resource-intensive", - "disclaimer": "This process is resource-intensive and may take a while to complete. In the future, it will be more configurable", - "analyzeButton": "Analyze books" + "heading": "책 분석", + "description": "라이브러리의 책을 분석하여 이미지 기반 책의 페이지 크기와 같은 추가 메타데이터를 데이터베이스에 저장합니다", + "disclaimerTitle": "이 작업은 많은 시스템 자원을 사용합니다", + "disclaimer": "완료하는 데 시간이 걸릴 수 있습니다. 향후 더 세부적으로 설정할 수 있도록 개선될 예정입니다", + "analyzeButton": "책 분석" }, "missingFiles": { - "heading": "Missing files", - "description": "Files that are referenced in the database but cannot be located on disk", - "noMissingFiles": "There are no missing files in this library" + "heading": "누락된 파일", + "description": "데이터베이스에 등록되어 있지만 디스크에서 찾을 수 없는 파일", + "noMissingFiles": "이 라이브러리에 누락된 파일이 없습니다" }, "duplicateFiles": { - "heading": "Duplicate files", - "description": "Files that are potentially duplicated on disk", - "noDuplicateFiles": "There are no duplicate files detected for this library" + "heading": "중복 파일", + "description": "디스크에 중복되어 있을 가능성이 있는 파일", + "noDuplicateFiles": "이 라이브러리에서 중복 파일이 발견되지 않았습니다" } } }, "integrations/metadata": { - "title": "Metadata", - "description": "Manage and review metadata matches for this library", + "title": "메타데이터", + "description": "이 라이브러리의 메타데이터 일치 결과를 관리하고 검토합니다", "sections": { "fetchMetadata": { - "heading": "Fetch metadata", - "description": "Fetch metadata for the media in this library", + "heading": "메타데이터 가져오기", + "description": "이 라이브러리의 미디어에 대한 메타데이터를 가져옵니다", "noProviders": { - "title": "There are no metadata providers available", - "description": "You can configure metadata providers in the metadata integration settings" + "title": "사용할 수 있는 메타데이터 공급자가 없습니다", + "description": "메타데이터 연동 설정에서 공급자를 구성할 수 있습니다" }, - "fetchButton": "Start background fetch", - "fetchError": "Failed to start metadata fetch" + "fetchButton": "백그라운드 가져오기 시작", + "fetchError": "메타데이터 가져오기를 시작하지 못했습니다" } } }, "danger-zone/access-control": { - "title": "Access Control", - "description": "Control who can access the content in this library", + "title": "접근 제어", + "description": "이 라이브러리의 콘텐츠에 접근할 수 있는 사용자를 관리합니다", "sections": { "libraryExclusions": { - "heading": "Exclude users", - "description": "Prevent certain users from accessing this library or its contents", - "noUsersTitle": "No users to exclude", - "noUsers": "There are no other registered users to exclude from this library", - "updated": "Exclusions updated", - "failure": "Failed to update exclusions" + "heading": "사용자 제외", + "description": "특정 사용자가 이 라이브러리나 콘텐츠에 접근하지 못하도록 설정합니다", + "noUsersTitle": "제외할 사용자가 없습니다", + "noUsers": "이 라이브러리에서 제외할 수 있는 다른 등록 사용자가 없습니다", + "updated": "제외 목록을 수정했습니다", + "failure": "제외 목록을 수정하지 못했습니다" } } }, "danger-zone/delete": { - "title": "Deletion Actions", - "description": "Permanently delete this library and all of its contents or certain resources", + "title": "삭제 작업", + "description": "이 라이브러리와 모든 콘텐츠 또는 특정 리소스를 영구적으로 삭제합니다", "sections": { "cleanLibrary": { - "heading": "Clean library", - "description": "Remove all media and series that cannot be located on disk", - "disclaimerTitle": "No files will be deleted", - "disclaimer": "While this action will not delete any files, it will permanently delete all records relating to media and series which have a missing status", + "heading": "라이브러리 정리", + "description": "디스크에서 찾을 수 없는 모든 미디어와 시리즈를 제거합니다", + "disclaimerTitle": "파일은 삭제되지 않습니다", + "disclaimer": "파일 자체는 삭제하지 않지만, 누락 상태인 미디어와 시리즈에 관련된 모든 레코드는 영구적으로 삭제합니다", "confirmation": { - "label": "Clean library", - "text": "Are you sure you want to clean this library?" + "label": "라이브러리 정리", + "text": "이 라이브러리를 정리하시겠습니까?" }, - "emptyText": "There was nothing to clean", + "emptyText": "정리할 항목이 없습니다", "missingEntitiesTable": { "columns": { "id": "ID", - "path": "Path", - "type": "Type" + "path": "경로", + "type": "유형" }, "types": { - "BOOK": "Book", - "SERIES": "Series", - "LIBRARY": "Library" + "BOOK": "책", + "SERIES": "시리즈", + "LIBRARY": "라이브러리" }, - "emptyHeading": "Nothing is missing for this library", - "emptySubtitle": "All files referenced in the database were located on disk" + "emptyHeading": "이 라이브러리에 누락된 항목이 없습니다", + "emptySubtitle": "데이터베이스에 등록된 모든 파일을 디스크에서 찾았습니다" } }, "deleteLibrary": { - "heading": "Delete library", + "heading": "라이브러리 삭제", "description": [ - "Permanently delete this library and all its contents.", - "This action cannot be undone" + "이 라이브러리와 모든 콘텐츠를 영구적으로 삭제합니다.", + "이 작업은 되돌릴 수 없습니다" ] } } @@ -2543,9 +2543,9 @@ "backupHeading": "작업 진행 중" }, "libraryStats": { - "seriesCount": "Total Series", - "bookCount": "Total Books", - "diskUsage": "Disk Usage" + "seriesCount": "전체 시리즈", + "bookCount": "전체 책", + "diskUsage": "디스크 사용량" }, "noLibraries": { "heading": "표시할 내용이 없습니다", From 7f35a12c8494c412bcff8682373245ff5aad2a40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:10:57 +0900 Subject: [PATCH 05/54] =?UTF-8?q?feat(i18n):=20=EC=B1=85=EA=B3=BC=20?= =?UTF-8?q?=EB=A9=94=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EB=B2=88=EC=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/i18n/src/locales/ko-KR.json | 233 ++++++++++++++------------- 1 file changed, 120 insertions(+), 113 deletions(-) diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index c180f319d..4bef99713 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -44,61 +44,68 @@ }, "bookManagementScene": { "metadataSearch": { - "heading": "Search metadata", - "description": "Search an external provider for this book and choose a match to apply", - "openButton": "Search Metadata", - "searchButton": "Search", - "noProviders": "No enabled metadata providers are configured for this library type. Configure one in server settings first.", - "noResults": "No matches were found for this search", - "partialResults": "Some results could not be loaded from the provider, likely due to rate limiting. Try searching again to see if more turn up.", - "searchFailed": "Failed to search metadata providers", + "heading": "메타데이터 검색", + "description": "외부 공급자에서 이 책을 검색하고 적용할 결과를 선택합니다", + "openButton": "메타데이터 검색", + "searchButton": "검색", + "noProviders": "이 라이브러리 유형에 사용 설정된 메타데이터 공급자가 없습니다. 먼저 서버 설정에서 공급자를 구성하세요.", + "noResults": "검색 결과와 일치하는 항목이 없습니다", + "partialResults": "공급자의 요청 제한으로 인해 일부 결과를 불러오지 못했을 수 있습니다. 더 많은 결과를 확인하려면 다시 검색해 보세요.", + "searchFailed": "메타데이터 공급자를 검색하지 못했습니다", "form": { "title": { - "label": "Title" + "label": "제목" }, "author": { - "label": "Author" + "label": "저자" }, "isbn": { "label": "ISBN" }, "year": { - "label": "Year" + "label": "연도" + }, + "number": { + "label": "호수" + }, + "comicVineVolumeId": { + "label": "ComicVine 권 ID", + "placeholder": "예: 129260" }, "provider": { - "label": "Provider", - "allOption": "All enabled providers" + "label": "공급자", + "allOption": "사용 설정된 모든 공급자" } } } }, "bookOverviewScene": { "fileInformation": { - "heading": "File Information", + "heading": "파일 정보", "labels": { "fileSize": "파일 크기", "fileType": "파일 형식", "fileLocation": "파일 위치", - "fileChecksum": "Checksum" + "fileChecksum": "체크섬" } }, "emailBook": { - "heading": "Email book", - "description": "Send this book to a device or email address", + "heading": "책 이메일로 보내기", + "description": "이 책을 기기나 이메일 주소로 보냅니다", "form": { "email": { - "label": "Email", - "placeholder": "Email address", - "description": "An email address to send the book to. Click add to finalize each input" + "label": "이메일", + "placeholder": "이메일 주소", + "description": "책을 보낼 이메일 주소입니다. 주소를 입력할 때마다 추가를 눌러 확정하세요" }, "devices": { - "label": "Devices", - "placeholder": "Select devices", - "noFilterMatch": "No devices match the filter" + "label": "기기", + "placeholder": "기기 선택", + "noFilterMatch": "필터와 일치하는 기기가 없습니다" } } }, - "nextInSeries": "Next in Series" + "nextInSeries": "시리즈의 다음 책" }, "createOrUpdateBookClubForm": { "fields": { @@ -842,142 +849,142 @@ }, "metadataEditor": { "labels": { - "ageRating": "Age rating", - "characters": "Characters", - "colorists": "Colorists", - "coverArtists": "Cover artists", - "day": "Day", - "editors": "Editors", - "format": "Format", + "ageRating": "연령 등급", + "characters": "등장인물", + "colorists": "채색가", + "coverArtists": "표지 작가", + "day": "일", + "editors": "편집자", + "format": "형식", "identifierAmazon": "Amazon ID", "identifierCalibre": "Calibre ID", "identifierGoogle": "Google ID", "identifierIsbn": "ISBN", "identifierMobiAsin": "Mobi ASIN", - "identifierUuid": "Book UUID", - "genres": "Genres", - "inkers": "Inkers", - "language": "Language", - "letterers": "Letterers", - "links": "Links", - "month": "Month", - "notes": "Notes", - "number": "Number", - "pageCount": "Pages", - "pencillers": "Pencillers", - "publisher": "Publisher", - "series": "Series", - "seriesGroup": "Series group", - "storyArc": "Story arc", - "storyArcNumber": "Story arc number", - "summary": "Summary", - "teams": "Teams", - "title": "Title", - "titleSort": "Title sort", - "volume": "Volume", - "writers": "Writers", - "year": "Year", - "metaType": "Type", - "imprint": "Imprint", - "comicid": "Comic ID", - "booktype": "Book type", - "status": "Status", - "totalIssues": "Total issues", - "publicationRun": "Publication run", - "comicImage": "Comic image", - "descriptionFormatted": "Description (formatted)" + "identifierUuid": "책 UUID", + "genres": "장르", + "inkers": "잉크 담당", + "language": "언어", + "letterers": "레터링 담당", + "links": "링크", + "month": "월", + "notes": "메모", + "number": "번호", + "pageCount": "페이지 수", + "pencillers": "작화가", + "publisher": "출판사", + "series": "시리즈", + "seriesGroup": "시리즈 그룹", + "storyArc": "스토리 아크", + "storyArcNumber": "스토리 아크 번호", + "summary": "요약", + "teams": "팀", + "title": "제목", + "titleSort": "제목 정렬 기준", + "volume": "권", + "writers": "글 작가", + "year": "연도", + "metaType": "유형", + "imprint": "임프린트", + "comicid": "코믹 ID", + "booktype": "책 유형", + "status": "상태", + "totalIssues": "전체 호수", + "publicationRun": "출간 기간", + "comicImage": "코믹 이미지", + "descriptionFormatted": "설명(서식 적용)" }, "addFields": { - "description": "Add each addition on its own line" + "description": "추가할 항목을 한 줄에 하나씩 입력하세요" } }, "metadataMatching": { "section": { - "heading": "Pending matches", - "description": "Review metadata matches fetched from external providers" + "heading": "검토 대기 중인 일치 항목", + "description": "외부 공급자에서 가져온 메타데이터 일치 결과를 검토합니다" }, "confidenceBadge": { - "label": "confidence" + "label": "신뢰도" }, - "includeField": "Include this field", - "excludeField": "Exclude this field", - "fieldLocked": "This field is locked", + "includeField": "이 필드 포함", + "excludeField": "이 필드 제외", + "fieldLocked": "이 필드는 잠겨 있습니다", "reviewDialog": { "previewEditor": { - "noCandidates": "No candidates available for this record", - "noComparableFields": "No comparable fields found", + "noCandidates": "이 레코드에 사용할 수 있는 후보가 없습니다", + "noComparableFields": "비교할 수 있는 필드가 없습니다", "headers": { - "field": "Field", - "current": "Current", - "external": "External", - "resolved": "Resolved" + "field": "필드", + "current": "현재 값", + "external": "외부 값", + "resolved": "적용할 값" }, "toolbar": { - "candidatePosition": "Candidate {{position}} of {{total}}", - "provider": "Provider" + "candidatePosition": "후보 {{position}}/{{total}}", + "provider": "공급자" } }, "footer": { - "accept": "Accept", - "reject": "Reject", + "accept": "수락", + "reject": "거절", "strategies": { - "FILL_GAPS": "Fill gaps", - "PREFER_EXTERNAL": "Prefer external", - "PREFER_EXTERNAL_AND_MERGE_LISTS": "Prefer external and merge lists", - "FILL_AND_MERGE_LISTS": "Fill and merge lists" + "FILL_GAPS": "빈 값 채우기", + "PREFER_EXTERNAL": "외부 값 우선", + "PREFER_EXTERNAL_AND_MERGE_LISTS": "외부 값 우선 및 목록 병합", + "FILL_AND_MERGE_LISTS": "빈 값 채우기 및 목록 병합" } }, "fieldAction": { - "keepCurrent": "Keep current", - "takeExternal": "Take external", - "mergeLists": "Merge lists", - "editManually": "Edit manually", - "lockField": "Lock field", - "unlockField": "Unlock field", - "reset": "Reset", - "undoManualEdit": "Undo manual edit" + "keepCurrent": "현재 값 유지", + "takeExternal": "외부 값 사용", + "mergeLists": "목록 병합", + "editManually": "직접 편집", + "lockField": "필드 잠그기", + "unlockField": "필드 잠금 해제", + "reset": "초기화", + "undoManualEdit": "직접 편집 취소" } }, "matchesTable": { "columns": { - "entityName": "Name", - "entityType": "Type", - "candidateCount": "Candidates", - "topConfidence": "Top Confidence", - "addedAt": "Fetched" + "entityName": "이름", + "entityType": "유형", + "candidateCount": "후보 수", + "topConfidence": "최고 신뢰도", + "addedAt": "가져온 일시" }, "acceptAll": { - "label": "Accept all", - "failed": "Failed to accept all matches", - "success": "All pending matches accepted" + "label": "모두 수락", + "failed": "모든 일치 항목을 수락하지 못했습니다", + "success": "대기 중인 모든 일치 항목을 수락했습니다" }, "rejectAll": { - "label": "Reject all", - "failed": "Failed to reject all matches", - "success": "All pending matches rejected" + "label": "모두 거절", + "failed": "모든 일치 항목을 거절하지 못했습니다", + "success": "대기 중인 모든 일치 항목을 거절했습니다" }, - "startReview": "Start review", - "nothingToReview": "No pending metadata matches to review" + "startReview": "검토 시작", + "nothingToReview": "검토할 메타데이터 일치 항목이 없습니다" } }, "seriesOverviewScene": { "buttons": { - "continueReading": "Continue Reading", - "downloadSeries": "Download Series" + "continueReading": "이어 읽기", + "downloadSeries": "시리즈 다운로드" } }, "seriesHeader": { "tabs": { - "books": "Books", - "files": "Files" + "books": "책", + "files": "파일" }, "actions": { - "markAsRead": "Mark series as read", - "markAsUnread": "Mark series as unread", - "goToLibrary": "Go to library" + "markAsRead": "시리즈를 읽음으로 표시", + "markAsUnread": "시리즈를 읽지 않음으로 표시", + "goToLibrary": "라이브러리로 이동" }, "errors": { - "failedToUpdateCompletion": "Failed to update book completion status" + "failedToUpdateCompletion": "책의 읽기 완료 상태를 수정하지 못했습니다" } }, "createOrUpdateSmartListForm": { From 1b11698f85a7d5ea660347909d8df60440b55dde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:13:15 +0900 Subject: [PATCH 06/54] =?UTF-8?q?feat(i18n):=20=EC=8A=A4=EB=A7=88=ED=8A=B8?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=ED=95=84=ED=84=B0=20=EB=AC=B8=EA=B5=AC?= =?UTF-8?q?=20=EB=B2=88=EC=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/i18n/src/locales/ko-KR.json | 208 +++++++++++++-------------- 1 file changed, 104 insertions(+), 104 deletions(-) diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 4bef99713..6d3ef8b73 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -989,189 +989,189 @@ }, "createOrUpdateSmartListForm": { "validation": { - "nameTooShort": "Name must be at least 2 characters", - "nameTaken": "Cannot use this name" + "nameTooShort": "이름은 2자 이상이어야 합니다", + "nameTaken": "이 이름은 사용할 수 없습니다" }, "fields": { "name": { - "label": "Name", - "placeholder": "My smart list", - "description": "Must be unique, but can be changed later" + "label": "이름", + "placeholder": "내 스마트 목록", + "description": "고유한 이름이어야 하며 나중에 변경할 수 있습니다" }, "description": { - "label": "Description", - "placeholder": "A very intelligent list", - "description": "An optional description of your smart list" + "label": "설명", + "placeholder": "아주 똑똑한 목록", + "description": "스마트 목록에 대한 선택 사항인 설명" }, "visibility": { - "label": "Visibility", + "label": "공개 범위", "options": { "public": { - "label": "Public", - "description": "Accessible to all users on the server" + "label": "공개", + "description": "서버의 모든 사용자가 접근할 수 있습니다" }, "shared": { - "label": "Shared", - "description": "Specific users can access this smart list", - "createDisclaimer": "You can select who this list is shared with after creation" + "label": "공유", + "description": "지정한 사용자가 이 스마트 목록에 접근할 수 있습니다", + "createDisclaimer": "목록을 만든 후 공유할 사용자를 선택할 수 있습니다" }, "private": { - "label": "Private", - "description": "Only you can access this smart list" + "label": "비공개", + "description": "나만 이 스마트 목록에 접근할 수 있습니다" } } }, "queryBuilder": { - "uiPerformance": "This feature is missing performance optimizations on the UI and may be slow with filters which produce large result sets. This will be improved in the future", + "uiPerformance": "이 기능은 아직 UI 성능 최적화가 부족하여 결과가 많은 필터에서는 느릴 수 있습니다. 향후 개선될 예정입니다", "rootJoiner": { "and": { - "label": "AND", - "description": "All filter groups must be true for a book to be matched" + "label": "그리고", + "description": "책이 일치하려면 모든 필터 그룹의 조건을 만족해야 합니다" }, "or": { - "label": "OR", - "description": "At least one filter group must be true for a book to be matched" + "label": "또는", + "description": "책이 일치하려면 필터 그룹 중 하나 이상의 조건을 만족해야 합니다" } }, "groupJoiner": { "and": { - "label": "AND", - "description": "All must be true" + "label": "그리고", + "description": "모든 조건을 만족해야 합니다" }, "or": { - "label": "OR", - "description": "At least one must be true" + "label": "또는", + "description": "하나 이상의 조건을 만족해야 합니다" }, "not": { - "label": "NOT", - "description": "None can be true" + "label": "제외", + "description": "어떤 조건도 만족하면 안 됩니다" } }, "grouping": { - "label": "Group by", - "description": "How the books in this smart list should be grouped", + "label": "그룹화 기준", + "description": "스마트 목록의 책을 그룹화하는 방식", "options": { "by_books": { - "label": "None" + "label": "그룹화 안 함" }, "by_series": { - "label": "Series" + "label": "시리즈" }, "by_library": { - "label": "Library" + "label": "라이브러리" } } }, "filters": { - "emptyState": "Add a group to get started", + "emptyState": "그룹을 추가하여 시작하세요", "actions": { - "addFilter": "Add filter", - "addGroup": "Add group", - "deleteFilter": "Delete filter", - "deleteGroup": "Delete group" + "addFilter": "필터 추가", + "addGroup": "그룹 추가", + "deleteFilter": "필터 삭제", + "deleteGroup": "그룹 삭제" }, - "is": "is", + "is": "조건", "fieldSelect": { - "placeholder": "Attribute", + "placeholder": "속성", "source": { - "label": "Source", - "back": "Back", + "label": "대상", + "back": "뒤로", "book": { - "label": "Book", + "label": "책", "attributes": { - "name": "Filename", - "size": "Size", - "extension": "Extension", - "status": "Status", - "path": "Path", - "pages": "Pages", - "tags": "Tags", - "createdAt": "Created at", - "updatedAt": "Updated at", - "readingStatus": "Reading status" + "name": "파일 이름", + "size": "크기", + "extension": "확장자", + "status": "상태", + "path": "경로", + "pages": "페이지 수", + "tags": "태그", + "createdAt": "생성일", + "updatedAt": "수정일", + "readingStatus": "읽기 상태" } }, "book_meta": { - "label": "Book Meta", + "label": "책 메타데이터", "attributes": { - "title": "Title", - "summary": "Summary", - "notes": "Notes", - "writers": "Writers", - "pencillers": "Pencillers", - "inkers": "Inkers", - "letterers": "Letterers", - "editors": "Editors", - "publisher": "Publisher", - "colorists": "Colorist", - "links": "Links", - "characters": "Characters", - "teams": "Teams", - "genres": "Genre", - "coverArtists": "Cover artists", - "ageRating": "Age rating", - "year": "Year", - "month": "Month", - "day": "Day" + "title": "제목", + "summary": "요약", + "notes": "메모", + "writers": "글 작가", + "pencillers": "작화가", + "inkers": "잉크 담당", + "letterers": "레터링 담당", + "editors": "편집자", + "publisher": "출판사", + "colorists": "채색가", + "links": "링크", + "characters": "등장인물", + "teams": "팀", + "genres": "장르", + "coverArtists": "표지 작가", + "ageRating": "연령 등급", + "year": "연도", + "month": "월", + "day": "일" } }, "series": { - "label": "Series", + "label": "시리즈", "attributes": { - "name": "Name", - "path": "Path" + "name": "이름", + "path": "경로" } }, "series_meta": { - "label": "Series Meta", + "label": "시리즈 메타데이터", "attributes": { - "title": "Title", - "summary": "Summary", - "publisher": "Publisher", - "imprint": "Imprint", - "comicid": "Comic ID", - "booktype": "Book type", - "status": "Status", - "volume": "Volume", - "metaType": "Type", - "ageRating": "Age rating", - "year": "Start year" + "title": "제목", + "summary": "요약", + "publisher": "출판사", + "imprint": "임프린트", + "comicid": "코믹 ID", + "booktype": "책 유형", + "status": "상태", + "volume": "권", + "metaType": "유형", + "ageRating": "연령 등급", + "year": "시작 연도" } }, "library": { - "label": "Library", + "label": "라이브러리", "attributes": { - "name": "Name", - "path": "Path" + "name": "이름", + "path": "경로" } } } }, "basicValue": { - "placeholder": "Value", - "date": "Date" + "placeholder": "값", + "date": "날짜" }, "rangeValue": { "from": { - "date": "From date", - "number": "From" + "date": "시작 날짜", + "number": "최솟값" }, "to": { - "date": "To date", - "number": "To" + "date": "종료 날짜", + "number": "최댓값" }, - "inclusive": "Inclusive" + "inclusive": "경곗값 포함" }, "listValue": { - "placeholder": "Add values", - "emptyState": "No values to display" + "placeholder": "값 추가", + "emptyState": "표시할 값이 없습니다" }, "enumValue": { - "selectPlaceholder": "Select value", - "READING": "Reading", - "FINISHED": "Finished", - "NOT_STARTED": "Not started", - "ABANDONED": "Abandoned" + "selectPlaceholder": "값 선택", + "READING": "읽는 중", + "FINISHED": "완독", + "NOT_STARTED": "읽기 전", + "ABANDONED": "중단" } } } From 3b78402a873a8bd02eb2cb81f9105fc3597d6f47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:15:58 +0900 Subject: [PATCH 07/54] =?UTF-8?q?feat(i18n):=20=EC=8A=A4=EB=A7=88=ED=8A=B8?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=ED=99=94=EB=A9=B4=20=EB=AC=B8=EA=B5=AC?= =?UTF-8?q?=20=EB=B2=88=EC=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/i18n/src/locales/ko-KR.json | 180 +++++++++++++-------------- 1 file changed, 90 insertions(+), 90 deletions(-) diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 6d3ef8b73..a44422a49 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -1178,40 +1178,40 @@ } }, "createSmartListScene": { - "heading": "Create smart list", - "subtitle": "On-the-fly list creation based on a set of filters", + "heading": "스마트 목록 만들기", + "subtitle": "설정한 필터에 따라 항목이 자동으로 구성되는 목록을 만듭니다", "form": { - "stepLabel": "Step", + "stepLabel": "단계", "steps": [ { - "label": "Basics", - "heading": "Basic Information", - "description": "The details of this smart list, including its name, description, and visibility" + "label": "기본 정보", + "heading": "기본 정보", + "description": "이름, 설명, 공개 범위 등 스마트 목록의 기본 정보" }, { - "label": "Filters", - "heading": "Filters", - "description": "The filters to apply to this smart list" + "label": "필터", + "heading": "필터", + "description": "이 스마트 목록에 적용할 필터" }, { - "label": "Review", - "heading": "Review", - "description": "Review your smart list settings before creating it" + "label": "검토", + "heading": "검토", + "description": "스마트 목록을 만들기 전에 설정을 검토합니다" } ], "review": { - "yes": "Yes", - "no": "No", - "none": "None", + "yes": "예", + "no": "아니요", + "none": "없음", "labels": { - "name": "Name", - "description": "Description", - "visibility": "Visibility", - "grouping": "Group by" + "name": "이름", + "description": "설명", + "visibility": "공개 범위", + "grouping": "그룹화 기준" } }, "buttons": { - "confirm": "Create list" + "confirm": "목록 만들기" } } }, @@ -1246,82 +1246,82 @@ "inheritDisclaimer": "이 API 키는 내 모든 권한을 상속하므로 내 계정으로 할 수 있는 모든 작업을 수행할 수 있습니다. 신중하게 설정하세요" }, "userSmartListsScene": { - "heading": "Smart lists", - "subtitle": "Your favorite searches and filters saved for easy access", - "searchPlaceholder": "Filter smart lists", + "heading": "스마트 목록", + "subtitle": "자주 사용하는 검색과 필터를 저장하여 간편하게 이용할 수 있습니다", + "searchPlaceholder": "스마트 목록 검색", "buttons": { - "createSmartList": "Create smart list" + "createSmartList": "스마트 목록 만들기" }, "list": { "emptyState": { - "heading": "No smart lists to display", - "noListsMessage": "Create a smart list to get started", - "noMatchesMessage": "Try changing your search query" + "heading": "표시할 스마트 목록이 없습니다", + "noListsMessage": "스마트 목록을 만들어 시작하세요", + "noMatchesMessage": "검색어를 변경해 보세요" }, "card": { "meta": { "figures": { - "books": "book", - "series": "series", - "library": "library" + "books": "책", + "series": "시리즈", + "library": "라이브러리" }, - "matches": "Matches" + "matches": "일치" } } } }, "smartListSettingsScene": { "sidebar": { - "basics": "Basics", + "basics": "기본 정보", "configuration": { - "label": "Configuration", - "access": "Access", - "filters": "Filters" + "label": "구성", + "access": "접근", + "filters": "필터" }, "danger-zone": { - "delete": "Deletion", - "label": "Danger Zone" + "delete": "삭제", + "label": "위험 영역" } }, "basics": { - "title": "Basic Information", - "description": "The details of this smart list, including its name and description" + "title": "기본 정보", + "description": "이름과 설명 등 스마트 목록의 기본 정보" }, "access": { - "title": "Access", - "description": "Control who can access this smart list", + "title": "접근", + "description": "이 스마트 목록에 접근할 수 있는 사용자를 관리합니다", "sections": { "visibility": { "confirmation": { - "public": "This will make the list visible to everyone", - "private": "This will change the list to be private, hiding it from anyone who previously had access", - "shared": "You will need to share the list with specific users to give them access", - "fallback": "Are you sure you want to change the visibility of this list?" + "public": "이 목록이 모든 사용자에게 표시됩니다", + "private": "목록을 비공개로 변경하면 이전에 접근할 수 있었던 사용자에게도 표시되지 않습니다", + "shared": "접근 권한을 부여하려면 목록을 특정 사용자와 공유해야 합니다", + "fallback": "이 목록의 공개 범위를 변경하시겠습니까?" } }, "accessManager": { - "disclaimer": "Sharing is not ready yet. Please check back later!" + "disclaimer": "공유 기능은 아직 준비 중입니다. 나중에 다시 확인해 주세요!" } } }, "filters": { - "title": "Filter Builder", - "description": "Manage the filters which construct this smart list" + "title": "필터 구성", + "description": "이 스마트 목록을 구성하는 필터를 관리합니다" }, "danger-zone/delete": { - "title": "Deletion Actions", - "description": "Permanently delete this smart list and all of its contents or certain resources", + "title": "삭제 작업", + "description": "이 스마트 목록과 모든 콘텐츠 또는 특정 리소스를 영구적으로 삭제합니다", "sections": { "deleteList": { - "heading": "Delete list", + "heading": "목록 삭제", "description": [ - "Permanently delete this smart list and all its contents.", - "This action cannot be undone" + "이 스마트 목록과 모든 콘텐츠를 영구적으로 삭제합니다.", + "이 작업은 되돌릴 수 없습니다" ], "confirmation": { - "title": "Delete list", - "description": "Are you sure you want to delete this smart list?", - "confirm": "Delete list" + "title": "목록 삭제", + "description": "이 스마트 목록을 삭제하시겠습니까?", + "confirm": "목록 삭제" } } } @@ -1329,73 +1329,73 @@ }, "userSmartListScene": { "navigation": { - "items": "Items", - "settings": "Settings" + "items": "항목", + "settings": "설정" }, "layout": { - "missingIdError": "This scene requires an ID in the URL", - "smartListNotFound": "Smart list not found", - "viewCreateError": "Failed to create view", - "viewSaveError": "Failed to save view" + "missingIdError": "이 화면의 URL에는 ID가 필요합니다", + "smartListNotFound": "스마트 목록을 찾을 수 없습니다", + "viewCreateError": "보기를 만들지 못했습니다", + "viewSaveError": "보기를 저장하지 못했습니다" }, "header": { - "backLink": "Lists" + "backLink": "목록" }, "itemsScene": { - "smartListNotFound": "Smart list not found", + "smartListNotFound": "스마트 목록을 찾을 수 없습니다", "actionHeader": { "viewSelector": { - "customView": "Custom view", - "defaultView": "Default view", - "noViewsSaved": "No views saved", - "selectView": "Select a saved view" + "customView": "사용자 지정 보기", + "defaultView": "기본 보기", + "noViewsSaved": "저장된 보기가 없습니다", + "selectView": "저장된 보기 선택" }, "filterDrawer": { - "heading": "Smart list filters", - "description": "Change the filters for this viewing session", + "heading": "스마트 목록 필터", + "description": "현재 보기 세션에 적용할 필터를 변경합니다", "rawData": { - "heading": "Raw filter data", - "description": "This is how Stump will process the filters" + "heading": "원본 필터 데이터", + "description": "Stump는 이 데이터에 따라 필터를 처리합니다" }, "buttons": { - "save": "Save", - "cancel": "Cancel" + "save": "저장", + "cancel": "취소" } }, "search": { - "placeholder": "Quick filter" + "placeholder": "빠른 필터" }, "viewManager": { - "updateSelected": "Update selected", - "deleteSelected": "Delete view", - "create": "Create new view", + "updateSelected": "보기 수정", + "deleteSelected": "보기 삭제", + "create": "새 보기 만들기", "modal": { "heading": { - "create": "Create view", - "update": "Update view" + "create": "보기 만들기", + "update": "보기 수정" }, "description": { - "create": "Create a new view for this smart list", - "update": "Update the view" + "create": "이 스마트 목록에 사용할 새 보기를 만듭니다", + "update": "보기를 수정합니다" }, "createForm": { "name": { - "label": "Name", - "placeholder": "My view", - "description": "A friendly name to uniquely identify this view" + "label": "이름", + "placeholder": "내 보기", + "description": "이 보기를 구분할 고유한 이름" } }, "updateForm": { "name": { - "label": "Name", - "placeholder": "My view", - "description": "The updated name for this view, if desired" + "label": "이름", + "placeholder": "내 보기", + "description": "필요한 경우 보기에 사용할 새 이름" } } }, "deleteModal": { - "title": "Delete view", - "description": "Are you sure you want to delete this view?" + "title": "보기 삭제", + "description": "이 보기를 삭제하시겠습니까?" } } } From 8b395e7559d1b5802d557ead588de1da8268c386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:17:45 +0900 Subject: [PATCH 08/54] =?UTF-8?q?feat(i18n):=20=EB=B6=81=ED=81=B4=EB=9F=BD?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EB=AC=B8=EA=B5=AC=20=EB=B2=88=EC=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/i18n/src/locales/ko-KR.json | 156 +++++++++++++-------------- 1 file changed, 78 insertions(+), 78 deletions(-) diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index a44422a49..81258ff28 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -110,161 +110,161 @@ "createOrUpdateBookClubForm": { "fields": { "name": { - "label": "Name", - "placeholder": "My book club", - "description": "The name of your book club" + "label": "이름", + "placeholder": "내 북클럽", + "description": "북클럽의 이름" }, "description": { - "label": "Description", - "placeholder": "An 'Our Flag Means Death' fan club. We read pirate fiction to our hearts' content", - "description": "An optional, short description of your book club" + "label": "설명", + "placeholder": "함께 좋아하는 책을 읽고 이야기하는 모임", + "description": "북클럽에 대한 선택 사항인 짧은 설명" }, "is_private": { - "label": "Private club", - "description": "If enabled, only users you invite will be able to join your book club" + "label": "비공개 북클럽", + "description": "사용하면 초대한 사용자만 북클럽에 가입할 수 있습니다" }, "member_role_spec": { - "heading": "Custom role mapping", + "heading": "역할 이름 사용자 지정", "subtitle": [ - "You can create custom names for the roles in your book club if you'd like. For more information, refer to the", - "documentation" + "원하는 경우 북클럽 역할에 사용할 이름을 직접 지정할 수 있습니다. 자세한 내용은", + "문서 보기" ], "member": { - "label": "Member", - "placeholder": "Member", - "description": "The name of the default role for your book club" + "label": "회원", + "placeholder": "회원", + "description": "북클럽의 기본 역할에 사용할 이름" }, "moderator": { - "label": "Moderator", - "placeholder": "Moderator", - "description": "The name of the moderator role for your book club" + "label": "운영자", + "placeholder": "운영자", + "description": "북클럽 운영자 역할에 사용할 이름" }, "admin": { - "label": "Admin", - "placeholder": "Admin", - "description": "The name of the admin role for your book club" + "label": "관리자", + "placeholder": "관리자", + "description": "북클럽 관리자 역할에 사용할 이름" }, "creator": { - "label": "Creator", - "placeholder": "Creator", - "description": "The name of the creator role for your book club. This is you!" + "label": "개설자", + "placeholder": "개설자", + "description": "북클럽 개설자 역할에 사용할 이름입니다. 바로 회원님입니다!" } }, "membershipOptions": { "displayName": { - "label": "Display Name", + "label": "표시 이름", "placeholder": "oromei", - "description": "An optional display name for your membership in the book club. If set, this takes precedence over your username" + "description": "북클럽에서 사용할 선택 사항인 표시 이름입니다. 설정하면 사용자 이름보다 우선하여 표시됩니다" }, "hideProgress": { - "label": "Hide Progress", - "description": "If enabled, your reading progress will be hidden from other members of the book club" + "label": "진행률 숨기기", + "description": "사용하면 내 읽기 진행률이 다른 북클럽 회원에게 표시되지 않습니다" } } }, "validation": { - "missingName": "Name is required", - "alreadyTaken": "is already taken" + "missingName": "이름을 입력하세요", + "alreadyTaken": "은(는) 이미 사용 중입니다" } }, "createBookClubScene": { - "heading": "Create a new book club", - "subtitle": "You can create a private book club for a select few, or make it public for anyone on the server to join", + "heading": "새 북클럽 만들기", + "subtitle": "선택한 사용자만 참여하는 비공개 북클럽이나 서버의 누구나 참여할 수 있는 공개 북클럽을 만들 수 있습니다", "form": { - "stepLabel": "Step", + "stepLabel": "단계", "steps": [ { - "label": "Basics", - "heading": "Basic Information", - "description": "The details of the club, including its name and description" + "label": "기본 정보", + "heading": "기본 정보", + "description": "이름과 설명 등 북클럽의 기본 정보" }, { - "label": "Roles", - "heading": "Roles", - "description": "Customize the roles in your book club" + "label": "역할", + "heading": "역할", + "description": "북클럽의 역할 이름을 설정합니다" }, { - "label": "Membership", - "heading": "Membership Preferences", - "description": "Additional options for your book club" + "label": "멤버십", + "heading": "멤버십 설정", + "description": "북클럽의 추가 옵션" }, { - "label": "Review", - "heading": "Review", - "description": "Review your club settings before creating it" + "label": "검토", + "heading": "검토", + "description": "북클럽을 만들기 전에 설정을 검토합니다" } ], "review": { - "yes": "Yes", - "no": "No", - "none": "None", + "yes": "예", + "no": "아니요", + "none": "없음", "labels": { - "name": "Name", - "description": "Description" + "name": "이름", + "description": "설명" } }, - "submit": "Create Book Club" + "submit": "북클럽 만들기" } }, "bookClubSettingsScene": { "sidebar": { - "basics": "Basics", + "basics": "기본 정보", "members": { - "members": "Members", - "roles": "Roles", - "label": "Membership" + "members": "회원", + "roles": "역할", + "label": "멤버십" }, "scheduling": { - "scheduler": "Scheduler", - "options": "Options", - "label": "Scheduling" + "scheduler": "일정 관리", + "options": "옵션", + "label": "읽기 일정" }, "danger-zone": { - "delete": "Deletion", - "label": "Danger Zone" + "delete": "삭제", + "label": "위험 영역" } }, "basics": { - "title": "Basic Information", - "description": "The details of this book club, including its name and description", + "title": "기본 정보", + "description": "이름과 설명 등 북클럽의 기본 정보", "form": { - "submit": "Update club" + "submit": "북클럽 수정" } }, "members": { - "title": "Members", - "description": "Manage the members of this book club", + "title": "회원", + "description": "이 북클럽의 회원을 관리합니다", "sections": {} }, "roles": { - "title": "Roles", - "description": "Customize the roles in this book club", + "title": "역할", + "description": "이 북클럽의 역할 이름을 설정합니다", "sections": { "roleMappings": { - "heading": "Existing role mappings", - "description": "The current role mappings for this book club" + "heading": "현재 역할 이름", + "description": "이 북클럽에서 현재 사용 중인 역할 이름" } } }, "scheduling/scheduler": { - "title": "Scheduler", - "description": "Create and manage the scheduled readings for this book club" + "title": "일정 관리", + "description": "이 북클럽의 읽기 일정을 만들고 관리합니다" }, "scheduling/options": { - "title": "Options", - "description": "Additional options for this book club's scheduling" + "title": "옵션", + "description": "이 북클럽의 읽기 일정에 대한 추가 옵션" }, "danger-zone/delete": { - "title": "Deletion Actions", - "description": "Permanently delete this book club and all of its contents or certain resources", + "title": "삭제 작업", + "description": "이 북클럽과 모든 콘텐츠 또는 특정 리소스를 영구적으로 삭제합니다", "sections": { "deleteClub": { - "heading": "Delete club", + "heading": "북클럽 삭제", "description": [ - "Permanently delete this book club and all its contents.", - "This action cannot be undone" + "이 북클럽과 모든 콘텐츠를 영구적으로 삭제합니다.", + "이 작업은 되돌릴 수 없습니다" ], - "trigger": "Delete club" + "trigger": "북클럽 삭제" } } } From dd42b1f5f30c4498deca29f462d1f8d1a5b4d762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:21:10 +0900 Subject: [PATCH 09/54] =?UTF-8?q?feat(i18n):=20=EA=B0=9C=EC=9D=B8=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=99=94=EB=A9=B4=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/i18n/src/locales/ko-KR.json | 216 +++++++++++++-------------- 1 file changed, 108 insertions(+), 108 deletions(-) diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 81258ff28..9c8c9944b 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -1695,222 +1695,222 @@ } }, "app/apiKeys": { - "helmet": "API keys", - "title": "API keys", - "description": "Manage your generated Stump API keys", + "helmet": "API 키", + "title": "API 키", + "description": "생성한 Stump API 키를 관리합니다", "shared": { "fields": { - "name": "Name", - "expiration": "Expiration", - "last_used": "Last used", - "created": "Created", - "permissions": "Permissions", - "inherited": "Inherited", - "explicit": "Explicit" + "name": "이름", + "expiration": "만료일", + "last_used": "마지막 사용", + "created": "생성일", + "permissions": "권한", + "inherited": "상속", + "explicit": "직접 지정" } }, "sections": { "inspector": { - "heading": "API key", - "description": "A detailed view of this key", + "heading": "API 키", + "description": "이 키의 상세 정보", "unrestrictedKey": { - "heading": "Unrestricted key", - "description": "Since you are the server owner, this key has unrestricted access when configured to inherit permissions. Please be careful." + "heading": "제한 없는 키", + "description": "서버 소유자가 권한 상속을 사용하도록 설정한 키는 모든 항목에 접근할 수 있습니다. 주의해서 사용하세요." } }, "createKey": { "modal": { - "trigger": "Create API key", + "trigger": "API 키 만들기", "heading": { - "creating": "Create API key", - "created": "Created key" + "creating": "API 키 만들기", + "created": "생성된 키" }, "description": { - "creating": "API keys are long-lived credentials that can be used to authenticate with the API", - "created": "Your new API key has been created. Save the secret somewhere safe, as it will not be shown again" + "creating": "API 키는 API 인증에 사용할 수 있는 장기 유효 인증 정보입니다", + "created": "새 API 키를 만들었습니다. 비밀 키는 다시 표시되지 않으므로 안전한 곳에 저장하세요" }, - "inheritDisclaimer": "This API key will inherit your full permissions, which means it can do anything you can do. Be careful with this setting", - "createdKey": "Created key", - "savedConfirm": "I saved my key", - "submit": "Create key" + "inheritDisclaimer": "이 API 키는 내 모든 권한을 상속하므로 내 계정으로 할 수 있는 모든 작업을 수행할 수 있습니다. 신중하게 설정하세요", + "createdKey": "생성된 키", + "savedConfirm": "키를 저장했습니다", + "submit": "키 만들기" } }, "table": { - "title": "Your keys", - "description": "These are the API keys you have created", + "title": "내 API 키", + "description": "내가 만든 API 키 목록", "actionMenu": { - "inspect": "Inspect", - "delete": "Delete" + "inspect": "상세 보기", + "delete": "삭제" }, "empty": { - "title": "You haven't created any keys yet", - "action": "Create an API key to get started" + "title": "아직 만든 API 키가 없습니다", + "action": "API 키를 만들어 시작하세요" }, - "noExpiration": "No expiration set" + "noExpiration": "만료일 없음" } } }, "app/reader": { - "helmet": "Reader settings", - "title": "Reader settings", - "description": "Default preferences for reading. Some of these are local-only to your current device", + "helmet": "리더 설정", + "title": "리더 설정", + "description": "읽기에 사용할 기본 설정입니다. 일부 설정은 현재 기기에만 적용됩니다", "sections": { "universal": { "sections": { "readingDirection": { - "label": "Reading direction", - "description": "The direction in which the reader will progress through pages" + "label": "읽기 방향", + "description": "리더에서 페이지를 넘기는 방향" } } }, "imageBasedBooks": { - "label": "Image-based books", - "description": "Comic books, manga, and other image-based books", + "label": "이미지 기반 책", + "description": "코믹스, 망가 및 기타 이미지 기반 책", "sections": { "preloadAheadCount": { - "label": "Preload ahead count", - "description": "The number of pages to preload ahead of the current page" + "label": "앞쪽 미리 불러오기", + "description": "현재 페이지보다 앞에서 미리 불러올 페이지 수" }, "preloadBehindCount": { - "label": "Preload behind count", - "description": "The number of pages to preload behind the current page" + "label": "뒤쪽 미리 불러오기", + "description": "현재 페이지보다 뒤에서 미리 불러올 페이지 수" } } }, "textBasedBooks": { - "label": "Ebook reader", - "description": "Default settings for text-based books", + "label": "전자책 리더", + "description": "텍스트 기반 책의 기본 설정", "sections": { "fontSize": { - "label": "Font size", - "description": "The size of the font used in the reader" + "label": "글꼴 크기", + "description": "리더에서 사용할 글꼴의 크기" }, "fontFamily": { - "label": "Font family", - "description": "The font used in the reader" + "label": "글꼴", + "description": "리더에서 사용할 글꼴" }, "lineHeight": { - "label": "Line height", - "description": "The height of the lines in the reader" + "label": "줄 높이", + "description": "리더에서 사용할 줄 높이" } } }, "data": { "sections": { "clearStore": { - "label": "Clear preferences", - "description": "Reset all individual book settings that override the defaults", - "button": "Clear preferences" + "label": "개별 설정 초기화", + "description": "기본값보다 우선하도록 지정한 모든 책별 설정을 초기화합니다", + "button": "개별 설정 초기화" } } } } }, "app/desktop": { - "helmet": "Desktop settings", - "title": "Desktop settings", - "description": "Settings related to the Stump desktop application", + "helmet": "데스크톱 설정", + "title": "데스크톱 설정", + "description": "Stump 데스크톱 애플리케이션 관련 설정", "sections": { "configuredServers": { - "label": "Configured servers", - "description": "The list of Stump servers you can connect to", - "getStarted": "Add a server to get started", + "label": "구성된 서버", + "description": "연결할 수 있는 Stump 서버 목록", + "getStarted": "서버를 추가하여 시작하세요", "unreachableServer": { - "tooltip": "Server unreachable" + "tooltip": "서버에 연결할 수 없음" }, "activeServer": { - "tooltip": "Current server" + "tooltip": "현재 서버" }, "addOrEditServer": { "validation": { - "nameExists": "A server with this name already exists", - "nameForbidden": "This name is reserved", - "urlExists": "A server with this URL already exists" + "nameExists": "같은 이름의 서버가 이미 있습니다", + "nameForbidden": "예약된 이름은 사용할 수 없습니다", + "urlExists": "같은 URL의 서버가 이미 있습니다" }, "fields": { "name": { - "label": "Name", - "description": "A friendly name to identify this server", - "placeholder": "My server" + "label": "이름", + "description": "이 서버를 구분할 이름", + "placeholder": "내 서버" }, "uri": { "label": "URI", - "description": "The URL of the Stump server", + "description": "Stump 서버의 URL", "placeholder": "https://stump.example.com" } } }, "addServer": { - "trigger": "Add server", - "title": "Add a new server", - "description": "Add a new Stump server to the desktop application", - "confirm": "Add server" + "trigger": "서버 추가", + "title": "새 서버 추가", + "description": "데스크톱 애플리케이션에 새 Stump 서버를 추가합니다", + "confirm": "서버 추가" }, "editServer": { - "tooltip": "Edit server", - "title": "Edit server", - "description": "Edit the details of this server", - "confirm": "Save changes" + "tooltip": "서버 편집", + "title": "서버 편집", + "description": "이 서버의 정보를 편집합니다", + "confirm": "변경 사항 저장" }, "deleteServer": { - "tooltip": "Delete server", + "tooltip": "서버 삭제", "confirmation": { - "title": "Delete this server", - "description": "This will remove the server from the desktop application", - "disclaimerActiveServer": "This server is currently active. You will be logged out", - "disclaimerLastServer": "This is the last configured server. You will be logged out and have to reconfigure any servers in the initialization process", - "confirm": "Delete server" + "title": "이 서버 삭제", + "description": "데스크톱 애플리케이션에서 이 서버를 제거합니다", + "disclaimerActiveServer": "현재 사용 중인 서버입니다. 삭제하면 로그아웃됩니다", + "disclaimerLastServer": "마지막으로 구성된 서버입니다. 삭제하면 로그아웃되며 초기 설정에서 서버를 다시 구성해야 합니다", + "confirm": "서버 삭제" } }, "switchToServer": { - "tooltip": "Switch to server", + "tooltip": "서버 전환", "confirmation": { - "title": "Switch to this server", - "description": "This will log you out of your current server", - "confirm": "Switch to server", - "server": "Server", + "title": "이 서버로 전환", + "description": "현재 서버에서 로그아웃하고 선택한 서버로 전환합니다", + "confirm": "서버 전환", + "server": "서버", "uri": "URL" } }, "resetConfiguration": { - "label": "Delete all servers", + "label": "모든 서버 삭제", "description": [ - "Delete all configured servers from the desktop application", - "This action cannot be undone" + "데스크톱 애플리케이션에서 구성된 모든 서버를 삭제합니다", + "이 작업은 되돌릴 수 없습니다" ], "confirmation": { - "trigger": "Delete all servers", - "title": "Delete all servers", - "description": "This will remove all servers from the desktop application", - "disclaimer": "You will be logged out and have to reconfigure any servers in the initialization process", - "confirm": "Delete all servers" + "trigger": "모든 서버 삭제", + "title": "모든 서버 삭제", + "description": "데스크톱 애플리케이션에서 모든 서버를 제거합니다", + "disclaimer": "로그아웃되며 초기 설정에서 서버를 다시 구성해야 합니다", + "confirm": "모든 서버 삭제" } }, "resetTokens": { - "label": "Clear authentication", + "label": "토큰 삭제", "description": [ - "Remove all securely-stored access tokens for your servers", - "You will have to re-authenticate with each server" + "API 키를 포함하여 서버에 대해 안전하게 저장된 모든 토큰을 제거합니다", + "각 서버를 다시 인증하거나 구성해야 합니다" ], "confirmation": { - "trigger": "Clear authentication", - "title": "Clear authentication", - "description": "This will remove all securely-stored access tokens for your servers", - "confirm": "Clear authentication" + "trigger": "토큰 삭제", + "title": "토큰 삭제", + "description": "저장된 모든 토큰을 삭제하시겠습니까?", + "confirm": "토큰 삭제" } } }, "optionalFeatures": { - "label": "Optional features", - "description": "Manage the optional features unique to the desktop application", + "label": "선택 기능", + "description": "데스크톱 애플리케이션에서만 제공하는 선택 기능을 관리합니다", "bundledServer": { - "label": "Bundled server", - "description": "Run the bundled server in the background while the desktop application is running (requires restart)" + "label": "내장 서버", + "description": "데스크톱 애플리케이션이 실행되는 동안 내장 서버를 백그라운드에서 실행합니다(재시작 필요)" }, "discordPresence": { - "label": "Discord Presence", - "description": "Display your Stump activity in Discord using Discord Rich Presence", - "reconnect": "Reconnect to Discord" + "label": "Discord 상태 표시", + "description": "Discord Rich Presence를 사용하여 Stump 활동을 Discord에 표시합니다", + "reconnect": "Discord에 다시 연결" } } } From e125a423646b7cd82e0819f784d3d88e63477ebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:24:18 +0900 Subject: [PATCH 10/54] =?UTF-8?q?feat(i18n):=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=EA=B3=BC=20=EB=A1=9C=EA=B7=B8=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EB=B2=88=EC=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/i18n/src/locales/ko-KR.json | 238 +++++++++++++-------------- 1 file changed, 119 insertions(+), 119 deletions(-) diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 9c8c9944b..0ebf0f991 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -1916,200 +1916,200 @@ } }, "server/general": { - "helmet": "General server settings", - "title": "General settings", - "description": "General settings related to your Stump server instance", + "helmet": "서버 설정", + "title": "서버 설정", + "description": "Stump 서버 인스턴스의 일반 설정", "sections": { "updateAvailable": { - "message": "Your server is not up to date. Please update to the latest version!" + "message": "서버가 최신 버전이 아닙니다. 최신 버전으로 업데이트하세요!" }, "serverInfo": { - "title": "Server information", - "description": "Basic details about your Stump server instance", + "title": "서버 정보", + "description": "Stump 서버 인스턴스의 기본 정보", "build": { - "label": "Build", - "description": "Details about the version and build", + "label": "빌드", + "description": "버전 및 빌드 상세 정보", "version": { - "semver": "Version", - "commitHash": "Exact commit", - "date": "Build date" + "semver": "버전", + "commitHash": "정확한 커밋", + "date": "빌드 날짜" } }, "nonStableChannel": { - "title": "You are running a non-stable build of Stump", + "title": "안정 버전이 아닌 Stump 빌드를 사용 중입니다", "description": [ - "It looks like you are running the", - "build channel" + "현재 사용 중인 빌드 채널:", + "빌드 채널" ] } }, "helpfulLinks": { - "title": "Helpful links", - "description": "Links to resources that may be helpful for managing your Stump server instance", + "title": "도움이 되는 링크", + "description": "Stump 서버 인스턴스를 관리할 때 참고할 수 있는 자료", "links": { - "documentation": "Documentation", - "changelog": "Changelog" + "documentation": "문서", + "changelog": "변경 기록" } }, "serverPublicUrl": { - "label": "Public URL", - "description": "The URL that your server is accessible from outside your network, if applicable. This will enable invite links and other features" + "label": "공개 URL", + "description": "네트워크 외부에서 서버에 접근할 수 있는 URL입니다. 설정하면 초대 링크와 기타 기능을 사용할 수 있습니다" }, "customEmojis": { - "title": "Custom emojis", - "description": "Upload and manage custom image emojis for your server", - "dropzone": "Drop an image or click to select", + "title": "사용자 지정 이모지", + "description": "서버에서 사용할 이미지 이모지를 업로드하고 관리합니다", + "dropzone": "이미지를 끌어다 놓거나 클릭하여 선택하세요", "input": { - "label": "Emoji name", - "placeholder": "e.g. party_parrot" + "label": "이모지 이름", + "placeholder": "예: party_parrot" }, "deleteModal": { - "title": "Delete emoji", - "description": "Are you sure you want to delete this emoji?" - }, - "noCustomEmojis": "No custom emojis have been added yet", - "invalidName": "Please enter a valid name for your emoji", - "uploadError": "Failed to upload emoji", - "uploadSuccess": "Emoji uploaded", - "renameSuccess": "Emoji renamed", - "renameError": "Failed to rename emoji", - "deleteSuccess": "Emoji deleted", - "deleteError": "Failed to delete emoji" + "title": "이모지 삭제", + "description": "이 이모지를 삭제하시겠습니까?" + }, + "noCustomEmojis": "아직 추가한 사용자 지정 이모지가 없습니다", + "invalidName": "유효한 이모지 이름을 입력하세요", + "uploadError": "이모지를 업로드하지 못했습니다", + "uploadSuccess": "이모지를 업로드했습니다", + "renameSuccess": "이모지 이름을 변경했습니다", + "renameError": "이모지 이름을 변경하지 못했습니다", + "deleteSuccess": "이모지를 삭제했습니다", + "deleteError": "이모지를 삭제하지 못했습니다" } } }, "server/logs": { - "helmet": "Logs", - "title": "Logs", - "description": "The logs generated by your Stump server instance", + "helmet": "로그", + "title": "로그", + "description": "Stump 서버 인스턴스에서 생성된 로그", "sections": { "persistedLogs": { - "title": "Persisted logs", - "description": "These logs have been manually persisted to the database, typically associated with a specific job or event", + "title": "저장된 로그", + "description": "데이터베이스에 수동으로 저장된 로그이며, 일반적으로 특정 작업이나 이벤트와 관련되어 있습니다", "table": { "columns": { - "level": "Level", - "message": "Message", - "timestamp": "Timestamp" + "level": "수준", + "message": "메시지", + "timestamp": "기록 일시" }, - "emptyHeading": "No logs to display", - "emptySubtitle": "Your server is either very healthy or very unhealthy" + "emptyHeading": "표시할 로그가 없습니다", + "emptySubtitle": "서버가 아주 안정적이거나 로그를 남길 수 없는 상태일 수 있습니다" }, "deleteLogs": { - "title": "Delete logs", - "description": "Permanently delete all persisted logs from the database", - "disclaimer": "This action cannot be undone", - "confirm": "Delete logs" + "title": "로그 삭제", + "description": "데이터베이스에 저장된 모든 로그를 영구적으로 삭제합니다", + "disclaimer": "이 작업은 되돌릴 수 없습니다", + "confirm": "로그 삭제" } }, "liveLogs": { - "title": "Live logs feed", - "description": "Streamed directly from your Stump server instance in real-time" + "title": "실시간 로그", + "description": "Stump 서버 인스턴스에서 실시간으로 전송되는 로그" } } }, "server/jobs": { - "helmet": "Jobs", - "title": "Jobs", - "description": "Background jobs that run on your Stump server instance", + "helmet": "작업", + "title": "작업", + "description": "Stump 서버 인스턴스에서 실행되는 백그라운드 작업", "sections": { "scheduling": { - "title": "Scheduling", - "description": "Some jobs can be configured to run on a schedule. Changes to this configuration will take effect after a server restart", - "restartRequiredTitle": "Restart required", - "restartRequired": "Changes to this configuration will take effect after a server restart", - "newScheduledJob": "New scheduled job", + "title": "예약 작업", + "description": "일부 작업은 일정에 따라 자동으로 실행되도록 구성할 수 있습니다", + "restartRequiredTitle": "재시작 필요", + "restartRequired": "이 구성의 변경 사항은 서버를 재시작한 후 적용됩니다", + "newScheduledJob": "새 예약 작업", "deleteScheduledJob": { - "title": "Delete scheduled job", - "description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", - "confirm": "Delete" + "title": "예약 작업 삭제", + "description": "\"{{name}}\" 작업을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "confirm": "삭제" }, "dialog": { - "titleCreate": "New scheduled job", - "titleEdit": "Edit scheduled job", - "cancel": "Cancel", - "create": "Create", - "saveChanges": "Save changes" + "titleCreate": "새 예약 작업", + "titleEdit": "예약 작업 편집", + "cancel": "취소", + "create": "만들기", + "saveChanges": "변경 사항 저장" }, "fields": { "name": { - "label": "Name", - "placeholder": "e.g. \"Daily library scan\"" + "label": "이름", + "placeholder": "예: \"매일 라이브러리 스캔\"" }, "schedule": { - "label": "Cron schedule", - "description": "A cron expression to define the frequency", + "label": "Cron 일정", + "description": "실행 주기를 정의하는 Cron 표현식", "placeholder": "0 0 0 * * *" }, "schedulePreset": { - "label": "Preset", - "custom": "Custom", - "everySixHours": "Every 6 hours", - "everyTwelveHours": "Every 12 hours", - "dailyAtMidnight": "Daily at midnight", - "weeklySunday": "Weekly (Sunday)", - "monthlyFirst": "Monthly (1st)" + "label": "사전 설정", + "custom": "사용자 지정", + "everySixHours": "6시간마다", + "everyTwelveHours": "12시간마다", + "dailyAtMidnight": "매일 자정", + "weeklySunday": "매주 일요일", + "monthlyFirst": "매월 1일" }, "kind": { - "label": "Job type", - "libraryScan": "Library Scan", - "metadataRetry": "Metadata Retry" + "label": "작업 유형", + "libraryScan": "라이브러리 스캔", + "metadataRetry": "메타데이터 다시 시도" }, "libraryIds": { - "label": "Libraries to scan", - "description": "Leave empty to scan all libraries" + "label": "스캔할 라이브러리", + "description": "비워 두면 모든 라이브러리를 스캔합니다" }, "statuses": { - "label": "Statuses to retry", - "description": "Which metadata fetch statuses should be retried", - "rateLimited": "Rate Limited", - "failed": "Failed" + "label": "다시 시도할 상태", + "description": "다시 시도할 메타데이터 가져오기 상태", + "rateLimited": "요청 제한됨", + "failed": "실패" }, - "enabled": "Enabled" + "enabled": "사용" }, "row": { - "enabled": "Enabled", - "disabled": "Disabled", - "allLibraries": "All libraries", - "rateLimited": "rate limited", - "lastRun": "Last run at {{date}}", - "edit": "Edit", - "delete": "Delete" + "enabled": "사용", + "disabled": "사용 안 함", + "allLibraries": "모든 라이브러리", + "rateLimited": "요청 제한됨", + "lastRun": "마지막 실행: {{date}}", + "edit": "편집", + "delete": "삭제" }, "toasts": { - "createSuccess": "Scheduled job created", - "createError": "Failed to create scheduled job", - "updateSuccess": "Scheduled job updated", - "updateError": "Failed to update scheduled job", - "deleteSuccess": "Scheduled job deleted", - "deleteError": "Failed to delete scheduled job" + "createSuccess": "예약 작업을 만들었습니다", + "createError": "예약 작업을 만들지 못했습니다", + "updateSuccess": "예약 작업을 수정했습니다", + "updateError": "예약 작업을 수정하지 못했습니다", + "deleteSuccess": "예약 작업을 삭제했습니다", + "deleteError": "예약 작업을 삭제하지 못했습니다" }, "errors": { - "invalidCron": "Schedule must be a valid cron expression" + "invalidCron": "유효한 Cron 표현식을 입력하세요" } }, "history": { - "title": "History", - "description": "A record of jobs that have been run on your Stump server instance", + "title": "기록", + "description": "Stump 서버 인스턴스에서 실행된 작업 기록", "table": { "columns": { - "name": "Type", - "description": "Description", - "status": "Status", - "createdAt": "Started At", - "elapsed": "Elapsed", - "tasks": "Tasks" + "name": "유형", + "description": "설명", + "status": "상태", + "createdAt": "시작 일시", + "elapsed": "소요 시간", + "tasks": "작업" }, - "emptyHeading": "There are no jobs to display", - "emptySubtitle": "You can't have a job if you don't have a job", - "deleteAllMessage": "Job history and statistics may be deleted from the database at any time. This action cannot be undone" + "emptyHeading": "표시할 작업이 없습니다", + "emptySubtitle": "작업을 실행하면 여기에 기록이 표시됩니다", + "deleteAllMessage": "작업 기록과 통계는 언제든 데이터베이스에서 삭제할 수 있습니다. 이 작업은 되돌릴 수 없습니다" }, "deleteJobHistory": { - "title": "Delete job history", - "description": "Permanently delete all job history from the database", - "confirmText": "Are you sure you want to delete all job history?", - "confirm": "Delete", - "emptyState": "No jobs to clear" + "title": "작업 기록 삭제", + "description": "데이터베이스에서 모든 작업 기록을 영구적으로 삭제합니다", + "confirmText": "모든 작업 기록을 삭제하시겠습니까?", + "confirm": "삭제", + "emptyState": "삭제할 작업이 없습니다" } } } From 01efa3d4a993eb395c76c5a75406ea147016f08b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:27:10 +0900 Subject: [PATCH 11/54] =?UTF-8?q?feat(i18n):=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20=EB=AC=B8=EA=B5=AC=20=EB=B2=88=EC=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/i18n/src/locales/ko-KR.json | 184 +++++++++++++-------------- 1 file changed, 92 insertions(+), 92 deletions(-) diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 0ebf0f991..6b5d98e73 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -2115,184 +2115,184 @@ } }, "server/users": { - "helmet": "User management", - "title": "Users", - "description": "Manage the users on this server", + "helmet": "사용자 관리", + "title": "사용자", + "description": "이 서버의 사용자를 관리합니다", "createUser": { - "helmet": "Create user", - "title": "Create user", - "description": "Create a new user on this server" + "helmet": "사용자 만들기", + "title": "사용자 만들기", + "description": "이 서버에 새 사용자를 만듭니다" }, "updateUser": { - "helmet": "Update user", - "title": "Update user", - "description": "Update the details of this user" + "helmet": "사용자 수정", + "title": "사용자 수정", + "description": "이 사용자의 정보를 수정합니다" }, "createOrUpdateForm": { "accessControl": { - "heading": "Access control and restrictions", + "heading": "접근 제어 및 제한", "subtitle": [ - "Configure any restrictions you want to apply to this user. For more information about access control, see the", - "documentation" + "이 사용자에게 적용할 제한을 설정합니다. 접근 제어에 대한 자세한 내용은", + "문서 보기" ], "ageRestriction": { - "label": "Age restriction", - "description": "Restricts the user from accessing content above the set age rating threshold", + "label": "연령 제한", + "description": "설정한 연령 등급을 초과하는 콘텐츠에 사용자가 접근하지 못하도록 제한합니다", "placeholder": "13", "enforceUnset": { - "label": "Enforce on unset", - "description": "If enabled, users will be restricted from accessing content with no age rating available" + "label": "등급 미설정 콘텐츠에도 적용", + "description": "사용하면 연령 등급이 없는 콘텐츠에도 사용자가 접근하지 못합니다" } }, "tagRestriction": { - "label": "Tag restrictions", - "description": "Restricts the user from accessing content with the selected tags", - "placeholder": "Adult, Gore" + "label": "태그 제한", + "description": "선택한 태그가 있는 콘텐츠에 사용자가 접근하지 못하도록 제한합니다", + "placeholder": "성인, 잔혹" } }, "permissions": { "groups": { - "accountManagement": "Account", - "featureAccess": "Feature access", - "fileManagement": "Files", - "emailers": "Emailers", - "emailing": "Emailing", - "libraryManagement": "Library management", - "userManagement": "User management", - "serverManagement": "Server management", - "metadataManagement": "Metadata management" - }, - "heading": "Permissions", + "accountManagement": "계정", + "featureAccess": "기능 접근", + "fileManagement": "파일", + "emailers": "이메일 전송 서비스", + "emailing": "이메일 전송", + "libraryManagement": "라이브러리 관리", + "userManagement": "사용자 관리", + "serverManagement": "서버 관리", + "metadataManagement": "메타데이터 관리" + }, + "heading": "권한", "subtitle": [ - "Select the permissions you want to grant to this user. For more information on what each permission does, see the", - "documentation" + "이 사용자에게 부여할 권한을 선택합니다. 각 권한에 대한 자세한 내용은", + "문서 보기" ], "bookclub": { - "label": "Book Clubs", + "label": "북클럽", "read": { - "label": "Access book club features", - "description": "Allows the user to access book club features, including viewing and joining book clubs" + "label": "북클럽 기능 접근", + "description": "북클럽 보기와 가입을 포함한 북클럽 기능에 접근할 수 있습니다" }, "create": { - "label": "Create book clubs", - "description": "Allows the user to create new book clubs" + "label": "북클럽 만들기", + "description": "새 북클럽을 만들 수 있습니다" } }, "feature": { - "label": "Feature Opt-In", + "label": "선택 기능", "api_keys": { - "label": "API keys", - "description": "Allow the user to manage their own API keys" + "label": "API 키", + "description": "자신의 API 키를 관리할 수 있습니다" }, "koreader_sync": { - "label": "KoReader sync", - "description": "Allow the user to access the KoReader sync integration" + "label": "KoReader 동기화", + "description": "KoReader 동기화 연동에 접근할 수 있습니다" } }, "file": { - "label": "File Management", + "label": "파일 관리", "explorer": { - "label": "File Explorer", - "description": "Allows the user to access the Library File Explorer.\nContent restriction is not supported when this feature is granted" + "label": "파일 탐색기", + "description": "여러 파일 탐색기에 접근할 수 있습니다.\n이 기능을 허용하면 콘텐츠 제한을 보장할 수 없습니다" }, "download": { - "label": "Download files", - "description": "Allows the user to download files from the server" + "label": "파일 다운로드", + "description": "서버에서 파일을 다운로드할 수 있습니다" }, "upload": { - "label": "Upload files", - "description": "Allows the user to upload files to the server" + "label": "파일 업로드", + "description": "서버에 파일을 업로드할 수 있습니다" } }, "emailer": { - "label": "Emailers", + "label": "이메일 전송 서비스", "read": { - "label": "Access emailers", - "description": "Allows the user to view the configured emailers" + "label": "이메일 전송 서비스 접근", + "description": "구성된 이메일 전송 서비스를 볼 수 있습니다" }, "create": { - "label": "Create emailers", - "description": "Allows the user to create new emailers" + "label": "이메일 전송 서비스 만들기", + "description": "새 이메일 전송 서비스를 만들 수 있습니다" }, "manage": { - "label": "Manage emailers", - "description": "Allows the user to manage existing emailers" + "label": "이메일 전송 서비스 관리", + "description": "기존 이메일 전송 서비스를 관리할 수 있습니다" } }, "email": { - "label": "Emailing", + "label": "이메일 전송", "send": { - "label": "Send emails", - "description": "Allows the user to send emails from the server to any configured aliases" + "label": "이메일 보내기", + "description": "서버에서 구성된 별칭으로 이메일을 보낼 수 있습니다" }, "arbitrary_send": { - "label": "Send arbitrary emails", - "description": "Allows the user to send emails from the server to any email address" + "label": "임의 주소로 이메일 보내기", + "description": "서버에서 원하는 이메일 주소로 이메일을 보낼 수 있습니다" } }, "library": { - "label": "Library Management", + "label": "라이브러리 관리", "create": { - "label": "Create libraries", - "description": "Allows the user to create new libraries.\nIncludes permissions to edit and scan" + "label": "라이브러리 만들기", + "description": "새 라이브러리를 만들 수 있습니다.\n편집 및 스캔 권한을 포함합니다" }, "scan": { - "label": "Scan libraries", - "description": "Allows the user to initiate scans for existing libraries" + "label": "라이브러리 스캔", + "description": "기존 라이브러리의 스캔을 시작할 수 있습니다" }, "edit": { - "label": "Edit libraries", - "description": "Allows the user to edit basic details for existing libraries" + "label": "라이브러리 편집", + "description": "기존 라이브러리의 기본 정보를 편집할 수 있습니다" }, "manage": { - "label": "Manage libraries", - "description": "Allows the user to manage advanced settings for existing libraries.\nIncludes permissions to edit and scan" + "label": "라이브러리 관리", + "description": "기존 라이브러리의 고급 설정을 관리할 수 있습니다.\n편집 및 스캔 권한을 포함합니다" }, "delete": { - "label": "Delete libraries", - "description": "Allows the user to delete existing libraries.\nIncludes permissions to edit, manage, and scan" + "label": "라이브러리 삭제", + "description": "기존 라이브러리를 삭제할 수 있습니다.\n편집, 관리 및 스캔 권한을 포함합니다" } }, "server": { - "label": "Server Management", + "label": "서버 관리", "manage": { - "label": "Manage server", - "description": "Allows the user to manage the server.\nIncludes *many* other permissions" + "label": "서버 관리", + "description": "서버를 관리할 수 있습니다.\n다른 여러 권한을 포함합니다" } }, "user": { - "label": "User Management", + "label": "사용자 관리", "read": { - "label": "Read users", - "description": "Allows the user to query other users on the server. This is required for some other features, e.g. excluding users from accessing a library" + "label": "사용자 조회", + "description": "서버의 다른 사용자를 조회할 수 있습니다. 라이브러리 접근에서 특정 사용자를 제외하는 등의 기능에 필요합니다" }, "manage": { - "label": "Manage users", - "description": "Allows the user to manage other users on the server.\nIncludes permissions to create and update" + "label": "사용자 관리", + "description": "서버의 다른 사용자를 관리할 수 있습니다.\n조회, 생성 및 수정 권한을 포함합니다" } }, "smartlist": { - "label": "Smart Lists", + "label": "스마트 목록", "read": { - "label": "Access smart list feature", - "description": "Allows the user to access smart lists features" + "label": "스마트 목록 기능 접근", + "description": "스마트 목록 기능에 접근할 수 있습니다. 세부 권한은 아직 구현되지 않아 자신의 목록을 조회, 생성 및 수정하는 권한이 모두 포함됩니다" } } }, "validation": { - "ageRestrictionTooLow": "Age restriction cannot be less than 0", - "missingUsername": "Username is required", - "missingPassword": "Password is required", - "usernameAlreadyExists": "Username already exists" + "ageRestrictionTooLow": "연령 제한은 0보다 작을 수 없습니다", + "missingUsername": "사용자 이름을 입력하세요", + "missingPassword": "비밀번호를 입력하세요", + "usernameAlreadyExists": "이미 사용 중인 사용자 이름입니다" }, - "createSubmitButton": "Create user", - "updateSubmitButton": "Update user" + "createSubmitButton": "사용자 만들기", + "updateSubmitButton": "사용자 수정" }, "loginActivity": { "deleteConfirmation": { - "title": "Delete activity", - "description": "All records of login activity will be permanently deleted from the database", - "confirm": "Delete" + "title": "활동 기록 삭제", + "description": "모든 로그인 활동 기록을 데이터베이스에서 영구적으로 삭제합니다", + "confirm": "삭제" } } }, From 148df7f9b7e1476320e66952be07d4e572518131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:30:16 +0900 Subject: [PATCH 12/54] =?UTF-8?q?feat(i18n):=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EC=84=A4=EC=A0=95=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/i18n/src/locales/ko-KR.json | 260 ++++++++++++++------------- 1 file changed, 131 insertions(+), 129 deletions(-) diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 6b5d98e73..092fd2f5f 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -2297,251 +2297,253 @@ } }, "server/email": { - "helmet": "Email settings", - "title": "Email", - "description": "Settings related to configuring an SMTP emailer for your Stump server instance", + "helmet": "이메일 설정", + "title": "이메일", + "description": "Stump 서버 인스턴스의 SMTP 이메일 전송 서비스 설정", "sections": { "emailers": { - "title": "SMTP emailers", - "description": "The clients you have configured for sending emails from your Stump server instance", - "singleInstanceDisclaimer": "While Stump supports multiple SMTP configurations in the backend, only one can be configured on the UI. This will be improved in the future", + "title": "SMTP 이메일 전송 서비스", + "description": "Stump 서버 인스턴스에서 이메일을 보내도록 구성한 클라이언트", + "singleInstanceDisclaimer": "Stump 백엔드는 여러 SMTP 구성을 지원하지만 UI에서는 하나만 구성할 수 있습니다. 향후 개선될 예정입니다", "list": { - "emptyHeading": "No SMTP emailers configured", - "primaryEmailer": "Primary emailer", + "emptyHeading": "구성된 SMTP 이메일 전송 서비스가 없습니다", + "primaryEmailer": "기본 이메일 전송 서비스", "sendHistory": { - "heading": "Send history", - "description": "A record of emails sent from this emailer", + "heading": "전송 기록", + "description": "이 이메일 전송 서비스에서 보낸 이메일 기록", "table": { "columns": { - "to": "To", - "subject": "Subject", - "status": "Status", - "createdAt": "Sent at" + "to": "받는 사람", + "subject": "제목", + "status": "상태", + "createdAt": "보낸 일시" } }, - "emptyHeading": "No history", - "emptySubtitle": "The history of this emailer is empty. It has either not been used or the history has been cleared" + "emptyHeading": "전송 기록이 없습니다", + "emptySubtitle": "이 이메일 전송 서비스를 아직 사용하지 않았거나 기록을 삭제했습니다" } } }, "devices": { - "title": "Email devices", - "description": "The device aliases you have configured for receiving emails from your Stump server instance", - "addDevice": "Add device alias", + "title": "이메일 기기", + "description": "Stump 서버 인스턴스의 이메일을 수신하도록 구성한 기기 별칭", + "addDevice": "기기 별칭 추가", "addOrUpdateDevice": { "title": { - "create": "Create device alias", - "update": "Update device alias" + "create": "기기 별칭 만들기", + "update": "기기 별칭 수정" }, "name": { - "label": "Name", - "description": "A friendly name to uniquely identify this device alias" + "label": "이름", + "description": "이 기기 별칭을 구분할 고유한 이름" }, "email": { - "label": "Email", - "description": "The email address this alias will be associated with" + "label": "이메일", + "description": "이 별칭과 연결할 이메일 주소" }, "forbidden": { - "label": "Forbidden", - "description": "When enabled, no user may send emails to the address associated with this alias" + "label": "전송 금지", + "description": "사용하면 어떤 사용자도 이 별칭과 연결된 주소로 이메일을 보낼 수 없습니다" }, "submit": { - "create": "Create device alias", - "update": "Update device alias" + "create": "기기 별칭 만들기", + "update": "기기 별칭 수정" } }, "table": { - "emptyHeading": "No devices registered", - "emptySubtitle": "Create a new device alias for future use" + "emptyHeading": "등록된 기기가 없습니다", + "emptySubtitle": "사용할 기기 별칭을 새로 만드세요" } } }, "createOrUpdateForm": { "name": { - "label": "Name", - "description": "A friendly name to uniquely identify this emailer" + "label": "이름", + "description": "이 이메일 전송 서비스를 구분할 고유한 이름" }, "smtpSettings": { - "heading": "SMTP settings", - "description": "The SMTP-specific settings for this emailer" + "heading": "SMTP 설정", + "description": "이 이메일 전송 서비스의 SMTP 관련 설정" }, "smtpProvider": { - "label": "SMTP provider preset", - "description": "The provider of the SMTP server. This is used to prefill some common settings, but doesn't capture all providers" + "label": "SMTP 공급자 사전 설정", + "description": "SMTP 서버 공급자를 선택하면 일반 설정을 미리 채웁니다. 모든 공급자가 포함되어 있지는 않습니다" }, "smtpHost": { - "label": "SMTP host", - "description": "The hostname of the SMTP server" + "label": "SMTP 호스트", + "description": "SMTP 서버의 호스트 이름" }, "smtpPort": { - "label": "SMTP port", - "description": "The port of the SMTP server" + "label": "SMTP 포트", + "description": "SMTP 서버의 포트" }, "tlsEnabled": { - "label": "TLS enabled", - "description": "If enabled, the connection to the SMTP server will be encrypted" + "label": "TLS 사용", + "description": "사용하면 SMTP 서버와의 연결을 암호화합니다" }, "username": { - "label": "Username", - "description": "The username for authenticating with the SMTP server. This is typically the email address" + "label": "사용자 이름", + "description": "SMTP 서버 인증에 사용할 사용자 이름입니다. 일반적으로 이메일 주소를 사용합니다" }, "password": { - "label": "Password", - "description": "The password for authenticating with the SMTP server. If you're using Gmail, you may need to generate an app-specific password" + "label": "비밀번호", + "description": "SMTP 서버 인증에 사용할 비밀번호입니다. Gmail을 사용한다면 앱 비밀번호를 만들어야 할 수 있습니다" }, "senderSettings": { - "heading": "Sender settings", - "description": "The settings for the sender of the email" + "heading": "보내는 사람 설정", + "description": "이메일의 보내는 사람에 대한 설정" }, "senderDisplayName": { - "label": "Sender display name", - "description": "The name that will be displayed to recipients" + "label": "보내는 사람 표시 이름", + "description": "받는 사람에게 표시할 이름" }, "senderEmail": { - "label": "Sender email address", - "description": "The email address that will be displayed to recipients" + "label": "보내는 사람 이메일 주소", + "description": "받는 사람에게 표시할 이메일 주소" }, "additionalSettings": { - "heading": "Additional settings", - "description": "Additional settings for the emailer" + "heading": "추가 설정", + "description": "이메일 전송 서비스의 추가 설정" }, "maxAttachmentSize": { - "label": "Max attachment size", - "description": "The maximum size, in bytes, that an attachment can be" + "label": "최대 첨부 파일 크기", + "description": "첨부 파일의 최대 크기(바이트)" }, "submit": { - "create": "Create emailer", - "update": "Update emailer" + "create": "이메일 전송 서비스 만들기", + "update": "이메일 전송 서비스 수정" }, "testEmailer": { - "button": "Send test email", - "recipientLabel": "Test recipient", - "recipientDescription": "An email address to send the test email to", - "success": "Test email sent successfully", + "button": "테스트 이메일 보내기", + "recipientLabel": "테스트 수신자", + "recipientDescription": "테스트 이메일을 보낼 이메일 주소", + "success": "테스트 이메일을 보냈습니다", "error": { - "title": "Failed to send test email", - "description": "Check your SMTP settings and try again" + "title": "테스트 이메일을 보내지 못했습니다", + "description": "SMTP 설정을 확인하고 다시 시도하세요" } } }, "createEmailer": { - "helmet": "Create emailer", - "title": "Create emailer", - "description": "Create a new SMTP emailer for your Stump server instance" + "helmet": "이메일 전송 서비스 만들기", + "title": "이메일 전송 서비스 만들기", + "description": "Stump 서버 인스턴스에 새 SMTP 이메일 전송 서비스를 만듭니다" }, "updateEmailer": { - "helmet": "Update emailer", - "title": "Update emailer", - "description": "Update the details of this SMTP emailer" + "helmet": "이메일 전송 서비스 수정", + "title": "이메일 전송 서비스 수정", + "description": "이 SMTP 이메일 전송 서비스의 정보를 수정합니다" } }, "server/metadataIntegrations": { - "helmet": "Metadata integrations", - "title": "Metadata integrations", - "addProvider": "Add provider", - "noProviders": "No providers configured yet. Add one to get started", + "helmet": "메타데이터 연동", + "title": "메타데이터 연동", + "addProvider": "공급자 추가", + "createProviderError": "공급자를 만들지 못했습니다", + "updateProviderError": "공급자를 수정하지 못했습니다", + "noProviders": "아직 구성된 공급자가 없습니다. 공급자를 추가하여 시작하세요", "providers": { - "title": "Providers", - "description": "Configure external metadata providers to fetch and enrich your library data" + "title": "공급자", + "description": "라이브러리 데이터를 가져오고 보완할 외부 메타데이터 공급자를 구성합니다" }, "providerForm": { "apiToken": { - "label": "API token", - "description": "The API token for authenticating with this provider", - "validationError": "The auto-validation failed for this token", - "validationRequestError": "The validation request failed", - "validationRequestErrorUnknown": "An unknown error occurred during validation", - "hardcoverStatusError": "Hardcover API responded with status {{status}}", - "hardcoverValidationError": "Hardcover API responded with an error: {{message}}", - "noBearerPrefixRequired": "Remove the 'Bearer ' prefix from the API token" + "label": "API 토큰", + "description": "이 공급자 인증에 사용할 API 토큰", + "validationError": "이 토큰의 자동 검증에 실패했습니다", + "validationRequestError": "검증 요청에 실패했습니다", + "validationRequestErrorUnknown": "검증 중 알 수 없는 오류가 발생했습니다", + "providerStatusError": "공급자가 {{status}} 상태로 응답했습니다", + "providerError": "공급자가 오류로 응답했습니다: {{message}}", + "noBearerPrefixRequired": "API 토큰에서 'Bearer ' 접두사를 제거하세요" }, "apiTokenExpiresAt": { - "label": "API token expiration", - "description": "The date when the API token will expire (optional)" + "label": "API 토큰 만료일", + "description": "API 토큰이 만료되는 날짜(선택 사항)" }, "enableProvider": { - "label": "Enable provider", - "description": "It will be used for fetching if enabled" + "label": "공급자 사용", + "description": "사용하면 메타데이터를 가져올 때 이 공급자를 이용합니다" }, "autoApplyConfig": { "enabled": { - "label": "Enable auto-apply", - "description": "Skip manual approval and apply likely matches" + "label": "자동 적용 사용", + "description": "직접 승인하는 단계를 건너뛰고 일치 가능성이 높은 결과를 적용합니다" }, "threshold": { - "label": "Confidence threshold", - "description": "Minimum confidence score (0-1) to auto-apply a match", - "disclaimer": "The computed confidence score is a best-effort estimation. It may not always be accurate" + "label": "신뢰도 기준값", + "description": "일치 결과를 자동 적용할 최소 신뢰도 점수(0~1)", + "disclaimer": "계산된 신뢰도 점수는 가능한 범위에서 추정한 값이므로 항상 정확하지는 않을 수 있습니다" }, "strategy": { - "label": "Merge strategy", + "label": "병합 방식", "options": { - "FILL_GAPS": "Accept missing fields", - "FILL_AND_MERGE_LISTS": "Accept missing fields and merge lists", - "PREFER_EXTERNAL": "Prefer external data" + "FILL_GAPS": "비어 있는 필드 채우기", + "FILL_AND_MERGE_LISTS": "비어 있는 필드 채우기 및 목록 병합", + "PREFER_EXTERNAL": "외부 데이터 우선" } } } }, - "providerEnabled": "This provider is enabled", - "providerDisabled": "This provider is disabled", - "providerTokenExpiresSoon": "This provider's token will expire soon", - "autoApplyOn": "Auto-apply on", - "autoApplyOff": "Auto-apply off", + "providerEnabled": "이 공급자는 사용 중입니다", + "providerDisabled": "이 공급자는 사용하지 않습니다", + "providerTokenExpiresSoon": "이 공급자의 토큰이 곧 만료됩니다", + "autoApplyOn": "자동 적용 사용", + "autoApplyOff": "자동 적용 안 함", "editDialog": { - "tooltip": "Edit provider" + "tooltip": "공급자 편집" }, "deleteDialog": { - "title": "Delete provider", - "description": "Are you sure you want to delete {{providerName}}? This action cannot be undone." + "title": "공급자 삭제", + "description": "{{providerName}} 공급자를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." } }, "server/tags": { - "helmet": "Tag management", - "title": "Tags", - "description": "Create and manage tags for organizing your content", + "helmet": "태그 관리", + "title": "태그", + "description": "콘텐츠를 정리할 태그를 만들고 관리합니다", "sections": { "table": { - "title": "Tags", - "description": "All tags available on this server", + "title": "태그", + "description": "이 서버에서 사용할 수 있는 모든 태그", "empty": { - "title": "No tags", - "action": "Create your first tag to get started" + "title": "태그가 없습니다", + "action": "첫 태그를 만들어 시작하세요" }, "columns": { - "name": "Name" + "name": "이름" }, "actionMenu": { - "rename": "Rename" + "rename": "이름 변경" } }, "createTag": { - "trigger": "Create tag", + "trigger": "태그 만들기", "modal": { - "heading": "Create new tag", - "description": "Tags can be assigned to books, series, and libraries", + "heading": "새 태그 만들기", + "description": "태그는 책, 시리즈, 라이브러리에 지정할 수 있습니다", "name": { - "label": "Name", - "placeholder": "Enter tag name" + "label": "이름", + "placeholder": "태그 이름 입력" }, - "submit": "Create" + "submit": "만들기" } }, "renameTag": { "modal": { - "heading": "Rename tag", - "description": "Change the name of this tag across all assigned items", + "heading": "태그 이름 변경", + "description": "이 태그가 지정된 모든 항목에서 태그 이름을 변경합니다", "name": { - "label": "Name", - "placeholder": "Enter new tag name" + "label": "이름", + "placeholder": "새 태그 이름 입력" }, - "submit": "Rename" + "submit": "이름 변경" } }, "deleteTag": { - "title": "Delete tag", - "description": "This tag will be removed from all books, series, and libraries. This cannot be undone.", - "confirm": "Delete" + "title": "태그 삭제", + "description": "모든 책, 시리즈, 라이브러리에서 이 태그를 제거합니다. 이 작업은 되돌릴 수 없습니다.", + "confirm": "삭제" } } } From 0b9b128edf97f0afc72b0c166013b593669852d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:33:14 +0900 Subject: [PATCH 13/54] =?UTF-8?q?feat(i18n):=20=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=EC=99=80=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=20=EB=AC=B8=EA=B5=AC=20=EB=B2=88=EC=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/i18n/src/locales/ko-KR.json | 310 +++++++++++++-------------- 1 file changed, 155 insertions(+), 155 deletions(-) diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 092fd2f5f..df09cef8b 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -2895,41 +2895,41 @@ "mobileApp": { "addOrEditServer": { "addServer": { - "title": "Add Server", - "subtitle": "Configure a new server to access your content" + "title": "서버 추가", + "subtitle": "콘텐츠에 접근할 새 서버를 구성합니다" }, "editServer": { - "title": "Edit Server", - "subtitle": "Edit Server" + "title": "서버 편집", + "subtitle": "서버 편집" }, - "kind": "Kind", + "kind": "유형", "customHeaders": { - "label": "Custom Headers", - "addHeader": "Add header", - "invalidHeader": "Invalid header" + "label": "사용자 지정 헤더", + "addHeader": "헤더 추가", + "invalidHeader": "유효하지 않은 헤더" }, "auth": { - "label": "Auth", + "label": "인증", "default": { - "label": "Login", - "description": "You will be prompted to login when accessing content as needed" + "label": "로그인", + "description": "콘텐츠에 접근할 때 필요하면 로그인 화면이 표시됩니다" }, "token": { - "label": "Token", - "placeholder": "Raw Token" - }, - "basic": "Basic" - }, - "serverNamePlaceholder": "My Server", - "catalogUrl": "Catalog URL", - "failedToConnect": "Failed to connect to server", - "didConnect": "Connected", - "checkConnection": "Check Connection", - "setAsDefaultServer": "Set as Default Server", - "enableOPDS": "Enable OPDS", + "label": "토큰", + "placeholder": "원본 토큰" + }, + "basic": "기본 인증" + }, + "serverNamePlaceholder": "내 서버", + "catalogUrl": "카탈로그 URL", + "failedToConnect": "서버에 연결하지 못했습니다", + "didConnect": "연결됨", + "checkConnection": "연결 확인", + "setAsDefaultServer": "기본 서버로 설정", + "enableOPDS": "OPDS 사용", "validation": { - "nameAlreadyExists": "Name already exists", - "cannotSetAuthorizationHeader": "Cannot set Authorization header" + "nameAlreadyExists": "이미 사용 중인 이름입니다", + "cannotSetAuthorizationHeader": "Authorization 헤더는 설정할 수 없습니다" } }, "bookActions": { @@ -2981,61 +2981,61 @@ "identifiers": "Identifiers" }, "common": { - "book": "Book", - "cancel": "Cancel", - "chapter": "Chapter", - "clear": "Clear", - "completed": "Completed", - "confirm": "Confirm", - "continue": "Continue", - "content": "Content", - "details": "Details", - "delete": "Delete", - "dismiss": "Dismiss", - "download": "Download", - "downloaded": "Downloaded", - "downloads": "Downloads", - "done": "Done", - "edit": "Edit", - "enabled": "Enabled", - "enable": "Enable", - "error": "Error", - "favorite": "Favorite", - "finished": "Finished", - "grid": "Grid", - "list": "List", - "lastCompleted": "Last Completed", - "name": "Name", - "none": "None", - "notAvailable": "Not Available", - "options": "Options", - "page": "Page", - "pages": "Pages", - "pageXOfY": "Page {{current}} of {{total}}", - "password": "Password", - "progress": "Progress", - "publication": "Publication", - "read": "Read", - "readAgain": "Read Again", - "readTime": "Reading Time", - "readthrough": "Readthrough", - "readMore": "Read More", - "refresh": "Refresh", - "save": "Save", - "select": "Select", - "settings": "Settings", - "servers": "Servers", - "thisBook": "this book", - "thisServer": "this server", - "unknown": "Unknown", - "unknownTitle": "Untitled", + "book": "책", + "cancel": "취소", + "chapter": "장", + "clear": "지우기", + "completed": "완료", + "confirm": "확인", + "continue": "계속", + "content": "콘텐츠", + "details": "상세 정보", + "delete": "삭제", + "dismiss": "닫기", + "download": "다운로드", + "downloaded": "다운로드됨", + "downloads": "다운로드", + "done": "완료", + "edit": "편집", + "enabled": "사용", + "enable": "사용", + "error": "오류", + "favorite": "즐겨찾기", + "finished": "완독", + "grid": "격자", + "list": "목록", + "lastCompleted": "마지막 완료", + "name": "이름", + "none": "없음", + "notAvailable": "사용할 수 없음", + "options": "옵션", + "page": "페이지", + "pages": "페이지", + "pageXOfY": "{{total}}페이지 중 {{current}}페이지", + "password": "비밀번호", + "progress": "진행률", + "publication": "출판물", + "read": "읽기", + "readAgain": "다시 읽기", + "readTime": "읽은 시간", + "readthrough": "회독", + "readMore": "더 읽기", + "refresh": "새로고침", + "save": "저장", + "select": "선택", + "settings": "설정", + "servers": "서버", + "thisBook": "이 책", + "thisServer": "이 서버", + "unknown": "알 수 없음", + "unknownTitle": "제목 없음", "url": "URL", - "username": "Username", - "value": "Value", - "version": "Version" + "username": "사용자 이름", + "value": "값", + "version": "버전" }, "errors": { - "unknown": "Unknown error" + "unknown": "알 수 없는 오류" }, "epubMenu": { "tableOfContents": "Table of Contents", @@ -3058,89 +3058,89 @@ "unknownSeriesName": "series" }, "emptyState": { - "noStumpServers": "No Stump servers added", - "noOPDSServers": "No OPDS servers added", - "noServers": "No Servers Configured", - "cta": "Get started by adding a server to access your book collections", - "seeDocumentation": "See Documentation", + "noStumpServers": "추가된 Stump 서버가 없습니다", + "noOPDSServers": "추가된 OPDS 서버가 없습니다", + "noServers": "구성된 서버가 없습니다", + "cta": "서버를 추가하고 책 컬렉션을 이용해 보세요", + "seeDocumentation": "문서 보기", "noSmartLists": { - "label": "No lists returned", - "description": "Once created they will appear here" + "label": "표시할 목록이 없습니다", + "description": "목록을 만들면 여기에 표시됩니다" } }, "tabs": { - "servers": "Servers", - "localLibrary": "Library", - "settings": "Settings", - "home": "Home", - "browse": "Browse", - "clubs": "Clubs", - "search": "Search" + "servers": "서버", + "localLibrary": "라이브러리", + "settings": "설정", + "home": "홈", + "browse": "둘러보기", + "clubs": "북클럽", + "search": "검색" }, "localLibrary": { - "title": "Local Library", + "title": "로컬 라이브러리", "emptyState": { "all": { - "title": "Nothing to Read Yet", - "description": "Once you have books for offline reading, they will appear here" + "title": "아직 읽을 책이 없습니다", + "description": "오프라인으로 읽을 책이 생기면 여기에 표시됩니다" }, "imported": { - "title": "No Imported Books", - "description": "Import books to read them offline" + "title": "가져온 책이 없습니다", + "description": "오프라인으로 읽을 책을 가져오세요" }, "server": { - "title": "No Downloaded Books", - "description": "Download books from your library to read them offline" + "title": "다운로드한 책이 없습니다", + "description": "오프라인으로 읽을 책을 라이브러리에서 다운로드하세요" } }, "downloadQueue": { - "downloading": "Downloading", - "failed": "Failed", - "nothingDownloading": "Nothing is being downloaded", - "noActiveDownloads": "No active downloads", - "noFailedDownloads": "No failed downloads", - "downloadStarting": "Starting..." + "downloading": "다운로드 중", + "failed": "실패", + "nothingDownloading": "다운로드 중인 항목이 없습니다", + "noActiveDownloads": "진행 중인 다운로드가 없습니다", + "noFailedDownloads": "실패한 다운로드가 없습니다", + "downloadStarting": "시작 중..." }, "downloadsHeaderMenu": { "deleteAllDownloads": { - "label": "Delete Books", - "confirmation": "Are you sure you want to delete your local library?", - "disclaimer": "This action cannot be undone." + "label": "책 삭제", + "confirmation": "로컬 라이브러리를 삭제하시겠습니까?", + "disclaimer": "이 작업은 되돌릴 수 없습니다." }, - "seeProblems": "See Problems ({{problemCount}})", - "hideCurated": "Hide Curated", - "showCurated": "Show Curated", - "attemptSync": "Attempt Sync" + "seeProblems": "문제 보기({{problemCount}}개)", + "hideCurated": "추천 항목 숨기기", + "showCurated": "추천 항목 표시", + "attemptSync": "동기화 시도" }, "downloadsHeaderSortMenu": { "sortBy": { - "NAME": "Name", - "ADDED_AT": "Date Added", - "SERIES": "Series" + "NAME": "이름", + "ADDED_AT": "추가일", + "SERIES": "시리즈" }, "sortDirection": { - "ASC": "Ascending", - "DESC": "Descending" + "ASC": "오름차순", + "DESC": "내림차순" }, "sortDirectionText": { "ASC": "A → Z", "DESC": "Z → A" }, "sortDirectionDate": { - "ASC": "Oldest First", - "DESC": "Newest First" + "ASC": "오래된 순", + "DESC": "최신 순" }, "sourceFilter": { - "all": "All Books", - "server": "Downloaded", - "imported": "Imported" + "all": "모든 책", + "server": "다운로드", + "imported": "가져온 항목" } }, "downloadProblemsSheet": { - "title": "Failed Downloads", - "noFailedDownloads": "No failed downloads", - "retryAll": "Retry All", - "dismissAll": "Dismiss All" + "title": "실패한 다운로드", + "noFailedDownloads": "실패한 다운로드가 없습니다", + "retryAll": "모두 다시 시도", + "dismissAll": "모두 닫기" } }, "progressSync": { @@ -3152,9 +3152,9 @@ "syncFailed": "Failed to sync offline progress" }, "search": { - "label": "Search This Server", - "description": "Find specific content on this server", - "placeholder": "Search" + "label": "이 서버 검색", + "description": "이 서버에서 특정 콘텐츠를 찾습니다", + "placeholder": "검색" }, "settings": { "preferences": { @@ -3317,28 +3317,28 @@ }, "stumpServer": { "browse": { - "books": "Books", - "favorites": "Favorites", - "files": "Files", - "libraries": "Libraries", - "series": "Series", - "smartLists": "Smart Lists" + "books": "책", + "favorites": "즐겨찾기", + "files": "파일", + "libraries": "라이브러리", + "series": "시리즈", + "smartLists": "스마트 목록" }, "continueReading": { - "label": "Continue Reading", - "emptyText": "No books in progress" + "label": "이어 읽기", + "emptyText": "읽는 중인 책이 없습니다" }, "onDeck": { - "label": "Your Next Read", - "emptyText": "No books on deck" + "label": "다음에 읽을 책", + "emptyText": "읽을 예정인 책이 없습니다" }, "recentlyAddedSeries": { - "label": "Recently Added Series", - "emptyText": "No recently added series" + "label": "최근 추가된 시리즈", + "emptyText": "최근 추가된 시리즈가 없습니다" }, "recentlyAddedBooks": { - "label": "Recently Added Books", - "emptyText": "No recently added books" + "label": "최근 추가된 책", + "emptyText": "최근 추가된 책이 없습니다" } }, "readerSettings": { @@ -3493,24 +3493,24 @@ } }, "libraryType": { - "BOOK": "Books", - "COMIC": "Comics", - "LIGHT_NOVEL": "Light Novels", - "MANGA": "Manga", - "MANHWA": "Manhwa", - "MIXED": "Mixed", - "WEBTOON": "Webtoons", - "WEB_NOVEL": "Web Novels" + "BOOK": "도서", + "COMIC": "코믹스", + "LIGHT_NOVEL": "라이트 노벨", + "MANGA": "망가", + "MANHWA": "한국 만화", + "MIXED": "혼합", + "WEBTOON": "웹툰", + "WEB_NOVEL": "웹소설" }, "savedServerActions": { - "clearCache": "Clear Cache", + "clearCache": "캐시 지우기", "discardTokens": { - "label": "Discard Tokens", - "description": "Affects login tokens only" + "label": "토큰 폐기", + "description": "로그인 토큰에만 적용됩니다" }, "deleteServer": { - "title": "Delete Server", - "confirmation": "Are you sure you want to delete {{serverName}}?" + "title": "서버 삭제", + "confirmation": "{{serverName}} 서버를 삭제하시겠습니까?" } }, "filtering": { From 69023ce7e1d8d52ba91ca1e6b5515623c43b97ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:36:43 +0900 Subject: [PATCH 14/54] =?UTF-8?q?feat(i18n):=20=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20=EB=A6=AC=EB=8D=94=20=EB=AC=B8=EA=B5=AC=20=EB=B2=88=EC=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/i18n/src/locales/ko-KR.json | 262 +++++++++++++-------------- 1 file changed, 131 insertions(+), 131 deletions(-) diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index df09cef8b..955d9408d 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -2934,51 +2934,51 @@ }, "bookActions": { "markAsRead": { - "label": "Mark as Read", - "confirmation": "Are you sure you want to mark {{bookTitle}} as read?", + "label": "읽음으로 표시", + "confirmation": "{{bookTitle}}을(를) 읽음으로 표시하시겠습니까?", "failure": "책을 읽음으로 표시하지 못했습니다" }, "clearProgress": { - "label": "Clear Progress", - "confirmation": "Are you sure you want to clear your progress for {{bookTitle}}?", + "label": "진행률 지우기", + "confirmation": "{{bookTitle}}의 읽기 진행률을 지우시겠습니까?", "failure": "현재 진행률을 삭제하지 못했습니다" }, "deleteReadHistory": { - "label": "Delete Read History", - "confirmation": "Are you sure you want to delete your read history for {{bookTitle}}?", + "label": "읽기 기록 삭제", + "confirmation": "{{bookTitle}}의 읽기 기록을 삭제하시겠습니까?", "failure": "읽기 기록을 삭제하지 못했습니다" }, "deleteBook": { - "label": "Delete Book", - "confirmation": "Are you sure you want to delete {{bookTitle}}?" + "label": "책 삭제", + "confirmation": "{{bookTitle}}을(를) 삭제하시겠습니까?" }, "deleteDownload": { - "label": "Delete Download", - "confirmation": "Are you sure you want to delete the downloaded file for {{bookTitle}}?" + "label": "다운로드 삭제", + "confirmation": "{{bookTitle}}의 다운로드 파일을 삭제하시겠습니까?" }, - "goToLibrary": "Go to Library", - "goToSeries": "Go to Series", - "seeDetails": "See Details" + "goToLibrary": "라이브러리로 이동", + "goToSeries": "시리즈로 이동", + "seeDetails": "상세 정보 보기" }, "bookMetadata": { - "ageRating": "Age Rating", - "downloadedAt": "Downloaded", - "characters": "Characters", - "format": "Format", - "genres": "Genres", - "language": "Language", - "publisher": "Publisher", - "size": "Size", - "volume": "Volume", - "writers": "Writers", - "colorists": "Colorists", - "inkers": "Inkers", - "letterers": "Letterers", - "coverArtists": "Cover Artists", - "links": "Links", - "year": "Year", - "identifier": "Identifier", - "identifiers": "Identifiers" + "ageRating": "연령 등급", + "downloadedAt": "다운로드 일시", + "characters": "등장인물", + "format": "형식", + "genres": "장르", + "language": "언어", + "publisher": "출판사", + "size": "크기", + "volume": "권", + "writers": "글 작가", + "colorists": "채색가", + "inkers": "잉크 담당", + "letterers": "레터링 담당", + "coverArtists": "표지 작가", + "links": "링크", + "year": "연도", + "identifier": "식별자", + "identifiers": "식별자" }, "common": { "book": "책", @@ -3038,24 +3038,24 @@ "unknown": "알 수 없는 오류" }, "epubMenu": { - "tableOfContents": "Table of Contents", - "bookmarksAndAnnotations": "Bookmarks & Annotations", - "appearance": "Appearance" + "tableOfContents": "목차", + "bookmarksAndAnnotations": "북마크 및 주석", + "appearance": "모양" }, "formatSeriesPosition": { "none": { - "positionWithTotal": "{{position}} of {{total}} in {{seriesName}}", - "position": "{{position}} in {{seriesName}}" + "positionWithTotal": "{{seriesName}}의 {{total}}개 중 {{position}}번째", + "position": "{{seriesName}}의 {{position}}번째" }, "book": { - "positionWithTotal": "Book {{position}} of {{total}} in {{seriesName}}", - "position": "Book {{position}} in {{seriesName}}" + "positionWithTotal": "{{seriesName}}의 {{total}}권 중 {{position}}권", + "position": "{{seriesName}}의 {{position}}권" }, "hashtag": { - "positionWithTotal": "#{{position}} of {{total}} in {{seriesName}}", - "position": "#{{position}} in {{seriesName}}" + "positionWithTotal": "{{seriesName}}의 {{total}}개 중 #{{position}}", + "position": "{{seriesName}}의 #{{position}}" }, - "unknownSeriesName": "series" + "unknownSeriesName": "시리즈" }, "emptyState": { "noStumpServers": "추가된 Stump 서버가 없습니다", @@ -3145,11 +3145,11 @@ }, "progressSync": { "skippedSync": { - "skippedOneServer": "Sync skipped for {{skippedCount}} server", - "skippedMultipleServers": "Sync skipped for {{skippedCount}} servers", - "explanation": "Some servers use authentication methods that cannot be automatically refreshed" + "skippedOneServer": "서버 {{skippedCount}}개의 동기화를 건너뛰었습니다", + "skippedMultipleServers": "서버 {{skippedCount}}개의 동기화를 건너뛰었습니다", + "explanation": "일부 서버는 자동으로 갱신할 수 없는 인증 방식을 사용합니다" }, - "syncFailed": "Failed to sync offline progress" + "syncFailed": "오프라인 진행률을 동기화하지 못했습니다" }, "search": { "label": "이 서버 검색", @@ -3342,85 +3342,85 @@ } }, "readerSettings": { - "title": "Reader Settings", - "overrideGlobalSettings": "Override Global Settings", + "title": "이미지 리더 설정", + "overrideGlobalSettings": "전체 설정보다 우선 적용", "sections": { - "mode": "Mode", - "imageOptions": "Image Options", - "controls": "Controls" + "mode": "방식", + "imageOptions": "이미지 옵션", + "controls": "조작" }, "readingMode": { - "label": "Flow", + "label": "페이지 표시 방식", "options": { - "PAGED": "Paged", - "CONTINUOUS_HORIZONTAL": "Scroll (Horizontal)", - "CONTINUOUS_VERTICAL": "Scroll (Vertical)" + "PAGED": "페이지 넘김", + "CONTINUOUS_HORIZONTAL": "가로 스크롤", + "CONTINUOUS_VERTICAL": "세로 스크롤" } }, "readingDirection": { - "label": "Direction", + "label": "읽기 방향", "options": { - "RTL": "Right to left", - "LTR": "Left to right" + "RTL": "오른쪽에서 왼쪽", + "LTR": "왼쪽에서 오른쪽" } }, "doublePageBehavior": { - "label": "Double Paged", + "label": "두 페이지 보기", "options": { - "always": "Always", - "auto": "Auto", - "off": "Off" + "always": "항상", + "auto": "자동", + "off": "사용 안 함" } }, - "separateSecondPage": "Separate Second Page", + "separateSecondPage": "두 번째 페이지 분리", "imageScaling": { - "label": "Scaling", + "label": "크기 조정", "options": { - "AUTO": "Auto", - "HEIGHT": "Fit Height", - "WIDTH": "Fit Width", - "NONE": "None" + "AUTO": "자동", + "HEIGHT": "높이에 맞춤", + "WIDTH": "너비에 맞춤", + "NONE": "조정 안 함" } }, - "allowDownscaling": "Downscaling", - "tapSidesToNavigate": "Tap Sides to Navigate", - "volumeButtonsNavigate": "Volume Buttons Navigate", + "allowDownscaling": "이미지 축소 허용", + "tapSidesToNavigate": "화면 가장자리를 눌러 이동", + "volumeButtonsNavigate": "음량 버튼으로 이동", "footerControls": { - "label": "Footer", + "label": "하단 조작부", "options": { - "images": "Image Gallery", - "slider": "Slider" + "images": "이미지 갤러리", + "slider": "슬라이더" } }, "readingTimer": { - "label": "Reading Timer", - "resetTimer": "Reset Timer" + "label": "읽기 타이머", + "resetTimer": "타이머 초기화" }, - "allSettings": "All Settings" + "allSettings": "모든 설정" }, "tableOfContents": { - "goToPageX": "Go to page {{pageNumber}}", - "goToPagePlaceholder": "Go to page...", + "goToPageX": "{{pageNumber}}페이지로 이동", + "goToPagePlaceholder": "페이지로 이동...", "errors": { - "pageDoesNotExist": "Page {{page}} does not exist", - "invalidPage": "'{{page}}' is not a page number" + "pageDoesNotExist": "{{page}}페이지가 없습니다", + "invalidPage": "'{{page}}'은(는) 페이지 번호가 아닙니다" }, - "showCurrentChapter": "Show Current Chapter" + "showCurrentChapter": "현재 장 표시" }, "annotationsSheet": { "tabs": { - "all": "All", - "bookmarks": "Bookmarks", - "highlights": "Highlights", - "notes": "Notes" + "all": "전체", + "bookmarks": "북마크", + "highlights": "하이라이트", + "notes": "메모" } }, "epubSettings": { - "title": "Epub Reader Settings", + "title": "EPUB 리더 설정", "typeface": { - "label": "Typeface", + "label": "글꼴", "options": { - "system": "System", + "system": "시스템", "opendyslexic": "OpenDyslexic", "literata": "Literata", "atkinsonHyperlegible": "Atkinson Hyperlegible", @@ -3428,68 +3428,68 @@ "bitter": "Bitter" } }, - "fontSize": "Font Size", + "fontSize": "글꼴 크기", "fontWeight": { - "label": "Font Weight", + "label": "글꼴 굵기", "options": { - "light": "Light", - "normal": "Normal", - "medium": "Medium", - "bold": "Bold" + "light": "얇게", + "normal": "보통", + "medium": "중간", + "bold": "굵게" } }, - "textNormalization": "Text Normalization", - "verticalText": "Vertical Text", + "textNormalization": "텍스트 정규화", + "verticalText": "세로쓰기", "readingDirection": { - "label": "Reading Direction", + "label": "읽기 방향", "options": { - "rtl": "Right to left", - "ltr": "Left to right" + "rtl": "오른쪽에서 왼쪽", + "ltr": "왼쪽에서 오른쪽" } }, "columns": { - "label": "Columns", + "label": "단", "options": { - "auto": "Auto", - "single": "Single", - "double": "Double" + "auto": "자동", + "single": "한 단", + "double": "두 단" } }, - "pageMargins": "Page Margins", + "pageMargins": "페이지 여백", "imageFilter": { - "label": "Image Filter", + "label": "이미지 필터", "options": { - "none": "None", - "darken": "Darken", - "invert": "Invert" + "none": "없음", + "darken": "어둡게", + "invert": "색상 반전" } }, - "publisherStyles": "Publisher Styles", - "volumeButtonsNavigate": "Volume Buttons Navigate", + "publisherStyles": "출판사 스타일", + "volumeButtonsNavigate": "음량 버튼으로 이동", "textAlignment": { - "label": "Text Alignment", + "label": "텍스트 정렬", "options": { - "start": "Start", - "left": "Left", - "center": "Center", - "right": "Right", - "justify": "Justify" - } - }, - "typeScale": "Type Scale", - "lineHeight": "Line Height", - "paragraphIndent": "Paragraph Indent", - "paragraphSpacing": "Paragraph Spacing", - "wordSpacing": "Word Spacing", - "letterSpacing": "Letter Spacing", - "hyphens": "Hyphens", - "ligatures": "Ligatures", + "start": "시작", + "left": "왼쪽", + "center": "가운데", + "right": "오른쪽", + "justify": "양쪽 맞춤" + } + }, + "typeScale": "글자 크기 배율", + "lineHeight": "줄 높이", + "paragraphIndent": "문단 들여쓰기", + "paragraphSpacing": "문단 간격", + "wordSpacing": "단어 간격", + "letterSpacing": "글자 간격", + "hyphens": "하이픈 연결", + "ligatures": "합자", "customizeTheme": { - "placeholder": "Theme name", - "background": "Background", - "text": "Text", - "highlight": "Highlight", - "premadeThemes": "Premade themes" + "placeholder": "테마 이름", + "background": "배경", + "text": "텍스트", + "highlight": "강조", + "premadeThemes": "기본 제공 테마" } }, "libraryType": { From f9bc60ae2423a229451db4d1fa976535601ea557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:38:50 +0900 Subject: [PATCH 15/54] =?UTF-8?q?feat(i18n):=20=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=EA=B3=BC=20=EC=A0=95=EB=A0=AC=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EB=B2=88=EC=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/i18n/src/locales/ko-KR.json | 210 +++++++++++++-------------- 1 file changed, 105 insertions(+), 105 deletions(-) diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 955d9408d..a92dac59a 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -3158,161 +3158,161 @@ }, "settings": { "preferences": { - "label": "Preferences", + "label": "환경설정", "appTheme": { - "label": "Theme", + "label": "테마", "options": { - "light": "Light", - "dark": "Dark" + "light": "라이트", + "dark": "다크" } }, "appPrimaryColor": { - "label": "Accent", + "label": "강조색", "options": { - "chromaScale": "Chroma Scale", + "chromaScale": "색상표", "hues": { - "red": "Red", - "orange": "Orange", - "amber": "Amber", - "yellow": "Yellow", - "lime": "Lime", - "green": "Green", - "emerald": "Emerald", - "teal": "Teal", - "cyan": "Cyan", - "sky": "Sky", - "blue": "Blue", - "indigo": "Indigo", - "violet": "Violet", - "purple": "Purple", - "fuchsia": "Fuchsia", - "pink": "Pink", - "rose": "Rose", - "slate": "Slate", - "gray": "Gray", - "zinc": "Zinc", - "neutral": "Neutral", - "stone": "Stone" + "red": "빨강", + "orange": "주황", + "amber": "호박색", + "yellow": "노랑", + "lime": "라임", + "green": "초록", + "emerald": "에메랄드", + "teal": "청록", + "cyan": "시안", + "sky": "하늘색", + "blue": "파랑", + "indigo": "남색", + "violet": "보라", + "purple": "자주", + "fuchsia": "푸시아", + "pink": "분홍", + "rose": "장미색", + "slate": "슬레이트", + "gray": "회색", + "zinc": "아연색", + "neutral": "무채색", + "stone": "돌색" } } }, - "appLanguage": "Language", - "defaultServer": "Default Server", - "thumbnailRatio": "Thumbnail Ratio", + "appLanguage": "언어", + "defaultServer": "기본 서버", + "thumbnailRatio": "썸네일 비율", "thumbnailPlaceholder": { - "label": "Thumbnail Placeholder", + "label": "썸네일 플레이스홀더", "options": { - "grayscale": "Grayscale", - "averageColor": "Average Color", - "colorful": "Colorful", + "grayscale": "회색조", + "averageColor": "평균 색상", + "colorful": "다채로운 색상", "thumbhash": "Thumbhash" } }, "thumbnailResizeMode": { - "label": "Thumbnail Resize", + "label": "썸네일 크기 조정", "options": { - "cover": "Cover", - "stretch": "Stretch", - "fit": "Fit" + "cover": "채우기", + "stretch": "늘이기", + "fit": "맞추기" } } }, "reading": { - "label": "Reading", - "preferNativePdf": "Prefer Native PDF", - "preferMinimalReader": "Prefer Minimal Reader", - "disableDismissGesture": "Disable Dismiss Gesture", - "incognitoReading": "Incognito Reading" + "label": "읽기", + "preferNativePdf": "기본 PDF 리더 우선", + "preferMinimalReader": "간소화된 리더 우선", + "disableDismissGesture": "닫기 제스처 사용 안 함", + "incognitoReading": "비공개 읽기" }, "stump": { "label": "Stump", - "description": "Stump features are optional and can be turned off if you do not need them", - "bookClubsEnabled": "Book Club Features" + "description": "Stump 기능은 선택 사항이며 필요하지 않으면 끌 수 있습니다", + "bookClubsEnabled": "북클럽 기능" }, - "autoSyncLocalData": "Auto-Sync Local Data", + "autoSyncLocalData": "로컬 데이터 자동 동기화", "textCase": { - "label": "Text Case", + "label": "영문 대소문자", "options": { - "lowerCase": "Lowercase", - "sentenceCase": "Sentence Case", - "titleCase": "Title Case" + "lowerCase": "소문자", + "sentenceCase": "문장형", + "titleCase": "제목형" } }, - "tintListBackground": "Tint List Backgrounds", + "tintListBackground": "목록 배경에 강조색 적용", "management": { - "label": "Management", + "label": "관리", "dataUsage": { - "label": "Data Usage", - "nonStumpData": "Non-Stump Data", - "serversTotal": "Servers Total", - "totalFiles": "Total Files", - "totalSize": "Total Size", + "label": "데이터 사용량", + "nonStumpData": "Stump 외 데이터", + "serversTotal": "서버 합계", + "totalFiles": "전체 파일", + "totalSize": "전체 크기", "deleteDownloads": { - "label": "Delete Downloads", - "confirmation": "Are you sure you want to delete all downloads from {{serverName}}?", + "label": "다운로드 삭제", + "confirmation": "{{serverName}}에서 다운로드한 모든 파일을 삭제하시겠습니까?", "deleteFailed": { - "title": "Deletion Failed", - "description": "There was an error deleting all downloads from {{serverName}}" + "title": "삭제 실패", + "description": "{{serverName}}의 모든 다운로드를 삭제하는 중 오류가 발생했습니다" } }, - "noDownloads": "No downloaded files for this server", + "noDownloads": "이 서버에서 다운로드한 파일이 없습니다", "storedPreferences": { - "label": "Stored Preferences", - "description": "Miscellaneous data like book preferences, reading timers, etc" + "label": "저장된 환경설정", + "description": "책별 설정, 읽기 타이머 등 기타 데이터" }, - "clearPreferences": "Clear Preferences" + "clearPreferences": "환경설정 지우기" } }, "debug": { - "label": "Debug", + "label": "디버그", "clearCache": { - "label": "Clear Cache", + "label": "캐시 지우기", "memory": { - "button": "Memory", - "success": "Memory Cache Cleared" + "button": "메모리", + "success": "메모리 캐시를 지웠습니다" }, "disk": { - "button": "Disk", - "success": "Disk Cache Cleared" + "button": "디스크", + "success": "디스크 캐시를 지웠습니다" } }, "deleteDatabase": { - "label": "Delete Database", + "label": "데이터베이스 삭제", "success": { - "title": "Database Deleted", - "description": "Please restart the app" + "title": "데이터베이스를 삭제했습니다", + "description": "앱을 다시 시작하세요" }, "error": { - "title": "Deletion Failed", - "description": "There was an error deleting the database" + "title": "삭제 실패", + "description": "데이터베이스를 삭제하는 중 오류가 발생했습니다" } }, - "performanceMonitor": "Performance Monitor", - "reduceAnimations": "Reduce Animations", - "maskUrls": "Mask URLs", + "performanceMonitor": "성능 모니터", + "reduceAnimations": "애니메이션 줄이기", + "maskUrls": "URL 가리기", "displayLanguageKeys": { - "label": "Display Language Keys", + "label": "번역 키 표시", "options": { - "none": "None", - "abbreviated": "Abbreviated", - "full": "Full" + "none": "표시 안 함", + "abbreviated": "축약", + "full": "전체" } }, "debugAnalytics": { - "label": "Debug Analytics", - "description": "Send additional debug-related events to help troubleshoot issues" + "label": "디버그 분석", + "description": "문제 해결에 도움이 되도록 디버그 관련 이벤트를 추가로 전송합니다" } }, "contactInfo": { - "label": "Contact", - "email": "Email", + "label": "문의", + "email": "이메일", "discord": "Discord", "github": "GitHub" }, "supportInfo": { - "buildInformation": "Build Information", - "version": "Version: {{version}}", - "suuportIdentifier": "Support Identifier: {{supportID}}" + "buildInformation": "빌드 정보", + "version": "버전: {{version}}", + "suuportIdentifier": "지원 식별자: {{supportID}}" } }, "stumpServer": { @@ -3514,31 +3514,31 @@ } }, "filtering": { - "notStarted": "Not Started", - "currentlyReading": "Currently Reading", - "finished": "Finished" + "notStarted": "읽기 전", + "currentlyReading": "읽는 중", + "finished": "완독" }, "sorting": { - "labelEllipsis": "Sort By...", + "labelEllipsis": "정렬 기준...", "sortField": { - "NAME": "Name", - "ADDED_AT": "Date Added", - "CREATED_AT": "Date Added", - "NUMBER": "Series Position", - "SERIES": "Series", - "YEAR": "Year Published" + "NAME": "이름", + "ADDED_AT": "추가일", + "CREATED_AT": "추가일", + "NUMBER": "시리즈 순서", + "SERIES": "시리즈", + "YEAR": "출판 연도" }, "sortDirection": { - "ASC": "Ascending", - "DESC": "Descending" + "ASC": "오름차순", + "DESC": "내림차순" }, "sortDirectionString": { "ASC": "A → Z", "DESC": "Z → A" }, "sortDirectionDate": { - "ASC": "Oldest First", - "DESC": "Newest First" + "ASC": "오래된 순", + "DESC": "최신 순" }, "sortDirectionNumber": { "ASC": "1 → 10", From a2507a39691c5e88bd685378cf1b09d747cd1959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:44:39 +0900 Subject: [PATCH 16/54] =?UTF-8?q?feat(i18n):=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=20=EC=97=B4=20=EA=B5=AC=EC=84=B1=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/book/table/columns.tsx | 41 ++++++++-------- .../table/EntityTableColumnConfiguration.tsx | 38 +++++++++------ .../items/table/TableColumnsBottomDrawer.tsx | 39 ++++++++------- .../smartList/items/table/groupColumns.tsx | 4 +- .../smartList/items/table/mediaColumns.tsx | 35 +++++++------- packages/i18n/src/locales/en-US.json | 47 +++++++++++++++++++ packages/i18n/src/locales/ko-KR.json | 47 +++++++++++++++++++ 7 files changed, 179 insertions(+), 72 deletions(-) diff --git a/packages/browser/src/components/book/table/columns.tsx b/packages/browser/src/components/book/table/columns.tsx index 46f8c64c8..d89726382 100644 --- a/packages/browser/src/components/book/table/columns.tsx +++ b/packages/browser/src/components/book/table/columns.tsx @@ -419,28 +419,27 @@ export const columnMap = { position: positionColumn, } as Record> -// TODO: localization keys instead of hardcoded strings export const columnOptionMap: Record = { - added: 'Added', - age_rating: 'Age Rating', - artists: 'Artists', - characters: 'Characters', - colorists: 'Colorists', - cover: 'Cover', - extension: 'Extension', - file_size: 'File Size', - genres: 'Genres', - inkers: 'Inkers', - letterers: 'Letterers', - links: 'Links', - name: 'Name', - pages: 'Pages', - pencillers: 'Pencillers', - published: 'Published', - publisher: 'Publisher', - volume: 'Volume', - writers: 'Writers', - position: 'Position', + added: 'tableColumns.labels.added', + age_rating: 'tableColumns.labels.age_rating', + artists: 'tableColumns.labels.artists', + characters: 'tableColumns.labels.characters', + colorists: 'tableColumns.labels.colorists', + cover: 'tableColumns.labels.cover', + extension: 'tableColumns.labels.extension', + file_size: 'tableColumns.labels.file_size', + genres: 'tableColumns.labels.genres', + inkers: 'tableColumns.labels.inkers', + letterers: 'tableColumns.labels.letterers', + links: 'tableColumns.labels.links', + name: 'tableColumns.labels.name', + pages: 'tableColumns.labels.pages', + pencillers: 'tableColumns.labels.pencillers', + published: 'tableColumns.labels.published', + publisher: 'tableColumns.labels.publisher', + volume: 'tableColumns.labels.volume', + writers: 'tableColumns.labels.writers', + position: 'tableColumns.labels.position', } export const defaultColumns = [ diff --git a/packages/browser/src/components/table/EntityTableColumnConfiguration.tsx b/packages/browser/src/components/table/EntityTableColumnConfiguration.tsx index e38ef5278..b56044123 100644 --- a/packages/browser/src/components/table/EntityTableColumnConfiguration.tsx +++ b/packages/browser/src/components/table/EntityTableColumnConfiguration.tsx @@ -63,8 +63,8 @@ export default function EntityTableColumnConfiguration({ entity, configuration, }, [entity]) const resolved = useMemo( - () => resolveConfiguration(configuration, columnMap), - [configuration, columnMap], + () => resolveConfiguration(configuration, columnMap, t), + [configuration, columnMap, t], ) const toBuckets = (columns: ResolvedColumn[]): [ResolvedColumn[], ResolvedColumn[]] => @@ -262,13 +262,13 @@ export default function EntityTableColumnConfiguration({ entity, configuration, return ( setIsOpen(false)} onOpen={() => setIsOpen(true)} trigger={ - + setIsOpen(true)}> @@ -284,7 +284,7 @@ export default function EntityTableColumnConfiguration({ entity, configuration, variant="outline" className="w-full" onClick={() => { - setBuckets(toBuckets(resolveConfiguration(configuration, columnMap))) + setBuckets(toBuckets(resolveConfiguration(configuration, columnMap, t))) setIsOpen(false) }} > @@ -304,14 +304,18 @@ export default function EntityTableColumnConfiguration({ entity, configuration, >
handleMoveColumn(id, false)} /> ) => +const resolveConfiguration = ( + configuration: ColumnSort[], + columnMap: Record, + translate: (key: string) => string, +) => Object.entries(columnMap) - .map(([key, label], idx) => { + .map(([key, translationKey], idx) => { const configPosition = configuration.findIndex((column) => column.id === key) return { id: key, - label, + label: translate(translationKey), position: configPosition === -1 ? configuration.length + idx : configPosition, selected: configPosition !== -1, } @@ -390,6 +398,8 @@ function ColumnOverlay({ column }: { column: ReturnType identifiers: string[] @@ -398,14 +408,14 @@ type ColumnBucketProps = { function ColumnBucket({ title, + moveLabel, + emptyLabel, containerId, items, identifiers, onMoveToOtherList, }: ColumnBucketProps) { const { setNodeRef } = useDroppable({ id: containerId }) - const moveLabel = title === 'Visible' ? 'Move to hidden' : 'Move to visible' - return (
@@ -430,7 +440,7 @@ function ColumnBucket({ {items.length === 0 && (
- Drop columns here + {emptyLabel}
)} diff --git a/packages/browser/src/scenes/smartList/items/table/TableColumnsBottomDrawer.tsx b/packages/browser/src/scenes/smartList/items/table/TableColumnsBottomDrawer.tsx index 962b30c05..e1c95e697 100644 --- a/packages/browser/src/scenes/smartList/items/table/TableColumnsBottomDrawer.tsx +++ b/packages/browser/src/scenes/smartList/items/table/TableColumnsBottomDrawer.tsx @@ -8,6 +8,7 @@ import { Text, ToolTip, } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { TableProperties } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' @@ -17,6 +18,7 @@ import { getColumnOptionMap as getGroupColumnOptionMap } from './groupColumns' import { columnOptionMap as mediaColumnOptionMap } from './mediaColumns' export default function TableColumnsBottomDrawer() { + const { t } = useLocaleContext() const [isOpen, setIsOpen] = useState(false) const { list: { defaultGrouping }, @@ -69,10 +71,10 @@ export default function TableColumnsBottomDrawer() { () => Object.entries(mediaColumnOptionMap).map(([key, label]) => ({ isSelected: bookColumnState[key] ?? false, - label, + label: t(label), value: key, })), - [bookColumnState], + [bookColumnState, t], ) const isGroupedBySeries = defaultGrouping === 'BY_SERIES' @@ -81,13 +83,13 @@ export default function TableColumnsBottomDrawer() { const all = getGroupColumnOptionMap(isGroupedBySeries) return Object.entries(all).map(([key, label]) => ({ isSelected: groupByEntityColumnState[key] ?? false, - label, + label: t(label), value: key, })) } return [] - }, [isGrouped, isGroupedBySeries, groupByEntityColumnState]) + }, [isGrouped, isGroupedBySeries, groupByEntityColumnState, t]) /** * A callback to update the local state when a book column is selected or deselected @@ -118,7 +120,7 @@ export default function TableColumnsBottomDrawer() { .map(([id], idx) => ({ id, position: idx })) if (!bookColumns.length || (isGrouped && !groupColumns.length)) { - toast.error('You must select at least one column') + toast.error(t('tableColumns.configuration.selectionRequired')) return } @@ -181,7 +183,7 @@ export default function TableColumnsBottomDrawer() { return ( - + setIsOpen(true)}> @@ -191,15 +193,17 @@ export default function TableColumnsBottomDrawer() {
- Table configuration - Choose which columns to display or hide + {t('tableColumns.configuration.drawerTitle')} + + {t('tableColumns.configuration.drawerDescription')} +
{isGrouped && (
- + - This only affects the parent table by which books are grouped + {t('tableColumns.configuration.groupColumnsDescription')}
{groupColumnOptions.map(({ label, value, isSelected }) => ( @@ -216,9 +220,9 @@ export default function TableColumnsBottomDrawer() { )}
- + - This only affects the nested book table(s) + {t('tableColumns.configuration.bookColumnsDescription')}
{bookColumnOptions.map(({ label, value, isSelected }) => ( @@ -235,10 +239,11 @@ export default function TableColumnsBottomDrawer() {
- + - Sorting by a column removes the previous sort. This setting allows combining them, - instead + {t('tableColumns.configuration.multiSortDescription')}
@@ -253,10 +258,10 @@ export default function TableColumnsBottomDrawer() {
- +
diff --git a/packages/browser/src/scenes/smartList/items/table/groupColumns.tsx b/packages/browser/src/scenes/smartList/items/table/groupColumns.tsx index 2a39aa444..93230952d 100644 --- a/packages/browser/src/scenes/smartList/items/table/groupColumns.tsx +++ b/packages/browser/src/scenes/smartList/items/table/groupColumns.tsx @@ -101,12 +101,12 @@ export const getColumnMap = (isGroupedBySeries: boolean) => }) as Record> const staticColumnOptionMap: Record = { - books: 'Books', + books: 'tableColumns.labels.books', } export const getColumnOptionMap = (isGroupedBySeries: boolean) => ({ - name: `Name (${isGroupedBySeries ? 'series' : 'library'})`, + name: isGroupedBySeries ? 'tableColumns.labels.seriesName' : 'tableColumns.labels.libraryName', ...staticColumnOptionMap, }) as Record diff --git a/packages/browser/src/scenes/smartList/items/table/mediaColumns.tsx b/packages/browser/src/scenes/smartList/items/table/mediaColumns.tsx index 0472055ce..1d6de827c 100644 --- a/packages/browser/src/scenes/smartList/items/table/mediaColumns.tsx +++ b/packages/browser/src/scenes/smartList/items/table/mediaColumns.tsx @@ -345,25 +345,24 @@ export const columnMap = { writers: writersColumn, } as Record> -// TODO: localization keys instead of hardcoded strings export const columnOptionMap: Record = { - added: 'Added', - age_rating: 'Age Rating', - artists: 'Artists', - characters: 'Characters', - colorists: 'Colorists', - cover: 'Cover', - genres: 'Genres', - inkers: 'Inkers', - letterers: 'Letterers', - links: 'Links', - name: 'Name', - pages: 'Pages', - pencillers: 'Pencillers', - published: 'Published', - publisher: 'Publisher', - volume: 'Volume', - writers: 'Writers', + added: 'tableColumns.labels.added', + age_rating: 'tableColumns.labels.age_rating', + artists: 'tableColumns.labels.artists', + characters: 'tableColumns.labels.characters', + colorists: 'tableColumns.labels.colorists', + cover: 'tableColumns.labels.cover', + genres: 'tableColumns.labels.genres', + inkers: 'tableColumns.labels.inkers', + letterers: 'tableColumns.labels.letterers', + links: 'tableColumns.labels.links', + name: 'tableColumns.labels.name', + pages: 'tableColumns.labels.pages', + pencillers: 'tableColumns.labels.pencillers', + published: 'tableColumns.labels.published', + publisher: 'tableColumns.labels.publisher', + volume: 'tableColumns.labels.volume', + writers: 'tableColumns.labels.writers', } export const defaultColumns = [ diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index 2ca3b1ac7..b0e3892db 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -2730,6 +2730,53 @@ }, "unknownError": "An unknown error occurred" }, + "tableColumns": { + "configuration": { + "title": "Configure columns", + "description": "Adjust which columns are displayed in book-exploration tables", + "tooltip": "Configure columns", + "adjustTooltip": "Adjust columns", + "visible": "Visible", + "hidden": "Hidden", + "moveToHidden": "Move to hidden", + "moveToVisible": "Move to visible", + "dropHere": "Drop columns here", + "drawerTitle": "Table configuration", + "drawerDescription": "Choose which columns to display or hide", + "groupColumns": "Group columns", + "groupColumnsDescription": "This only affects the parent table by which books are grouped", + "bookColumns": "Book columns", + "bookColumnsDescription": "This only affects the nested book table(s)", + "multiSort": "Enable multi-sort", + "multiSortDescription": "Sorting by a column removes the previous sort. This setting allows combining them instead", + "selectionRequired": "You must select at least one column" + }, + "labels": { + "added": "Added", + "age_rating": "Age Rating", + "artists": "Artists", + "characters": "Characters", + "colorists": "Colorists", + "cover": "Cover", + "extension": "Extension", + "file_size": "File Size", + "genres": "Genres", + "inkers": "Inkers", + "letterers": "Letterers", + "links": "Links", + "name": "Name", + "pages": "Pages", + "pencillers": "Pencillers", + "position": "Position", + "published": "Published", + "publisher": "Publisher", + "volume": "Volume", + "writers": "Writers", + "books": "Books", + "seriesName": "Name (series)", + "libraryName": "Name (library)" + } + }, "userPermissions": { "ACCESS_API_KEYS": { "label": "API keys", diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index a92dac59a..3206165e9 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -2730,6 +2730,53 @@ }, "unknownError": "알 수 없는 오류가 발생했습니다" }, + "tableColumns": { + "configuration": { + "title": "열 구성", + "description": "책 탐색 표에 표시할 열을 조정합니다", + "tooltip": "열 구성", + "adjustTooltip": "열 조정", + "visible": "표시", + "hidden": "숨김", + "moveToHidden": "숨김으로 이동", + "moveToVisible": "표시로 이동", + "dropHere": "열을 여기에 놓으세요", + "drawerTitle": "표 구성", + "drawerDescription": "표시하거나 숨길 열을 선택합니다", + "groupColumns": "그룹 열", + "groupColumnsDescription": "책을 그룹화하는 상위 표에만 적용됩니다", + "bookColumns": "책 열", + "bookColumnsDescription": "중첩된 책 표에만 적용됩니다", + "multiSort": "다중 정렬 사용", + "multiSortDescription": "열을 기준으로 정렬하면 이전 정렬이 제거됩니다. 이 설정을 사용하면 여러 정렬 기준을 함께 적용할 수 있습니다", + "selectionRequired": "열을 하나 이상 선택해야 합니다" + }, + "labels": { + "added": "추가일", + "age_rating": "연령 등급", + "artists": "작가", + "characters": "등장인물", + "colorists": "채색가", + "cover": "표지", + "extension": "확장자", + "file_size": "파일 크기", + "genres": "장르", + "inkers": "잉크 담당", + "letterers": "레터링 담당", + "links": "링크", + "name": "이름", + "pages": "페이지 수", + "pencillers": "작화가", + "position": "순서", + "published": "출판일", + "publisher": "출판사", + "volume": "권", + "writers": "글 작가", + "books": "책", + "seriesName": "이름(시리즈)", + "libraryName": "이름(라이브러리)" + } + }, "userPermissions": { "ACCESS_API_KEYS": { "label": "API 키 접근", From 1f64021dcacefe7479d354488d2dc62600a84115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:47:41 +0900 Subject: [PATCH 17/54] =?UTF-8?q?feat(i18n):=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=20=ED=97=A4=EB=8D=94=20=EB=AC=B8=EA=B5=AC=20=EA=B5=AD=EC=A0=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/book/table/columns.tsx | 109 +++--------------- .../components/table/TableColumnHeader.tsx | 17 +++ .../smartList/items/table/groupColumns.tsx | 82 ++++++++----- .../smartList/items/table/mediaColumns.tsx | 89 +++----------- packages/i18n/src/locales/en-US.json | 8 +- packages/i18n/src/locales/ko-KR.json | 8 +- 6 files changed, 121 insertions(+), 192 deletions(-) create mode 100644 packages/browser/src/components/table/TableColumnHeader.tsx diff --git a/packages/browser/src/components/book/table/columns.tsx b/packages/browser/src/components/book/table/columns.tsx index d89726382..441558059 100644 --- a/packages/browser/src/components/book/table/columns.tsx +++ b/packages/browser/src/components/book/table/columns.tsx @@ -7,6 +7,7 @@ import { format, intlFormat } from 'date-fns' import paths from '@/paths' +import TableColumnHeader from '../../table/TableColumnHeader' import { BookCardFragment } from '../BookCard' import BookLinksCell from './BookLinksCell' import CoverImageCell from './CoverImageCell' @@ -69,11 +70,7 @@ const nameColumn = columnHelper.accessor(({ resolvedName }) => resolvedName, { ), enableGlobalFilter: true, enableSorting: true, - header: () => ( - - Name - - ), + header: () => , id: MediaModelOrdering.Name, // TODO (graphql): should this be resovledName?, sorting by `name` is different from sorting by `resolvedName` minSize: 285, }) @@ -86,11 +83,7 @@ const pagesColumn = columnHelper.accessor('pages', { ), enableGlobalFilter: true, enableSorting: true, - header: () => ( - - Pages - - ), + header: () => , id: MediaModelOrdering.Pages, size: 60, }) @@ -103,11 +96,7 @@ const fileSizeColumn = columnHelper.accessor('size', { ), enableGlobalFilter: true, enableSorting: true, - header: () => ( - - File Size - - ), + header: () => , id: MediaModelOrdering.Size, size: 100, }) @@ -120,11 +109,7 @@ const extensionColumn = columnHelper.accessor('extension', { ), enableGlobalFilter: true, enableSorting: true, - header: () => ( - - Extension - - ), + header: () => , id: MediaModelOrdering.Extension, size: 90, }) @@ -153,11 +138,7 @@ const publishedColumn = columnHelper.accessor( enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Published - - ), + header: () => , id: 'published', }, ) @@ -180,11 +161,7 @@ const addedColumn = columnHelper.accessor( ), enableGlobalFilter: true, enableSorting: true, - header: () => ( - - Added - - ), + header: () => , id: MediaModelOrdering.CreatedAt, }, ) @@ -198,11 +175,7 @@ const publisherColumn = columnHelper.accessor(({ metadata }) => metadata?.publis enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Publisher - - ), + header: () => , id: 'publisher', }) @@ -215,11 +188,7 @@ const ageRatingColumn = columnHelper.accessor(({ metadata }) => metadata?.ageRat enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Age Rating - - ), + header: () => , id: 'age_rating', }) @@ -228,11 +197,7 @@ const genresColumn = columnHelper.accessor(({ metadata }) => metadata?.genres, { enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Genres - - ), + header: () => , id: 'genres', }) @@ -245,11 +210,7 @@ const volumeColumn = columnHelper.accessor(({ metadata }) => metadata?.volume, { enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Volume - - ), + header: () => , id: 'volume', }) @@ -258,11 +219,7 @@ const inkersColumn = columnHelper.accessor(({ metadata }) => metadata?.inkers, { enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Inkers - - ), + header: () => , id: 'inkers', }) @@ -271,11 +228,7 @@ const writersColumn = columnHelper.accessor(({ metadata }) => metadata?.writers, enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Writers - - ), + header: () => , id: 'writers', }) @@ -284,11 +237,7 @@ const pencillersColumn = columnHelper.accessor(({ metadata }) => metadata?.penci enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Pencillers - - ), + header: () => , id: 'pencillers', }) @@ -297,11 +246,7 @@ const coloristsColumn = columnHelper.accessor(({ metadata }) => metadata?.colori enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Colorists - - ), + header: () => , id: 'colorists', }) @@ -311,11 +256,7 @@ const letterersColumn = columnHelper.accessor(({ metadata }) => metadata?.letter enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Letterers - - ), + header: () => , id: 'letterers', }) @@ -325,11 +266,7 @@ const artistsColumn = columnHelper.accessor(({ metadata }) => metadata?.coverArt enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Artists - - ), + header: () => , id: 'artists', }) @@ -339,11 +276,7 @@ const charactersColumn = columnHelper.accessor(({ metadata }) => metadata?.chara enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Characters - - ), + header: () => , id: 'characters', }) @@ -357,11 +290,7 @@ const linksColumn = columnHelper.accessor(({ metadata }) => metadata?.links?.joi enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Links - - ), + header: () => , id: 'links', }) diff --git a/packages/browser/src/components/table/TableColumnHeader.tsx b/packages/browser/src/components/table/TableColumnHeader.tsx new file mode 100644 index 000000000..897f7f689 --- /dev/null +++ b/packages/browser/src/components/table/TableColumnHeader.tsx @@ -0,0 +1,17 @@ +import { Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' + +type Props = { + translationKey: string + variant?: 'muted' | 'secondary' +} + +export default function TableColumnHeader({ translationKey, variant = 'secondary' }: Props) { + const { t } = useLocaleContext() + + return ( + + {t(translationKey)} + + ) +} diff --git a/packages/browser/src/scenes/smartList/items/table/groupColumns.tsx b/packages/browser/src/scenes/smartList/items/table/groupColumns.tsx index 93230952d..a6be1b04d 100644 --- a/packages/browser/src/scenes/smartList/items/table/groupColumns.tsx +++ b/packages/browser/src/scenes/smartList/items/table/groupColumns.tsx @@ -1,11 +1,50 @@ import { cn, Text } from '@stump/components' import { SmartListGroupedItem, SmartListViewColumn } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { ColumnDef, createColumnHelper } from '@tanstack/react-table' import { ChevronDown } from 'lucide-react' +import { MouseEventHandler, ReactNode } from 'react' + +import TableColumnHeader from '@/components/table/TableColumnHeader' type EntityGroup = SmartListGroupedItem const columnHelper = createColumnHelper() +type ExpandToggleProps = { + expanded: boolean + onClick: MouseEventHandler + disabled?: boolean + all?: boolean + children?: ReactNode +} + +function ExpandToggle({ expanded, onClick, disabled, all, children }: ExpandToggleProps) { + const { t } = useLocaleContext() + const translationKey = all + ? expanded + ? 'tableColumns.configuration.collapseAll' + : 'tableColumns.configuration.expandAll' + : expanded + ? 'tableColumns.configuration.collapse' + : 'tableColumns.configuration.expand' + + return ( + + ) +} + const buildNameColumn = (isGroupedBySeries: boolean) => columnHelper.accessor('entity.name', { cell: ({ @@ -21,22 +60,13 @@ const buildNameColumn = (isGroupedBySeries: boolean) => const isExpanded = getIsExpanded() return ( - + ) }, enableGlobalFilter: true, @@ -46,24 +76,22 @@ const buildNameColumn = (isGroupedBySeries: boolean) => return (
- - - {isGroupedBySeries ? 'Series' : 'Library'} - + /> +
) }, @@ -82,11 +110,7 @@ const booksCountColumn = columnHelper.accessor(({ books }) => books.length, { ), enableGlobalFilter: true, enableSorting: true, - header: () => ( - - Books - - ), + header: () => , id: 'books', }) diff --git a/packages/browser/src/scenes/smartList/items/table/mediaColumns.tsx b/packages/browser/src/scenes/smartList/items/table/mediaColumns.tsx index 1d6de827c..b0ba9520f 100644 --- a/packages/browser/src/scenes/smartList/items/table/mediaColumns.tsx +++ b/packages/browser/src/scenes/smartList/items/table/mediaColumns.tsx @@ -5,6 +5,7 @@ import { ColumnDef, createColumnHelper } from '@tanstack/react-table' import { format, intlFormat, isValid } from 'date-fns' import paths from '@/paths' +import TableColumnHeader from '@/components/table/TableColumnHeader' import BookLinksCell from './BookLinksCell' import CoverImageCell from './CoverImageCell' @@ -14,11 +15,7 @@ const columnHelper = createColumnHelper() const coverColumn = columnHelper.display({ cell: ({ row: { original: book } }) => , enableGlobalFilter: true, - header: () => ( - - Cover - - ), + header: () => , id: 'cover', size: 80, }) @@ -39,11 +36,7 @@ const nameColumn = columnHelper.accessor(({ resolvedName }) => resolvedName, { ), enableGlobalFilter: true, enableSorting: true, - header: () => ( - - Name - - ), + header: () => , id: 'name', minSize: 285, }) @@ -56,11 +49,7 @@ const pagesColumn = columnHelper.accessor('pages', { ), enableGlobalFilter: true, enableSorting: true, - header: () => ( - - Pages - - ), + header: () => , id: 'pages', size: 100, }) @@ -89,9 +78,7 @@ const publishedColumn = columnHelper.accessor( enableGlobalFilter: true, enableSorting: true, header: () => ( - - Published - + ), id: 'published', }, @@ -115,11 +102,7 @@ const addedColumn = columnHelper.accessor( ), enableGlobalFilter: true, enableSorting: true, - header: () => ( - - Added - - ), + header: () => , id: 'added', }, ) @@ -133,9 +116,7 @@ const publisherColumn = columnHelper.accessor(({ metadata }) => metadata?.publis enableGlobalFilter: true, enableSorting: true, header: () => ( - - Publisher - + ), id: 'publisher', }) @@ -149,9 +130,7 @@ const ageRatingColumn = columnHelper.accessor(({ metadata }) => metadata?.ageRat enableGlobalFilter: true, enableSorting: true, header: () => ( - - Age Rating - + ), id: 'age_rating', }) @@ -164,11 +143,7 @@ const genresColumn = columnHelper.accessor(({ metadata }) => metadata?.genres?.j ), enableGlobalFilter: true, enableSorting: true, - header: () => ( - - Genres - - ), + header: () => , id: 'genres', }) @@ -180,11 +155,7 @@ const volumeColumn = columnHelper.accessor(({ metadata }) => metadata?.volume, { ), enableGlobalFilter: true, enableSorting: true, - header: () => ( - - Volume - - ), + header: () => , id: 'volume', }) @@ -196,11 +167,7 @@ const inkersColumn = columnHelper.accessor(({ metadata }) => metadata?.inkers?.j ), enableGlobalFilter: true, enableSorting: true, - header: () => ( - - Inkers - - ), + header: () => , id: 'inkers', }) @@ -212,11 +179,7 @@ const writersColumn = columnHelper.accessor(({ metadata }) => metadata?.writers? ), enableGlobalFilter: true, enableSorting: true, - header: () => ( - - Writers - - ), + header: () => , id: 'writers', }) @@ -229,9 +192,7 @@ const pencillersColumn = columnHelper.accessor(({ metadata }) => metadata?.penci enableGlobalFilter: true, enableSorting: true, header: () => ( - - Pencillers - + ), id: 'pencillers', }) @@ -245,9 +206,7 @@ const coloristsColumn = columnHelper.accessor(({ metadata }) => metadata?.colori enableGlobalFilter: true, enableSorting: true, header: () => ( - - Colorists - + ), id: 'colorists', }) @@ -262,9 +221,7 @@ const letterersColumn = columnHelper.accessor(({ metadata }) => metadata?.letter enableGlobalFilter: true, enableSorting: true, header: () => ( - - Letterers - + ), id: 'letterers', }) @@ -278,11 +235,7 @@ const artistsColumn = columnHelper.accessor(({ metadata }) => metadata?.coverArt enableGlobalFilter: true, enableSorting: true, - header: () => ( - - Artists - - ), + header: () => , id: 'artists', }) @@ -296,9 +249,7 @@ const charactersColumn = columnHelper.accessor(({ metadata }) => metadata?.chara enableGlobalFilter: true, enableSorting: true, header: () => ( - - Characters - + ), id: 'characters', }) @@ -312,11 +263,7 @@ const linksColumn = columnHelper.accessor(({ metadata }) => metadata?.links?.joi enableGlobalFilter: true, enableSorting: true, - header: () => ( - - Links - - ), + header: () => , id: 'links', }) diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index b0e3892db..84d455603 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -2749,7 +2749,11 @@ "bookColumnsDescription": "This only affects the nested book table(s)", "multiSort": "Enable multi-sort", "multiSortDescription": "Sorting by a column removes the previous sort. This setting allows combining them instead", - "selectionRequired": "You must select at least one column" + "selectionRequired": "You must select at least one column", + "collapse": "Collapse", + "expand": "Expand", + "collapseAll": "Collapse all", + "expandAll": "Expand all" }, "labels": { "added": "Added", @@ -2773,6 +2777,8 @@ "volume": "Volume", "writers": "Writers", "books": "Books", + "series": "Series", + "library": "Library", "seriesName": "Name (series)", "libraryName": "Name (library)" } diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 3206165e9..7098bf0a4 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -2749,7 +2749,11 @@ "bookColumnsDescription": "중첩된 책 표에만 적용됩니다", "multiSort": "다중 정렬 사용", "multiSortDescription": "열을 기준으로 정렬하면 이전 정렬이 제거됩니다. 이 설정을 사용하면 여러 정렬 기준을 함께 적용할 수 있습니다", - "selectionRequired": "열을 하나 이상 선택해야 합니다" + "selectionRequired": "열을 하나 이상 선택해야 합니다", + "collapse": "접기", + "expand": "펼치기", + "collapseAll": "모두 접기", + "expandAll": "모두 펼치기" }, "labels": { "added": "추가일", @@ -2773,6 +2777,8 @@ "volume": "권", "writers": "글 작가", "books": "책", + "series": "시리즈", + "library": "라이브러리", "seriesName": "이름(시리즈)", "libraryName": "이름(라이브러리)" } From c1052a3e9a4e778558189520f7ccfd1f70e61fdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:51:23 +0900 Subject: [PATCH 18/54] =?UTF-8?q?feat(i18n):=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=AC=B8=EA=B5=AC=20=EA=B5=AD=EC=A0=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../browser/src/components/ErrorFallback.tsx | 24 +++++++++++-------- .../src/components/HorizontalCardList.tsx | 10 ++++---- .../components/MultiRowHorizontalCardList.tsx | 10 ++++---- .../src/scenes/book/BookFileInformation.tsx | 13 ++++++---- .../src/scenes/book/BookOverviewScene.tsx | 4 +++- .../src/scenes/book/BooksAfterCursor.tsx | 8 ++++--- .../browser/src/scenes/error/FourOhFour.tsx | 9 ++++--- packages/i18n/src/locales/en-US.json | 24 ++++++++++++++++--- packages/i18n/src/locales/ko-KR.json | 24 ++++++++++++++++--- 9 files changed, 90 insertions(+), 36 deletions(-) diff --git a/packages/browser/src/components/ErrorFallback.tsx b/packages/browser/src/components/ErrorFallback.tsx index 5400df73b..7c730574f 100644 --- a/packages/browser/src/components/ErrorFallback.tsx +++ b/packages/browser/src/components/ErrorFallback.tsx @@ -1,4 +1,5 @@ import { Button, ButtonOrLink, useBodyLock } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { ExternalLink } from 'lucide-react' import { FallbackProps } from 'react-error-boundary' import { toast } from 'sonner' @@ -8,11 +9,12 @@ import { copyTextToClipboard } from '../utils/misc' // TODO: take in platform? export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) { useBodyLock() + const { t } = useLocaleContext() function copyErrorStack() { if (error.stack) { copyTextToClipboard(error.stack).then(() => { - toast.success('Copied error details to your clipboard') + toast.success(t('errorScene.copiedDetails')) }) } } @@ -24,39 +26,41 @@ export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) { > Construction illustration
-

A critical error occurred

+

+ {t('errorScene.criticalHeading')} +

- {error.message || 'The error message was empty.'} + {error.message || t('errorScene.emptyMessage')}

- Go Home + {t('errorScene.buttons.goHome')} - Report Bug + {t('errorScene.buttons.report')} {error.stack && ( )}
diff --git a/packages/browser/src/components/HorizontalCardList.tsx b/packages/browser/src/components/HorizontalCardList.tsx index f954a5c2c..c75290158 100644 --- a/packages/browser/src/components/HorizontalCardList.tsx +++ b/packages/browser/src/components/HorizontalCardList.tsx @@ -1,4 +1,5 @@ import { Button, cn, Heading, Text, ToolTip } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { ChevronLeft, ChevronRight, CircleSlash2 } from 'lucide-react' import { forwardRef, useMemo } from 'react' import { ScrollerProps, Virtuoso } from 'react-virtuoso' @@ -23,6 +24,7 @@ export default function HorizontalCardList({ height: heightProp, footerHeight = 96, }: Props) { + const { t } = useLocaleContext() const { preferences: { thumbnailRatio }, } = usePreferences() @@ -52,9 +54,9 @@ export default function HorizontalCardList({
- Nothing to show + {t('common.nothingToShow')} - No results present to display + {t('common.noResultsToDisplay')}
@@ -86,7 +88,7 @@ export default function HorizontalCardList({ {title}
- + - +
@@ -103,7 +105,7 @@ export default function MultiRowHorizontalCardList({ {title}
- + - +
) diff --git a/packages/browser/src/scenes/book/BookOverviewScene.tsx b/packages/browser/src/scenes/book/BookOverviewScene.tsx index 20abb5c09..674f2320e 100644 --- a/packages/browser/src/scenes/book/BookOverviewScene.tsx +++ b/packages/browser/src/scenes/book/BookOverviewScene.tsx @@ -1,5 +1,6 @@ import { Heading } from '@stump/components' import { useFragment, UserPermission } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import sortBy from 'lodash/sortBy' import { Suspense, useEffect, useMemo } from 'react' import { Helmet } from 'react-helmet' @@ -19,6 +20,7 @@ import BookReaderLink from './BookReaderLink' import BooksAfterCursor from './BooksAfterCursor' export default function BookOverviewScene() { + const { t } = useLocaleContext() const { id } = useParams() const { data: { mediaById: media }, @@ -71,7 +73,7 @@ export default function BookOverviewScene() {
- Metadata + {t('common.metadata')}
diff --git a/packages/browser/src/scenes/book/BooksAfterCursor.tsx b/packages/browser/src/scenes/book/BooksAfterCursor.tsx index 1ad158434..e502610e6 100644 --- a/packages/browser/src/scenes/book/BooksAfterCursor.tsx +++ b/packages/browser/src/scenes/book/BooksAfterCursor.tsx @@ -1,6 +1,7 @@ import { PREFETCH_STALE_TIME, queryClient, useInfiniteSuspenseGraphQL, useSDK } from '@stump/client' import { Text } from '@stump/components' import { graphql } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { BookX } from 'lucide-react' import { Suspense, useCallback } from 'react' @@ -60,6 +61,7 @@ export default function BooksAfterCurrentContainer({ cursor }: Props) { } function BooksAfterCurrent({ cursor }: Props) { + const { t } = useLocaleContext() const { data, hasNextPage, isFetchingNextPage, fetchNextPage } = useInfiniteSuspenseGraphQL( query, ['booksAfterCursor', cursor], @@ -83,7 +85,7 @@ function BooksAfterCurrent({ cursor }: Props) { return (
- Nothing to show + {t('common.nothingToShow')} - No books remain after this one + {t('bookOverviewScene.nextInSeriesEmpty')}
diff --git a/packages/browser/src/scenes/error/FourOhFour.tsx b/packages/browser/src/scenes/error/FourOhFour.tsx index 13bc03084..55edc2e0d 100644 --- a/packages/browser/src/scenes/error/FourOhFour.tsx +++ b/packages/browser/src/scenes/error/FourOhFour.tsx @@ -1,6 +1,9 @@ import { ButtonOrLink, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' export default function FourOhFour() { + const { t } = useLocaleContext() + return (

404

- The page you are looking for does not seem to exist! + {t('errorScene.notFound.message')}
- Go home + {t('errorScene.buttons.goHome')} - Report an issue + {t('errorScene.notFound.reportIssue')}
diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index 84d455603..039a5e72f 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -105,7 +105,8 @@ } } }, - "nextInSeries": "Next in Series" + "nextInSeries": "Next in Series", + "nextInSeriesEmpty": "No books remain after this one" }, "createOrUpdateBookClubForm": { "fields": { @@ -810,11 +811,22 @@ }, "errorScene": { "heading": "lol, oops", + "criticalHeading": "A critical error occurred", + "emptyMessage": "The error message was empty.", + "copiedDetails": "Copied error details to your clipboard", + "imageAlt": "Construction illustration", "message": "An error occurred:", "buttons": { "report": "Report Bug", "copy": "Copy Error Details", - "goHome": "Go Home" + "goHome": "Go Home", + "goHomeTitle": "Go back to the homepage", + "reportTitle": "Report this error as a potential bug on GitHub", + "copyTitle": "Copy the error details to your clipboard" + }, + "notFound": { + "message": "The page you are looking for does not seem to exist!", + "reportIssue": "Report an issue" } }, "homeScene": { @@ -2728,7 +2740,13 @@ "tooManyFiles": "You have selected too many files", "noValidFiles": "No valid files were selected" }, - "unknownError": "An unknown error occurred" + "unknownError": "An unknown error occurred", + "nothingToShow": "Nothing to show", + "noResultsToDisplay": "No results present to display", + "noResultsReturned": "No results were returned", + "seekBackward": "Seek backwards", + "seekAhead": "Seek ahead", + "metadata": "Metadata" }, "tableColumns": { "configuration": { diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 7098bf0a4..0d236be1c 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -105,7 +105,8 @@ } } }, - "nextInSeries": "시리즈의 다음 책" + "nextInSeries": "시리즈의 다음 책", + "nextInSeriesEmpty": "이 책 뒤에 남은 책이 없습니다" }, "createOrUpdateBookClubForm": { "fields": { @@ -810,11 +811,22 @@ }, "errorScene": { "heading": "문제가 발생했습니다", + "criticalHeading": "치명적인 오류가 발생했습니다", + "emptyMessage": "오류 메시지가 비어 있습니다.", + "copiedDetails": "오류 정보를 클립보드에 복사했습니다", + "imageAlt": "공사 중임을 나타내는 그림", "message": "다음 오류가 발생했습니다:", "buttons": { "report": "버그 신고", "copy": "오류 정보 복사", - "goHome": "홈으로 이동" + "goHome": "홈으로 이동", + "goHomeTitle": "홈페이지로 돌아가기", + "reportTitle": "GitHub에 잠재적인 버그로 신고하기", + "copyTitle": "오류 정보를 클립보드에 복사하기" + }, + "notFound": { + "message": "찾으시는 페이지가 존재하지 않는 것 같습니다!", + "reportIssue": "문제 신고" } }, "homeScene": { @@ -2728,7 +2740,13 @@ "tooManyFiles": "선택한 파일이 너무 많습니다", "noValidFiles": "올바른 파일이 선택되지 않았습니다" }, - "unknownError": "알 수 없는 오류가 발생했습니다" + "unknownError": "알 수 없는 오류가 발생했습니다", + "nothingToShow": "표시할 항목이 없습니다", + "noResultsToDisplay": "표시할 결과가 없습니다", + "noResultsReturned": "검색 결과가 없습니다", + "seekBackward": "뒤로 이동", + "seekAhead": "앞으로 이동", + "metadata": "메타데이터" }, "tableColumns": { "configuration": { From af201b85a65d27b72195c94d3ddaf7c5a73b6527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:54:03 +0900 Subject: [PATCH 19/54] =?UTF-8?q?feat(i18n):=20=EB=A9=94=ED=83=80=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EA=B4=80=EB=A6=AC=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book/metadata/MediaMetadataEditor.tsx | 4 +- .../metadataEditor/MetadataEditorTable.tsx | 6 ++- .../metadata/metadataEditor/ResetMetadata.tsx | 29 ++++++------- .../series/metadata/SeriesMetadataEditor.tsx | 4 +- .../book/settings/BookManagementScene.tsx | 24 ++++++----- .../scenes/book/settings/BookTagEditor.tsx | 6 ++- .../book/settings/BookThumbnailSelector.tsx | 20 ++++----- .../thumbnails/LibraryThumbnailSelector.tsx | 28 ++++++------ .../series/tabs/settings/SeriesTagEditor.tsx | 6 ++- .../tabs/settings/SeriesThumbnailSelector.tsx | 22 +++++----- packages/i18n/src/locales/en-US.json | 43 ++++++++++++++++++- packages/i18n/src/locales/ko-KR.json | 43 ++++++++++++++++++- 12 files changed, 164 insertions(+), 71 deletions(-) diff --git a/packages/browser/src/components/book/metadata/MediaMetadataEditor.tsx b/packages/browser/src/components/book/metadata/MediaMetadataEditor.tsx index d816b3367..390290150 100644 --- a/packages/browser/src/components/book/metadata/MediaMetadataEditor.tsx +++ b/packages/browser/src/components/book/metadata/MediaMetadataEditor.tsx @@ -132,7 +132,7 @@ export default function MediaMetadataEditor({ mediaId, data }: Props) { onClick={() => setShowMissing((prev) => !prev)} /> - Missing + {t('metadataEditor.missing')}
), @@ -268,7 +268,7 @@ export default function MediaMetadataEditor({ mediaId, data }: Props) { size: 0, }), ], - [metadata, paths, checkPermission], + [metadata, paths, checkPermission, t], ) as ColumnDef[] const items = useMemo( diff --git a/packages/browser/src/components/metadata/metadataEditor/MetadataEditorTable.tsx b/packages/browser/src/components/metadata/metadataEditor/MetadataEditorTable.tsx index 22b7bd42c..711a24a81 100644 --- a/packages/browser/src/components/metadata/metadataEditor/MetadataEditorTable.tsx +++ b/packages/browser/src/components/metadata/metadataEditor/MetadataEditorTable.tsx @@ -6,6 +6,7 @@ import { RowData, useReactTable, } from '@tanstack/react-table' +import { useLocaleContext } from '@stump/i18n' import { useCallback, useLayoutEffect, useRef } from 'react' import { useWindowSize } from 'rooks' @@ -26,6 +27,7 @@ export default function MetadataEditorTable({ items, showMissing, }: Props) { + const { t } = useLocaleContext() const table = useReactTable({ columns, data: items, @@ -172,7 +174,9 @@ export default function MetadataEditorTable({ {!rows.length && ( -
No Metadata
+
+ {t('metadataEditor.noMetadata')} +
)} diff --git a/packages/browser/src/components/metadata/metadataEditor/ResetMetadata.tsx b/packages/browser/src/components/metadata/metadataEditor/ResetMetadata.tsx index ac01a4d08..9e011db0b 100644 --- a/packages/browser/src/components/metadata/metadataEditor/ResetMetadata.tsx +++ b/packages/browser/src/components/metadata/metadataEditor/ResetMetadata.tsx @@ -8,6 +8,7 @@ import { RadioGroup, } from '@stump/components' import { MetadataResetImpact } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { AlertTriangle } from 'lucide-react' import { useState } from 'react' @@ -17,6 +18,7 @@ type Props = { } export default function ResetMetadata({ onConfirmReset, isDisabled }: Props) { + const { t } = useLocaleContext() const [showConfirmation, setShowConfirmation] = useState(false) const [impact, setImpact] = useState(MetadataResetImpact.Series) @@ -30,15 +32,15 @@ export default function ResetMetadata({ onConfirmReset, isDisabled }: Props) { return (
{ onConfirmReset(impact) setShowConfirmation(false) @@ -52,27 +54,27 @@ export default function ResetMetadata({ onConfirmReset, isDisabled }: Props) { className="divide gap-0 space-y-0 divide-y divide-border overflow-hidden rounded-xl border border-border" > - This action cannot be undone - - This will permanently delete all metadata for the selected items. You will need to - trigger a custom scan to regenerate the metadata - + {t('common.thisActionCannotBeUndone')} + {t('metadataEditor.reset.warning')}
diff --git a/packages/browser/src/components/series/metadata/SeriesMetadataEditor.tsx b/packages/browser/src/components/series/metadata/SeriesMetadataEditor.tsx index 23699be23..7e26a3c70 100644 --- a/packages/browser/src/components/series/metadata/SeriesMetadataEditor.tsx +++ b/packages/browser/src/components/series/metadata/SeriesMetadataEditor.tsx @@ -120,7 +120,7 @@ export default function SeriesMetadataEditor({ seriesId, data }: Props) { onClick={() => setShowMissing((prev) => !prev)} /> - Missing + {t('metadataEditor.missing')}
), @@ -212,7 +212,7 @@ export default function SeriesMetadataEditor({ seriesId, data }: Props) { cell: () => null, }), ], - [metadata, paths, checkPermission], + [metadata, paths, checkPermission, t], ) as ColumnDef[] const items = useMemo( diff --git a/packages/browser/src/scenes/book/settings/BookManagementScene.tsx b/packages/browser/src/scenes/book/settings/BookManagementScene.tsx index 0203c94d5..a282fc7e9 100644 --- a/packages/browser/src/scenes/book/settings/BookManagementScene.tsx +++ b/packages/browser/src/scenes/book/settings/BookManagementScene.tsx @@ -102,38 +102,40 @@ export default function BookManagementScene() {
- Manage + {t('bookManagementScene.heading')} - Make changes to this book + {t('bookManagementScene.description')}
- - Book management is currently under development and has very limited functionality - + {t('bookManagementScene.developmentNotice')} {checkPermission(UserPermission.ManageLibrary) && (
- Analysis + {t('bookManagementScene.analysis.heading')} - Re-analyze this book to update metadata from its file + {t('bookManagementScene.analysis.description')}
@@ -173,9 +175,9 @@ export default function BookManagementScene() { {checkPermission(UserPermission.EditThumbnails) && (
- Thumbnail + {t('bookManagementScene.thumbnail.heading')} - Change the cover image for this book + {t('bookManagementScene.thumbnail.description')}
diff --git a/packages/browser/src/scenes/book/settings/BookTagEditor.tsx b/packages/browser/src/scenes/book/settings/BookTagEditor.tsx index ae0494231..3a9ad4009 100644 --- a/packages/browser/src/scenes/book/settings/BookTagEditor.tsx +++ b/packages/browser/src/scenes/book/settings/BookTagEditor.tsx @@ -1,6 +1,7 @@ import { useGraphQLMutation, useSDK } from '@stump/client' import { Heading, Text } from '@stump/components' import { graphql, Tag } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useQueryClient } from '@tanstack/react-query' import { useCallback } from 'react' import { toast } from 'sonner' @@ -25,6 +26,7 @@ type Props = { } export default function BookTagEditor({ mediaId, tags }: Props) { + const { t } = useLocaleContext() const { sdk } = useSDK() const client = useQueryClient() @@ -62,9 +64,9 @@ export default function BookTagEditor({ mediaId, tags }: Props) { return (
- Tags + {t('bookManagementScene.tags.heading')} - Assign tags to this book + {t('bookManagementScene.tags.bookDescription')}
diff --git a/packages/browser/src/scenes/book/settings/BookThumbnailSelector.tsx b/packages/browser/src/scenes/book/settings/BookThumbnailSelector.tsx index 7ebe644a7..c1cd77a35 100644 --- a/packages/browser/src/scenes/book/settings/BookThumbnailSelector.tsx +++ b/packages/browser/src/scenes/book/settings/BookThumbnailSelector.tsx @@ -6,6 +6,7 @@ import { graphql, useFragment, } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useCallback, useState } from 'react' import { toast } from 'sonner' @@ -58,6 +59,7 @@ type Props = { } export default function BookThumbnailSelector({ fragment }: Props) { + const { t } = useLocaleContext() const book = useFragment(BookThumbnailSelectorFragment, fragment) const [isOpen, setIsOpen] = useState(false) @@ -109,10 +111,10 @@ export default function BookThumbnailSelector({ fragment }: Props) { setIsOpen(false) } catch (error) { console.error(error) - toast.error('Failed to upload image') + toast.error(t('common.failedToUploadImage')) } }, - [book.id, uploadThumbnail], + [book.id, uploadThumbnail, t], ) const handleConfirm = useCallback(async () => { @@ -123,9 +125,9 @@ export default function BookThumbnailSelector({ fragment }: Props) { setIsOpen(false) } catch (error) { console.error(error) - toast.error('Failed to update thumbnail') + toast.error(t('common.failedToUpdateThumbnail')) } - }, [patchThumbnail, page, book.id]) + }, [patchThumbnail, page, book.id, t]) return (
@@ -147,10 +149,8 @@ export default function BookThumbnailSelector({ fragment }: Props) { - Select a thumbnail - - Choose a page from this book to use as the new thumbnail - + {t('common.selectThumbnail')} + {t('common.choosePageForThumbnail')} setIsOpen(false)} /> @@ -163,14 +163,14 @@ export default function BookThumbnailSelector({ fragment }: Props) { diff --git a/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/LibraryThumbnailSelector.tsx b/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/LibraryThumbnailSelector.tsx index f91b4b9c5..3f0ecae6a 100644 --- a/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/LibraryThumbnailSelector.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/LibraryThumbnailSelector.tsx @@ -1,6 +1,7 @@ import { useGraphQLMutation, useSDK } from '@stump/client' import { Button, Dialog, Label, PickSelect, Text } from '@stump/components' import { graphql, LibraryThumbnailSelectorUpdateMutation } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { Suspense, useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' @@ -38,6 +39,7 @@ const uploadMutation = graphql(` type OnSuccessData = PickSelect export default function LibraryThumbnailSelector() { + const { t } = useLocaleContext() const { sdk } = useSDK() const [selectedSeries, setSelectedSeries] = useState() const [selectedBook, setSelectedBook] = useState() @@ -93,10 +95,10 @@ export default function LibraryThumbnailSelector() { setIsOpen(false) } catch (error) { console.error(error) - toast.error('Failed to upload image') + toast.error(t('common.failedToUploadImage')) } }, - [library.id, uploadThumbnail], + [library.id, uploadThumbnail, t], ) const handleConfirm = useCallback(async () => { @@ -107,9 +109,9 @@ export default function LibraryThumbnailSelector() { setIsOpen(false) } catch (error) { console.error(error) - toast.error('Failed to update thumbnail') + toast.error(t('common.failedToUpdateThumbnail')) } - }, [patchThumbnail, selectedBook, page, library.id]) + }, [patchThumbnail, selectedBook, page, library.id, t]) useEffect(() => { return () => { @@ -138,11 +140,11 @@ export default function LibraryThumbnailSelector() { const renderDescription = () => { if (selectedBook) { - return 'Choose a page from this book to use as the new thumbnail' + return t('common.choosePageForThumbnail') } else if (selectedSeries) { - return 'Select a book from the series' + return t('common.selectBookFromSeries') } else { - return 'Select a series from the library' + return t('common.selectSeriesFromLibrary') } } @@ -161,7 +163,7 @@ export default function LibraryThumbnailSelector() { } }} > - Go back + {t('common.goBack')} ) } @@ -169,9 +171,9 @@ export default function LibraryThumbnailSelector() { return (
- + - Choose a different thumbnail for this library, either from a book or upload a custom one + {t('common.libraryThumbnailDescription')}
@@ -185,7 +187,7 @@ export default function LibraryThumbnailSelector() { - Select a thumbnail + {t('common.selectThumbnail')} {renderDescription()} {renderGoBack()} @@ -196,14 +198,14 @@ export default function LibraryThumbnailSelector() { {renderContent()} diff --git a/packages/browser/src/scenes/series/tabs/settings/SeriesTagEditor.tsx b/packages/browser/src/scenes/series/tabs/settings/SeriesTagEditor.tsx index 0b9f4077c..c070ced23 100644 --- a/packages/browser/src/scenes/series/tabs/settings/SeriesTagEditor.tsx +++ b/packages/browser/src/scenes/series/tabs/settings/SeriesTagEditor.tsx @@ -1,6 +1,7 @@ import { useGraphQLMutation, useSDK } from '@stump/client' import { Heading, Text } from '@stump/components' import { graphql, Tag } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useQueryClient } from '@tanstack/react-query' import { useCallback } from 'react' import { toast } from 'sonner' @@ -25,6 +26,7 @@ type Props = { } export default function SeriesTagEditor({ seriesId, tags }: Props) { + const { t } = useLocaleContext() const { sdk } = useSDK() const client = useQueryClient() @@ -60,9 +62,9 @@ export default function SeriesTagEditor({ seriesId, tags }: Props) { return (
- Tags + {t('bookManagementScene.tags.heading')} - Assign tags to this series + {t('bookManagementScene.tags.seriesDescription')}
diff --git a/packages/browser/src/scenes/series/tabs/settings/SeriesThumbnailSelector.tsx b/packages/browser/src/scenes/series/tabs/settings/SeriesThumbnailSelector.tsx index 9cd914083..cd737747b 100644 --- a/packages/browser/src/scenes/series/tabs/settings/SeriesThumbnailSelector.tsx +++ b/packages/browser/src/scenes/series/tabs/settings/SeriesThumbnailSelector.tsx @@ -6,6 +6,7 @@ import { SeriesThumbnailSelectorUpdateMutation, useFragment, } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { Suspense, useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' @@ -61,6 +62,7 @@ type Props = { // in the header thumb on hover that lets you change if you have permissions export default function SeriesThumbnailSelector({ fragment }: Props) { + const { t } = useLocaleContext() const series = useFragment(SeriesThumbnailSelectorFragment, fragment) const { sdk } = useSDK() @@ -114,10 +116,10 @@ export default function SeriesThumbnailSelector({ fragment }: Props) { setIsOpen(false) } catch (error) { console.error(error) - toast.error('Failed to upload image') + toast.error(t('common.failedToUploadImage')) } }, - [series.id, uploadThumbnail], + [series.id, uploadThumbnail, t], ) const handleConfirm = useCallback(async () => { @@ -128,9 +130,9 @@ export default function SeriesThumbnailSelector({ fragment }: Props) { setIsOpen(false) } catch (error) { console.error(error) - toast.error('Failed to update thumbnail') + toast.error(t('common.failedToUpdateThumbnail')) } - }, [patchThumbnail, page, selectedBook, series.id]) + }, [patchThumbnail, page, selectedBook, series.id, t]) const renderContent = () => { if (selectedBook) { @@ -176,11 +178,9 @@ export default function SeriesThumbnailSelector({ fragment }: Props) { - Select a thumbnail + {t('common.selectThumbnail')} - {selectedBook - ? 'Choose a page from this book to use as the new thumbnail' - : 'Select a book from the series'} + {selectedBook ? t('common.choosePageForThumbnail') : t('common.selectBookFromSeries')} {selectedBook && ( - Go back + {t('common.goBack')} )} @@ -201,14 +201,14 @@ export default function SeriesThumbnailSelector({ fragment }: Props) { diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index 039a5e72f..7a432e7b8 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -43,6 +43,25 @@ } }, "bookManagementScene": { + "heading": "Manage", + "description": "Make changes to this book", + "developmentNotice": "Book management is currently under development and has very limited functionality", + "analysis": { + "heading": "Analysis", + "description": "Re-analyze this book to update metadata from its file", + "inProgress": "Analysis already in progress", + "actionTitle": "Analyze this book", + "action": "Analyze media" + }, + "tags": { + "heading": "Tags", + "bookDescription": "Assign tags to this book", + "seriesDescription": "Assign tags to this series" + }, + "thumbnail": { + "heading": "Thumbnail", + "description": "Change the cover image for this book" + }, "metadataSearch": { "heading": "Search metadata", "description": "Search an external provider for this book and choose a match to apply", @@ -860,6 +879,19 @@ } }, "metadataEditor": { + "missing": "Missing", + "noMetadata": "No metadata", + "reset": { + "action": "Delete metadata", + "description": "Select the impact for the deletion", + "series": "Series", + "seriesDescription": "Remove only this series' metadata", + "books": "Books", + "booksDescription": "Remove all the metadata for books in this series", + "everything": "Everything", + "everythingDescription": "Remove all the metadata for everything related to this series", + "warning": "This will permanently delete all metadata for the selected items. You will need to trigger a custom scan to regenerate the metadata" + }, "labels": { "ageRating": "Age rating", "characters": "Characters", @@ -2746,7 +2778,16 @@ "noResultsReturned": "No results were returned", "seekBackward": "Seek backwards", "seekAhead": "Seek ahead", - "metadata": "Metadata" + "metadata": "Metadata", + "thisActionCannotBeUndone": "This action cannot be undone", + "selectThumbnail": "Select a thumbnail", + "choosePageForThumbnail": "Choose a page from this book to use as the new thumbnail", + "selectBookFromSeries": "Select a book from the series", + "selectSeriesFromLibrary": "Select a series from the library", + "libraryThumbnailDescription": "Choose a different thumbnail for this library, either from a book or upload a custom one", + "confirmSelection": "Confirm selection", + "failedToUploadImage": "Failed to upload image", + "failedToUpdateThumbnail": "Failed to update thumbnail" }, "tableColumns": { "configuration": { diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 0d236be1c..cdf672a63 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -43,6 +43,25 @@ } }, "bookManagementScene": { + "heading": "책 관리", + "description": "이 책의 설정을 변경합니다", + "developmentNotice": "책 관리 기능은 현재 개발 중이며 사용할 수 있는 기능이 매우 제한적입니다", + "analysis": { + "heading": "분석", + "description": "파일에서 메타데이터를 다시 읽어 이 책의 정보를 업데이트합니다", + "inProgress": "분석이 이미 진행 중입니다", + "actionTitle": "이 책 분석", + "action": "미디어 분석" + }, + "tags": { + "heading": "태그", + "bookDescription": "이 책에 태그를 지정합니다", + "seriesDescription": "이 시리즈에 태그를 지정합니다" + }, + "thumbnail": { + "heading": "썸네일", + "description": "이 책의 표지 이미지를 변경합니다" + }, "metadataSearch": { "heading": "메타데이터 검색", "description": "외부 공급자에서 이 책을 검색하고 적용할 결과를 선택합니다", @@ -860,6 +879,19 @@ } }, "metadataEditor": { + "missing": "누락", + "noMetadata": "메타데이터 없음", + "reset": { + "action": "메타데이터 삭제", + "description": "삭제할 범위를 선택하세요", + "series": "시리즈", + "seriesDescription": "이 시리즈의 메타데이터만 제거합니다", + "books": "책", + "booksDescription": "이 시리즈에 속한 모든 책의 메타데이터를 제거합니다", + "everything": "전체", + "everythingDescription": "이 시리즈와 관련된 모든 메타데이터를 제거합니다", + "warning": "선택한 항목의 모든 메타데이터가 영구적으로 삭제됩니다. 메타데이터를 다시 만들려면 사용자 지정 스캔을 실행해야 합니다" + }, "labels": { "ageRating": "연령 등급", "characters": "등장인물", @@ -2746,7 +2778,16 @@ "noResultsReturned": "검색 결과가 없습니다", "seekBackward": "뒤로 이동", "seekAhead": "앞으로 이동", - "metadata": "메타데이터" + "metadata": "메타데이터", + "thisActionCannotBeUndone": "이 작업은 되돌릴 수 없습니다", + "selectThumbnail": "썸네일 선택", + "choosePageForThumbnail": "새 썸네일로 사용할 페이지를 이 책에서 선택하세요", + "selectBookFromSeries": "시리즈에서 책을 선택하세요", + "selectSeriesFromLibrary": "라이브러리에서 시리즈를 선택하세요", + "libraryThumbnailDescription": "책에서 선택하거나 이미지를 직접 업로드하여 이 라이브러리의 썸네일을 변경하세요", + "confirmSelection": "선택 확인", + "failedToUploadImage": "이미지를 업로드하지 못했습니다", + "failedToUpdateThumbnail": "썸네일을 업데이트하지 못했습니다" }, "tableColumns": { "configuration": { From b3891a38269db867adbdc111cc16b7547f67c98a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:56:30 +0900 Subject: [PATCH 20/54] =?UTF-8?q?feat(i18n):=20=EC=84=A4=EC=A0=95=EA=B3=BC?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=EC=9A=94=EC=95=BD=20=EB=AC=B8=EA=B5=AC?= =?UTF-8?q?=20=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenes/settings/SettingsNavigation.tsx | 2 +- .../src/scenes/settings/SettingsSideBar.tsx | 2 +- .../server/general/ServerInfoSection.tsx | 14 +++++--- .../settings/server/general/ServerStats.tsx | 14 +++++--- .../settings/server/users/UsersStats.tsx | 18 +++++++---- .../users/create-or-update/AccountDetails.tsx | 15 ++++++--- .../CreateOrUpdateUserForm.tsx | 6 ++-- .../create-or-update/MaxSessionsAllowed.tsx | 6 ++-- .../create-or-update/UserRestrictionsForm.tsx | 2 +- packages/i18n/src/locales/en-US.json | 32 +++++++++++++++++++ packages/i18n/src/locales/ko-KR.json | 32 +++++++++++++++++++ 11 files changed, 116 insertions(+), 27 deletions(-) diff --git a/packages/browser/src/scenes/settings/SettingsNavigation.tsx b/packages/browser/src/scenes/settings/SettingsNavigation.tsx index 780fb0dae..941775d16 100644 --- a/packages/browser/src/scenes/settings/SettingsNavigation.tsx +++ b/packages/browser/src/scenes/settings/SettingsNavigation.tsx @@ -79,7 +79,7 @@ export default function SettingsNavigation() { )}
- + navigate(paths.home())} diff --git a/packages/browser/src/scenes/settings/server/general/ServerInfoSection.tsx b/packages/browser/src/scenes/settings/server/general/ServerInfoSection.tsx index 73c5dd722..e3a8e520d 100644 --- a/packages/browser/src/scenes/settings/server/general/ServerInfoSection.tsx +++ b/packages/browser/src/scenes/settings/server/general/ServerInfoSection.tsx @@ -67,7 +67,9 @@ export default function ServerInfoSection() {
{version && (
- + - + {toUpper(buildChannel.charAt(0)) + buildChannel.slice(1)} @@ -94,7 +98,9 @@ export default function ServerInfoSection() { {version && (
- + - + {intlFormat(new Date(version.compileTime), { month: 'long', diff --git a/packages/browser/src/scenes/settings/server/general/ServerStats.tsx b/packages/browser/src/scenes/settings/server/general/ServerStats.tsx index 65c68e626..03ec30837 100644 --- a/packages/browser/src/scenes/settings/server/general/ServerStats.tsx +++ b/packages/browser/src/scenes/settings/server/general/ServerStats.tsx @@ -2,6 +2,7 @@ import { useSuspenseGraphQL } from '@stump/client' import { formatBytesSeparate } from '@stump/client' import { Statistic } from '@stump/components' import { graphql } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useMemo } from 'react' const query = graphql(` @@ -14,6 +15,7 @@ const query = graphql(` `) export default function ServerStats() { + const { t } = useLocaleContext() const { data } = useSuspenseGraphQL(query, ['serverStats']) const stats = useMemo( @@ -29,22 +31,26 @@ export default function ServerStats() { return (
- Libraries + + {t('settingsScene.server/general.sections.stats.libraries')} + - Series + {t('settingsScene.server/general.sections.stats.series')} - Books + {t('settingsScene.server/general.sections.stats.books')} - Disk Usage + + {t('settingsScene.server/general.sections.stats.diskUsage')} + }) export default function UsersStats() { + const { t } = useLocaleContext() const { data } = useSuspenseGraphQL(query, ['userStats']) const [powerReader] = useMemo(() => data.topReaders, [data.topReaders]) @@ -36,28 +37,31 @@ export default function UsersStats() { return (
- Users + {t('settingsScene.server/users.stats.users')} - Books completed + {t('settingsScene.server/users.stats.booksCompleted')} - Books in progress + {t('settingsScene.server/users.stats.booksInProgress')} {!!powerReader && ( - Power Reader + {t('settingsScene.server/users.stats.powerReader')} {powerReader.username}{' '} - ({powerReader.finishedReadingSessionsCount}{' '} - {pluralize('book', powerReader.finishedReadingSessionsCount)}) + ( + {t('settingsScene.server/users.stats.booksRead', { + count: powerReader.finishedReadingSessionsCount, + })} + ) diff --git a/packages/browser/src/scenes/settings/server/users/create-or-update/AccountDetails.tsx b/packages/browser/src/scenes/settings/server/users/create-or-update/AccountDetails.tsx index e06feaafb..1fdb8b4ff 100644 --- a/packages/browser/src/scenes/settings/server/users/create-or-update/AccountDetails.tsx +++ b/packages/browser/src/scenes/settings/server/users/create-or-update/AccountDetails.tsx @@ -1,4 +1,5 @@ import { Button, Input, InputGroup, Label, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { Eye, EyeOff, Shield } from 'lucide-react' import { useState } from 'react' import { useFormContext, useFormState } from 'react-hook-form' @@ -6,6 +7,7 @@ import { useFormContext, useFormState } from 'react-hook-form' import { CreateOrUpdateUserSchema } from './schema' export default function AccountDetails() { + const { t } = useLocaleContext() const form = useFormContext() const { errors } = useFormState({ control: form.control }) @@ -16,21 +18,23 @@ export default function AccountDetails() {
- + form.setValue('password', generateRandomPassword())} data-testid="generatePassword" > - Generate Random Password + {' '} + {t('settingsScene.server/users.createOrUpdateForm.accountDetails.generatePassword')}
diff --git a/packages/browser/src/scenes/settings/server/users/create-or-update/CreateOrUpdateUserForm.tsx b/packages/browser/src/scenes/settings/server/users/create-or-update/CreateOrUpdateUserForm.tsx index 6edc53974..123f6735f 100644 --- a/packages/browser/src/scenes/settings/server/users/create-or-update/CreateOrUpdateUserForm.tsx +++ b/packages/browser/src/scenes/settings/server/users/create-or-update/CreateOrUpdateUserForm.tsx @@ -155,9 +155,11 @@ export default function CreateOrUpdateUserForm({ user, existingUsernames }: Prop
- Account details + + {t('settingsScene.server/users.createOrUpdateForm.accountDetails.heading')} + - They can change these values at any time + {t('settingsScene.server/users.createOrUpdateForm.accountDetails.description')}
diff --git a/packages/browser/src/scenes/settings/server/users/create-or-update/MaxSessionsAllowed.tsx b/packages/browser/src/scenes/settings/server/users/create-or-update/MaxSessionsAllowed.tsx index a758549dd..e71393b7e 100644 --- a/packages/browser/src/scenes/settings/server/users/create-or-update/MaxSessionsAllowed.tsx +++ b/packages/browser/src/scenes/settings/server/users/create-or-update/MaxSessionsAllowed.tsx @@ -1,10 +1,12 @@ import { Input } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { useCallback, useEffect } from 'react' import { useFormContext, useFormState, useWatch } from 'react-hook-form' import { CreateOrUpdateUserSchema } from './schema' export default function MaxSessionsAllowed() { + const { t } = useLocaleContext() const form = useFormContext() const { errors } = useFormState({ control: form.control }) @@ -37,8 +39,8 @@ export default function MaxSessionsAllowed() { return (
- Access control + {t(getLocaleKey('heading'))} {renderDescription()} diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index 7a432e7b8..faa497782 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -1446,6 +1446,7 @@ } }, "settingsScene": { + "section": "Section", "navigation": { "general": "General", "logs": "Logs", @@ -1964,6 +1965,12 @@ "title": "Server settings", "description": "General settings related to your Stump server instance", "sections": { + "stats": { + "libraries": "Libraries", + "series": "Series", + "books": "Books", + "diskUsage": "Disk usage" + }, "updateAvailable": { "message": "Your server is not up to date. Please update to the latest version!" }, @@ -1979,6 +1986,12 @@ "date": "Build date" } }, + "labels": { + "semanticVersion": "Semantic version", + "buildChannel": "Build channel", + "exactCommit": "Exact commit", + "buildDate": "Build date" + }, "nonStableChannel": { "title": "You are running a non-stable build of Stump", "description": [ @@ -2173,6 +2186,17 @@ "description": "Update the details of this user" }, "createOrUpdateForm": { + "accountDetails": { + "heading": "Account details", + "description": "They can change these values at any time", + "username": "Username", + "password": "Password", + "generatePassword": "Generate random password" + }, + "maxSessions": { + "label": "Max sessions allowed", + "description": "The maximum number of valid sessions allowed at a time. If a user tries to log in once this limit is reached, the oldest session will be invalidated." + }, "accessControl": { "heading": "Access control and restrictions", "subtitle": [ @@ -2338,6 +2362,13 @@ "description": "All records of login activity will be permanently deleted from the database", "confirm": "Delete" } + }, + "stats": { + "users": "Users", + "booksCompleted": "Books completed", + "booksInProgress": "Books in progress", + "powerReader": "Power reader", + "booksRead": "{{count}} books" } }, "server/email": { @@ -2773,6 +2804,7 @@ "noValidFiles": "No valid files were selected" }, "unknownError": "An unknown error occurred", + "goHome": "Go home", "nothingToShow": "Nothing to show", "noResultsToDisplay": "No results present to display", "noResultsReturned": "No results were returned", diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index cdf672a63..15484a4b2 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -1446,6 +1446,7 @@ } }, "settingsScene": { + "section": "항목", "navigation": { "general": "일반", "logs": "로그", @@ -1964,6 +1965,12 @@ "title": "서버 설정", "description": "Stump 서버 인스턴스의 일반 설정", "sections": { + "stats": { + "libraries": "라이브러리", + "series": "시리즈", + "books": "책", + "diskUsage": "디스크 사용량" + }, "updateAvailable": { "message": "서버가 최신 버전이 아닙니다. 최신 버전으로 업데이트하세요!" }, @@ -1979,6 +1986,12 @@ "date": "빌드 날짜" } }, + "labels": { + "semanticVersion": "의미적 버전", + "buildChannel": "빌드 채널", + "exactCommit": "정확한 커밋", + "buildDate": "빌드 날짜" + }, "nonStableChannel": { "title": "안정 버전이 아닌 Stump 빌드를 사용 중입니다", "description": [ @@ -2173,6 +2186,17 @@ "description": "이 사용자의 정보를 수정합니다" }, "createOrUpdateForm": { + "accountDetails": { + "heading": "계정 정보", + "description": "사용자는 언제든 이 값을 변경할 수 있습니다", + "username": "사용자 이름", + "password": "비밀번호", + "generatePassword": "임의 비밀번호 생성" + }, + "maxSessions": { + "label": "허용할 최대 세션 수", + "description": "동시에 유지할 수 있는 유효한 세션의 최대 개수입니다. 한도에 도달한 뒤 사용자가 로그인하면 가장 오래된 세션이 무효화됩니다." + }, "accessControl": { "heading": "접근 제어 및 제한", "subtitle": [ @@ -2338,6 +2362,13 @@ "description": "모든 로그인 활동 기록을 데이터베이스에서 영구적으로 삭제합니다", "confirm": "삭제" } + }, + "stats": { + "users": "사용자", + "booksCompleted": "완독한 책", + "booksInProgress": "읽는 중인 책", + "powerReader": "다독가", + "booksRead": "{{count}}권" } }, "server/email": { @@ -2773,6 +2804,7 @@ "noValidFiles": "올바른 파일이 선택되지 않았습니다" }, "unknownError": "알 수 없는 오류가 발생했습니다", + "goHome": "홈으로 이동", "nothingToShow": "표시할 항목이 없습니다", "noResultsToDisplay": "표시할 결과가 없습니다", "noResultsReturned": "검색 결과가 없습니다", From 619ce0c6d6b7a62925ccd9515c4d4ccc97e64348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 17:58:45 +0900 Subject: [PATCH 21/54] =?UTF-8?q?feat(i18n):=20=ED=83=90=EC=83=89=EA=B3=BC?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=20=EB=AC=B8=EA=B5=AC=20=EA=B5=AD=EC=A0=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/ServerStatusOverlay.tsx | 10 ++++--- packages/browser/src/components/UserMenu.tsx | 16 ++++++----- .../src/components/book/BookSearch.tsx | 4 ++- .../src/components/book/BookSearchOverlay.tsx | 8 +++--- .../components/explorer/upload/UploadMenu.tsx | 8 +++--- .../explorer/upload/UploadModal.tsx | 9 +++---- .../components/filters/FilterSlideOver.tsx | 14 +++++----- .../navigation/topbar/sections/UserMenu.tsx | 8 +++--- .../library/LibraryNavigationItem.tsx | 16 +++++++---- .../items/table/FilterBottomDrawer.tsx | 2 +- packages/i18n/src/locales/en-US.json | 27 +++++++++++++++++++ packages/i18n/src/locales/ko-KR.json | 27 +++++++++++++++++++ 12 files changed, 110 insertions(+), 39 deletions(-) diff --git a/packages/browser/src/components/ServerStatusOverlay.tsx b/packages/browser/src/components/ServerStatusOverlay.tsx index cb7977543..e5243adfe 100644 --- a/packages/browser/src/components/ServerStatusOverlay.tsx +++ b/packages/browser/src/components/ServerStatusOverlay.tsx @@ -1,10 +1,12 @@ import { Link, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { AnimatePresence, motion } from 'framer-motion' import { useEffect, useState } from 'react' import { useAppStore } from '@/stores' export default function ServerStatusOverlay() { + const { t } = useLocaleContext() const [show, setShow] = useState(false) const isConnected = useAppStore((store) => store.isConnectedWithServer) @@ -39,7 +41,7 @@ export default function ServerStatusOverlay() { >
- Server is not connected + {t('serverStatus.disconnected')}
@@ -49,11 +51,11 @@ export default function ServerStatusOverlay() {
- Please check your internet connection.{' '} + {t('serverStatus.checkConnection')}{' '} - Click here + {t('serverStatus.changeUrlLink')} {' '} - to change your server URL. + {t('serverStatus.changeUrlSuffix')}
diff --git a/packages/browser/src/components/UserMenu.tsx b/packages/browser/src/components/UserMenu.tsx index c771d4a1b..87d80ebcc 100644 --- a/packages/browser/src/components/UserMenu.tsx +++ b/packages/browser/src/components/UserMenu.tsx @@ -1,4 +1,5 @@ import { Avatar, cn, ConfirmationModal, Dropdown, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { Bell, Server, Settings } from 'lucide-react' import { useState } from 'react' import { useNavigate } from 'react-router-dom' @@ -14,6 +15,7 @@ type Props = { } export default function UserMenu({ variant = 'sidebar' }: Props) { + const { t } = useLocaleContext() const [isOpen, setIsOpen] = useState(false) const [isSignOutConfirmOpen, setIsSignOutConfirmOpen] = useState(false) const navigate = useNavigate() @@ -30,9 +32,9 @@ export default function UserMenu({ variant = 'sidebar' }: Props) { return ( <> setIsSignOutConfirmOpen(false)} @@ -87,7 +89,7 @@ export default function UserMenu({ variant = 'sidebar' }: Props) { - Notifications + {t('common.notifications')} - Settings + {t('sidebar.buttons.settings')} @@ -105,7 +107,7 @@ export default function UserMenu({ variant = 'sidebar' }: Props) { {platform !== 'browser' && ( navigate('/')} className={itemClasses(isSidebar)}> - Switch server + {t('common.switchServer')} )} @@ -114,7 +116,7 @@ export default function UserMenu({ variant = 'sidebar' }: Props) { isDestructive className={itemClasses(isSidebar, true)} > - Sign out + {t('signOutModal.buttons.signOut')} diff --git a/packages/browser/src/components/book/BookSearch.tsx b/packages/browser/src/components/book/BookSearch.tsx index 0fa10a29c..fb15cf41d 100644 --- a/packages/browser/src/components/book/BookSearch.tsx +++ b/packages/browser/src/components/book/BookSearch.tsx @@ -1,6 +1,7 @@ import { useInfiniteGraphQL } from '@stump/client' import { Input } from '@stump/components' import { BookCardFragment, graphql } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useState } from 'react' import { useDebouncedValue } from 'rooks' @@ -38,6 +39,7 @@ const query = graphql(` * a filter slide over. Must be used within a `FilterProvider`. */ export default function BookSearch({ onBookSelect }: Props) { + const { t } = useLocaleContext() const [search, setSearch] = useState('') const [debouncedValue] = useDebouncedValue(search, 500) @@ -70,7 +72,7 @@ export default function BookSearch({ onBookSelect }: Props) { return (
setSearch(e.target.value)} /> diff --git a/packages/browser/src/components/book/BookSearchOverlay.tsx b/packages/browser/src/components/book/BookSearchOverlay.tsx index 35f75e8f9..8975b3a78 100644 --- a/packages/browser/src/components/book/BookSearchOverlay.tsx +++ b/packages/browser/src/components/book/BookSearchOverlay.tsx @@ -1,5 +1,6 @@ import { Button, Sheet } from '@stump/components' import { BookCardFragment } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { Search } from 'lucide-react' import { useState } from 'react' @@ -17,6 +18,7 @@ type Props = { } export default function BookSearchOverlay({ onBookSelect, sheetProps }: Props) { + const { t } = useLocaleContext() const [isOpen, setIsOpen] = useState(false) const renderTrigger = () => { @@ -27,7 +29,7 @@ export default function BookSearchOverlay({ onBookSelect, sheetProps }: Props) { return ( ) } @@ -42,8 +44,8 @@ export default function BookSearchOverlay({ onBookSelect, sheetProps }: Props) { open={isOpen} onClose={() => setIsOpen(false)} onOpen={() => setIsOpen(true)} - title="Search for a book" - description={sheetProps?.prompt || 'You can use the search bar below to find a book'} + title={t('bookSearch.title')} + description={sheetProps?.prompt || t('bookSearch.description')} trigger={renderTrigger()} size="xl" > diff --git a/packages/browser/src/components/explorer/upload/UploadMenu.tsx b/packages/browser/src/components/explorer/upload/UploadMenu.tsx index 8793d9171..285ff4f10 100644 --- a/packages/browser/src/components/explorer/upload/UploadMenu.tsx +++ b/packages/browser/src/components/explorer/upload/UploadMenu.tsx @@ -1,4 +1,5 @@ import { Dropdown, IconButton, ToolTip } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { BookPlus, FolderPlus, Upload } from 'lucide-react' import { useSeriesContextSafe } from '@/scenes/series' @@ -8,11 +9,12 @@ type Props = { } export default function UploadMenu({ onSelect }: Props) { + const { t } = useLocaleContext() const enableSeries = useSeriesContextSafe() == null return ( - + @@ -24,13 +26,13 @@ export default function UploadMenu({ onSelect }: Props) { onSelect('books')}> - Add books + {t('fileExplorer.uploadModal.menu.addBooks')} {enableSeries && ( onSelect('series')}> - Add series + {t('fileExplorer.uploadModal.menu.addSeries')} )} diff --git a/packages/browser/src/components/explorer/upload/UploadModal.tsx b/packages/browser/src/components/explorer/upload/UploadModal.tsx index c4f95ed1f..d77bba805 100644 --- a/packages/browser/src/components/explorer/upload/UploadModal.tsx +++ b/packages/browser/src/components/explorer/upload/UploadModal.tsx @@ -310,13 +310,10 @@ export default function UploadModal() { {uploadType === 'series' && (
- Series name - - This will be used as the name of the series directory. Your zip archive will be - unpacked here. - + {t(getKey('seriesName.heading'))} + {t(getKey('seriesName.description'))} setSeriesDirName(e.target.value)} className="mt-2" diff --git a/packages/browser/src/components/filters/FilterSlideOver.tsx b/packages/browser/src/components/filters/FilterSlideOver.tsx index a406113e1..a6ef7e8c7 100644 --- a/packages/browser/src/components/filters/FilterSlideOver.tsx +++ b/packages/browser/src/components/filters/FilterSlideOver.tsx @@ -1,5 +1,6 @@ import { Button, Sheet, Text } from '@stump/components' import { LibraryFilterInput, MediaFilterInput, SeriesFilterInput } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { Filter } from 'lucide-react' import { useState } from 'react' import { useMediaMatch } from 'rooks' @@ -24,6 +25,7 @@ type Props = { * A component that renders a slide over with filter options. */ export default function FilterSlideOver({ prompt, formVariant }: Props) { + const { t } = useLocaleContext() const { filters, setFilters } = useFilterContext() const [isOpen, setIsOpen] = useState(false) @@ -50,7 +52,7 @@ export default function FilterSlideOver({ prompt, formVariant }: Props) { } else if (formVariant === 'series') { return } else { - return Not implemented yet + return {t('common.unimplemented')} } } @@ -59,12 +61,12 @@ export default function FilterSlideOver({ prompt, formVariant }: Props) { open={isOpen} onClose={onClose} onOpen={onOpen} - title="Filter options" - description={prompt || 'Use the options below to narrow your search'} + title={t('common.filterOptions')} + description={prompt || t('common.filterDescription')} trigger={ } @@ -72,11 +74,11 @@ export default function FilterSlideOver({ prompt, formVariant }: Props) { footer={
} diff --git a/packages/browser/src/components/navigation/topbar/sections/UserMenu.tsx b/packages/browser/src/components/navigation/topbar/sections/UserMenu.tsx index b36eeb45c..508f31aa5 100644 --- a/packages/browser/src/components/navigation/topbar/sections/UserMenu.tsx +++ b/packages/browser/src/components/navigation/topbar/sections/UserMenu.tsx @@ -1,5 +1,6 @@ import { invalidateQueries, useSDK } from '@stump/client' import { Avatar, cn, NavigationMenu } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { Bell, LogOut } from 'lucide-react' import { useNavigate } from 'react-router' import { toast } from 'sonner' @@ -12,6 +13,7 @@ import TopBarButtonItem from '../TopBarButtonItem' import TopBarLinkListItem from '../TopBarLinkListItem' export default function UserMenu() { + const { t } = useLocaleContext() const { sdk } = useSDK() const { user } = useAppContext() @@ -26,7 +28,7 @@ export default function UserMenu() { navigate('/auth') } catch (error) { console.error(error) - toast.error('There was an error logging you out. Please try again.') + toast.error(t('common.unknownError')) } } @@ -56,12 +58,12 @@ export default function UserMenu() { isDisabled > - Notifications + {t('common.notifications')} - Logout + {t('signOutModal.buttons.signOut')} diff --git a/packages/browser/src/components/navigation/topbar/sections/library/LibraryNavigationItem.tsx b/packages/browser/src/components/navigation/topbar/sections/library/LibraryNavigationItem.tsx index 7edc2d9f9..1260b1c31 100644 --- a/packages/browser/src/components/navigation/topbar/sections/library/LibraryNavigationItem.tsx +++ b/packages/browser/src/components/navigation/topbar/sections/library/LibraryNavigationItem.tsx @@ -1,6 +1,7 @@ import { useSDK, useSuspenseGraphQL } from '@stump/client' import { cn, cx, Label, NavigationMenu, ScrollArea, Text } from '@stump/components' import { FilterableArrangementEntityLink, graphql, UserPermission } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { CircleSlash2, Library, LibrarySquare } from 'lucide-react' import { Suspense, useMemo } from 'react' import { useLocation } from 'react-router-dom' @@ -31,6 +32,7 @@ export default function LibraryNavigationItem({ links = [FilterableArrangementEntityLink.Create], width, }: Props) { + const { t } = useLocaleContext() const { sdk } = useSDK() const { data: { @@ -56,9 +58,9 @@ export default function LibraryNavigationItem({
- + - You don't have any libraries yet + {t('noLibraries.message.create')}
@@ -99,7 +101,7 @@ export default function LibraryNavigationItem({ - Libraries + {t('sidebar.buttons.libraries')}
- Create library + + {t('sidebar.buttons.createLibrary')} + )} @@ -134,7 +138,9 @@ export default function LibraryNavigationItem({ isActive={location.pathname.startsWith(paths.libraries())} className="p-1 justify-center self-end border border-dashed border-sidebar-border" > - See all + + {t('sidebar.buttons.seeAll')} + )}
diff --git a/packages/browser/src/scenes/smartList/items/table/FilterBottomDrawer.tsx b/packages/browser/src/scenes/smartList/items/table/FilterBottomDrawer.tsx index 653043b24..b3a73ee1e 100644 --- a/packages/browser/src/scenes/smartList/items/table/FilterBottomDrawer.tsx +++ b/packages/browser/src/scenes/smartList/items/table/FilterBottomDrawer.tsx @@ -62,7 +62,7 @@ export default function FilterBottomDrawer() { - This functionality is not yet implemented + {t('common.unimplemented')}
diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index faa497782..5bccea15f 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -592,6 +592,10 @@ }, "fileExplorer": { "uploadModal": { + "menu": { + "addBooks": "Add books", + "addSeries": "Add series" + }, "title": { "books": "Upload books", "series": "Upload series" @@ -611,8 +615,24 @@ "alt": "Or click to browse your computer for books to upload" }, "addedFiles": "Added files" + ,"seriesName": { + "heading": "Series name", + "description": "This will be used as the name of the series directory. Your zip archive will be unpacked here.", + "placeholder": "Enter series name" + } } }, + "bookSearch": { + "title": "Search for a book", + "description": "You can use the search bar below to find a book", + "placeholder": "Search for a book..." + }, + "serverStatus": { + "disconnected": "Server is not connected", + "checkConnection": "Please check your internet connection.", + "changeUrlLink": "Click here", + "changeUrlSuffix": "to change your server URL." + }, "libraryHeader": { "tabs": { "books": "Books", @@ -2820,6 +2840,13 @@ "confirmSelection": "Confirm selection", "failedToUploadImage": "Failed to upload image", "failedToUpdateThumbnail": "Failed to update thumbnail" + ,"switchServer": "Switch server", + "notifications": "Notifications", + "filterOptions": "Filter options", + "filterDescription": "Use the options below to narrow your search", + "filter": "Filter", + "clearFilters": "Clear filters", + "applyFilters": "Apply filters" }, "tableColumns": { "configuration": { diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 15484a4b2..6d4bed774 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -592,6 +592,10 @@ }, "fileExplorer": { "uploadModal": { + "menu": { + "addBooks": "책 추가", + "addSeries": "시리즈 추가" + }, "title": { "books": "책 업로드", "series": "시리즈 업로드" @@ -611,8 +615,24 @@ "alt": "또는 클릭하여 업로드할 책을 선택하세요" }, "addedFiles": "추가된 파일" + ,"seriesName": { + "heading": "시리즈 이름", + "description": "시리즈 디렉터리 이름으로 사용됩니다. ZIP 파일은 이 디렉터리에 압축 해제됩니다.", + "placeholder": "시리즈 이름 입력" + } } }, + "bookSearch": { + "title": "책 검색", + "description": "아래 검색창에서 책을 찾을 수 있습니다", + "placeholder": "책 검색..." + }, + "serverStatus": { + "disconnected": "서버에 연결되지 않았습니다", + "checkConnection": "인터넷 연결을 확인하세요.", + "changeUrlLink": "여기를 눌러", + "changeUrlSuffix": "서버 URL을 변경할 수 있습니다." + }, "libraryHeader": { "tabs": { "books": "책", @@ -2820,6 +2840,13 @@ "confirmSelection": "선택 확인", "failedToUploadImage": "이미지를 업로드하지 못했습니다", "failedToUpdateThumbnail": "썸네일을 업데이트하지 못했습니다" + ,"switchServer": "서버 전환", + "notifications": "알림", + "filterOptions": "필터 옵션", + "filterDescription": "아래 옵션을 사용해 검색 범위를 좁힐 수 있습니다", + "filter": "필터", + "clearFilters": "필터 지우기", + "applyFilters": "필터 적용" }, "tableColumns": { "configuration": { From 7f418f86838bae2a0582a999e05d3c39c7c9bbe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 18:00:38 +0900 Subject: [PATCH 22/54] =?UTF-8?q?feat(i18n):=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=EA=B3=BC=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../login-activity/LoginActivitySection.tsx | 6 +- .../login-activity/LoginActivityTable.tsx | 141 ++++++++-------- .../users/user-table/DeleteUserModal.tsx | 13 +- .../users/user-table/InspectUserSlideOver.tsx | 8 +- .../server/users/user-table/UserTable.tsx | 151 ++++++++++-------- .../users/user-table/UserTableSection.tsx | 8 +- packages/i18n/src/locales/en-US.json | 42 +++++ packages/i18n/src/locales/ko-KR.json | 42 +++++ 8 files changed, 260 insertions(+), 151 deletions(-) diff --git a/packages/browser/src/scenes/settings/server/users/login-activity/LoginActivitySection.tsx b/packages/browser/src/scenes/settings/server/users/login-activity/LoginActivitySection.tsx index f39f81e70..bec9cda9d 100644 --- a/packages/browser/src/scenes/settings/server/users/login-activity/LoginActivitySection.tsx +++ b/packages/browser/src/scenes/settings/server/users/login-activity/LoginActivitySection.tsx @@ -1,4 +1,5 @@ import { Heading, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { Suspense } from 'react' import ClearLoginActivityConfirmation from './ClearActivityConfirmation' @@ -6,13 +7,14 @@ import LoginActivityTable from './LoginActivityTable' // TODO(i8n): add key/values export default function LoginActivitySection() { + const { t } = useLocaleContext() return (
- Authentication history + {t('settingsScene.server/users.loginActivity.heading')} - All login attempts are logged and stored for security purposes + {t('settingsScene.server/users.loginActivity.description')}
diff --git a/packages/browser/src/scenes/settings/server/users/login-activity/LoginActivityTable.tsx b/packages/browser/src/scenes/settings/server/users/login-activity/LoginActivityTable.tsx index b88baaffe..a93e7cc80 100644 --- a/packages/browser/src/scenes/settings/server/users/login-activity/LoginActivityTable.tsx +++ b/packages/browser/src/scenes/settings/server/users/login-activity/LoginActivityTable.tsx @@ -1,6 +1,7 @@ import { useSDK, useSuspenseGraphQL } from '@stump/client' import { Badge, Card, Text } from '@stump/components' import { graphql, LoginActivityTableQuery } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { Api } from '@stump/sdk' import { QueryClient } from '@tanstack/react-query' import { @@ -12,6 +13,7 @@ import { import { intlFormat } from 'date-fns' import { Fingerprint, Slash } from 'lucide-react' import { useState } from 'react' +import { useMemo } from 'react' import { Table } from '@/components/table' @@ -46,6 +48,7 @@ export const prefetchLoginActivity = async (sdk: Api, client: QueryClient) => }) export default function LoginActivityTable() { + const { t } = useLocaleContext() const { sdk } = useSDK() const { data: { loginActivity }, @@ -55,6 +58,7 @@ export default function LoginActivityTable() { pageIndex: 0, pageSize: 10, }) + const columns = useMemo(() => getBaseColumns(t), [t]) if (!loginActivity?.length && !pagination.pageIndex) { return ( @@ -68,9 +72,9 @@ export default function LoginActivityTable() {
- No login activity + {t('settingsScene.server/users.loginActivity.empty')} - You cleared this, didn't you? + {t('settingsScene.server/users.loginActivity.emptyDescription')}
@@ -83,7 +87,7 @@ export default function LoginActivityTable() { () -const baseColumns = [ - columnHelper.display({ - cell: ({ - row: { - original: { user }, +const getBaseColumns = (t: ReturnType['t']) => + [ + columnHelper.display({ + cell: ({ + row: { + original: { user }, + }, + }) => { + if (!user) { + return null + } + return }, - }) => { - if (!user) { - return null - } - return - }, - header: 'User', - id: 'user', - size: 100, - }), - columnHelper.accessor('timestamp', { - cell: ({ row: { original: activity } }) => { - const formatted = intlFormat(new Date(activity.timestamp), { - month: 'long', - day: 'numeric', - year: 'numeric', - hour: 'numeric', - minute: '2-digit', - }) - return ( - - {formatted} + header: t('settingsScene.server/users.loginActivity.columns.user'), + id: 'user', + size: 100, + }), + columnHelper.accessor('timestamp', { + cell: ({ row: { original: activity } }) => { + const formatted = intlFormat(new Date(activity.timestamp), { + month: 'long', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) + return ( + + {formatted} + + ) + }, + header: t('settingsScene.server/users.loginActivity.columns.timestamp'), + size: 100, + }), + columnHelper.accessor('ipAddress', { + cell: ({ row: { original: activity } }) => ( + + {activity.ipAddress} - ) - }, - header: 'Timestamp', - size: 100, - }), - columnHelper.accessor('ipAddress', { - cell: ({ row: { original: activity } }) => ( - - {activity.ipAddress} - - ), - header: 'IP address', - size: 100, - }), - columnHelper.accessor('userAgent', { - cell: ({ row: { original: activity } }) => ( - - {activity.userAgent} - - ), - header: 'User-agent', - }), - columnHelper.display({ - cell: ({ row: { original: activity } }) => ( - - {activity.authenticationSuccessful ? 'Success' : 'Failure'} - - ), - header: 'Auth result', - id: 'authenticationSuccessful', - }), -] as ColumnDef[] + ), + header: t('settingsScene.server/users.loginActivity.columns.ipAddress'), + size: 100, + }), + columnHelper.accessor('userAgent', { + cell: ({ row: { original: activity } }) => ( + + {activity.userAgent} + + ), + header: t('settingsScene.server/users.loginActivity.columns.userAgent'), + }), + columnHelper.display({ + cell: ({ row: { original: activity } }) => ( + + {activity.authenticationSuccessful + ? t('settingsScene.server/users.loginActivity.success') + : t('settingsScene.server/users.loginActivity.failure')} + + ), + header: t('settingsScene.server/users.loginActivity.columns.result'), + id: 'authenticationSuccessful', + }), + ] as ColumnDef[] diff --git a/packages/browser/src/scenes/settings/server/users/user-table/DeleteUserModal.tsx b/packages/browser/src/scenes/settings/server/users/user-table/DeleteUserModal.tsx index d059b59ad..40f7e8db7 100644 --- a/packages/browser/src/scenes/settings/server/users/user-table/DeleteUserModal.tsx +++ b/packages/browser/src/scenes/settings/server/users/user-table/DeleteUserModal.tsx @@ -1,6 +1,7 @@ import { useGraphQLMutation, useSDK } from '@stump/client' import { Button, CheckBox, Dialog } from '@stump/components' import { graphql } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useQueryClient } from '@tanstack/react-query' import { useCallback, useState } from 'react' @@ -20,6 +21,7 @@ type Props = { } export default function DeleteUserModal({ deletingUser, onClose }: Props) { + const { t } = useLocaleContext() const { sdk } = useSDK() const [hardDelete, setHardDelete] = useState(false) @@ -44,10 +46,9 @@ export default function DeleteUserModal({ deletingUser, onClose }: Props) { - Delete User Account + {t('settingsScene.server/users.table.delete.title')} - Are you sure you want to delete this user? If you select the hard delete option, this - user and all of their data will be permanently deleted. + {t('settingsScene.server/users.table.delete.description')} @@ -55,7 +56,7 @@ export default function DeleteUserModal({ deletingUser, onClose }: Props) {
setHardDelete((prev) => !prev)} /> @@ -63,10 +64,10 @@ export default function DeleteUserModal({ deletingUser, onClose }: Props) {
diff --git a/packages/browser/src/scenes/settings/server/users/user-table/InspectUserSlideOver.tsx b/packages/browser/src/scenes/settings/server/users/user-table/InspectUserSlideOver.tsx index a262f6675..43923dcb2 100644 --- a/packages/browser/src/scenes/settings/server/users/user-table/InspectUserSlideOver.tsx +++ b/packages/browser/src/scenes/settings/server/users/user-table/InspectUserSlideOver.tsx @@ -1,5 +1,6 @@ import { Sheet } from '@stump/components' import { Preformatted } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { User } from './UserTable' @@ -10,14 +11,15 @@ type Props = { // TODO: do more than just json dump export default function InspectUserSlideOver({ user, onClose }: Props) { + const { t } = useLocaleContext() return ( - + ) } diff --git a/packages/browser/src/scenes/settings/server/users/user-table/UserTable.tsx b/packages/browser/src/scenes/settings/server/users/user-table/UserTable.tsx index aa7318de4..2f2327fb3 100644 --- a/packages/browser/src/scenes/settings/server/users/user-table/UserTable.tsx +++ b/packages/browser/src/scenes/settings/server/users/user-table/UserTable.tsx @@ -1,6 +1,7 @@ import { useGraphQL, useSDK } from '@stump/client' import { Badge, Card, Text, ToolTip } from '@stump/components' import { graphql, UserTableQuery } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { Api } from '@stump/sdk' import { QueryClient } from '@tanstack/react-query' import { ColumnDef, createColumnHelper, PaginationState } from '@tanstack/react-table' @@ -67,6 +68,7 @@ export const prefetchUsersTable = async (sdk: Api, client: QueryClient) => export type User = NonNullable['users']>['nodes'][number] export default function UserTable() { + const { t } = useLocaleContext() const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10, @@ -104,7 +106,7 @@ export default function UserTable() { const columns = useMemo( () => [ - ...baseColumns, + ...getBaseColumns(t), columnHelper.display({ cell: ({ row: { original } }) => (
@@ -119,7 +121,7 @@ export default function UserTable() { size: 28, }), ], - [], + [t], ) return ( @@ -150,71 +152,80 @@ export default function UserTable() { const columnHelper = createColumnHelper() -const baseColumns = [ - columnHelper.accessor('username', { - cell: ({ row: { original: user } }) => , - header: 'User', - }), - columnHelper.display({ - cell: ({ - row: { - original: { isServerOwner }, - }, - }) => {isServerOwner ? 'Server Owner' : 'Member'}, - header: 'Role', - id: 'isServerOwner', - }), - columnHelper.accessor('createdAt', { - cell: ({ - row: { - original: { createdAt }, - }, - }) => ( - - {intlFormat(new Date(createdAt), { month: 'long', day: 'numeric', year: 'numeric' })} - - ), - header: 'Created at', - }), - columnHelper.accessor('lastLogin', { - cell: ({ - row: { - original: { lastLogin }, - }, - }) => ( - - {lastLogin - ? intlFormat(new Date(lastLogin), { month: 'long', day: 'numeric', year: 'numeric' }) - : 'Never'} - - ), - header: 'Last login', - }), - columnHelper.display({ - cell: ({ row: { original } }) => ( - - {original.loginSessionsCount} - - ), - header: () => ( -
- Sessions - - - -
- ), - id: 'loginSessionsCount', - size: 110, - }), - columnHelper.display({ - cell: ({ row: { original } }) => ( - - {original.isLocked ? 'Locked' : 'Active'} - - ), - header: 'Status', - id: 'isLocked', - size: 100, - }), -] as ColumnDef[] +const getBaseColumns = (t: ReturnType['t']) => + [ + columnHelper.accessor('username', { + cell: ({ row: { original: user } }) => , + header: t('settingsScene.server/users.table.columns.user'), + }), + columnHelper.display({ + cell: ({ + row: { + original: { isServerOwner }, + }, + }) => ( + + {isServerOwner + ? t('settingsScene.server/users.table.serverOwner') + : t('settingsScene.server/users.table.member')} + + ), + header: t('settingsScene.server/users.table.columns.role'), + id: 'isServerOwner', + }), + columnHelper.accessor('createdAt', { + cell: ({ + row: { + original: { createdAt }, + }, + }) => ( + + {intlFormat(new Date(createdAt), { month: 'long', day: 'numeric', year: 'numeric' })} + + ), + header: t('settingsScene.server/users.table.columns.createdAt'), + }), + columnHelper.accessor('lastLogin', { + cell: ({ + row: { + original: { lastLogin }, + }, + }) => ( + + {lastLogin + ? intlFormat(new Date(lastLogin), { month: 'long', day: 'numeric', year: 'numeric' }) + : t('common.never')} + + ), + header: t('settingsScene.server/users.table.columns.lastLogin'), + }), + columnHelper.display({ + cell: ({ row: { original } }) => ( + + {original.loginSessionsCount} + + ), + header: () => ( +
+ {t('settingsScene.server/users.table.columns.sessions')} + + + +
+ ), + id: 'loginSessionsCount', + size: 110, + }), + columnHelper.display({ + cell: ({ row: { original } }) => ( + + {original.isLocked + ? t('settingsScene.server/users.table.locked') + : t('settingsScene.server/users.table.active')} + + ), + header: t('settingsScene.server/users.table.columns.status'), + id: 'isLocked', + size: 100, + }), + ] as ColumnDef[] diff --git a/packages/browser/src/scenes/settings/server/users/user-table/UserTableSection.tsx b/packages/browser/src/scenes/settings/server/users/user-table/UserTableSection.tsx index 882208f8f..998578062 100644 --- a/packages/browser/src/scenes/settings/server/users/user-table/UserTableSection.tsx +++ b/packages/browser/src/scenes/settings/server/users/user-table/UserTableSection.tsx @@ -1,21 +1,23 @@ import { ButtonOrLink, Heading, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { Suspense } from 'react' import UserTable from './UserTable' export default function UserTableSection() { + const { t } = useLocaleContext() return (
- Existing accounts + {t('settingsScene.server/users.table.heading')} - A list of all existing accounts on the server + {t('settingsScene.server/users.table.description')}
- Create user + {t('settingsScene.server/users.table.createUser')}
diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index 5bccea15f..9ec2e2b59 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -2377,6 +2377,19 @@ "updateSubmitButton": "Update user" }, "loginActivity": { + "heading": "Authentication history", + "description": "All login attempts are logged and stored for security purposes", + "empty": "No login activity", + "emptyDescription": "No authentication attempts have been recorded", + "columns": { + "user": "User", + "timestamp": "Timestamp", + "ipAddress": "IP address", + "userAgent": "User agent", + "result": "Auth result" + }, + "success": "Success", + "failure": "Failure", "deleteConfirmation": { "title": "Delete activity", "description": "All records of login activity will be permanently deleted from the database", @@ -2389,6 +2402,35 @@ "booksInProgress": "Books in progress", "powerReader": "Power reader", "booksRead": "{{count}} books" + }, + "table": { + "heading": "Existing accounts", + "description": "A list of all existing accounts on the server", + "createUser": "Create user", + "columns": { + "user": "User", + "role": "Role", + "createdAt": "Created at", + "lastLogin": "Last login", + "sessions": "Sessions", + "sessionsDescription": "The number of non-expired login sessions for this user", + "status": "Status" + }, + "serverOwner": "Server owner", + "member": "Member", + "locked": "Locked", + "active": "Active", + "inspect": { + "title": "Inspect user", + "description": "Inspect a user's information and configuration", + "rawJson": "Raw JSON" + }, + "delete": { + "title": "Delete user account", + "description": "Are you sure you want to delete this user? If you select the hard delete option, this user and all of their data will be permanently deleted.", + "hardDelete": "Hard delete user", + "confirm": "Delete user" + } } }, "server/email": { diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 6d4bed774..59298e68f 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -2377,6 +2377,19 @@ "updateSubmitButton": "사용자 수정" }, "loginActivity": { + "heading": "인증 기록", + "description": "보안을 위해 모든 로그인 시도를 기록하고 저장합니다", + "empty": "로그인 활동 없음", + "emptyDescription": "기록된 인증 시도가 없습니다", + "columns": { + "user": "사용자", + "timestamp": "시각", + "ipAddress": "IP 주소", + "userAgent": "사용자 에이전트", + "result": "인증 결과" + }, + "success": "성공", + "failure": "실패", "deleteConfirmation": { "title": "활동 기록 삭제", "description": "모든 로그인 활동 기록을 데이터베이스에서 영구적으로 삭제합니다", @@ -2389,6 +2402,35 @@ "booksInProgress": "읽는 중인 책", "powerReader": "다독가", "booksRead": "{{count}}권" + }, + "table": { + "heading": "기존 계정", + "description": "서버에 있는 모든 계정 목록", + "createUser": "사용자 만들기", + "columns": { + "user": "사용자", + "role": "역할", + "createdAt": "생성일", + "lastLogin": "마지막 로그인", + "sessions": "세션", + "sessionsDescription": "이 사용자의 만료되지 않은 로그인 세션 수", + "status": "상태" + }, + "serverOwner": "서버 소유자", + "member": "일반 사용자", + "locked": "잠김", + "active": "활성", + "inspect": { + "title": "사용자 살펴보기", + "description": "사용자의 정보와 구성을 살펴봅니다", + "rawJson": "원시 JSON" + }, + "delete": { + "title": "사용자 계정 삭제", + "description": "이 사용자를 삭제하시겠습니까? 완전 삭제 옵션을 선택하면 사용자와 관련 데이터가 모두 영구적으로 삭제됩니다.", + "hardDelete": "사용자 완전 삭제", + "confirm": "사용자 삭제" + } } }, "server/email": { From b29a6ae6700329c5c8d5d98c228905218f69738d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 18:03:50 +0900 Subject: [PATCH 23/54] =?UTF-8?q?feat(i18n):=20=EC=9B=B9=20=EB=A6=AC?= =?UTF-8?q?=EB=8D=94=20=EB=AC=B8=EA=B5=AC=20=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../readers/epub/controls/FontSizeControl.tsx | 6 ++- .../epub/controls/LineHeightControl.tsx | 4 +- .../epub/controls/ReadingDirection.tsx | 8 ++-- .../readers/epub/controls/ReadingMode.tsx | 8 ++-- .../readers/epub/controls/ThemeControls.tsx | 8 ++-- .../readers/epub/locations/Bookmarks.tsx | 4 +- .../epub/locations/LocationManager.tsx | 10 ++-- .../epub/locations/TableOfContents.tsx | 4 +- .../container/BrightnessControl.tsx | 4 +- .../container/DoubleSpreadBehavior.tsx | 10 ++-- .../container/ImageScalingSelect.tsx | 12 +++-- .../imageBased/container/NextInSeries.tsx | 6 ++- .../imageBased/container/ReaderSettings.tsx | 24 ++++++---- .../container/ReadingDirectionSelect.tsx | 8 ++-- .../container/ReadingModeSelect.tsx | 10 ++-- .../imageBased/container/SettingsDialog.tsx | 6 ++- .../imageBased/container/TimerMenu.tsx | 6 ++- packages/i18n/src/locales/en-US.json | 46 +++++++++++++++++++ packages/i18n/src/locales/ko-KR.json | 46 +++++++++++++++++++ 19 files changed, 181 insertions(+), 49 deletions(-) diff --git a/packages/browser/src/components/readers/epub/controls/FontSizeControl.tsx b/packages/browser/src/components/readers/epub/controls/FontSizeControl.tsx index c87b41581..056fd1192 100644 --- a/packages/browser/src/components/readers/epub/controls/FontSizeControl.tsx +++ b/packages/browser/src/components/readers/epub/controls/FontSizeControl.tsx @@ -1,4 +1,5 @@ import { cx, IconButton, Label, Text, TEXT_VARIANTS } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { Minus, Plus } from 'lucide-react' import { useCallback, useEffect, useState } from 'react' @@ -8,6 +9,7 @@ import { useBookPreferences } from '@/scenes/book/reader/useBookPreferences' import { useEpubReaderContext } from '../context' export default function FontSizeControl() { + const { t } = useLocaleContext() const { readerMeta: { bookEntity }, } = useEpubReaderContext() @@ -46,7 +48,7 @@ export default function FontSizeControl() { return (
- +
{localFontSize > 50 && ( - Live font preview is capped at 50px + {t('readerUi.fontPreviewLimit')} )}
diff --git a/packages/browser/src/components/readers/epub/controls/LineHeightControl.tsx b/packages/browser/src/components/readers/epub/controls/LineHeightControl.tsx index 36e2507ce..fc69d4c4b 100644 --- a/packages/browser/src/components/readers/epub/controls/LineHeightControl.tsx +++ b/packages/browser/src/components/readers/epub/controls/LineHeightControl.tsx @@ -1,4 +1,5 @@ import { cx, IconButton, Label, TEXT_VARIANTS } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { Minus, Plus } from 'lucide-react' import { useCallback, useEffect, useState } from 'react' @@ -8,6 +9,7 @@ import { useBookPreferences } from '@/scenes/book/reader/useBookPreferences' import { useEpubReaderContext } from '../context' export default function LineHeightControl() { + const { t } = useLocaleContext() const { readerMeta: { bookEntity }, } = useEpubReaderContext() @@ -40,7 +42,7 @@ export default function LineHeightControl() { return (
- +
- + - + - + - Appearance + {t('readerUi.appearance')} @@ -32,7 +34,7 @@ export default function ThemeControls() { return ( - + diff --git a/packages/browser/src/components/readers/epub/locations/Bookmarks.tsx b/packages/browser/src/components/readers/epub/locations/Bookmarks.tsx index 9cb38c5a6..ff1479ad4 100644 --- a/packages/browser/src/components/readers/epub/locations/Bookmarks.tsx +++ b/packages/browser/src/components/readers/epub/locations/Bookmarks.tsx @@ -1,4 +1,5 @@ import { Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { useCallback, useMemo } from 'react' import GenericEmptyState from '@/components/GenericEmptyState' @@ -9,6 +10,7 @@ type Props = { onLocationChanged?: () => void } export default function Bookmarks({ onLocationChanged }: Props) { + const { t } = useLocaleContext() const { readerMeta: { bookMeta }, controls: { onGoToCfi }, @@ -29,7 +31,7 @@ export default function Bookmarks({ onLocationChanged }: Props) { ) if (!bookmarksArray.length) { - return + return } return ( diff --git a/packages/browser/src/components/readers/epub/locations/LocationManager.tsx b/packages/browser/src/components/readers/epub/locations/LocationManager.tsx index d78e4e0eb..312de9da9 100644 --- a/packages/browser/src/components/readers/epub/locations/LocationManager.tsx +++ b/packages/browser/src/components/readers/epub/locations/LocationManager.tsx @@ -1,4 +1,5 @@ import { Dialog, Tabs, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { List } from 'lucide-react' import { useCallback, useState } from 'react' @@ -9,6 +10,7 @@ import TableOfContents from './TableOfContents' type LocationTab = 'contents' | 'annotations' | 'bookmarks' export default function LocationManager() { + const { t } = useLocaleContext() const [isOpen, setIsOpen] = useState(false) const [activeTab, setActiveTab] = useState('contents') @@ -40,7 +42,7 @@ export default function LocationManager() { return ( - + setIsOpen(true)} /> @@ -49,11 +51,11 @@ export default function LocationManager() { handleTabChange('contents')}> - Contents + {t('readerUi.contents')} handleTabChange('bookmarks')}> - Bookmarks + {t('readerUi.bookmarks')} handleTabChange('annotations')} disabled > - Annotations + {t('readerUi.annotations')} diff --git a/packages/browser/src/components/readers/epub/locations/TableOfContents.tsx b/packages/browser/src/components/readers/epub/locations/TableOfContents.tsx index 3fd427e7f..b39bcbb53 100644 --- a/packages/browser/src/components/readers/epub/locations/TableOfContents.tsx +++ b/packages/browser/src/components/readers/epub/locations/TableOfContents.tsx @@ -1,4 +1,5 @@ import { Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { useCallback } from 'react' import { EpubContent, useEpubReaderContext } from '../context' @@ -29,6 +30,7 @@ function TableOfContentsItem({ item, handleSelect }: ItemProps) { } export default function TableOfContents({ onLocationChanged }: Props) { + const { t } = useLocaleContext() const { readerMeta, controls } = useEpubReaderContext() const { toc } = readerMeta.bookMeta || {} @@ -43,7 +45,7 @@ export default function TableOfContents({ onLocationChanged }: Props) { return (
{toc?.map((item) => ( diff --git a/packages/browser/src/components/readers/imageBased/container/BrightnessControl.tsx b/packages/browser/src/components/readers/imageBased/container/BrightnessControl.tsx index dd64c83bc..e95c056d9 100644 --- a/packages/browser/src/components/readers/imageBased/container/BrightnessControl.tsx +++ b/packages/browser/src/components/readers/imageBased/container/BrightnessControl.tsx @@ -1,4 +1,5 @@ import { Label, Slider } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { useCallback, useMemo } from 'react' import { useBookPreferences } from '@/scenes/book/reader/useBookPreferences' @@ -6,6 +7,7 @@ import { useBookPreferences } from '@/scenes/book/reader/useBookPreferences' import { useImageBaseReaderContext } from '../context' export default function BrightnessControl() { + const { t } = useLocaleContext() const { book } = useImageBaseReaderContext() const { bookPreferences: { brightness }, @@ -28,7 +30,7 @@ export default function BrightnessControl() { return (
- + ) => { if (isDoublePageBehavior(e.target.value)) { @@ -21,14 +23,14 @@ export default function DoubleSpreadBehavior({ behavior, onChange }: Props) { return (
- + - +
- + {nextInSeries.name}
@@ -57,7 +59,7 @@ export default function NextInSeries() { /> - Read + {t('readerUi.read')}
diff --git a/packages/browser/src/components/readers/imageBased/container/ReaderSettings.tsx b/packages/browser/src/components/readers/imageBased/container/ReaderSettings.tsx index 02488b449..4cd566247 100644 --- a/packages/browser/src/components/readers/imageBased/container/ReaderSettings.tsx +++ b/packages/browser/src/components/readers/imageBased/container/ReaderSettings.tsx @@ -1,6 +1,7 @@ import { BookPreferences, DEFAULT_BOOK_PREFERENCES } from '@stump/client' import { Label, RawSwitch } from '@stump/components' import { ReadingMode } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import omit from 'lodash/omit' import { useCallback, useMemo } from 'react' import { useSearchParams } from 'react-router-dom' @@ -18,6 +19,7 @@ type Props = { } export default function ReaderSettings({ forBook, currentPage }: Props) { + const { t } = useLocaleContext() const [search, setSearch] = useSearchParams() const store = useReaderStore((state) => state) @@ -105,7 +107,9 @@ export default function ReaderSettings({ forBook, currentPage }: Props) { return (
- +
- +
- +
@@ -75,7 +76,7 @@ export default function MemberSpecDisplay() {
diff --git a/packages/browser/src/scenes/bookClub/tabs/settings/scheduler/AddBookCard.tsx b/packages/browser/src/scenes/bookClub/tabs/settings/scheduler/AddBookCard.tsx index dadb95329..8af340926 100644 --- a/packages/browser/src/scenes/bookClub/tabs/settings/scheduler/AddBookCard.tsx +++ b/packages/browser/src/scenes/bookClub/tabs/settings/scheduler/AddBookCard.tsx @@ -1,5 +1,6 @@ import { AspectRatio, Button, Card, DatePicker, Heading, Input, Text } from '@stump/components' import { BookCardFragment } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useCallback, useEffect, useMemo, useState } from 'react' import { useFormContext } from 'react-hook-form' @@ -15,6 +16,7 @@ type Props = { // FIXME(clubs): this component is a MESS and desperately needs a rewrite export default function AddBookCard({ index }: Props) { + const { t } = useLocaleContext() const [selectedBook, setSelectedBook] = useState(null) const form = useFormContext() @@ -75,13 +77,13 @@ export default function AddBookCard({ index }: Props) { onClick={() => setSelectedBook(null)} className="shrink-0" > - Remove selection + {t('bookClubUi.removeSelection')} ) - }, [selectedBook]) + }, [selectedBook, t]) const renderSelectedBookOptions = useCallback(() => { if (selectedBook) { @@ -97,9 +99,9 @@ export default function AddBookCard({ index }: Props) { return ( <>
- Pick a book + {t('bookClubUi.pickBook')} - You can add a book from your library + {t('bookClubUi.pickBookDescription')}
@@ -109,7 +111,7 @@ export default function AddBookCard({ index }: Props) { ) - }, [isDefiningExternalBook, renderBookInfo, renderSelectedBookOptions]) + }, [isDefiningExternalBook, renderBookInfo, renderSelectedBookOptions, t]) const renderExternalBookOption = () => { if (isEntityBook) return null @@ -117,29 +119,28 @@ export default function AddBookCard({ index }: Props) { return ( <>
- Add an external book + {t('bookClubUi.addExternalBook')} - If you want to add a book that isn't in your library, you can add information about - it here + {t('bookClubUi.addExternalBookDescription')}
@@ -150,28 +151,27 @@ export default function AddBookCard({ index }: Props) { return ( {renderBookPickerOption()} - {!isEntityBook && !isDefiningExternalBook && } + {!isEntityBook && !isDefiningExternalBook && } {renderExternalBookOption()} - +
- Configure the dates + {t('bookClubUi.configureDates')} - If not provided, the start and end dates will be set to a reasonable default based on the - last book's end date + {t('bookClubUi.configureDatesDescription')}
form.setValue(`books.${index}.startAt`, date?.toISOString())} /> form.setValue(`books.${index}.endAt`, date?.toISOString())} /> diff --git a/packages/browser/src/scenes/bookClub/tabs/settings/scheduler/CreateOrAddToScheduleForm.tsx b/packages/browser/src/scenes/bookClub/tabs/settings/scheduler/CreateOrAddToScheduleForm.tsx index 6fb1ce837..85983409e 100644 --- a/packages/browser/src/scenes/bookClub/tabs/settings/scheduler/CreateOrAddToScheduleForm.tsx +++ b/packages/browser/src/scenes/bookClub/tabs/settings/scheduler/CreateOrAddToScheduleForm.tsx @@ -1,5 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { Form, Input } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { useCallback } from 'react' import { useForm, useWatch } from 'react-hook-form' import { z } from 'zod' @@ -33,6 +34,7 @@ export const defaultBook = { } as BookSchema export default function CreateOrAddToScheduleForm() { + const { t } = useLocaleContext() const form = useForm({ defaultValues: { books: [defaultBook], @@ -70,7 +72,7 @@ export default function CreateOrAddToScheduleForm() { return (
Date: Thu, 6 Aug 2026 18:25:54 +0900 Subject: [PATCH 30/54] =?UTF-8?q?feat(i18n):=20=EC=9B=B9=20=EC=9E=94?= =?UTF-8?q?=EC=97=AC=20=ED=99=94=EB=A9=B4=20=EB=AC=B8=EA=B5=AC=20=EA=B5=AD?= =?UTF-8?q?=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../metadataMatching/PendingMatchesTable.tsx | 10 ++++++---- .../src/components/readers/epub/EpubReaderHeader.tsx | 4 +++- .../readers/epub/controls/SearchCommand.tsx | 10 ++++++---- .../readers/imageBased/container/ReaderHeader.tsx | 4 +++- .../src/components/readers/pdf/NativePDFViewer.tsx | 7 +++++-- .../browser/src/scenes/auth/LoginOrClaimScene.tsx | 4 +++- .../bookClub/createClub/CreateBookClubScene.tsx | 4 +++- .../src/scenes/bookSearch/BookSearchScene.tsx | 4 +++- .../src/scenes/smartList/UserSmartListsScene.tsx | 7 +++---- .../settings/access/ChangeVisibilityConfirmation.tsx | 2 +- packages/i18n/src/locales/en-US.json | 12 ++++++++++++ packages/i18n/src/locales/ko-KR.json | 12 ++++++++++++ 12 files changed, 60 insertions(+), 20 deletions(-) diff --git a/packages/browser/src/components/metadata/metadataMatching/PendingMatchesTable.tsx b/packages/browser/src/components/metadata/metadataMatching/PendingMatchesTable.tsx index 4cffbc9b9..d89c72537 100644 --- a/packages/browser/src/components/metadata/metadataMatching/PendingMatchesTable.tsx +++ b/packages/browser/src/components/metadata/metadataMatching/PendingMatchesTable.tsx @@ -54,6 +54,7 @@ type PendingMatchRow = { const columnHelper = createColumnHelper() function ReviewButton({ records, startIndex }: { records: MatchRecord[]; startIndex: number }) { + const { t } = useLocaleContext() const open = useMatchReviewStore((s) => s.open) return (
@@ -61,7 +62,7 @@ function ReviewButton({ records, startIndex }: { records: MatchRecord[]; startIn size="icon" variant="ghost" className="h-7 w-7 shrink-0" - title="Review match" + title={t(getKey('reviewMatch'))} onClick={() => open(records, startIndex)} > @@ -92,7 +93,8 @@ export function PendingMatchesTable() { const topCandidate = candidates[0] return { id: record.id, - entityName: record.media?.resolvedName ?? record.series?.resolvedName ?? 'Unknown', + entityName: + record.media?.resolvedName ?? record.series?.resolvedName ?? t('common.unknown'), entityType: record.mediaId ? 'Media' : 'Series', entityId: record.mediaId ?? record.seriesId ?? '', candidateCount: candidates.length, @@ -101,7 +103,7 @@ export function PendingMatchesTable() { record, } }), - [records], + [records, t], ) const columns = useMemo( @@ -120,7 +122,7 @@ export function PendingMatchesTable() { acceptAllPendingMatchesMutation, { onSuccess: () => { - toast.success('All pending matches accepted') + toast.success(t(getKey('acceptAll.success'))) client.invalidateQueries({ predicate: ({ queryKey }) => queryKey.some((key) => typeof key === 'string' && key === 'pendingMetadataMatches'), diff --git a/packages/browser/src/components/readers/epub/EpubReaderHeader.tsx b/packages/browser/src/components/readers/epub/EpubReaderHeader.tsx index 21cbbb0d1..1a37f4778 100644 --- a/packages/browser/src/components/readers/epub/EpubReaderHeader.tsx +++ b/packages/browser/src/components/readers/epub/EpubReaderHeader.tsx @@ -1,4 +1,5 @@ import { cn, Spacer, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { ArrowLeft } from 'lucide-react' import { Link } from 'react-router-dom' @@ -17,6 +18,7 @@ import { import { LocationManager } from './locations' export default function EpubReaderHeader() { + const { t } = useLocaleContext() const { readerMeta: { bookEntity }, } = useEpubReaderContext() @@ -27,7 +29,7 @@ export default function EpubReaderHeader() { return (
- + diff --git a/packages/browser/src/components/readers/epub/controls/SearchCommand.tsx b/packages/browser/src/components/readers/epub/controls/SearchCommand.tsx index ecd14ecf8..c91512799 100644 --- a/packages/browser/src/components/readers/epub/controls/SearchCommand.tsx +++ b/packages/browser/src/components/readers/epub/controls/SearchCommand.tsx @@ -1,4 +1,5 @@ import { cn, Command, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { Search } from 'lucide-react' import { useCallback, useEffect, useState } from 'react' @@ -8,6 +9,7 @@ import { SpineSearchResult, useEpubReaderContext } from '../context' import ControlButton from './ControlButton' export default function SearchCommand() { + const { t } = useLocaleContext() const { readerMeta, controls: { searchEntireBook, onGoToCfi }, @@ -81,16 +83,16 @@ export default function SearchCommand() { item = toc?.find((i) => i.play_order === adjustedIdx) } - return item?.label || `Spine item ${idx}` + return item?.label || t('readerUi.spineItem', { index: idx }) }, - [toc], + [toc, t], ) const renderResults = () => { if (!results) { return null } else if (!results.length) { - return No results found. + return {t('readerUi.noSearchResults')} } else { return results.map(({ spineIndex, results }, idx) => ( @@ -146,7 +148,7 @@ export default function SearchCommand() {
diff --git a/packages/browser/src/components/readers/pdf/NativePDFViewer.tsx b/packages/browser/src/components/readers/pdf/NativePDFViewer.tsx index 95b8273d5..efe1c6e2f 100644 --- a/packages/browser/src/components/readers/pdf/NativePDFViewer.tsx +++ b/packages/browser/src/components/readers/pdf/NativePDFViewer.tsx @@ -1,5 +1,6 @@ import { useSDK } from '@stump/client' import { Link, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { useEffect, useState } from 'react' type Props = { @@ -18,6 +19,7 @@ type Props = { * such as reading progress, bookmarks, and annotations. */ export default function NativePDFViewer({ id }: Props) { + const { t } = useLocaleContext() const { sdk } = useSDK() const [pdfObjectUrl, setPdfObjectUrl] = useState() @@ -55,8 +57,9 @@ export default function NativePDFViewer({ id }: Props) { return ( - PDF failed to load. Click here attempt - downloading it directly. + {t('readerUi.pdfLoadFailed')}{' '} + {t('readerUi.downloadPdf')}{' '} + {t('readerUi.downloadPdfSuffix')} ) diff --git a/packages/browser/src/scenes/auth/LoginOrClaimScene.tsx b/packages/browser/src/scenes/auth/LoginOrClaimScene.tsx index e6b0f9cca..6d739bd13 100644 --- a/packages/browser/src/scenes/auth/LoginOrClaimScene.tsx +++ b/packages/browser/src/scenes/auth/LoginOrClaimScene.tsx @@ -207,7 +207,9 @@ export default function LoginOrClaimScene() {
- Or + + {t('authScene.or')} +
)} diff --git a/packages/browser/src/scenes/bookClub/createClub/CreateBookClubScene.tsx b/packages/browser/src/scenes/bookClub/createClub/CreateBookClubScene.tsx index b40731289..4e6474d18 100644 --- a/packages/browser/src/scenes/bookClub/createClub/CreateBookClubScene.tsx +++ b/packages/browser/src/scenes/bookClub/createClub/CreateBookClubScene.tsx @@ -1,5 +1,6 @@ import { useGraphQLMutation } from '@stump/client' import { CreateBookClubInput, graphql } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { BookClubMemberRoleSpec } from '@stump/sdk' import { Suspense, useCallback, useState } from 'react' import { Helmet } from 'react-helmet' @@ -26,6 +27,7 @@ const mutation = graphql(` `) export default function CreateBookClubScene() { + const { t } = useLocaleContext() const [formStep, setFormStep] = useState(1) const navigate = useNavigate() @@ -67,7 +69,7 @@ export default function CreateBookClubScene() { return (
- Stump | Create a book club + Stump | {t('createBookClubScene.heading')} () const { filters: mediaFilters, @@ -335,7 +337,7 @@ function BookSearchScene() { >
- Stump | Books + Stump | {t('sidebar.buttons.books')}
diff --git a/packages/browser/src/scenes/smartList/UserSmartListsScene.tsx b/packages/browser/src/scenes/smartList/UserSmartListsScene.tsx index 38ba4d052..ca81b4eec 100644 --- a/packages/browser/src/scenes/smartList/UserSmartListsScene.tsx +++ b/packages/browser/src/scenes/smartList/UserSmartListsScene.tsx @@ -11,7 +11,6 @@ import { import { graphql, SmartListsInput } from '@stump/graphql' import { useLocaleContext } from '@stump/i18n' import { Search } from 'lucide-react' -import pluralize from 'pluralize' import { useEffect, useState } from 'react' import { useDebouncedValue } from 'rooks' @@ -119,13 +118,13 @@ export default function UserSmartListsScene() {
- Smart lists + {t(withLocaleKey('heading'))} - Your favorite searches and filters saved for easy access + {t(withLocaleKey('subtitle'))}
- You have access to {smartLists.length} smart {pluralize('list', smartLists.length)} + {t(withLocaleKey('accessCount'), { count: smartLists.length })}
diff --git a/packages/browser/src/scenes/smartList/settings/access/ChangeVisibilityConfirmation.tsx b/packages/browser/src/scenes/smartList/settings/access/ChangeVisibilityConfirmation.tsx index 93adf8e84..7e048d44f 100644 --- a/packages/browser/src/scenes/smartList/settings/access/ChangeVisibilityConfirmation.tsx +++ b/packages/browser/src/scenes/smartList/settings/access/ChangeVisibilityConfirmation.tsx @@ -23,7 +23,7 @@ export default function ChangeVisibilityConfirmation({ return ( Date: Thu, 6 Aug 2026 18:28:44 +0900 Subject: [PATCH 31/54] =?UTF-8?q?feat(i18n):=20=EC=9B=B9=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../explorer/FileExplorerProvider.tsx | 6 ++- .../components/explorer/table/FileTable.tsx | 38 +++++++-------- .../explorer/upload/UploadModal.tsx | 47 ++++++++++--------- .../src/components/filters/OrderBy.tsx | 4 +- .../src/components/filters/URLPageSize.tsx | 4 +- .../filters/form/ExtensionSelect.tsx | 6 ++- .../components/filters/form/OrderBySelect.tsx | 6 ++- .../metadataEditor/MetadataEditorHeader.tsx | 8 ++-- .../browser/src/components/table/Table.tsx | 22 ++++----- .../src/components/table/TableFooter.tsx | 9 ++-- .../components/thumbnail/UploadImageModal.tsx | 43 ++++++++++------- packages/i18n/src/locales/en-US.json | 23 ++++++++- packages/i18n/src/locales/ko-KR.json | 23 ++++++++- 13 files changed, 148 insertions(+), 91 deletions(-) diff --git a/packages/browser/src/components/explorer/FileExplorerProvider.tsx b/packages/browser/src/components/explorer/FileExplorerProvider.tsx index 098d525ab..fc495cf7b 100644 --- a/packages/browser/src/components/explorer/FileExplorerProvider.tsx +++ b/packages/browser/src/components/explorer/FileExplorerProvider.tsx @@ -1,4 +1,5 @@ import { useDirectoryListing, UseDirectoryListingFile, useSDK } from '@stump/client' +import { useLocaleContext } from '@stump/i18n' import { useCallback, useState } from 'react' import { useNavigate } from 'react-router' import { toast } from 'sonner' @@ -16,6 +17,7 @@ type Props = Pick // TODO: refactor to match other explore scenes, e.g. sticky header + fixed footer + window scrolling export default function FileExplorerProvider({ rootPath, ...ctx }: Props) { + const { t } = useLocaleContext() const navigate = useNavigate() const { sdk } = useSDK() @@ -52,11 +54,11 @@ export default function FileExplorerProvider({ rootPath, ...ctx }: Props) { }, }) } else { - toast.error('No associated DB entry found for this file') + toast.error(t('fileExplorer.noDatabaseEntry')) } } catch (err) { console.error(err) - toast.error('An unknown error occurred') + toast.error(t('common.unknownError')) } } } diff --git a/packages/browser/src/components/explorer/table/FileTable.tsx b/packages/browser/src/components/explorer/table/FileTable.tsx index 4d08106e5..f12a452c0 100644 --- a/packages/browser/src/components/explorer/table/FileTable.tsx +++ b/packages/browser/src/components/explorer/table/FileTable.tsx @@ -1,6 +1,7 @@ /* eslint-disable react/prop-types */ import { UseDirectoryListingFile } from '@stump/client' import { cn, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { createColumnHelper, flexRender, @@ -21,24 +22,8 @@ import { useFileExplorerContext } from '../context' import FileThumbnail from '../FileThumbnail' const columnHelper = createColumnHelper() -const baseColumns = [ - columnHelper.display({ - cell: ({ - row: { - original: { path, isDirectory }, - }, - }) => , - header: () => ( - - Cover - - ), - id: 'thumbnail', - size: 10, - }), -] - export default function FileTable() { + const { t } = useLocaleContext() const { files, onSelect, loadMore } = useFileExplorerContext() const { innerWidth } = useWindowSize() @@ -47,7 +32,20 @@ export default function FileTable() { const columns = useMemo( () => [ - ...baseColumns.slice(0, 1), + columnHelper.display({ + cell: ({ + row: { + original: { path, isDirectory }, + }, + }) => , + header: () => ( + + {t('fileExplorer.table.cover')} + + ), + id: 'thumbnail', + size: 10, + }), columnHelper.accessor('name', { cell: ({ row: { original: file }, getValue }) => ( ( - Name + {t('fileExplorer.table.name')} ), size: innerWidth ? innerWidth * 0.2 : 250, @@ -71,7 +69,7 @@ export default function FileTable() { // which is obviously not what we want enableSorting: false, })), - [onSelect, innerWidth], + [onSelect, innerWidth, t], ) const table = useReactTable({ diff --git a/packages/browser/src/components/explorer/upload/UploadModal.tsx b/packages/browser/src/components/explorer/upload/UploadModal.tsx index d77bba805..e5ea11363 100644 --- a/packages/browser/src/components/explorer/upload/UploadModal.tsx +++ b/packages/browser/src/components/explorer/upload/UploadModal.tsx @@ -105,24 +105,27 @@ export default function UploadModal() { const isUploading = isUploadingBooks || isUploadingSeries - const handleDrop = useCallback((acceptedFiles: File[], rejections: FileRejection[]) => { - if (rejections.length) { - console.warn('Some files were rejected:', rejections) - toast.error('Some files were rejected. Please check the file type and size') - } + const handleDrop = useCallback( + (acceptedFiles: File[], rejections: FileRejection[]) => { + if (rejections.length) { + console.warn('Some files were rejected:', rejections) + toast.error(t('common.fileUpload.someRejected')) + } - setFiles((prev) => { - const existingFingerprints = new Set(prev.map(({ file }) => getFileFingerprint(file))) - const nextEntries = acceptedFiles - .filter((file) => !existingFingerprints.has(getFileFingerprint(file))) - .map((file) => ({ - id: `${nextFileIdRef.current++}`, - file, - })) + setFiles((prev) => { + const existingFingerprints = new Set(prev.map(({ file }) => getFileFingerprint(file))) + const nextEntries = acceptedFiles + .filter((file) => !existingFingerprints.has(getFileFingerprint(file))) + .map((file) => ({ + id: `${nextFileIdRef.current++}`, + file, + })) - return [...prev, ...nextEntries] - }) - }, []) + return [...prev, ...nextEntries] + }) + }, + [t], + ) const { getRootProps, getInputProps, isFileDialogActive, isDragActive } = useDropzone({ accept: { @@ -152,13 +155,13 @@ export default function UploadModal() { async (params: UploadBooksInput) => { try { await uploadBooks({ input: params }) - toast.success('Successfully uploaded file(s)') + toast.success(t('fileExplorer.uploadModal.booksUploaded')) } catch (error) { console.error(error) - toast.error('Failed to upload book(s)') + toast.error(t('fileExplorer.uploadModal.booksUploadFailed')) } }, - [uploadBooks], + [uploadBooks, t], ) const enableSeries = useSeriesContextSafe() == null @@ -178,12 +181,12 @@ export default function UploadModal() { seriesDirName, }, }) - toast.success('Successfully uploaded series') + toast.success(t('fileExplorer.uploadModal.seriesUploaded')) } catch (error) { console.error(error) - toast.error('Failed to upload series') + toast.error(t('fileExplorer.uploadModal.seriesUploadFailed')) } - }, [uploadSeries, files, seriesDirName, currentPath, libraryID, enableSeries]) + }, [uploadSeries, files, seriesDirName, currentPath, libraryID, enableSeries, t]) const onUploadClicked = useCallback(async () => { // Return if files is empty diff --git a/packages/browser/src/components/filters/OrderBy.tsx b/packages/browser/src/components/filters/OrderBy.tsx index 7cfc14ca0..c96a89d46 100644 --- a/packages/browser/src/components/filters/OrderBy.tsx +++ b/packages/browser/src/components/filters/OrderBy.tsx @@ -1,5 +1,6 @@ import { Button, cx, Popover } from '@stump/components' import { OrderDirection } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { SortAsc } from 'lucide-react' import { useState } from 'react' import { useMediaMatch } from 'rooks' @@ -11,6 +12,7 @@ type Props = { entity: FilterableEntity } export default function OrderBy({ entity }: Props) { + const { t } = useLocaleContext() const [isOpen, setIsOpen] = useState(false) const isMobile = useMediaMatch('(max-width: 768px)') @@ -40,7 +42,7 @@ export default function OrderBy({ entity }: Props) { })} > - Order By + {t('controlUi.orderBy')} diff --git a/packages/browser/src/components/filters/URLPageSize.tsx b/packages/browser/src/components/filters/URLPageSize.tsx index 529670665..ffcbda5f1 100644 --- a/packages/browser/src/components/filters/URLPageSize.tsx +++ b/packages/browser/src/components/filters/URLPageSize.tsx @@ -1,9 +1,11 @@ import { Input, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { useCallback, useEffect, useState } from 'react' import { useURLPageParams } from './useFilterScene' export default function URLPageSize() { + const { t } = useLocaleContext() const { pageSize, setPageSize } = useURLPageParams() const [inputPageSize, setInputPageSize] = useState(pageSize) @@ -37,7 +39,7 @@ export default function URLPageSize() { min={1} /> - per page + {t('controlUi.perPage')} ) diff --git a/packages/browser/src/components/filters/form/ExtensionSelect.tsx b/packages/browser/src/components/filters/form/ExtensionSelect.tsx index 4f218d47b..d5137a8cd 100644 --- a/packages/browser/src/components/filters/form/ExtensionSelect.tsx +++ b/packages/browser/src/components/filters/form/ExtensionSelect.tsx @@ -1,19 +1,21 @@ import { Label, NativeSelect } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { useFormContext } from 'react-hook-form' import { MediaFilterFormSchema } from './MediaFilterForm' export default function ExtensionSelect() { + const { t } = useLocaleContext() const form = useFormContext() return (
void } export default function OrderBySelect({ entity, value, onChange }: Props) { + const { t } = useLocaleContext() const entityOptions = useMemo( () => options[entity].map((option) => ({ label: (option as string).toLowerCase(), value: option })), @@ -56,11 +58,11 @@ export default function OrderBySelect({ entity, value, onChange }: Props) { return (
onChange?.(e.target.value as OrderingField)} size="sm" diff --git a/packages/browser/src/components/metadata/metadataEditor/MetadataEditorHeader.tsx b/packages/browser/src/components/metadata/metadataEditor/MetadataEditorHeader.tsx index 13663a205..de6c7eb2f 100644 --- a/packages/browser/src/components/metadata/metadataEditor/MetadataEditorHeader.tsx +++ b/packages/browser/src/components/metadata/metadataEditor/MetadataEditorHeader.tsx @@ -1,8 +1,10 @@ import { Button } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { MetadataEditorState, useMetadataEditorContext } from './context' export const MetadataEditorHeader = () => { + const { t } = useLocaleContext() const { state, setState, isEditing, onCancel, onSave } = useMetadataEditorContext() // Note: I had to separate the buttons because dynamically setting `type` based @@ -17,13 +19,13 @@ export const MetadataEditorHeader = () => { className="h-7 px-2.5 rounded-lg" onClick={onCancel} > - Cancel + {t('common.cancel')} )} {isEditing && ( )} @@ -35,7 +37,7 @@ export const MetadataEditorHeader = () => { className="h-7 px-2.5 rounded-lg" onClick={() => setState(MetadataEditorState.Editing)} > - Edit + {t('common.edit')} )}
diff --git a/packages/browser/src/components/table/Table.tsx b/packages/browser/src/components/table/Table.tsx index 566b633aa..818fa37ee 100644 --- a/packages/browser/src/components/table/Table.tsx +++ b/packages/browser/src/components/table/Table.tsx @@ -1,4 +1,5 @@ import { cn, Heading, NativeSelect, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { Column, ColumnDef, @@ -55,6 +56,7 @@ export default function Table({ totalCount, ...props }: TableProps) { + const { t } = useLocaleContext() const rootRef = useRef(null) const viewportRef = useRef(null) @@ -225,24 +227,20 @@ export default function Table({
- {tableRows.length > 0 ? ( - <> - - {viewBounds.firstIndex} to{' '} - {viewBounds.lastIndex} - - of {viewBounds.totalCount} - - ) : ( - 'Nothing to show' - )} + {tableRows.length > 0 + ? t('controlUi.tableRange', { + first: viewBounds.firstIndex, + last: viewBounds.lastIndex, + total: viewBounds.totalCount, + }) + : t('common.nothingToShow')} ({ - label: `Show ${pageSize} rows`, + label: t('controlUi.showRows', { count: pageSize }), // FIXME: don't cast once my select can consume numbers :nomnom: value: pageSize.toString(), }))} diff --git a/packages/browser/src/components/table/TableFooter.tsx b/packages/browser/src/components/table/TableFooter.tsx index b6c0b4a2f..b74318f50 100644 --- a/packages/browser/src/components/table/TableFooter.tsx +++ b/packages/browser/src/components/table/TableFooter.tsx @@ -1,4 +1,5 @@ import { NativeSelect, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { PaginationState } from '@tanstack/react-table' import TablePagination from './Pagination' @@ -11,6 +12,7 @@ type Props = { } export default function TableFooter({ pagination, setPagination, pageCount, dataCount }: Props) { + const { t } = useLocaleContext() const firstIndex = pagination.pageIndex * pagination.pageSize + 1 const lastIndex = Math.min(firstIndex + pagination.pageSize - 1, dataCount) @@ -20,17 +22,14 @@ export default function TableFooter({ pagination, setPagination, pageCount, data
- - {firstIndex} to {lastIndex} - - of {dataCount} + {t('controlUi.tableRange', { first: firstIndex, last: lastIndex, total: dataCount })} ({ - label: `Show ${pageSize} rows`, + label: t('controlUi.showRows', { count: pageSize }), // FIXME: don't cast once my select can consume numbers :nomnom: value: pageSize.toString(), }))} diff --git a/packages/browser/src/components/thumbnail/UploadImageModal.tsx b/packages/browser/src/components/thumbnail/UploadImageModal.tsx index 51a44b24b..989c471d9 100644 --- a/packages/browser/src/components/thumbnail/UploadImageModal.tsx +++ b/packages/browser/src/components/thumbnail/UploadImageModal.tsx @@ -20,21 +20,30 @@ export default function UploadImageModal({ isOpen, onClose, onUploadImage }: Pro const [selectedFile, setSelectedFile] = useState(null) const [filePreview, setFilePreview] = useState(null) - const onDrop = useCallback((acceptedFiles: File[], fileRejections: FileRejection[]) => { - if (fileRejections.length > 0) { - console.error(fileRejections) - const firstError = fileRejections[0]?.errors[0] - const isTooLarge = firstError?.code === 'file-too-large' - toast.error(isTooLarge ? 'File too large (20MB max)' : firstError?.message || 'Unknown error') - } else if (acceptedFiles.length > 1 || !acceptedFiles.length) { - toast.error(acceptedFiles.length ? 'Only 1 file allowed' : 'No files provided') - } else if (acceptedFiles[0]) { - const file = acceptedFiles[0] + const onDrop = useCallback( + (acceptedFiles: File[], fileRejections: FileRejection[]) => { + if (fileRejections.length > 0) { + console.error(fileRejections) + const firstError = fileRejections[0]?.errors[0] + const isTooLarge = firstError?.code === 'file-too-large' + toast.error( + isTooLarge + ? t(withLocaleKey('fileTooLarge')) + : firstError?.message || t('common.unknownError'), + ) + } else if (acceptedFiles.length > 1 || !acceptedFiles.length) { + toast.error( + acceptedFiles.length ? t(withLocaleKey('onlyOneFile')) : t(withLocaleKey('noFile')), + ) + } else if (acceptedFiles[0]) { + const file = acceptedFiles[0] - setSelectedFile(file) - setFilePreview(URL.createObjectURL(file)) - } - }, []) + setSelectedFile(file) + setFilePreview(URL.createObjectURL(file)) + } + }, + [t], + ) const { getRootProps, getInputProps } = useDropzone({ accept: { @@ -51,7 +60,7 @@ export default function UploadImageModal({ isOpen, onClose, onUploadImage }: Pro await onUploadImage(selectedFile) } catch (error) { console.error(error) - toast.error('Failed to upload image') + toast.error(t('common.failedToUploadImage')) } } } @@ -116,10 +125,10 @@ export default function UploadImageModal({ isOpen, onClose, onUploadImage }: Pro diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index c8526e7dd..3d34caa1f 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -592,7 +592,16 @@ } }, "fileExplorer": { + "table": { + "cover": "Cover", + "name": "Name" + }, + "noDatabaseEntry": "No associated database entry was found for this file", "uploadModal": { + "booksUploaded": "Books uploaded successfully", + "booksUploadFailed": "Failed to upload books", + "seriesUploaded": "Series uploaded successfully", + "seriesUploadFailed": "Failed to upload series", "menu": { "addBooks": "Add books", "addSeries": "Add series" @@ -743,7 +752,14 @@ "goToSettings": "Go to settings", "lastVisited": "Last visited", "constructionIllustration": "Construction illustration", - "ofPages": "of {{pages}}" + "ofPages": "of {{pages}}", + "tableRange": "{{first}} to {{last}} of {{total}}", + "showRows": "Show {{count}} rows", + "perPage": "per page", + "orderBy": "Order by", + "extension": "Extension", + "any": "Any", + "selectOption": "Select an option" }, "filterUi": { "search": "Search", @@ -3060,7 +3076,10 @@ "uploadImage": { "emptyState": "A preview of your image will appear here", "prompt": "Drop image here or click to select", - "remove": "Remove image" + "remove": "Remove image", + "fileTooLarge": "File too large (20MB max)", + "onlyOneFile": "Only one file is allowed", + "noFile": "No file was provided" } }, "common": { diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 401925a7d..f5d7131b0 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -592,7 +592,16 @@ } }, "fileExplorer": { + "table": { + "cover": "표지", + "name": "이름" + }, + "noDatabaseEntry": "이 파일과 연결된 데이터베이스 항목을 찾을 수 없습니다", "uploadModal": { + "booksUploaded": "책을 업로드했습니다", + "booksUploadFailed": "책을 업로드하지 못했습니다", + "seriesUploaded": "시리즈를 업로드했습니다", + "seriesUploadFailed": "시리즈를 업로드하지 못했습니다", "menu": { "addBooks": "책 추가", "addSeries": "시리즈 추가" @@ -743,7 +752,14 @@ "goToSettings": "설정으로 이동", "lastVisited": "최근 방문", "constructionIllustration": "공사 중임을 나타내는 그림", - "ofPages": "전체 {{pages}}페이지" + "ofPages": "전체 {{pages}}페이지", + "tableRange": "전체 {{total}}개 중 {{first}}~{{last}}", + "showRows": "{{count}}개 행 표시", + "perPage": "페이지당", + "orderBy": "정렬 기준", + "extension": "확장자", + "any": "전체", + "selectOption": "옵션 선택" }, "filterUi": { "search": "검색", @@ -3060,7 +3076,10 @@ "uploadImage": { "emptyState": "이미지 미리보기가 여기에 표시됩니다", "prompt": "이미지를 끌어다 놓거나 클릭하여 선택하세요", - "remove": "이미지 제거" + "remove": "이미지 제거", + "fileTooLarge": "파일이 너무 큽니다(최대 20MB)", + "onlyOneFile": "파일은 하나만 선택할 수 있습니다", + "noFile": "선택한 파일이 없습니다" } }, "common": { From 1a22a6a6efaa99ba6b86618b67fd852cf23e655a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 18:32:03 +0900 Subject: [PATCH 32/54] =?UTF-8?q?feat(i18n):=20=EC=B1=85=EA=B3=BC=20?= =?UTF-8?q?=EB=A6=AC=EB=8D=94=20=EC=83=81=ED=83=9C=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../browser/src/components/book/BookCard.tsx | 9 +++--- .../book/metadata/MediaMetadataEditor.tsx | 4 +-- .../components/readers/epub/EpubJsReader.tsx | 18 ++++++------ .../imageBased/container/ReaderFooter.tsx | 7 +++-- .../src/components/series/SeriesCard.tsx | 9 +++--- .../series/metadata/SeriesMetadataEditor.tsx | 4 +-- .../src/components/series/table/columns.tsx | 28 +++++++++---------- .../src/scenes/book/BookActionMenu.tsx | 6 ++-- .../src/scenes/home/ContinueReading.tsx | 3 +- packages/i18n/src/locales/en-US.json | 10 +++++++ packages/i18n/src/locales/ko-KR.json | 10 +++++++ 11 files changed, 67 insertions(+), 41 deletions(-) diff --git a/packages/browser/src/components/book/BookCard.tsx b/packages/browser/src/components/book/BookCard.tsx index f6e43d710..42be1926e 100644 --- a/packages/browser/src/components/book/BookCard.tsx +++ b/packages/browser/src/components/book/BookCard.tsx @@ -2,7 +2,7 @@ import { getThumbnailTintColor } from '@stump/client' import { formatBytes } from '@stump/client' import { cn, ProgressBar, Text } from '@stump/components' import { FragmentType, graphql, useFragment } from '@stump/graphql' -import pluralize from 'pluralize' +import { useLocaleContext } from '@stump/i18n' import { memo, useCallback, useMemo } from 'react' import { Link } from '@/context' @@ -65,6 +65,7 @@ const BookCard = memo(function BookCard({ onSelect, fullWidth = true, }: Props) { + const { t } = useLocaleContext() const data = useFragment(BookCardFragment, fragment) const paths = usePaths() @@ -133,7 +134,7 @@ const BookCard = memo(function BookCard({ if (isMissing) { return ( - File Missing + {t('entityUi.fileMissing')} ) } @@ -146,7 +147,7 @@ const BookCard = memo(function BookCard({ {!isEbookProgress && ( - {pagesLeft} {pluralize('page', pagesLeft)} left + {t('entityUi.pagesLeft', { count: pagesLeft })} )}
@@ -154,7 +155,7 @@ const BookCard = memo(function BookCard({ } else if (progressPercent === 100) { return ( - Completed + {t('entityUi.completed')} ) } diff --git a/packages/browser/src/components/book/metadata/MediaMetadataEditor.tsx b/packages/browser/src/components/book/metadata/MediaMetadataEditor.tsx index 390290150..de4d82919 100644 --- a/packages/browser/src/components/book/metadata/MediaMetadataEditor.tsx +++ b/packages/browser/src/components/book/metadata/MediaMetadataEditor.tsx @@ -304,14 +304,14 @@ export default function MediaMetadataEditor({ mediaId, data }: Props) { }, onError: (error) => { console.error('Failed to update metadata', error) - toast.error('Failed to update metadata') + toast.error(t('entityUi.metadataUpdateFailed')) }, }) const { mutate: setLocked } = useGraphQLMutation(setLockedFieldsMutation, { onError: () => { setLockedFields(new Set(metadata?.lockedFields ?? [])) - toast.error('Failed to update locked fields') + toast.error(t('entityUi.lockedFieldsUpdateFailed')) }, }) diff --git a/packages/browser/src/components/readers/epub/EpubJsReader.tsx b/packages/browser/src/components/readers/epub/EpubJsReader.tsx index f4a6eeca1..303f349f4 100644 --- a/packages/browser/src/components/readers/epub/EpubJsReader.tsx +++ b/packages/browser/src/components/readers/epub/EpubJsReader.tsx @@ -8,6 +8,7 @@ import { ReadingMode, SupportedFont, } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useQueryClient } from '@tanstack/react-query' import { Book, Contents, Rendition } from 'epubjs' import uniqby from 'lodash/uniqBy' @@ -181,6 +182,7 @@ const injectFontStylesheet = (rendition: Rendition) => { * epub reader as an additional option. */ export default function EpubJsReader({ id, isIncognito }: EpubJsReaderProps) { + const { t } = useLocaleContext() const { sdk } = useSDK() const { isDarkVariant } = useTheme() @@ -647,10 +649,10 @@ export default function EpubJsReader({ id, isIncognito }: EpubJsReaderProps) { await rendition.next() } catch (err) { console.error(err) - toast.error('') + toast.error(t('common.somethingWentWrong')) } } - }, [rendition]) + }, [rendition, t]) /** * A callback for when the reader should paginate backward. This will only run if the @@ -662,10 +664,10 @@ export default function EpubJsReader({ id, isIncognito }: EpubJsReaderProps) { await rendition.prev() } catch (err) { console.error(err) - toast.error('Something went wrong!') + toast.error(t('common.somethingWentWrong')) } } - }, [rendition]) + }, [rendition, t]) /** * A callback for when the user wants to navigate to a specific cfi. This will only run @@ -683,10 +685,10 @@ export default function EpubJsReader({ id, isIncognito }: EpubJsReaderProps) { await rendition.display(cfi) } catch (err) { console.error(err) - toast.error('Failed to navigate, please check the integrity of the epub file') + toast.error(t('readerUi.navigationFailed')) } }, - [rendition], + [rendition, t], ) // jump to a specific section @@ -707,7 +709,7 @@ export default function EpubJsReader({ id, isIncognito }: EpubJsReaderProps) { return } - const failureMessage = 'Failed to navigate, please check the integrity of the epub file' + const failureMessage = t('readerUi.navigationFailed') const adjusted = href.split('#')[0] let spineItem = book.spine.get(adjusted) @@ -747,7 +749,7 @@ export default function EpubJsReader({ id, isIncognito }: EpubJsReaderProps) { toast.error(failureMessage) } }, - [book, rendition], + [book, rendition, t], ) /** diff --git a/packages/browser/src/components/readers/imageBased/container/ReaderFooter.tsx b/packages/browser/src/components/readers/imageBased/container/ReaderFooter.tsx index 2ec1cd29a..eacbdbae1 100644 --- a/packages/browser/src/components/readers/imageBased/container/ReaderFooter.tsx +++ b/packages/browser/src/components/readers/imageBased/container/ReaderFooter.tsx @@ -2,7 +2,7 @@ import { useSDK } from '@stump/client' import { cn, ProgressBar, Text, usePreviousIsDifferent } from '@stump/components' import { ReadingDirection, ReadingMode } from '@stump/graphql' -import { formatHumanDuration } from '@stump/i18n' +import { formatHumanDuration, useLocaleContext } from '@stump/i18n' import { motion } from 'framer-motion' import { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react' import { ItemProps, ScrollerProps, Virtuoso, VirtuosoHandle } from 'react-virtuoso' @@ -16,6 +16,7 @@ import { useImageBaseReaderContext } from '../context' const SIZE_MODIFIER = 1.5 export default function ReaderFooter() { + const { t } = useLocaleContext() const { sdk } = useSDK() const { book, currentPage, setCurrentPage, imageSizes, setPageSize, pageSets, timer } = useImageBaseReaderContext() @@ -177,7 +178,9 @@ export default function ReaderFooter() { className={cn('flex flex-row justify-between', { 'justify-around': !trackElapsedTime })} > {trackElapsedTime && ( - Reading time: {formattedReadTime} + + {t('readerUi.readingTime', { time: formattedReadTime })} + )} diff --git a/packages/browser/src/components/series/SeriesCard.tsx b/packages/browser/src/components/series/SeriesCard.tsx index 33467973c..ecfb3f4f1 100644 --- a/packages/browser/src/components/series/SeriesCard.tsx +++ b/packages/browser/src/components/series/SeriesCard.tsx @@ -1,13 +1,13 @@ import { useSDK } from '@stump/client' import { Text } from '@stump/components' import { FileStatus } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useCallback } from 'react' import { usePrefetchSeries } from '@/scenes/series' import { usePrefetchSeriesBooks } from '@/scenes/series/tabs/books/SeriesBooksScene' import paths from '../../paths' -import pluralizeStat from '../../utils/pluralize' import { EntityCard } from '../entity' export interface SeriesCardData { @@ -25,6 +25,7 @@ export type SeriesCardProps = { } export default function SeriesCard({ data, fullWidth, variant = 'default' }: SeriesCardProps) { + const { t } = useLocaleContext() const { sdk } = useSDK() const isCoverOnly = variant === 'cover' @@ -53,7 +54,7 @@ export default function SeriesCard({ data, fullWidth, variant = 'default' }: Ser if (isMissing) { return ( - Series Missing + {t('entityUi.seriesMissing')} ) } @@ -61,11 +62,11 @@ export default function SeriesCard({ data, fullWidth, variant = 'default' }: Ser return (
- {pluralizeStat('book', Number(data.mediaCount))} + {t('entityUi.booksCount', { count: Number(data.mediaCount) })}
) - }, [isCoverOnly, data]) + }, [isCoverOnly, data, t]) const overrides = isCoverOnly ? { diff --git a/packages/browser/src/components/series/metadata/SeriesMetadataEditor.tsx b/packages/browser/src/components/series/metadata/SeriesMetadataEditor.tsx index 7e26a3c70..9126a3e2d 100644 --- a/packages/browser/src/components/series/metadata/SeriesMetadataEditor.tsx +++ b/packages/browser/src/components/series/metadata/SeriesMetadataEditor.tsx @@ -248,14 +248,14 @@ export default function SeriesMetadataEditor({ seriesId, data }: Props) { }, onError: (error) => { console.error('Failed to update metadata', error) - toast.error('Failed to update metadata') + toast.error(t('entityUi.metadataUpdateFailed')) }, }) const { mutate: setLocked } = useGraphQLMutation(setLockedFieldsMutation, { onError: () => { setLockedFields(new Set(metadata?.lockedFields ?? [])) - toast.error('Failed to update locked fields') + toast.error(t('entityUi.lockedFieldsUpdateFailed')) }, }) diff --git a/packages/browser/src/components/series/table/columns.tsx b/packages/browser/src/components/series/table/columns.tsx index 70393b407..461c60a3e 100644 --- a/packages/browser/src/components/series/table/columns.tsx +++ b/packages/browser/src/components/series/table/columns.tsx @@ -1,5 +1,6 @@ import { Link, Text } from '@stump/components' import { SeriesModelOrdering } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { ColumnSort } from '@stump/sdk' import { ColumnDef, createColumnHelper } from '@tanstack/react-table' @@ -10,6 +11,15 @@ import CoverImageCell from './CoverImageCell' const columnHelper = createColumnHelper() +function ColumnHeader({ label }: { label: 'books' | 'cover' | 'name' }) { + const { t } = useLocaleContext() + return ( + + {t(`tableColumns.labels.${label}`)} + + ) +} + const coverColumn = columnHelper.display({ cell: ({ row: { @@ -17,11 +27,7 @@ const coverColumn = columnHelper.display({ }, }) => , enableGlobalFilter: true, - header: () => ( - - Cover - - ), + header: () => , id: 'cover', size: 0, }) @@ -42,11 +48,7 @@ const nameColumn = columnHelper.accessor(({ resolvedName }) => resolvedName, { ), enableGlobalFilter: true, enableSorting: true, - header: () => ( - - Name - - ), + header: () => , id: SeriesModelOrdering.Name, minSize: 285, }) @@ -60,11 +62,7 @@ const booksCountColumn = columnHelper.accessor((series) => series.mediaCount?.to enableGlobalFilter: true, // TODO(prisma 0.7.0): Support order by relation enableSorting: false, - header: () => ( - - Books - - ), + header: () => , id: 'media_count', minSize: 60, }) diff --git a/packages/browser/src/scenes/book/BookActionMenu.tsx b/packages/browser/src/scenes/book/BookActionMenu.tsx index 0fe32b9e0..73c2d22a1 100644 --- a/packages/browser/src/scenes/book/BookActionMenu.tsx +++ b/packages/browser/src/scenes/book/BookActionMenu.tsx @@ -65,21 +65,21 @@ export default function BookActionMenu({ book }: Props) { onSuccess, onError: (error) => { console.error(error) - toast.error('Failed to update book completion status') + toast.error(t('entityUi.completionUpdateFailed')) }, }) const { mutate: deleteCurrentSession } = useGraphQLMutation(deleteMutation, { onSuccess, onError: (error) => { console.error(error) - toast.error('Failed to delete current session') + toast.error(t('entityUi.currentSessionDeleteFailed')) }, }) const { mutate: deleteReadHistory } = useGraphQLMutation(deleteHistoryMutation, { onSuccess, onError: (error) => { console.error(error) - toast.error('Failed to delete read history') + toast.error(t('entityUi.readHistoryDeleteFailed')) }, }) diff --git a/packages/browser/src/scenes/home/ContinueReading.tsx b/packages/browser/src/scenes/home/ContinueReading.tsx index bee4b7174..5f149810a 100644 --- a/packages/browser/src/scenes/home/ContinueReading.tsx +++ b/packages/browser/src/scenes/home/ContinueReading.tsx @@ -163,6 +163,7 @@ type ContinueReadingCardProps = { const ContinueReadingCard = memo(function ContinueReadingCard({ fragment, }: ContinueReadingCardProps) { + const { t } = useLocaleContext() const isAtLeastMedium = useMediaMatch('(min-width: 768px)') const width = isAtLeastMedium ? IMAGE_WIDTH_TABLET : IMAGE_WIDTH_MOBILE const data = useFragment(ContinueReadingBookFragment, fragment) @@ -236,7 +237,7 @@ const ContinueReadingCard = memo(function ContinueReadingCard({
{!isEbookProgress && !!data.readProgress?.page && data.readProgress.page > 0 && ( - {pagesLeft} {pagesLeft === 1 ? 'page' : 'pages'} left + {t('entityUi.pagesLeft', { count: pagesLeft })} )} diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index 3d34caa1f..1fd603db4 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -652,6 +652,8 @@ "pdfLoadFailed": "PDF failed to load.", "downloadPdf": "Click here", "downloadPdfSuffix": "to download it directly.", + "navigationFailed": "Failed to navigate. Please check the integrity of the EPUB file.", + "readingTime": "Reading time: {{time}}", "locationManager": "Location manager", "contents": "Contents", "tableOfContents": "Table of contents", @@ -698,6 +700,14 @@ "read": "Read" }, "entityUi": { + "fileMissing": "File missing", + "pagesLeft": "{{count}} pages left", + "booksCount": "{{count}} books", + "metadataUpdateFailed": "Failed to update metadata", + "lockedFieldsUpdateFailed": "Failed to update locked fields", + "completionUpdateFailed": "Failed to update book completion status", + "currentSessionDeleteFailed": "Failed to delete the current reading session", + "readHistoryDeleteFailed": "Failed to delete reading history", "pages": "Pages", "size": "Size", "format": "Format", diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index f5d7131b0..54d1dd905 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -652,6 +652,8 @@ "pdfLoadFailed": "PDF를 불러오지 못했습니다.", "downloadPdf": "여기를 눌러", "downloadPdfSuffix": "PDF를 직접 다운로드하세요.", + "navigationFailed": "이동하지 못했습니다. EPUB 파일이 올바른지 확인하세요.", + "readingTime": "읽은 시간: {{time}}", "locationManager": "위치 관리", "contents": "목차", "tableOfContents": "목차", @@ -698,6 +700,14 @@ "read": "읽기" }, "entityUi": { + "fileMissing": "파일 누락", + "pagesLeft": "{{count}}페이지 남음", + "booksCount": "책 {{count}}권", + "metadataUpdateFailed": "메타데이터를 업데이트하지 못했습니다", + "lockedFieldsUpdateFailed": "잠금 필드를 업데이트하지 못했습니다", + "completionUpdateFailed": "책의 완독 상태를 업데이트하지 못했습니다", + "currentSessionDeleteFailed": "현재 읽기 세션을 삭제하지 못했습니다", + "readHistoryDeleteFailed": "읽기 기록을 삭제하지 못했습니다", "pages": "페이지 수", "size": "크기", "format": "형식", From 297661e52cc71bc5adf088f66e7000fbe8daf6ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 18:35:22 +0900 Subject: [PATCH 33/54] =?UTF-8?q?feat(i18n):=20=EC=83=9D=EC=84=B1=EA=B3=BC?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EA=B4=80=EB=A6=AC=20=EB=AC=B8=EA=B5=AC?= =?UTF-8?q?=20=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bookClub/books/BookClubBookItem.tsx | 28 +++++++++++-------- .../book-club/BookClubNavigationItem.tsx | 2 +- .../smart-list/SmartListNavigationItem.tsx | 2 +- .../src/scenes/bookClub/BookClubHeader.tsx | 12 +++++--- .../createClub/CreateBookClubForm.tsx | 4 +-- .../createLibrary/CreateLibraryForm.tsx | 10 ++++--- .../createSmartList/CreateSmartListForm.tsx | 6 ++-- .../items/table/CreateOrUpdateTableView.tsx | 2 +- .../settings/basics/BasicSettingsScene.tsx | 2 +- .../settings/danger/DangerSettingsScene.tsx | 2 +- .../settings/filters/FiltersSettingsScene.tsx | 2 +- packages/i18n/src/locales/en-US.json | 19 +++++++++++-- packages/i18n/src/locales/ko-KR.json | 19 +++++++++++-- 13 files changed, 75 insertions(+), 35 deletions(-) diff --git a/packages/browser/src/components/bookClub/books/BookClubBookItem.tsx b/packages/browser/src/components/bookClub/books/BookClubBookItem.tsx index 8719daa1a..e959d51ae 100644 --- a/packages/browser/src/components/bookClub/books/BookClubBookItem.tsx +++ b/packages/browser/src/components/bookClub/books/BookClubBookItem.tsx @@ -1,8 +1,8 @@ import { AspectRatio, Badge, Card, cx, Heading, Link, Text } from '@stump/components' import { FragmentType, graphql, useFragment } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { differenceInDays, formatDistanceToNow } from 'date-fns' import { Book } from 'lucide-react' -import pluralize from 'pluralize' import { useMemo } from 'react' import { match } from 'ts-pattern' @@ -38,6 +38,7 @@ type Props = { data: FragmentType } export default function BookClubBookItem({ data }: Props) { + const { t } = useLocaleContext() const book = useFragment(fragment, data) const { bookClub } = useBookClubContext() @@ -50,12 +51,16 @@ export default function BookClubBookItem({ data }: Props) { let message if (isCurrent) { - message = `Started ${formatDistanceToNow(startedAt, { addSuffix: true })}` + message = t('bookClubUi.started', { + date: formatDistanceToNow(startedAt, { addSuffix: true }), + }) } else if (completedAt) { const daysAgo = differenceInDays(new Date(), completedAt) - message = `Completed ${daysAgo} ${pluralize('day', daysAgo)} ago` + message = t('bookClubUi.completedDaysAgo', { count: daysAgo }) } else { - message = `Added ${formatDistanceToNow(startedAt, { addSuffix: true })}` + message = t('bookClubUi.added', { + date: formatDistanceToNow(startedAt, { addSuffix: true }), + }) } return { @@ -63,7 +68,7 @@ export default function BookClubBookItem({ data }: Props) { start: startedAt, end: completedAt, } - }, [book, isCurrent]) + }, [book, isCurrent, t]) // const discussionInfo = useMemo(() => { // const archived = !isCurrent && !isDiscussing @@ -81,13 +86,13 @@ export default function BookClubBookItem({ data }: Props) { if (isCurrent) { return ( - Currently reading + {t('bookClubUi.currentlyReading')} ) } else { return ( - Past book + {t('bookClubUi.pastBook')} ) } @@ -169,11 +174,10 @@ export default function BookClubBookItem({ data }: Props) { )} > {daysInfo.end - ? `Read for ${differenceInDays(daysInfo.end, daysInfo.start)} ${pluralize( - 'day', - differenceInDays(daysInfo.end, daysInfo.start), - )}` - : 'Not completed yet'} + ? t('bookClubUi.readingDuration', { + count: differenceInDays(daysInfo.end, daysInfo.start), + }) + : t('bookClubUi.notCompletedYet')}
)} diff --git a/packages/browser/src/components/navigation/topbar/sections/book-club/BookClubNavigationItem.tsx b/packages/browser/src/components/navigation/topbar/sections/book-club/BookClubNavigationItem.tsx index 5f63310ff..21ac964b6 100644 --- a/packages/browser/src/components/navigation/topbar/sections/book-club/BookClubNavigationItem.tsx +++ b/packages/browser/src/components/navigation/topbar/sections/book-club/BookClubNavigationItem.tsx @@ -51,7 +51,7 @@ export default function BookClubNavigationItem({
- Join or create a book club to get started + {t('bookClubUi.navigationEmpty')}
diff --git a/packages/browser/src/components/navigation/topbar/sections/smart-list/SmartListNavigationItem.tsx b/packages/browser/src/components/navigation/topbar/sections/smart-list/SmartListNavigationItem.tsx index 8186664ce..87558dec6 100644 --- a/packages/browser/src/components/navigation/topbar/sections/smart-list/SmartListNavigationItem.tsx +++ b/packages/browser/src/components/navigation/topbar/sections/smart-list/SmartListNavigationItem.tsx @@ -46,7 +46,7 @@ export default function SmartListNavigationItem({
- Create your first smart list to get started + {t('userSmartListsScene.navigationEmpty')}
diff --git a/packages/browser/src/scenes/bookClub/BookClubHeader.tsx b/packages/browser/src/scenes/bookClub/BookClubHeader.tsx index 6ce92f3bf..53df768a9 100644 --- a/packages/browser/src/scenes/bookClub/BookClubHeader.tsx +++ b/packages/browser/src/scenes/bookClub/BookClubHeader.tsx @@ -1,11 +1,12 @@ import { Avatar, Card, cn, Heading, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { intlFormat } from 'date-fns' -import pluralize from 'pluralize' import { useBookClubContext } from '@/components/bookClub' import { usePreferences } from '@/hooks' export default function BookClubHeader() { + const { t, locale } = useLocaleContext() const { preferences: { primaryNavigationMode, layoutMaxWidthPx }, } = usePreferences() @@ -24,7 +25,7 @@ export default function BookClubHeader() { return ( - Created by + {t('bookClubUi.createdBy')}
@@ -58,8 +59,11 @@ export default function BookClubHeader() {
- {membersCount} {pluralize(roleSpec['MEMBER'], membersCount)} • Created{' '} - {intlFormat(new Date(createdAt), { month: 'long', year: 'numeric' })} + {t('bookClubUi.memberSummary', { + count: membersCount, + role: roleSpec['MEMBER'], + date: intlFormat(new Date(createdAt), { month: 'long', year: 'numeric' }, { locale }), + })}
diff --git a/packages/browser/src/scenes/bookClub/createClub/CreateBookClubForm.tsx b/packages/browser/src/scenes/bookClub/createClub/CreateBookClubForm.tsx index dae6ce0e5..e41a4ac4a 100644 --- a/packages/browser/src/scenes/bookClub/createClub/CreateBookClubForm.tsx +++ b/packages/browser/src/scenes/bookClub/createClub/CreateBookClubForm.tsx @@ -75,7 +75,7 @@ export default function CreateBookClubForm({ onSubmit }: Props) { const renderNextButton = (nextStep: number) => (
) @@ -122,7 +122,7 @@ export default function CreateBookClubForm({ onSubmit }: Props) { })} >
diff --git a/packages/browser/src/scenes/createLibrary/CreateLibraryForm.tsx b/packages/browser/src/scenes/createLibrary/CreateLibraryForm.tsx index f8095426a..dad63fa89 100644 --- a/packages/browser/src/scenes/createLibrary/CreateLibraryForm.tsx +++ b/packages/browser/src/scenes/createLibrary/CreateLibraryForm.tsx @@ -1,6 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { Button, cn, Form } from '@stump/components' import { CreateLibrarySceneExistingLibrariesQuery, UserPermission } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useCallback, useEffect, useMemo, useState } from 'react' import { useForm, useWatch } from 'react-hook-form' @@ -33,6 +34,7 @@ type Props = { } export default function CreateLibraryForm({ existingLibraries, onSubmit, isLoading }: Props) { + const { t } = useLocaleContext() const { currentStep, setStep } = useSteppedFormContext() const { checkPermission } = useAppContext() @@ -108,7 +110,7 @@ export default function CreateLibraryForm({ existingLibraries, onSubmit, isLoadi className="md:w-auto w-full" onClick={() => handleChangeStep(2)} > - Next step + {t('common.nextStep')}
@@ -123,7 +125,7 @@ export default function CreateLibraryForm({ existingLibraries, onSubmit, isLoadi
@@ -138,7 +140,7 @@ export default function CreateLibraryForm({ existingLibraries, onSubmit, isLoadi onClick={() => handleChangeStep(4)} type="button" > - Continue to review + {t('common.continueToReview')}
@@ -193,7 +195,7 @@ export default function CreateLibraryForm({ existingLibraries, onSubmit, isLoadi className="md:w-auto w-full" isLoading={isLoading} > - Create library + {t('createLibraryScene.form.buttons.confirm')}
diff --git a/packages/browser/src/scenes/createSmartList/CreateSmartListForm.tsx b/packages/browser/src/scenes/createSmartList/CreateSmartListForm.tsx index f772aa494..b87288ad8 100644 --- a/packages/browser/src/scenes/createSmartList/CreateSmartListForm.tsx +++ b/packages/browser/src/scenes/createSmartList/CreateSmartListForm.tsx @@ -89,7 +89,7 @@ export default function CreateSmartListForm({ onSubmit, isLoading }: Props) {
@@ -101,7 +101,7 @@ export default function CreateSmartListForm({ onSubmit, isLoading }: Props) {
@@ -131,7 +131,7 @@ export default function CreateSmartListForm({ onSubmit, isLoading }: Props) { className="md:w-auto w-full" isLoading={isLoading} > - Create list + {t('createSmartListScene.form.buttons.confirm')}
diff --git a/packages/browser/src/scenes/smartList/items/table/CreateOrUpdateTableView.tsx b/packages/browser/src/scenes/smartList/items/table/CreateOrUpdateTableView.tsx index aeedf6179..b6d967841 100644 --- a/packages/browser/src/scenes/smartList/items/table/CreateOrUpdateTableView.tsx +++ b/packages/browser/src/scenes/smartList/items/table/CreateOrUpdateTableView.tsx @@ -116,7 +116,7 @@ export default function CreateOrUpdateTableView({ isCreating, isOpen, onClose }:
diff --git a/packages/browser/src/scenes/smartList/settings/danger/DangerSettingsScene.tsx b/packages/browser/src/scenes/smartList/settings/danger/DangerSettingsScene.tsx index bd9555b9b..93acd9eaa 100644 --- a/packages/browser/src/scenes/smartList/settings/danger/DangerSettingsScene.tsx +++ b/packages/browser/src/scenes/smartList/settings/danger/DangerSettingsScene.tsx @@ -35,7 +35,7 @@ export default function DangerSettingsScene() { onClick={() => setShowConfirmation(true)} className="shrink-0" > - Delete list + {t(getKey('confirmation.confirm'))}
} diff --git a/packages/browser/src/scenes/smartList/settings/filters/FiltersSettingsScene.tsx b/packages/browser/src/scenes/smartList/settings/filters/FiltersSettingsScene.tsx index 52e70f553..754bf467d 100644 --- a/packages/browser/src/scenes/smartList/settings/filters/FiltersSettingsScene.tsx +++ b/packages/browser/src/scenes/smartList/settings/filters/FiltersSettingsScene.tsx @@ -59,7 +59,7 @@ export default function FiltersSettingsScene() {
diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index 1fd603db4..6614ebc45 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -875,6 +875,16 @@ "size": "Size" }, "bookClubUi": { + "createdBy": "Created by", + "memberSummary": "{{count}} {{role}} • Created {{date}}", + "started": "Started {{date}}", + "completedDaysAgo": "Completed {{count}} days ago", + "added": "Added {{date}}", + "readingDuration": "Read for {{count}} days", + "notCompletedYet": "Not completed yet", + "currentlyReading": "Currently reading", + "pastBook": "Past book", + "navigationEmpty": "Join or create a book club to get started", "deleteTitle": "Delete book club", "deleteDescription": "Are you sure you want to delete this club? This action cannot be undone.", "deleteConfirm": "Delete club", @@ -1597,6 +1607,7 @@ "heading": "Smart lists", "subtitle": "Your favorite searches and filters saved for easy access", "accessCount": "You have access to {{count}} smart lists", + "navigationEmpty": "Create your first smart list to get started", "searchPlaceholder": "Filter smart lists", "buttons": { "createSmartList": "Create smart list" @@ -1634,7 +1645,8 @@ }, "basics": { "title": "Basic Information", - "description": "The details of this smart list, including its name and description" + "description": "The details of this smart list, including its name and description", + "update": "Update list" }, "access": { "title": "Access", @@ -1656,7 +1668,8 @@ }, "filters": { "title": "Filter Builder", - "description": "Manage the filters which construct this smart list" + "description": "Manage the filters which construct this smart list", + "update": "Update filters" }, "danger-zone/delete": { "title": "Deletion Actions", @@ -3147,6 +3160,8 @@ "somethingWentWrong": "Something went wrong", "back": "Back", "continue": "Continue", + "nextStep": "Next step", + "continueToReview": "Continue to review", "book": "Book", "series": "Series", "fileUpload": { diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 54d1dd905..c2833754f 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -875,6 +875,16 @@ "size": "크기" }, "bookClubUi": { + "createdBy": "만든 사람", + "memberSummary": "{{role}} {{count}}명 • {{date}} 생성", + "started": "{{date}} 시작", + "completedDaysAgo": "{{count}}일 전에 완독", + "added": "{{date}} 추가", + "readingDuration": "{{count}}일 동안 읽음", + "notCompletedYet": "아직 완독하지 않음", + "currentlyReading": "현재 읽는 책", + "pastBook": "지난 책", + "navigationEmpty": "북클럽에 참여하거나 새로 만들어 보세요", "deleteTitle": "북클럽 삭제", "deleteDescription": "이 북클럽을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "deleteConfirm": "북클럽 삭제", @@ -1597,6 +1607,7 @@ "heading": "스마트 목록", "subtitle": "자주 사용하는 검색과 필터를 저장하여 간편하게 이용할 수 있습니다", "accessCount": "접근 가능한 스마트 목록이 {{count}}개 있습니다", + "navigationEmpty": "첫 스마트 목록을 만들어 시작하세요", "searchPlaceholder": "스마트 목록 검색", "buttons": { "createSmartList": "스마트 목록 만들기" @@ -1634,7 +1645,8 @@ }, "basics": { "title": "기본 정보", - "description": "이름과 설명 등 스마트 목록의 기본 정보" + "description": "이름과 설명 등 스마트 목록의 기본 정보", + "update": "목록 업데이트" }, "access": { "title": "접근", @@ -1656,7 +1668,8 @@ }, "filters": { "title": "필터 구성", - "description": "이 스마트 목록을 구성하는 필터를 관리합니다" + "description": "이 스마트 목록을 구성하는 필터를 관리합니다", + "update": "필터 업데이트" }, "danger-zone/delete": { "title": "삭제 작업", @@ -3147,6 +3160,8 @@ "somethingWentWrong": "문제가 발생했습니다", "back": "뒤로", "continue": "계속", + "nextStep": "다음 단계", + "continueToReview": "검토 단계로 이동", "book": "책", "series": "시리즈", "fileUpload": { From 6642d0ea4d1eff73972692d0924bc1691bd70b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 18:39:42 +0900 Subject: [PATCH 34/54] =?UTF-8?q?feat(i18n):=20=EC=9B=B9=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EA=B3=BC=20=EA=B4=80=EB=A6=AC=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/browser/src/AppLayout.tsx | 6 ++- .../library/DeleteLibraryConfirmation.tsx | 4 +- .../src/scenes/book/EmailBookDialog.tsx | 10 ++--- .../tabs/members/BookClubMembersScene.tsx | 9 ++-- .../tabs/settings/members/MembersTable.tsx | 4 +- .../settings/basics/BasicSettingsScene.tsx | 4 +- .../danger/deletion/DeleteLibrary.tsx | 2 +- .../readingDefaults/ReadingDefaultsScene.tsx | 4 +- .../settings/app/apiKeys/APIKeyInspector.tsx | 2 +- .../features/DiscordPresenceSwitch.tsx | 2 +- .../devices/CreateOrUpdateDeviceModal.tsx | 2 +- .../server/email/devices/DevicesTable.tsx | 43 ++++++++++--------- .../server/email/emailers/EmailersList.tsx | 2 +- .../server/general/ServerEmojisSection.tsx | 4 +- .../server/general/ServerPublicURL.tsx | 4 +- .../settings/server/jobs/JobActionMenu.tsx | 22 +++++----- .../providers/ExistingProviderCard.tsx | 2 +- .../users/user-table/UserActionMenu.tsx | 17 +++++--- packages/i18n/src/locales/en-US.json | 32 ++++++++++++++ packages/i18n/src/locales/ko-KR.json | 32 ++++++++++++++ 20 files changed, 143 insertions(+), 64 deletions(-) diff --git a/packages/browser/src/AppLayout.tsx b/packages/browser/src/AppLayout.tsx index d70d3416c..dbb829288 100644 --- a/packages/browser/src/AppLayout.tsx +++ b/packages/browser/src/AppLayout.tsx @@ -1,6 +1,7 @@ import { useAuthQuery, useSDK } from '@stump/client' import { cn, cx } from '@stump/components' import { UserPermission, UserPreferences } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { isAxiosError } from '@stump/sdk' import { useQueryClient } from '@tanstack/react-query' import { useOverlayScrollbars } from 'overlayscrollbars-react' @@ -22,6 +23,7 @@ import { useCoreEvent } from './hooks/useCoreEvent' import { useAppStore, useUserStore } from './stores' export function AppLayout() { + const { t } = useLocaleContext() const location = useLocation() const navigate = useNavigate() @@ -172,10 +174,10 @@ export function AppLayout() { client.clear() setUser(null) navigate('/auth') - toast.success('You have been logged out') + toast.success(t('common.loggedOut')) } catch (error) { console.error('Error logging out:', { error }) - toast.error('There was an error logging you out. Please try again.') + toast.error(t('common.logoutFailed')) } }, [sdk, client, setUser, navigate]) diff --git a/packages/browser/src/components/library/DeleteLibraryConfirmation.tsx b/packages/browser/src/components/library/DeleteLibraryConfirmation.tsx index b8a7279f5..c9ab1fcb8 100644 --- a/packages/browser/src/components/library/DeleteLibraryConfirmation.tsx +++ b/packages/browser/src/components/library/DeleteLibraryConfirmation.tsx @@ -72,9 +72,9 @@ export default function DeleteLibraryConfirmation({ console.error(error) if (isAxiosError(error)) { - toast.error(error.message || 'An error occurred while deleting the library') + toast.error(error.message || t('libraryUi.deleteLibraryError')) } else { - toast.error('An error occurred while deleting the library') + toast.error(t('libraryUi.deleteLibraryError')) } }, [error]) diff --git a/packages/browser/src/scenes/book/EmailBookDialog.tsx b/packages/browser/src/scenes/book/EmailBookDialog.tsx index 8db3ba823..2fb3c0b1b 100644 --- a/packages/browser/src/scenes/book/EmailBookDialog.tsx +++ b/packages/browser/src/scenes/book/EmailBookDialog.tsx @@ -74,7 +74,7 @@ function EmailBookDialog({ mediaId, isOpen, onClose, canArbitrarySendEmail }: Pr if (errors.length > 0) { onClose() console.warn(errors) - toast.error('Some errors occurred while sending email(s). Check the logs for more detail') + toast.error(t('bookOverviewScene.emailBook.partialFailure')) } }, }) @@ -102,9 +102,9 @@ function EmailBookDialog({ mediaId, isOpen, onClose, canArbitrarySendEmail }: Pr mutate({ id: mediaId, sendTo }) } catch (error) { console.error(error) - toast.error('Failed to send email') + toast.error(t('bookOverviewScene.emailBook.sendFailed')) } - }, [mutate, deviceIds, emails, canArbitrarySendEmail, mediaId]) + }, [mutate, deviceIds, emails, canArbitrarySendEmail, mediaId, t]) const renderArbitraryEmails = () => { if (!canArbitrarySendEmail) { @@ -181,10 +181,10 @@ function EmailBookDialog({ mediaId, isOpen, onClose, canArbitrarySendEmail }: Pr diff --git a/packages/browser/src/scenes/bookClub/tabs/members/BookClubMembersScene.tsx b/packages/browser/src/scenes/bookClub/tabs/members/BookClubMembersScene.tsx index 9d60e3388..b395f808b 100644 --- a/packages/browser/src/scenes/bookClub/tabs/members/BookClubMembersScene.tsx +++ b/packages/browser/src/scenes/bookClub/tabs/members/BookClubMembersScene.tsx @@ -1,7 +1,6 @@ +import { useLocaleContext } from '@stump/i18n' + export default function BookClubMembersScene() { - return ( -
- TODO: Book club members! probably a generic table? Prompt to invite members if none exist? -
- ) + const { t } = useLocaleContext() + return
{t('common.unimplemented')}
} diff --git a/packages/browser/src/scenes/bookClub/tabs/settings/members/MembersTable.tsx b/packages/browser/src/scenes/bookClub/tabs/settings/members/MembersTable.tsx index 228b6555e..86719dce1 100644 --- a/packages/browser/src/scenes/bookClub/tabs/settings/members/MembersTable.tsx +++ b/packages/browser/src/scenes/bookClub/tabs/settings/members/MembersTable.tsx @@ -1,6 +1,7 @@ import { useGraphQLMutation, useSDK, useSuspenseGraphQL } from '@stump/client' import { Avatar, Card } from '@stump/components' import { BookClubMembersTableQuery, graphql } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { BookClubMemberRoleSpec } from '@stump/sdk' import { ColumnDef, createColumnHelper } from '@tanstack/react-table' import upperFirst from 'lodash/upperFirst' @@ -39,6 +40,7 @@ const removeMutation = graphql(` `) export default function MembersTable() { + const { t } = useLocaleContext() const { sdk } = useSDK() const { user } = useAppContext() const { @@ -65,7 +67,7 @@ export default function MembersTable() { onSuccess: () => refetch(), onError: (error) => { console.error('Error removing member:', error) - toast.error('Failed to remove member') + toast.error(t('bookClubUi.removeMemberFailed')) }, }) diff --git a/packages/browser/src/scenes/library/tabs/settings/basics/BasicSettingsScene.tsx b/packages/browser/src/scenes/library/tabs/settings/basics/BasicSettingsScene.tsx index 978f46236..3b7afd08f 100644 --- a/packages/browser/src/scenes/library/tabs/settings/basics/BasicSettingsScene.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/basics/BasicSettingsScene.tsx @@ -2,6 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useSDK, useSuspenseGraphQL } from '@stump/client' import { Button, Form } from '@stump/components' import { graphql, UserPermission } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useCallback, useMemo, useState } from 'react' import { useForm, useWatch } from 'react-hook-form' @@ -31,6 +32,7 @@ const query = graphql(` `) export default function BasicSettingsScene() { + const { t } = useLocaleContext() const { library, patch } = useLibraryManagement() const { sdk } = useSDK() const { @@ -104,7 +106,7 @@ export default function BasicSettingsScene() {
diff --git a/packages/browser/src/scenes/library/tabs/settings/danger/deletion/DeleteLibrary.tsx b/packages/browser/src/scenes/library/tabs/settings/danger/deletion/DeleteLibrary.tsx index fc16f33b3..2efb5765b 100644 --- a/packages/browser/src/scenes/library/tabs/settings/danger/deletion/DeleteLibrary.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/danger/deletion/DeleteLibrary.tsx @@ -36,7 +36,7 @@ export default function DeleteLibrary() { onClick={() => setShowConfirmation(true)} className="shrink-0" > - Delete library + {t('libraryUi.deleteLibrary')} } diff --git a/packages/browser/src/scenes/library/tabs/settings/options/readingDefaults/ReadingDefaultsScene.tsx b/packages/browser/src/scenes/library/tabs/settings/options/readingDefaults/ReadingDefaultsScene.tsx index dce3a7903..944dec9e2 100644 --- a/packages/browser/src/scenes/library/tabs/settings/options/readingDefaults/ReadingDefaultsScene.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/options/readingDefaults/ReadingDefaultsScene.tsx @@ -1,5 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { Button, Form } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { useCallback, useEffect, useMemo } from 'react' import { useForm, useWatch } from 'react-hook-form' import { useDebouncedValue } from 'rooks' @@ -27,6 +28,7 @@ type PatchParams = Partial< > export default function ReadingDefaultsScene() { + const { t } = useLocaleContext() const { library, patch } = useLibraryManagement() const handleSubmit = useCallback( @@ -107,7 +109,7 @@ export default function ReadingDefaultsScene() {
diff --git a/packages/browser/src/scenes/settings/app/apiKeys/APIKeyInspector.tsx b/packages/browser/src/scenes/settings/app/apiKeys/APIKeyInspector.tsx index 397b473f9..74d942f6c 100644 --- a/packages/browser/src/scenes/settings/app/apiKeys/APIKeyInspector.tsx +++ b/packages/browser/src/scenes/settings/app/apiKeys/APIKeyInspector.tsx @@ -49,7 +49,7 @@ export default function APIKeyInspector({ apiKey, onClose }: Props) { return (
- Permissions + {t('settingsUi.permissions')}
{permissions.map((perm) => ( diff --git a/packages/browser/src/scenes/settings/app/desktop/features/DiscordPresenceSwitch.tsx b/packages/browser/src/scenes/settings/app/desktop/features/DiscordPresenceSwitch.tsx index 00da474ac..9c8acc428 100644 --- a/packages/browser/src/scenes/settings/app/desktop/features/DiscordPresenceSwitch.tsx +++ b/packages/browser/src/scenes/settings/app/desktop/features/DiscordPresenceSwitch.tsx @@ -35,7 +35,7 @@ export default function DiscordPresenceSwitch() { await update(newPreferences) } catch (err) { console.error(err) - toast.error('Failed to update!') + toast.error(t('common.updateFailed')) } } } diff --git a/packages/browser/src/scenes/settings/server/email/devices/CreateOrUpdateDeviceModal.tsx b/packages/browser/src/scenes/settings/server/email/devices/CreateOrUpdateDeviceModal.tsx index 086e8f6a7..541b61592 100644 --- a/packages/browser/src/scenes/settings/server/email/devices/CreateOrUpdateDeviceModal.tsx +++ b/packages/browser/src/scenes/settings/server/email/devices/CreateOrUpdateDeviceModal.tsx @@ -137,7 +137,7 @@ export default function CreateOrUpdateDeviceModal({ isOpen, updatingDevice, onCl
diff --git a/packages/browser/src/scenes/settings/server/general/ServerEmojisSection.tsx b/packages/browser/src/scenes/settings/server/general/ServerEmojisSection.tsx index 299f4ae25..a2e5e5a56 100644 --- a/packages/browser/src/scenes/settings/server/general/ServerEmojisSection.tsx +++ b/packages/browser/src/scenes/settings/server/general/ServerEmojisSection.tsx @@ -95,7 +95,7 @@ export default function ServerEmojisSection() { (acceptedFiles: File[], fileRejections: FileRejection[]) => { if (fileRejections.length > 0) { const firstError = fileRejections[0]?.errors[0] - toast.error(firstError?.message ?? t('common.fileUpload.someFilesRejected')) + toast.error(firstError?.message ?? t('common.fileUpload.someRejected')) return } @@ -192,7 +192,7 @@ export default function ServerEmojisSection() { const name = sanitizedName(renameValue) if (!name) { - toast.error('Please provide an emoji name') + toast.error(t('settingsUi.emojiNameRequired')) return } diff --git a/packages/browser/src/scenes/settings/server/general/ServerPublicURL.tsx b/packages/browser/src/scenes/settings/server/general/ServerPublicURL.tsx index 1c90d1b83..f2b872a2c 100644 --- a/packages/browser/src/scenes/settings/server/general/ServerPublicURL.tsx +++ b/packages/browser/src/scenes/settings/server/general/ServerPublicURL.tsx @@ -34,7 +34,7 @@ export default function ServerPublicURL() { const { mutate: updatePublicUrl } = useGraphQLMutation(mutation, { onSuccess: (data) => { - toast.success('Public URL updated successfully') + toast.success(t('settingsUi.publicUrlUpdated')) client.setQueryData(['serverConfig', 'publicUrl'], { serverConfig: { publicUrl: data.updatePublicUrl.publicUrl, @@ -42,7 +42,7 @@ export default function ServerPublicURL() { }) }, onError: (error) => { - toast.error('Failed to update public URL', { + toast.error(t('settingsUi.publicUrlUpdateFailed'), { description: error instanceof Error ? error.message : 'An unknown error occurred', }) }, diff --git a/packages/browser/src/scenes/settings/server/jobs/JobActionMenu.tsx b/packages/browser/src/scenes/settings/server/jobs/JobActionMenu.tsx index c559352f8..eb2aa6da2 100644 --- a/packages/browser/src/scenes/settings/server/jobs/JobActionMenu.tsx +++ b/packages/browser/src/scenes/settings/server/jobs/JobActionMenu.tsx @@ -1,6 +1,7 @@ import { useGraphQLMutation, useSDK } from '@stump/client' import { Button, DropdownMenu } from '@stump/components' import { graphql } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useQueryClient } from '@tanstack/react-query' import { Ban, Database, FileClock, ListX, MoreVertical, Trash2 } from 'lucide-react' import { useCallback, useMemo } from 'react' @@ -38,6 +39,7 @@ type Props = { } export default function JobActionMenu({ job, onInspectData }: Props) { + const { t } = useLocaleContext() const navigate = useNavigate() const client = useQueryClient() @@ -56,8 +58,8 @@ export default function JobActionMenu({ job, onInspectData }: Props) { }), onError: (error: unknown) => { if (isBadStateError(error)) { - toast.warning('This job was found in an invalid state', { - description: 'Please check server logs and report this issue', + toast.warning(t('settingsUi.jobInvalidState'), { + description: t('settingsUi.checkServerLogs'), }) client.refetchQueries({ predicate: ({ queryKey }) => queryKey.includes(sdk.cacheKeys.jobs), @@ -66,11 +68,11 @@ export default function JobActionMenu({ job, onInspectData }: Props) { toast.error(error.message) } else { console.error(error) - toast.error('An unknown error occurred') + toast.error(t('common.unknownError')) } }, }), - [sdk, client], + [sdk, client, t], ) const { mutate: cancelJob } = useGraphQLMutation(cancelMutation, options) @@ -112,7 +114,7 @@ export default function JobActionMenu({ job, onInspectData }: Props) { ...(isCancelable ? [ { - label: 'Cancel', + label: t('settingsUi.cancelJob'), leftIcon: , onClick: () => handleAction('cancel'), }, @@ -121,7 +123,7 @@ export default function JobActionMenu({ job, onInspectData }: Props) { ...(jobData ? [ { - label: 'View data', + label: t('settingsUi.viewJobData'), leftIcon: , onClick: () => onInspectData(jobData), }, @@ -130,12 +132,12 @@ export default function JobActionMenu({ job, onInspectData }: Props) { ...(hasLogs ? [ { - label: 'View logs', + label: t('settingsUi.viewLogs'), leftIcon: , onClick: () => navigate(paths.serverLogs(jobId)), }, { - label: 'Clear logs', + label: t('settingsUi.clearLogs'), leftIcon: , onClick: () => handleAction('deleteLogs'), }, @@ -145,14 +147,14 @@ export default function JobActionMenu({ job, onInspectData }: Props) { ...(isDeletable ? [ { - label: 'Delete', + label: t('common.delete'), leftIcon: , onClick: () => handleAction('delete'), }, ] : []), ], - [isCancelable, isDeletable, hasLogs, jobId, jobData, navigate, onInspectData, handleAction], + [isCancelable, isDeletable, hasLogs, jobId, jobData, navigate, onInspectData, handleAction, t], ) return ( diff --git a/packages/browser/src/scenes/settings/server/metadataIntegrations/providers/ExistingProviderCard.tsx b/packages/browser/src/scenes/settings/server/metadataIntegrations/providers/ExistingProviderCard.tsx index 93ceffb96..ac5e29e84 100644 --- a/packages/browser/src/scenes/settings/server/metadataIntegrations/providers/ExistingProviderCard.tsx +++ b/packages/browser/src/scenes/settings/server/metadataIntegrations/providers/ExistingProviderCard.tsx @@ -78,7 +78,7 @@ export function ExistingProviderCard({ data }: Props) {
- Added {intlFormat(new Date(provider.createdAt))} + {t('settingsUi.addedAt', { date: intlFormat(new Date(provider.createdAt)) })} diff --git a/packages/browser/src/scenes/settings/server/users/user-table/UserActionMenu.tsx b/packages/browser/src/scenes/settings/server/users/user-table/UserActionMenu.tsx index 8e3993f02..6773ecde5 100644 --- a/packages/browser/src/scenes/settings/server/users/user-table/UserActionMenu.tsx +++ b/packages/browser/src/scenes/settings/server/users/user-table/UserActionMenu.tsx @@ -1,6 +1,7 @@ import { useGraphQLMutation, useSDK } from '@stump/client' import { DropdownMenu, IconButton } from '@stump/components' import { graphql } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useQueryClient } from '@tanstack/react-query' import { Database, Lock, MoreVertical, Pencil, Search, Trash, Unlock } from 'lucide-react' import { useCallback, useMemo } from 'react' @@ -34,6 +35,7 @@ type Props = { } export default function UserActionMenu({ user, onSelectForInspect, onSelectForDeletion }: Props) { + const { t } = useLocaleContext() const { sdk } = useSDK() const { isServerOwner, user: byUser } = useAppContext() @@ -45,14 +47,14 @@ export default function UserActionMenu({ user, onSelectForInspect, onSelectForDe }, onError: (error) => { console.error(error) - toast.error('An error occurred while locking the user') + toast.error(t('settingsUi.userLockUpdateFailed')) }, }) const { mutateAsync: deleteSessions } = useGraphQLMutation(deleteSessionsMutation, { onError: (error) => { console.error(error) - toast.error('An error occurred while deleting user sessions') + toast.error(t('settingsUi.userSessionsDeleteFailed')) }, }) @@ -78,13 +80,13 @@ export default function UserActionMenu({ user, onSelectForInspect, onSelectForDe { items: [ { - label: 'Inspect', + label: t('settingsUi.inspectUser'), leftIcon: , onClick: () => onSelectForInspect(user), }, { disabled: user.loginSessionsCount === 0, - label: 'Clear sessions', + label: t('settingsUi.clearSessions'), isDestructive: true, leftIcon: , onClick: handleClearUserSessions, @@ -94,21 +96,21 @@ export default function UserActionMenu({ user, onSelectForInspect, onSelectForDe { items: [ { - label: 'Edit', + label: t('common.edit'), disabled: isSelf, leftIcon: , onClick: () => navigate(paths.updateUser(user.id)), }, { disabled: isSelf, - label: 'Delete', + label: t('common.delete'), isDestructive: true, leftIcon: , onClick: () => onSelectForDeletion(user), }, { disabled: isSelf || user.isServerOwner, - label: `${user.isLocked ? 'Unlock' : 'Lock'} account`, + label: user.isLocked ? t('settingsUi.unlockAccount') : t('settingsUi.lockAccount'), leftIcon: user.isLocked ? ( ) : ( @@ -128,6 +130,7 @@ export default function UserActionMenu({ user, onSelectForInspect, onSelectForDe handleClearUserSessions, handleSetLockStatus, onSelectForDeletion, + t, ], ) diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index 6614ebc45..3fb0c9be4 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -112,6 +112,8 @@ "emailBook": { "heading": "Email book", "description": "Send this book to a device or email address", + "partialFailure": "Some emails could not be sent. Check the logs for details.", + "sendFailed": "Failed to send email", "form": { "email": { "label": "Email", @@ -806,6 +808,9 @@ "adjustFilter": "Adjust filter" }, "libraryUi": { + "deleteLibraryError": "An error occurred while deleting the library", + "updateLibrary": "Update library", + "deleteLibrary": "Delete library", "directoryPicker": { "title": "Select a directory", "description": "Specify the directory where your library is located.", @@ -841,6 +846,29 @@ "emptyFolder": "This folder is empty" }, "settingsUi": { + "permissions": "Permissions", + "deviceName": "Name", + "deviceEmail": "Email", + "deviceStatus": "Status", + "createEmailer": "Create an emailer", + "emojiNameRequired": "Please provide an emoji name", + "publicUrlUpdated": "Public URL updated successfully", + "publicUrlUpdateFailed": "Failed to update public URL", + "userLockUpdateFailed": "An error occurred while updating the user lock", + "userSessionsDeleteFailed": "An error occurred while deleting user sessions", + "jobInvalidState": "This job is in an invalid state", + "addedAt": "Added {{date}}", + "allowed": "Allowed", + "forbidden": "Forbidden", + "inspectUser": "Inspect", + "clearSessions": "Clear sessions", + "lockAccount": "Lock account", + "unlockAccount": "Unlock account", + "cancelJob": "Cancel", + "viewJobData": "View data", + "viewLogs": "View logs", + "clearLogs": "Clear logs", + "checkServerLogs": "Please check server logs and report this issue", "readerUniversal": "Universal", "readerUniversalDescription": "Settings which apply to all types of books", "readerFormats": "Formats", @@ -885,6 +913,7 @@ "currentlyReading": "Currently reading", "pastBook": "Past book", "navigationEmpty": "Join or create a book club to get started", + "removeMemberFailed": "Failed to remove member", "deleteTitle": "Delete book club", "deleteDescription": "Are you sure you want to delete this club? This action cannot be undone.", "deleteConfirm": "Delete club", @@ -3158,6 +3187,9 @@ "never": "Never", "scan": "Scan", "somethingWentWrong": "Something went wrong", + "loggedOut": "You have been logged out", + "logoutFailed": "There was an error logging you out. Please try again.", + "updateFailed": "Failed to update", "back": "Back", "continue": "Continue", "nextStep": "Next step", diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index c2833754f..deb7153b4 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -112,6 +112,8 @@ "emailBook": { "heading": "책 이메일로 보내기", "description": "이 책을 기기나 이메일 주소로 보냅니다", + "partialFailure": "일부 이메일을 보내지 못했습니다. 자세한 내용은 로그를 확인하세요.", + "sendFailed": "이메일을 보내지 못했습니다", "form": { "email": { "label": "이메일", @@ -806,6 +808,9 @@ "adjustFilter": "필터 조정" }, "libraryUi": { + "deleteLibraryError": "라이브러리를 삭제하는 중 오류가 발생했습니다", + "updateLibrary": "라이브러리 업데이트", + "deleteLibrary": "라이브러리 삭제", "directoryPicker": { "title": "디렉터리 선택", "description": "라이브러리가 있는 디렉터리를 지정하세요.", @@ -841,6 +846,29 @@ "emptyFolder": "이 폴더는 비어 있습니다" }, "settingsUi": { + "permissions": "권한", + "deviceName": "이름", + "deviceEmail": "이메일", + "deviceStatus": "상태", + "createEmailer": "이메일 전송 서비스 만들기", + "emojiNameRequired": "이모지 이름을 입력하세요", + "publicUrlUpdated": "공개 URL을 업데이트했습니다", + "publicUrlUpdateFailed": "공개 URL을 업데이트하지 못했습니다", + "userLockUpdateFailed": "사용자 잠금 상태를 업데이트하는 중 오류가 발생했습니다", + "userSessionsDeleteFailed": "사용자 세션을 삭제하는 중 오류가 발생했습니다", + "jobInvalidState": "이 작업은 올바르지 않은 상태입니다", + "addedAt": "{{date}} 추가", + "allowed": "허용", + "forbidden": "차단", + "inspectUser": "상세 보기", + "clearSessions": "세션 지우기", + "lockAccount": "계정 잠금", + "unlockAccount": "계정 잠금 해제", + "cancelJob": "취소", + "viewJobData": "데이터 보기", + "viewLogs": "로그 보기", + "clearLogs": "로그 지우기", + "checkServerLogs": "서버 로그를 확인하고 이 문제를 신고해 주세요", "readerUniversal": "공통", "readerUniversalDescription": "모든 책 형식에 적용되는 설정", "readerFormats": "형식별 설정", @@ -885,6 +913,7 @@ "currentlyReading": "현재 읽는 책", "pastBook": "지난 책", "navigationEmpty": "북클럽에 참여하거나 새로 만들어 보세요", + "removeMemberFailed": "회원을 내보내지 못했습니다", "deleteTitle": "북클럽 삭제", "deleteDescription": "이 북클럽을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "deleteConfirm": "북클럽 삭제", @@ -3158,6 +3187,9 @@ "never": "없음", "scan": "스캔", "somethingWentWrong": "문제가 발생했습니다", + "loggedOut": "로그아웃되었습니다", + "logoutFailed": "로그아웃하는 중 오류가 발생했습니다. 다시 시도하세요.", + "updateFailed": "업데이트하지 못했습니다", "back": "뒤로", "continue": "계속", "nextStep": "다음 단계", From 51779799c392bcd0a3492378ebc7bee5b8e69391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 18:42:51 +0900 Subject: [PATCH 35/54] =?UTF-8?q?feat(i18n):=20=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=EC=99=80=20=EC=9D=B8=EC=A6=9D=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/expo/components/ServerAuthDialog.tsx | 31 +++++++++++------- apps/expo/components/Unimplemented.tsx | 19 +++++------ .../components/emoji/EmojiPickerSheet.tsx | 11 ++++--- .../error/PotentiallyOutdatedServer.tsx | 20 ++++++------ .../components/error/ServerConnectFailed.tsx | 18 ++++++----- .../components/error/ServerErrorBoundary.tsx | 19 ++++++----- .../localLibrary/ContinueReading.tsx | 5 +-- .../search/SearchHistoryAndFavorites.tsx | 12 ++++--- packages/i18n/src/locales/en-US.json | 32 ++++++++++++++++++- packages/i18n/src/locales/ko-KR.json | 32 ++++++++++++++++++- 10 files changed, 139 insertions(+), 60 deletions(-) diff --git a/apps/expo/components/ServerAuthDialog.tsx b/apps/expo/components/ServerAuthDialog.tsx index 8306b1691..6938a90ff 100644 --- a/apps/expo/components/ServerAuthDialog.tsx +++ b/apps/expo/components/ServerAuthDialog.tsx @@ -11,6 +11,7 @@ import urlJoin from 'url-join' import { z } from 'zod' import { useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { startOidcLogin } from '~/lib/sdk/auth' import { useUserStore } from '~/stores' @@ -24,6 +25,7 @@ type ServerAuthDialogProps = { } export default function ServerAuthDialog({ isOpen, onClose }: ServerAuthDialogProps) { + const { t } = useTranslate() const setUser = useUserStore((state) => state.setUser) const { activeServer } = useActiveServer() const oidcConfig = useOidcConfig() @@ -40,6 +42,10 @@ export default function ServerAuthDialog({ isOpen, onClose }: ServerAuthDialogPr const hasAuthSucceeded = useRef(false) const colors = useColors() + const schema = z.object({ + password: z.string().min(1, { message: t('auth.passwordRequired') }), + username: z.string().min(1, { message: t('auth.usernameRequired') }), + }) const { control, @@ -135,12 +141,12 @@ export default function ServerAuthDialog({ isOpen, onClose }: ServerAuthDialogPr }} render={({ field: { onChange, onBlur, value } }) => ( ( - Password + + {t('common.password')} + - Login + {t('auth.login')} {oidcConfig?.enabled && ( <> - Or + {t('auth.or')} @@ -216,7 +224,7 @@ export default function ServerAuthDialog({ isOpen, onClose }: ServerAuthDialogPr roundness="full" variant="secondary" > - Login with OIDC + {t('auth.loginWithOidc')} )} @@ -227,8 +235,7 @@ export default function ServerAuthDialog({ isOpen, onClose }: ServerAuthDialogPr ) } -const schema = z.object({ - password: z.string().min(1, { message: 'Password must be at least 2 characters long' }), - username: z.string().min(1, { message: 'Username is required' }), -}) -type LoginSchema = z.infer +type LoginSchema = { + password: string + username: string +} diff --git a/apps/expo/components/Unimplemented.tsx b/apps/expo/components/Unimplemented.tsx index 8a8710ec1..1b17bdf53 100644 --- a/apps/expo/components/Unimplemented.tsx +++ b/apps/expo/components/Unimplemented.tsx @@ -1,39 +1,40 @@ import { useRouter } from 'expo-router' import { View } from 'react-native' +import { useTranslate } from '~/lib/hooks' + import Owl, { useOwlHeaderOffset } from './Owl' import { Button, Heading, Text } from './ui' -const DEFAULT_TEXT = 'This feature is not yet implemented. Please check back later!' - type Props = { message?: string } -export default function Unimplemented({ message = DEFAULT_TEXT }: Props) { +export default function Unimplemented({ message }: Props) { + const { t } = useTranslate() const emptyContainerStyle = useOwlHeaderOffset() const router = useRouter() return ( - - Coming Soon! + + {t('unimplemented.title')} - {message} + {message ?? t('unimplemented.description')} - + diff --git a/apps/expo/components/emoji/EmojiPickerSheet.tsx b/apps/expo/components/emoji/EmojiPickerSheet.tsx index 458eae914..4058511b5 100644 --- a/apps/expo/components/emoji/EmojiPickerSheet.tsx +++ b/apps/expo/components/emoji/EmojiPickerSheet.tsx @@ -5,7 +5,7 @@ import { forwardRef, useImperativeHandle, useMemo, useRef, useState } from 'reac import { Image, Pressable, View } from 'react-native' import { IS_IOS_26_PLUS, useColors } from '~/lib/constants' -import { useDisplay } from '~/lib/hooks' +import { useDisplay, useTranslate } from '~/lib/hooks' import { Input, SheetHeader, Text } from '../ui' import type { Emoji, EmojiSelection } from './types' @@ -87,6 +87,7 @@ const emojiMatchesQuery = (emoji: Emoji, queryTokens: string[]) => { } export const EmojiPickerSheet = forwardRef(({ onEmojiSelect }, ref) => { + const { t } = useTranslate() const sheetRef = useRef(null) const colors = useColors() const emojisByCategory = useEmojis() @@ -212,7 +213,7 @@ export const EmojiPickerSheet = forwardRef(({ onEmoj (({ onEmoj grabber backgroundColor={IS_IOS_26_PLUS ? undefined : colors.sheet.background} grabberOptions={{ color: colors.sheet.grabber }} - header={} + header={} scrollable > (({ onEmoj keyExtractor={(item) => item.key} ListEmptyComponent={ - No emojis found + {t('emojiPicker.empty')} } renderItem={({ item }) => { diff --git a/apps/expo/components/error/PotentiallyOutdatedServer.tsx b/apps/expo/components/error/PotentiallyOutdatedServer.tsx index 3c193ca4e..ec249dc30 100644 --- a/apps/expo/components/error/PotentiallyOutdatedServer.tsx +++ b/apps/expo/components/error/PotentiallyOutdatedServer.tsx @@ -2,6 +2,8 @@ import { useRouter } from 'expo-router' import { Linking, View } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' +import { useTranslate } from '~/lib/hooks' + import Owl, { useOwlHeaderOffset } from '../Owl' import { Button, Heading, Text } from '../ui' import { getIssueUrl } from './utils' @@ -12,33 +14,33 @@ type Props = { } export default function PotentiallyOutdatedServer({ error, onRetry }: Props) { + const { t } = useTranslate() const router = useRouter() const emptyContainerStyle = useOwlHeaderOffset() return ( - - Outdated Server + + {t('errors.outdatedServer')} - An error was returned that suggests your server is outdated. Please make sure your - server is updated to continue. + {t('errors.outdatedServerDescription')} - + {onRetry && ( )} diff --git a/apps/expo/components/error/ServerConnectFailed.tsx b/apps/expo/components/error/ServerConnectFailed.tsx index ab1da09ba..e2d6eb14e 100644 --- a/apps/expo/components/error/ServerConnectFailed.tsx +++ b/apps/expo/components/error/ServerConnectFailed.tsx @@ -2,6 +2,8 @@ import { useRouter } from 'expo-router' import { View } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' +import { useTranslate } from '~/lib/hooks' + import Owl, { useOwlHeaderOffset } from '../Owl' import { Button, Heading, Text } from '../ui' @@ -10,33 +12,33 @@ type Props = { } export default function ServerConnectFailed({ onRetry }: Props) { + const { t } = useTranslate() const router = useRouter() const emptyContainerStyle = useOwlHeaderOffset() return ( - - Failed to Connect + + {t('errors.connectionFailed')} - A network error suggests this server is currently unavailable. Please ensure that it is - running and accessible from this device + {t('errors.connectionFailedDescription')} - + {onRetry && ( @@ -47,7 +49,7 @@ export default function ServerConnectFailed({ onRetry }: Props) { className="ml-2" onPress={onRetry} > - Try Again + {t('errors.tryAgain')} )} diff --git a/apps/expo/components/error/ServerErrorBoundary.tsx b/apps/expo/components/error/ServerErrorBoundary.tsx index 11651852b..01c6edb6e 100644 --- a/apps/expo/components/error/ServerErrorBoundary.tsx +++ b/apps/expo/components/error/ServerErrorBoundary.tsx @@ -3,6 +3,8 @@ import { useRouter } from 'expo-router' import { Linking, ScrollView, View } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' +import { useTranslate } from '~/lib/hooks' + import Owl from '../Owl' import { Button, Heading, Text } from '../ui' import PotentiallyOutdatedServer from './PotentiallyOutdatedServer' @@ -15,6 +17,7 @@ type Props = { } export default function ServerErrorBoundary({ error, onRetry }: Props) { + const { t } = useTranslate() const router = useRouter() if (isNetworkError(error)) { @@ -26,14 +29,14 @@ export default function ServerErrorBoundary({ error, onRetry }: Props) { } return ( - - - + + + - Something went wrong! + {t('errors.somethingWentWrong')} @@ -44,14 +47,14 @@ export default function ServerErrorBoundary({ error, onRetry }: Props) { - + {onRetry && ( )} diff --git a/apps/expo/components/localLibrary/ContinueReading.tsx b/apps/expo/components/localLibrary/ContinueReading.tsx index a19e834d3..d79a06966 100644 --- a/apps/expo/components/localLibrary/ContinueReading.tsx +++ b/apps/expo/components/localLibrary/ContinueReading.tsx @@ -5,7 +5,7 @@ import { Fragment, useMemo } from 'react' import { View } from 'react-native' import { db, downloadedFiles, libraryRefs, readProgress, seriesRefs } from '~/db' -import { useListItemSize } from '~/lib/hooks' +import { useListItemSize, useTranslate } from '~/lib/hooks' import { Heading } from '../ui' import DownloadedListItem from './DownloadedListItem' @@ -14,6 +14,7 @@ import { useDownloadsState } from './store' import { intoDownloadedFile } from './types' export default function ContinueReading() { + const { t } = useTranslate() // Note: This is a workaround for https://github.com/drizzle-team/drizzle-orm/issues/2660 const id = useDownloadsState((state) => state.fetchCounter) @@ -61,7 +62,7 @@ export default function ContinueReading() { {leftOffBooks.length > 0 && ( - Continue Reading + {t('stumpServer.continueReading.label')} - - Your favorites and recent searches will appear here + + {t('searchHistory.empty')} ) @@ -52,7 +54,7 @@ export function SearchHistoryAndFavorites({ onSelect }: Props) { > {hasFavorites && ( - Favorites + {t('searchHistory.favorites')} {favoriteSearches.map((record, index) => ( {index > 0 && } @@ -84,9 +86,9 @@ export function SearchHistoryAndFavorites({ onSelect }: Props) { {hasHistory && ( - Recently Searched + {t('searchHistory.recent')} clearSearchHistory(serverID)} hitSlop={10}> - Clear + {t('common.clear')} {searchHistory.map((record, index) => ( diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index 3fb0c9be4..2ed496cea 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -3583,8 +3583,38 @@ "value": "Value", "version": "Version" }, + "auth": { + "login": "Login", + "loginWithOidc": "Login with OIDC", + "or": "Or", + "usernameRequired": "Username is required", + "passwordRequired": "Password is required" + }, "errors": { - "unknown": "Unknown error" + "unknown": "Unknown error", + "somethingWentWrong": "Something went wrong!", + "returnHome": "Return Home", + "reportIssue": "Report Issue", + "tryAgain": "Try Again", + "outdatedServer": "Outdated Server", + "outdatedServerDescription": "An error suggests that your server is outdated. Update the server to continue.", + "connectionFailed": "Failed to Connect", + "connectionFailedDescription": "This server is currently unavailable. Make sure it is running and accessible from this device." + }, + "searchHistory": { + "empty": "Your favorites and recent searches will appear here", + "favorites": "Favorites", + "recent": "Recently Searched" + }, + "emojiPicker": { + "title": "Emojis", + "search": "Search emojis", + "empty": "No emojis found" + }, + "unimplemented": { + "title": "Coming Soon!", + "description": "This feature is not yet implemented. Please check back later!", + "okay": "Okay" }, "epubMenu": { "tableOfContents": "Table of Contents", diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index deb7153b4..ec5cffe99 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -3583,8 +3583,38 @@ "value": "값", "version": "버전" }, + "auth": { + "login": "로그인", + "loginWithOidc": "OIDC로 로그인", + "or": "또는", + "usernameRequired": "사용자 이름을 입력하세요", + "passwordRequired": "비밀번호를 입력하세요" + }, "errors": { - "unknown": "알 수 없는 오류" + "unknown": "알 수 없는 오류", + "somethingWentWrong": "문제가 발생했습니다!", + "returnHome": "홈으로 돌아가기", + "reportIssue": "문제 신고", + "tryAgain": "다시 시도", + "outdatedServer": "서버 버전이 오래되었습니다", + "outdatedServerDescription": "서버 버전이 오래된 것으로 보입니다. 계속하려면 서버를 업데이트하세요.", + "connectionFailed": "연결하지 못했습니다", + "connectionFailedDescription": "현재 이 서버를 사용할 수 없습니다. 서버가 실행 중이며 이 기기에서 접근할 수 있는지 확인하세요." + }, + "searchHistory": { + "empty": "즐겨찾기와 최근 검색어가 여기에 표시됩니다", + "favorites": "즐겨찾기", + "recent": "최근 검색" + }, + "emojiPicker": { + "title": "이모지", + "search": "이모지 검색", + "empty": "이모지를 찾을 수 없습니다" + }, + "unimplemented": { + "title": "준비 중입니다!", + "description": "아직 구현되지 않은 기능입니다. 나중에 다시 확인해 주세요!", + "okay": "확인" }, "epubMenu": { "tableOfContents": "목차", From 9959284f4e5506f1f10db089421f7d43c0f5a3fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 18:49:15 +0900 Subject: [PATCH 36/54] =?UTF-8?q?feat(i18n):=20=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20OPDS=20=EB=AC=B8=EA=B5=AC=20=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/opds-legacy/[id]/(tabs)/_layout.tsx | 6 ++- .../opds-legacy/[id]/(tabs)/feed/[url].tsx | 7 ++-- .../opds-legacy/[id]/(tabs)/feed/_layout.tsx | 4 +- .../opds-legacy/[id]/(tabs)/feed/index.tsx | 7 ++-- .../[id]/(tabs)/search/[query].tsx | 9 ++-- .../[id]/(tabs)/search/_layout.tsx | 4 +- .../opds-legacy/[id]/(tabs)/search/index.tsx | 12 +++--- apps/expo/app/opds-legacy/[id]/_layout.tsx | 4 +- apps/expo/app/opds/[id]/(tabs)/_layout.tsx | 6 ++- apps/expo/app/opds/[id]/(tabs)/feed/[url].tsx | 4 +- .../app/opds/[id]/(tabs)/feed/_layout.tsx | 4 +- apps/expo/app/opds/[id]/(tabs)/feed/index.tsx | 4 +- .../app/opds/[id]/(tabs)/search/[query].tsx | 6 ++- .../app/opds/[id]/(tabs)/search/_layout.tsx | 4 +- .../app/opds/[id]/(tabs)/search/index.tsx | 12 +++--- apps/expo/app/opds/[id]/_layout.tsx | 6 ++- apps/expo/app/opds/[id]/publication/index.tsx | 42 ++++++++++--------- apps/expo/components/opds/FeedSelfURL.tsx | 6 ++- apps/expo/components/opds/FeedTitle.tsx | 6 ++- apps/expo/components/opds/MaybeErrorFeed.tsx | 18 ++++---- apps/expo/components/opds/Navigation.tsx | 9 ++-- apps/expo/components/opds/NavigationFeed.tsx | 2 +- apps/expo/components/opds/NavigationGroup.tsx | 13 ++++-- apps/expo/components/opds/NavigationLink.tsx | 4 +- apps/expo/components/opds/OPDSAuthDialog.tsx | 10 +++-- .../expo/components/opds/PublicationGroup.tsx | 13 +++--- apps/expo/components/opds/PublicationMenu.tsx | 7 ++-- .../components/opdsLegacy/FeedActionMenu.tsx | 22 +++++----- .../opdsLegacy/MaybeErrorLegacyFeed.tsx | 18 ++++---- .../opdsLegacy/OPDSLegacyEntryItem.tsx | 23 +++++----- .../opdsLegacy/OPDSLegacyEntryItemSheet.tsx | 16 ++++--- packages/i18n/src/locales/en-US.json | 38 +++++++++++++++++ packages/i18n/src/locales/ko-KR.json | 38 +++++++++++++++++ 33 files changed, 261 insertions(+), 123 deletions(-) diff --git a/apps/expo/app/opds-legacy/[id]/(tabs)/_layout.tsx b/apps/expo/app/opds-legacy/[id]/(tabs)/_layout.tsx index 656f2b798..4438d1ff7 100644 --- a/apps/expo/app/opds-legacy/[id]/(tabs)/_layout.tsx +++ b/apps/expo/app/opds-legacy/[id]/(tabs)/_layout.tsx @@ -3,10 +3,12 @@ import { NativeTabs } from 'expo-router/unstable-native-tabs' import { useOPDSLegacyFeedContext } from '~/context/opdsLegacy' import { useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' // TODO(opds): Support favorites and add a tab for it export default function TabLayout() { + const { t } = useTranslate() const { sdk } = useSDK() const { hasSearch } = useOPDSLegacyFeedContext() @@ -26,7 +28,7 @@ export default function TabLayout() { labelVisibilityMode="labeled" > - Feed + {t('opds.feed')} {hasSearch && ( - Search + {t('opds.search')} )} diff --git a/apps/expo/app/opds-legacy/[id]/(tabs)/feed/[url].tsx b/apps/expo/app/opds-legacy/[id]/(tabs)/feed/[url].tsx index d211a42b9..0a07bee16 100644 --- a/apps/expo/app/opds-legacy/[id]/(tabs)/feed/[url].tsx +++ b/apps/expo/app/opds-legacy/[id]/(tabs)/feed/[url].tsx @@ -13,10 +13,11 @@ import { useLegacyOPDSEntrySize } from '~/components/opdsLegacy/useLegacyOPDSEnt import RefreshControl from '~/components/RefreshControl' import { FullScreenLoader } from '~/components/ui' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' -import { useLegacyOPDSFeed } from '~/lib/hooks' +import { useLegacyOPDSFeed, useTranslate } from '~/lib/hooks' import { useDynamicHeader } from '~/lib/hooks/useDynamicHeader' export default function Screen() { + const { t } = useTranslate() const { url: feedURL } = useLocalSearchParams<{ url: string }>() const { feed, // The current page feed @@ -43,14 +44,14 @@ export default function Screen() { } } - if (showLoader) return + if (showLoader) return if (isLoading) return null if (!feed || !!error) return if (!entries.length) { - return + return } return ( diff --git a/apps/expo/app/opds-legacy/[id]/(tabs)/feed/_layout.tsx b/apps/expo/app/opds-legacy/[id]/(tabs)/feed/_layout.tsx index aaa18b493..e6230031d 100644 --- a/apps/expo/app/opds-legacy/[id]/(tabs)/feed/_layout.tsx +++ b/apps/expo/app/opds-legacy/[id]/(tabs)/feed/_layout.tsx @@ -2,9 +2,11 @@ import { Stack } from 'expo-router' import { Platform } from 'react-native' import { IS_IOS_26_PLUS } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' export default function Layout() { + const { t } = useTranslate() const animationEnabled = usePreferencesStore((state) => !state.reduceAnimations) return ( @@ -18,7 +20,7 @@ export default function Layout() { name="index" options={{ headerShown: true, - title: 'Feeds', + title: t('opds.feeds'), headerTransparent: Platform.OS === 'ios', headerBlurEffect: IS_IOS_26_PLUS ? undefined : 'regular', headerLargeTitle: Platform.OS === 'ios', diff --git a/apps/expo/app/opds-legacy/[id]/(tabs)/feed/index.tsx b/apps/expo/app/opds-legacy/[id]/(tabs)/feed/index.tsx index da4c0bff8..80dcb55da 100644 --- a/apps/expo/app/opds-legacy/[id]/(tabs)/feed/index.tsx +++ b/apps/expo/app/opds-legacy/[id]/(tabs)/feed/index.tsx @@ -14,10 +14,11 @@ import RefreshControl from '~/components/RefreshControl' import { FullScreenLoader } from '~/components/ui' import { useOPDSLegacyFeedContext } from '~/context/opdsLegacy' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' -import { useLegacyOPDSFeed } from '~/lib/hooks' +import { useLegacyOPDSFeed, useTranslate } from '~/lib/hooks' import { useDynamicHeader } from '~/lib/hooks/useDynamicHeader' export default function Screen() { + const { t } = useTranslate() const { activeServer } = useActiveServer() const { catalogMeta } = useOPDSLegacyFeedContext() const { @@ -35,7 +36,7 @@ export default function Screen() { const { numColumns } = useLegacyOPDSEntrySize() useDynamicHeader({ - title: activeServer?.name || 'OPDS Feed', + title: activeServer?.name || t('opds.feedTitle'), headerLeft: () => , headerRight: () => , }) @@ -46,7 +47,7 @@ export default function Screen() { } } - if (showLoader) return + if (showLoader) return if (isLoading) return null diff --git a/apps/expo/app/opds-legacy/[id]/(tabs)/search/[query].tsx b/apps/expo/app/opds-legacy/[id]/(tabs)/search/[query].tsx index 9dbe97c62..4823896c1 100644 --- a/apps/expo/app/opds-legacy/[id]/(tabs)/search/[query].tsx +++ b/apps/expo/app/opds-legacy/[id]/(tabs)/search/[query].tsx @@ -15,11 +15,12 @@ import RefreshControl from '~/components/RefreshControl' import { FullScreenLoader } from '~/components/ui' import { useOPDSLegacyFeedContext } from '~/context/opdsLegacy' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' -import { useLegacyOPDSFeed } from '~/lib/hooks' +import { useLegacyOPDSFeed, useTranslate } from '~/lib/hooks' import { useDynamicHeader } from '~/lib/hooks/useDynamicHeader' import { constructLegacySearchURL } from '~/lib/opdsUtils' export default function Screen() { + const { t } = useTranslate() const { query } = useLocalSearchParams<{ query: string }>() const { searchDoc } = useOPDSLegacyFeedContext() const { numColumns } = useLegacyOPDSEntrySize() @@ -42,7 +43,7 @@ export default function Screen() { const showLoader = useShowSlowLoader(isLoading) useDynamicHeader({ - title: query || 'Search Results', + title: query || t('opds.searchResults'), headerLeft: () => , headerRight: () => , }) @@ -53,14 +54,14 @@ export default function Screen() { } } - if (showLoader) return + if (showLoader) return if (isLoading) return null if (!feed || !!error) return if (!entries.length) { - return + return } return ( diff --git a/apps/expo/app/opds-legacy/[id]/(tabs)/search/_layout.tsx b/apps/expo/app/opds-legacy/[id]/(tabs)/search/_layout.tsx index f3ff560ad..ab232127f 100644 --- a/apps/expo/app/opds-legacy/[id]/(tabs)/search/_layout.tsx +++ b/apps/expo/app/opds-legacy/[id]/(tabs)/search/_layout.tsx @@ -2,9 +2,11 @@ import { Stack } from 'expo-router' import { Platform } from 'react-native' import { IS_IOS_26_PLUS } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' export default function Layout() { + const { t } = useTranslate() const animationEnabled = usePreferencesStore((state) => !state.reduceAnimations) return ( @@ -18,7 +20,7 @@ export default function Layout() { name="index" options={{ headerShown: true, - title: 'Search', + title: t('opds.search'), headerTransparent: Platform.OS === 'ios', headerBlurEffect: IS_IOS_26_PLUS ? undefined : 'regular', }} diff --git a/apps/expo/app/opds-legacy/[id]/(tabs)/search/index.tsx b/apps/expo/app/opds-legacy/[id]/(tabs)/search/index.tsx index dfff2aaef..234b9eadb 100644 --- a/apps/expo/app/opds-legacy/[id]/(tabs)/search/index.tsx +++ b/apps/expo/app/opds-legacy/[id]/(tabs)/search/index.tsx @@ -9,9 +9,11 @@ import { SearchHistoryAndFavorites } from '~/components/search/SearchHistoryAndF import { Text } from '~/components/ui' import { useOPDSLegacyFeedContext } from '~/context/opdsLegacy' import { IS_IOS_26_PLUS, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useSearchStore } from '~/stores/search' export default function Screen() { + const { t } = useTranslate() const { activeServer: { id: serverID }, } = useActiveServer() @@ -53,7 +55,7 @@ export default function Screen() { headerTransparent: Platform.OS === 'ios', headerBlurEffect: IS_IOS_26_PLUS ? undefined : 'regular', headerSearchBarOptions: { - placeholder: 'Search', + placeholder: t('opds.search'), onChangeText: (e: NativeSyntheticEvent) => setQuery(e.nativeEvent.text), shouldShowHintSearchIcon: true, @@ -67,7 +69,7 @@ export default function Screen() { textColor: colors.foreground.DEFAULT, }, }) - }, [navigation, setQuery, onSearch, colors]) + }, [navigation, setQuery, onSearch, colors, t]) if (!isInputFocused) { return ( @@ -76,11 +78,11 @@ export default function Screen() { - Search the feed + {t('opds.searchFeed')} - - Enter a search query to find content in this OPDS feed + + {t('opds.searchFeedDescription')} diff --git a/apps/expo/app/opds-legacy/[id]/_layout.tsx b/apps/expo/app/opds-legacy/[id]/_layout.tsx index 5e1932f0d..1775006ba 100644 --- a/apps/expo/app/opds-legacy/[id]/_layout.tsx +++ b/apps/expo/app/opds-legacy/[id]/_layout.tsx @@ -15,6 +15,7 @@ import { } from '~/context/opdsLegacy' import { getOPDSInstance, isOPDSAuthError } from '~/lib/sdk/auth' import { usePreferencesStore, useSavedServers } from '~/stores' +import { useTranslate } from '~/lib/hooks' import { useCacheStore } from '~/stores/cache' type OPDSFeedProviderProps = { @@ -22,6 +23,7 @@ type OPDSFeedProviderProps = { } function OPDSFeedProvider({ children }: OPDSFeedProviderProps) { + const { t } = useTranslate() const { sdk } = useSDK() const { activeServer } = useActiveServer() const { onUnauthenticatedResponse } = useClientContext() @@ -75,7 +77,7 @@ function OPDSFeedProvider({ children }: OPDSFeedProviderProps) { ) if (isCatalogLoading && !catalog) { - return + return } return ( diff --git a/apps/expo/app/opds/[id]/(tabs)/_layout.tsx b/apps/expo/app/opds/[id]/(tabs)/_layout.tsx index 64dc940bf..2dec7d562 100644 --- a/apps/expo/app/opds/[id]/(tabs)/_layout.tsx +++ b/apps/expo/app/opds/[id]/(tabs)/_layout.tsx @@ -3,10 +3,12 @@ import { NativeTabs } from 'expo-router/unstable-native-tabs' import { useOPDSFeedContext } from '~/context/opds' import { useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' // TODO(opds): Support favorites and add a tab for it export default function TabLayout() { + const { t } = useTranslate() const { sdk } = useSDK() const { hasSearch } = useOPDSFeedContext() @@ -26,7 +28,7 @@ export default function TabLayout() { labelVisibilityMode="labeled" > - Feeds + {t('opds.feeds')} {hasSearch && ( - Search + {t('opds.search')} )} diff --git a/apps/expo/app/opds/[id]/(tabs)/feed/[url].tsx b/apps/expo/app/opds/[id]/(tabs)/feed/[url].tsx index 3a5a39781..617d7b612 100644 --- a/apps/expo/app/opds/[id]/(tabs)/feed/[url].tsx +++ b/apps/expo/app/opds/[id]/(tabs)/feed/[url].tsx @@ -5,9 +5,11 @@ import BackLink from '~/components/BackLink' import { MaybeErrorFeed, OPDSFeed } from '~/components/opds' import { useOPDSFeed } from '~/components/opds/useOPDSFeed' import { FullScreenLoader } from '~/components/ui' +import { useTranslate } from '~/lib/hooks' import { useDynamicHeader } from '~/lib/hooks/useDynamicHeader' export default function Screen() { + const { t } = useTranslate() const { url: feedURL } = useLocalSearchParams<{ url: string }>() const { @@ -30,7 +32,7 @@ export default function Screen() { headerLeft: () => , }) - if (showLoader) return + if (showLoader) return if (isLoading) return null diff --git a/apps/expo/app/opds/[id]/(tabs)/feed/_layout.tsx b/apps/expo/app/opds/[id]/(tabs)/feed/_layout.tsx index aaa18b493..e6230031d 100644 --- a/apps/expo/app/opds/[id]/(tabs)/feed/_layout.tsx +++ b/apps/expo/app/opds/[id]/(tabs)/feed/_layout.tsx @@ -2,9 +2,11 @@ import { Stack } from 'expo-router' import { Platform } from 'react-native' import { IS_IOS_26_PLUS } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' export default function Layout() { + const { t } = useTranslate() const animationEnabled = usePreferencesStore((state) => !state.reduceAnimations) return ( @@ -18,7 +20,7 @@ export default function Layout() { name="index" options={{ headerShown: true, - title: 'Feeds', + title: t('opds.feeds'), headerTransparent: Platform.OS === 'ios', headerBlurEffect: IS_IOS_26_PLUS ? undefined : 'regular', headerLargeTitle: Platform.OS === 'ios', diff --git a/apps/expo/app/opds/[id]/(tabs)/feed/index.tsx b/apps/expo/app/opds/[id]/(tabs)/feed/index.tsx index 6ffd4c6e4..e0db01f24 100644 --- a/apps/expo/app/opds/[id]/(tabs)/feed/index.tsx +++ b/apps/expo/app/opds/[id]/(tabs)/feed/index.tsx @@ -5,9 +5,11 @@ import BackLink from '~/components/BackLink' import { MaybeErrorFeed, OPDSFeed } from '~/components/opds' import { FullScreenLoader } from '~/components/ui' import { useOPDSFeedContext } from '~/context/opds' +import { useTranslate } from '~/lib/hooks' import { useDynamicHeader } from '~/lib/hooks/useDynamicHeader' export default function Screen() { + const { t } = useTranslate() const { activeServer } = useActiveServer() const { catalog: feed, isLoading, error, refetch } = useOPDSFeedContext() const [isRefetching, onRefetch] = useRefetch(refetch) @@ -18,7 +20,7 @@ export default function Screen() { headerLeft: () => , }) - if (showLoader) return + if (showLoader) return if (isLoading) return null diff --git a/apps/expo/app/opds/[id]/(tabs)/search/[query].tsx b/apps/expo/app/opds/[id]/(tabs)/search/[query].tsx index 725199e53..c1258b256 100644 --- a/apps/expo/app/opds/[id]/(tabs)/search/[query].tsx +++ b/apps/expo/app/opds/[id]/(tabs)/search/[query].tsx @@ -7,10 +7,12 @@ import EmptyState from '~/components/EmptyState' import { MaybeErrorFeed, OPDSFeed } from '~/components/opds' import { PaginationTarget } from '~/components/opds/useOPDSFeed' import { useOPDSFeedContext } from '~/context/opds' +import { useTranslate } from '~/lib/hooks' import { useDynamicHeader } from '~/lib/hooks/useDynamicHeader' import { constructSearchURL } from '~/lib/opdsUtils' export default function Screen() { + const { t } = useTranslate() const { query } = useLocalSearchParams<{ query: string }>() const { sdk } = useSDK() const { searchURL } = useOPDSFeedContext() @@ -32,7 +34,7 @@ export default function Screen() { const [isRefetching, onRefetch] = useRefetch(refetch) useDynamicHeader({ - title: query || 'Search Results', + title: query || t('opds.searchResults'), headerLeft: () => , }) @@ -42,7 +44,7 @@ export default function Screen() { !feed?.groups?.length && !feed?.publications?.length && !feed?.navigation?.length if (emptyFeed) { - return + return } if (!feed || !!error) { diff --git a/apps/expo/app/opds/[id]/(tabs)/search/_layout.tsx b/apps/expo/app/opds/[id]/(tabs)/search/_layout.tsx index f3ff560ad..ab232127f 100644 --- a/apps/expo/app/opds/[id]/(tabs)/search/_layout.tsx +++ b/apps/expo/app/opds/[id]/(tabs)/search/_layout.tsx @@ -2,9 +2,11 @@ import { Stack } from 'expo-router' import { Platform } from 'react-native' import { IS_IOS_26_PLUS } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' export default function Layout() { + const { t } = useTranslate() const animationEnabled = usePreferencesStore((state) => !state.reduceAnimations) return ( @@ -18,7 +20,7 @@ export default function Layout() { name="index" options={{ headerShown: true, - title: 'Search', + title: t('opds.search'), headerTransparent: Platform.OS === 'ios', headerBlurEffect: IS_IOS_26_PLUS ? undefined : 'regular', }} diff --git a/apps/expo/app/opds/[id]/(tabs)/search/index.tsx b/apps/expo/app/opds/[id]/(tabs)/search/index.tsx index 426d046e1..793c7cd00 100644 --- a/apps/expo/app/opds/[id]/(tabs)/search/index.tsx +++ b/apps/expo/app/opds/[id]/(tabs)/search/index.tsx @@ -9,9 +9,11 @@ import { SearchHistoryAndFavorites } from '~/components/search/SearchHistoryAndF import { Text } from '~/components/ui' import { useOPDSFeedContext } from '~/context/opds' import { IS_IOS_26_PLUS, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useSearchStore } from '~/stores/search' export default function Screen() { + const { t } = useTranslate() const { activeServer: { id: serverID }, } = useActiveServer() @@ -53,7 +55,7 @@ export default function Screen() { headerTransparent: Platform.OS === 'ios', headerBlurEffect: IS_IOS_26_PLUS ? undefined : 'regular', headerSearchBarOptions: { - placeholder: 'Search', + placeholder: t('opds.search'), onChangeText: (e: NativeSyntheticEvent) => setQuery(e.nativeEvent.text), shouldShowHintSearchIcon: true, @@ -67,7 +69,7 @@ export default function Screen() { textColor: colors.foreground.DEFAULT, }, }) - }, [navigation, setQuery, onSearch, colors]) + }, [navigation, setQuery, onSearch, colors, t]) if (!isInputFocused) { return ( @@ -76,11 +78,11 @@ export default function Screen() { - Search the feed + {t('opds.searchFeed')} - - Enter a search query to find content in this OPDS feed + + {t('opds.searchFeedDescription')} diff --git a/apps/expo/app/opds/[id]/_layout.tsx b/apps/expo/app/opds/[id]/_layout.tsx index 73052e99c..cc6f7be47 100644 --- a/apps/expo/app/opds/[id]/_layout.tsx +++ b/apps/expo/app/opds/[id]/_layout.tsx @@ -16,6 +16,7 @@ import { FullScreenLoader } from '~/components/ui' import { feedHasSearch, getSearchURL, OPDSFeedContext } from '~/context/opds' import { getOPDSInstance, isOPDSAuthError } from '~/lib/sdk/auth' import { usePreferencesStore, useSavedServers } from '~/stores' +import { useTranslate } from '~/lib/hooks' import { useCacheStore } from '~/stores/cache' type OPDSFeedProviderProps = { @@ -24,6 +25,7 @@ type OPDSFeedProviderProps = { } function OPDSFeedProvider({ children, isAuthPending }: OPDSFeedProviderProps) { + const { t } = useTranslate() const { sdk } = useSDK() const { activeServer } = useActiveServer() const { onUnauthenticatedResponse } = useClientContext() @@ -66,13 +68,13 @@ function OPDSFeedProvider({ children, isAuthPending }: OPDSFeedProviderProps) { ) if (isCatalogLoading && !catalog) { - return + return } const isAuthError = isOPDSAuthError(error) if (isAuthError || isAuthPending) { - return + return } return {children} diff --git a/apps/expo/app/opds/[id]/publication/index.tsx b/apps/expo/app/opds/[id]/publication/index.tsx index b4f855b28..690a96a7d 100644 --- a/apps/expo/app/opds/[id]/publication/index.tsx +++ b/apps/expo/app/opds/[id]/publication/index.tsx @@ -201,11 +201,13 @@ export default function Screen() { const isCompleted = !!(percentageCompleted && percentageCompleted >= 1) if (isCompleted) { - return + return ( + + ) } else { return ( ) @@ -288,7 +290,7 @@ export default function Screen() { } disabled={!canStream || !isSupportedStream} > - Stream + {t('opds.stream')} {!isDownloaded && ( )} @@ -324,7 +326,7 @@ export default function Screen() { )} {progression.locator.locations?.totalProgression != null && ( )} @@ -347,15 +349,13 @@ export default function Screen() { {!canStream && ( - This publication lacks a defined reading order and cannot be streamed + {t('opds.noReadingOrder')} )} {!isSupportedStream && ( - - This publication contains unsupported media types and cannot be streamed yet - + {t('opds.unsupportedMedia')} )} @@ -367,15 +367,15 @@ export default function Screen() { - {!!publisher && } - {volume != null && } - {issue != null && } - {!!numberOfPages && } + {!!publisher && } + {volume != null && } + {issue != null && } + {!!numberOfPages && } ({ label: subject.label, onPress: () => goToFeedLink(getFirstLink(subject.links)), @@ -429,19 +429,21 @@ export default function Screen() { )} - - {subtitle && } - {language && } - {readingDirection && } + + {subtitle && } + {language && } + {readingDirection && ( + + )} {modified && ( )} {published && ( )} diff --git a/apps/expo/components/opds/FeedSelfURL.tsx b/apps/expo/components/opds/FeedSelfURL.tsx index b38fdf6d3..703171049 100644 --- a/apps/expo/components/opds/FeedSelfURL.tsx +++ b/apps/expo/components/opds/FeedSelfURL.tsx @@ -4,6 +4,7 @@ import { ComponentPropsWithoutRef } from 'react' import { Pressable, View } from 'react-native' import { useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useActiveServer } from '../activeServer' import { Text } from '../ui' @@ -13,7 +14,8 @@ type Props = { label?: string } & Omit, 'children' | 'onPress'> -export default function FeedSelfURL({ label = 'See more', url }: Props) { +export default function FeedSelfURL({ label, url }: Props) { + const { t } = useTranslate() const router = useRouter() const { activeServer: { id: serverID }, @@ -42,7 +44,7 @@ export default function FeedSelfURL({ label = 'See more', url }: Props) { > - {label} + {label ?? t('opds.seeMore')} diff --git a/apps/expo/components/opds/FeedTitle.tsx b/apps/expo/components/opds/FeedTitle.tsx index 152996bb0..44470b4cb 100644 --- a/apps/expo/components/opds/FeedTitle.tsx +++ b/apps/expo/components/opds/FeedTitle.tsx @@ -2,6 +2,7 @@ import { OPDSFeed } from '@stump/sdk' import { View } from 'react-native' import { cn } from '~/lib/utils' +import { useTranslate } from '~/lib/hooks' import { Heading } from '../ui' @@ -11,10 +12,11 @@ type Props = { } export default function FeedTitle({ feed: { metadata }, className }: Props) { - const title = metadata.title || 'OPDS Feed' + const { t } = useTranslate() + const title = metadata.title || t('opds.feedTitle') return ( - + {title} diff --git a/apps/expo/components/opds/MaybeErrorFeed.tsx b/apps/expo/components/opds/MaybeErrorFeed.tsx index bcdc8cfcf..8bb3d0879 100644 --- a/apps/expo/components/opds/MaybeErrorFeed.tsx +++ b/apps/expo/components/opds/MaybeErrorFeed.tsx @@ -5,6 +5,7 @@ import { SafeAreaView } from 'react-native-safe-area-context' import { ZodError } from 'zod' import { isOPDSAuthError } from '~/lib/sdk/auth' +import { useTranslate } from '~/lib/hooks' import Owl from '../Owl' import { Button, Heading, Text } from '../ui' @@ -14,6 +15,7 @@ type Props = { onRetry?: () => void } export default function MaybeErrorFeed({ error, onRetry }: Props) { + const { t } = useTranslate() const router = useRouter() if (!error) return null @@ -21,7 +23,7 @@ export default function MaybeErrorFeed({ error, onRetry }: Props) { // Note: This is handled above in tree if (isOPDSAuthError(error)) return null - const errorTitle = error instanceof ZodError ? 'Invalid Feed' : 'Error Loading Feed' + const errorTitle = error instanceof ZodError ? t('opds.invalidFeed') : t('opds.feedLoadFailed') const errorMessage = error instanceof ZodError ? `This feed does not adhere to the OPDS v2.0 specification: ${error.message}` @@ -30,9 +32,9 @@ export default function MaybeErrorFeed({ error, onRetry }: Props) { : 'There was an error fetching this feed.' return ( - - - + + + @@ -46,14 +48,14 @@ export default function MaybeErrorFeed({ error, onRetry }: Props) { - + {onRetry && ( )} diff --git a/apps/expo/components/opds/Navigation.tsx b/apps/expo/components/opds/Navigation.tsx index cba60efad..ccaa98ad3 100644 --- a/apps/expo/components/opds/Navigation.tsx +++ b/apps/expo/components/opds/Navigation.tsx @@ -4,6 +4,8 @@ import { useRouter } from 'expo-router' import { ChevronRight, Rss } from 'lucide-react-native' import { Pressable, View } from 'react-native' +import { useTranslate } from '~/lib/hooks' + import { useActiveServer } from '../activeServer' import { Card, ListEmptyMessage } from '../ui' import { Icon } from '../ui/icon' @@ -14,6 +16,7 @@ type Props = { } & FeedComponentOptions export default function Navigation({ navigation, renderEmpty }: Props) { + const { t } = useTranslate() const { sdk } = useSDK() const { activeServer } = useActiveServer() const router = useRouter() @@ -22,7 +25,7 @@ export default function Navigation({ navigation, renderEmpty }: Props) { return ( - + {navigation.map((link) => ( {({ pressed }) => ( - + )} ))} - {!navigation.length && } + {!navigation.length && } ) } diff --git a/apps/expo/components/opds/NavigationFeed.tsx b/apps/expo/components/opds/NavigationFeed.tsx index 708cb6d82..03c8d81dc 100644 --- a/apps/expo/components/opds/NavigationFeed.tsx +++ b/apps/expo/components/opds/NavigationFeed.tsx @@ -57,7 +57,7 @@ export default function NavigationFeed({ > {({ pressed }) => ( - + )} diff --git a/apps/expo/components/opds/NavigationGroup.tsx b/apps/expo/components/opds/NavigationGroup.tsx index 49f697a4f..50e0752c4 100644 --- a/apps/expo/components/opds/NavigationGroup.tsx +++ b/apps/expo/components/opds/NavigationGroup.tsx @@ -6,6 +6,8 @@ import { ChevronRight, Rss } from 'lucide-react-native' import { useMemo } from 'react' import { Pressable, View } from 'react-native' +import { useTranslate } from '~/lib/hooks' + import { useActiveServer } from '../activeServer' import { Card, ListEmptyMessage } from '../ui' import { Icon } from '../ui/icon' @@ -21,6 +23,7 @@ export default function NavigationGroup({ group: { metadata, links, navigation: initialNavigation }, renderEmpty, }: Props) { + const { t } = useTranslate() const selfURL = links.find((link) => hasLinkRel(link, 'self'))?.href const hasGroupPagination = links.some((link) => hasLinkRel(link, 'next')) const { sdk } = useSDK() @@ -52,9 +55,9 @@ export default function NavigationGroup({ return ( - + : undefined} className="flex-1" > @@ -70,7 +73,7 @@ export default function NavigationGroup({ > {({ pressed }) => ( - + )} @@ -78,7 +81,9 @@ export default function NavigationGroup({ - {!navigation.length && } + {!navigation.length && ( + + )} ) } diff --git a/apps/expo/components/opds/NavigationLink.tsx b/apps/expo/components/opds/NavigationLink.tsx index 83ca95fdc..81cb037b2 100644 --- a/apps/expo/components/opds/NavigationLink.tsx +++ b/apps/expo/components/opds/NavigationLink.tsx @@ -34,14 +34,14 @@ export default function NavigationLink({ link }: Props) { > {({ pressed }) => ( {link.title} - + )} diff --git a/apps/expo/components/opds/OPDSAuthDialog.tsx b/apps/expo/components/opds/OPDSAuthDialog.tsx index 844fed518..dbc529d0c 100644 --- a/apps/expo/components/opds/OPDSAuthDialog.tsx +++ b/apps/expo/components/opds/OPDSAuthDialog.tsx @@ -13,6 +13,7 @@ import urlJoin from 'url-join' import { z } from 'zod' import { useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useActiveServer } from '../activeServer' import { SheetBackDetection } from '../SheetBackDetection' @@ -27,6 +28,7 @@ type OPDSAuthDialogProps = { } export default function OPDSAuthDialog({ isOpen, authDoc, onClose }: OPDSAuthDialogProps) { + const { t } = useTranslate() const { activeServer } = useActiveServer() const { sdk } = useSDK() @@ -93,7 +95,7 @@ export default function OPDSAuthDialog({ isOpen, authDoc, onClose }: OPDSAuthDia try { const newApi = await attemptRequest({ username, password }) if (!newApi) { - setLoginError('Failed to authenticate') + setLoginError(t('errors.connectionFailed')) return } @@ -113,11 +115,11 @@ export default function OPDSAuthDialog({ isOpen, authDoc, onClose }: OPDSAuthDia if (isAxiosError(error)) { setLoginError(error.message) } else { - setLoginError('An error occurred') + setLoginError(t('errors.unknown')) } } }, - [attemptRequest, sdk, onClose], + [attemptRequest, sdk, onClose, t], ) const basicAuth = authDoc?.authentication.find( @@ -216,7 +218,7 @@ export default function OPDSAuthDialog({ isOpen, authDoc, onClose }: OPDSAuthDia /> {/* eslint-disable-next-line react-hooks/refs */} diff --git a/apps/expo/components/opds/PublicationGroup.tsx b/apps/expo/components/opds/PublicationGroup.tsx index 2735e9569..81fdcfd12 100644 --- a/apps/expo/components/opds/PublicationGroup.tsx +++ b/apps/expo/components/opds/PublicationGroup.tsx @@ -8,7 +8,7 @@ import { Rss } from 'lucide-react-native' import { useCallback, useMemo } from 'react' import { Pressable, View } from 'react-native' -import { useListItemSize } from '~/lib/hooks' +import { useListItemSize, useTranslate } from '~/lib/hooks' import { cn } from '~/lib/utils' import { useActiveServer } from '../activeServer' @@ -27,6 +27,7 @@ export default function PublicationGroup({ // eslint-disable-next-line react/prop-types renderEmpty, }: Props) { + const { t } = useTranslate() const selfURL = links?.find((link) => hasLinkRel(link, 'self'))?.href const hasGroupPagination = links?.some((link) => hasLinkRel(link, 'next')) const router = useRouter() @@ -88,7 +89,7 @@ export default function PublicationGroup({ > {({ pressed }) => ( @@ -124,8 +125,8 @@ export default function PublicationGroup({ // card list sections return ( - - {metadata.title || 'Publications'} + + {metadata.title || t('opds.publications')} {selfURL && } @@ -142,7 +143,9 @@ export default function PublicationGroup({ ItemSeparatorComponent={() => } /> - {!publications.length && } + {!publications.length && ( + + )} ) } diff --git a/apps/expo/components/opds/PublicationMenu.tsx b/apps/expo/components/opds/PublicationMenu.tsx index 6cb4fc015..40c98e2f7 100644 --- a/apps/expo/components/opds/PublicationMenu.tsx +++ b/apps/expo/components/opds/PublicationMenu.tsx @@ -6,7 +6,7 @@ import { Platform, View } from 'react-native' import { Pressable } from 'react-native-gesture-handler' import * as DropdownMenu from 'zeego/dropdown-menu' -import { useIsOPDSPublicationDownloaded, useOPDSDownload } from '~/lib/hooks' +import { useIsOPDSPublicationDownloaded, useOPDSDownload, useTranslate } from '~/lib/hooks' import { useActiveServer } from '../activeServer' import { Icon } from '../ui' @@ -17,6 +17,7 @@ type Props = { } export default function PublicationMenu({ publicationUrl, metadata }: Props) { + const { t } = useTranslate() const { activeServer: { id: serverID }, } = useActiveServer() @@ -41,7 +42,7 @@ export default function PublicationMenu({ publicationUrl, metadata }: Props) { destructive disabled={!isDownloaded || isDeleting} > - Delete Download + {t('bookActions.deleteDownload.label')} @@ -71,7 +72,7 @@ export default function PublicationMenu({ publicationUrl, metadata }: Props) { disabled={isDeleting} destructive > - Delete Download + {t('bookActions.deleteDownload.label')} diff --git a/apps/expo/components/opdsLegacy/FeedActionMenu.tsx b/apps/expo/components/opdsLegacy/FeedActionMenu.tsx index 61005a562..eea7951ca 100644 --- a/apps/expo/components/opdsLegacy/FeedActionMenu.tsx +++ b/apps/expo/components/opdsLegacy/FeedActionMenu.tsx @@ -16,8 +16,10 @@ import { } from '~/components/ui' import { useColors } from '~/lib/constants' import { usePreferencesStore } from '~/stores' +import { useTranslate } from '~/lib/hooks' export default function FeedActionMenu() { + const { t } = useTranslate() const [isOpen, setIsOpen] = useState(false) const insets = useSafeAreaInsets() @@ -69,9 +71,9 @@ export default function FeedActionMenu() { key="displayAsGrid" onSelect={() => setPreferences({ opdsLayout: 'grid' })} > - Grid + {t('common.grid')} - Grid, + {t('common.grid')}, setPreferences({ opdsLayout: 'list' })} > - List + {t('common.list')} - List, + {t('common.list')}, @@ -91,7 +93,7 @@ export default function FeedActionMenu() { {onRetry && ( )} diff --git a/apps/expo/components/opdsLegacy/OPDSLegacyEntryItem.tsx b/apps/expo/components/opdsLegacy/OPDSLegacyEntryItem.tsx index 897ded808..ad8abc756 100644 --- a/apps/expo/components/opdsLegacy/OPDSLegacyEntryItem.tsx +++ b/apps/expo/components/opdsLegacy/OPDSLegacyEntryItem.tsx @@ -12,7 +12,7 @@ import { useRef } from 'react' import { Image, Platform, Pressable, View } from 'react-native' import { getLegacyStreamingContextValue } from '~/context/opdsLegacy' -import { useIsLegacyOPDSEntryDownloaded, useOPDSDownload } from '~/lib/hooks' +import { useIsLegacyOPDSEntryDownloaded, useOPDSDownload, useTranslate } from '~/lib/hooks' import { useColorScheme } from '~/lib/useColorScheme' import { cn } from '~/lib/utils' import { usePreferencesStore } from '~/stores' @@ -31,6 +31,7 @@ type Props = { } export default function OPDSEntry({ entry }: Props) { + const { t } = useTranslate() const { colorScheme } = useColorScheme() const { sdk } = useSDK() const { @@ -97,7 +98,7 @@ export default function OPDSEntry({ entry }: Props) { { items: [ { - label: 'See Details', + label: t('bookActions.seeDetails'), icon: { ios: 'info.circle', android: Info, @@ -117,7 +118,7 @@ export default function OPDSEntry({ entry }: Props) { { items: [ { - label: 'Download', + label: t('common.download'), disabled: !downloadLink || isDownloaded, onPress: () => { if (!downloadLink) return @@ -157,7 +158,7 @@ export default function OPDSEntry({ entry }: Props) { { items: [ { - label: 'Delete Download', + label: t('bookActions.deleteDownload.label'), onPress: () => { deleteBook({ id: entry.id, @@ -179,8 +180,8 @@ export default function OPDSEntry({ entry }: Props) { {({ pressed }) => ( + {isStreamable && ( - + )} {isDownloaded && ( - + )} @@ -251,7 +252,9 @@ export default function OPDSEntry({ entry }: Props) { {layout === 'list' && streamingContext?.pageCount != null && ( - {streamingContext.pageCount} pages + + {streamingContext.pageCount} {t('common.pages')} + )} diff --git a/apps/expo/components/opdsLegacy/OPDSLegacyEntryItemSheet.tsx b/apps/expo/components/opdsLegacy/OPDSLegacyEntryItemSheet.tsx index 83530c8d3..427e88279 100644 --- a/apps/expo/components/opdsLegacy/OPDSLegacyEntryItemSheet.tsx +++ b/apps/expo/components/opdsLegacy/OPDSLegacyEntryItemSheet.tsx @@ -10,6 +10,7 @@ import TImage from 'react-native-turbo-image' import { stripHtml } from 'string-strip-html' import { useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useColorScheme } from '~/lib/useColorScheme' import { usePreferencesStore } from '~/stores' @@ -29,6 +30,7 @@ type Props = { export const OPDSLegacyEntryItemSheet = forwardRef( function OPDSLegacyEntryItemSheet({ entry }, ref) { + const { t } = useTranslate() const { activeServer: { name: serverName }, } = useActiveServer() @@ -140,8 +142,10 @@ export const OPDSLegacyEntryItemSheet = forwardRef( {(pageCount != null || currentPage != null) && ( - {pageCount != null && } - {currentPage != null && } + {pageCount != null && } + {currentPage != null && ( + + )} )} @@ -149,15 +153,15 @@ export const OPDSLegacyEntryItemSheet = forwardRef( {!!description && } ({ label: author.name })) || [])]} /> - - + + {entry.updated && ( Date: Thu, 6 Aug 2026 18:56:12 +0900 Subject: [PATCH 37/54] =?UTF-8?q?feat(i18n):=20=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20=EC=B1=85=EA=B3=BC=20=EB=A6=AC=EB=8D=94=20=EB=AC=B8=EA=B5=AC?= =?UTF-8?q?=20=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../expo/components/book/BooksAfterCursor.tsx | 5 +- .../book/overview/AndroidBookMenu.tsx | 34 +++++---- .../book/reader/UnsupportedReader.tsx | 4 +- .../reader/epub/AnnotationsSheetContent.tsx | 12 +-- .../book/reader/epub/ReadiumReader.tsx | 5 +- .../epub/annotations/AnnotationInput.tsx | 6 +- .../annotations/CreateAnnotationSheet.tsx | 4 +- .../annotations/UpdateAnnotationSheet.tsx | 14 ++-- .../book/reader/epub/controls/ColumnCount.tsx | 2 +- .../book/reader/epub/controls/ThemeSelect.tsx | 26 ++++--- .../colorPickerRow/ColorPickerRow.tsx | 8 +- .../components/book/reader/image/Footer.tsx | 9 ++- .../book/reader/image/NextUpOverlay.tsx | 9 ++- .../components/book/reader/pdf/PdfReader.tsx | 5 +- .../components/series/SeriesOverviewSheet.tsx | 75 +++++++++++++------ .../components/series/SeriesSearchItem.tsx | 10 ++- packages/i18n/src/locales/en-US.json | 44 +++++++++++ packages/i18n/src/locales/ko-KR.json | 44 +++++++++++ 18 files changed, 228 insertions(+), 88 deletions(-) diff --git a/apps/expo/components/book/BooksAfterCursor.tsx b/apps/expo/components/book/BooksAfterCursor.tsx index d0656cc3b..8be16a710 100644 --- a/apps/expo/components/book/BooksAfterCursor.tsx +++ b/apps/expo/components/book/BooksAfterCursor.tsx @@ -4,7 +4,7 @@ import { graphql } from '@stump/graphql' import { View } from 'react-native' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' -import { useListItemSize } from '~/lib/hooks' +import { useListItemSize, useTranslate } from '~/lib/hooks' import { useActiveServer } from '../activeServer' import { ListLabel } from '../ui' @@ -36,6 +36,7 @@ type Props = { } export function BooksAfterCursor({ cursor }: Props) { + const { t } = useTranslate() const { activeServer: { id: serverID }, } = useActiveServer() @@ -64,7 +65,7 @@ export function BooksAfterCursor({ cursor }: Props) { return ( - Up Next + {t('reader.nextUp')} } diff --git a/apps/expo/components/book/overview/AndroidBookMenu.tsx b/apps/expo/components/book/overview/AndroidBookMenu.tsx index 17afb8585..d1d0b49d1 100644 --- a/apps/expo/components/book/overview/AndroidBookMenu.tsx +++ b/apps/expo/components/book/overview/AndroidBookMenu.tsx @@ -25,6 +25,7 @@ import { Text, } from '~/components/ui' import { cn } from '~/lib/utils' +import { useTranslate } from '~/lib/hooks' type Props = { book: BookMenuFragment @@ -47,6 +48,7 @@ export default function AndroidBookMenu({ deleteCurrentSession, deleteReadHistory, }: Props) { + const { t } = useTranslate() const router = useRouter() const insets = useSafeAreaInsets() const contentInsets = { @@ -63,7 +65,7 @@ export default function AndroidBookMenu({ return ( - diff --git a/apps/expo/components/book/reader/epub/controls/ColumnCount.tsx b/apps/expo/components/book/reader/epub/controls/ColumnCount.tsx index 12638df17..ce8073aee 100644 --- a/apps/expo/components/book/reader/epub/controls/ColumnCount.tsx +++ b/apps/expo/components/book/reader/epub/controls/ColumnCount.tsx @@ -27,7 +27,7 @@ export default function ColumnCount() { } return ( - + { + const { t } = useTranslate() const { onSelect, deleteTheme, addTheme } = useEpubThemesStore( useShallow((store) => ({ onSelect: store.selectTheme, @@ -71,21 +73,21 @@ const ThemePreviewButton = ({ name, config, isActive, themeNames }: ThemePreview const openCustomizeTheme = useEpubSheetStore((state) => state.openCustomizeTheme) const handleDuplicate = useCallback(() => { - const newName = `${name} copy` + const newName = t('reader.themeCopyName', { name }) addTheme(newName, config) onSelect(newName) - }, [name, config, addTheme, onSelect]) + }, [name, config, addTheme, onSelect, t]) const handleDelete = useCallback(() => { if (themeNames.length <= 1) { - Alert.alert('Error', 'You must have at least one theme') + Alert.alert(t('common.error'), t('reader.minimumThemeError')) return } - Alert.alert('Delete Theme', `Are you sure you want to delete '${name}'?`, [ - { text: 'Cancel', style: 'cancel' }, + Alert.alert(t('reader.deleteTheme'), t('reader.deleteThemeConfirmation', { name }), [ + { text: t('common.cancel'), style: 'cancel' }, { - text: 'Delete', + text: t('common.delete'), style: 'destructive', onPress: () => { if (isActive) { @@ -101,7 +103,7 @@ const ThemePreviewButton = ({ name, config, isActive, themeNames }: ThemePreview }, }, ]) - }, [themeNames, name, deleteTheme, isActive, onSelect]) + }, [themeNames, name, deleteTheme, isActive, onSelect, t]) return ( @@ -110,7 +112,7 @@ const ThemePreviewButton = ({ name, config, isActive, themeNames }: ThemePreview @@ -118,7 +120,7 @@ const ThemePreviewButton = ({ name, config, isActive, themeNames }: ThemePreview openCustomizeTheme({ mode: 'edit', name })}> - Edit Theme + {t('reader.editTheme')} @@ -126,12 +128,12 @@ const ThemePreviewButton = ({ name, config, isActive, themeNames }: ThemePreview ios={{ name: 'plus.square.on.square' }} androidIconName="ic_menu_add" /> - Duplicate + {t('reader.duplicateTheme')} - Delete + {t('common.delete')} @@ -142,7 +144,7 @@ const NewThemeButton = () => { const openCustomizeTheme = useEpubSheetStore((state) => state.openCustomizeTheme) return ( openCustomizeTheme({ mode: 'create' })}> - + diff --git a/apps/expo/components/book/reader/epub/controls/customTheme/colorPickerRow/ColorPickerRow.tsx b/apps/expo/components/book/reader/epub/controls/customTheme/colorPickerRow/ColorPickerRow.tsx index b72339335..f4e9618b8 100644 --- a/apps/expo/components/book/reader/epub/controls/customTheme/colorPickerRow/ColorPickerRow.tsx +++ b/apps/expo/components/book/reader/epub/controls/customTheme/colorPickerRow/ColorPickerRow.tsx @@ -6,6 +6,7 @@ import ColorPicker, { HueSlider, Panel1 } from 'reanimated-color-picker' import { SheetBackDetection } from '~/components/SheetBackDetection' import { Button, Text } from '~/components/ui' import { useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' type Props = { label: string @@ -14,6 +15,7 @@ type Props = { } export function ColorPickerRow({ label, value, onChange }: Props) { + const { t } = useTranslate() const sheetRef = useRef(null) const [tempColor, setTempColor] = useState(value) const colors = useColors() @@ -68,17 +70,17 @@ export function ColorPickerRow({ label, value, onChange }: Props) { - Hue + {t('reader.hue')} diff --git a/apps/expo/components/book/reader/image/Footer.tsx b/apps/expo/components/book/reader/image/Footer.tsx index ece0d98ec..207213555 100644 --- a/apps/expo/components/book/reader/image/Footer.tsx +++ b/apps/expo/components/book/reader/image/Footer.tsx @@ -11,7 +11,7 @@ import TImage from 'react-native-turbo-image' import { getThumbnailResizeProps, TurboImage } from '~/components/image' import { Progress, Text } from '~/components/ui' import { useColors } from '~/lib/constants' -import { useDisplay, usePrevious } from '~/lib/hooks' +import { useDisplay, usePrevious, useTranslate } from '~/lib/hooks' import { cn } from '~/lib/utils' import { usePreferencesStore, useReaderStore } from '~/stores' import { useBookPreferences } from '~/stores/reader' @@ -22,6 +22,7 @@ import { useImageBasedReader } from './context' const SIZE_MODIFIER = 1.5 export default function Footer() { + const { t } = useTranslate() const { isTablet, width } = useDisplay() const { book, @@ -556,13 +557,15 @@ export default function Footer() { > {trackElapsedTime && ( - Reading time: {formattedReadTime} + + {t('reader.readingTime', { time: formattedReadTime })} + )} - Page {currentPage} of {book.pages} + {t('reader.pageOf', { current: currentPage, total: book.pages })} diff --git a/apps/expo/components/book/reader/image/NextUpOverlay.tsx b/apps/expo/components/book/reader/image/NextUpOverlay.tsx index 81d1ad41d..a90573275 100644 --- a/apps/expo/components/book/reader/image/NextUpOverlay.tsx +++ b/apps/expo/components/book/reader/image/NextUpOverlay.tsx @@ -14,7 +14,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context' import { ThumbnailImage } from '~/components/image' import { Button, Heading, Icon, Label, Text } from '~/components/ui' import { COLORS } from '~/lib/constants' -import { useDisplay } from '~/lib/hooks' +import { useDisplay, useTranslate } from '~/lib/hooks' import { cn } from '~/lib/utils' import { usePreferencesStore } from '~/stores' @@ -27,6 +27,7 @@ type Props = { } export default function NextUpOverlay({ isVisible, book, onClose }: Props) { + const { t } = useTranslate() const { sdk } = useSDK() const { serverId } = useImageBasedReader() @@ -106,7 +107,7 @@ export default function NextUpOverlay({ isVisible, book, onClose }: Props) { - + {book.name} @@ -130,8 +131,8 @@ export default function NextUpOverlay({ isVisible, book, onClose }: Props) { width: size + 16, }} > - diff --git a/apps/expo/components/book/reader/pdf/PdfReader.tsx b/apps/expo/components/book/reader/pdf/PdfReader.tsx index 53a8fd82c..d72af0a5f 100644 --- a/apps/expo/components/book/reader/pdf/PdfReader.tsx +++ b/apps/expo/components/book/reader/pdf/PdfReader.tsx @@ -5,7 +5,7 @@ import { View } from 'react-native' import { useShallow } from 'zustand/react/shallow' import { FullScreenLoader } from '~/components/ui' -import { useDownload } from '~/lib/hooks' +import { useDownload, useTranslate } from '~/lib/hooks' import { intoPDFReadiumLocator, PDFBookLoadedEvent, @@ -59,6 +59,7 @@ type Props = { // - settings sheet, a chunk of existing settings do not apply to PDF export default function PdfReader({ book, initialPage, onPageChanged, ...ctx }: Props) { + const { t } = useTranslate() const { downloadImmediate } = useDownload({ serverId: ctx.serverId }) const [localUri, setLocalUri] = useState(() => ctx.offlineUri || null) @@ -227,7 +228,7 @@ export default function PdfReader({ book, initialPage, onPageChanged, ...ctx }: [controlsVisible, setControlsVisible], ) - if (isDownloading) return + if (isDownloading) return if (!localUri) return null diff --git a/apps/expo/components/series/SeriesOverviewSheet.tsx b/apps/expo/components/series/SeriesOverviewSheet.tsx index d02f0b66b..0b9f950cf 100644 --- a/apps/expo/components/series/SeriesOverviewSheet.tsx +++ b/apps/expo/components/series/SeriesOverviewSheet.tsx @@ -11,6 +11,7 @@ import { ScrollView } from 'react-native-gesture-handler' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { IS_IOS_26_PLUS, STAT_COLORS, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useGridItemSize } from '../listLayout/grid/useGridItemSize' import { MetadataBadgeSection } from '../overview' @@ -127,6 +128,7 @@ type SheetContentProps = { } function SheetContent({ series: { stats, metadata, resolvedName, tags } }: SheetContentProps) { + const { t } = useTranslate() const formattedSize = formatBytesSeparate(stats.totalBytes) const formattedTime = formatHumanDurationSeparate(stats.totalReadingTimeSeconds) @@ -137,34 +139,34 @@ function SheetContent({ series: { stats, metadata, resolvedName, tags } }: Sheet const seriesStats = [ { - label: 'In Progress', + label: t('seriesOverview.inProgress'), value: stats.inProgressBooks, icon: BookOpen, colors: STAT_COLORS.inProgress, }, { - label: 'Completed', + label: t('seriesOverview.completed'), value: stats.completedBooks, suffix: `/ ${stats.bookCount}`, icon: BookCheck, colors: STAT_COLORS.completed, }, { - label: 'Books', + label: t('seriesOverview.books'), value: stats.bookCount, icon: Library, colors: STAT_COLORS.books, }, { - label: 'Reading Time', + label: t('seriesOverview.readingTime'), value: formattedTime ? formattedTime.value : '??', suffix: formattedTime ? formattedTime.unit : undefined, icon: Clock, colors: STAT_COLORS.readingTime, }, { - label: 'Size', - value: formattedSize ? formattedSize.value : 'Unknown', + label: t('seriesOverview.size'), + value: formattedSize ? formattedSize.value : t('common.unknown'), suffix: formattedSize ? formattedSize.unit : '', icon: HardDrive, colors: STAT_COLORS.size, @@ -211,50 +213,75 @@ function SheetContent({ series: { stats, metadata, resolvedName, tags } }: Sheet {hasAbout && ( - - {metadata?.summary && } + + {metadata?.summary && ( + + )} {metadata?.descriptionFormatted && !metadata?.summary && ( - + )} )} {hasPublicationInfo && ( - - {metadata?.publisher && } - {metadata?.imprint && } + + {metadata?.publisher && ( + + )} + {metadata?.imprint && ( + + )} {metadata?.publicationRun && ( - + + )} + {metadata?.status && ( + + )} + {metadata?.booktype && ( + + )} + {metadata?.year && ( + + )} + {metadata?.volume && ( + )} - {metadata?.status && } - {metadata?.booktype && } - {metadata?.year && } - {metadata?.volume && } {metadata?.totalIssues && ( - + )} )} {hasDetails && ( - + {metadata?.ageRating && ( - + + )} + {metadata?.metaType && ( + )} - {metadata?.metaType && } )} ({ label: genre }))} /> ({ label: writer }))} /> ({ label: character }))} /> diff --git a/apps/expo/components/series/SeriesSearchItem.tsx b/apps/expo/components/series/SeriesSearchItem.tsx index 30e851581..54007c2f6 100644 --- a/apps/expo/components/series/SeriesSearchItem.tsx +++ b/apps/expo/components/series/SeriesSearchItem.tsx @@ -3,7 +3,7 @@ import { FragmentType, graphql, useFragment } from '@stump/graphql' import { useRouter } from 'expo-router' import { Pressable, View } from 'react-native' -import { useDisplay } from '~/lib/hooks' +import { useDisplay, useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' import { useActiveServer } from '../activeServer' @@ -48,6 +48,7 @@ type Props = { } export default function SeriesSearchItem({ series }: Props) { + const { t } = useTranslate() const { sdk } = useSDK() const { activeServer: { id: serverID }, @@ -72,7 +73,7 @@ export default function SeriesSearchItem({ series }: Props) { width: width * 0.75, }} > - + - + {data.resolvedName} - {data.readCount}/{data.mediaCount} books • {data.percentageCompleted.toFixed(1)}% + {data.readCount}/{data.mediaCount} {t('seriesOverview.books')} •{' '} + {data.percentageCompleted.toFixed(1)}% diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index 842283227..097c36d38 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -3581,8 +3581,49 @@ "url": "URL", "username": "Username", "value": "Value", + "share": "Share", + "clearFilters": "Clear Filters", "version": "Version" }, + "reader": { + "unsupportedFormat": "The book reader for this format is not supported yet. Check back later!", + "downloading": "Downloading...", + "readingTime": "Reading time: {{time}}", + "pageOf": "Page {{current}} of {{total}}", + "nextUp": "Next Up:", + "readNext": "Read Next", + "editTheme": "Edit Theme", + "duplicateTheme": "Duplicate", + "newAnnotation": "New Annotation", + "editAnnotation": "Edit Annotation", + "note": "Note", + "notePlaceholder": "Enter your notes...", + "hue": "Hue", + "themeCopyName": "{{name}} copy", + "minimumThemeError": "You must have at least one theme", + "deleteTheme": "Delete Theme", + "deleteThemeConfirmation": "Are you sure you want to delete '{{name}}'?", + "deleteHighlight": "Delete Highlight", + "deleteAnnotationConfirmation": "Are you sure you want to delete this annotation?" + }, + "seriesOverview": { + "about": "About", + "summary": "Summary", + "description": "Description", + "publication": "Publication", + "imprint": "Imprint", + "publicationRun": "Publication Run", + "status": "Status", + "bookType": "Book Type", + "totalIssues": "Total Issues", + "type": "Type", + "booksCount": "{{count}} books", + "inProgress": "In Progress", + "completed": "Completed", + "books": "Books", + "readingTime": "Reading Time", + "size": "Size" + }, "auth": { "login": "Login", "loginWithOidc": "Login with OIDC", @@ -4025,6 +4066,9 @@ "showCurrentChapter": "Show Current Chapter" }, "annotationsSheet": { + "highlight": "Highlight", + "note": "Note", + "bookmark": "Bookmark", "tabs": { "all": "All", "bookmarks": "Bookmarks", diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index cef9905ee..1b2790e46 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -3581,8 +3581,49 @@ "url": "URL", "username": "사용자 이름", "value": "값", + "share": "공유", + "clearFilters": "필터 지우기", "version": "버전" }, + "reader": { + "unsupportedFormat": "이 형식의 책은 아직 리더에서 지원하지 않습니다. 나중에 다시 확인해 주세요!", + "downloading": "다운로드 중...", + "readingTime": "읽은 시간: {{time}}", + "pageOf": "{{total}}페이지 중 {{current}}페이지", + "nextUp": "다음 책:", + "readNext": "다음 책 읽기", + "editTheme": "테마 편집", + "duplicateTheme": "복제", + "newAnnotation": "새 주석", + "editAnnotation": "주석 편집", + "note": "메모", + "notePlaceholder": "메모를 입력하세요...", + "hue": "색상", + "themeCopyName": "{{name}} 복사본", + "minimumThemeError": "테마가 하나 이상 있어야 합니다", + "deleteTheme": "테마 삭제", + "deleteThemeConfirmation": "'{{name}}' 테마를 삭제하시겠습니까?", + "deleteHighlight": "하이라이트 삭제", + "deleteAnnotationConfirmation": "이 주석을 삭제하시겠습니까?" + }, + "seriesOverview": { + "about": "소개", + "summary": "요약", + "description": "설명", + "publication": "출판 정보", + "imprint": "임프린트", + "publicationRun": "출판 기간", + "status": "상태", + "bookType": "책 유형", + "totalIssues": "전체 호 수", + "type": "유형", + "booksCount": "책 {{count}}권", + "inProgress": "읽는 중", + "completed": "완독", + "books": "책", + "readingTime": "읽은 시간", + "size": "크기" + }, "auth": { "login": "로그인", "loginWithOidc": "OIDC로 로그인", @@ -4025,6 +4066,9 @@ "showCurrentChapter": "현재 장 표시" }, "annotationsSheet": { + "highlight": "하이라이트", + "note": "메모", + "bookmark": "북마크", "tabs": { "all": "전체", "bookmarks": "북마크", From 02aca543461cf146327994ff2dc0134a7ceaff23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 19:02:43 +0900 Subject: [PATCH 38/54] =?UTF-8?q?feat(i18n):=20=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20=EB=B6=81=ED=81=B4=EB=9F=BD=20=EB=AC=B8=EA=B5=AC=20=EA=B5=AD?= =?UTF-8?q?=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/server/[id]/(tabs)/clubs/_layout.tsx | 8 +- .../app/server/[id]/(tabs)/clubs/create.tsx | 18 +++-- .../app/server/[id]/(tabs)/clubs/index.tsx | 7 +- .../app/server/[id]/(tabs)/clubs/invites.tsx | 7 +- .../server/[id]/clubs/[clubId]/_layout.tsx | 6 +- .../[id]/clubs/[clubId]/archive/_layout.tsx | 4 +- .../[id]/clubs/[clubId]/archive/index.tsx | 7 +- .../[clubId]/archive/past-book/[bookId].tsx | 10 ++- .../[roomId]/thread/[messageId].tsx | 8 +- .../app/server/[id]/clubs/[clubId]/index.tsx | 31 +++++--- .../server/[id]/clubs/[clubId]/settings.tsx | 6 +- .../bookClub/AddBookOptionsSheet.tsx | 18 +++-- .../expo/components/bookClub/AddBookSheet.tsx | 16 +++- .../components/bookClub/CurrentBookCard.tsx | 44 ++++++----- .../components/bookClub/CurrentBookSheet.tsx | 4 +- .../bookClub/ManualBookEntrySheet.tsx | 36 +++++---- .../components/bookClub/PastBookGridItem.tsx | 6 +- .../bookClub/PastDiscussionsLink.tsx | 10 ++- .../components/bookClub/PreviewBookSheet.tsx | 4 +- .../bookClub/SuggestionsPickerSheet.tsx | 24 ++++-- .../bookClub/discussion/Message.tsx | 24 +++--- .../discussion/MessageActionSheet.tsx | 23 ++++-- .../bookClub/discussion/MessageComposer.tsx | 27 ++++--- .../discussion/MessageReplyPreview.tsx | 14 ++-- packages/i18n/src/locales/en-US.json | 77 +++++++++++++++++++ packages/i18n/src/locales/ko-KR.json | 77 +++++++++++++++++++ 26 files changed, 376 insertions(+), 140 deletions(-) diff --git a/apps/expo/app/server/[id]/(tabs)/clubs/_layout.tsx b/apps/expo/app/server/[id]/(tabs)/clubs/_layout.tsx index 692cdd078..9a11dd5c7 100644 --- a/apps/expo/app/server/[id]/(tabs)/clubs/_layout.tsx +++ b/apps/expo/app/server/[id]/(tabs)/clubs/_layout.tsx @@ -8,9 +8,11 @@ import { useActiveServer, useStumpServer } from '~/components/activeServer' import BackLink from '~/components/BackLink' import { Icon } from '~/components/ui' import { IS_IOS_26_PLUS } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' export default function Layout() { + const { t } = useTranslate() const animationEnabled = usePreferencesStore((state) => !state.reduceAnimations) const { activeServer: { id: serverID }, @@ -25,7 +27,7 @@ export default function Layout() { name="index" options={{ headerShown: true, - headerTitle: 'Clubs', + headerTitle: t('bookClub.clubs'), headerTransparent: Platform.OS === 'ios', headerBlurEffect: IS_IOS_26_PLUS ? undefined : 'regular', headerLargeTitle: true, @@ -41,7 +43,7 @@ export default function Layout() { name="invites" options={{ headerShown: true, - title: 'Invites', + title: t('bookClub.invites'), headerTransparent: Platform.OS === 'ios', headerBlurEffect: IS_IOS_26_PLUS ? undefined : 'regular', animation: animationEnabled ? 'default' : 'none', @@ -55,7 +57,7 @@ export default function Layout() { name="create" options={{ headerShown: true, - title: 'Create Club', + title: t('bookClub.createClub'), headerTransparent: Platform.OS === 'ios', headerBlurEffect: IS_IOS_26_PLUS ? undefined : 'regular', presentation: 'formSheet', diff --git a/apps/expo/app/server/[id]/(tabs)/clubs/create.tsx b/apps/expo/app/server/[id]/(tabs)/clubs/create.tsx index 0e22b2168..931b5efcf 100644 --- a/apps/expo/app/server/[id]/(tabs)/clubs/create.tsx +++ b/apps/expo/app/server/[id]/(tabs)/clubs/create.tsx @@ -10,6 +10,7 @@ import { toast } from 'sonner-native' import { useActiveServer } from '~/components/activeServer' import { Icon, Input, Switch, Text } from '~/components/ui' +import { useTranslate } from '~/lib/hooks' const createMutation = graphql(` mutation CreateBookClubMobile($input: CreateBookClubInput!) { @@ -21,6 +22,7 @@ const createMutation = graphql(` `) export default function Screen() { + const { t } = useTranslate() const router = useRouter() const { activeServer: { id: serverID }, @@ -51,11 +53,11 @@ export default function Screen() { router.replace(`/server/${serverID}/clubs/${result.createBookClub.id}`) } } catch (error) { - toast.error('Failed to create club', { - description: error instanceof Error ? error.message : 'An unknown error occurred', + toast.error(t('bookClub.createFailed'), { + description: error instanceof Error ? error.message : t('errors.unknown'), }) } - }, [canSubmit, createClub, description, isPrivate, name, router, serverID]) + }, [canSubmit, createClub, description, isPrivate, name, router, serverID, t]) const navigation = useNavigation() useLayoutEffect(() => { @@ -82,8 +84,8 @@ export default function Screen() { > setName(e.nativeEvent.text)} autoCapitalize="words" @@ -91,8 +93,8 @@ export default function Screen() { /> setDescription(e.nativeEvent.text)} multiline @@ -102,7 +104,7 @@ export default function Screen() { {/* FIXME: No idea why I need mt here, something weird with switches */} - Private + {t('bookClub.private')} diff --git a/apps/expo/app/server/[id]/(tabs)/clubs/index.tsx b/apps/expo/app/server/[id]/(tabs)/clubs/index.tsx index 23b7561e4..b83599315 100644 --- a/apps/expo/app/server/[id]/(tabs)/clubs/index.tsx +++ b/apps/expo/app/server/[id]/(tabs)/clubs/index.tsx @@ -14,6 +14,7 @@ import { BookClubCard } from '~/components/bookClub' import ListEmpty from '~/components/ListEmpty' import RefreshControl from '~/components/RefreshControl' import { Icon } from '~/components/ui' +import { useTranslate } from '~/lib/hooks' const query = graphql(` query BookClubsScreen { @@ -28,6 +29,7 @@ const query = graphql(` `) export default function Screen() { + const { t } = useTranslate() const { activeServer: { id: serverID }, } = useActiveServer() @@ -87,10 +89,7 @@ export default function Screen() { contentInsetAdjustmentBehavior="always" ItemSeparatorComponent={() => } ListEmptyComponent={ - + } refreshControl={} /> diff --git a/apps/expo/app/server/[id]/(tabs)/clubs/invites.tsx b/apps/expo/app/server/[id]/(tabs)/clubs/invites.tsx index f015e4c05..db6064d06 100644 --- a/apps/expo/app/server/[id]/(tabs)/clubs/invites.tsx +++ b/apps/expo/app/server/[id]/(tabs)/clubs/invites.tsx @@ -4,6 +4,7 @@ import { SafeAreaView } from 'react-native-safe-area-context' import { useActiveServer } from '~/components/activeServer' import ListEmpty from '~/components/ListEmpty' +import { useTranslate } from '~/lib/hooks' const query = graphql(` query BookClubInvitesScreen { @@ -33,6 +34,7 @@ type Invitation = NonNullable< > export default function Screen() { + const { t } = useTranslate() const { activeServer: { id: serverID }, } = useActiveServer() @@ -52,10 +54,7 @@ export default function Screen() { if (!invitations.length) { return ( - + ) } diff --git a/apps/expo/app/server/[id]/clubs/[clubId]/_layout.tsx b/apps/expo/app/server/[id]/clubs/[clubId]/_layout.tsx index c1ff08b03..513912fa6 100644 --- a/apps/expo/app/server/[id]/clubs/[clubId]/_layout.tsx +++ b/apps/expo/app/server/[id]/clubs/[clubId]/_layout.tsx @@ -6,6 +6,7 @@ import { Platform } from 'react-native' import BackLink from '~/components/BackLink' import { BookClubContext } from '~/components/bookClub/context' import { IS_IOS_26_PLUS } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' const clubContextQuery = graphql(` @@ -21,6 +22,7 @@ const clubContextQuery = graphql(` `) export default function Screen() { + const { t } = useTranslate() const { clubId } = useLocalSearchParams<{ clubId: string }>() const animationEnabled = usePreferencesStore((state) => !state.reduceAnimations) @@ -53,14 +55,14 @@ export default function Screen() { options={{ presentation: 'modal', headerShown: true, - title: 'Club Settings', + title: t('bookClub.clubSettings'), }} /> diff --git a/apps/expo/app/server/[id]/clubs/[clubId]/archive/_layout.tsx b/apps/expo/app/server/[id]/clubs/[clubId]/archive/_layout.tsx index 7f8526173..9323c1e85 100644 --- a/apps/expo/app/server/[id]/clubs/[clubId]/archive/_layout.tsx +++ b/apps/expo/app/server/[id]/clubs/[clubId]/archive/_layout.tsx @@ -2,9 +2,11 @@ import { Stack } from 'expo-router' import { Platform } from 'react-native' import BackLink from '~/components/BackLink' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' export default function Screen() { + const { t } = useTranslate() const animationEnabled = usePreferencesStore((state) => !state.reduceAnimations) return ( @@ -13,7 +15,7 @@ export default function Screen() { name="index" options={{ headerShown: true, - title: 'Past Discussions', + title: t('bookClub.pastDiscussions'), headerLeft: Platform.OS === 'android' ? undefined : () => , }} /> diff --git a/apps/expo/app/server/[id]/clubs/[clubId]/archive/index.tsx b/apps/expo/app/server/[id]/clubs/[clubId]/archive/index.tsx index 8fd5c22c6..781dca4d6 100644 --- a/apps/expo/app/server/[id]/clubs/[clubId]/archive/index.tsx +++ b/apps/expo/app/server/[id]/clubs/[clubId]/archive/index.tsx @@ -12,6 +12,7 @@ import { PastBookGridItem } from '~/components/bookClub' import { usePastDiscussionSize } from '~/components/bookClub/usePastDiscussionSize' import ListEmpty from '~/components/ListEmpty' import RefreshControl from '~/components/RefreshControl' +import { useTranslate } from '~/lib/hooks' const query = graphql(` query BookClubPastDiscussions($bookClubId: ID!) { @@ -30,6 +31,7 @@ const query = graphql(` type BookClubBook = BookClubPastDiscussionsQuery['previousBookClubDiscussions'][number]['book'] export default function Screen() { + const { t } = useTranslate() const { clubId } = useLocalSearchParams<{ clubId: string }>() const { data, refetch } = useSuspenseGraphQL(query, ['bookClubPastDiscussions', clubId], { @@ -101,7 +103,10 @@ export default function Screen() { contentContainerStyle={{ paddingHorizontal, paddingVertical: 16 }} ItemSeparatorComponent={() => } ListEmptyComponent={ - + } // TODO: Support both archive discussions without books and with books renderItem={({ item }) => diff --git a/apps/expo/app/server/[id]/clubs/[clubId]/archive/past-book/[bookId].tsx b/apps/expo/app/server/[id]/clubs/[clubId]/archive/past-book/[bookId].tsx index afb0080a0..58bb8b048 100644 --- a/apps/expo/app/server/[id]/clubs/[clubId]/archive/past-book/[bookId].tsx +++ b/apps/expo/app/server/[id]/clubs/[clubId]/archive/past-book/[bookId].tsx @@ -10,6 +10,7 @@ import { useActiveServer } from '~/components/activeServer' import { DiscussionListItem } from '~/components/bookClub/discussion' import ListEmpty from '~/components/ListEmpty' import { Card } from '~/components/ui' +import { useTranslate } from '~/lib/hooks' const query = graphql(` query BookClubPastBookScreen($bookId: ID!) { @@ -27,6 +28,7 @@ const query = graphql(` `) export default function Screen() { + const { t } = useTranslate() const { bookId } = useLocalSearchParams<{ bookId: string }>() const { activeServer: { id: serverID }, @@ -43,9 +45,9 @@ export default function Screen() { useEffect(() => { if (!bookName) return navigation.setOptions({ - title: `${bookName} - Archive`, + title: t('bookClub.archiveTitle', { book: bookName }), }) - }, [bookName, navigation]) + }, [bookName, navigation, t]) // TODO(book-club): FlashList most likely return ( @@ -53,7 +55,7 @@ export default function Screen() { {discussions.length > 0 && ( - + {discussions.map((discussion) => ( @@ -61,7 +63,7 @@ export default function Screen() { ))} )} - {discussions.length === 0 && } + {discussions.length === 0 && } diff --git a/apps/expo/app/server/[id]/clubs/[clubId]/discussion/[roomId]/thread/[messageId].tsx b/apps/expo/app/server/[id]/clubs/[clubId]/discussion/[roomId]/thread/[messageId].tsx index 3a6cf0814..cea6dec62 100644 --- a/apps/expo/app/server/[id]/clubs/[clubId]/discussion/[roomId]/thread/[messageId].tsx +++ b/apps/expo/app/server/[id]/clubs/[clubId]/discussion/[roomId]/thread/[messageId].tsx @@ -9,6 +9,7 @@ import { KeyboardAvoidingView } from 'react-native-keyboard-controller' import { useBookClubContext } from '~/components/bookClub/context' import { DiscussionRoom, Message, type MessageData } from '~/components/bookClub/discussion' import type { EmojiSelection } from '~/components/emoji/types' +import { useTranslate } from '~/lib/hooks' const parentMessageQuery = graphql(` query ThreadParentMessage($id: ID!) { @@ -127,6 +128,7 @@ const discussionQuery = graphql(` `) export default function ThreadScreen() { + const { t } = useTranslate() const { roomId, messageId } = useLocalSearchParams<{ roomId: string messageId: string @@ -151,9 +153,9 @@ export default function ThreadScreen() { useLayoutEffect(() => { navigation.setOptions({ headerShown: true, - title: 'Thread', + title: t('bookClub.thread'), }) - }, [navigation]) + }, [navigation, t]) const { data: parentData } = useSuspenseGraphQL( parentMessageQuery, @@ -229,7 +231,7 @@ export default function ThreadScreen() { const threadHeader = useMemo(() => { if (!parentMessage) return undefined return ( - + () const router = useRouter() const { @@ -97,10 +99,13 @@ export default function Screen() { const renderProgression = () => { if (currentBookCompletedAt) { + const date = intlFormat(new Date(currentBookCompletedAt), { + month: 'long', + year: 'numeric', + }) return ( - Completed{' '} - {intlFormat(new Date(currentBookCompletedAt), { month: 'long', year: 'numeric' })} + {t('bookClub.completedAt', { date })} ) } else if (activeProgress) { @@ -110,18 +115,20 @@ export default function Screen() { if (decimal != null) { return ( - You are {decimal.toFixed(2)}% through the book! + {t('bookClub.progressThroughBook', { percentage: decimal.toFixed(2) })} ) } const startedAt = new Date(activeProgress.startedAt) return ( - Started {intlFormat(startedAt, { month: 'long', year: 'numeric' })} + {t('bookClub.startedAt', { + date: intlFormat(startedAt, { month: 'long', year: 'numeric' }), + })} ) } else { - return You have not started this book yet + return {t('bookClub.notStarted')} } } @@ -138,7 +145,7 @@ export default function Screen() { {club.moderators.length > 0 && ( - Moderated by + {t('bookClub.moderatedBy')} @@ -146,7 +153,7 @@ export default function Screen() { )} {club.pinnedDiscussions.length > 0 && ( - + {club.pinnedDiscussions.map((discussion) => ( @@ -179,7 +186,11 @@ export default function Screen() { } > - {currentBookCompletedAt ? 'See book' : activeProgress ? 'Continue' : 'Start'} + {currentBookCompletedAt + ? t('bookClub.seeBook') + : activeProgress + ? t('common.continue') + : t('bookClub.start')} @@ -188,9 +199,9 @@ export default function Screen() { )} {club.currentBook?.discussions.map((discussion) => ( diff --git a/apps/expo/app/server/[id]/clubs/[clubId]/settings.tsx b/apps/expo/app/server/[id]/clubs/[clubId]/settings.tsx index 6f84d8903..f985ab78e 100644 --- a/apps/expo/app/server/[id]/clubs/[clubId]/settings.tsx +++ b/apps/expo/app/server/[id]/clubs/[clubId]/settings.tsx @@ -5,6 +5,7 @@ import { ScrollView } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { Text } from '~/components/ui' +import { useTranslate } from '~/lib/hooks' const query = graphql(` query BookClubSettings($id: ID!) { @@ -47,6 +48,7 @@ const leaveClubMutation = graphql(` `) export default function Screen() { + const { t } = useTranslate() const { clubId } = useLocalSearchParams<{ clubId: string }>() const { data } = useSuspenseGraphQL(query, ['bookClubById', clubId, 'settings'], { @@ -57,8 +59,8 @@ export default function Screen() { return ( - - TODO: Make me + + {t('bookClub.settingsNotImplemented')} ) diff --git a/apps/expo/components/bookClub/AddBookOptionsSheet.tsx b/apps/expo/components/bookClub/AddBookOptionsSheet.tsx index d351dcb97..45ff24c08 100644 --- a/apps/expo/components/bookClub/AddBookOptionsSheet.tsx +++ b/apps/expo/components/bookClub/AddBookOptionsSheet.tsx @@ -7,6 +7,7 @@ import { Platform, View } from 'react-native' import { Pressable } from 'react-native-gesture-handler' import { IS_IOS_26_PLUS, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { Icon, Text } from '../ui' import { AddBookSheet, type AddBookSheetRef, usePrefetchAddBookSheet } from './AddBookSheet' @@ -29,6 +30,7 @@ type Props = { export const AddBookOptionsSheet = forwardRef( ({ onAddBook }, ref) => { + const { t } = useTranslate() const sheetRef = useRef(null) const addSheetRef = useRef(null) const manualSheetRef = useRef(null) @@ -70,25 +72,25 @@ export const AddBookOptionsSheet = forwardRef( onDidPresent={prefetchAddBookSheet} > - Add a book + {t('bookClub.addBook')} handleOptionPress('search')} /> handleOptionPress('manual')} /> handleOptionPress('suggestions')} /> @@ -116,7 +118,7 @@ type OptionRowProps = { function OptionRow({ label, description, icon, onPress }: OptionRowProps) { return ( - + {optionIcons[icon]} diff --git a/apps/expo/components/bookClub/AddBookSheet.tsx b/apps/expo/components/bookClub/AddBookSheet.tsx index 4393eb8de..0023de1fc 100644 --- a/apps/expo/components/bookClub/AddBookSheet.tsx +++ b/apps/expo/components/bookClub/AddBookSheet.tsx @@ -6,6 +6,7 @@ import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState import { useSafeAreaInsets } from 'react-native-safe-area-context' import { IS_IOS_26_PLUS, ON_END_REACHED_THRESHOLD, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useActiveServer } from '../activeServer' import BookListItem from '../book/BookListItem' @@ -66,6 +67,7 @@ type Props = { } export const AddBookSheet = forwardRef(({ onAddBook }, ref) => { + const { t } = useTranslate() const sheetRef = useRef(null) const previewSheetRef = useRef(null) @@ -171,20 +173,26 @@ export const AddBookSheet = forwardRef(({ onAddBook }, r contentInsetAdjustmentBehavior="automatic" ListHeaderComponentStyle={{ paddingBottom: 16 }} ListHeaderComponent={ - + } ListEmptyComponent={ {isFiltered && ( )} } diff --git a/apps/expo/components/bookClub/CurrentBookCard.tsx b/apps/expo/components/bookClub/CurrentBookCard.tsx index 16ef4ef02..7bc4dcda7 100644 --- a/apps/expo/components/bookClub/CurrentBookCard.tsx +++ b/apps/expo/components/bookClub/CurrentBookCard.tsx @@ -17,6 +17,7 @@ import LinearGradient from 'react-native-linear-gradient' import { toast } from 'sonner-native' import { useColorScheme } from '~/lib/useColorScheme' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' import { ThumbnailImage } from '../image' @@ -77,6 +78,7 @@ type Props = { } export function CurrentBookCard({ data }: Props) { + const { t } = useTranslate() const { clubId, checkRole } = useBookClubContext() const queryClient = useQueryClient() @@ -143,8 +145,8 @@ export function CurrentBookCard({ data }: Props) { }, onError: (error) => { console.error('Failed to add book to club', error) - toast.error('Failed to add book to club', { - description: error instanceof Error ? error.message : 'An unknown error occurred', + toast.error(t('bookClub.addBookFailed'), { + description: error instanceof Error ? error.message : t('errors.unknown'), }) }, }) @@ -160,32 +162,34 @@ export function CurrentBookCard({ data }: Props) { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bookClubById', clubId] }) queryClient.invalidateQueries({ queryKey: ['bookClubContext', clubId] }) - toast.success('Book archived', { - description: 'The current book has been archived', + toast.success(t('bookClub.bookArchived'), { + description: t('bookClub.bookArchivedDescription'), }) }, onError: (error) => { console.error('Failed to archive book', error) - toast.error('Failed to archive book', { - description: error instanceof Error ? error.message : 'An unknown error occurred', + toast.error(t('bookClub.archiveBookFailed'), { + description: error instanceof Error ? error.message : t('errors.unknown'), }) }, }) const confirmArchiveBook = useCallback(() => { Alert.alert( - 'Archive book', - `Are you sure you are ready to archive ${book?.title ? `'${book?.title}'` : 'the current book'}?`, + t('bookClub.archiveBook'), + t('bookClub.archiveBookConfirmation', { + book: book?.title ? `'${book.title}'` : t('common.thisBook'), + }), [ - { text: 'Cancel', style: 'cancel' }, + { text: t('common.cancel'), style: 'cancel' }, { - text: 'Archive', + text: t('bookClub.archiveAction'), style: 'destructive', onPress: () => archiveBook({ bookClubBookId: book?.id || '' }), }, ], ) - }, [archiveBook, book]) + }, [archiveBook, book, t]) const isModerator = checkRole(BookClubMemberRole.Moderator) @@ -200,7 +204,7 @@ export function CurrentBookCard({ data }: Props) { } style={{ flexGrow: 1 }} > - + {backgroundGradient && ( )} - + {isModerator && !isEmpty && ( - + - + {EditIcon} - + {ArchiveIcon} @@ -273,15 +277,15 @@ export function CurrentBookCard({ data }: Props) { )} {isModerator && isEmpty && ( - - + + {PlusIcon} )} - - + + {isEmpty ? 'Add a book' : 'Currently reading'} diff --git a/apps/expo/components/bookClub/CurrentBookSheet.tsx b/apps/expo/components/bookClub/CurrentBookSheet.tsx index cbe4657c3..fa8926653 100644 --- a/apps/expo/components/bookClub/CurrentBookSheet.tsx +++ b/apps/expo/components/bookClub/CurrentBookSheet.tsx @@ -10,6 +10,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context' import TImage from 'react-native-turbo-image' import { IS_IOS_26_PLUS, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' import { useActiveServer } from '../activeServer' @@ -29,6 +30,7 @@ type Props = { } export const CurrentBookSheet = forwardRef(({ book }, ref) => { + const { t } = useTranslate() const sheetRef = useRef(null) useImperativeHandle(ref, () => ({ @@ -140,7 +142,7 @@ export const CurrentBookSheet = forwardRef(({ book } onPress={onGoToBook} disabled={!book.entity?.id && !book.url} > - Go to Book + {t('bookClub.goToBook')} diff --git a/apps/expo/components/bookClub/ManualBookEntrySheet.tsx b/apps/expo/components/bookClub/ManualBookEntrySheet.tsx index de69bde9b..1f45e4516 100644 --- a/apps/expo/components/bookClub/ManualBookEntrySheet.tsx +++ b/apps/expo/components/bookClub/ManualBookEntrySheet.tsx @@ -1,24 +1,28 @@ import { zodResolver } from '@hookform/resolvers/zod' import { TrueSheet } from '@lodev09/react-native-true-sheet' import { BookClubBookInput } from '@stump/graphql' -import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react' +import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react' import { Controller, useForm, useFormState } from 'react-hook-form' import { ScrollView, View } from 'react-native' import { z } from 'zod' import { IS_IOS_26_PLUS, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { SheetBackDetection } from '../SheetBackDetection' import { Input, SheetHeader } from '../ui' -const schema = z.object({ - title: z.string().min(1, { message: 'Title is required' }), - author: z.string().min(1, { message: 'Author is required' }), - url: z.string().optional(), - imageUrl: z.string().optional(), -}) +type Translate = ReturnType['t'] -type ManualBookEntrySchema = z.infer +const createSchema = (t: Translate) => + z.object({ + title: z.string().min(1, { message: t('bookClub.titleRequired') }), + author: z.string().min(1, { message: t('bookClub.authorRequired') }), + url: z.string().optional(), + imageUrl: z.string().optional(), + }) + +type ManualBookEntrySchema = z.infer> export type ManualBookEntrySheetRef = { open: () => void @@ -31,6 +35,8 @@ type Props = { export const ManualBookEntrySheet = forwardRef( ({ onAddBook }, ref) => { + const { t } = useTranslate() + const schema = useMemo(() => createSchema(t), [t]) const sheetRef = useRef(null) const colors = useColors() @@ -89,7 +95,7 @@ export const ManualBookEntrySheet = forwardRef( onDidDismiss={handleDismiss} header={ sheetRef.current?.dismiss()} onSubmit={handleSubmit(onSubmit)} /> @@ -105,8 +111,8 @@ export const ManualBookEntrySheet = forwardRef( name="title" render={({ field: { onChange, onBlur, value } }) => ( ( name="author" render={({ field: { onChange, onBlur, value } }) => ( ( name="url" render={({ field: { onChange, onBlur, value } }) => ( ( name="imageUrl" render={({ field: { onChange, onBlur, value } }) => ( {({ pressed }) => ( - + - + {backgroundGradient && ( + - - No past discussions + + {t('bookClub.noPastDiscussions')} )} diff --git a/apps/expo/components/bookClub/PreviewBookSheet.tsx b/apps/expo/components/bookClub/PreviewBookSheet.tsx index ddd1141b6..bbb5d3c25 100644 --- a/apps/expo/components/bookClub/PreviewBookSheet.tsx +++ b/apps/expo/components/bookClub/PreviewBookSheet.tsx @@ -8,6 +8,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context' import TImage from 'react-native-turbo-image' import { IS_IOS_26_PLUS, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' import { useOverviewAnimations } from '../book/overview' @@ -110,6 +111,7 @@ type BookContentProps = { } & Pick function BookContent({ book, onConfirmAddBook }: BookContentProps) { + const { t } = useTranslate() const { sdk } = useSDK() const thumbnailRatio = usePreferencesStore((store) => store.thumbnailRatio) @@ -170,7 +172,7 @@ function BookContent({ book, onConfirmAddBook }: BookContentProps) { diff --git a/apps/expo/components/bookClub/SuggestionsPickerSheet.tsx b/apps/expo/components/bookClub/SuggestionsPickerSheet.tsx index 51039b0b3..0a7e042d9 100644 --- a/apps/expo/components/bookClub/SuggestionsPickerSheet.tsx +++ b/apps/expo/components/bookClub/SuggestionsPickerSheet.tsx @@ -14,6 +14,7 @@ import { Pressable } from 'react-native-gesture-handler' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { IS_IOS_26_PLUS, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import ListEmpty from '../ListEmpty' import { SheetBackDetection } from '../SheetBackDetection' @@ -51,6 +52,7 @@ type Suggestion = SuggestionsPickerSheetQuery['bookClubSuggestions'][number] export const SuggestionsPickerSheet = forwardRef( ({ onAddBook }, ref) => { + const { t } = useTranslate() const sheetRef = useRef(null) const { clubId } = useBookClubContext() @@ -107,7 +109,10 @@ export const SuggestionsPickerSheet = forwardRef sheetRef.current?.dismiss()} /> + sheetRef.current?.dismiss()} + /> } onDidPresent={() => setIsOpen(true)} onDidDismiss={() => setIsOpen(false)} @@ -119,7 +124,7 @@ export const SuggestionsPickerSheet = forwardRef} + ListEmptyComponent={} ItemSeparatorComponent={() => } /> @@ -139,30 +144,33 @@ type SuggestionRowProps = { // TODO(book-club): MAke not ugly, just a mock at this point really function SuggestionRow({ suggestion, onSelect }: SuggestionRowProps) { - const suggestedByName = suggestion.suggestedBy?.user?.username ?? 'Unknown' + const { t } = useTranslate() + const suggestedByName = suggestion.suggestedBy?.user?.username ?? t('common.unknown') return ( - - + + - {suggestion.title || 'Untitled'} + {suggestion.title || t('common.unknownTitle')} {suggestion.author && ( {suggestion.author} )} - Suggested by {suggestedByName} + + {t('bookClub.suggestedBy', { name: suggestedByName })} + {suggestion.notes && ( “{suggestion.notes}” diff --git a/apps/expo/components/bookClub/discussion/Message.tsx b/apps/expo/components/bookClub/discussion/Message.tsx index 57ecc093c..39240cc62 100644 --- a/apps/expo/components/bookClub/discussion/Message.tsx +++ b/apps/expo/components/bookClub/discussion/Message.tsx @@ -8,6 +8,7 @@ import { Image, Pressable, View } from 'react-native' import type { EmojiSelection } from '~/components/emoji/types' import { Avatar, AvatarFallback, AvatarImage, Icon, Text } from '~/components/ui' import { useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { cn } from '~/lib/utils' import MessageReplyPreview from './MessageReplyPreview' @@ -75,10 +76,11 @@ function Message({ onThreadPress, onToggleReaction, }: MessageProps) { + const { t } = useTranslate() const { sdk } = useSDK() const isDeleted = !!message.deletedAt - const displayName = message.member?.displayName || message.member?.username || 'Unknown' + const displayName = message.member?.displayName || message.member?.username || t('common.unknown') const threadChildrenCount = message.threadChildrenCount ?? 0 const reactions = message.reactions ?? [] const showReplyPreview = !!message.replyTo && !isThreadHeader @@ -87,10 +89,10 @@ function Message({ if (isDeleted) { return ( - + - - This message was deleted + + {t('bookClub.messageDeleted')} ) @@ -106,7 +108,7 @@ function Message({ {showReplyPreview && } @@ -125,15 +127,15 @@ function Message({ - - + + {displayName} {formatTimestamp(message.timestamp)} {message.editedAt && ( - (edited) + {t('bookClub.edited')} )} {message.isPinnedMessage && } @@ -141,7 +143,7 @@ function Message({ {message.content} - + {reactions.map((reaction) => { const key = reaction.emoji ?? `custom:${reaction.customEmojiId}` @@ -166,7 +168,7 @@ function Message({ 0 && !isThreadHeader && ( onThreadPress?.(message)} > diff --git a/apps/expo/components/bookClub/discussion/MessageActionSheet.tsx b/apps/expo/components/bookClub/discussion/MessageActionSheet.tsx index bf5565276..72e65cdbe 100644 --- a/apps/expo/components/bookClub/discussion/MessageActionSheet.tsx +++ b/apps/expo/components/bookClub/discussion/MessageActionSheet.tsx @@ -7,6 +7,7 @@ import { Pressable } from 'react-native-gesture-handler' import { Divider } from '~/components/Divider' import { IS_IOS_26_PLUS, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { cn } from '~/lib/utils' import { EmojiPickerSheet, type EmojiPickerSheetRef } from '../../emoji/EmojiPickerSheet' @@ -33,6 +34,7 @@ type Props = { export const MessageActionSheet = forwardRef( ({ quickEmojis = QUICK_EMOJIS, onReply, onThreadPress, onToggleReaction, onDelete }, ref) => { + const { t } = useTranslate() const sheetRef = useRef(null) const emojiPickerRef = useRef(null) @@ -128,13 +130,18 @@ export const MessageActionSheet = forwardRef( {/* TODO: Support message editing */} - + {onReply && ( )} @@ -144,8 +151,8 @@ export const MessageActionSheet = forwardRef( // TODO: This icon was the one that made me not use native, although I'd prefer all native icons here. // I'm kinda surprised sf doens't have yarn or spool or something thread related icon={Spool} - label="Thread" - description="Start a thread" + label={t('bookClub.thread')} + description={t('bookClub.startThread')} onPress={handleThread} /> )} @@ -153,8 +160,8 @@ export const MessageActionSheet = forwardRef( {canDelete && onDelete && ( @@ -184,7 +191,7 @@ function ActionRow({ icon, label, description, onPress, disabled, destructive }: (null) + const resolvedPlaceholder = placeholder ?? t('bookClub.messagePlaceholder') const [text, setText] = useState('') const [keyboardVisible, setKeyboardVisible] = useState(false) @@ -67,12 +70,12 @@ export default function MessageComposer({ if (isLocked) { return ( - This discussion is locked. + {t('bookClub.discussionLocked')} ) @@ -81,11 +84,15 @@ export default function MessageComposer({ return ( {replyingTo && ( - + - Replying to{' '} - {replyingTo.member?.displayName || replyingTo.member?.username || 'Unknown'} + {t('bookClub.replyingTo', { + name: + replyingTo.member?.displayName || + replyingTo.member?.username || + t('common.unknown'), + })} {replyingTo.content} @@ -97,13 +104,13 @@ export default function MessageComposer({ )} + {/* 16px row pad + 16px avatar center - 2px stroke + 4px extra for spacing and line shenanigans */} - + @@ -45,14 +47,14 @@ export default function MessageReplyPreview({ replyTo }: Props) { /> )} - {getSenderInitials(replyTo.member)} + {getSenderInitials(replyTo.member)} {replyName} - + {replyTo.content} diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index 097c36d38..6c213ef7a 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -3624,6 +3624,83 @@ "readingTime": "Reading Time", "size": "Size" }, + "bookClub": { + "clubs": "Clubs", + "invites": "Invites", + "createClub": "Create Club", + "clubSettings": "Club Settings", + "archive": "Archive", + "pastDiscussions": "Past Discussions", + "thread": "Thread", + "createFailed": "Failed to create club", + "name": "Name", + "namePlaceholder": "Enter club name", + "description": "Description", + "descriptionPlaceholder": "What is this club about? (optional)", + "private": "Private", + "noClubs": "No clubs yet", + "noClubsDescription": "Join a club or create your own to get started with book clubs.", + "noInvites": "No pending invites", + "noInvitesDescription": "You don't have any pending club invitations", + "noPastBooks": "No Past Books", + "noPastBooksDescription": "Completed books will appear here", + "noDiscussionsForBook": "No discussions for this book", + "noActiveDiscussions": "No active discussions right now", + "noPastDiscussions": "No past discussions", + "addBook": "Add a book", + "searchServer": "Search server", + "searchServerDescription": "Find a book from your library", + "enterManually": "Enter manually", + "enterManuallyDescription": "Add a book not on your server", + "fromSuggestions": "From suggestions", + "fromSuggestionsDescription": "Pick from member suggestions", + "searchBooksPlaceholder": "Search books...", + "noBooksMatchingFilters": "No books found matching your filters", + "noBooksReturned": "No books returned", + "goToBook": "Go to Book", + "addToClub": "Add to Club", + "enterBookDetails": "Enter book details", + "title": "Title", + "bookTitlePlaceholder": "Book title", + "titleRequired": "Title is required", + "author": "Author", + "authorPlaceholder": "Author name", + "authorRequired": "Author is required", + "urlOptional": "URL (optional)", + "coverImageUrlOptional": "Cover image URL (optional)", + "addBookFailed": "Failed to add book to club", + "bookArchived": "Book archived", + "bookArchivedDescription": "The current book has been archived", + "archiveBookFailed": "Failed to archive book", + "archiveBook": "Archive book", + "archiveBookConfirmation": "Are you sure you are ready to archive {{book}}?", + "archiveAction": "Archive", + "completedAt": "Completed {{date}}", + "progressThroughBook": "You are {{percentage}}% through the book!", + "startedAt": "Started {{date}}", + "notStarted": "You have not started this book yet", + "moderatedBy": "Moderated by", + "pinnedRooms": "Pinned Rooms", + "activeDiscussions": "Active Discussions", + "seeBook": "See book", + "start": "Start", + "discussions": "Discussions", + "archiveTitle": "{{book}} - Archive", + "memberSuggestions": "Member suggestions", + "noMemberSuggestions": "No pending suggestions from members yet", + "suggestedBy": "Suggested by {{name}}", + "messageDeleted": "This message was deleted", + "edited": "(edited)", + "editMessage": "Edit this message", + "reply": "Reply", + "replyInline": "Reply inline", + "startThread": "Start a thread", + "deleteMessage": "Delete this message", + "discussionLocked": "This discussion is locked.", + "replyingTo": "Replying to {{name}}", + "messagePlaceholder": "Type a message...", + "settingsNotImplemented": "Book club settings are not implemented yet" + }, "auth": { "login": "Login", "loginWithOidc": "Login with OIDC", diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 1b2790e46..790cf9b9b 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -3624,6 +3624,83 @@ "readingTime": "읽은 시간", "size": "크기" }, + "bookClub": { + "clubs": "북클럽", + "invites": "초대", + "createClub": "북클럽 만들기", + "clubSettings": "북클럽 설정", + "archive": "보관함", + "pastDiscussions": "지난 토론", + "thread": "스레드", + "createFailed": "북클럽을 만들지 못했습니다", + "name": "이름", + "namePlaceholder": "북클럽 이름을 입력하세요", + "description": "설명", + "descriptionPlaceholder": "어떤 북클럽인지 소개해 주세요(선택)", + "private": "비공개", + "noClubs": "북클럽이 없습니다", + "noClubsDescription": "북클럽에 참여하거나 직접 만들어 시작해 보세요.", + "noInvites": "대기 중인 초대가 없습니다", + "noInvitesDescription": "받은 북클럽 초대가 없습니다", + "noPastBooks": "지난 책이 없습니다", + "noPastBooksDescription": "완독한 책이 여기에 표시됩니다", + "noDiscussionsForBook": "이 책에 대한 토론이 없습니다", + "noActiveDiscussions": "현재 진행 중인 토론이 없습니다", + "noPastDiscussions": "지난 토론이 없습니다", + "addBook": "책 추가", + "searchServer": "서버에서 검색", + "searchServerDescription": "라이브러리에서 책을 찾습니다", + "enterManually": "직접 입력", + "enterManuallyDescription": "서버에 없는 책을 추가합니다", + "fromSuggestions": "추천에서 선택", + "fromSuggestionsDescription": "회원이 추천한 책에서 선택합니다", + "searchBooksPlaceholder": "책 검색...", + "noBooksMatchingFilters": "필터와 일치하는 책이 없습니다", + "noBooksReturned": "표시할 책이 없습니다", + "goToBook": "책으로 이동", + "addToClub": "북클럽에 추가", + "enterBookDetails": "책 정보 입력", + "title": "제목", + "bookTitlePlaceholder": "책 제목", + "titleRequired": "제목을 입력하세요", + "author": "저자", + "authorPlaceholder": "저자 이름", + "authorRequired": "저자를 입력하세요", + "urlOptional": "URL(선택)", + "coverImageUrlOptional": "표지 이미지 URL(선택)", + "addBookFailed": "북클럽에 책을 추가하지 못했습니다", + "bookArchived": "책을 보관했습니다", + "bookArchivedDescription": "현재 책을 보관함으로 옮겼습니다", + "archiveBookFailed": "책을 보관하지 못했습니다", + "archiveBook": "책 보관", + "archiveBookConfirmation": "{{book}} 항목을 보관하시겠습니까?", + "archiveAction": "보관", + "completedAt": "{{date}} 완독", + "progressThroughBook": "현재 책의 {{percentage}}%를 읽었습니다!", + "startedAt": "{{date}} 시작", + "notStarted": "아직 이 책을 읽기 시작하지 않았습니다", + "moderatedBy": "운영자", + "pinnedRooms": "고정된 토론방", + "activeDiscussions": "진행 중인 토론", + "seeBook": "책 보기", + "start": "시작", + "discussions": "토론", + "archiveTitle": "{{book}} - 보관함", + "memberSuggestions": "회원 추천", + "noMemberSuggestions": "대기 중인 회원 추천이 없습니다", + "suggestedBy": "{{name}} 님의 추천", + "messageDeleted": "삭제된 메시지입니다", + "edited": "(편집됨)", + "editMessage": "이 메시지 편집", + "reply": "답장", + "replyInline": "바로 답장", + "startThread": "스레드 시작", + "deleteMessage": "이 메시지 삭제", + "discussionLocked": "이 토론은 잠겨 있습니다.", + "replyingTo": "{{name}} 님에게 답장", + "messagePlaceholder": "메시지 입력...", + "settingsNotImplemented": "북클럽 설정은 아직 구현되지 않았습니다" + }, "auth": { "login": "로그인", "loginWithOidc": "OIDC로 로그인", From 201b46c3081c5f5dcf9f72b48fdc84a0a8222cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 19:14:11 +0900 Subject: [PATCH 39/54] =?UTF-8?q?feat(i18n):=20=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20=EC=9E=94=EC=97=AC=20=ED=99=94=EB=A9=B4=20=EB=AC=B8=EA=B5=AC?= =?UTF-8?q?=20=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/expo/app/(tabs)/settings/usage/index.tsx | 2 +- apps/expo/app/offline/[fileId]/read.tsx | 12 +- apps/expo/app/opds-legacy/[id]/_layout.tsx | 5 +- apps/expo/app/opds-legacy/[id]/read.tsx | 6 +- apps/expo/app/opds/[id]/publication/index.tsx | 12 +- apps/expo/app/server/[id]/(tabs)/_layout.tsx | 6 +- .../app/server/[id]/(tabs)/browse/_layout.tsx | 4 +- .../app/server/[id]/(tabs)/search/[query].tsx | 22 ++-- .../app/server/[id]/(tabs)/search/_layout.tsx | 4 +- apps/expo/app/server/[id]/_layout.tsx | 6 +- apps/expo/app/server/[id]/books/[id]/read.tsx | 4 +- apps/expo/app/server/[id]/books/index.tsx | 10 +- apps/expo/app/server/[id]/books/search[q].tsx | 6 +- apps/expo/app/server/[id]/files/[path].tsx | 8 +- apps/expo/app/server/[id]/libraries/[id].tsx | 12 +- apps/expo/app/server/[id]/libraries/index.tsx | 8 +- apps/expo/app/server/[id]/series/[id].tsx | 10 +- apps/expo/app/server/[id]/series/index.tsx | 10 +- apps/expo/components/ServerAuthDialog.tsx | 2 +- .../activeServer/home/ReadingNow.tsx | 2 +- .../preferences/AllowDownscaling.tsx | 6 +- .../book/listHeader/SeriesBooksListHeader.tsx | 18 +-- .../book/overview/AndroidBookMenu.tsx | 4 +- .../book/reader/epub/ReadiumHeader.tsx | 8 +- .../book/reader/epub/controls/FontConfig.tsx | 2 +- .../controls/customTheme/CustomizeTheme.tsx | 7 +- .../components/bookClub/CurrentBookCard.tsx | 2 +- .../downloadQueue/FailedDownloadItem.tsx | 13 +- .../components/emoji/EmojiPickerSheet.tsx | 36 +++--- .../library/LibraryOverviewSheet.tsx | 16 +-- .../listHeader/LibrarySeriesListHeader.tsx | 8 +- .../components/localLibrary/ReadingNow.tsx | 5 +- apps/expo/components/opds/MaybeErrorFeed.tsx | 4 +- apps/expo/components/opds/OPDSAuthDialog.tsx | 20 ++-- .../opdsLegacy/MaybeErrorLegacyFeed.tsx | 4 +- .../savedServer/AddOrEditServerForm.tsx | 14 +-- .../search/SearchHistoryAndFavorites.tsx | 8 +- .../selection/SelectionRightScreenHeader.tsx | 26 ++-- .../smartList/SmartListActionMenu.tsx | 30 +++-- apps/expo/components/ui/card.tsx | 37 +++--- apps/expo/components/ui/stepper.tsx | 8 +- packages/i18n/src/locales/en-US.json | 112 ++++++++++++++++-- packages/i18n/src/locales/ko-KR.json | 112 ++++++++++++++++-- 43 files changed, 458 insertions(+), 193 deletions(-) diff --git a/apps/expo/app/(tabs)/settings/usage/index.tsx b/apps/expo/app/(tabs)/settings/usage/index.tsx index 266f92b49..99e887ca6 100644 --- a/apps/expo/app/(tabs)/settings/usage/index.tsx +++ b/apps/expo/app/(tabs)/settings/usage/index.tsx @@ -67,7 +67,7 @@ export default function Screen() { {savedServers.length > 0 && ( {savedServers.map((server) => ( () @@ -76,7 +77,7 @@ export default function Screen() { ) if (!record && !!updatedAt) { - throw new Error('Downloaded file not found') + throw new Error(t('errors.downloadedFileNotFound')) } if (!record) { @@ -102,6 +103,7 @@ type ReaderProps = { } function Reader({ record, bookmarks, annotations }: ReaderProps) { + const { t } = useTranslate() const downloadedFile = useMemo(() => record.downloaded_files, [record]) const unsyncedProgress = useMemo(() => record.read_progress, [record]) @@ -144,12 +146,10 @@ function Reader({ record, bookmarks, annotations }: ReaderProps) { }) console.error('Failed to initialize streamer:', error) setStreamerError( - error instanceof Error - ? error - : new Error('Failed to initialize streamer. Please reach out for support'), + error instanceof Error ? error : new Error(t('errors.streamerInitializationFailed')), ) } - }, [book.id, downloadedFile.serverId, downloadedFile.uri]) + }, [book.id, downloadedFile.serverId, downloadedFile.uri, t]) useEffect( () => { diff --git a/apps/expo/app/opds-legacy/[id]/_layout.tsx b/apps/expo/app/opds-legacy/[id]/_layout.tsx index 1775006ba..cd4bfa6c4 100644 --- a/apps/expo/app/opds-legacy/[id]/_layout.tsx +++ b/apps/expo/app/opds-legacy/[id]/_layout.tsx @@ -94,6 +94,7 @@ function OPDSFeedProvider({ children }: OPDSFeedProviderProps) { // minimal smart phones where theres just a few buttons for the essentials etc. Definitely something to revisit // for the v2 flow, as I think that can be much prettier than it currently is export default function Screen() { + const { t } = useTranslate() const animationEnabled = usePreferencesStore((state) => !state.reduceAnimations) const { savedServers, getServerConfig } = useSavedServers() @@ -134,8 +135,8 @@ export default function Screen() { const onAuthError = useCallback(() => { removeInstanceFromCache(`${serverID}-opds`) - throw new Error('This OPDS server requires authentication') - }, [serverID, removeInstanceFromCache]) + throw new Error(t('opds.authenticationRequired')) + }, [serverID, removeInstanceFromCache, t]) if (!activeServer) { // @ts-expect-error: It's fine diff --git a/apps/expo/app/opds-legacy/[id]/read.tsx b/apps/expo/app/opds-legacy/[id]/read.tsx index 756962f81..3e76f7d50 100644 --- a/apps/expo/app/opds-legacy/[id]/read.tsx +++ b/apps/expo/app/opds-legacy/[id]/read.tsx @@ -13,6 +13,7 @@ import { useResolveURL } from '~/components/opds/utils' import { OPDSLegacyStreamingContextValue } from '~/context/opdsLegacy' import { db, downloadedFiles, readProgress, syncStatus } from '~/db' import { useReaderStore } from '~/stores' +import { useTranslate } from '~/lib/hooks' import { useBookPreferences, useBookTimer } from '~/stores/reader' type Params = Omit & { @@ -21,6 +22,7 @@ type Params = Omit() @@ -69,10 +71,10 @@ export default function Screen() { if (streamingURL.includes('{pageNumber}')) { return streamingURL.replace('{pageNumber}', actualPageNumber.toString()) } else { - throw new Error('Unsupported streaming URL format') + throw new Error(t('opds.unsupportedStreamingUrl')) } }, - [resolveUrl, contextValue.streamingURL], + [resolveUrl, contextValue.streamingURL, t], ) const book = useMemo( diff --git a/apps/expo/app/opds/[id]/publication/index.tsx b/apps/expo/app/opds/[id]/publication/index.tsx index 690a96a7d..04e8934d8 100644 --- a/apps/expo/app/opds/[id]/publication/index.tsx +++ b/apps/expo/app/opds/[id]/publication/index.tsx @@ -315,7 +315,7 @@ export default function Screen() { {progression.locator.locations?.position && ( 0 @@ -341,8 +341,10 @@ export default function Screen() { {!downloadURL - ? 'No download link available for this publication' - : `Unsupported file format: ${acquisitionLink?.type || 'unknown'}`} + ? t('opds.noDownloadLink') + : t('opds.unsupportedFileFormat', { + format: acquisitionLink?.type || t('common.unknown'), + })} )} @@ -391,7 +393,7 @@ export default function Screen() { - {belongsToSeries?.name || 'Series Books'} + {belongsToSeries?.name || t('opds.seriesBooks')} {seriesUrl && } @@ -412,7 +414,7 @@ export default function Screen() { - {belongsToCollection?.name || 'Collection Books'} + {belongsToCollection?.name || t('opds.collectionBooks')} {collectionUrl && } diff --git a/apps/expo/app/server/[id]/(tabs)/_layout.tsx b/apps/expo/app/server/[id]/(tabs)/_layout.tsx index 71265f5b6..b49a09ed8 100644 --- a/apps/expo/app/server/[id]/(tabs)/_layout.tsx +++ b/apps/expo/app/server/[id]/(tabs)/_layout.tsx @@ -42,12 +42,12 @@ export default function TabLayout() { // This can happen if the client is "newer" than the server and is trying to use an endpoint that doesn't exist. // We should probably inform the user that they need to update their server. // For now, throw to trigger the error boundary - throw new Error('Incompatible server', { cause: error }) + throw new Error(t('errors.incompatibleServer'), { cause: error }) } } else if (error?.message === 'Malformed response received from server') { - throw new Error('Incompatible server', { cause: error }) + throw new Error(t('errors.incompatibleServer'), { cause: error }) } - }, [error, onUnauthenticatedResponse]) + }, [error, onUnauthenticatedResponse, t]) const showClubs = bookClubsEnabled && checkPermission(UserPermission.AccessBookClub) diff --git a/apps/expo/app/server/[id]/(tabs)/browse/_layout.tsx b/apps/expo/app/server/[id]/(tabs)/browse/_layout.tsx index d9285a215..4ab0bc21d 100644 --- a/apps/expo/app/server/[id]/(tabs)/browse/_layout.tsx +++ b/apps/expo/app/server/[id]/(tabs)/browse/_layout.tsx @@ -2,15 +2,17 @@ import { Stack } from 'expo-router' import { Platform } from 'react-native' import { IS_IOS_26_PLUS } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' export default function Layout() { + const { t } = useTranslate() return ( () const { activeServer: { id: serverID }, @@ -142,8 +144,8 @@ export default function Screen() { if (noResults) { return ( ) } @@ -151,17 +153,17 @@ export default function Screen() { return ( {!!bookResults?.media.nodes.length && ( - - Books + + {t('search.results.books')} {getHasMore(bookResults?.media.pageInfo) && ( - See More + {t('search.results.seeMore')} )} @@ -190,8 +192,8 @@ export default function Screen() { {!!seriesResults?.series.nodes.length && ( - - Series + + {t('search.results.series')} {/* {getHasMore(seriesResults?.series.pageInfo) && ( See More @@ -223,8 +225,8 @@ export default function Screen() { {!!librariesResults?.libraries.nodes.length && ( - - Libraries + + {t('search.results.libraries')} {/* {getHasMore(seriesResults?.series.pageInfo) && ( See More diff --git a/apps/expo/app/server/[id]/(tabs)/search/_layout.tsx b/apps/expo/app/server/[id]/(tabs)/search/_layout.tsx index 06b208661..802d3ad6a 100644 --- a/apps/expo/app/server/[id]/(tabs)/search/_layout.tsx +++ b/apps/expo/app/server/[id]/(tabs)/search/_layout.tsx @@ -2,8 +2,10 @@ import { Stack } from 'expo-router' import { Platform } from 'react-native' import { IS_IOS_26_PLUS } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' export default function Layout() { + const { t } = useTranslate() return ( !state.reduceAnimations) @@ -259,11 +261,11 @@ export default function Screen() { } if (isAutoAuthenticating) { - return + return } if (!sdk) { - return + return } return ( diff --git a/apps/expo/app/server/[id]/books/[id]/read.tsx b/apps/expo/app/server/[id]/books/[id]/read.tsx index c11f7da19..b4a0bc046 100644 --- a/apps/expo/app/server/[id]/books/[id]/read.tsx +++ b/apps/expo/app/server/[id]/books/[id]/read.tsx @@ -29,6 +29,7 @@ import { useSyncOnlineToOfflineAnnotations, useSyncOnlineToOfflineBookmarks, useSyncOnlineToOfflineProgress, + useTranslate, } from '~/lib/hooks' import { intoReadiumLocator, ReadiumLocator } from '~/modules/readium' import { usePreferencesStore, useReaderStore } from '~/stores' @@ -262,6 +263,7 @@ type Params = { // TODO(reading): support incognito, not using it here lol export default function Screen() { + const { t } = useTranslate() useKeepAwake() const { id: bookID } = useLocalSearchParams() @@ -279,7 +281,7 @@ export default function Screen() { const preferNativePdfReader = usePreferencesStore((store) => Boolean(store.preferNativePdf)) if (!book) { - throw new Error('Book not found') + throw new Error(t('errors.bookNotFound')) } // TODO: Swap to suspense when available diff --git a/apps/expo/app/server/[id]/books/index.tsx b/apps/expo/app/server/[id]/books/index.tsx index 76aa15724..9287d4d54 100644 --- a/apps/expo/app/server/[id]/books/index.tsx +++ b/apps/expo/app/server/[id]/books/index.tsx @@ -16,6 +16,7 @@ import { useListSizing } from '~/components/listLayout' import RefreshControl from '~/components/RefreshControl' import { Button, Text } from '~/components/ui' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { BookFilterContext, createBookFilterStore, useInitialBookFilters } from '~/stores/filters' import { useBooksLayout } from '~/stores/layout' @@ -64,6 +65,7 @@ const statsQuery = graphql(` `) export default function Screen() { + const { t } = useTranslate() const { activeServer: { id: serverID }, } = useActiveServer() @@ -154,16 +156,18 @@ export default function Screen() { refreshControl={} ListEmptyComponent={ {isFiltered && ( )} } diff --git a/apps/expo/app/server/[id]/books/search[q].tsx b/apps/expo/app/server/[id]/books/search[q].tsx index 1bf80cd7a..51b2f3663 100644 --- a/apps/expo/app/server/[id]/books/search[q].tsx +++ b/apps/expo/app/server/[id]/books/search[q].tsx @@ -10,6 +10,7 @@ import BackLink from '~/components/BackLink' import { BookListItem } from '~/components/book' import { useGridItemSize } from '~/components/listLayout/grid/useGridItemSize' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' const query = graphql(` query BookSearchScreen($filter: MediaFilterInput!, $pagination: Pagination!) { @@ -45,6 +46,7 @@ export const prefetchBookSearch = (sdk: Api, client: QueryClient, search: string } export default function Screen() { + const { t } = useTranslate() const { q: searchQuery } = useLocalSearchParams<{ q: string }>() const navigation = useNavigation() @@ -53,11 +55,11 @@ export default function Screen() { useLayoutEffect(() => { navigation.setOptions({ headerShown: true, - headerTitle: 'Search Results', + headerTitle: t('search.results.title'), headerBackButtonMenuEnabled: true, headerLeft: () => , }) - }, [navigation]) + }, [navigation, t]) const filter = useMemo( () => ({ diff --git a/apps/expo/app/server/[id]/files/[path].tsx b/apps/expo/app/server/[id]/files/[path].tsx index 050a2673d..f281ae2d5 100644 --- a/apps/expo/app/server/[id]/files/[path].tsx +++ b/apps/expo/app/server/[id]/files/[path].tsx @@ -8,8 +8,10 @@ import { SafeAreaView } from 'react-native-safe-area-context' import { FileExplorerGridItem } from '~/components/fileExplorer' import { Heading, Text } from '~/components/ui' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' export default function Screen() { + const { t } = useTranslate() const params = useLocalSearchParams<{ path: string friendlyName?: string @@ -49,11 +51,11 @@ export default function Screen() { const render = () => { if (errorMessage) { return ( - + - Something went wrong + {t('errors.somethingWentWrong')} - {errorMessage} + {errorMessage} ) } else { diff --git a/apps/expo/app/server/[id]/libraries/[id].tsx b/apps/expo/app/server/[id]/libraries/[id].tsx index ab3660052..4944c8e64 100644 --- a/apps/expo/app/server/[id]/libraries/[id].tsx +++ b/apps/expo/app/server/[id]/libraries/[id].tsx @@ -18,6 +18,7 @@ import RefreshControl from '~/components/RefreshControl' import SeriesListItem from '~/components/series/SeriesListItem' import { Button, FullScreenLoader, RefreshButton, Text } from '~/components/ui' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useDynamicHeader } from '~/lib/hooks/useDynamicHeader' import { createSeriesFilterStore, SeriesFilterContext } from '~/stores/filters' import { useSeriesLayout } from '~/stores/layout' @@ -67,6 +68,7 @@ const seriesQuery = graphql(` type Node = LibrarySeriesScreenQuery['series']['nodes'][number] export default function Screen() { + const { t } = useTranslate() const { id } = useLocalSearchParams<{ id: string }>() const { data: { libraryById: library }, @@ -189,11 +191,9 @@ export default function Screen() { ) : ( @@ -204,7 +204,7 @@ export default function Screen() { size="lg" onPress={() => resetFilters()} > - Clear Filters + {t('common.clearFilters')} )} @@ -215,7 +215,7 @@ export default function Screen() { onPress={() => handleRefetch()} isRefreshing={isRefetching} > - Refresh + {t('common.refresh')} } diff --git a/apps/expo/app/server/[id]/libraries/index.tsx b/apps/expo/app/server/[id]/libraries/index.tsx index d76a9bad9..ba35b8ff7 100644 --- a/apps/expo/app/server/[id]/libraries/index.tsx +++ b/apps/expo/app/server/[id]/libraries/index.tsx @@ -12,6 +12,7 @@ import ListEmpty from '~/components/ListEmpty' import RefreshControl from '~/components/RefreshControl' import { RefreshButton, Text } from '~/components/ui' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' const query = graphql(` query LibrariesScreen($pagination: Pagination) { @@ -33,6 +34,7 @@ const query = graphql(` `) export default function Screen() { + const { t } = useTranslate() const { activeServer: { id: serverID }, } = useActiveServer() @@ -108,8 +110,8 @@ export default function Screen() { } ListEmptyComponent={ handleRefetch()} isRefreshing={isRefetching} > - Refresh + {t('common.refresh')} } diff --git a/apps/expo/app/server/[id]/series/[id].tsx b/apps/expo/app/server/[id]/series/[id].tsx index fb9624abc..1a0a298ab 100644 --- a/apps/expo/app/server/[id]/series/[id].tsx +++ b/apps/expo/app/server/[id]/series/[id].tsx @@ -20,6 +20,7 @@ import RefreshControl from '~/components/RefreshControl' import { SeriesOverviewSheet, usePrefetchSeriesOverview } from '~/components/series' import { Button, RefreshButton, Text } from '~/components/ui' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useDownloadSeries } from '~/lib/hooks/db/downloadSeries' import { useDynamicHeader } from '~/lib/hooks/useDynamicHeader' import { BookFilterContext, createBookFilterStore } from '~/stores/filters' @@ -72,6 +73,7 @@ const booksQuery = graphql(` type Node = SeriesBooksScreenQuery['media']['nodes'][number] export default function Screen() { + const { t } = useTranslate() const navigationState = useNavigationState((state) => state.routes) const { id } = useLocalSearchParams<{ id: string }>() const { @@ -197,7 +199,9 @@ export default function Screen() { } ListEmptyComponent={ {isFiltered && ( @@ -207,7 +211,7 @@ export default function Screen() { variant="secondary" onPress={() => resetFilters()} > - Clear Filters + {t('common.clearFilters')} )} handleRefetch()} isRefreshing={isRefetching} > - Refresh + {t('common.refresh')} } diff --git a/apps/expo/app/server/[id]/series/index.tsx b/apps/expo/app/server/[id]/series/index.tsx index 907909567..84cdcffd3 100644 --- a/apps/expo/app/server/[id]/series/index.tsx +++ b/apps/expo/app/server/[id]/series/index.tsx @@ -16,6 +16,7 @@ import { SeriesListHeader } from '~/components/series/listHeader' import SeriesListItem from '~/components/series/SeriesListItem' import { Button, FullScreenLoader, RefreshButton, Text } from '~/components/ui' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useSeriesFilterStore } from '~/stores/filters' import { useSeriesLayout } from '~/stores/layout' @@ -64,6 +65,7 @@ const statsQuery = graphql(` `) export default function Screen() { + const { t } = useTranslate() const { activeServer: { id: serverID }, } = useActiveServer() @@ -160,7 +162,9 @@ export default function Screen() { ) : ( {isFiltered && ( @@ -170,7 +174,7 @@ export default function Screen() { size="lg" onPress={() => resetFilters()} > - Clear Filters + {t('common.clearFilters')} )} @@ -181,7 +185,7 @@ export default function Screen() { onPress={() => handleRefetch()} isRefreshing={isRefetching} > - Refresh + {t('common.refresh')} } diff --git a/apps/expo/components/ServerAuthDialog.tsx b/apps/expo/components/ServerAuthDialog.tsx index 6938a90ff..034d10105 100644 --- a/apps/expo/components/ServerAuthDialog.tsx +++ b/apps/expo/components/ServerAuthDialog.tsx @@ -111,7 +111,7 @@ export default function ServerAuthDialog({ isOpen, onClose }: ServerAuthDialogPr }, [activeServer.url, setUser, onClose]) if (!isClaimed && !isCheckingClaimed) { - throw new Error('Not supported yet') + throw new Error(t('errors.notSupported')) } return ( diff --git a/apps/expo/components/activeServer/home/ReadingNow.tsx b/apps/expo/components/activeServer/home/ReadingNow.tsx index fd360498f..16b00d0c7 100644 --- a/apps/expo/components/activeServer/home/ReadingNow.tsx +++ b/apps/expo/components/activeServer/home/ReadingNow.tsx @@ -396,7 +396,7 @@ function ReadingNowItem({ book }: ReadingNowItemProps) { > {data.readProgress?.updatedAt ? formatDistanceToNow(new Date(data.readProgress?.updatedAt), { addSuffix: true }) - : 'unknown time ago'} + : t('common.unknownTimeAgo')} diff --git a/apps/expo/components/appSettings/preferences/AllowDownscaling.tsx b/apps/expo/components/appSettings/preferences/AllowDownscaling.tsx index 35f6b754d..84d74a21d 100644 --- a/apps/expo/components/appSettings/preferences/AllowDownscaling.tsx +++ b/apps/expo/components/appSettings/preferences/AllowDownscaling.tsx @@ -3,11 +3,13 @@ import { View } from 'react-native' import { useShallow } from 'zustand/react/shallow' import { Switch } from '~/components/ui' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' import AppSettingsRow from '../AppSettingsRow' export default function AllowDownscaling() { + const { t } = useTranslate() const { allowDownscaling, patch } = usePreferencesStore( useShallow((state) => ({ allowDownscaling: state.allowDownscaling, @@ -18,10 +20,10 @@ export default function AllowDownscaling() { return ( patch({ allowDownscaling: !allowDownscaling })} > - + patch({ allowDownscaling: checked })} diff --git a/apps/expo/components/book/listHeader/SeriesBooksListHeader.tsx b/apps/expo/components/book/listHeader/SeriesBooksListHeader.tsx index ae2e0ca36..d43c98489 100644 --- a/apps/expo/components/book/listHeader/SeriesBooksListHeader.tsx +++ b/apps/expo/components/book/listHeader/SeriesBooksListHeader.tsx @@ -9,6 +9,7 @@ import { useStumpServer } from '~/components/activeServer' import { useEntityListHeader } from '~/components/filter/EntityListHeader' import { ActionDef } from '~/components/filter/types' import { MiniEntityStatCards } from '~/components/stats' +import { useTranslate } from '~/lib/hooks' import { useBooksFilterMenu } from './BooksFilterMenu' import { useSeriesBooksSortAndDisplayMenu } from './SeriesBooksSortAndDisplayMenu' @@ -32,6 +33,7 @@ type Props = { } export function SeriesBooksListHeader({ seriesId, layoutKey, stats, additionalActions }: Props) { + const { t } = useTranslate() const client = useQueryClient() const { mutate: scanSeries } = useGraphQLMutation(scanMutation, { onSuccess: () => { @@ -52,7 +54,7 @@ export function SeriesBooksListHeader({ seriesId, layoutKey, stats, additionalAc const result: ActionDef[] = [ { key: 'overview', - label: 'Overview', + label: t('common.overview'), icon: { ios: 'info.circle', android: Info }, onPress: additionalActions.onShowOverview, }, @@ -61,7 +63,7 @@ export function SeriesBooksListHeader({ seriesId, layoutKey, stats, additionalAc if (checkPermission(UserPermission.ScanLibrary)) { result.push({ key: 'scan', - label: 'Scan Series', + label: t('entityActions.scanSeries'), icon: { ios: 'document.viewfinder', android: ScanLine }, onPress: () => scanSeries({ id: seriesId }), }) @@ -70,15 +72,15 @@ export function SeriesBooksListHeader({ seriesId, layoutKey, stats, additionalAc if (checkPermission(UserPermission.DownloadFile)) { result.push({ key: 'download', - label: 'Download Series', + label: t('entityActions.downloadSeries'), icon: { ios: 'arrow.down.circle', android: DownloadCloud }, onPress: () => { Alert.alert( - 'Download Series', - 'Are you sure you want to enqueue the download for this entire series?', + t('entityActions.downloadSeries'), + t('entityActions.downloadSeriesConfirmation'), [ - { text: 'Cancel', style: 'cancel' }, - { text: 'Download', onPress: additionalActions.onDownloadSeries }, + { text: t('common.cancel'), style: 'cancel' }, + { text: t('common.download'), onPress: additionalActions.onDownloadSeries }, ], ) }, @@ -86,7 +88,7 @@ export function SeriesBooksListHeader({ seriesId, layoutKey, stats, additionalAc } return result - }, [additionalActions, checkPermission, scanSeries, seriesId]) + }, [additionalActions, checkPermission, scanSeries, seriesId, t]) const sortMenu = useSeriesBooksSortAndDisplayMenu({ layoutKey, diff --git a/apps/expo/components/book/overview/AndroidBookMenu.tsx b/apps/expo/components/book/overview/AndroidBookMenu.tsx index d1d0b49d1..58c5b6306 100644 --- a/apps/expo/components/book/overview/AndroidBookMenu.tsx +++ b/apps/expo/components/book/overview/AndroidBookMenu.tsx @@ -79,7 +79,9 @@ export default function AndroidBookMenu({ align="end" > - {isFavorite ? 'Unfavorite' : 'Favorite'} + + {isFavorite ? t('common.unfavorite') : t('common.favorite')} + state.currentChapter || state.book?.name || state.embeddedMetadata?.title, ) @@ -177,9 +179,9 @@ function useChapterProgress() { const progressText = useMemo(() => { if (pagesLeftInChapter == null) return null - if (pagesLeftInChapter === 0) return 'Final page in chapter' - else return `${pagesLeftInChapter} pages left in chapter` - }, [pagesLeftInChapter]) + if (pagesLeftInChapter === 0) return t('reader.finalPageInChapter') + else return t('reader.pagesLeftInChapter', { count: pagesLeftInChapter }) + }, [pagesLeftInChapter, t]) return { chapterTitle, progressText } } diff --git a/apps/expo/components/book/reader/epub/controls/FontConfig.tsx b/apps/expo/components/book/reader/epub/controls/FontConfig.tsx index 2824bc5e2..897f51c26 100644 --- a/apps/expo/components/book/reader/epub/controls/FontConfig.tsx +++ b/apps/expo/components/book/reader/epub/controls/FontConfig.tsx @@ -86,7 +86,7 @@ export default function FontConfig() { store.setSettings({ verticalText: checked })} - accessibilityLabel="Toggle Vertical Text" + accessibilityLabel={t('reader.toggleVerticalText')} /> diff --git a/apps/expo/components/book/reader/epub/controls/customTheme/CustomizeTheme.tsx b/apps/expo/components/book/reader/epub/controls/customTheme/CustomizeTheme.tsx index 23a55f39e..384271cec 100644 --- a/apps/expo/components/book/reader/epub/controls/customTheme/CustomizeTheme.tsx +++ b/apps/expo/components/book/reader/epub/controls/customTheme/CustomizeTheme.tsx @@ -117,7 +117,7 @@ export default function CustomizeTheme({ onCancel, mode = 'edit', theme: namedTh const trimmedName = name.trim() if (!trimmedName) { - Alert.alert('Error', 'Please enter a theme name') + Alert.alert(t('common.error'), t('reader.themeNameRequired')) return } @@ -126,7 +126,7 @@ export default function CustomizeTheme({ onCancel, mode = 'edit', theme: namedTh !customTheme.colors?.foreground || !customTheme.colors?.highlight ) { - Alert.alert('Error', 'Theme colors are required') + Alert.alert(t('common.error'), t('reader.themeColorsRequired')) return } @@ -134,7 +134,7 @@ export default function CustomizeTheme({ onCancel, mode = 'edit', theme: namedTh if (isCreateMode) { if (themes[trimmedName]) { - Alert.alert('Error', 'A theme with this name already exists') + Alert.alert(t('common.error'), t('reader.themeNameExists')) return } addTheme(trimmedName, customTheme) @@ -153,6 +153,7 @@ export default function CustomizeTheme({ onCancel, mode = 'edit', theme: namedTh isCreateMode, addTheme, selectTheme, + t, onCancel, ]) diff --git a/apps/expo/components/bookClub/CurrentBookCard.tsx b/apps/expo/components/bookClub/CurrentBookCard.tsx index 7bc4dcda7..b4decfb9e 100644 --- a/apps/expo/components/bookClub/CurrentBookCard.tsx +++ b/apps/expo/components/bookClub/CurrentBookCard.tsx @@ -286,7 +286,7 @@ export function CurrentBookCard({ data }: Props) { - {isEmpty ? 'Add a book' : 'Currently reading'} + {isEmpty ? t('bookClub.addBook') : t('bookClub.currentlyReading')} diff --git a/apps/expo/components/downloadQueue/FailedDownloadItem.tsx b/apps/expo/components/downloadQueue/FailedDownloadItem.tsx index 31f43950d..80048bb2d 100644 --- a/apps/expo/components/downloadQueue/FailedDownloadItem.tsx +++ b/apps/expo/components/downloadQueue/FailedDownloadItem.tsx @@ -2,7 +2,7 @@ import { AlertCircle, RefreshCw, X } from 'lucide-react-native' import { Pressable, View } from 'react-native' import { downloadQueueMetadata } from '~/db' -import { useDownloadQueue } from '~/lib/hooks' +import { useDownloadQueue, useTranslate } from '~/lib/hooks' import { Card, Icon, Text } from '../ui' @@ -13,28 +13,29 @@ type Props = { } export default function FailedDownloadItem({ item, onRetry, onDismiss }: Props) { + const { t } = useTranslate() return ( - + {downloadQueueMetadata.safeParse(item.metadata).data?.bookName || item.filename} - {item.failureReason || 'Unknown error'} + {item.failureReason || t('errors.unknown')} - + onRetry(item.id)} - className="rounded-full bg-white/75 p-2 active:opacity-70 dark:bg-black/40" + className="bg-white/75 p-2 dark:bg-black/40 rounded-full active:opacity-70" > onDismiss(item.id)} - className="rounded-full bg-white/75 p-2 active:opacity-70 dark:bg-black/40" + className="bg-white/75 p-2 dark:bg-black/40 rounded-full active:opacity-70" > diff --git a/apps/expo/components/emoji/EmojiPickerSheet.tsx b/apps/expo/components/emoji/EmojiPickerSheet.tsx index 4058511b5..65c16cecf 100644 --- a/apps/expo/components/emoji/EmojiPickerSheet.tsx +++ b/apps/expo/components/emoji/EmojiPickerSheet.tsx @@ -35,17 +35,16 @@ const CATEGORY_ORDER = [ 'flags', ] as const -// TODO(localization): Add translation strings instead -const LABELS: Record<(typeof CATEGORY_ORDER)[number], string> = { - 'smileys & emotion': 'Smileys & Emotion', - 'people & body': 'People & Body', - 'animals & nature': 'Animals & Nature', - 'food & drink': 'Food & Drink', - 'travel & places': 'Travel & Places', - activities: 'Activities', - objects: 'Objects', - symbols: 'Symbols', - flags: 'Flags', +const CATEGORY_LOCALE_KEYS: Record<(typeof CATEGORY_ORDER)[number], string> = { + 'smileys & emotion': 'smileysAndEmotion', + 'people & body': 'peopleAndBody', + 'animals & nature': 'animalsAndNature', + 'food & drink': 'foodAndDrink', + 'travel & places': 'travelAndPlaces', + activities: 'activities', + objects: 'objects', + symbols: 'symbols', + flags: 'flags', } type EmojiSection = { @@ -135,7 +134,7 @@ export const EmojiPickerSheet = forwardRef(({ onEmoj ) if (serverEmojis.length) { nextSections.push({ - title: 'Server', + title: t('emojiPicker.server'), emojis: serverEmojis, }) } @@ -157,13 +156,18 @@ export const EmojiPickerSheet = forwardRef(({ onEmoj if (!categoryEmojis?.length) continue nextSections.push({ - title: category, + title: + category in CATEGORY_LOCALE_KEYS + ? t( + `emojiPicker.categories.${CATEGORY_LOCALE_KEYS[category as keyof typeof CATEGORY_LOCALE_KEYS]}`, + ) + : category, emojis: categoryEmojis, }) } return nextSections - }, [emojisByCategory, queryTokens]) + }, [emojisByCategory, queryTokens, t]) const listData = useMemo(() => { const items: ListItem[] = [] @@ -289,9 +293,7 @@ export const EmojiPickerSheet = forwardRef(({ onEmoj if (item.type === 'header') { return ( - - {LABELS[item.title as keyof typeof LABELS] ?? item.title} - + {item.title} ) } diff --git a/apps/expo/components/library/LibraryOverviewSheet.tsx b/apps/expo/components/library/LibraryOverviewSheet.tsx index 119dc0efd..d3cd4793f 100644 --- a/apps/expo/components/library/LibraryOverviewSheet.tsx +++ b/apps/expo/components/library/LibraryOverviewSheet.tsx @@ -11,6 +11,7 @@ import { ScrollView } from 'react-native-gesture-handler' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { IS_IOS_26_PLUS, STAT_COLORS, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useGridItemSize } from '../listLayout/grid/useGridItemSize' import { SheetBackDetection } from '../SheetBackDetection' @@ -103,6 +104,7 @@ type SheetContentProps = { // TODO: make less ugly, low key kinda ugly tbh. I think my brain wants a grid rather than flex wrap maybe // TODO: Show more stuff function SheetContent({ library }: SheetContentProps) { + const { t } = useTranslate() const { stats } = library const formattedSize = formatBytesSeparate(stats.totalBytes) @@ -116,40 +118,40 @@ function SheetContent({ library }: SheetContentProps) { const libraryStats = [ { - label: 'In Progress', + label: t('libraryOverview.inProgress'), value: stats.inProgressBooks, icon: BookOpen, colors: STAT_COLORS.inProgress, }, { - label: 'Completed', + label: t('libraryOverview.completed'), value: stats.completedBooks, suffix: `/ ${stats.bookCount}`, icon: BookCheck, colors: STAT_COLORS.completed, }, { - label: 'Books', + label: t('libraryOverview.books'), value: stats.bookCount, icon: Library, colors: STAT_COLORS.books, }, { - label: 'Series', + label: t('libraryOverview.series'), value: stats.seriesCount, icon: Layers, colors: STAT_COLORS.series, }, { - label: 'Reading Time', + label: t('libraryOverview.readingTime'), value: formattedTime ? formattedTime.value : '??', suffix: formattedTime ? formattedTime.unit : undefined, icon: Clock, colors: STAT_COLORS.readingTime, }, { - label: 'Size', - value: formattedSize ? formattedSize.value : 'Unknown', + label: t('libraryOverview.size'), + value: formattedSize ? formattedSize.value : t('common.unknown'), suffix: formattedSize ? formattedSize.unit : '', icon: HardDrive, colors: STAT_COLORS.size, diff --git a/apps/expo/components/library/listHeader/LibrarySeriesListHeader.tsx b/apps/expo/components/library/listHeader/LibrarySeriesListHeader.tsx index 938eb8847..7bea7c4d5 100644 --- a/apps/expo/components/library/listHeader/LibrarySeriesListHeader.tsx +++ b/apps/expo/components/library/listHeader/LibrarySeriesListHeader.tsx @@ -10,6 +10,7 @@ import { ActionDef } from '~/components/filter/types' import { useSeriesFilterMenu } from '~/components/series/listHeader/SeriesFilterMenu' import { useSeriesSortAndDisplayMenu } from '~/components/series/listHeader/SeriesSortAndDisplayMenu' import { MiniEntityStatCards } from '~/components/stats' +import { useTranslate } from '~/lib/hooks' const scanMutation = graphql(` mutation LibrarySeriesListHeaderScanLibrary($id: ID!) { @@ -28,6 +29,7 @@ type Props = { } export function LibrarySeriesListHeader({ libraryId, stats, additionalActions }: Props) { + const { t } = useTranslate() const client = useQueryClient() const { mutate: scanLibrary } = useGraphQLMutation(scanMutation, { onSuccess: () => { @@ -48,7 +50,7 @@ export function LibrarySeriesListHeader({ libraryId, stats, additionalActions }: const result: ActionDef[] = [ { key: 'overview', - label: 'Overview', + label: t('common.overview'), icon: { ios: 'info.circle', android: Info }, onPress: additionalActions.onShowOverview, }, @@ -57,14 +59,14 @@ export function LibrarySeriesListHeader({ libraryId, stats, additionalActions }: if (checkPermission(UserPermission.ScanLibrary)) { result.push({ key: 'scan', - label: 'Scan Library', + label: t('entityActions.scanLibrary'), icon: { ios: 'document.viewfinder', android: ScanLine }, onPress: () => scanLibrary({ id: libraryId }), }) } return result - }, [additionalActions, checkPermission, scanLibrary, libraryId]) + }, [additionalActions, checkPermission, scanLibrary, libraryId, t]) const sortMenu = useSeriesSortAndDisplayMenu({ layoutKey: `library-${libraryId}-series`, diff --git a/apps/expo/components/localLibrary/ReadingNow.tsx b/apps/expo/components/localLibrary/ReadingNow.tsx index 2a5206d3e..fdd0a73a2 100644 --- a/apps/expo/components/localLibrary/ReadingNow.tsx +++ b/apps/expo/components/localLibrary/ReadingNow.tsx @@ -16,7 +16,7 @@ import { ThumbnailImage } from '~/components/image' import { Badge, Heading, Progress, Text } from '~/components/ui' import { epubProgress, imageMeta, syncStatus } from '~/db' import { COLORS, useColors } from '~/lib/constants' -import { useDisplay } from '~/lib/hooks' +import { useDisplay, useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' import { SyncIcon } from './sync-icon/SyncIcon' @@ -140,6 +140,7 @@ type ReadingNowItemProps = { } function ReadingNowItem({ book }: ReadingNowItemProps) { + const { t } = useTranslate() const { width, isTablet } = useDisplay() const colors = useColors() @@ -323,7 +324,7 @@ function ReadingNowItem({ book }: ReadingNowItemProps) { ? formatDistanceToNow(new Date(book.readProgress?.lastModified), { addSuffix: true, }) - : 'unknown time ago'} + : t('common.unknownTimeAgo')} diff --git a/apps/expo/components/opds/MaybeErrorFeed.tsx b/apps/expo/components/opds/MaybeErrorFeed.tsx index 8bb3d0879..709e76532 100644 --- a/apps/expo/components/opds/MaybeErrorFeed.tsx +++ b/apps/expo/components/opds/MaybeErrorFeed.tsx @@ -26,10 +26,10 @@ export default function MaybeErrorFeed({ error, onRetry }: Props) { const errorTitle = error instanceof ZodError ? t('opds.invalidFeed') : t('opds.feedLoadFailed') const errorMessage = error instanceof ZodError - ? `This feed does not adhere to the OPDS v2.0 specification: ${error.message}` + ? t('opds.feedSpecError', { version: 'v2.0', error: error.message }) : error instanceof Error && error.message ? error.message - : 'There was an error fetching this feed.' + : t('opds.feedFetchError') return ( diff --git a/apps/expo/components/opds/OPDSAuthDialog.tsx b/apps/expo/components/opds/OPDSAuthDialog.tsx index dbc529d0c..a58cba09b 100644 --- a/apps/expo/components/opds/OPDSAuthDialog.tsx +++ b/apps/expo/components/opds/OPDSAuthDialog.tsx @@ -4,7 +4,7 @@ import { queryClient, useSDK } from '@stump/client' import { Api, constants, OPDSAuthenticationDocument, resolveUrl } from '@stump/sdk' import { opdsURL } from '@stump/sdk/controllers' import { isAxiosError } from 'axios' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Controller, useForm, useFormState } from 'react-hook-form' import { View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' @@ -29,6 +29,14 @@ type OPDSAuthDialogProps = { export default function OPDSAuthDialog({ isOpen, authDoc, onClose }: OPDSAuthDialogProps) { const { t } = useTranslate() + const schema = useMemo( + () => + z.object({ + password: z.string().min(1, { message: t('auth.passwordRequired') }), + username: z.string().min(1, { message: t('auth.usernameRequired') }), + }), + [t], + ) const { activeServer } = useActiveServer() const { sdk } = useSDK() @@ -130,8 +138,8 @@ export default function OPDSAuthDialog({ isOpen, authDoc, onClose }: OPDSAuthDia ? resolveUrl(logoLink.href, sdk?.rootURL ?? activeServer?.url) : undefined - const usernameLabel = basicAuth?.labels?.login || 'Username' - const passwordLabel = basicAuth?.labels?.password || 'Password' + const usernameLabel = basicAuth?.labels?.login || t('common.username') + const passwordLabel = basicAuth?.labels?.password || t('common.password') return ( <> @@ -233,8 +241,4 @@ type Credentials = { password: string } -const schema = z.object({ - password: z.string().min(1, { message: 'Password is required' }), - username: z.string().min(1, { message: 'Username is required' }), -}) -type LoginSchema = z.infer +type LoginSchema = Credentials diff --git a/apps/expo/components/opdsLegacy/MaybeErrorLegacyFeed.tsx b/apps/expo/components/opdsLegacy/MaybeErrorLegacyFeed.tsx index 790e4ffd1..85b4ef61f 100644 --- a/apps/expo/components/opdsLegacy/MaybeErrorLegacyFeed.tsx +++ b/apps/expo/components/opdsLegacy/MaybeErrorLegacyFeed.tsx @@ -26,10 +26,10 @@ export default function MaybeErrorLegacyFeed({ error, onRetry }: Props) { const errorTitle = error instanceof ZodError ? t('opds.invalidFeed') : t('opds.feedLoadFailed') const errorMessage = error instanceof ZodError - ? `This feed does not adhere to the OPDS v1.2 specification: ${error.message}` + ? t('opds.feedSpecError', { version: 'v1.2', error: error.message }) : error instanceof Error && error.message ? error.message - : 'There was an error fetching this feed.' + : t('opds.feedFetchError') return ( diff --git a/apps/expo/components/savedServer/AddOrEditServerForm.tsx b/apps/expo/components/savedServer/AddOrEditServerForm.tsx index 776dc8b2d..21158bf8d 100644 --- a/apps/expo/components/savedServer/AddOrEditServerForm.tsx +++ b/apps/expo/components/savedServer/AddOrEditServerForm.tsx @@ -145,7 +145,7 @@ export default function AddOrEditServerForm({ const renderAuthMode = () => { if (authMode === 'default') { return ( - + {t(getKey('auth.default.description'))} ) @@ -196,7 +196,7 @@ export default function AddOrEditServerForm({ control={control} render={({ field: { onChange, onBlur, value } }) => ( {formValues.customHeaders?.length && ( - + {formValues.customHeaders.map((header, index) => ( @@ -412,7 +412,7 @@ export default function AddOrEditServerForm({ )} {isAddingHeader ? ( - + {({ pressed }) => ( diff --git a/apps/expo/components/search/SearchHistoryAndFavorites.tsx b/apps/expo/components/search/SearchHistoryAndFavorites.tsx index 4929ca111..3473dac38 100644 --- a/apps/expo/components/search/SearchHistoryAndFavorites.tsx +++ b/apps/expo/components/search/SearchHistoryAndFavorites.tsx @@ -64,7 +64,7 @@ export function SearchHistoryAndFavorites({ onSelect }: Props) { { items: [ { - label: 'Unfavorite', + label: t('searchHistory.unfavorite'), icon: { ios: 'bookmark.slash', android: BookmarkX }, onPress: () => unfavoriteSearch(record.query, serverID), role: 'destructive', @@ -102,7 +102,7 @@ export function SearchHistoryAndFavorites({ onSelect }: Props) { ...(!favoriteQuerySet.has(record.query) ? [ { - label: 'Favorite', + label: t('common.favorite'), icon: { ios: 'bookmark.fill' as const, android: Bookmark, @@ -112,7 +112,7 @@ export function SearchHistoryAndFavorites({ onSelect }: Props) { ] : [ { - label: 'Unfavorite', + label: t('searchHistory.unfavorite'), icon: { ios: 'bookmark.slash' as const, android: BookmarkX, @@ -121,7 +121,7 @@ export function SearchHistoryAndFavorites({ onSelect }: Props) { }, ]), { - label: 'Remove', + label: t('searchHistory.remove'), icon: { ios: 'trash', android: Trash }, onPress: () => removeFromHistory(record.query, serverID), role: 'destructive' as const, diff --git a/apps/expo/components/selection/SelectionRightScreenHeader.tsx b/apps/expo/components/selection/SelectionRightScreenHeader.tsx index eb1c0c2bc..4d4556d7f 100644 --- a/apps/expo/components/selection/SelectionRightScreenHeader.tsx +++ b/apps/expo/components/selection/SelectionRightScreenHeader.tsx @@ -1,11 +1,11 @@ import { Host, Image } from '@expo/ui/swift-ui' import { Stack } from 'expo-router' import { CheckCircle2, Share, Trash } from 'lucide-react-native' -import pluralize from 'pluralize' import { useCallback, useMemo } from 'react' import { Alert, Platform, Pressable, View } from 'react-native' import { useSelectionStore } from '~/stores/selection' +import { useTranslate } from '~/lib/hooks' import { ActionMenu } from '../ui/action-menu/action-menu' import { Icon } from '../ui/icon' @@ -13,6 +13,7 @@ import { Icon } from '../ui/icon' // TODO: Redesign after https://github.com/software-mansion/react-native-screens/issues/2990#issuecomment-3448692775 export default function SelectionRightScreenHeader() { + const { t } = useTranslate() const onStopSelection = useSelectionStore((state) => state.resetSelection) const currentSelection = useSelectionStore((state) => state.selectionState) const customActions = useSelectionStore((state) => state.customActions) @@ -27,15 +28,16 @@ export default function SelectionRightScreenHeader() { }, [currentSelection, deleteAction, onStopSelection]) const confirmDeleteSelection = useCallback(() => { + const count = currentSelection.size Alert.alert( - `Delete ${currentSelection.size} ${pluralize('download', currentSelection.size)}`, - 'This action cannot be undone.', + t(count === 1 ? 'selection.deleteOneDownload' : 'selection.deleteManyDownloads', { count }), + t('selection.cannotUndo'), [ - { text: 'Cancel', style: 'cancel' }, - { text: 'Delete', style: 'destructive', onPress: onDeleteSelection }, + { text: t('common.cancel'), style: 'cancel' }, + { text: t('common.delete'), style: 'destructive', onPress: onDeleteSelection }, ], ) - }, [currentSelection.size, onDeleteSelection]) + }, [currentSelection.size, onDeleteSelection, t]) return Platform.select({ ios: ( @@ -46,7 +48,7 @@ export default function SelectionRightScreenHeader() { {}} disabled> - Share + {t('common.share')} @@ -57,7 +59,7 @@ export default function SelectionRightScreenHeader() { destructive disabled={!deleteAction} > - Delete + {t('common.delete')} @@ -90,7 +92,7 @@ export default function SelectionRightScreenHeader() { { items: [ { - label: 'Share', + label: t('common.share'), icon: { ios: 'square.and.arrow.up', android: Share, @@ -103,7 +105,7 @@ export default function SelectionRightScreenHeader() { { items: [ { - label: 'Delete', + label: t('common.delete'), icon: { ios: 'trash', android: Trash, @@ -146,7 +148,7 @@ export default function SelectionRightScreenHeader() { { items: [ { - label: 'Share', + label: t('common.share'), icon: { ios: 'square.and.arrow.up', android: Share, @@ -159,7 +161,7 @@ export default function SelectionRightScreenHeader() { { items: [ { - label: 'Delete', + label: t('common.delete'), icon: { ios: 'trash', android: Trash, diff --git a/apps/expo/components/smartList/SmartListActionMenu.tsx b/apps/expo/components/smartList/SmartListActionMenu.tsx index 1ad1f5567..976d64139 100644 --- a/apps/expo/components/smartList/SmartListActionMenu.tsx +++ b/apps/expo/components/smartList/SmartListActionMenu.tsx @@ -18,6 +18,7 @@ import { Text, } from '~/components/ui' import { useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' type Props = { @@ -26,6 +27,7 @@ type Props = { } export default function SmartListActionMenu({ onCollapseAll, onExpandAll }: Props) { + const { t } = useTranslate() const [isOpen, setIsOpen] = useState(false) const insets = useSafeAreaInsets() @@ -77,7 +79,7 @@ export default function SmartListActionMenu({ onCollapseAll, onExpandAll }: Prop key="displayAsGrid" onSelect={() => setPreferences({ smartListLayout: 'grid' })} > - Grid + {t('common.grid')} @@ -86,7 +88,7 @@ export default function SmartListActionMenu({ onCollapseAll, onExpandAll }: Prop key="displayAsList" onSelect={() => setPreferences({ smartListLayout: 'list' })} > - List + {t('common.list')} @@ -94,12 +96,16 @@ export default function SmartListActionMenu({ onCollapseAll, onExpandAll }: Prop {onCollapseAll && onExpandAll && ( - Collapse All + + {t('smartList.collapseAll')} + - Expand All + + {t('smartList.expandAll')} + @@ -111,7 +117,7 @@ export default function SmartListActionMenu({ onCollapseAll, onExpandAll }: Prop @@ -134,7 +138,7 @@ export default function OperatorSelect({ idx }: Props) { })} value={operator} > - {operatorMap[operator]} + {t(`${LOCALE_BASE}.operations.${operator}`)} ))} @@ -152,20 +156,4 @@ const operatorGroups = { string: ['contains', 'excludes', 'neq', 'eq'] satisfies StringOperation[], } -const operatorMap: Record = { - anyOf: 'any in list', - contains: 'contains string', - eq: 'equal to', - excludes: 'excludes string', - is: 'is', - isNot: 'is not', - isAnyOf: 'is any of', - isNoneOf: 'is none of', - gt: 'greater than', - gte: 'greater than or equal to', - lt: 'less than', - lte: 'less than or equal to', - noneOf: 'none in list', - neq: 'not equal to', - range: 'in range', -} +const LOCALE_BASE = 'createOrUpdateSmartListForm.fields.queryBuilder.filters.operatorSelect' diff --git a/packages/browser/src/scenes/book/BookActionMenu.tsx b/packages/browser/src/scenes/book/BookActionMenu.tsx index 73c2d22a1..0502bfbe1 100644 --- a/packages/browser/src/scenes/book/BookActionMenu.tsx +++ b/packages/browser/src/scenes/book/BookActionMenu.tsx @@ -140,26 +140,26 @@ export default function BookActionMenu({ book }: Props) { ...(continueReadingLink ? [ { - label: 'Continue reading', + label: t('bookActionMenu.continueReading'), leftIcon: , onClick: () => navigate(continueReadingLink), }, ] : []), { - label: 'Read from beginning', + label: t('bookActionMenu.readFromBeginning'), leftIcon: , onClick: () => navigate(getReadFromBeginningLink(false)), }, { - label: 'Incognito mode', + label: t('bookActionMenu.incognitoMode'), leftIcon: , onClick: () => navigate(getReadFromBeginningLink(true)), }, ...(book.extension?.match(PDF_EXTENSION) ? [ { - label: 'Native PDF viewer', + label: t('bookActionMenu.nativePdfViewer'), leftIcon: , onClick: () => navigate(paths.bookReader(book.id, { isPdf: true, isStreaming: false })), @@ -173,7 +173,7 @@ export default function BookActionMenu({ book }: Props) { ...(progression.isUntouched || progression.isReading ? [ { - label: 'Mark as read', + label: t('bookActionMenu.markAsRead'), leftIcon: , onClick: () => { actions.completeBook({ id: book.id }) @@ -184,7 +184,7 @@ export default function BookActionMenu({ book }: Props) { ...(progression.isReading ? [ { - label: 'Clear progress', + label: t('bookActionMenu.clearProgress'), leftIcon: , onClick: () => { actions.deleteCurrentSession({ id: book.id }) @@ -195,7 +195,7 @@ export default function BookActionMenu({ book }: Props) { ...(progression.isPreviouslyCompleted ? [ { - label: 'Delete history', + label: t('bookActionMenu.deleteHistory'), leftIcon: , onClick: () => { setShowDeleteHistoryConfirmation(true) @@ -211,7 +211,7 @@ export default function BookActionMenu({ book }: Props) { checkPermission(UserPermission.EditThumbnails) ? [ { - label: 'Manage', + label: t('bookActionMenu.manage'), leftIcon: , onClick: () => { navigate(paths.bookManagement(book.id)) @@ -223,7 +223,7 @@ export default function BookActionMenu({ book }: Props) { checkPermission(UserPermission.EmailArbitrarySend) ? [ { - label: 'Email', + label: t('bookActionMenu.email'), leftIcon: , onClick: () => setShowEmailDialog(true), }, @@ -241,6 +241,7 @@ export default function BookActionMenu({ book }: Props) { actions, continueReadingLink, getReadFromBeginningLink, + t, ], ) diff --git a/packages/browser/src/scenes/bookClub/BookClubNavigation.tsx b/packages/browser/src/scenes/bookClub/BookClubNavigation.tsx index a9cad3e11..7728081e7 100644 --- a/packages/browser/src/scenes/bookClub/BookClubNavigation.tsx +++ b/packages/browser/src/scenes/bookClub/BookClubNavigation.tsx @@ -1,4 +1,5 @@ import { cn, cx, Link } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { noop } from 'lodash' import { useMemo } from 'react' import { useLocation } from 'react-router' @@ -9,6 +10,7 @@ import { usePreferences } from '@/hooks' // TODO(book-clubs): Implement // TODO: when viewing a thread, only show something like "<-- Return to chat board" export default function BookClubNavigation() { + const { t } = useLocaleContext() const location = useLocation() const { preferences: { primaryNavigationMode, layoutMaxWidthPx }, @@ -21,7 +23,7 @@ export default function BookClubNavigation() { const base = [ { isActive: location.pathname.match(/\/clubs\/[^/]+\/?(home)?$/), - label: 'Home', + label: t('bookClubNavigation.home'), to: '.', }, ] @@ -34,22 +36,22 @@ export default function BookClubNavigation() { ...base, { isActive: location.pathname.match(/\/clubs\/[^/]+\/discussion(\/.*)?$/), - label: 'Discussion', + label: t('bookClubNavigation.discussion'), onHover: () => prefetch(), to: 'discussion', }, { isActive: location.pathname.match(/\/clubs\/[^/]+\/members(\/.*)?$/), - label: 'Members', + label: t('bookClubNavigation.members'), to: 'members', }, { isActive: location.pathname.match(/\/clubs\/[^/]+\/settings(\/.*)?$/), - label: 'Settings', + label: t('bookClubNavigation.settings'), to: 'settings', }, ] - }, [location, viewerIsMember, prefetch]) + }, [location, viewerIsMember, prefetch, t]) const preferTopBar = primaryNavigationMode === 'TOPBAR' diff --git a/packages/browser/src/scenes/bookClub/tabs/settings/members/MemberActionMenu.tsx b/packages/browser/src/scenes/bookClub/tabs/settings/members/MemberActionMenu.tsx index 5406a4334..23f16cab1 100644 --- a/packages/browser/src/scenes/bookClub/tabs/settings/members/MemberActionMenu.tsx +++ b/packages/browser/src/scenes/bookClub/tabs/settings/members/MemberActionMenu.tsx @@ -1,4 +1,5 @@ import { Button, DropdownMenu } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { MoreVertical, Trash } from 'lucide-react' type Props = { @@ -6,6 +7,8 @@ type Props = { } export default function MemberActionMenu({ onSelectForRemoval }: Props) { + const { t } = useLocaleContext() + return (
, onClick: onSelectForRemoval, }, diff --git a/packages/browser/src/scenes/settings/app/reader/DefaultFontFamily.tsx b/packages/browser/src/scenes/settings/app/reader/DefaultFontFamily.tsx index 684368caa..4b839823d 100644 --- a/packages/browser/src/scenes/settings/app/reader/DefaultFontFamily.tsx +++ b/packages/browser/src/scenes/settings/app/reader/DefaultFontFamily.tsx @@ -35,7 +35,7 @@ export default function DefaultFontFamily() { void } export default function DeviceActionMenu({ onEdit, onDelete }: Props) { + const { t } = useLocaleContext() + return ( , onClick: onEdit, }, { - label: 'Delete', + label: t('common.delete'), leftIcon: , onClick: onDelete, }, diff --git a/packages/browser/src/scenes/settings/server/email/emailers/CreateOrUpdateEmailerForm.tsx b/packages/browser/src/scenes/settings/server/email/emailers/CreateOrUpdateEmailerForm.tsx index 965b497ef..8c3dd25fa 100644 --- a/packages/browser/src/scenes/settings/server/email/emailers/CreateOrUpdateEmailerForm.tsx +++ b/packages/browser/src/scenes/settings/server/email/emailers/CreateOrUpdateEmailerForm.tsx @@ -108,7 +108,7 @@ export default function CreateOrUpdateEmailerForm({ emailer, existingNames, onSu
void } export default function EmailerActionMenu({ onEdit, onDelete }: Props) { + const { t } = useLocaleContext() + return ( , onClick: onEdit, }, ...(onDelete ? [ { - label: 'Delete', + label: t('common.delete'), leftIcon: , onClick: onDelete, }, diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index 8dd20a730..a2fd4224e 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -664,6 +664,7 @@ "noBookmarks": "No bookmarks", "themeAndOptions": "Theme and options", "appearance": "Appearance", + "default": "Default", "fontSize": "Font size", "fontPreviewLimit": "Live font preview is capped at 50px", "lineHeight": "Line height", @@ -701,6 +702,23 @@ "nextUp": "Next up:", "read": "Read" }, + "bookActionMenu": { + "continueReading": "Continue reading", + "readFromBeginning": "Read from beginning", + "incognitoMode": "Incognito mode", + "nativePdfViewer": "Native PDF viewer", + "markAsRead": "Mark as read", + "clearProgress": "Clear progress", + "deleteHistory": "Delete history", + "manage": "Manage", + "email": "Email" + }, + "bookClubNavigation": { + "home": "Home", + "discussion": "Discussion", + "members": "Members", + "settings": "Settings" + }, "entityUi": { "fileMissing": "File missing", "pagesLeft": "{{count}} pages left", @@ -1458,6 +1476,29 @@ "deleteGroup": "Delete group" }, "is": "is", + "operatorSelect": { + "match": "Match", + "equality": "Equality", + "list": "List", + "operator": "Operator", + "operations": { + "anyOf": "any in list", + "contains": "contains string", + "eq": "equal to", + "excludes": "excludes string", + "is": "is", + "isNot": "is not", + "isAnyOf": "is any of", + "isNoneOf": "is none of", + "gt": "greater than", + "gte": "greater than or equal to", + "lt": "less than", + "lte": "less than or equal to", + "noneOf": "none in list", + "neq": "not equal to", + "range": "in range" + } + }, "fieldSelect": { "placeholder": "Attribute", "source": { @@ -2832,7 +2873,8 @@ }, "smtpProvider": { "label": "SMTP provider preset", - "description": "The provider of the SMTP server. This is used to prefill some common settings, but doesn't capture all providers" + "description": "The provider of the SMTP server. This is used to prefill some common settings, but doesn't capture all providers", + "custom": "Custom" }, "smtpHost": { "label": "SMTP host", diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 630cdb17d..236b9fd54 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -664,6 +664,7 @@ "noBookmarks": "북마크 없음", "themeAndOptions": "테마 및 옵션", "appearance": "화면 설정", + "default": "기본값", "fontSize": "글자 크기", "fontPreviewLimit": "실시간 글자 미리보기는 최대 50px까지 표시됩니다", "lineHeight": "줄 높이", @@ -701,6 +702,23 @@ "nextUp": "다음 책:", "read": "읽기" }, + "bookActionMenu": { + "continueReading": "이어 읽기", + "readFromBeginning": "처음부터 읽기", + "incognitoMode": "기록 없이 읽기", + "nativePdfViewer": "기본 PDF 뷰어", + "markAsRead": "읽음으로 표시", + "clearProgress": "진행률 지우기", + "deleteHistory": "읽기 기록 삭제", + "manage": "관리", + "email": "이메일로 보내기" + }, + "bookClubNavigation": { + "home": "홈", + "discussion": "토론", + "members": "회원", + "settings": "설정" + }, "entityUi": { "fileMissing": "파일 누락", "pagesLeft": "{{count}}페이지 남음", @@ -1458,6 +1476,29 @@ "deleteGroup": "그룹 삭제" }, "is": "조건", + "operatorSelect": { + "match": "일치 조건", + "equality": "비교 조건", + "list": "목록 조건", + "operator": "연산자", + "operations": { + "anyOf": "목록 중 하나와 일치", + "contains": "문자열 포함", + "eq": "같음", + "excludes": "문자열 미포함", + "is": "일치", + "isNot": "일치하지 않음", + "isAnyOf": "하나 이상 일치", + "isNoneOf": "모두 일치하지 않음", + "gt": "초과", + "gte": "이상", + "lt": "미만", + "lte": "이하", + "noneOf": "목록과 모두 불일치", + "neq": "같지 않음", + "range": "범위 내" + } + }, "fieldSelect": { "placeholder": "속성", "source": { @@ -2832,7 +2873,8 @@ }, "smtpProvider": { "label": "SMTP 공급자 사전 설정", - "description": "SMTP 서버 공급자를 선택하면 일반 설정을 미리 채웁니다. 모든 공급자가 포함되어 있지는 않습니다" + "description": "SMTP 서버 공급자를 선택하면 일반 설정을 미리 채웁니다. 모든 공급자가 포함되어 있지는 않습니다", + "custom": "사용자 지정" }, "smtpHost": { "label": "SMTP 호스트", From 0707c5be87f42ecca5088d4e9655bb53b7b21ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 19:25:40 +0900 Subject: [PATCH 41/54] =?UTF-8?q?feat(i18n):=20=EC=9B=B9=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=98=A4=EB=A5=98=EC=99=80=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../library/createOrUpdate/schema.ts | 75 ++++++++++--------- .../metadataMatching/useMatchActions.ts | 23 +++--- .../createLibrary/CreateLibraryForm.tsx | 2 +- .../settings/basics/BasicSettingsScene.tsx | 2 +- .../readingDefaults/ReadingDefaultsScene.tsx | 4 +- .../FileConversionOptionsPatchForm.tsx | 4 +- .../options/scanner/IgnoreRulesPatchForm.tsx | 4 +- .../scanner/ScannerFeaturesPatchForm.tsx | 4 +- .../thumbnails/ThumbnailSettingsScene.tsx | 4 +- .../browser/src/scenes/smartList/hooks.ts | 20 +++-- packages/i18n/src/locales/en-US.json | 21 +++++- packages/i18n/src/locales/ko-KR.json | 21 +++++- 12 files changed, 123 insertions(+), 61 deletions(-) diff --git a/packages/browser/src/components/library/createOrUpdate/schema.ts b/packages/browser/src/components/library/createOrUpdate/schema.ts index 9ce48a04c..606d06b00 100644 --- a/packages/browser/src/components/library/createOrUpdate/schema.ts +++ b/packages/browser/src/components/library/createOrUpdate/schema.ts @@ -11,6 +11,7 @@ import { ScaledDimensionResize, SupportedImageFormat, } from '@stump/graphql' +import type { LocaleContextProps } from '@stump/i18n' import isValidGlob from 'is-valid-glob' import omit from 'lodash/omit' import { match } from 'ts-pattern' @@ -72,45 +73,44 @@ const exactResize = z.object({ width: z.number().int().positive(), }) -const scaledEventlyByFactor = z.object({ - mode: z.literal('scaleEvenlyByFactor'), - factor: z.number().refine((value) => value > 0 && value <= 1, { - message: 'Factor must be between 0 and 1', - }), -}) - const scaledDimension = z.object({ mode: z.literal('scaleDimension'), dimension: z.enum(['HEIGHT', 'WIDTH']), size: z.number().int().positive(), }) -const resizeOptionsSchema = z.union([exactResize, scaledEventlyByFactor, scaledDimension]) - -const thumbnailConfig = z.object({ - enabled: z.boolean().default(false), - format: imageFormatSchema.default(SupportedImageFormat.Webp), - quality: z - .number() - .int() - .nullish() - .refine( - (value) => value == undefined || (value > 0 && value <= 100), - () => ({ - message: 'Thumbnail quality must be between 0 and 100', - }), - ), - resizeMethod: resizeOptionsSchema.nullish(), -}) - /** * A function which builds a schema used for validating form data when creating or updating a library */ export const buildSchema = ( + t: LocaleContextProps['t'], existingLibraries: CreateLibrarySceneExistingLibrariesQuery['libraries']['nodes'], library?: Library, -) => - z.object({ +) => { + const scaledEventlyByFactor = z.object({ + mode: z.literal('scaleEvenlyByFactor'), + factor: z.number().refine((value) => value > 0 && value <= 1, { + message: t(getValidationKey('factorRange')), + }), + }) + const resizeOptionsSchema = z.union([exactResize, scaledEventlyByFactor, scaledDimension]) + const thumbnailConfig = z.object({ + enabled: z.boolean().default(false), + format: imageFormatSchema.default(SupportedImageFormat.Webp), + quality: z + .number() + .int() + .nullish() + .refine( + (value) => value == undefined || (value > 0 && value <= 100), + () => ({ + message: t(getValidationKey('thumbnailQualityRange')), + }), + ), + resizeMethod: resizeOptionsSchema.nullish(), + }) + + return z.object({ convertRarToZip: z.boolean().default(false), defaultReadingDir: z.enum(['LTR', 'RTL']).default('LTR').optional(), defaultReadingImageScaleFit: z @@ -129,7 +129,9 @@ export const buildSchema = ( ignoreRules: z .array( z.object({ - glob: z.string().refine(isValidGlob, { message: 'Invalid glob pattern' }), + glob: z.string().refine(isValidGlob, { + message: t(getValidationKey('invalidGlob')), + }), ignore_parents: z.boolean().default(false), ignore_subdirs: z.boolean().default(false), }), @@ -142,7 +144,7 @@ export const buildSchema = ( skipBookOverview: z.boolean().default(false), name: z .string() - .min(1, { message: 'Library name is required' }) + .min(1, { message: t(getValidationKey('nameRequired')) }) .refine( // return falsy value to indicate failure. // If the library name is already taken -> fail @@ -152,13 +154,13 @@ export const buildSchema = ( const isUnchanged = library?.name === val return !isTaken || isUnchanged }, - (val) => ({ - message: `You already have a library named ${val}.`, + (name) => ({ + message: t(getValidationKey('nameTaken'), { name }), }), ), path: z .string() - .min(1, { message: 'Library path is required' }) + .min(1, { message: t(getValidationKey('pathRequired')) }) .transform((val) => normalizePath(val)) .superRefine((val, ctx) => { // If the path is unchanged -> pass @@ -170,7 +172,7 @@ export const buildSchema = ( const isTaken = existingLibraries.some((l) => l.path === val) if (isTaken) { - ctx.addIssue({ code: 'custom', message: 'This path is taken by an existing library' }) + ctx.addIssue({ code: 'custom', message: t(getValidationKey('pathTaken')) }) return } @@ -180,7 +182,7 @@ export const buildSchema = ( if (isParent) { ctx.addIssue({ code: 'custom', - message: 'This path is a parent directory of an existing library', + message: t(getValidationKey('pathParent')), }) return } @@ -189,7 +191,7 @@ export const buildSchema = ( if (isChild) { ctx.addIssue({ code: 'custom', - message: 'This path is a sub-directory of an existing library', + message: t(getValidationKey('pathChild')), }) return } @@ -207,6 +209,9 @@ export const buildSchema = ( thumbnailConfig, processThumbnailColorsEvenWithoutConfig: z.boolean().default(false), }) +} + +const getValidationKey = (key: string) => `createOrUpdateLibraryForm.validation.${key}` export type CreateOrUpdateLibrarySchema = z.infer> /** diff --git a/packages/browser/src/components/metadata/metadataMatching/useMatchActions.ts b/packages/browser/src/components/metadata/metadataMatching/useMatchActions.ts index 194433294..ab7215988 100644 --- a/packages/browser/src/components/metadata/metadataMatching/useMatchActions.ts +++ b/packages/browser/src/components/metadata/metadataMatching/useMatchActions.ts @@ -1,5 +1,6 @@ import { useGraphQLMutation, useSDK } from '@stump/client' import { graphql, MetadataField } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useQueryClient } from '@tanstack/react-query' import { useCallback, useMemo } from 'react' import { toast } from 'sonner' @@ -85,6 +86,7 @@ const setSeriesLockedFieldsMutation = graphql(` `) export function useToggleLockedField() { + const { t } = useLocaleContext() const { sdk } = useSDK() const { records, currentRecordIndex, toggleLockedField, getLockedFields } = useMatchReviewStore() @@ -106,14 +108,15 @@ export function useToggleLockedField() { promise.catch(() => { toggleLockedField(field) - toast.error('Failed to update locked fields') + toast.error(t('entityUi.lockedFieldsUpdateFailed')) }) }, - [sdk, records, currentRecordIndex, toggleLockedField, getLockedFields], + [sdk, records, currentRecordIndex, toggleLockedField, getLockedFields, t], ) } export function useMatchActions() { + const { t } = useLocaleContext() const { sdk } = useSDK() const { records, @@ -165,7 +168,7 @@ export function useMatchActions() { } } const onAcceptSuccess = () => { - toast.success('Match accepted') + toast.success(t(`${LOCALE_BASE}.accepted`)) invalidateQueries() advance() } @@ -174,7 +177,7 @@ export function useMatchActions() { acceptMediaMatchMutation, { onSuccess: () => onAcceptSuccess(), - onError: () => toast.error('Failed to accept match'), + onError: () => toast.error(t(`${LOCALE_BASE}.acceptFailed`)), }, ) @@ -182,7 +185,7 @@ export function useMatchActions() { acceptSeriesMatchMutation, { onSuccess: () => onAcceptSuccess(), - onError: () => toast.error('Failed to accept match'), + onError: () => toast.error(t(`${LOCALE_BASE}.acceptFailed`)), }, ) @@ -190,11 +193,11 @@ export function useMatchActions() { rejectMediaMatchMutation, { onSuccess: () => { - toast.success('Match rejected') + toast.success(t(`${LOCALE_BASE}.rejected`)) invalidateQueries() advance() }, - onError: () => toast.error('Failed to reject match'), + onError: () => toast.error(t(`${LOCALE_BASE}.rejectFailed`)), }, ) @@ -202,11 +205,11 @@ export function useMatchActions() { rejectSeriesMatchMutation, { onSuccess: () => { - toast.success('Match rejected') + toast.success(t(`${LOCALE_BASE}.rejected`)) invalidateQueries() advance() }, - onError: () => toast.error('Failed to reject match'), + onError: () => toast.error(t(`${LOCALE_BASE}.rejectFailed`)), }, ) @@ -266,3 +269,5 @@ export function useMatchActions() { return { accept, reject, isPending, hasCandidate } } + +const LOCALE_BASE = 'metadataMatching.reviewDialog.notifications' diff --git a/packages/browser/src/scenes/createLibrary/CreateLibraryForm.tsx b/packages/browser/src/scenes/createLibrary/CreateLibraryForm.tsx index dad63fa89..5eb16110b 100644 --- a/packages/browser/src/scenes/createLibrary/CreateLibraryForm.tsx +++ b/packages/browser/src/scenes/createLibrary/CreateLibraryForm.tsx @@ -40,7 +40,7 @@ export default function CreateLibraryForm({ existingLibraries, onSubmit, isLoadi const [showDirectoryPicker, setShowDirectoryPicker] = useState(false) - const schema = useMemo(() => buildSchema(existingLibraries), [existingLibraries]) + const schema = useMemo(() => buildSchema(t, existingLibraries), [t, existingLibraries]) const form = useForm({ defaultValues: formDefaults(), reValidateMode: 'onChange', diff --git a/packages/browser/src/scenes/library/tabs/settings/basics/BasicSettingsScene.tsx b/packages/browser/src/scenes/library/tabs/settings/basics/BasicSettingsScene.tsx index 3b7afd08f..3007f1d12 100644 --- a/packages/browser/src/scenes/library/tabs/settings/basics/BasicSettingsScene.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/basics/BasicSettingsScene.tsx @@ -42,7 +42,7 @@ export default function BasicSettingsScene() { } = useSuspenseGraphQL(query, [sdk.cacheKeys.libraryCreateLibraryQuery]) const { checkPermission } = useAppContext() - const schema = useMemo(() => buildSchema(libraries, library), [libraries, library]) + const schema = useMemo(() => buildSchema(t, libraries, library), [t, libraries, library]) const form = useForm({ defaultValues: formDefaults(library), reValidateMode: 'onChange', diff --git a/packages/browser/src/scenes/library/tabs/settings/options/readingDefaults/ReadingDefaultsScene.tsx b/packages/browser/src/scenes/library/tabs/settings/options/readingDefaults/ReadingDefaultsScene.tsx index 944dec9e2..6b5402b91 100644 --- a/packages/browser/src/scenes/library/tabs/settings/options/readingDefaults/ReadingDefaultsScene.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/options/readingDefaults/ReadingDefaultsScene.tsx @@ -47,7 +47,7 @@ export default function ReadingDefaultsScene() { const schema = useMemo( () => - buildSchema([], library).pick({ + buildSchema(t, [], library).pick({ defaultReadingDir: true, defaultReadingImageScaleFit: true, defaultReadingMode: true, @@ -55,7 +55,7 @@ export default function ReadingDefaultsScene() { hideSeriesView: true, skipBookOverview: true, }), - [library], + [t, library], ) const form = useForm({ diff --git a/packages/browser/src/scenes/library/tabs/settings/options/scanner/FileConversionOptionsPatchForm.tsx b/packages/browser/src/scenes/library/tabs/settings/options/scanner/FileConversionOptionsPatchForm.tsx index 3e95007e1..991ae125b 100644 --- a/packages/browser/src/scenes/library/tabs/settings/options/scanner/FileConversionOptionsPatchForm.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/options/scanner/FileConversionOptionsPatchForm.tsx @@ -1,5 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { Form } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { useCallback, useMemo } from 'react' import { useForm } from 'react-hook-form' @@ -13,9 +14,10 @@ import { import { useLibraryManagement } from '../../context' export default function FileConversionOptionsPatchForm() { + const { t } = useLocaleContext() const { library, patch } = useLibraryManagement() - const schema = useMemo(() => buildSchema([], library), [library]) + const schema = useMemo(() => buildSchema(t, [], library), [t, library]) const form = useForm({ defaultValues: formDefaults(library), reValidateMode: 'onChange', diff --git a/packages/browser/src/scenes/library/tabs/settings/options/scanner/IgnoreRulesPatchForm.tsx b/packages/browser/src/scenes/library/tabs/settings/options/scanner/IgnoreRulesPatchForm.tsx index a1433b293..598b62091 100644 --- a/packages/browser/src/scenes/library/tabs/settings/options/scanner/IgnoreRulesPatchForm.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/options/scanner/IgnoreRulesPatchForm.tsx @@ -1,5 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { Form } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { useCallback, useMemo } from 'react' import { useForm } from 'react-hook-form' @@ -13,9 +14,10 @@ import { import { useLibraryManagement } from '../../context' export default function IgnoreRulesPatchForm() { + const { t } = useLocaleContext() const { library, patch } = useLibraryManagement() - const schema = useMemo(() => buildSchema([], library), [library]) + const schema = useMemo(() => buildSchema(t, [], library), [t, library]) const form = useForm({ defaultValues: formDefaults(library), reValidateMode: 'onChange', diff --git a/packages/browser/src/scenes/library/tabs/settings/options/scanner/ScannerFeaturesPatchForm.tsx b/packages/browser/src/scenes/library/tabs/settings/options/scanner/ScannerFeaturesPatchForm.tsx index 8bc01d08a..49990c360 100644 --- a/packages/browser/src/scenes/library/tabs/settings/options/scanner/ScannerFeaturesPatchForm.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/options/scanner/ScannerFeaturesPatchForm.tsx @@ -1,5 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { Form } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { useCallback, useMemo } from 'react' import { useForm } from 'react-hook-form' @@ -13,9 +14,10 @@ import { import { useLibraryManagement } from '../../context' export default function ScannerFeaturesPatchForm() { + const { t } = useLocaleContext() const { library, patch } = useLibraryManagement() - const schema = useMemo(() => buildSchema([], library), [library]) + const schema = useMemo(() => buildSchema(t, [], library), [t, library]) const form = useForm({ defaultValues: formDefaults(library), reValidateMode: 'onChange', diff --git a/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/ThumbnailSettingsScene.tsx b/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/ThumbnailSettingsScene.tsx index 02f6a5058..ecf36654b 100644 --- a/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/ThumbnailSettingsScene.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/ThumbnailSettingsScene.tsx @@ -1,5 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { Form } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { useCallback, useMemo } from 'react' import { useForm } from 'react-hook-form' @@ -15,9 +16,10 @@ import { useLibraryManagement } from '../../context' import ThumbnailManagementSection from './ThumbnailManagementSection' export default function ThumbnailSettingsScene() { + const { t } = useLocaleContext() const { library, patch } = useLibraryManagement() - const schema = useMemo(() => buildSchema([], library), [library]) + const schema = useMemo(() => buildSchema(t, [], library), [t, library]) const form = useForm({ defaultValues: formDefaults(library), reValidateMode: 'onChange', diff --git a/packages/browser/src/scenes/smartList/hooks.ts b/packages/browser/src/scenes/smartList/hooks.ts index 6700e1d00..72ee9cc10 100644 --- a/packages/browser/src/scenes/smartList/hooks.ts +++ b/packages/browser/src/scenes/smartList/hooks.ts @@ -1,5 +1,6 @@ import { useGraphQLMutation, useSDK } from '@stump/client' import { graphql, SaveSmartListView } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useQueryClient } from '@tanstack/react-query' import omit from 'lodash/omit' import { useCallback } from 'react' @@ -77,6 +78,7 @@ const deleteViewMutation = graphql(` * Hook to save the current working view as a new stored view */ export const useSaveWorkingView = () => { + const { t } = useLocaleContext() const { list } = useSmartListContext() const workingView = useSmartListViewStore((s) => s.workingView) const selectStoredView = useSmartListViewStore((s) => s.selectStoredView) @@ -109,7 +111,7 @@ export const useSaveWorkingView = () => { }) } catch (error) { console.error(error) - const prefix = 'Failed to create view' + const prefix = t(`${LOCALE_BASE}.viewCreateError`) if (error instanceof Error) { toast.error(`${prefix}${error.message ? `: ${error.message}` : ''}`) } else { @@ -117,7 +119,7 @@ export const useSaveWorkingView = () => { } } }, - [workingView, list.id, createView], + [workingView, list.id, createView, t], ) return { saveWorkingView } @@ -127,6 +129,7 @@ export const useSaveWorkingView = () => { * Hook to update the currently selected stored view with the working view changes */ export const useSaveSelectedStoredView = () => { + const { t } = useLocaleContext() const { list } = useSmartListContext() const workingView = useSmartListViewStore((s) => s.workingView) const selectedView = useSmartListViewStore((s) => s.selectedView) @@ -164,7 +167,7 @@ export const useSaveSelectedStoredView = () => { }) } catch (error) { console.error(error) - const prefix = 'Failed to save view' + const prefix = t(`${LOCALE_BASE}.viewSaveError`) if (error instanceof Error) { toast.error(`${prefix}${error.message ? `: ${error.message}` : ''}`) } else { @@ -172,7 +175,7 @@ export const useSaveSelectedStoredView = () => { } } }, - [selectedView, workingView, updateView], + [selectedView, workingView, updateView, t], ) return { saveSelectedStoredView } @@ -182,6 +185,7 @@ export const useSaveSelectedStoredView = () => { * Hook to delete the currently selected stored view */ export const useDeleteSelectedView = () => { + const { t } = useLocaleContext() const { list } = useSmartListContext() const selectedView = useSmartListViewStore((s) => s.selectedView) const selectStoredView = useSmartListViewStore((s) => s.selectStoredView) @@ -208,17 +212,19 @@ export const useDeleteSelectedView = () => { id: list.id, name: selectedView.name, }) - toast.success('View deleted') + toast.success(t(`${LOCALE_BASE}.viewDeleteSuccess`)) } catch (error) { console.error(error) - const prefix = 'Failed to delete view' + const prefix = t(`${LOCALE_BASE}.viewDeleteError`) if (error instanceof Error) { toast.error(`${prefix}${error.message ? `: ${error.message}` : ''}`) } else { toast.error(prefix) } } - }, [selectedView, list.id, deleteView]) + }, [selectedView, list.id, deleteView, t]) return { deleteSelectedView, isDeleting: isPending } } + +const LOCALE_BASE = 'userSmartListScene.layout' diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index a2fd4224e..b57210fbc 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -293,6 +293,17 @@ } }, "createOrUpdateLibraryForm": { + "validation": { + "factorRange": "Factor must be between 0 and 1", + "thumbnailQualityRange": "Thumbnail quality must be between 0 and 100", + "invalidGlob": "Invalid glob pattern", + "nameRequired": "Library name is required", + "nameTaken": "You already have a library named {{name}}.", + "pathRequired": "Library path is required", + "pathTaken": "This path is taken by an existing library", + "pathParent": "This path is a parent directory of an existing library", + "pathChild": "This path is a sub-directory of an existing library" + }, "fields": { "name": { "label": "Name", @@ -1314,6 +1325,12 @@ "excludeField": "Exclude this field", "fieldLocked": "This field is locked", "reviewDialog": { + "notifications": { + "accepted": "Match accepted", + "acceptFailed": "Failed to accept match", + "rejected": "Match rejected", + "rejectFailed": "Failed to reject match" + }, "previewEditor": { "noCandidates": "No candidates available for this record", "noComparableFields": "No comparable fields found", @@ -1769,7 +1786,9 @@ "missingIdError": "This scene requires an ID in the URL", "smartListNotFound": "Smart list not found", "viewCreateError": "Failed to create view", - "viewSaveError": "Failed to save view" + "viewSaveError": "Failed to save view", + "viewDeleteSuccess": "View deleted", + "viewDeleteError": "Failed to delete view" }, "header": { "backLink": "Lists" diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 236b9fd54..b4df97c44 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -293,6 +293,17 @@ } }, "createOrUpdateLibraryForm": { + "validation": { + "factorRange": "배율은 0보다 크고 1 이하여야 합니다", + "thumbnailQualityRange": "썸네일 품질은 0보다 크고 100 이하여야 합니다", + "invalidGlob": "올바른 glob 패턴을 입력하세요", + "nameRequired": "라이브러리 이름을 입력하세요", + "nameTaken": "{{name}} 이름의 라이브러리가 이미 있습니다.", + "pathRequired": "라이브러리 경로를 입력하세요", + "pathTaken": "다른 라이브러리에서 이미 사용 중인 경로입니다", + "pathParent": "기존 라이브러리의 상위 디렉터리는 사용할 수 없습니다", + "pathChild": "기존 라이브러리의 하위 디렉터리는 사용할 수 없습니다" + }, "fields": { "name": { "label": "이름", @@ -1314,6 +1325,12 @@ "excludeField": "이 필드 제외", "fieldLocked": "이 필드는 잠겨 있습니다", "reviewDialog": { + "notifications": { + "accepted": "일치 항목을 수락했습니다", + "acceptFailed": "일치 항목을 수락하지 못했습니다", + "rejected": "일치 항목을 거절했습니다", + "rejectFailed": "일치 항목을 거절하지 못했습니다" + }, "previewEditor": { "noCandidates": "이 레코드에 사용할 수 있는 후보가 없습니다", "noComparableFields": "비교할 수 있는 필드가 없습니다", @@ -1769,7 +1786,9 @@ "missingIdError": "이 화면의 URL에는 ID가 필요합니다", "smartListNotFound": "스마트 목록을 찾을 수 없습니다", "viewCreateError": "보기를 만들지 못했습니다", - "viewSaveError": "보기를 저장하지 못했습니다" + "viewSaveError": "보기를 저장하지 못했습니다", + "viewDeleteSuccess": "보기를 삭제했습니다", + "viewDeleteError": "보기를 삭제하지 못했습니다" }, "header": { "backLink": "목록" From c7b82cfcb4969f41e0fc9d3a931c83e1f049928d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 19:28:11 +0900 Subject: [PATCH 42/54] =?UTF-8?q?feat(i18n):=20=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20OPDS=EC=99=80=20=EC=A0=91=EA=B7=BC=EC=84=B1=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/expo/components/opds/CreditsSection.tsx | 6 ++-- apps/expo/components/opds/PublicationMenu.tsx | 2 +- apps/expo/components/opds/utils.ts | 32 +++++++++---------- .../components/opdsLegacy/FeedActionMenu.tsx | 2 +- .../selection/SelectionLeftScreenHeader.tsx | 6 ++-- .../selection/SelectionRightScreenHeader.tsx | 4 +-- .../smartList/SmartListActionMenu.tsx | 2 +- .../ui/action-menu/action-menu.ios.tsx | 6 +++- apps/expo/components/ui/picker-sheet.tsx | 6 ++-- apps/expo/components/ui/picker/picker.tsx | 6 ++-- packages/i18n/src/locales/en-US.json | 15 +++++++++ packages/i18n/src/locales/ko-KR.json | 15 +++++++++ 12 files changed, 72 insertions(+), 30 deletions(-) diff --git a/apps/expo/components/opds/CreditsSection.tsx b/apps/expo/components/opds/CreditsSection.tsx index a5cb96947..44191e26a 100644 --- a/apps/expo/components/opds/CreditsSection.tsx +++ b/apps/expo/components/opds/CreditsSection.tsx @@ -3,6 +3,7 @@ import { useMemo } from 'react' import { View } from 'react-native' import MetadataBadgeSection from '~/components/overview/MetadataBadgeSection' +import { useTranslate } from '~/lib/hooks' import { extractCredits, OPDSMetadataLinkableItem } from './utils' @@ -12,6 +13,7 @@ type Props = { } export default function CreditsSection({ metadata, onPressCredit }: Props) { + const { t } = useTranslate() const credits = useMemo(() => extractCredits(metadata), [metadata]) if (credits.length === 0) { @@ -22,8 +24,8 @@ export default function CreditsSection({ metadata, onPressCredit }: Props) { {credits.map((credit) => ( ({ label: item.label, onPress: onPressCredit ? () => onPressCredit(item) : undefined, diff --git a/apps/expo/components/opds/PublicationMenu.tsx b/apps/expo/components/opds/PublicationMenu.tsx index 40c98e2f7..381039d7a 100644 --- a/apps/expo/components/opds/PublicationMenu.tsx +++ b/apps/expo/components/opds/PublicationMenu.tsx @@ -52,7 +52,7 @@ export default function PublicationMenu({ publicationUrl, metadata }: Props) { 0) { credits.push({ - label: definition.label, + labelKey: definition.labelKey, items: uniqueItems, }) } diff --git a/apps/expo/components/opdsLegacy/FeedActionMenu.tsx b/apps/expo/components/opdsLegacy/FeedActionMenu.tsx index eea7951ca..96ba2f8db 100644 --- a/apps/expo/components/opdsLegacy/FeedActionMenu.tsx +++ b/apps/expo/components/opdsLegacy/FeedActionMenu.tsx @@ -48,7 +48,7 @@ export default function FeedActionMenu() { }} > state.isSelectAll()) const clearSelection = useSelectionStore((state) => state.clearSelection) const selectAll = useSelectionStore((state) => state.selectAll) @@ -22,7 +24,7 @@ export default function SelectionLeftScreenHeader() { const PressableChild = Platform.select({ ios: ( ), android: ( - + ), diff --git a/apps/expo/components/selection/SelectionRightScreenHeader.tsx b/apps/expo/components/selection/SelectionRightScreenHeader.tsx index 4d4556d7f..fdc64fdc0 100644 --- a/apps/expo/components/selection/SelectionRightScreenHeader.tsx +++ b/apps/expo/components/selection/SelectionRightScreenHeader.tsx @@ -75,7 +75,7 @@ export default function SelectionRightScreenHeader() { }} > ({ options, onValueChange, disabled = false, - placeholder = 'Select...', + placeholder, className, }: PickerProps) { + const { t } = useTranslate() const sheetRef = useRef(null) const colors = useColors() @@ -45,7 +47,7 @@ export function PickerSheet({ !selectedOption && 'text-foreground-subtle', )} > - {selectedOption?.label ?? placeholder} + {selectedOption?.label ?? placeholder ?? t('common.select')} diff --git a/apps/expo/components/ui/picker/picker.tsx b/apps/expo/components/ui/picker/picker.tsx index ee538c499..7b5bb0e22 100644 --- a/apps/expo/components/ui/picker/picker.tsx +++ b/apps/expo/components/ui/picker/picker.tsx @@ -2,6 +2,7 @@ import { ChevronsUpDown } from 'lucide-react-native' import { View } from 'react-native' import { usePortalHost } from '~/lib/PortalHostContext' +import { useTranslate } from '~/lib/hooks' import { cn } from '~/lib/utils' import { Button } from '../button' @@ -21,9 +22,10 @@ export function Picker({ options, onValueChange, disabled = false, - placeholder = 'Select...', + placeholder, className, }: PickerProps) { + const { t } = useTranslate() const portalHost = usePortalHost() const selectedOption = options.find((option) => option.value === value) @@ -41,7 +43,7 @@ export function Picker({ !selectedOption && 'text-foreground-subtle', )} > - {selectedOption?.label ?? placeholder} + {selectedOption?.label ?? placeholder ?? t('common.select')} diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index b57210fbc..ef40f4e3f 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -3859,6 +3859,21 @@ "noEntries": "No entries were returned for this feed", "currentPage": "Current Page", "authors": "Authors", + "credits": { + "authors": "Authors", + "writers": "Writers", + "artists": "Artists", + "pencilers": "Pencilers", + "inkers": "Inkers", + "colorists": "Colorists", + "letterers": "Letterers", + "coverArtists": "Cover Artists", + "editors": "Editors", + "translators": "Translators", + "contributors": "Contributors", + "illustrators": "Illustrators", + "narrators": "Narrators" + }, "updated": "Updated", "completed": "Completed", "lastRead": "Last read", diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index b4df97c44..66d0fe870 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -3859,6 +3859,21 @@ "noEntries": "이 피드에서 반환된 항목이 없습니다", "currentPage": "현재 페이지", "authors": "저자", + "credits": { + "authors": "저자", + "writers": "글 작가", + "artists": "작가", + "pencilers": "작화가", + "inkers": "잉크 담당", + "colorists": "채색가", + "letterers": "레터링 담당", + "coverArtists": "표지 작가", + "editors": "편집자", + "translators": "번역가", + "contributors": "기여자", + "illustrators": "삽화가", + "narrators": "낭독자" + }, "updated": "업데이트", "completed": "완독", "lastRead": "마지막 읽기", From f02ab44a4e499856d9ebc8e97c0cce7206fe93f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 19:30:00 +0900 Subject: [PATCH 43/54] =?UTF-8?q?feat(i18n):=20=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=EC=99=80=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=95=8C=EB=A6=BC=20=EA=B5=AD=EC=A0=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/expo/lib/hooks/db/downloadSeries.ts | 8 +++--- apps/expo/lib/hooks/sync/useAnnotationSync.ts | 8 +++--- apps/expo/lib/hooks/sync/useBookmarkSync.ts | 8 +++--- apps/expo/lib/import/importFile.ts | 11 +++++--- apps/expo/lib/import/useFileImportListener.ts | 27 +++++++++++-------- packages/i18n/src/locales/en-US.json | 19 +++++++++++++ packages/i18n/src/locales/ko-KR.json | 19 +++++++++++++ 7 files changed, 77 insertions(+), 23 deletions(-) diff --git a/apps/expo/lib/hooks/db/downloadSeries.ts b/apps/expo/lib/hooks/db/downloadSeries.ts index 6d4f50d17..3f655c186 100644 --- a/apps/expo/lib/hooks/db/downloadSeries.ts +++ b/apps/expo/lib/hooks/db/downloadSeries.ts @@ -6,6 +6,7 @@ import { toast } from 'sonner-native' import { useActiveServer } from '~/components/activeServer' import { useDownloadQueue } from './downloadQueue' +import { useTranslate } from '../useTranslate' const seriesBooksForDownloadQuery = graphql(` query SeriesBooksForDownload($id: ID!) { @@ -100,6 +101,7 @@ export type UseDownloadSeriesParams = { } export function useDownloadSeries({ serverId }: UseDownloadSeriesParams = {}) { + const { t } = useTranslate() const { activeServer } = useActiveServer() const effectiveServerId = serverId ?? activeServer.id @@ -138,14 +140,14 @@ export function useDownloadSeries({ serverId }: UseDownloadSeriesParams = {}) { const positions = await Promise.all(enqueuePromises) if (positions.every((pos) => pos === -1)) { - toast.info('Nothing to download', { - description: 'All books in this series are already downloaded or in the queue', + toast.info(t('entityActions.nothingToDownload'), { + description: t('entityActions.allBooksAlreadyDownloaded'), }) } return series.media.length }, - [enqueueBook, fetchSeriesBooks], + [enqueueBook, fetchSeriesBooks, t], ) return { diff --git a/apps/expo/lib/hooks/sync/useAnnotationSync.ts b/apps/expo/lib/hooks/sync/useAnnotationSync.ts index d49115a1b..5fcffb0da 100644 --- a/apps/expo/lib/hooks/sync/useAnnotationSync.ts +++ b/apps/expo/lib/hooks/sync/useAnnotationSync.ts @@ -14,6 +14,7 @@ import { ReadiumLocator } from '~/modules/readium' import { PushSyncParams, SyncParams } from './types' import { useServerInstances } from './utils' +import { useTranslate } from '../useTranslate' export function useAnnotationSync() { const { getInstances } = useServerInstances() @@ -65,6 +66,7 @@ type AutoSyncParams = { } export function useAutoSyncAnnotationsForActiveServer({ enabled = true }: AutoSyncParams = {}) { + const { t } = useTranslate() const { activeServer: { id: serverId }, } = useActiveServer() @@ -87,8 +89,8 @@ export function useAutoSyncAnnotationsForActiveServer({ enabled = true }: AutoSy Sentry.captureException(error, { extra: { serverId }, }) - toast.error('Failed to sync offline annotations', { - description: error instanceof Error ? error.message : 'Unknown error', + toast.error(t('annotationSync.syncFailed'), { + description: error instanceof Error ? error.message : t('errors.unknown'), }) } } @@ -99,7 +101,7 @@ export function useAutoSyncAnnotationsForActiveServer({ enabled = true }: AutoSy } // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled, serverId]), + }, [enabled, serverId, t]), ) } diff --git a/apps/expo/lib/hooks/sync/useBookmarkSync.ts b/apps/expo/lib/hooks/sync/useBookmarkSync.ts index 9157741d6..ca500133a 100644 --- a/apps/expo/lib/hooks/sync/useBookmarkSync.ts +++ b/apps/expo/lib/hooks/sync/useBookmarkSync.ts @@ -14,6 +14,7 @@ import { ReadiumLocator } from '~/modules/readium' import { PushSyncParams, SyncParams } from './types' import { useServerInstances } from './utils' +import { useTranslate } from '../useTranslate' export function useBookmarkSync() { const { getInstances } = useServerInstances() @@ -65,6 +66,7 @@ type AutoSyncParams = { } export function useAutoSyncBookmarksForActiveServer({ enabled = true }: AutoSyncParams = {}) { + const { t } = useTranslate() const { activeServer: { id: serverId }, } = useActiveServer() @@ -87,8 +89,8 @@ export function useAutoSyncBookmarksForActiveServer({ enabled = true }: AutoSync Sentry.captureException(error, { extra: { serverId }, }) - toast.error('Failed to sync offline bookmarks', { - description: error instanceof Error ? error.message : 'Unknown error', + toast.error(t('bookmarkSync.syncFailed'), { + description: error instanceof Error ? error.message : t('errors.unknown'), }) } } @@ -99,7 +101,7 @@ export function useAutoSyncBookmarksForActiveServer({ enabled = true }: AutoSync } // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled, serverId]), + }, [enabled, serverId, t]), ) } diff --git a/apps/expo/lib/import/importFile.ts b/apps/expo/lib/import/importFile.ts index 1c81f3def..835f35fc7 100644 --- a/apps/expo/lib/import/importFile.ts +++ b/apps/expo/lib/import/importFile.ts @@ -1,4 +1,5 @@ import { File } from 'expo-file-system' +import type { LocaleContextProps } from '@stump/i18n' import { DownloadRepository } from '~/db/downloads' import { booksDirectory, toAbsolutePath } from '~/lib/filesystem' @@ -25,6 +26,7 @@ export type ImportResult = */ export async function importLocalFile( externalUri: string, + t: LocaleContextProps['t'], originalFilename?: string, ): Promise { try { @@ -33,7 +35,10 @@ export async function importLocalFile( if (!isImportableFile(filename)) { return { success: false, - error: `Unsupported file type: ${filename}. Supported formats: EPUB, CBZ, PDF`, + error: t('fileImport.unsupportedFileType', { + filename, + formats: 'EPUB, CBZ, PDF', + }), } } @@ -42,7 +47,7 @@ export async function importLocalFile( if (!extension) { return { success: false, - error: `Could not determine file extension for file: ${filename}`, + error: t('fileImport.extensionUnknown', { filename }), } } const storedFilename = `${bookId}.${extension}` @@ -80,7 +85,7 @@ export async function importLocalFile( console.error('[importLocalFile] Failed to import file:', error) return { success: false, - error: error instanceof Error ? error.message : 'Unknown error during import', + error: t('fileImport.failedFallback'), } } } diff --git a/apps/expo/lib/import/useFileImportListener.ts b/apps/expo/lib/import/useFileImportListener.ts index e86885641..50d29d4ff 100644 --- a/apps/expo/lib/import/useFileImportListener.ts +++ b/apps/expo/lib/import/useFileImportListener.ts @@ -5,10 +5,12 @@ import { useCallback, useEffect, useRef } from 'react' import { Alert } from 'react-native' import { useDownloadsState } from '~/components/localLibrary/store' +import { useTranslate } from '~/lib/hooks/useTranslate' import { importLocalFile } from './importFile' export function useFileImportListener() { + const { t } = useTranslate() const increment = useDownloadsState((state) => state.increment) const processingRef = useRef(false) @@ -22,30 +24,33 @@ export function useFileImportListener() { processingRef.current = true try { - const result = await importLocalFile(url) + const result = await importLocalFile(url, t) if (result.success) { increment() - Alert.alert('File Imported', `"${result.filename}" has been added to your library.`, [ - { text: 'View Library', onPress: () => router.push('/library') }, - { text: 'OK' }, - ]) + Alert.alert( + t('fileImport.importedTitle'), + t('fileImport.importedDescription', { + filename: result.filename, + }), + [ + { text: t('fileImport.viewLibrary'), onPress: () => router.push('/library') }, + { text: t('fileImport.ok') }, + ], + ) } else { Sentry.captureMessage('File import failed', { extra: { url, error: result.error } }) - Alert.alert('Import Failed', result.error) + Alert.alert(t('fileImport.failedTitle'), result.error) } } catch (error) { Sentry.captureException(error, { extra: { url } }) - Alert.alert( - 'Import Error', - error instanceof Error ? error.message : 'Failed to import file', - ) + Alert.alert(t('fileImport.errorTitle'), t('fileImport.failedFallback')) } finally { processingRef.current = false } }, - [increment], + [increment, t], ) useEffect(() => { diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index ef40f4e3f..a7539d276 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -4006,6 +4006,17 @@ "dismissAll": "Dismiss All" } }, + "fileImport": { + "importedTitle": "File Imported", + "importedDescription": "\"{{filename}}\" has been added to your library.", + "viewLibrary": "View Library", + "ok": "OK", + "failedTitle": "Import Failed", + "errorTitle": "Import Error", + "failedFallback": "Failed to import file", + "unsupportedFileType": "Unsupported file type: {{filename}}. Supported formats: {{formats}}", + "extensionUnknown": "Could not determine file extension for file: {{filename}}" + }, "progressSync": { "skippedSync": { "skippedOneServer": "Sync skipped for {{skippedCount}} server", @@ -4014,6 +4025,12 @@ }, "syncFailed": "Failed to sync offline progress" }, + "annotationSync": { + "syncFailed": "Failed to sync offline annotations" + }, + "bookmarkSync": { + "syncFailed": "Failed to sync offline bookmarks" + }, "search": { "label": "Search This Server", "description": "Find specific content on this server", @@ -4046,6 +4063,8 @@ "scanSeries": "Scan Series", "downloadSeries": "Download Series", "downloadSeriesConfirmation": "Are you sure you want to enqueue the download for this entire series?", + "nothingToDownload": "Nothing to download", + "allBooksAlreadyDownloaded": "All books in this series are already downloaded or in the queue", "scanLibrary": "Scan Library" }, "libraryOverview": { diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 66d0fe870..b3a01fdfa 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -4006,6 +4006,17 @@ "dismissAll": "모두 닫기" } }, + "fileImport": { + "importedTitle": "파일을 가져왔습니다", + "importedDescription": "\"{{filename}}\"을(를) 라이브러리에 추가했습니다.", + "viewLibrary": "라이브러리 보기", + "ok": "확인", + "failedTitle": "가져오지 못했습니다", + "errorTitle": "가져오기 오류", + "failedFallback": "파일을 가져오지 못했습니다", + "unsupportedFileType": "지원하지 않는 파일 형식입니다: {{filename}}. 지원 형식: {{formats}}", + "extensionUnknown": "파일 확장자를 확인할 수 없습니다: {{filename}}" + }, "progressSync": { "skippedSync": { "skippedOneServer": "서버 {{skippedCount}}개의 동기화를 건너뛰었습니다", @@ -4014,6 +4025,12 @@ }, "syncFailed": "오프라인 진행률을 동기화하지 못했습니다" }, + "annotationSync": { + "syncFailed": "오프라인 주석을 동기화하지 못했습니다" + }, + "bookmarkSync": { + "syncFailed": "오프라인 북마크를 동기화하지 못했습니다" + }, "search": { "label": "이 서버 검색", "description": "이 서버에서 특정 콘텐츠를 찾습니다", @@ -4046,6 +4063,8 @@ "scanSeries": "시리즈 스캔", "downloadSeries": "시리즈 다운로드", "downloadSeriesConfirmation": "이 시리즈의 모든 책을 다운로드 대기열에 추가하시겠습니까?", + "nothingToDownload": "다운로드할 항목이 없습니다", + "allBooksAlreadyDownloaded": "이 시리즈의 모든 책이 이미 다운로드되었거나 대기열에 있습니다", "scanLibrary": "라이브러리 스캔" }, "libraryOverview": { From 57c81df9d61c205cb4a859a47e7f0438f9e64c79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 19:30:54 +0900 Subject: [PATCH 44/54] =?UTF-8?q?feat(i18n):=20=EC=8A=A4=EB=A7=88=ED=8A=B8?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=ED=95=84=ED=84=B0=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../smartList/createOrUpdate/schema.ts | 59 ++++++++++--------- packages/i18n/src/locales/en-US.json | 3 +- packages/i18n/src/locales/ko-KR.json | 3 +- 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/packages/browser/src/components/smartList/createOrUpdate/schema.ts b/packages/browser/src/components/smartList/createOrUpdate/schema.ts index 41c275b5d..905bbb487 100644 --- a/packages/browser/src/components/smartList/createOrUpdate/schema.ts +++ b/packages/browser/src/components/smartList/createOrUpdate/schema.ts @@ -103,32 +103,20 @@ export const dateField = z.enum(['createdAt', 'updatedAt', 'completedAt']) export type DateField = z.infer export const isDateField = (field: string): field is DateField => dateField.safeParse(field).success -export const filter = z - .object({ - id: z.string().optional(), - field: z.string(), - operation, - source: z.enum(['book', 'book_meta', 'series', 'series_meta', 'library']), - value: z.union([ - z.string(), - z.string().array(), - z.number(), - z.number().array(), - z.date(), - fromOperation, - ]), - }) - // strings may not use gt, gte, lt, lte, from - .refine( - (input) => - !( - stringField.safeParse(input.field).success && - ['gt', 'gte', 'lt', 'lte', 'from'].includes(input.operation) - ), - { - message: 'String fields may not use gt, gte, lt, lte, from', - }, - ) +export const filter = z.object({ + id: z.string().optional(), + field: z.string(), + operation, + source: z.enum(['book', 'book_meta', 'series', 'series_meta', 'library']), + value: z.union([ + z.string(), + z.string().array(), + z.number(), + z.number().array(), + z.date(), + fromOperation, + ]), +}) export type FilterSchema = z.infer export type FilterSource = FilterSchema['source'] @@ -230,9 +218,26 @@ export const createSchema = ( updatingList?: SmartListParsed, ) => { const forbiddenNames = existingNames.filter((name) => name !== updatingList?.name) + const localizedFilter = filter.refine( + (input) => + !( + stringField.safeParse(input.field).success && + ['gt', 'gte', 'lt', 'lte', 'from'].includes(input.operation) + ), + { message: t(validationKey('invalidStringOperation')) }, + ) + const localizedFilterGroup = z.object({ + filters: z.array(localizedFilter), + joiner: z.enum(['and', 'or', 'not']), + }) + const localizedFilterConfig = z.object({ + groups: z.array(localizedFilterGroup), + joiner: z.enum(['and', 'or']), + }) + return z.object({ description: z.string().optional(), - filters: filterConfig, + filters: localizedFilterConfig, grouping: grouping.optional(), name: z .string() diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index a7539d276..96dd8ebb7 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -1412,7 +1412,8 @@ "createOrUpdateSmartListForm": { "validation": { "nameTooShort": "Name must be at least 2 characters", - "nameTaken": "Cannot use this name" + "nameTaken": "Cannot use this name", + "invalidStringOperation": "String fields may not use numeric or range operators" }, "fields": { "name": { diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index b3a01fdfa..af45a6f75 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -1412,7 +1412,8 @@ "createOrUpdateSmartListForm": { "validation": { "nameTooShort": "이름은 2자 이상이어야 합니다", - "nameTaken": "이 이름은 사용할 수 없습니다" + "nameTaken": "이 이름은 사용할 수 없습니다", + "invalidStringOperation": "문자열 필드에는 숫자 비교 또는 범위 연산자를 사용할 수 없습니다" }, "fields": { "name": { From 6710d94fd2e6fcd4365b612534bf88434b830429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 19:35:44 +0900 Subject: [PATCH 45/54] =?UTF-8?q?feat(i18n):=20=EC=9B=B9=20=EB=B9=88=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=99=80=20=EB=B3=B4=EC=A1=B0=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/browser/src/components/TagSelect.tsx | 4 ++- .../components/book/table/CoverImageCell.tsx | 4 ++- .../components/markdown/MarkdownPreview.tsx | 4 ++- .../metadataEditor/cells/LockFieldButton.tsx | 6 ++++- .../epub/controls/FullScreenToggle.tsx | 4 ++- .../imageBased/container/ReaderFooter.tsx | 13 +++++----- .../components/series/LibrarySeriesCard.tsx | 6 ++--- .../series/table/CoverImageCell.tsx | 4 ++- packages/browser/src/hooks/useCoreEvent.ts | 13 ++++++---- .../src/scenes/bookSearch/BookSearchScene.tsx | 8 +++--- .../browser/src/scenes/home/HomeScene.tsx | 4 ++- .../library/tabs/books/LibraryBooksScene.tsx | 10 ++++--- .../tabs/series/LibrarySeriesScene.tsx | 10 ++++--- .../series/tabs/books/SeriesBooksScene.tsx | 10 ++++--- .../smartList/items/table/CoverImageCell.tsx | 4 ++- packages/i18n/src/locales/en-US.json | 26 ++++++++++++++++++- packages/i18n/src/locales/ko-KR.json | 26 ++++++++++++++++++- 17 files changed, 116 insertions(+), 40 deletions(-) diff --git a/packages/browser/src/components/TagSelect.tsx b/packages/browser/src/components/TagSelect.tsx index 97f1e078c..b9df13113 100644 --- a/packages/browser/src/components/TagSelect.tsx +++ b/packages/browser/src/components/TagSelect.tsx @@ -1,6 +1,7 @@ import { useSDK, useSuspenseGraphQL } from '@stump/client' import { ComboBox } from '@stump/components' import { graphql } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useCallback, useEffect, useState } from 'react' const query = graphql(` @@ -25,6 +26,7 @@ type Props = { } export default function TagSelect({ label, description, selected = [], onChange }: Props) { + const { t } = useLocaleContext() const { sdk } = useSDK() const { data: { tags }, @@ -74,7 +76,7 @@ export default function TagSelect({ label, description, selected = [], onChange return ( a.label.localeCompare(b.label)).map(({ value }) => value)} diff --git a/packages/browser/src/components/book/table/CoverImageCell.tsx b/packages/browser/src/components/book/table/CoverImageCell.tsx index f7ed0b528..fe63f2e42 100644 --- a/packages/browser/src/components/book/table/CoverImageCell.tsx +++ b/packages/browser/src/components/book/table/CoverImageCell.tsx @@ -1,5 +1,6 @@ import { useSDK } from '@stump/client' import { Book } from 'lucide-react' +import { useLocaleContext } from '@stump/i18n' import { useState } from 'react' import { EntityImage } from '@/components/entity' @@ -11,6 +12,7 @@ type Props = { } export default function CoverImageCell({ id, title }: Props) { + const { t } = useLocaleContext() const { sdk } = useSDK() const { preferences: { thumbnailRatio }, @@ -41,7 +43,7 @@ export default function CoverImageCell({ id, title }: Props) { if (showFallback) { return (
) => ( * A spoiler component, e.g. :spoiler[hidden text] */ const Spoiler = ({ children }: PropsWithChildren) => { + const { t } = useLocaleContext() const [isSpoiler, setIsSpoiler] = useState(true) return ( @@ -70,7 +72,7 @@ const Spoiler = ({ children }: PropsWithChildren) => { }, )} onClick={() => setIsSpoiler(!isSpoiler)} - title={isSpoiler ? 'Click to reveal' : 'Click to hide'} + title={t(`markdownPreview.${isSpoiler ? 'reveal' : 'hide'}`)} > {children} diff --git a/packages/browser/src/components/metadata/metadataEditor/cells/LockFieldButton.tsx b/packages/browser/src/components/metadata/metadataEditor/cells/LockFieldButton.tsx index e1f3cbc2b..bd0af6696 100644 --- a/packages/browser/src/components/metadata/metadataEditor/cells/LockFieldButton.tsx +++ b/packages/browser/src/components/metadata/metadataEditor/cells/LockFieldButton.tsx @@ -1,4 +1,5 @@ import { cn } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { Lock, LockOpen } from 'lucide-react' import { BINDING_TO_METADATA_FIELD } from '../../fieldDefs' @@ -9,6 +10,7 @@ type Props = { } export default function LockFieldButton({ binding }: Props) { + const { t } = useLocaleContext() const { lockedFields, onToggleLock } = useMetadataEditorContext() const metadataField = BINDING_TO_METADATA_FIELD[binding] @@ -30,7 +32,9 @@ export default function LockFieldButton({ binding }: Props) { ? 'text-muted-foreground opacity-100' : 'text-muted-foreground/50 opacity-0 group-hover/row:opacity-100', )} - title={isLocked ? 'Unlock field' : 'Lock field'} + title={t( + `metadataMatching.reviewDialog.fieldAction.${isLocked ? 'unlockField' : 'lockField'}`, + )} > {isLocked ? : } diff --git a/packages/browser/src/components/readers/epub/controls/FullScreenToggle.tsx b/packages/browser/src/components/readers/epub/controls/FullScreenToggle.tsx index d013e5a3f..799796fdb 100644 --- a/packages/browser/src/components/readers/epub/controls/FullScreenToggle.tsx +++ b/packages/browser/src/components/readers/epub/controls/FullScreenToggle.tsx @@ -1,13 +1,15 @@ import { Fullscreen } from 'lucide-react' +import { useLocaleContext } from '@stump/i18n' import { useEpubReaderControls } from '../context' import ControlButton from './ControlButton' export default function FullScreenToggle() { + const { t } = useLocaleContext() const { fullscreen, setFullscreen } = useEpubReaderControls() return ( - + setFullscreen(!fullscreen)} /> ) diff --git a/packages/browser/src/components/readers/imageBased/container/ReaderFooter.tsx b/packages/browser/src/components/readers/imageBased/container/ReaderFooter.tsx index eacbdbae1..303720772 100644 --- a/packages/browser/src/components/readers/imageBased/container/ReaderFooter.tsx +++ b/packages/browser/src/components/readers/imageBased/container/ReaderFooter.tsx @@ -184,12 +184,13 @@ export default function ReaderFooter() { )} - {[...currentSet] - .map((idx) => idx + 1) - .sort((a, b) => a - b) - .join('-')} - {' of '} - {book.pages} + {t('readerUi.pageXOfY', { + current: [...currentSet] + .map((idx) => idx + 1) + .sort((a, b) => a - b) + .join('-'), + total: book.pages, + })}
diff --git a/packages/browser/src/components/series/LibrarySeriesCard.tsx b/packages/browser/src/components/series/LibrarySeriesCard.tsx index dd1be8138..898e4a3fd 100644 --- a/packages/browser/src/components/series/LibrarySeriesCard.tsx +++ b/packages/browser/src/components/series/LibrarySeriesCard.tsx @@ -1,10 +1,9 @@ import { LibrarySeriesQuery } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { memo, useEffect, useRef, useState } from 'react' import { StackedSeriesCard } from '@/components/series' -import pluralizeStat from '../../utils/pluralize' - export type LibrarySeriesCardData = LibrarySeriesQuery['series']['nodes'][number] type Props = { @@ -12,6 +11,7 @@ type Props = { } const LibrarySeriesCard = memo(function LibrarySeriesCard({ data }: Props) { + const { t } = useLocaleContext() const containerRef = useRef(null) const [width, setWidth] = useState(null) @@ -44,7 +44,7 @@ const LibrarySeriesCard = memo(function LibrarySeriesCard({ data }: Props) { ({ addJob: state.addJob, @@ -80,8 +82,8 @@ export function useCoreEvent({ liveRefetch, onConnectionWithServerChanged }: Par const onPayloadReceived = useCallback( (payload: UseCoreEventSubscription) => - eventHandler(payload.readEvents, { store, client, sdk, liveRefetch }), - [store, client, sdk, liveRefetch], + eventHandler(payload.readEvents, { store, client, sdk, liveRefetch, t }), + [store, client, sdk, liveRefetch, t], ) const [, dispose] = useGraphQLSubscription(subscription, { @@ -117,12 +119,13 @@ type EventHandlerParams = { } client: QueryClient sdk: Api + t: LocaleContextProps['t'] liveRefetch?: boolean } const eventHandler = async ( event: UseCoreEventSubscription['readEvents'], - { store, client, sdk, liveRefetch }: EventHandlerParams, + { store, client, sdk, liveRefetch, t }: EventHandlerParams, ) => { const { __typename } = event @@ -136,7 +139,7 @@ const eventHandler = async ( store.removeJob(event.id) await client.invalidateQueries({ queryKey: [sdk.cacheKeys.jobs], exact: false }) if (event.status === JobStatus.Completed) { - toast.success(event.message || 'Job completed') + toast.success(event.message || t('common.jobCompleted')) } } else { store.upsertJob(event) @@ -171,7 +174,7 @@ const eventHandler = async ( const handleJobOutput = async ( { output }: Extract, - { client, sdk }: Omit, + { client, sdk }: Omit, ) => { const affectedBooks = match(output) .with( diff --git a/packages/browser/src/scenes/bookSearch/BookSearchScene.tsx b/packages/browser/src/scenes/bookSearch/BookSearchScene.tsx index 27abe8404..e88d474de 100644 --- a/packages/browser/src/scenes/bookSearch/BookSearchScene.tsx +++ b/packages/browser/src/scenes/bookSearch/BookSearchScene.tsx @@ -279,13 +279,13 @@ function BookSearchScene() { 0 - ? 'No books match your search' - : "It doesn't look like there are any books here" + ? t('entityListEmpty.books.noMatchesTitle') + : t('entityListEmpty.books.emptyTitle') } subtitle={ Object.keys(filters || {}).length > 0 - ? 'Try removing some filters to see more books' - : 'Do you have any books in your library?' + ? t('entityListEmpty.books.noMatchesSubtitle') + : t('entityListEmpty.books.emptySubtitle') } />
diff --git a/packages/browser/src/scenes/home/HomeScene.tsx b/packages/browser/src/scenes/home/HomeScene.tsx index 36ec417c8..25afc7576 100644 --- a/packages/browser/src/scenes/home/HomeScene.tsx +++ b/packages/browser/src/scenes/home/HomeScene.tsx @@ -1,5 +1,6 @@ import { useSDK, useSuspenseGraphQL } from '@stump/client' import { graphql } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { Helmet } from 'react-helmet' import { SceneContainer } from '@/components/container' @@ -33,13 +34,14 @@ export const usePrefetchHomeScene = () => { // TODO: account for new accounts, i.e. no media at all export default function HomeScene() { + const { t } = useLocaleContext() const { sdk } = useSDK() const { data } = useSuspenseGraphQL(query, sdk.cacheKey('numberOfLibraries')) const helmet = ( {/* Doing this so Helmet splits the title into an array, I'm not just insane lol */} - Stump | {'Home'} + Stump | {t('homeScene.pageTitle')} ) diff --git a/packages/browser/src/scenes/library/tabs/books/LibraryBooksScene.tsx b/packages/browser/src/scenes/library/tabs/books/LibraryBooksScene.tsx index 11b393b77..e671e9521 100644 --- a/packages/browser/src/scenes/library/tabs/books/LibraryBooksScene.tsx +++ b/packages/browser/src/scenes/library/tabs/books/LibraryBooksScene.tsx @@ -1,6 +1,7 @@ import { PREFETCH_STALE_TIME, useGraphQL, useSDK } from '@stump/client' import { usePrevious, usePreviousIsDifferent } from '@stump/components' import { graphql, InterfaceLayout, MediaFilterInput, MediaOrderBy } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useQueryClient } from '@tanstack/react-query' import { Suspense, useCallback, useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet' @@ -145,6 +146,7 @@ function getQueryKey( } function LibraryBooksScene() { + const { t } = useLocaleContext() const { library } = useLibraryContext() const { filters: mediaFilters, @@ -318,13 +320,13 @@ function LibraryBooksScene() { 0 - ? 'No books match your search' - : "It doesn't look like there are any books here" + ? t('entityListEmpty.books.noMatchesTitle') + : t('entityListEmpty.books.emptyTitle') } subtitle={ Object.keys(filters || {}).length > 0 - ? 'Try removing some filters to see more books' - : 'Do you have any books in your library?' + ? t('entityListEmpty.books.noMatchesSubtitle') + : t('entityListEmpty.books.emptySubtitle') } />
diff --git a/packages/browser/src/scenes/library/tabs/series/LibrarySeriesScene.tsx b/packages/browser/src/scenes/library/tabs/series/LibrarySeriesScene.tsx index 016c766c8..d9913ad54 100644 --- a/packages/browser/src/scenes/library/tabs/series/LibrarySeriesScene.tsx +++ b/packages/browser/src/scenes/library/tabs/series/LibrarySeriesScene.tsx @@ -8,6 +8,7 @@ import { SeriesModelOrdering, SeriesOrderBy, } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet' @@ -185,6 +186,7 @@ function getQueryKey( } export default function LibrarySeriesScene() { + const { t } = useLocaleContext() const { library: { id, name }, } = useLibraryContext() @@ -352,13 +354,13 @@ export default function LibrarySeriesScene() { 0 - ? 'No series match your search' - : "It doesn't look like there are any series here" + ? t('entityListEmpty.series.noMatchesTitle') + : t('entityListEmpty.series.emptyTitle') } subtitle={ Object.keys(filters || {}).length > 0 - ? 'Try removing some filters to see more series' - : 'Do you have any series in your library?' + ? t('entityListEmpty.series.noMatchesSubtitle') + : t('entityListEmpty.series.emptySubtitle') } />
diff --git a/packages/browser/src/scenes/series/tabs/books/SeriesBooksScene.tsx b/packages/browser/src/scenes/series/tabs/books/SeriesBooksScene.tsx index 44b22034f..2796afd1c 100644 --- a/packages/browser/src/scenes/series/tabs/books/SeriesBooksScene.tsx +++ b/packages/browser/src/scenes/series/tabs/books/SeriesBooksScene.tsx @@ -1,6 +1,7 @@ import { PREFETCH_STALE_TIME, useGraphQL, useSDK } from '@stump/client' import { usePrevious } from '@stump/components' import { graphql, InterfaceLayout, MediaFilterInput, MediaOrderBy } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useQueryClient } from '@tanstack/react-query' import { Suspense, useCallback, useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet' @@ -138,6 +139,7 @@ function getQueryKey( } function SeriesBooksScene() { + const { t } = useLocaleContext() const { series } = useSeriesContext() const { filters: mediaFilters, @@ -310,13 +312,13 @@ function SeriesBooksScene() { 0 - ? 'No books match your search' - : "It doesn't look like there are any books here" + ? t('entityListEmpty.books.noMatchesTitle') + : t('entityListEmpty.books.emptyTitle') } subtitle={ Object.keys(filters || {}).length > 0 - ? 'Try removing some filters to see more books' - : 'Do you have any books in your library?' + ? t('entityListEmpty.books.noMatchesSubtitle') + : t('entityListEmpty.books.emptySubtitle') } /> diff --git a/packages/browser/src/scenes/smartList/items/table/CoverImageCell.tsx b/packages/browser/src/scenes/smartList/items/table/CoverImageCell.tsx index cda04a3c9..b27481dd1 100644 --- a/packages/browser/src/scenes/smartList/items/table/CoverImageCell.tsx +++ b/packages/browser/src/scenes/smartList/items/table/CoverImageCell.tsx @@ -1,5 +1,6 @@ import { useSDK } from '@stump/client' import { Book } from 'lucide-react' +import { useLocaleContext } from '@stump/i18n' import { useState } from 'react' import { EntityImage } from '@/components/entity' @@ -10,6 +11,7 @@ type Props = { title?: string } export default function CoverImageCell({ id, title }: Props) { + const { t } = useLocaleContext() const { sdk } = useSDK() const { preferences: { thumbnailRatio }, @@ -40,7 +42,7 @@ export default function CoverImageCell({ id, title }: Props) { if (showFallback) { return (
Date: Thu, 6 Aug 2026 19:36:48 +0900 Subject: [PATCH 46/54] =?UTF-8?q?feat(i18n):=20=EC=9B=B9=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=EC=99=80=20=EC=9E=91=EC=97=85=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/danger/deletion/CleanLibrary.tsx | 7 ++-- .../settings/app/general/AvatarPicker.tsx | 35 ++++++++++++------- .../server/general/ServerPublicURL.tsx | 2 +- .../items/table/CreateOrUpdateTableView.tsx | 13 ++++--- packages/i18n/src/locales/en-US.json | 6 +++- packages/i18n/src/locales/ko-KR.json | 6 +++- 6 files changed, 44 insertions(+), 25 deletions(-) diff --git a/packages/browser/src/scenes/library/tabs/settings/danger/deletion/CleanLibrary.tsx b/packages/browser/src/scenes/library/tabs/settings/danger/deletion/CleanLibrary.tsx index 3d68faabc..487fb7463 100644 --- a/packages/browser/src/scenes/library/tabs/settings/danger/deletion/CleanLibrary.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/danger/deletion/CleanLibrary.tsx @@ -65,7 +65,10 @@ export default function CleanLibrary() { } else if (result.deletedMediaCount === 0 && result.deletedSeriesCount === 0) { return t(getKey('confirmation.nothingToDelete')) } else { - return `Cleaned ${result.deletedMediaCount} media and ${result.deletedSeriesCount} series` + return t(getKey('cleaned'), { + mediaCount: result.deletedMediaCount, + seriesCount: result.deletedSeriesCount, + }) } }, error: (error) => { @@ -79,7 +82,7 @@ export default function CleanLibrary() { setShowConfirmation(false) } catch (error) { console.error(error) - const fallbackMessage = 'An error occurred while cleaning the library' + const fallbackMessage = t(getKey('confirmation.error')) if (error instanceof Error) { toast.error(error.message || fallbackMessage) } else { diff --git a/packages/browser/src/scenes/settings/app/general/AvatarPicker.tsx b/packages/browser/src/scenes/settings/app/general/AvatarPicker.tsx index 4e97c63f6..8a7aa2193 100644 --- a/packages/browser/src/scenes/settings/app/general/AvatarPicker.tsx +++ b/packages/browser/src/scenes/settings/app/general/AvatarPicker.tsx @@ -64,19 +64,28 @@ export default function AvatarPicker() { }, }) - const onDrop = useCallback((acceptedFiles: File[], fileRejections: FileRejection[]) => { - if (fileRejections.length > 0) { - const firstError = fileRejections[0]?.errors[0] - const isTooLarge = firstError?.code === 'file-too-large' - toast.error(isTooLarge ? 'File too large (20MB max)' : firstError?.message || 'Unknown error') - } else if (acceptedFiles.length !== 1 || !acceptedFiles[0]) { - toast.error(acceptedFiles.length ? 'Only 1 file allowed' : 'No files provided') - } else { - const file = acceptedFiles[0] - setSelectedFile(file) - setFilePreview(URL.createObjectURL(file)) - } - }, []) + const onDrop = useCallback( + (acceptedFiles: File[], fileRejections: FileRejection[]) => { + if (fileRejections.length > 0) { + const firstError = fileRejections[0]?.errors[0] + const isTooLarge = firstError?.code === 'file-too-large' + toast.error( + isTooLarge + ? t('thumbnailDropdown.uploadImage.fileTooLarge') + : t('common.fileUpload.noValidFiles'), + ) + } else if (acceptedFiles.length !== 1 || !acceptedFiles[0]) { + toast.error( + t(`thumbnailDropdown.uploadImage.${acceptedFiles.length ? 'onlyOneFile' : 'noFile'}`), + ) + } else { + const file = acceptedFiles[0] + setSelectedFile(file) + setFilePreview(URL.createObjectURL(file)) + } + }, + [t], + ) const { getRootProps, getInputProps, isDragActive, isFileDialogActive } = useDropzone({ accept: { 'image/*': [] }, diff --git a/packages/browser/src/scenes/settings/server/general/ServerPublicURL.tsx b/packages/browser/src/scenes/settings/server/general/ServerPublicURL.tsx index f2b872a2c..33a92d4cd 100644 --- a/packages/browser/src/scenes/settings/server/general/ServerPublicURL.tsx +++ b/packages/browser/src/scenes/settings/server/general/ServerPublicURL.tsx @@ -43,7 +43,7 @@ export default function ServerPublicURL() { }, onError: (error) => { toast.error(t('settingsUi.publicUrlUpdateFailed'), { - description: error instanceof Error ? error.message : 'An unknown error occurred', + description: error instanceof Error ? error.message : t('common.unknownError'), }) }, }) diff --git a/packages/browser/src/scenes/smartList/items/table/CreateOrUpdateTableView.tsx b/packages/browser/src/scenes/smartList/items/table/CreateOrUpdateTableView.tsx index b6d967841..f1b0011bd 100644 --- a/packages/browser/src/scenes/smartList/items/table/CreateOrUpdateTableView.tsx +++ b/packages/browser/src/scenes/smartList/items/table/CreateOrUpdateTableView.tsx @@ -64,6 +64,9 @@ export default function CreateOrUpdateTableView({ isCreating, isOpen, onClose }: */ const handleSubmit = useCallback( async ({ name }: z.infer>) => { + const fallbackMessage = t( + `userSmartListScene.layout.${isCreating ? 'viewCreateError' : 'viewSaveError'}`, + ) try { if (isCreating) { await saveWorkingView(name) @@ -74,17 +77,13 @@ export default function CreateOrUpdateTableView({ isCreating, isOpen, onClose }: } catch (error) { console.error(error) if (error instanceof Error) { - toast.error( - `Failed to ${isCreating ? 'create' : 'update'} view${ - error.message ? `: ${error.message}` : '' - }`, - ) + toast.error(`${fallbackMessage}${error.message ? `: ${error.message}` : ''}`) } else { - toast.error(`Failed to ${isCreating ? 'create' : 'update'} view`) + toast.error(fallbackMessage) } } }, - [isCreating, saveWorkingView, saveSelectedStoredView, onClose], + [isCreating, saveWorkingView, saveSelectedStoredView, onClose, t], ) return ( diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index 2f6f668ba..1b622bb4a 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -1175,9 +1175,13 @@ "disclaimer": "While this action will not delete any files, it will permanently delete all records relating to media and series which have a missing status", "confirmation": { "label": "Clean library", - "text": "Are you sure you want to clean this library?" + "text": "Are you sure you want to clean this library?", + "loading": "Cleaning library", + "nothingToDelete": "There was nothing to delete", + "error": "An error occurred while cleaning the library" }, "emptyText": "There was nothing to clean", + "cleaned": "Cleaned {{mediaCount}} media and {{seriesCount}} series", "missingEntitiesTable": { "columns": { "id": "ID", diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index e7edd540b..062b0e3fa 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -1175,9 +1175,13 @@ "disclaimer": "파일 자체는 삭제하지 않지만, 누락 상태인 미디어와 시리즈에 관련된 모든 레코드는 영구적으로 삭제합니다", "confirmation": { "label": "라이브러리 정리", - "text": "이 라이브러리를 정리하시겠습니까?" + "text": "이 라이브러리를 정리하시겠습니까?", + "loading": "라이브러리 정리 중", + "nothingToDelete": "삭제할 항목이 없습니다", + "error": "라이브러리를 정리하는 중 오류가 발생했습니다" }, "emptyText": "정리할 항목이 없습니다", + "cleaned": "미디어 {{mediaCount}}개와 시리즈 {{seriesCount}}개를 정리했습니다", "missingEntitiesTable": { "columns": { "id": "ID", From 14e225831109fa0fa31c5bcbf43153279f7504e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 19:39:02 +0900 Subject: [PATCH 47/54] =?UTF-8?q?feat(i18n):=20=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20OPDS=EC=99=80=20=ED=86=A0=EB=A1=A0=20=EB=B3=B4=EC=A1=B0=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=20=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/expo/app/opds/[id]/publication/index.tsx | 4 ++-- apps/expo/components/bookClub/discussion/DiscussionRoom.tsx | 4 +++- packages/i18n/src/locales/en-US.json | 1 + packages/i18n/src/locales/ko-KR.json | 1 + 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/expo/app/opds/[id]/publication/index.tsx b/apps/expo/app/opds/[id]/publication/index.tsx index 04e8934d8..83b280515 100644 --- a/apps/expo/app/opds/[id]/publication/index.tsx +++ b/apps/expo/app/opds/[id]/publication/index.tsx @@ -66,10 +66,10 @@ export default function Screen() { const navigation = useNavigation() useLayoutEffect(() => { navigation.setOptions({ - title: title || 'Publication', + title: title || t('common.publication'), // headerRight: () => , }) - }, [navigation, url, title, metadata]) + }, [navigation, url, title, metadata, t]) const menuFragment = usePublicationMenu({ publicationUrl: url, diff --git a/apps/expo/components/bookClub/discussion/DiscussionRoom.tsx b/apps/expo/components/bookClub/discussion/DiscussionRoom.tsx index a509ea302..77bdfabc0 100644 --- a/apps/expo/components/bookClub/discussion/DiscussionRoom.tsx +++ b/apps/expo/components/bookClub/discussion/DiscussionRoom.tsx @@ -3,6 +3,7 @@ import { useCallback, useRef, useState } from 'react' import { View } from 'react-native' import type { EmojiSelection } from '~/components/emoji/types' +import { useTranslate } from '~/lib/hooks' import Message, { type MessageData } from './Message' import { MessageActionSheet, type MessageActionSheetRef } from './MessageActionSheet' @@ -36,6 +37,7 @@ export default function DiscussionRoom({ listHeader, parentMessageId, }: DiscussionRoomProps) { + const { t } = useTranslate() const actionSheetRef = useRef(null) const [replyingTo, setReplyingTo] = useState(null) @@ -88,7 +90,7 @@ export default function DiscussionRoom({ onSend={handleSend} isSending={isSending} isLocked={isLocked} - placeholder={`Message #${name}...`} + placeholder={t('bookClub.messageRoomPlaceholder', { name })} parentMessageId={parentMessageId} replyingTo={replyingTo} onCancelReply={() => setReplyingTo(null)} diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index 1b622bb4a..08c57cc6c 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -3808,6 +3808,7 @@ "discussionLocked": "This discussion is locked.", "replyingTo": "Replying to {{name}}", "messagePlaceholder": "Type a message...", + "messageRoomPlaceholder": "Message #{{name}}...", "settingsNotImplemented": "Book club settings are not implemented yet", "currentlyReading": "Currently reading" }, diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 062b0e3fa..0710d722f 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -3808,6 +3808,7 @@ "discussionLocked": "이 토론은 잠겨 있습니다.", "replyingTo": "{{name}} 님에게 답장", "messagePlaceholder": "메시지 입력...", + "messageRoomPlaceholder": "#{{name}}에 메시지 입력...", "settingsNotImplemented": "북클럽 설정은 아직 구현되지 않았습니다", "currentlyReading": "현재 읽는 책" }, From 58f684e26686eea8f190a5af1213db64395b92d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 19:42:05 +0900 Subject: [PATCH 48/54] =?UTF-8?q?fix(i18n):=20=EC=9E=98=EB=AA=BB=EB=90=9C?= =?UTF-8?q?=20=EB=B2=88=EC=97=AD=20=ED=82=A4=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../activeServer/home/RecentlyAddedBooks.tsx | 2 +- .../src/scenes/error/ServerConnectionErrorScene.tsx | 2 +- .../library/tabs/settings/LibrarySettingsRouter.tsx | 4 ++-- .../settings/server/general/ServerEmojisSection.tsx | 10 +++++----- packages/i18n/src/locales/en-US.json | 2 ++ packages/i18n/src/locales/ko-KR.json | 2 ++ 6 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/expo/components/activeServer/home/RecentlyAddedBooks.tsx b/apps/expo/components/activeServer/home/RecentlyAddedBooks.tsx index 4f6d5f513..7628876b9 100644 --- a/apps/expo/components/activeServer/home/RecentlyAddedBooks.tsx +++ b/apps/expo/components/activeServer/home/RecentlyAddedBooks.tsx @@ -81,7 +81,7 @@ function RecentlyAddedBooks() { ItemSeparatorComponent={() => } ListEmptyComponent={ - {t('stumpServer.recentlyAddedBooks.emptyText)')} + {t('stumpServer.recentlyAddedBooks.emptyText')} } /> diff --git a/packages/browser/src/scenes/error/ServerConnectionErrorScene.tsx b/packages/browser/src/scenes/error/ServerConnectionErrorScene.tsx index 1a9504edc..0cf2a4561 100644 --- a/packages/browser/src/scenes/error/ServerConnectionErrorScene.tsx +++ b/packages/browser/src/scenes/error/ServerConnectionErrorScene.tsx @@ -61,7 +61,7 @@ export default function ServerConnectionErrorScene() { toast.promise( new Promise((resolve) => setTimeout(resolve, 2000)).then(() => setGoHome(true)), { - error: t('serverSOS.reconnectFailed'), + error: t('serverSOS.reconnectionFailed'), loading: t('serverSOS.reconnected'), success: t('serverSOS.reconnected'), }, diff --git a/packages/browser/src/scenes/library/tabs/settings/LibrarySettingsRouter.tsx b/packages/browser/src/scenes/library/tabs/settings/LibrarySettingsRouter.tsx index 04eb9dbb7..914965e5d 100644 --- a/packages/browser/src/scenes/library/tabs/settings/LibrarySettingsRouter.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/LibrarySettingsRouter.tsx @@ -114,7 +114,7 @@ export default function LibrarySettingsRouter() { }) }, onError: (error) => { - toast.error(t('createOrUpdateLibrary.errors.failedToUpdate'), { + toast.error(t('createOrUpdateLibraryForm.errors.failedToUpdate'), { description: extractErrorMessage(error), }) }, @@ -122,7 +122,7 @@ export default function LibrarySettingsRouter() { const { mutate: scan } = useGraphQLMutation(scanMutation, { onError: (error) => { - toast.error(t('createOrUpdateLibrary.errors.failedToScan'), { + toast.error(t('createOrUpdateLibraryForm.errors.failedToScan'), { description: extractErrorMessage(error), }) }, diff --git a/packages/browser/src/scenes/settings/server/general/ServerEmojisSection.tsx b/packages/browser/src/scenes/settings/server/general/ServerEmojisSection.tsx index a2e5e5a56..ae95c61fb 100644 --- a/packages/browser/src/scenes/settings/server/general/ServerEmojisSection.tsx +++ b/packages/browser/src/scenes/settings/server/general/ServerEmojisSection.tsx @@ -155,7 +155,7 @@ export default function ServerEmojisSection() { const name = sanitizedName(emojiName) if (!name) { - toast.error(t('customEmojis.invalidName')) + toast.error(t(getKey('invalidName'))) return } @@ -201,11 +201,11 @@ export default function ServerEmojisSection() { id: String(renamingId), input: { name }, }) - toast.success(t('customEmojis.renameSuccess')) + toast.success(t(getKey('renameSuccess'))) cancelRename() } catch (error) { console.error(error) - toast.error(t('customEmojis.renameError'), { + toast.error(t(getKey('renameError')), { description: error instanceof Error ? error.message : undefined, }) } @@ -216,11 +216,11 @@ export default function ServerEmojisSection() { try { await deleteEmoji({ id: String(deletingEmoji.id) }) - toast.success(t('customEmojis.deleteSuccess')) + toast.success(t(getKey('deleteSuccess'))) setDeletingEmoji(null) } catch (error) { console.error(error) - toast.error(t('customEmojis.deleteError'), { + toast.error(t(getKey('deleteError')), { description: error instanceof Error ? error.message : undefined, }) } diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index 08c57cc6c..d36e3fa75 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -2785,6 +2785,7 @@ }, "validation": { "ageRestrictionTooLow": "Age restriction cannot be less than 0", + "maxSessionsAllowedTooLow": "Maximum sessions allowed cannot be less than 0", "missingUsername": "Username is required", "missingPassword": "Password is required", "usernameAlreadyExists": "Username already exists" @@ -3679,6 +3680,7 @@ "thisBook": "this book", "thisServer": "this server", "unknown": "Unknown", + "unknownError": "An unknown error occurred", "unknownTitle": "Untitled", "url": "URL", "username": "Username", diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 0710d722f..fd08debad 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -2785,6 +2785,7 @@ }, "validation": { "ageRestrictionTooLow": "연령 제한은 0보다 작을 수 없습니다", + "maxSessionsAllowedTooLow": "허용할 최대 세션 수는 0보다 작을 수 없습니다", "missingUsername": "사용자 이름을 입력하세요", "missingPassword": "비밀번호를 입력하세요", "usernameAlreadyExists": "이미 사용 중인 사용자 이름입니다" @@ -3679,6 +3680,7 @@ "thisBook": "이 책", "thisServer": "이 서버", "unknown": "알 수 없음", + "unknownError": "알 수 없는 오류가 발생했습니다", "unknownTitle": "제목 없음", "url": "URL", "username": "사용자 이름", From f8cb662a4404427653586f92be9e16f65402449b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 19:48:06 +0900 Subject: [PATCH 49/54] =?UTF-8?q?feat(i18n):=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B0=20=EB=AC=B8=EA=B5=AC=EC=99=80=20=EA=B0=9C?= =?UTF-8?q?=EC=88=98=20=ED=91=9C=ED=98=84=20=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/expo/app/opds/[id]/publication/index.tsx | 2 +- apps/expo/components/book/BookSearchItem.tsx | 7 ++++--- .../components/book/overview/BookMenu.tsx | 2 +- .../expo/components/bookClub/BookClubCard.tsx | 12 ++++++----- .../bookClub/discussion/Message.tsx | 7 ++++++- packages/browser/src/components/BadgeList.tsx | 6 +++++- .../browser/src/components/Pagination.tsx | 6 ++++-- packages/browser/src/components/ReadMore.tsx | 4 +++- .../bookClub/books/BookClubBookItem.tsx | 4 ++-- .../filters/form/OrderByDirection.tsx | 5 ++++- .../src/components/jobs/JobOverlay.tsx | 4 +++- .../sections/SmartListReview.tsx | 2 +- .../src/scenes/auth/LoginOrClaimScene.tsx | 2 +- .../bookClub/createClub/CreateClubReview.tsx | 2 +- .../tabs/settings/roles/MemberSpecDisplay.tsx | 8 ++++--- .../scenes/createLibrary/LibraryReview.tsx | 21 ++++++++++--------- .../src/scenes/settings/SettingsHeader.tsx | 2 +- .../settings/app/apiKeys/APIKeyTable.tsx | 2 +- packages/i18n/src/locales/en-US.json | 15 +++++++++++++ packages/i18n/src/locales/ko-KR.json | 15 +++++++++++++ 20 files changed, 91 insertions(+), 37 deletions(-) diff --git a/apps/expo/app/opds/[id]/publication/index.tsx b/apps/expo/app/opds/[id]/publication/index.tsx index 83b280515..cd6663477 100644 --- a/apps/expo/app/opds/[id]/publication/index.tsx +++ b/apps/expo/app/opds/[id]/publication/index.tsx @@ -267,7 +267,7 @@ export default function Screen() { - {title || 'Untitled'} + {title || t('common.unknownTitle')} {seriesText && ( diff --git a/apps/expo/components/book/BookSearchItem.tsx b/apps/expo/components/book/BookSearchItem.tsx index df55969d4..8973999e2 100644 --- a/apps/expo/components/book/BookSearchItem.tsx +++ b/apps/expo/components/book/BookSearchItem.tsx @@ -2,10 +2,9 @@ import { useSDK } from '@stump/client' import { formatBytes } from '@stump/client' import { FragmentType, graphql, useFragment } from '@stump/graphql' import { useRouter } from 'expo-router' -import pluralize from 'pluralize' import { Pressable, View } from 'react-native' -import { useDisplay } from '~/lib/hooks' +import { useDisplay, useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' import { useActiveServer } from '../activeServer' @@ -49,6 +48,7 @@ type Props = { } export default function BookSearchItem({ book }: Props) { + const { t } = useTranslate() const { sdk } = useSDK() const { activeServer: { id: serverID }, @@ -94,7 +94,8 @@ export default function BookSearchItem({ book }: Props) { {data.resolvedName} - {formatBytes(data.size)} • {data.pages} {pluralize('page', data.pages)} + {formatBytes(data.size)} • {data.pages}{' '} + {t(data.pages === 1 ? 'common.page' : 'common.pages')} diff --git a/apps/expo/components/book/overview/BookMenu.tsx b/apps/expo/components/book/overview/BookMenu.tsx index 038e653d6..957cbb82c 100644 --- a/apps/expo/components/book/overview/BookMenu.tsx +++ b/apps/expo/components/book/overview/BookMenu.tsx @@ -235,7 +235,7 @@ export default function BookMenu({ data }: Props) { icon={isFavorite ? 'heart.fill' : 'heart'} onPress={() => favoriteBook()} > - {isFavorite ? 'Unfavorite' : 'Favorite'} + {t(isFavorite ? 'common.unfavorite' : 'common.favorite')} diff --git a/apps/expo/components/bookClub/BookClubCard.tsx b/apps/expo/components/bookClub/BookClubCard.tsx index d26bd3ab9..9e23cd2ff 100644 --- a/apps/expo/components/bookClub/BookClubCard.tsx +++ b/apps/expo/components/bookClub/BookClubCard.tsx @@ -1,9 +1,10 @@ import { useSDK } from '@stump/client' import { FragmentType, graphql, useFragment } from '@stump/graphql' import { useRouter } from 'expo-router' -import pluralize from 'pluralize' import { Pressable, Text, View } from 'react-native' +import { useTranslate } from '~/lib/hooks' + import { useActiveServer } from '../activeServer' import { ThumbnailImage } from '../image' import { AvatarStack, Card, Heading } from '../ui' @@ -53,6 +54,7 @@ type Props = { } export function BookClubCard({ club }: Props) { + const { t } = useTranslate() const data = useFragment(fragment, club) const router = useRouter() const { @@ -80,7 +82,7 @@ export function BookClubCard({ club }: Props) { className="w-full" > - + - + {data.name} {data.description && ( @@ -105,7 +107,7 @@ export function BookClubCard({ club }: Props) { )} - + - {pluralize('member', data.membersCount, true)} + {t('bookClub.memberCount', { count: data.membersCount })} diff --git a/apps/expo/components/bookClub/discussion/Message.tsx b/apps/expo/components/bookClub/discussion/Message.tsx index 39240cc62..ef3a66ddb 100644 --- a/apps/expo/components/bookClub/discussion/Message.tsx +++ b/apps/expo/components/bookClub/discussion/Message.tsx @@ -215,7 +215,12 @@ function Message({ > - {threadChildrenCount} {threadChildrenCount === 1 ? 'reply' : 'replies'} + {t( + threadChildrenCount === 1 + ? 'bookClub.replyCountOne' + : 'bookClub.replyCountOther', + { count: threadChildrenCount }, + )} )} diff --git a/packages/browser/src/components/BadgeList.tsx b/packages/browser/src/components/BadgeList.tsx index e39683328..58b03e0db 100644 --- a/packages/browser/src/components/BadgeList.tsx +++ b/packages/browser/src/components/BadgeList.tsx @@ -1,4 +1,5 @@ import { cn, useBoolean } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { Children, ReactNode, useEffect, useRef, useState } from 'react' type Props = { @@ -8,6 +9,7 @@ type Props = { } export default function BadgeList({ children, className, maxItems = 20 }: Props) { + const { t } = useLocaleContext() const containerRef = useRef(null) const [expanded, { toggle }] = useBoolean(false) @@ -44,7 +46,9 @@ export default function BadgeList({ children, className, maxItems = 20 }: Props) onClick={toggle} className="px-3 py-0.5 text-xs font-medium cursor-pointer rounded-full border border-dashed border-border bg-background text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" > - {expanded ? 'See less' : `See ${items.length - maxItems} more`} + {expanded + ? t('common.seeLess') + : t('common.seeMoreCount', { count: items.length - maxItems })}
diff --git a/packages/browser/src/components/Pagination.tsx b/packages/browser/src/components/Pagination.tsx index 6edc9e72b..be4a62d96 100644 --- a/packages/browser/src/components/Pagination.tsx +++ b/packages/browser/src/components/Pagination.tsx @@ -1,4 +1,5 @@ import { cn, cx } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { ArrowLeft, ArrowRight, MoreHorizontal } from 'lucide-react' import { useCallback, useMemo } from 'react' import { useWindowSize } from 'rooks' @@ -14,6 +15,7 @@ type PaginationArrowProps = { } function PaginationArrow({ kind, isDisabled, onClick, onMouseEnter }: PaginationArrowProps) { + const { t } = useLocaleContext() const ArrowIcon = kind === 'previous' ? ArrowLeft : ArrowRight // NOTE: notice I am wrapping the link (which will have pointer-events-none when @@ -41,7 +43,7 @@ function PaginationArrow({ kind, isDisabled, onClick, onMouseEnter }: Pagination { 'pl-1 pr-0': kind === 'next' }, )} > - {kind === 'next' && 'Next'} + {kind === 'next' && t('pagination.buttons.next')}
diff --git a/packages/browser/src/components/ReadMore.tsx b/packages/browser/src/components/ReadMore.tsx index b7a369933..f022718b2 100644 --- a/packages/browser/src/components/ReadMore.tsx +++ b/packages/browser/src/components/ReadMore.tsx @@ -1,4 +1,5 @@ import { cn, useBoolean } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { DEBUG_ENV } from '../index.ts' import Markdown from './markdown/MarkdownPreview.tsx' @@ -12,6 +13,7 @@ const COLLAPSED_HEIGHT = 72 const MAX_EXPANDED_HEIGHT = 300 export default function ReadMore({ text, muted }: Props) { + const { t } = useLocaleContext() const [showingAll, { toggle }] = useBoolean(false) const resolvedText = text ? text : DEBUG_ENV ? DEBUG_FAKE_TEXT : '' @@ -48,7 +50,7 @@ export default function ReadMore({ text, muted }: Props) { onClick={toggle} className="px-3 py-0.5 text-xs font-medium cursor-pointer rounded-full border border-dashed border-border bg-background text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" > - {showingAll ? 'Read less' : 'Read more'} + {showingAll ? t('common.readLess') : t('common.readMore')}
diff --git a/packages/browser/src/components/bookClub/books/BookClubBookItem.tsx b/packages/browser/src/components/bookClub/books/BookClubBookItem.tsx index e959d51ae..39f49fb4b 100644 --- a/packages/browser/src/components/bookClub/books/BookClubBookItem.tsx +++ b/packages/browser/src/components/bookClub/books/BookClubBookItem.tsx @@ -124,7 +124,7 @@ export default function BookClubBookItem({ data }: Props) { ) const link = details?.url const isExternal = !book.entity - const heading = details?.title ?? 'Untitled' + const heading = details?.title ?? t('common.unknownTitle') const author = details?.author return ( @@ -138,7 +138,7 @@ export default function BookClubBookItem({ data }: Props) { {author && {author}} {link && ( - {isExternal ? 'External link' : 'Access book'} + {isExternal ? t('bookClubUi.externalLink') : t('bookClubUi.accessBook')} )} diff --git a/packages/browser/src/components/filters/form/OrderByDirection.tsx b/packages/browser/src/components/filters/form/OrderByDirection.tsx index 583638f87..fb6c51556 100644 --- a/packages/browser/src/components/filters/form/OrderByDirection.tsx +++ b/packages/browser/src/components/filters/form/OrderByDirection.tsx @@ -1,5 +1,6 @@ import { Button, cx } from '@stump/components' import { OrderDirection } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { SortAsc } from 'lucide-react' type Props = { @@ -7,6 +8,8 @@ type Props = { onChange: (value: OrderDirection) => void } export default function OrderByDirection({ value, onChange }: Props) { + const { t } = useLocaleContext() + return ( ) } diff --git a/packages/browser/src/components/jobs/JobOverlay.tsx b/packages/browser/src/components/jobs/JobOverlay.tsx index bc7593337..2a068fccf 100644 --- a/packages/browser/src/components/jobs/JobOverlay.tsx +++ b/packages/browser/src/components/jobs/JobOverlay.tsx @@ -1,10 +1,12 @@ import { useFooterOffsetStore, useJobStore } from '@stump/client' import { ProgressBar, Text } from '@stump/components' import { JobUpdate } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { AnimatePresence, motion } from 'framer-motion' import { useMemo } from 'react' export default function JobOverlay() { + const { t } = useLocaleContext() const storeJobs = useJobStore((state) => state.jobs) /** @@ -51,7 +53,7 @@ export default function JobOverlay() { >
- {firstRunningJob.message ?? 'Job in progress'} + {firstRunningJob.message ?? t('jobOverlay.backupHeading')} {firstRunningJob.subtitle && ( diff --git a/packages/browser/src/components/smartList/createOrUpdate/sections/SmartListReview.tsx b/packages/browser/src/components/smartList/createOrUpdate/sections/SmartListReview.tsx index 173bb62bd..a35a48757 100644 --- a/packages/browser/src/components/smartList/createOrUpdate/sections/SmartListReview.tsx +++ b/packages/browser/src/components/smartList/createOrUpdate/sections/SmartListReview.tsx @@ -29,7 +29,7 @@ export default function SmartListReview() {
- {state.description || 'None'} + {state.description || t('createSmartListScene.form.review.none')}
diff --git a/packages/browser/src/scenes/auth/LoginOrClaimScene.tsx b/packages/browser/src/scenes/auth/LoginOrClaimScene.tsx index 6d739bd13..6419a7da9 100644 --- a/packages/browser/src/scenes/auth/LoginOrClaimScene.tsx +++ b/packages/browser/src/scenes/auth/LoginOrClaimScene.tsx @@ -138,7 +138,7 @@ export default function LoginOrClaimScene() { return ( - {message || 'An unknown error occurred'} + {message || t('common.unknownError')} ) } diff --git a/packages/browser/src/scenes/bookClub/createClub/CreateClubReview.tsx b/packages/browser/src/scenes/bookClub/createClub/CreateClubReview.tsx index 86800248f..b3081d489 100644 --- a/packages/browser/src/scenes/bookClub/createClub/CreateClubReview.tsx +++ b/packages/browser/src/scenes/bookClub/createClub/CreateClubReview.tsx @@ -28,7 +28,7 @@ export default function CreateClubReview() {
- {state.description || 'None'} + {state.description || t('createBookClubScene.form.review.none')}
diff --git a/packages/browser/src/scenes/bookClub/tabs/settings/roles/MemberSpecDisplay.tsx b/packages/browser/src/scenes/bookClub/tabs/settings/roles/MemberSpecDisplay.tsx index 0066550e6..162e769b5 100644 --- a/packages/browser/src/scenes/bookClub/tabs/settings/roles/MemberSpecDisplay.tsx +++ b/packages/browser/src/scenes/bookClub/tabs/settings/roles/MemberSpecDisplay.tsx @@ -4,8 +4,6 @@ import { useState } from 'react' import { useBookClubContext } from '@/components/bookClub' -const upperFirst = (str: string) => str.charAt(0).toUpperCase() + str.slice(1) - export default function MemberSpecDisplay() { const { t } = useLocaleContext() const { @@ -56,7 +54,11 @@ export default function MemberSpecDisplay() { return (
diff --git a/packages/browser/src/scenes/bookClub/tabs/settings/scheduler/AddBookCard.tsx b/packages/browser/src/scenes/bookClub/tabs/settings/scheduler/AddBookCard.tsx index 8af340926..d14205a7a 100644 --- a/packages/browser/src/scenes/bookClub/tabs/settings/scheduler/AddBookCard.tsx +++ b/packages/browser/src/scenes/bookClub/tabs/settings/scheduler/AddBookCard.tsx @@ -77,7 +77,7 @@ export default function AddBookCard({ index }: Props) { onClick={() => setSelectedBook(null)} className="shrink-0" > - {t('bookClubUi.removeSelection')} + {t('bookClubUi.schedule.bookPicker.actions.removeSelection')} @@ -99,9 +99,9 @@ export default function AddBookCard({ index }: Props) { return ( <>
- {t('bookClubUi.pickBook')} + {t('bookClubUi.schedule.bookPicker.libraryBook.title')} - {t('bookClubUi.pickBookDescription')} + {t('bookClubUi.schedule.bookPicker.libraryBook.description')}
@@ -119,28 +119,28 @@ export default function AddBookCard({ index }: Props) { return ( <>
- {t('bookClubUi.addExternalBook')} + {t('bookClubUi.schedule.bookPicker.externalBook.title')} - {t('bookClubUi.addExternalBookDescription')} + {t('bookClubUi.schedule.bookPicker.externalBook.description')}
@@ -151,27 +151,29 @@ export default function AddBookCard({ index }: Props) { return ( {renderBookPickerOption()} - {!isEntityBook && !isDefiningExternalBook && } + {!isEntityBook && !isDefiningExternalBook && ( + + )} {renderExternalBookOption()} - +
- {t('bookClubUi.configureDates')} + {t('bookClubUi.schedule.bookPicker.dates.title')} - {t('bookClubUi.configureDatesDescription')} + {t('bookClubUi.schedule.bookPicker.dates.description')}
form.setValue(`books.${index}.startAt`, date?.toISOString())} /> form.setValue(`books.${index}.endAt`, date?.toISOString())} /> diff --git a/packages/browser/src/scenes/bookClub/tabs/settings/scheduler/CreateOrAddToScheduleForm.tsx b/packages/browser/src/scenes/bookClub/tabs/settings/scheduler/CreateOrAddToScheduleForm.tsx index 85983409e..e2707c29b 100644 --- a/packages/browser/src/scenes/bookClub/tabs/settings/scheduler/CreateOrAddToScheduleForm.tsx +++ b/packages/browser/src/scenes/bookClub/tabs/settings/scheduler/CreateOrAddToScheduleForm.tsx @@ -72,7 +72,7 @@ export default function CreateOrAddToScheduleForm() { return (
- {t('libraryUi.createFailed')} + {t('libraryUi.create.error')} {createError} )} diff --git a/packages/browser/src/scenes/home/ContinueReading.tsx b/packages/browser/src/scenes/home/ContinueReading.tsx index 5f149810a..c50613d01 100644 --- a/packages/browser/src/scenes/home/ContinueReading.tsx +++ b/packages/browser/src/scenes/home/ContinueReading.tsx @@ -237,7 +237,7 @@ const ContinueReadingCard = memo(function ContinueReadingCard({
{!isEbookProgress && !!data.readProgress?.page && data.readProgress.page > 0 && ( - {t('entityUi.pagesLeft', { count: pagesLeft })} + {t('entityUi.bookCard.pagesLeft', { count: pagesLeft })} )} diff --git a/packages/browser/src/scenes/home/NoLibraries.tsx b/packages/browser/src/scenes/home/NoLibraries.tsx index 96d2b0b84..eee023580 100644 --- a/packages/browser/src/scenes/home/NoLibraries.tsx +++ b/packages/browser/src/scenes/home/NoLibraries.tsx @@ -16,7 +16,7 @@ export default function NoLibraries() {
{t('controlUi.constructionIllustration')} diff --git a/packages/browser/src/scenes/library/tabs/settings/basics/BasicSettingsScene.tsx b/packages/browser/src/scenes/library/tabs/settings/basics/BasicSettingsScene.tsx index 3007f1d12..745bf59ca 100644 --- a/packages/browser/src/scenes/library/tabs/settings/basics/BasicSettingsScene.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/basics/BasicSettingsScene.tsx @@ -106,7 +106,7 @@ export default function BasicSettingsScene() {
diff --git a/packages/browser/src/scenes/library/tabs/settings/danger/deletion/DeleteLibrary.tsx b/packages/browser/src/scenes/library/tabs/settings/danger/deletion/DeleteLibrary.tsx index 2efb5765b..7a40798e4 100644 --- a/packages/browser/src/scenes/library/tabs/settings/danger/deletion/DeleteLibrary.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/danger/deletion/DeleteLibrary.tsx @@ -36,7 +36,7 @@ export default function DeleteLibrary() { onClick={() => setShowConfirmation(true)} className="shrink-0" > - {t('libraryUi.deleteLibrary')} + {t('libraryUi.deleteLibrary.title')}
} diff --git a/packages/browser/src/scenes/library/tabs/settings/integrations/analysis/LibraryAnalysisScene.tsx b/packages/browser/src/scenes/library/tabs/settings/integrations/analysis/LibraryAnalysisScene.tsx index 56e9bd791..662d945a7 100644 --- a/packages/browser/src/scenes/library/tabs/settings/integrations/analysis/LibraryAnalysisScene.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/integrations/analysis/LibraryAnalysisScene.tsx @@ -10,8 +10,8 @@ export default function LibraryAnalysisScene() {
- {t('libraryUi.workInProgress')} - {t('libraryUi.workInProgressDescription')} + {t('libraryUi.analysis.workInProgress.title')} + {t('libraryUi.analysis.workInProgress.description')} diff --git a/packages/browser/src/scenes/library/tabs/settings/options/scanner/customScan/ScanConfigForm.tsx b/packages/browser/src/scenes/library/tabs/settings/options/scanner/customScan/ScanConfigForm.tsx index 443618d04..2ecdeca98 100644 --- a/packages/browser/src/scenes/library/tabs/settings/options/scanner/customScan/ScanConfigForm.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/options/scanner/customScan/ScanConfigForm.tsx @@ -94,16 +94,18 @@ export default function ScanConfigForm({ onScan }: Props) { {variant === 'custom' && (
form.setValue('config.regenMeta', value)} /> form.setValue('config.regenHashes', value)} diff --git a/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/DeleteLibraryThumbnails.tsx b/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/DeleteLibraryThumbnails.tsx index 8e0e10622..f17788243 100644 --- a/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/DeleteLibraryThumbnails.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/DeleteLibraryThumbnails.tsx @@ -37,10 +37,10 @@ export default function DeleteLibraryThumbnails() { const handleDeleteThumbnails = useCallback(async () => { try { await deleteThumbnails({ id }) - toast.success(t('libraryUi.deleteSuccess')) + toast.success(t('libraryUi.thumbnailSettings.delete.success')) } catch (error) { console.error(error) - const fallbackMessage = t('libraryUi.deleteError') + const fallbackMessage = t('libraryUi.thumbnailSettings.delete.error') if (error instanceof Error) { toast.error(error.message || fallbackMessage) } else { @@ -52,9 +52,9 @@ export default function DeleteLibraryThumbnails() { return ( <>
- {t('libraryUi.delete')} + {t('common.delete')} - {t('libraryUi.deleteDescription')} + {t('libraryUi.thumbnailSettings.delete.description')}
@@ -65,14 +65,14 @@ export default function DeleteLibraryThumbnails() { className="shrink-0" disabled={isPending || !!data} > - {t('libraryUi.delete')} + {t('common.delete')}
setShowConfirmation(false)} @@ -83,7 +83,7 @@ export default function DeleteLibraryThumbnails() { {t('common.thisActionCannotBeUndone')} - {t('libraryUi.deleteWarning')} + {t('libraryUi.thumbnailSettings.delete.warning')} diff --git a/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/ProcessLibraryThumbnails.tsx b/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/ProcessLibraryThumbnails.tsx index 9b8edee0c..2271376e7 100644 --- a/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/ProcessLibraryThumbnails.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/ProcessLibraryThumbnails.tsx @@ -29,9 +29,9 @@ export default function ProcessLibraryThumbnails() { return (
- + - {t('libraryUi.processColorsDescription')} + {t('libraryUi.thumbnailSettings.processColors.description')}
@@ -39,7 +39,7 @@ export default function ProcessLibraryThumbnails() { - {t('libraryUi.extractColors')} + {t('libraryUi.thumbnailSettings.processColors.action')} } @@ -47,12 +47,12 @@ export default function ProcessLibraryThumbnails() { { items: [ { - label: t('libraryUi.missingOnly'), + label: t('libraryUi.thumbnailSettings.processColors.options.missingOnly'), leftIcon: , onClick: () => process(false), }, { - label: t('libraryUi.reprocessAll'), + label: t('libraryUi.thumbnailSettings.processColors.options.reprocessAll'), isDestructive: true, leftIcon: , onClick: () => process(true), diff --git a/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/RegenerateThumbnails.tsx b/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/RegenerateThumbnails.tsx index bfa3a0928..c0886d30f 100644 --- a/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/RegenerateThumbnails.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/options/thumbnails/RegenerateThumbnails.tsx @@ -29,9 +29,9 @@ export default function RegenerateThumbnails() { return (
- + - {t('libraryUi.regenerateDescription')} + {t('libraryUi.thumbnailSettings.regenerate.description')}
@@ -39,7 +39,7 @@ export default function RegenerateThumbnails() { - {t('libraryUi.generate')} + {t('libraryUi.thumbnailSettings.regenerate.action')} } @@ -47,12 +47,12 @@ export default function RegenerateThumbnails() { { items: [ { - label: t('libraryUi.createMissing'), + label: t('libraryUi.thumbnailSettings.regenerate.options.missingOnly'), leftIcon: , onClick: () => regenerate(false), }, { - label: t('libraryUi.forceRecreate'), + label: t('libraryUi.thumbnailSettings.regenerate.options.forceAll'), isDestructive: true, leftIcon: , onClick: () => regenerate(true), diff --git a/packages/browser/src/scenes/series/CompleteSeriesConfirmation.tsx b/packages/browser/src/scenes/series/CompleteSeriesConfirmation.tsx index 86f3c3042..1b4e8696c 100644 --- a/packages/browser/src/scenes/series/CompleteSeriesConfirmation.tsx +++ b/packages/browser/src/scenes/series/CompleteSeriesConfirmation.tsx @@ -12,8 +12,8 @@ export default function CompleteSeriesConfirmation({ isOpen, onCancel, onConfirm const { t } = useLocaleContext() return ( - {t('entityUi.seriesManagementNotice')} + {t('entityUi.seriesSettings.notice')}
- {t('entityUi.analysis')} + {t('entityUi.seriesSettings.analysis.title')} - {t('entityUi.seriesAnalysisDescription')} + {t('entityUi.seriesSettings.analysis.description')}
@@ -114,9 +118,9 @@ export default function SeriesSettingsScene() {
- {t('entityUi.thumbnail')} + {t('entityUi.seriesSettings.thumbnail.title')} - {t('entityUi.seriesThumbnailDescription')} + {t('entityUi.seriesSettings.thumbnail.description')}
@@ -126,8 +130,8 @@ export default function SeriesSettingsScene() {
- {t('entityUi.metadata')} - {t('entityUi.seriesMetadataDescription')} + {t('common.metadata')} + {t('entityUi.seriesSettings.metadata.description')}
{checkPermission(UserPermission.EditMetadata) && ( diff --git a/packages/browser/src/scenes/settings/app/apiKeys/APIKeyInspector.tsx b/packages/browser/src/scenes/settings/app/apiKeys/APIKeyInspector.tsx index 74d942f6c..ba286d702 100644 --- a/packages/browser/src/scenes/settings/app/apiKeys/APIKeyInspector.tsx +++ b/packages/browser/src/scenes/settings/app/apiKeys/APIKeyInspector.tsx @@ -49,7 +49,7 @@ export default function APIKeyInspector({ apiKey, onClose }: Props) { return (
- {t('settingsUi.permissions')} + {t('settingsScene.app/apiKeys.shared.fields.permissions')}
{permissions.map((perm) => ( diff --git a/packages/browser/src/scenes/settings/app/apiKeys/CreateOrUpdateAPIKeyForm.tsx b/packages/browser/src/scenes/settings/app/apiKeys/CreateOrUpdateAPIKeyForm.tsx index 3d2b471ae..f64999119 100644 --- a/packages/browser/src/scenes/settings/app/apiKeys/CreateOrUpdateAPIKeyForm.tsx +++ b/packages/browser/src/scenes/settings/app/apiKeys/CreateOrUpdateAPIKeyForm.tsx @@ -107,7 +107,7 @@ export default function CreateOrUpdateAPIKeyForm({ > { ) expect(screen.getByTestId('permissions-meta')).toBeInTheDocument() - expect(screen.getByText('settingsUi.permissions')).toBeInTheDocument() + expect( + screen.getByText('settingsScene.app/apiKeys.shared.fields.permissions'), + ).toBeInTheDocument() expect(screen.getAllByTestId('permission-badge')).toHaveLength(2) }) @@ -97,7 +99,9 @@ describe('APIKeyInspector', () => { ) expect(screen.getByTestId('permissions-meta')).toBeInTheDocument() - expect(screen.getByText('settingsUi.permissions')).toBeInTheDocument() + expect( + screen.getByText('settingsScene.app/apiKeys.shared.fields.permissions'), + ).toBeInTheDocument() expect(screen.getAllByTestId('permission-badge')).toHaveLength(2) }) diff --git a/packages/browser/src/scenes/settings/app/preferences/DebugSettings.tsx b/packages/browser/src/scenes/settings/app/preferences/DebugSettings.tsx index 7d9274842..723da4a36 100644 --- a/packages/browser/src/scenes/settings/app/preferences/DebugSettings.tsx +++ b/packages/browser/src/scenes/settings/app/preferences/DebugSettings.tsx @@ -19,12 +19,12 @@ function DebugSettings() { return ( store.patch({ showQueryTools: !store.showQueryTools })} > {t(getKey('fontFamily.label'))} @@ -56,8 +56,8 @@ export default function ReaderDefaultSettingsScene() { { console.error(error) - toast.error(t('settingsUi.deleteDeviceError')) + toast.error(t('settingsScene.server/email.sections.devices.deleteDevice.error')) }, }) @@ -47,9 +47,9 @@ export default function DeleteDeviceConfirmation({ device, onClose }: Props) { return ( () -function DeviceColumnHeader({ label }: { label: 'deviceEmail' | 'deviceName' | 'deviceStatus' }) { +function DeviceColumnHeader({ label }: { label: 'email' | 'name' | 'status' }) { const { t } = useLocaleContext() return ( - {t(`settingsUi.${label}`)} + {t(`common.${label}`)} ) } @@ -126,7 +126,9 @@ function DeviceStatus({ forbidden }: { forbidden: boolean }) { const { t } = useLocaleContext() return ( - {forbidden ? t('settingsUi.forbidden') : t('settingsUi.allowed')} + {forbidden + ? t('settingsScene.server/email.sections.devices.table.status.forbidden') + : t('settingsScene.server/email.sections.devices.table.status.allowed')} ) } @@ -134,11 +136,11 @@ function DeviceStatus({ forbidden }: { forbidden: boolean }) { const baseColumns = [ columnHelper.accessor('name', { cell: ({ getValue }) => {getValue()}, - header: () => , + header: () => , }), columnHelper.accessor('email', { cell: ({ getValue }) => {getValue()}, - header: () => , + header: () => , }), columnHelper.display({ cell: ({ @@ -146,7 +148,7 @@ const baseColumns = [ original: { forbidden }, }, }) => , - header: () => , + header: () => , id: 'status', }), ] as ColumnDef[] diff --git a/packages/browser/src/scenes/settings/server/email/emailers/EmailerListItem.tsx b/packages/browser/src/scenes/settings/server/email/emailers/EmailerListItem.tsx index fafbc9af2..30d3caa6c 100644 --- a/packages/browser/src/scenes/settings/server/email/emailers/EmailerListItem.tsx +++ b/packages/browser/src/scenes/settings/server/email/emailers/EmailerListItem.tsx @@ -90,7 +90,11 @@ export default function EmailerListItem({ fragment }: Props) {
{emailer.isPrimary && ( - + )} diff --git a/packages/browser/src/scenes/settings/server/email/emailers/EmailerSendHistoryTable.tsx b/packages/browser/src/scenes/settings/server/email/emailers/EmailerSendHistoryTable.tsx index 8d2e2292a..9485dfc0a 100644 --- a/packages/browser/src/scenes/settings/server/email/emailers/EmailerSendHistoryTable.tsx +++ b/packages/browser/src/scenes/settings/server/email/emailers/EmailerSendHistoryTable.tsx @@ -105,7 +105,7 @@ const getColumns = (t: ReturnType['t']) => [ ), header: () => ( - {t('settingsUi.sentAt')} + {t('settingsScene.server/email.sections.emailers.list.sendHistory.table.columns.createdAt')} ), id: 'sentAt', @@ -114,7 +114,7 @@ const getColumns = (t: ReturnType['t']) => [ cell: ({ getValue }) => {getValue()}, header: () => ( - {t('settingsUi.recipient')} + {t('settingsScene.server/email.sections.emailers.list.sendHistory.recipient')} ), id: 'recipientEmail', @@ -143,12 +143,12 @@ const getColumns = (t: ReturnType['t']) => [
) } else { - return {t('settingsUi.unknown')} + return {t('common.unknown')} } }, header: () => ( - {t('settingsUi.sender')} + {t('settingsScene.server/email.sections.emailers.list.sendHistory.sender')} ), id: 'sender', @@ -161,7 +161,7 @@ const getColumns = (t: ReturnType['t']) => [ } = row if (!attachmentMeta) { - return {t('settingsUi.none')} + return {t('common.none')} } const isAlreadyExpanded = row.getIsExpanded() @@ -170,7 +170,11 @@ const getColumns = (t: ReturnType['t']) => [ className="space-x-2 flex cursor-pointer items-center" onClick={row.getToggleExpandedHandler()} > - {isAlreadyExpanded ? t('settingsUi.hide') : t('settingsUi.show')} + + {isAlreadyExpanded + ? t('settingsScene.server/email.sections.emailers.list.sendHistory.actions.hide') + : t('settingsScene.server/email.sections.emailers.list.sendHistory.actions.show')} + ['t']) => [ }, header: () => ( - {t('settingsUi.attachments')} + {t('settingsScene.server/email.sections.emailers.list.sendHistory.attachments')} ), id: 'attachments-sub-table', diff --git a/packages/browser/src/scenes/settings/server/email/emailers/EmailerSendRecordAttachmentTable.tsx b/packages/browser/src/scenes/settings/server/email/emailers/EmailerSendRecordAttachmentTable.tsx index 2de851d97..84d8dffa3 100644 --- a/packages/browser/src/scenes/settings/server/email/emailers/EmailerSendRecordAttachmentTable.tsx +++ b/packages/browser/src/scenes/settings/server/email/emailers/EmailerSendRecordAttachmentTable.tsx @@ -119,7 +119,13 @@ const getColumns = (t: ReturnType['t']) => [ } = row if (!media) { - return {t('settingsUi.notFound')} + return ( + + {t( + 'settingsScene.server/email.sections.emailers.list.sendHistory.attachmentTable.notFound', + )} + + ) } return ( @@ -130,7 +136,9 @@ const getColumns = (t: ReturnType['t']) => [ }, header: () => ( - {t('settingsUi.media')} + {t( + 'settingsScene.server/email.sections.emailers.list.sendHistory.attachmentTable.columns.media', + )} ), }), @@ -138,7 +146,9 @@ const getColumns = (t: ReturnType['t']) => [ cell: ({ getValue }) => {getValue()}, header: () => ( - {t('settingsUi.filename')} + {t( + 'settingsScene.server/email.sections.emailers.list.sendHistory.attachmentTable.columns.filename', + )} ), }), @@ -146,7 +156,7 @@ const getColumns = (t: ReturnType['t']) => [ cell: ({ getValue }) => {formatBytes(getValue())}, header: () => ( - {t('settingsUi.size')} + {t('common.size')} ), }), diff --git a/packages/browser/src/scenes/settings/server/email/emailers/EmailersList.tsx b/packages/browser/src/scenes/settings/server/email/emailers/EmailersList.tsx index 44ce2fa81..c3eaf05c5 100644 --- a/packages/browser/src/scenes/settings/server/email/emailers/EmailersList.tsx +++ b/packages/browser/src/scenes/settings/server/email/emailers/EmailersList.tsx @@ -35,7 +35,7 @@ export default function EmailersList() { {t(`${LOCALE_BASE}.emptyHeading`)} {canCreateEmailer && ( - {t('settingsUi.createEmailer')} + {t('settingsScene.server/email.sections.emailers.list.createEmailer')} )}
diff --git a/packages/browser/src/scenes/settings/server/general/ServerEmojisSection.tsx b/packages/browser/src/scenes/settings/server/general/ServerEmojisSection.tsx index f3de113cd..cd68bc7d2 100644 --- a/packages/browser/src/scenes/settings/server/general/ServerEmojisSection.tsx +++ b/packages/browser/src/scenes/settings/server/general/ServerEmojisSection.tsx @@ -192,7 +192,7 @@ export default function ServerEmojisSection() { const name = sanitizedName(renameValue) if (!name) { - toast.error(t('settingsUi.emojiNameRequired')) + toast.error(t('settingsScene.server/general.sections.customEmojis.nameRequired')) return } diff --git a/packages/browser/src/scenes/settings/server/general/ServerPublicURL.tsx b/packages/browser/src/scenes/settings/server/general/ServerPublicURL.tsx index 33a92d4cd..b18876684 100644 --- a/packages/browser/src/scenes/settings/server/general/ServerPublicURL.tsx +++ b/packages/browser/src/scenes/settings/server/general/ServerPublicURL.tsx @@ -34,7 +34,7 @@ export default function ServerPublicURL() { const { mutate: updatePublicUrl } = useGraphQLMutation(mutation, { onSuccess: (data) => { - toast.success(t('settingsUi.publicUrlUpdated')) + toast.success(t('settingsScene.server/general.sections.serverPublicUrl.toasts.updated')) client.setQueryData(['serverConfig', 'publicUrl'], { serverConfig: { publicUrl: data.updatePublicUrl.publicUrl, @@ -42,7 +42,7 @@ export default function ServerPublicURL() { }) }, onError: (error) => { - toast.error(t('settingsUi.publicUrlUpdateFailed'), { + toast.error(t('settingsScene.server/general.sections.serverPublicUrl.toasts.updateFailed'), { description: error instanceof Error ? error.message : t('common.unknownError'), }) }, diff --git a/packages/browser/src/scenes/settings/server/jobs/JobActionMenu.tsx b/packages/browser/src/scenes/settings/server/jobs/JobActionMenu.tsx index eb2aa6da2..b4484802a 100644 --- a/packages/browser/src/scenes/settings/server/jobs/JobActionMenu.tsx +++ b/packages/browser/src/scenes/settings/server/jobs/JobActionMenu.tsx @@ -58,8 +58,8 @@ export default function JobActionMenu({ job, onInspectData }: Props) { }), onError: (error: unknown) => { if (isBadStateError(error)) { - toast.warning(t('settingsUi.jobInvalidState'), { - description: t('settingsUi.checkServerLogs'), + toast.warning(t('settingsScene.server/jobs.sections.history.errors.invalidState'), { + description: t('settingsScene.server/jobs.sections.history.errors.checkServerLogs'), }) client.refetchQueries({ predicate: ({ queryKey }) => queryKey.includes(sdk.cacheKeys.jobs), @@ -114,7 +114,7 @@ export default function JobActionMenu({ job, onInspectData }: Props) { ...(isCancelable ? [ { - label: t('settingsUi.cancelJob'), + label: t('common.cancel'), leftIcon: , onClick: () => handleAction('cancel'), }, @@ -123,7 +123,7 @@ export default function JobActionMenu({ job, onInspectData }: Props) { ...(jobData ? [ { - label: t('settingsUi.viewJobData'), + label: t('settingsScene.server/jobs.sections.history.actionMenu.viewData'), leftIcon: , onClick: () => onInspectData(jobData), }, @@ -132,12 +132,12 @@ export default function JobActionMenu({ job, onInspectData }: Props) { ...(hasLogs ? [ { - label: t('settingsUi.viewLogs'), + label: t('settingsScene.server/jobs.sections.history.actionMenu.viewLogs'), leftIcon: , onClick: () => navigate(paths.serverLogs(jobId)), }, { - label: t('settingsUi.clearLogs'), + label: t('settingsScene.server/jobs.sections.history.actionMenu.clearLogs'), leftIcon: , onClick: () => handleAction('deleteLogs'), }, diff --git a/packages/browser/src/scenes/settings/server/jobs/JobDataInspector.tsx b/packages/browser/src/scenes/settings/server/jobs/JobDataInspector.tsx index dce6d9e89..de94c3bb0 100644 --- a/packages/browser/src/scenes/settings/server/jobs/JobDataInspector.tsx +++ b/packages/browser/src/scenes/settings/server/jobs/JobDataInspector.tsx @@ -49,10 +49,13 @@ export default function JobDataInspector({ data, onClose }: Props) { - + ) } diff --git a/packages/browser/src/scenes/settings/server/metadataIntegrations/providers/EditProviderDialog.tsx b/packages/browser/src/scenes/settings/server/metadataIntegrations/providers/EditProviderDialog.tsx index 9ec0f9865..914ed7a69 100644 --- a/packages/browser/src/scenes/settings/server/metadataIntegrations/providers/EditProviderDialog.tsx +++ b/packages/browser/src/scenes/settings/server/metadataIntegrations/providers/EditProviderDialog.tsx @@ -86,7 +86,11 @@ export function EditProviderDialog({ provider }: Props) { return ( <> - +
- Show as + {t('bookClubUi.showAs')}
- {upperFirst(key.toLowerCase())} + + {t( + `createOrUpdateBookClubForm.fields.member_role_spec.${key.toLowerCase()}.label`, + )} + - {state.thumbnailConfig.quality || 'Default'} + {state.thumbnailConfig.quality || t(getKey('default'))} @@ -114,7 +113,7 @@ export default function LibraryReview() {
- {state.description || 'None'} + {state.description || t(getKey('none'))}
@@ -158,7 +157,9 @@ export default function LibraryReview() { )} {!!state.ignoreRules?.length && ( - {state.ignoreRules.length} {pluralize('rule', state.ignoreRules.length)} + {t(getKey(state.ignoreRules.length === 1 ? 'ignoreRule' : 'ignoreRules'), { + count: state.ignoreRules.length, + })} )} @@ -166,42 +167,42 @@ export default function LibraryReview() {
- {state.processMetadata ? 'Yes' : 'No'} + {t(getKey(state.processMetadata ? 'yes' : 'no'))}
- {state.watch ? 'Yes' : 'No'} + {t(getKey(state.watch ? 'yes' : 'no'))}
- {state.generateFileHashes ? 'Yes' : 'No'} + {t(getKey(state.generateFileHashes ? 'yes' : 'no'))}
- {state.generateKoreaderHashes ? 'Yes' : 'No'} + {t(getKey(state.generateKoreaderHashes ? 'yes' : 'no'))}
- {state.convertRarToZip ? 'Yes' : 'No'} + {t(getKey(state.convertRarToZip ? 'yes' : 'no'))}
- {state.hardDeleteConversions ? 'Yes' : 'No'} + {t(getKey(state.hardDeleteConversions ? 'yes' : 'no'))}
diff --git a/packages/browser/src/scenes/settings/SettingsHeader.tsx b/packages/browser/src/scenes/settings/SettingsHeader.tsx index 5e1762424..1706bcb9a 100644 --- a/packages/browser/src/scenes/settings/SettingsHeader.tsx +++ b/packages/browser/src/scenes/settings/SettingsHeader.tsx @@ -90,7 +90,7 @@ export default function SettingsHeader({ renderNavigation }: Props) { to={`${basePath}${backlink.to}`} className="text-muted-foreground no-underline hover:underline" > - {t(`settingsScene.${backlink.localeKey}`) ?? 'Back'} + {t(`settingsScene.${backlink.localeKey}`) ?? t('common.back')} {' /'} diff --git a/packages/browser/src/scenes/settings/app/apiKeys/APIKeyTable.tsx b/packages/browser/src/scenes/settings/app/apiKeys/APIKeyTable.tsx index 71be69c99..e01f99782 100644 --- a/packages/browser/src/scenes/settings/app/apiKeys/APIKeyTable.tsx +++ b/packages/browser/src/scenes/settings/app/apiKeys/APIKeyTable.tsx @@ -152,7 +152,7 @@ export default function APIKeyTable() { > {valid ? intlFormat(parsed, { month: 'long', day: 'numeric', year: 'numeric' }) - : 'Never'} + : t(getKey('noExpiration'))} ) }, diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index d36e3fa75..31704cfdf 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -221,6 +221,9 @@ "yes": "Yes", "no": "No", "none": "None", + "default": "Default", + "ignoreRule": "{{count}} rule", + "ignoreRules": "{{count}} rules", "labels": { "name": "Name", "description": "Description" @@ -960,6 +963,8 @@ "active": "Active", "inactive": "Inactive", "memberCount": "{{count}} members", + "externalLink": "External link", + "accessBook": "Access book", "goToClub": "Go to club", "noMemberships": "You are not a member of any book clubs", "explorePublic": "Explore public book clubs", @@ -3264,6 +3269,13 @@ "unimplemented": "This functionality is not yet implemented! Check back later", "upNext": "Up next", "unknown": "Unknown", + "unknownTitle": "Untitled", + "seeLess": "See less", + "seeMoreCount": "See {{count}} more", + "readLess": "Read less", + "readMore": "Read more", + "ascending": "Ascending", + "descending": "Descending", "lastUpdated": "Last updated", "limitedFunctionality": "This is not yet fully implemented and is lacking some features. Check back later", "logIn": "Log in", @@ -3802,6 +3814,9 @@ "suggestedBy": "Suggested by {{name}}", "messageDeleted": "This message was deleted", "edited": "(edited)", + "memberCount": "{{count}} members", + "replyCountOne": "{{count}} reply", + "replyCountOther": "{{count}} replies", "editMessage": "Edit this message", "reply": "Reply", "replyInline": "Reply inline", diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index fd08debad..033b64bba 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -221,6 +221,9 @@ "yes": "예", "no": "아니요", "none": "없음", + "default": "기본값", + "ignoreRule": "규칙 {{count}}개", + "ignoreRules": "규칙 {{count}}개", "labels": { "name": "이름", "description": "설명" @@ -960,6 +963,8 @@ "active": "활성", "inactive": "비활성", "memberCount": "회원 {{count}}명", + "externalLink": "외부 링크", + "accessBook": "책 보기", "goToClub": "북클럽으로 이동", "noMemberships": "가입한 북클럽이 없습니다", "explorePublic": "공개 북클럽 둘러보기", @@ -3264,6 +3269,13 @@ "unimplemented": "아직 구현되지 않은 기능입니다. 나중에 다시 확인해 주세요", "upNext": "다음 항목", "unknown": "알 수 없음", + "unknownTitle": "제목 없음", + "seeLess": "간략히 보기", + "seeMoreCount": "{{count}}개 더 보기", + "readLess": "간략히 보기", + "readMore": "더 읽기", + "ascending": "오름차순", + "descending": "내림차순", "lastUpdated": "마지막 업데이트", "limitedFunctionality": "아직 완전히 구현되지 않아 일부 기능이 제공되지 않습니다. 나중에 다시 확인해 주세요", "logIn": "로그인", @@ -3802,6 +3814,9 @@ "suggestedBy": "{{name}} 님의 추천", "messageDeleted": "삭제된 메시지입니다", "edited": "(편집됨)", + "memberCount": "회원 {{count}}명", + "replyCountOne": "답글 {{count}}개", + "replyCountOther": "답글 {{count}}개", "editMessage": "이 메시지 편집", "reply": "답장", "replyInline": "바로 답장", From 2e196c7fe16f1b793f9fee6b740e26eacbf0fa98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 19:52:16 +0900 Subject: [PATCH 50/54] =?UTF-8?q?feat(i18n):=20=EC=A0=91=EA=B7=BC=EC=84=B1?= =?UTF-8?q?=20=EB=AC=B8=EA=B5=AC=EC=99=80=20=EA=B8=B0=EB=B3=B8=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=EA=B5=AD=EC=A0=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/expo/app/(tabs)/library/index.tsx | 5 +++-- apps/expo/app/server/[id]/files/[path].tsx | 2 +- apps/expo/components/bookClub/Moderators.tsx | 8 +++++--- apps/expo/components/opds/useFeedTitle.ts | 4 +++- apps/expo/components/ui/avatar-stack.tsx | 11 +++++++---- .../src/components/explorer/FileExplorerFooter.tsx | 6 ++++-- .../reviewDialog/MatchReviewDialog.tsx | 3 ++- .../settings/server/general/ServerEmojisSection.tsx | 2 +- .../providers/CreateProviderDialog.tsx | 5 ++++- .../providers/EditProviderDialog.tsx | 4 +++- packages/i18n/src/locales/en-US.json | 7 +++++++ packages/i18n/src/locales/ko-KR.json | 7 +++++++ 12 files changed, 47 insertions(+), 17 deletions(-) diff --git a/apps/expo/app/(tabs)/library/index.tsx b/apps/expo/app/(tabs)/library/index.tsx index 7d0d2a527..6ffb7ada0 100644 --- a/apps/expo/app/(tabs)/library/index.tsx +++ b/apps/expo/app/(tabs)/library/index.tsx @@ -26,6 +26,7 @@ import { usePreferencesStore } from '~/stores' import { useSelectionStore } from '~/stores/selection' export default function Screen() { + const { t } = useTranslate() // Note: The id is a workaround for https://github.com/drizzle-team/drizzle-orm/issues/2660 const { id, increment, sortConfig, sourceFilter } = useDownloadsState( useShallow((state) => ({ @@ -89,11 +90,11 @@ export default function Screen() { // We create a sectioned list by grouping by series, then flatten it so that we have something like: // ["Series 1", item1, item2, "Series 2", item3, item4] // See https://shopify.github.io/flash-list/docs/guides/section-list/ - const grouped = groupBy(data, (item) => item.series_refs?.name || 'Unknown') + const grouped = groupBy(data, (item) => item.series_refs?.name || t('common.unknown')) return Object.entries(grouped).flatMap(([seriesName, items]) => { return [seriesName, ...items] }) - }, [sortConfig, data]) + }, [sortConfig, data, t]) const renderItem = useCallback(({ item }: { item: (typeof data)[0] | string }) => { if (typeof item === 'string') { diff --git a/apps/expo/app/server/[id]/files/[path].tsx b/apps/expo/app/server/[id]/files/[path].tsx index f281ae2d5..01989bbd0 100644 --- a/apps/expo/app/server/[id]/files/[path].tsx +++ b/apps/expo/app/server/[id]/files/[path].tsx @@ -20,7 +20,7 @@ export default function Screen() { const friendlyName = params.friendlyName const navigation = useNavigation() - const basename = rootPath?.split('/').filter(Boolean).pop() ?? 'Files' + const basename = rootPath?.split('/').filter(Boolean).pop() ?? t('common.files') useEffect(() => { navigation.setOptions({ headerTitle: friendlyName || basename, diff --git a/apps/expo/components/bookClub/Moderators.tsx b/apps/expo/components/bookClub/Moderators.tsx index a23eaebd5..c801857ea 100644 --- a/apps/expo/components/bookClub/Moderators.tsx +++ b/apps/expo/components/bookClub/Moderators.tsx @@ -1,6 +1,7 @@ import { useSDK } from '@stump/client' import { View } from 'react-native' +import { useTranslate } from '~/lib/hooks' import { cn } from '~/lib/utils' import { Avatar, AvatarFallback, AvatarImage, AvatarStack, Text } from '../ui' @@ -14,6 +15,7 @@ type Props = { } export function Moderators({ moderators }: Props) { + const { t } = useTranslate() const { sdk } = useSDK() const headers = { @@ -40,16 +42,16 @@ export function Moderators({ moderators }: Props) { if (!moderator) return null return ( - + {moderator.avatarUrl && } - + {getFallback(moderator.displayName)} diff --git a/apps/expo/components/opds/useFeedTitle.ts b/apps/expo/components/opds/useFeedTitle.ts index 0eabff41c..ce2876039 100644 --- a/apps/expo/components/opds/useFeedTitle.ts +++ b/apps/expo/components/opds/useFeedTitle.ts @@ -4,9 +4,11 @@ import { useLayoutEffect, useMemo } from 'react' import { Platform } from 'react-native' import { IS_IOS_26_PLUS } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' export function useFeedTitle(feed?: OPDSFeed | null) { - const title = useMemo(() => (feed ? feed.metadata.title || 'OPDS Feed' : null), [feed]) + const { t } = useTranslate() + const title = useMemo(() => (feed ? feed.metadata.title || t('opds.feedTitle') : null), [feed, t]) const navigation = useNavigation() useLayoutEffect(() => { if (!title) return diff --git a/apps/expo/components/ui/avatar-stack.tsx b/apps/expo/components/ui/avatar-stack.tsx index 0e4cbb847..2d6e13152 100644 --- a/apps/expo/components/ui/avatar-stack.tsx +++ b/apps/expo/components/ui/avatar-stack.tsx @@ -1,5 +1,6 @@ import { View } from 'react-native' +import { useTranslate } from '~/lib/hooks' import { cn } from '~/lib/utils' import { Avatar, AvatarFallback, AvatarImage } from './avatar' @@ -24,11 +25,13 @@ export function AvatarStack({ className, requestHeaders, }: AvatarStackProps) { + const { t } = useTranslate() + return ( {avatars.map((avatar, index) => ( 0 && '-ml-2', { 'border-black/10 dark:border-white/20': !avatar.src, @@ -36,7 +39,7 @@ export function AvatarStack({ > {avatar.src && } - + {avatar.fallback || '?'} @@ -44,8 +47,8 @@ export function AvatarStack({ ))} {overflowCount && overflowCount > 0 ? ( - - +{overflowCount} + + +{overflowCount} ) : null} diff --git a/packages/browser/src/components/explorer/FileExplorerFooter.tsx b/packages/browser/src/components/explorer/FileExplorerFooter.tsx index e7b6f36fd..d4e64ae20 100644 --- a/packages/browser/src/components/explorer/FileExplorerFooter.tsx +++ b/packages/browser/src/components/explorer/FileExplorerFooter.tsx @@ -1,4 +1,5 @@ import { ChevronRight } from 'lucide-react' +import { useLocaleContext } from '@stump/i18n' import { Fragment, useMemo } from 'react' import { useFileExplorerContext } from './context' @@ -6,10 +7,11 @@ import { useFileExplorerContext } from './context' export const FOOTER_HEIGHT = 40 export default function FileExplorerFooter() { + const { t } = useLocaleContext() const { currentPath, rootPath, navigateToPath } = useFileExplorerContext() const pathSegments = useMemo(() => { - const rootName = rootPath.split('/').filter(Boolean).pop() ?? 'Library' + const rootName = rootPath.split('/').filter(Boolean).pop() ?? t('common.library') if (!currentPath || currentPath === rootPath) { return [{ name: rootName, path: rootPath }] @@ -25,7 +27,7 @@ export default function FileExplorerFooter() { path: rootPath + '/' + parts.slice(0, i + 1).join('/'), })), ] - }, [currentPath, rootPath]) + }, [currentPath, rootPath, t]) return (
diff --git a/packages/browser/src/components/metadata/metadataMatching/reviewDialog/MatchReviewDialog.tsx b/packages/browser/src/components/metadata/metadataMatching/reviewDialog/MatchReviewDialog.tsx index 95bf299d1..4ad932b1e 100644 --- a/packages/browser/src/components/metadata/metadataMatching/reviewDialog/MatchReviewDialog.tsx +++ b/packages/browser/src/components/metadata/metadataMatching/reviewDialog/MatchReviewDialog.tsx @@ -11,7 +11,8 @@ export function MatchReviewDialog() { const record = records[currentRecordIndex] const isMedia = !!record?.mediaId - const entityName = record?.media?.resolvedName ?? record?.series?.resolvedName ?? 'Unknown' + const entityName = + record?.media?.resolvedName ?? record?.series?.resolvedName ?? t('common.unknown') if (!record) return null diff --git a/packages/browser/src/scenes/settings/server/general/ServerEmojisSection.tsx b/packages/browser/src/scenes/settings/server/general/ServerEmojisSection.tsx index ae95c61fb..f3de113cd 100644 --- a/packages/browser/src/scenes/settings/server/general/ServerEmojisSection.tsx +++ b/packages/browser/src/scenes/settings/server/general/ServerEmojisSection.tsx @@ -266,7 +266,7 @@ export default function ServerEmojisSection() { {previewUrl && ( {emojiName )} diff --git a/packages/browser/src/scenes/settings/server/metadataIntegrations/providers/CreateProviderDialog.tsx b/packages/browser/src/scenes/settings/server/metadataIntegrations/providers/CreateProviderDialog.tsx index b96f197bb..909aa2f88 100644 --- a/packages/browser/src/scenes/settings/server/metadataIntegrations/providers/CreateProviderDialog.tsx +++ b/packages/browser/src/scenes/settings/server/metadataIntegrations/providers/CreateProviderDialog.tsx @@ -89,7 +89,10 @@ export function CreateProviderDialog() { } const dialogTitle = - step === 0 ? 'Choose provider' : (PROVIDER_LABELS[selectedProvider || ''] ?? 'Provider') + step === 0 + ? t('settingsScene.server/metadataIntegrations.chooseProvider') + : (PROVIDER_LABELS[selectedProvider || ''] ?? + t('settingsScene.server/metadataIntegrations.providerFallback')) return ( <> diff --git a/packages/browser/src/scenes/settings/server/metadataIntegrations/providers/EditProviderDialog.tsx b/packages/browser/src/scenes/settings/server/metadataIntegrations/providers/EditProviderDialog.tsx index f75305513..9ec0f9865 100644 --- a/packages/browser/src/scenes/settings/server/metadataIntegrations/providers/EditProviderDialog.tsx +++ b/packages/browser/src/scenes/settings/server/metadataIntegrations/providers/EditProviderDialog.tsx @@ -78,7 +78,9 @@ export function EditProviderDialog({ provider }: Props) { }) } - const dialogTitle = PROVIDER_LABELS[provider.providerType] ?? 'Provider' + const dialogTitle = + PROVIDER_LABELS[provider.providerType] ?? + t('settingsScene.server/metadataIntegrations.providerFallback') const isPending = isEditPending || isDeletePending diff --git a/packages/i18n/src/locales/en-US.json b/packages/i18n/src/locales/en-US.json index 31704cfdf..c9a1ead5e 100644 --- a/packages/i18n/src/locales/en-US.json +++ b/packages/i18n/src/locales/en-US.json @@ -2457,6 +2457,7 @@ "title": "Custom emojis", "description": "Upload and manage custom image emojis for your server", "dropzone": "Drop an image or click to select", + "previewAlt": "Emoji preview", "input": { "label": "Emoji name", "placeholder": "e.g. party_parrot" @@ -2999,6 +3000,8 @@ "helmet": "Metadata integrations", "title": "Metadata integrations", "addProvider": "Add provider", + "chooseProvider": "Choose provider", + "providerFallback": "Provider", "createProviderError": "Failed to create provider", "updateProviderError": "Failed to update provider", "noProviders": "No providers configured yet. Add one to get started", @@ -3297,6 +3300,7 @@ "nextStep": "Next step", "continueToReview": "Continue to review", "book": "Book", + "library": "Library", "series": "Series", "fileUpload": { "someRejected": "Some files were rejected. Please check the file types and try again", @@ -3634,6 +3638,7 @@ }, "common": { "book": "Book", + "avatar": "Avatar", "cancel": "Cancel", "chapter": "Chapter", "clear": "Clear", @@ -3647,6 +3652,7 @@ "download": "Download", "downloaded": "Downloaded", "downloads": "Downloads", + "files": "Files", "done": "Done", "edit": "Edit", "enabled": "Enabled", @@ -3803,6 +3809,7 @@ "startedAt": "Started {{date}}", "notStarted": "You have not started this book yet", "moderatedBy": "Moderated by", + "moderator": "Moderator", "pinnedRooms": "Pinned Rooms", "activeDiscussions": "Active Discussions", "seeBook": "See book", diff --git a/packages/i18n/src/locales/ko-KR.json b/packages/i18n/src/locales/ko-KR.json index 033b64bba..9222902f7 100644 --- a/packages/i18n/src/locales/ko-KR.json +++ b/packages/i18n/src/locales/ko-KR.json @@ -2457,6 +2457,7 @@ "title": "사용자 지정 이모지", "description": "서버에서 사용할 이미지 이모지를 업로드하고 관리합니다", "dropzone": "이미지를 끌어다 놓거나 클릭하여 선택하세요", + "previewAlt": "이모지 미리보기", "input": { "label": "이모지 이름", "placeholder": "예: party_parrot" @@ -2999,6 +3000,8 @@ "helmet": "메타데이터 연동", "title": "메타데이터 연동", "addProvider": "공급자 추가", + "chooseProvider": "공급자 선택", + "providerFallback": "공급자", "createProviderError": "공급자를 만들지 못했습니다", "updateProviderError": "공급자를 수정하지 못했습니다", "noProviders": "아직 구성된 공급자가 없습니다. 공급자를 추가하여 시작하세요", @@ -3297,6 +3300,7 @@ "nextStep": "다음 단계", "continueToReview": "검토 단계로 이동", "book": "책", + "library": "라이브러리", "series": "시리즈", "fileUpload": { "someRejected": "일부 파일을 추가하지 못했습니다. 파일 형식을 확인하고 다시 시도하세요", @@ -3634,6 +3638,7 @@ }, "common": { "book": "책", + "avatar": "아바타", "cancel": "취소", "chapter": "장", "clear": "지우기", @@ -3647,6 +3652,7 @@ "download": "다운로드", "downloaded": "다운로드됨", "downloads": "다운로드", + "files": "파일", "done": "완료", "edit": "편집", "enabled": "사용", @@ -3803,6 +3809,7 @@ "startedAt": "{{date}} 시작", "notStarted": "아직 이 책을 읽기 시작하지 않았습니다", "moderatedBy": "운영자", + "moderator": "운영자", "pinnedRooms": "고정된 토론방", "activeDiscussions": "진행 중인 토론", "seeBook": "책 보기", From 7f7208693e355a1cef5011409731b732ce8ed585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 19:55:30 +0900 Subject: [PATCH 51/54] =?UTF-8?q?style(i18n):=20=EB=B2=88=EC=97=AD=20?= =?UTF-8?q?=ED=9B=85=20import=EC=99=80=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/browser/src/AppLayout.tsx | 2 +- packages/browser/src/components/book/table/CoverImageCell.tsx | 2 +- packages/browser/src/components/explorer/FileExplorer.tsx | 3 ++- .../browser/src/components/explorer/FileExplorerFooter.tsx | 2 +- .../browser/src/components/filters/form/ReadStatusSelect.tsx | 3 ++- .../src/components/library/DeleteLibraryConfirmation.tsx | 2 +- .../components/metadata/metadataEditor/MetadataEditorTable.tsx | 2 +- .../src/components/readers/epub/controls/FullScreenToggle.tsx | 2 +- .../browser/src/components/series/table/CoverImageCell.tsx | 2 +- packages/browser/src/components/table/TableFilterInput.tsx | 2 +- packages/browser/src/scenes/book/DeleteHistoryConfirmation.tsx | 2 +- .../settings/integrations/analysis/LibraryAnalysisScene.tsx | 2 +- .../browser/src/scenes/series/CompleteSeriesConfirmation.tsx | 2 +- .../src/scenes/smartList/items/table/CoverImageCell.tsx | 2 +- .../browser/src/scenes/smartList/items/table/mediaColumns.tsx | 2 +- 15 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/browser/src/AppLayout.tsx b/packages/browser/src/AppLayout.tsx index dbb829288..49d7aeea6 100644 --- a/packages/browser/src/AppLayout.tsx +++ b/packages/browser/src/AppLayout.tsx @@ -179,7 +179,7 @@ export function AppLayout() { console.error('Error logging out:', { error }) toast.error(t('common.logoutFailed')) } - }, [sdk, client, setUser, navigate]) + }, [sdk, client, setUser, navigate, t]) useEffect(() => { if (user) { diff --git a/packages/browser/src/components/book/table/CoverImageCell.tsx b/packages/browser/src/components/book/table/CoverImageCell.tsx index fe63f2e42..1dc13b05e 100644 --- a/packages/browser/src/components/book/table/CoverImageCell.tsx +++ b/packages/browser/src/components/book/table/CoverImageCell.tsx @@ -1,6 +1,6 @@ import { useSDK } from '@stump/client' -import { Book } from 'lucide-react' import { useLocaleContext } from '@stump/i18n' +import { Book } from 'lucide-react' import { useState } from 'react' import { EntityImage } from '@/components/entity' diff --git a/packages/browser/src/components/explorer/FileExplorer.tsx b/packages/browser/src/components/explorer/FileExplorer.tsx index ccb67ccae..7fe16a4b5 100644 --- a/packages/browser/src/components/explorer/FileExplorer.tsx +++ b/packages/browser/src/components/explorer/FileExplorer.tsx @@ -1,6 +1,7 @@ -import GenericEmptyState from '@/components/GenericEmptyState' import { useLocaleContext } from '@stump/i18n' +import GenericEmptyState from '@/components/GenericEmptyState' + import { useFileExplorerContext } from './context' import { FileGrid } from './grid' import { FileTable } from './table' diff --git a/packages/browser/src/components/explorer/FileExplorerFooter.tsx b/packages/browser/src/components/explorer/FileExplorerFooter.tsx index d4e64ae20..c1c5c19cf 100644 --- a/packages/browser/src/components/explorer/FileExplorerFooter.tsx +++ b/packages/browser/src/components/explorer/FileExplorerFooter.tsx @@ -1,5 +1,5 @@ -import { ChevronRight } from 'lucide-react' import { useLocaleContext } from '@stump/i18n' +import { ChevronRight } from 'lucide-react' import { Fragment, useMemo } from 'react' import { useFileExplorerContext } from './context' diff --git a/packages/browser/src/components/filters/form/ReadStatusSelect.tsx b/packages/browser/src/components/filters/form/ReadStatusSelect.tsx index 1dff618d1..49e743e85 100644 --- a/packages/browser/src/components/filters/form/ReadStatusSelect.tsx +++ b/packages/browser/src/components/filters/form/ReadStatusSelect.tsx @@ -1,6 +1,7 @@ -import GenericFilterMultiselect from './GenericFilterMultiselect' import { useLocaleContext } from '@stump/i18n' +import GenericFilterMultiselect from './GenericFilterMultiselect' + export default function ReadStatusSelect() { const { t } = useLocaleContext() return ( diff --git a/packages/browser/src/components/library/DeleteLibraryConfirmation.tsx b/packages/browser/src/components/library/DeleteLibraryConfirmation.tsx index c9ab1fcb8..34e49dd6a 100644 --- a/packages/browser/src/components/library/DeleteLibraryConfirmation.tsx +++ b/packages/browser/src/components/library/DeleteLibraryConfirmation.tsx @@ -76,7 +76,7 @@ export default function DeleteLibraryConfirmation({ } else { toast.error(t('libraryUi.deleteLibraryError')) } - }, [error]) + }, [error, t]) const entityI18nValues = { name: libraryName, type: 'library' } diff --git a/packages/browser/src/components/metadata/metadataEditor/MetadataEditorTable.tsx b/packages/browser/src/components/metadata/metadataEditor/MetadataEditorTable.tsx index 711a24a81..bede35a71 100644 --- a/packages/browser/src/components/metadata/metadataEditor/MetadataEditorTable.tsx +++ b/packages/browser/src/components/metadata/metadataEditor/MetadataEditorTable.tsx @@ -1,4 +1,5 @@ import { Card, cn } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { ColumnDef, flexRender, @@ -6,7 +7,6 @@ import { RowData, useReactTable, } from '@tanstack/react-table' -import { useLocaleContext } from '@stump/i18n' import { useCallback, useLayoutEffect, useRef } from 'react' import { useWindowSize } from 'rooks' diff --git a/packages/browser/src/components/readers/epub/controls/FullScreenToggle.tsx b/packages/browser/src/components/readers/epub/controls/FullScreenToggle.tsx index 799796fdb..6482657c8 100644 --- a/packages/browser/src/components/readers/epub/controls/FullScreenToggle.tsx +++ b/packages/browser/src/components/readers/epub/controls/FullScreenToggle.tsx @@ -1,5 +1,5 @@ -import { Fullscreen } from 'lucide-react' import { useLocaleContext } from '@stump/i18n' +import { Fullscreen } from 'lucide-react' import { useEpubReaderControls } from '../context' import ControlButton from './ControlButton' diff --git a/packages/browser/src/components/series/table/CoverImageCell.tsx b/packages/browser/src/components/series/table/CoverImageCell.tsx index 30d345d6d..3a8a4bcc1 100644 --- a/packages/browser/src/components/series/table/CoverImageCell.tsx +++ b/packages/browser/src/components/series/table/CoverImageCell.tsx @@ -1,6 +1,6 @@ import { useSDK } from '@stump/client' -import { Book } from 'lucide-react' import { useLocaleContext } from '@stump/i18n' +import { Book } from 'lucide-react' import { useState } from 'react' import { EntityImage } from '@/components/entity' diff --git a/packages/browser/src/components/table/TableFilterInput.tsx b/packages/browser/src/components/table/TableFilterInput.tsx index 4b5c24723..abd7f019f 100644 --- a/packages/browser/src/components/table/TableFilterInput.tsx +++ b/packages/browser/src/components/table/TableFilterInput.tsx @@ -1,5 +1,5 @@ -import { useDebounce } from 'rooks' import { useLocaleContext } from '@stump/i18n' +import { useDebounce } from 'rooks' type Props = { onChange: (value: string) => void diff --git a/packages/browser/src/scenes/book/DeleteHistoryConfirmation.tsx b/packages/browser/src/scenes/book/DeleteHistoryConfirmation.tsx index 3d6e3bfb2..05ee274ad 100644 --- a/packages/browser/src/scenes/book/DeleteHistoryConfirmation.tsx +++ b/packages/browser/src/scenes/book/DeleteHistoryConfirmation.tsx @@ -1,6 +1,6 @@ import { Alert, AlertDescription, AlertTitle, ConfirmationModal } from '@stump/components' -import { AlertTriangle } from 'lucide-react' import { useLocaleContext } from '@stump/i18n' +import { AlertTriangle } from 'lucide-react' type Props = { isOpen: boolean diff --git a/packages/browser/src/scenes/library/tabs/settings/integrations/analysis/LibraryAnalysisScene.tsx b/packages/browser/src/scenes/library/tabs/settings/integrations/analysis/LibraryAnalysisScene.tsx index 8737e8441..56e9bd791 100644 --- a/packages/browser/src/scenes/library/tabs/settings/integrations/analysis/LibraryAnalysisScene.tsx +++ b/packages/browser/src/scenes/library/tabs/settings/integrations/analysis/LibraryAnalysisScene.tsx @@ -1,6 +1,6 @@ import { Alert, AlertDescription, AlertTitle } from '@stump/components' -import { AlertTriangle } from 'lucide-react' import { useLocaleContext } from '@stump/i18n' +import { AlertTriangle } from 'lucide-react' import AnalyzeMedia from './AnalyzeMedia' diff --git a/packages/browser/src/scenes/series/CompleteSeriesConfirmation.tsx b/packages/browser/src/scenes/series/CompleteSeriesConfirmation.tsx index 8d3c511b5..86f3c3042 100644 --- a/packages/browser/src/scenes/series/CompleteSeriesConfirmation.tsx +++ b/packages/browser/src/scenes/series/CompleteSeriesConfirmation.tsx @@ -1,6 +1,6 @@ import { Alert, AlertTitle, ConfirmationModal } from '@stump/components' -import { AlertTriangle } from 'lucide-react' import { useLocaleContext } from '@stump/i18n' +import { AlertTriangle } from 'lucide-react' type Props = { isOpen: boolean diff --git a/packages/browser/src/scenes/smartList/items/table/CoverImageCell.tsx b/packages/browser/src/scenes/smartList/items/table/CoverImageCell.tsx index b27481dd1..887b972aa 100644 --- a/packages/browser/src/scenes/smartList/items/table/CoverImageCell.tsx +++ b/packages/browser/src/scenes/smartList/items/table/CoverImageCell.tsx @@ -1,6 +1,6 @@ import { useSDK } from '@stump/client' -import { Book } from 'lucide-react' import { useLocaleContext } from '@stump/i18n' +import { Book } from 'lucide-react' import { useState } from 'react' import { EntityImage } from '@/components/entity' diff --git a/packages/browser/src/scenes/smartList/items/table/mediaColumns.tsx b/packages/browser/src/scenes/smartList/items/table/mediaColumns.tsx index b0ba9520f..e06e5918f 100644 --- a/packages/browser/src/scenes/smartList/items/table/mediaColumns.tsx +++ b/packages/browser/src/scenes/smartList/items/table/mediaColumns.tsx @@ -4,8 +4,8 @@ import { ColumnSort } from '@stump/sdk' import { ColumnDef, createColumnHelper } from '@tanstack/react-table' import { format, intlFormat, isValid } from 'date-fns' -import paths from '@/paths' import TableColumnHeader from '@/components/table/TableColumnHeader' +import paths from '@/paths' import BookLinksCell from './BookLinksCell' import CoverImageCell from './CoverImageCell' From 8ed2cdbe4c7f88316d38480715032b87c8379a77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Thu, 6 Aug 2026 19:56:43 +0900 Subject: [PATCH 52/54] =?UTF-8?q?test(i18n):=20=EA=B5=AD=EC=A0=9C=ED=99=94?= =?UTF-8?q?=EB=90=9C=20=EB=A6=AC=EB=8D=94=EC=99=80=20API=20=ED=82=A4=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../container/__tests__/ImageScalingSelect.test.tsx | 8 ++++++-- .../container/__tests__/ReadingDirectionSelect.test.tsx | 8 +++++--- .../container/__tests__/ReadingModeSelect.test.tsx | 2 +- .../app/apiKeys/__tests__/APIKeyInspector.test.tsx | 4 ++-- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/browser/src/components/readers/imageBased/container/__tests__/ImageScalingSelect.test.tsx b/packages/browser/src/components/readers/imageBased/container/__tests__/ImageScalingSelect.test.tsx index 01820ec4b..8a47ec744 100644 --- a/packages/browser/src/components/readers/imageBased/container/__tests__/ImageScalingSelect.test.tsx +++ b/packages/browser/src/components/readers/imageBased/container/__tests__/ImageScalingSelect.test.tsx @@ -34,7 +34,9 @@ describe('ImageScalingSelect', () => { ReadingImageScaleFit.None, ] for (const option of validOptions) { - fireEvent.change(screen.getByLabelText('Image scaling'), { target: { value: option } }) + fireEvent.change(screen.getByLabelText('readerUi.imageScaling'), { + target: { value: option }, + }) expect(onChange).toHaveBeenCalledWith(option) } }) @@ -42,7 +44,9 @@ describe('ImageScalingSelect', () => { it('should not allow invalid image scaling options', () => { const onChange = jest.fn() render() - fireEvent.change(screen.getByLabelText('Image scaling'), { target: { value: 'invalid' } }) + fireEvent.change(screen.getByLabelText('readerUi.imageScaling'), { + target: { value: 'invalid' }, + }) expect(onChange).not.toHaveBeenCalled() }) }) diff --git a/packages/browser/src/components/readers/imageBased/container/__tests__/ReadingDirectionSelect.test.tsx b/packages/browser/src/components/readers/imageBased/container/__tests__/ReadingDirectionSelect.test.tsx index a3c89dbaf..f5e871c88 100644 --- a/packages/browser/src/components/readers/imageBased/container/__tests__/ReadingDirectionSelect.test.tsx +++ b/packages/browser/src/components/readers/imageBased/container/__tests__/ReadingDirectionSelect.test.tsx @@ -25,10 +25,10 @@ describe('ReadingDirectionSelect', () => { , ) - fireEvent.change(getByLabelText('Reading direction'), { target: { value: 'RTL' } }) + fireEvent.change(getByLabelText('readerUi.readingDirection'), { target: { value: 'RTL' } }) expect(onChange).toHaveBeenCalledWith(ReadingDirection.Rtl) - fireEvent.change(getByLabelText('Reading direction'), { target: { value: 'LTR' } }) + fireEvent.change(getByLabelText('readerUi.readingDirection'), { target: { value: 'LTR' } }) expect(onChange).toHaveBeenCalledWith(ReadingDirection.Ltr) }) @@ -38,7 +38,9 @@ describe('ReadingDirectionSelect', () => { , ) - fireEvent.change(getByLabelText('Reading direction'), { target: { value: 'invalid' } }) + fireEvent.change(getByLabelText('readerUi.readingDirection'), { + target: { value: 'invalid' }, + }) expect(onChange).not.toHaveBeenCalled() }) }) diff --git a/packages/browser/src/components/readers/imageBased/container/__tests__/ReadingModeSelect.test.tsx b/packages/browser/src/components/readers/imageBased/container/__tests__/ReadingModeSelect.test.tsx index 12cf67db3..7979672ef 100644 --- a/packages/browser/src/components/readers/imageBased/container/__tests__/ReadingModeSelect.test.tsx +++ b/packages/browser/src/components/readers/imageBased/container/__tests__/ReadingModeSelect.test.tsx @@ -25,7 +25,7 @@ describe('ReadingModeSelect', () => { , ) - fireEvent.change(getByLabelText('Flow'), { target: { value: 'invalid' } }) + fireEvent.change(getByLabelText('readerUi.flow'), { target: { value: 'invalid' } }) expect(onChange).not.toHaveBeenCalled() }) }) diff --git a/packages/browser/src/scenes/settings/app/apiKeys/__tests__/APIKeyInspector.test.tsx b/packages/browser/src/scenes/settings/app/apiKeys/__tests__/APIKeyInspector.test.tsx index 22dc5fcd7..3c6bc064e 100644 --- a/packages/browser/src/scenes/settings/app/apiKeys/__tests__/APIKeyInspector.test.tsx +++ b/packages/browser/src/scenes/settings/app/apiKeys/__tests__/APIKeyInspector.test.tsx @@ -73,7 +73,7 @@ describe('APIKeyInspector', () => { ) expect(screen.getByTestId('permissions-meta')).toBeInTheDocument() - expect(screen.getByText('Permissions')).toBeInTheDocument() + expect(screen.getByText('settingsUi.permissions')).toBeInTheDocument() expect(screen.getAllByTestId('permission-badge')).toHaveLength(2) }) @@ -97,7 +97,7 @@ describe('APIKeyInspector', () => { ) expect(screen.getByTestId('permissions-meta')).toBeInTheDocument() - expect(screen.getByText('Permissions')).toBeInTheDocument() + expect(screen.getByText('settingsUi.permissions')).toBeInTheDocument() expect(screen.getAllByTestId('permission-badge')).toHaveLength(2) }) From 71ade5ab55091109e3ad9688390150e7b363ba39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Sat, 8 Aug 2026 16:02:30 +0900 Subject: [PATCH 53/54] fix(i18n): keep locale available to error fallback --- packages/browser/src/App.tsx | 12 ++++-- packages/browser/src/AppRouter.tsx | 40 ++++++++---------- packages/browser/src/__tests__/App.test.tsx | 46 +++++++++++++++++++++ 3 files changed, 72 insertions(+), 26 deletions(-) create mode 100644 packages/browser/src/__tests__/App.test.tsx diff --git a/packages/browser/src/App.tsx b/packages/browser/src/App.tsx index 9a4e9241e..c7fe4b6bb 100644 --- a/packages/browser/src/App.tsx +++ b/packages/browser/src/App.tsx @@ -2,6 +2,7 @@ import './styles/index.css' import '@stump/components/styles/overrides.css' import { SDKProvider, StumpClientContextProvider, StumpClientProps } from '@stump/client' +import { type AllowedLocale, LocaleProvider } from '@stump/i18n' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { useEffect, useState } from 'react' import { ErrorBoundary } from 'react-error-boundary' @@ -16,10 +17,15 @@ import { useApplyTheme } from './hooks' import { useAppStore, useDebugStore, useUserStore } from './stores' export default function StumpWebClient(props: StumpClientProps) { + const locale = useUserStore((store) => store.userPreferences?.locale) + const resolvedLocale = (locale as AllowedLocale) || 'en-US' + return ( - - - + + + + + ) } diff --git a/packages/browser/src/AppRouter.tsx b/packages/browser/src/AppRouter.tsx index 07e656ad7..724d50217 100644 --- a/packages/browser/src/AppRouter.tsx +++ b/packages/browser/src/AppRouter.tsx @@ -1,5 +1,3 @@ -import { LocaleProvider } from '@stump/i18n' -import { type AllowedLocale } from '@stump/i18n' import { lazy } from 'react' import { Route, Routes } from 'react-router-dom' @@ -11,7 +9,7 @@ import { LibraryRouter } from './scenes/library' import { SeriesRouter } from './scenes/series' import { SettingsRouter } from './scenes/settings' import { SmartListRouter } from './scenes/smartList' -import { useAppStore, useUserStore } from './stores' +import { useAppStore } from './stores' const HomeScene = lazy(() => import('./scenes/home')) const FourOhFour = lazy(() => import('./scenes/error/FourOhFour.tsx')) @@ -25,33 +23,29 @@ type AppRouterProps = { } export function AppRouter({ basePath }: AppRouterProps = {}) { - const locale = useUserStore((store) => store.userPreferences?.locale) const baseUrl = useAppStore((state) => state.baseUrl) - const resolvedLocale = (locale as AllowedLocale) || 'en-US' if (!baseUrl) { throw new Error('Base URL is not set') } return ( - - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + - } /> - } /> - } /> - - - + } /> + } /> + } /> + + ) } diff --git a/packages/browser/src/__tests__/App.test.tsx b/packages/browser/src/__tests__/App.test.tsx new file mode 100644 index 000000000..f8a42b58c --- /dev/null +++ b/packages/browser/src/__tests__/App.test.tsx @@ -0,0 +1,46 @@ +import type { UserPreferences } from '@stump/graphql' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' + +import StumpWebClient from '../App' +import { useUserStore } from '../stores' + +jest.mock('../styles/index.css', () => ({})) +jest.mock('@stump/components/styles/overrides.css', () => ({})) + +jest.mock('../AppRouter', () => ({ + AppRouter: () => { + throw new Error('Test error') + }, +})) + +describe('StumpWebClient', () => { + const originalError = console.error + + beforeAll(() => { + console.error = jest.fn() + }) + + afterAll(() => { + console.error = originalError + }) + + afterEach(() => { + useUserStore.getState().setUserPreferences(null) + }) + + it('renders the error fallback with the selected locale', async () => { + useUserStore.getState().setUserPreferences({ locale: 'ko-KR' } as unknown as UserPreferences) + + render( + + + , + ) + + expect( + await screen.findByRole('heading', { name: '치명적인 오류가 발생했습니다' }), + ).toBeInTheDocument() + expect(screen.getByRole('link', { name: '홈으로 이동' })).toBeInTheDocument() + }) +}) From b9f7ebd114436b0807981e5ac97a2ea9ac370bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Sun, 9 Aug 2026 03:58:16 +0900 Subject: [PATCH 54/54] refactor(i18n): group locale keys by component --- .../src/components/PagePopoverForm.tsx | 6 +- .../src/components/ServerStatusOverlay.tsx | 2 +- .../src/components/TableOrGridLayout.tsx | 4 +- packages/browser/src/components/TagSelect.tsx | 2 +- .../browser/src/components/book/BookCard.tsx | 6 +- .../book/metadata/MediaMetadataEditor.tsx | 4 +- .../components/book/table/CoverImageCell.tsx | 2 +- .../bookClub/DeleteBookClubConfirmation.tsx | 8 +- .../bookClub/books/BookClubBookItem.tsx | 18 +- .../bookClub/books/BookClubBooks.tsx | 12 +- .../src/components/explorer/FileExplorer.tsx | 5 +- .../src/components/explorer/LayoutButtons.tsx | 4 +- .../src/components/filters/OrderBy.tsx | 2 +- .../browser/src/components/filters/Search.tsx | 8 +- .../components/filters/URLFilterDrawer.tsx | 6 +- .../src/components/filters/URLOrdering.tsx | 2 +- .../src/components/filters/URLPageSize.tsx | 2 +- .../src/components/filters/URLPagination.tsx | 6 +- .../filters/form/AgeRatingFilter.tsx | 12 +- .../filters/form/ExtensionSelect.tsx | 4 +- .../filters/form/MediaFilterForm.tsx | 20 +- .../components/filters/form/OrderBySelect.tsx | 4 +- .../filters/form/ReadStatusSelect.tsx | 8 +- .../filters/form/SeriesFilterForm.tsx | 6 +- .../library/DeleteLibraryConfirmation.tsx | 4 +- .../components/library/LastVisitedLibrary.tsx | 2 +- .../sections/DefaultReadingSettings.tsx | 24 +- .../GenerateThumbnailColorsSwitch.tsx | 4 +- .../components/markdown/MarkdownEditor.tsx | 13 +- .../metadataEditor/cells/BadgeListCell.tsx | 2 +- .../cells/InlineBadgeListInput.tsx | 2 +- .../cells/InlineNumberInput.tsx | 4 +- .../metadataEditor/cells/InlineTextInput.tsx | 4 +- .../metadataEditor/cells/NumberCell.tsx | 4 +- .../metadataEditor/cells/TextCell.tsx | 4 +- .../metadataMatching/useMatchActions.ts | 2 +- .../navigation/mobile/NavigationButtons.tsx | 4 +- .../navigation/sidebar/Settings.tsx | 2 +- .../book-club/BookClubNavigationItem.tsx | 2 +- .../components/readers/epub/EpubJsReader.tsx | 4 +- .../readers/epub/EpubReaderHeader.tsx | 5 +- .../readers/epub/controls/FontFamily.tsx | 2 +- .../readers/epub/controls/FontSizeControl.tsx | 4 +- .../epub/controls/FullScreenToggle.tsx | 2 +- .../epub/controls/LineHeightControl.tsx | 2 +- .../epub/controls/ReadingDirection.tsx | 6 +- .../readers/epub/controls/ReadingMode.tsx | 9 +- .../readers/epub/controls/SearchCommand.tsx | 6 +- .../readers/epub/controls/ThemeControls.tsx | 6 +- .../readers/epub/locations/Bookmarks.tsx | 2 +- .../epub/locations/LocationManager.tsx | 14 +- .../epub/locations/TableOfContents.tsx | 2 +- .../container/BrightnessControl.tsx | 2 +- .../container/DoubleSpreadBehavior.tsx | 8 +- .../container/ImageScalingSelect.tsx | 10 +- .../imageBased/container/NextInSeries.tsx | 4 +- .../imageBased/container/ReaderFooter.tsx | 4 +- .../imageBased/container/ReaderHeader.tsx | 2 +- .../imageBased/container/ReaderSettings.tsx | 16 +- .../container/ReadingDirectionSelect.tsx | 6 +- .../container/ReadingModeSelect.tsx | 11 +- .../imageBased/container/SettingsDialog.tsx | 4 +- .../imageBased/container/TimerMenu.tsx | 4 +- .../__tests__/ImageScalingSelect.test.tsx | 4 +- .../__tests__/ReadingDirectionSelect.test.tsx | 10 +- .../__tests__/ReadingModeSelect.test.tsx | 4 +- .../readers/pdf/NativePDFViewer.tsx | 6 +- .../components/series/LibrarySeriesCard.tsx | 2 +- .../src/components/series/SeriesCard.tsx | 4 +- .../series/SeriesExplorationLayout.tsx | 4 +- .../components/series/StackedSeriesCard.tsx | 2 +- .../series/metadata/SeriesMetadataEditor.tsx | 4 +- .../series/table/CoverImageCell.tsx | 2 +- .../sections/FilterConfigJSON.tsx | 4 +- .../browser/src/components/table/Table.tsx | 4 +- .../src/components/table/TableFilterInput.tsx | 2 +- .../src/components/table/TableFooter.tsx | 4 +- .../unimplemented/UnderConstruction.tsx | 2 +- .../src/scenes/book/BookActionMenu.tsx | 10 +- .../scenes/book/BookOverviewSceneHeader.tsx | 33 +- .../scenes/book/DeleteHistoryConfirmation.tsx | 6 +- .../src/scenes/bookClub/BookClubHeader.tsx | 4 +- .../scenes/bookClub/UserBookClubsScene.tsx | 18 +- .../settings/basics/BasicSettingsScene.tsx | 2 +- .../tabs/settings/members/MembersTable.tsx | 2 +- .../members/RemoveMemberConfirmation.tsx | 4 +- .../tabs/settings/roles/MemberSpecDisplay.tsx | 8 +- .../tabs/settings/scheduler/AddBookCard.tsx | 30 +- .../scheduler/CreateOrAddToScheduleForm.tsx | 2 +- .../createLibrary/CreateLibraryScene.tsx | 2 +- .../src/scenes/home/ContinueReading.tsx | 2 +- .../browser/src/scenes/home/NoLibraries.tsx | 2 +- .../settings/basics/BasicSettingsScene.tsx | 2 +- .../danger/deletion/DeleteLibrary.tsx | 2 +- .../analysis/LibraryAnalysisScene.tsx | 4 +- .../scanner/customScan/ScanConfigForm.tsx | 10 +- .../thumbnails/DeleteLibraryThumbnails.tsx | 18 +- .../thumbnails/ProcessLibraryThumbnails.tsx | 10 +- .../thumbnails/RegenerateThumbnails.tsx | 10 +- .../series/CompleteSeriesConfirmation.tsx | 4 +- .../tabs/settings/SeriesSettingsScene.tsx | 22 +- .../settings/app/apiKeys/APIKeyInspector.tsx | 2 +- .../app/apiKeys/CreateOrUpdateAPIKeyForm.tsx | 2 +- .../app/apiKeys/DeleteAPIKeyConfirmModal.tsx | 6 +- .../__tests__/APIKeyInspector.test.tsx | 8 +- .../app/preferences/DebugSettings.tsx | 8 +- .../settings/app/reader/DefaultFontFamily.tsx | 2 +- .../app/reader/DefaultReadingDirection.tsx | 10 +- .../app/reader/ReaderDefaultSettingsScene.tsx | 8 +- .../devices/DeleteDeviceConfirmation.tsx | 8 +- .../server/email/devices/DevicesTable.tsx | 14 +- .../server/email/emailers/EmailerListItem.tsx | 6 +- .../emailers/EmailerSendHistoryTable.tsx | 18 +- .../EmailerSendRecordAttachmentTable.tsx | 18 +- .../server/email/emailers/EmailersList.tsx | 2 +- .../server/general/ServerEmojisSection.tsx | 2 +- .../server/general/ServerPublicURL.tsx | 4 +- .../settings/server/jobs/JobActionMenu.tsx | 12 +- .../settings/server/jobs/JobDataInspector.tsx | 9 +- .../providers/EditProviderDialog.tsx | 6 +- .../providers/ExistingProviderCard.tsx | 2 +- .../users/user-table/UserActionMenu.tsx | 12 +- .../smartList/items/table/CoverImageCell.tsx | 2 +- .../items/table/FilterBottomDrawer.tsx | 2 +- packages/i18n/src/locales/en-US.json | 863 ++++++++++------- packages/i18n/src/locales/ko-KR.json | 878 +++++++++++------- 126 files changed, 1553 insertions(+), 981 deletions(-) diff --git a/packages/browser/src/components/PagePopoverForm.tsx b/packages/browser/src/components/PagePopoverForm.tsx index ee2ec4497..54d2e7ded 100644 --- a/packages/browser/src/components/PagePopoverForm.tsx +++ b/packages/browser/src/components/PagePopoverForm.tsx @@ -34,7 +34,7 @@ export default function PagePopoverForm({ return num > 0 && num <= totalPages }, () => ({ - message: t('controlUi.pageRangeError', { total: totalPages }), + message: t('controlUi.pagination.rangeError', { total: totalPages }), }), ), }) @@ -74,13 +74,13 @@ export default function PagePopoverForm({
{ if (ref) { diff --git a/packages/browser/src/components/ServerStatusOverlay.tsx b/packages/browser/src/components/ServerStatusOverlay.tsx index e5243adfe..2c8f89886 100644 --- a/packages/browser/src/components/ServerStatusOverlay.tsx +++ b/packages/browser/src/components/ServerStatusOverlay.tsx @@ -53,7 +53,7 @@ export default function ServerStatusOverlay() { {t('serverStatus.checkConnection')}{' '} - {t('serverStatus.changeUrlLink')} + {t('common.clickHere')} {' '} {t('serverStatus.changeUrlSuffix')} diff --git a/packages/browser/src/components/TableOrGridLayout.tsx b/packages/browser/src/components/TableOrGridLayout.tsx index b7c82a62d..07c232eaa 100644 --- a/packages/browser/src/components/TableOrGridLayout.tsx +++ b/packages/browser/src/components/TableOrGridLayout.tsx @@ -12,7 +12,7 @@ export default function TableOrGridLayout({ layout, setLayout }: Props) { const { t } = useLocaleContext() return (
- + - + a.label.localeCompare(b.label)).map(({ value }) => value)} diff --git a/packages/browser/src/components/book/BookCard.tsx b/packages/browser/src/components/book/BookCard.tsx index 42be1926e..f6d0319c4 100644 --- a/packages/browser/src/components/book/BookCard.tsx +++ b/packages/browser/src/components/book/BookCard.tsx @@ -134,7 +134,7 @@ const BookCard = memo(function BookCard({ if (isMissing) { return ( - {t('entityUi.fileMissing')} + {t('entityUi.bookCard.fileMissing')} ) } @@ -147,7 +147,7 @@ const BookCard = memo(function BookCard({ {!isEbookProgress && ( - {t('entityUi.pagesLeft', { count: pagesLeft })} + {t('entityUi.bookCard.pagesLeft', { count: pagesLeft })} )}
@@ -155,7 +155,7 @@ const BookCard = memo(function BookCard({ } else if (progressPercent === 100) { return ( - {t('entityUi.completed')} + {t('common.completed')} ) } diff --git a/packages/browser/src/components/book/metadata/MediaMetadataEditor.tsx b/packages/browser/src/components/book/metadata/MediaMetadataEditor.tsx index de4d82919..1e5b6f44a 100644 --- a/packages/browser/src/components/book/metadata/MediaMetadataEditor.tsx +++ b/packages/browser/src/components/book/metadata/MediaMetadataEditor.tsx @@ -304,14 +304,14 @@ export default function MediaMetadataEditor({ mediaId, data }: Props) { }, onError: (error) => { console.error('Failed to update metadata', error) - toast.error(t('entityUi.metadataUpdateFailed')) + toast.error(t('entityUi.metadataEditor.errors.updateFailed')) }, }) const { mutate: setLocked } = useGraphQLMutation(setLockedFieldsMutation, { onError: () => { setLockedFields(new Set(metadata?.lockedFields ?? [])) - toast.error(t('entityUi.lockedFieldsUpdateFailed')) + toast.error(t('entityUi.metadataEditor.errors.lockedFieldsUpdateFailed')) }, }) diff --git a/packages/browser/src/components/book/table/CoverImageCell.tsx b/packages/browser/src/components/book/table/CoverImageCell.tsx index 1dc13b05e..34f3a1ffd 100644 --- a/packages/browser/src/components/book/table/CoverImageCell.tsx +++ b/packages/browser/src/components/book/table/CoverImageCell.tsx @@ -43,7 +43,7 @@ export default function CoverImageCell({ id, title }: Props) { if (showFallback) { return (
{ console.error('Error deleting book club:', error) - toast.error(t('bookClubUi.deleteError')) + toast.error(t('bookClubUi.deleteConfirmation.error')) }, }) return ( - {t('bookClubUi.currentlyReading')} + {t('bookClubUi.bookItem.currentlyReading')} ) } else { return ( - {t('bookClubUi.pastBook')} + {t('bookClubUi.bookItem.pastBook')} ) } @@ -138,7 +138,9 @@ export default function BookClubBookItem({ data }: Props) { {author && {author}} {link && ( - {isExternal ? t('bookClubUi.externalLink') : t('bookClubUi.accessBook')} + {isExternal + ? t('bookClubUi.bookItem.externalLink') + : t('bookClubUi.bookItem.accessBook')} )}
@@ -174,10 +176,10 @@ export default function BookClubBookItem({ data }: Props) { )} > {daysInfo.end - ? t('bookClubUi.readingDuration', { + ? t('bookClubUi.bookItem.readingDuration', { count: differenceInDays(daysInfo.end, daysInfo.start), }) - : t('bookClubUi.notCompletedYet')} + : t('bookClubUi.bookItem.notCompletedYet')}
)} diff --git a/packages/browser/src/components/bookClub/books/BookClubBooks.tsx b/packages/browser/src/components/bookClub/books/BookClubBooks.tsx index 724f40d59..88ff5cd94 100644 --- a/packages/browser/src/components/bookClub/books/BookClubBooks.tsx +++ b/packages/browser/src/components/bookClub/books/BookClubBooks.tsx @@ -75,7 +75,9 @@ export default function BookClubBooks() { onClick={togglePastBooks} > - {showPastBooks ? t('bookClubUi.hidePastBooks') : t('bookClubUi.showPastBooks')} + {showPastBooks + ? t('bookClubUi.booksList.actions.hidePast') + : t('bookClubUi.booksList.actions.showPast')} @@ -97,14 +99,14 @@ export default function BookClubBooks() { return (
{viewerCanManage && ( - {t('bookClubUi.createSchedule')} + {t('bookClubUi.booksList.empty.createSchedule')} )}
@@ -128,7 +130,7 @@ export default function BookClubBooks() {
{!!bookClub.currentBook && ( - {t('bookClubUi.books')} + {t('bookClubUi.booksList.title')} )} {renderContent()} diff --git a/packages/browser/src/components/explorer/FileExplorer.tsx b/packages/browser/src/components/explorer/FileExplorer.tsx index 7fe16a4b5..d957681e7 100644 --- a/packages/browser/src/components/explorer/FileExplorer.tsx +++ b/packages/browser/src/components/explorer/FileExplorer.tsx @@ -16,7 +16,10 @@ export default function FileExplorer() { if (!files.length) { return (
- +
) } diff --git a/packages/browser/src/components/explorer/LayoutButtons.tsx b/packages/browser/src/components/explorer/LayoutButtons.tsx index a7d8e046d..0def3b25f 100644 --- a/packages/browser/src/components/explorer/LayoutButtons.tsx +++ b/packages/browser/src/components/explorer/LayoutButtons.tsx @@ -10,7 +10,7 @@ export default function LayoutButtons() { return (
- + - + - {t('controlUi.orderBy')} + {t('controlUi.ordering.label')} diff --git a/packages/browser/src/components/filters/Search.tsx b/packages/browser/src/components/filters/Search.tsx index b17271f2d..ade98a082 100644 --- a/packages/browser/src/components/filters/Search.tsx +++ b/packages/browser/src/components/filters/Search.tsx @@ -90,7 +90,7 @@ export default function Search({ tabIndex={expanded ? undefined : 0} onClick={expanded ? undefined : handleExpand} onKeyDown={expanded ? undefined : (e) => e.key === 'Enter' && handleExpand()} - title={isDisabled ? t('filterUi.searchUnavailable') : undefined} + title={isDisabled ? t('filterUi.search.unavailable') : undefined} className={cn( 'h-8 gap-2 text-sm ease-in-out relative flex shrink-0 cursor-pointer items-center overflow-hidden rounded-md border border-border bg-transparent transition-all duration-300', 'text-muted-foreground hover:bg-muted/30 hover:text-foreground', @@ -107,12 +107,14 @@ export default function Search({ value={value} onChange={(e) => setValue(e.target.value)} onBlur={handleBlur} - placeholder={placeholder || t('filterUi.search')} + placeholder={placeholder || t('filterUi.search.label')} disabled={isDisabled} className="pr-8 text-sm h-full w-full bg-transparent text-foreground outline-none placeholder:text-muted-foreground" /> ) : ( - {t('filterUi.search')} + + {t('filterUi.search.label')} + )} {showLoader && ( diff --git a/packages/browser/src/components/filters/URLFilterDrawer.tsx b/packages/browser/src/components/filters/URLFilterDrawer.tsx index ab1b64881..51bd6212e 100644 --- a/packages/browser/src/components/filters/URLFilterDrawer.tsx +++ b/packages/browser/src/components/filters/URLFilterDrawer.tsx @@ -50,10 +50,10 @@ export default function URLFilterDrawer({ entity }: Props) { open={isOpen} onClose={() => setIsOpen(false)} onOpen={() => setIsOpen(true)} - title={t('filterUi.configureTitle')} - description={t('filterUi.configureDescription')} + title={t('filterUi.urlFilterDrawer.title')} + description={t('filterUi.urlFilterDrawer.description')} trigger={ - + - + diff --git a/packages/browser/src/components/filters/URLPageSize.tsx b/packages/browser/src/components/filters/URLPageSize.tsx index ffcbda5f1..d98dcf02e 100644 --- a/packages/browser/src/components/filters/URLPageSize.tsx +++ b/packages/browser/src/components/filters/URLPageSize.tsx @@ -39,7 +39,7 @@ export default function URLPageSize() { min={1} /> - {t('controlUi.perPage')} + {t('controlUi.pagination.perPage')} ) diff --git a/packages/browser/src/components/filters/URLPagination.tsx b/packages/browser/src/components/filters/URLPagination.tsx index 15787e770..157fdcd74 100644 --- a/packages/browser/src/components/filters/URLPagination.tsx +++ b/packages/browser/src/components/filters/URLPagination.tsx @@ -78,11 +78,11 @@ export default function URLPagination({ pages, currentPage, onChangePage, onPref min={1} /> - {t('controlUi.ofPages', { pages })} + {t('controlUi.pagination.ofPages', { pages })}
- + - + - + {!!seriesContext && ( setOnlyFromSeries((prev) => !prev)} /> @@ -231,7 +231,7 @@ export default function MediaFilterForm() { <> ({ label: genre, value: genre.toLowerCase() })) || [] } @@ -239,7 +239,7 @@ export default function MediaFilterForm() { ({ label: writer, value: writer.toLowerCase() })) || [] } @@ -247,7 +247,7 @@ export default function MediaFilterForm() { ({ label: penciller, @@ -258,7 +258,7 @@ export default function MediaFilterForm() { ({ label: colorist, @@ -269,7 +269,7 @@ export default function MediaFilterForm() { ({ label: letterer, @@ -280,7 +280,7 @@ export default function MediaFilterForm() { ({ label: inker, value: inker.toLowerCase() })) || [] } @@ -288,7 +288,7 @@ export default function MediaFilterForm() { ({ label: publisher, @@ -299,7 +299,7 @@ export default function MediaFilterForm() { ({ label: editor, value: editor.toLowerCase() })) || [] } @@ -307,7 +307,7 @@ export default function MediaFilterForm() { ({ label: character, diff --git a/packages/browser/src/components/filters/form/OrderBySelect.tsx b/packages/browser/src/components/filters/form/OrderBySelect.tsx index 532587845..4308ac24f 100644 --- a/packages/browser/src/components/filters/form/OrderBySelect.tsx +++ b/packages/browser/src/components/filters/form/OrderBySelect.tsx @@ -58,11 +58,11 @@ export default function OrderBySelect({ entity, value, onChange }: Props) { return (
onChange?.(e.target.value as OrderingField)} size="sm" diff --git a/packages/browser/src/components/filters/form/ReadStatusSelect.tsx b/packages/browser/src/components/filters/form/ReadStatusSelect.tsx index 49e743e85..8b885756d 100644 --- a/packages/browser/src/components/filters/form/ReadStatusSelect.tsx +++ b/packages/browser/src/components/filters/form/ReadStatusSelect.tsx @@ -7,18 +7,18 @@ export default function ReadStatusSelect() { return ( diff --git a/packages/browser/src/components/library/DeleteLibraryConfirmation.tsx b/packages/browser/src/components/library/DeleteLibraryConfirmation.tsx index 34e49dd6a..2efab8029 100644 --- a/packages/browser/src/components/library/DeleteLibraryConfirmation.tsx +++ b/packages/browser/src/components/library/DeleteLibraryConfirmation.tsx @@ -72,9 +72,9 @@ export default function DeleteLibraryConfirmation({ console.error(error) if (isAxiosError(error)) { - toast.error(error.message || t('libraryUi.deleteLibraryError')) + toast.error(error.message || t('libraryUi.deleteLibrary.error')) } else { - toast.error(t('libraryUi.deleteLibraryError')) + toast.error(t('libraryUi.deleteLibrary.error')) } }, [error, t]) diff --git a/packages/browser/src/components/library/LastVisitedLibrary.tsx b/packages/browser/src/components/library/LastVisitedLibrary.tsx index 8daaf86ed..d2e52d5ab 100644 --- a/packages/browser/src/components/library/LastVisitedLibrary.tsx +++ b/packages/browser/src/components/library/LastVisitedLibrary.tsx @@ -37,7 +37,7 @@ export default function LastVisitedLibrary({ container }: Props) { const renderContent = () => { return (
- + {t(getKey('imageScaling.label'))} @@ -32,8 +32,8 @@ export default function DefaultReadingSettings() { @@ -47,9 +47,15 @@ export default function DefaultReadingSettings() { diff --git a/packages/browser/src/components/library/createOrUpdate/sections/GenerateThumbnailColorsSwitch.tsx b/packages/browser/src/components/library/createOrUpdate/sections/GenerateThumbnailColorsSwitch.tsx index ac995ce23..761d85509 100644 --- a/packages/browser/src/components/library/createOrUpdate/sections/GenerateThumbnailColorsSwitch.tsx +++ b/packages/browser/src/components/library/createOrUpdate/sections/GenerateThumbnailColorsSwitch.tsx @@ -23,8 +23,8 @@ export default function GenerateThumbnailColorsSwitch() { return (
diff --git a/packages/browser/src/components/markdown/MarkdownEditor.tsx b/packages/browser/src/components/markdown/MarkdownEditor.tsx index 913052c9c..b9aa1f7a3 100644 --- a/packages/browser/src/components/markdown/MarkdownEditor.tsx +++ b/packages/browser/src/components/markdown/MarkdownEditor.tsx @@ -55,7 +55,9 @@ export default function MarkdownEditor({ initialState = 'writing', initialConten - {t('controlUi.write')} + + {t('controlUi.markdownEditor.tabs.write')} + - {t('controlUi.preview')} + {t('controlUi.markdownEditor.tabs.preview')}
- + - + diff --git a/packages/browser/src/components/metadata/metadataEditor/cells/BadgeListCell.tsx b/packages/browser/src/components/metadata/metadataEditor/cells/BadgeListCell.tsx index 26c9acd52..d913afd2b 100644 --- a/packages/browser/src/components/metadata/metadataEditor/cells/BadgeListCell.tsx +++ b/packages/browser/src/components/metadata/metadataEditor/cells/BadgeListCell.tsx @@ -77,7 +77,7 @@ export default function BadgeListCell({ variant="destructive" size="icon" className="-right-2 -top-2 h-4 w-4 absolute z-10 opacity-0 transition-opacity group-hover:opacity-100" - aria-label={t('controlUi.removeItem')} + aria-label={t('controlUi.metadataEditor.removeItem')} onClick={() => onRemove(index)} > diff --git a/packages/browser/src/components/metadata/metadataEditor/cells/InlineBadgeListInput.tsx b/packages/browser/src/components/metadata/metadataEditor/cells/InlineBadgeListInput.tsx index 0173b9860..bac9fd300 100644 --- a/packages/browser/src/components/metadata/metadataEditor/cells/InlineBadgeListInput.tsx +++ b/packages/browser/src/components/metadata/metadataEditor/cells/InlineBadgeListInput.tsx @@ -29,7 +29,7 @@ export default function InlineBadgeListInput({ values, onChange, className, bind variant="destructive" size="icon" className="-right-2 -top-2 h-4 w-4 absolute z-10 opacity-0 transition-opacity group-hover:opacity-100" - aria-label={t('controlUi.removeItem')} + aria-label={t('controlUi.metadataEditor.removeItem')} onClick={() => onRemove(index)} > diff --git a/packages/browser/src/components/metadata/metadataEditor/cells/InlineNumberInput.tsx b/packages/browser/src/components/metadata/metadataEditor/cells/InlineNumberInput.tsx index 34dd017f1..ad36e8b67 100644 --- a/packages/browser/src/components/metadata/metadataEditor/cells/InlineNumberInput.tsx +++ b/packages/browser/src/components/metadata/metadataEditor/cells/InlineNumberInput.tsx @@ -37,12 +37,12 @@ export default function InlineNumberInput({ }} /> - +
diff --git a/packages/browser/src/components/readers/epub/EpubJsReader.tsx b/packages/browser/src/components/readers/epub/EpubJsReader.tsx index 303f349f4..2288f9922 100644 --- a/packages/browser/src/components/readers/epub/EpubJsReader.tsx +++ b/packages/browser/src/components/readers/epub/EpubJsReader.tsx @@ -685,7 +685,7 @@ export default function EpubJsReader({ id, isIncognito }: EpubJsReaderProps) { await rendition.display(cfi) } catch (err) { console.error(err) - toast.error(t('readerUi.navigationFailed')) + toast.error(t('readerUi.errors.navigationFailed')) } }, [rendition, t], @@ -709,7 +709,7 @@ export default function EpubJsReader({ id, isIncognito }: EpubJsReaderProps) { return } - const failureMessage = t('readerUi.navigationFailed') + const failureMessage = t('readerUi.errors.navigationFailed') const adjusted = href.split('#')[0] let spineItem = book.spine.get(adjusted) diff --git a/packages/browser/src/components/readers/epub/EpubReaderHeader.tsx b/packages/browser/src/components/readers/epub/EpubReaderHeader.tsx index 1a37f4778..ee73ddfdb 100644 --- a/packages/browser/src/components/readers/epub/EpubReaderHeader.tsx +++ b/packages/browser/src/components/readers/epub/EpubReaderHeader.tsx @@ -29,7 +29,10 @@ export default function EpubReaderHeader() { return (
- + diff --git a/packages/browser/src/components/readers/epub/controls/FontFamily.tsx b/packages/browser/src/components/readers/epub/controls/FontFamily.tsx index dc0397e4b..0d33bd07b 100644 --- a/packages/browser/src/components/readers/epub/controls/FontFamily.tsx +++ b/packages/browser/src/components/readers/epub/controls/FontFamily.tsx @@ -35,7 +35,7 @@ export default function FontFamily() { - +
{localFontSize > 50 && ( - {t('readerUi.fontPreviewLimit')} + {t('readerUi.fontSize.previewLimit')} )}
diff --git a/packages/browser/src/components/readers/epub/controls/FullScreenToggle.tsx b/packages/browser/src/components/readers/epub/controls/FullScreenToggle.tsx index 6482657c8..948059cb8 100644 --- a/packages/browser/src/components/readers/epub/controls/FullScreenToggle.tsx +++ b/packages/browser/src/components/readers/epub/controls/FullScreenToggle.tsx @@ -9,7 +9,7 @@ export default function FullScreenToggle() { const { fullscreen, setFullscreen } = useEpubReaderControls() return ( - + setFullscreen(!fullscreen)} /> ) diff --git a/packages/browser/src/components/readers/epub/controls/LineHeightControl.tsx b/packages/browser/src/components/readers/epub/controls/LineHeightControl.tsx index fc69d4c4b..97817a7ff 100644 --- a/packages/browser/src/components/readers/epub/controls/LineHeightControl.tsx +++ b/packages/browser/src/components/readers/epub/controls/LineHeightControl.tsx @@ -42,7 +42,7 @@ export default function LineHeightControl() { return (
- +
- + - + i.play_order === adjustedIdx) } - return item?.label || t('readerUi.spineItem', { index: idx }) + return item?.label || t('readerUi.search.spineItem', { index: idx }) }, [toc, t], ) @@ -92,7 +92,7 @@ export default function SearchCommand() { if (!results) { return null } else if (!results.length) { - return {t('readerUi.noSearchResults')} + return {t('readerUi.search.noResults')} } else { return results.map(({ spineIndex, results }, idx) => ( @@ -148,7 +148,7 @@ export default function SearchCommand() {
- + - {t('readerUi.appearance')} + {t('readerUi.themeControls.appearance')} @@ -34,7 +34,7 @@ export default function ThemeControls() { return ( - + diff --git a/packages/browser/src/components/readers/epub/locations/Bookmarks.tsx b/packages/browser/src/components/readers/epub/locations/Bookmarks.tsx index ff1479ad4..a3db53f24 100644 --- a/packages/browser/src/components/readers/epub/locations/Bookmarks.tsx +++ b/packages/browser/src/components/readers/epub/locations/Bookmarks.tsx @@ -31,7 +31,7 @@ export default function Bookmarks({ onLocationChanged }: Props) { ) if (!bookmarksArray.length) { - return + return } return ( diff --git a/packages/browser/src/components/readers/epub/locations/LocationManager.tsx b/packages/browser/src/components/readers/epub/locations/LocationManager.tsx index 312de9da9..e20728f52 100644 --- a/packages/browser/src/components/readers/epub/locations/LocationManager.tsx +++ b/packages/browser/src/components/readers/epub/locations/LocationManager.tsx @@ -42,7 +42,7 @@ export default function LocationManager() { return ( - + setIsOpen(true)} /> @@ -51,11 +51,15 @@ export default function LocationManager() { handleTabChange('contents')}> - {t('readerUi.contents')} + + {t('readerUi.locations.tabs.contents')} + handleTabChange('bookmarks')}> - {t('readerUi.bookmarks')} + + {t('readerUi.locations.tabs.bookmarks')} + handleTabChange('annotations')} disabled > - {t('readerUi.annotations')} + + {t('readerUi.locations.tabs.annotations')} + diff --git a/packages/browser/src/components/readers/epub/locations/TableOfContents.tsx b/packages/browser/src/components/readers/epub/locations/TableOfContents.tsx index b39bcbb53..b8c89e2b7 100644 --- a/packages/browser/src/components/readers/epub/locations/TableOfContents.tsx +++ b/packages/browser/src/components/readers/epub/locations/TableOfContents.tsx @@ -45,7 +45,7 @@ export default function TableOfContents({ onLocationChanged }: Props) { return (
{toc?.map((item) => ( diff --git a/packages/browser/src/components/readers/imageBased/container/BrightnessControl.tsx b/packages/browser/src/components/readers/imageBased/container/BrightnessControl.tsx index e95c056d9..b29d1ac09 100644 --- a/packages/browser/src/components/readers/imageBased/container/BrightnessControl.tsx +++ b/packages/browser/src/components/readers/imageBased/container/BrightnessControl.tsx @@ -30,7 +30,7 @@ export default function BrightnessControl() { return (
- + - + - +
- + {nextInSeries.name}
@@ -59,7 +59,7 @@ export default function NextInSeries() { /> - {t('readerUi.read')} + {t('readerUi.actions.read')}
diff --git a/packages/browser/src/components/readers/imageBased/container/ReaderFooter.tsx b/packages/browser/src/components/readers/imageBased/container/ReaderFooter.tsx index 303720772..67b928fba 100644 --- a/packages/browser/src/components/readers/imageBased/container/ReaderFooter.tsx +++ b/packages/browser/src/components/readers/imageBased/container/ReaderFooter.tsx @@ -179,12 +179,12 @@ export default function ReaderFooter() { > {trackElapsedTime && ( - {t('readerUi.readingTime', { time: formattedReadTime })} + {t('readerUi.footer.readingTime', { time: formattedReadTime })} )} - {t('readerUi.pageXOfY', { + {t('readerUi.footer.pageXOfY', { current: [...currentSet] .map((idx) => idx + 1) .sort((a, b) => a - b) diff --git a/packages/browser/src/components/readers/imageBased/container/ReaderHeader.tsx b/packages/browser/src/components/readers/imageBased/container/ReaderHeader.tsx index d9ae9e6c0..36c9c9d46 100644 --- a/packages/browser/src/components/readers/imageBased/container/ReaderHeader.tsx +++ b/packages/browser/src/components/readers/imageBased/container/ReaderHeader.tsx @@ -39,7 +39,7 @@ export default function ReaderHeader() {
diff --git a/packages/browser/src/components/readers/imageBased/container/ReaderSettings.tsx b/packages/browser/src/components/readers/imageBased/container/ReaderSettings.tsx index 4cd566247..cc4815370 100644 --- a/packages/browser/src/components/readers/imageBased/container/ReaderSettings.tsx +++ b/packages/browser/src/components/readers/imageBased/container/ReaderSettings.tsx @@ -108,7 +108,7 @@ export default function ReaderSettings({ forBook, currentPage }: Props) {
- {t('bookClubUi.showAs')} + {t('bookClubUi.roleNames.columns.showAs')}