feat: initialize core components and multi-language support structures
Gitea Auto Deploy / Deploy-Container (push) Successful in 38s

This commit is contained in:
2026-08-28 00:56:32 +07:00
parent 0f75a8ba7b
commit ff4342efc0
22 changed files with 1548 additions and 425 deletions
+50 -3
View File
@@ -153,10 +153,28 @@
"importFreeSRDataSuccess": "FreeSR-Daten erfolgreich importiert",
"importDatabaseSuccess": "Datenbank erfolgreich importiert",
"getData": "Daten abrufen",
"fetchingData": "Daten werden abgerufen...",
"import": "Importieren",
"freeSRImport": "FreeSR-Import",
"onlySupportFreeSRJsonFile": "Unterstützt nur FreeSR-JSON-Dateien",
"pickAFile": "Datei auswählen",
"changeFile": "Datei ändern",
"avatars": "Charaktere",
"noFreeSRDataLoaded": "Keine FreeSR-Daten geladen",
"dropFreeSRJsonHint": "Ziehe deine FreeSR.json-Datei hierher oder klicke, um sie auszuwählen.",
"step1": "SCHRITT 1",
"step2": "SCHRITT 2",
"step3": "SCHRITT 3",
"exportJsonFromFreeSR": "JSON aus FreeSR exportieren",
"dropOrSelectFile": "Datei ablegen oder auswählen",
"syncCharacterBuilds": "Charakter-Builds synchronisieren",
"enterUidPlaceholder": "9-stellige UID eingeben...",
"publicCharacters": "Öffentliche Charaktere",
"fetchFromEnka": "Von Enka.Network abrufen",
"fetchEnkaHint": "Gib oben deine Honkai: Star Rail UID ein, um deine öffentlich ausgestellten Charaktere zu synchronisieren.",
"inGameProfile": "Im Spiel: Trailblazer-Profil",
"displayCharactersPublicly": "Charaktere öffentlich anzeigen",
"enterUidAndSync": "UID eingeben & synchronisieren",
"lightConeSetting": "Lichtkegel-Einstellung",
"relicMaker": "Relikt-Ersteller",
"pleaseSelectAllOptions": "Bitte wähle alle Optionen",
@@ -219,10 +237,40 @@
"searchStage": "Phase suchen...",
"noStageFound": "Keine Phase gefunden",
"searchMonster": "Monster suchen...",
"changeRelic": "Relikt wechseln",
"HEAD": "Kopf",
"HAND": "Handschuhe",
"BODY": "Körper",
"FOOT": "Füße",
"NECK": "Planarsphäre",
"OBJECT": "Verbindungskette",
"changeRelic": "Relikt ändern",
"deleteRelic": "Relikt löschen",
"deleteRelicConfirm": "Möchtest du das Relikt auf diesem Platz wirklich löschen?",
"setEffects": "Effekte einstellen",
"confirmDelete": "Löschen bestätigen",
"cancel": "Abbrechen",
"relicDeletedSuccessfully": "Relikt erfolgreich gelöscht",
"profileCreatedSuccessfully": "Profil erfolgreich erstellt",
"profileUpdatedSuccessfully": "Profil erfolgreich aktualisiert",
"profileDeletedSuccessfully": "Profil erfolgreich gelöscht",
"profileSwitched": "Profil gewechselt zu",
"deleteProfileConfirm": "Möchtest du das Profil wirklich löschen",
"deleteProfile": "Profil löschen",
"copyText": "Text kopieren",
"copyImage": "Bild kopieren",
"processing": "Verarbeite...",
"saving": "Speichere...",
"buildInfoCopied": "Build-Infos in Zwischenablage kopiert!",
"copyFailed": "Kopieren der Build-Infos fehlgeschlagen!",
"imageCopiedToClipboard": "Bild in Zwischenablage kopiert!",
"clipboardWriteFailed": "Direktes Schreiben in Zwischenablage fehlgeschlagen!",
"imageSavedSuccessfully": "Bild erfolgreich gespeichert!",
"failedToGenerateImage": "Bildgenerierung fehlgeschlagen!",
"errorGeneratingShowcaseCard": "Fehler beim Erstellen der Showcase-Karte!",
"feature": "Feature",
"fix": "Behebung",
"improvement": "Verbesserung",
"version": "Version",
"setEffects": "Set-Effekte einstellen",
"details": "Details",
"normal": "Standardangriff",
"bpskill": "Fähigkeit",
@@ -258,7 +306,6 @@
"upRoll": "Aufwertung hoch",
"downRoll": "Aufwertung runter",
"actions": "Aktionen",
"avatars": "Avatare",
"quickView": "Schnellansicht",
"extraSetting": "Extra-Einstellungen",
"disableCensorship": "Zensur deaktivieren",
+48 -2
View File
@@ -154,10 +154,28 @@
"importFreeSRDataSuccess": "Import FreeSR data success",
"importDatabaseSuccess": "Import database success",
"getData": "Get Data",
"fetchingData": "Fetching...",
"import": "Import",
"freeSRImport": "FreeSR Import",
"onlySupportFreeSRJsonFile": "Only support FreeSR json file",
"pickAFile": "Pick a file",
"changeFile": "Change File",
"avatars": "Avatars",
"noFreeSRDataLoaded": "No FreeSR Data Loaded",
"dropFreeSRJsonHint": "Drop your FreeSR.json file here or click anywhere to select from your device.",
"step1": "STEP 1",
"step2": "STEP 2",
"step3": "STEP 3",
"exportJsonFromFreeSR": "Export JSON from FreeSR",
"dropOrSelectFile": "Drop or select file",
"syncCharacterBuilds": "Sync character builds",
"enterUidPlaceholder": "Enter 9-digit UID...",
"publicCharacters": "Public Characters",
"fetchFromEnka": "Fetch from Enka.Network",
"fetchEnkaHint": "Enter your Honkai: Star Rail UID above to sync your public showcase characters.",
"inGameProfile": "In-game Trailblazer Profile",
"displayCharactersPublicly": "Display characters publicly",
"enterUidAndSync": "Enter UID & Sync",
"lightConeSetting": "LightCone Setting",
"relicMaker": "Relic Maker",
"pleaseSelectAllOptions": "Please select all options",
@@ -219,10 +237,39 @@
"addNewWave": "Add New Wave",
"searchStage": "Search stage...",
"noStageFound": "No stage found",
"searchMonster": "Search monster...",
"HEAD": "Head",
"HAND": "Hands",
"BODY": "Body",
"FOOT": "Feet",
"NECK": "Planar Sphere",
"OBJECT": "Link Rope",
"changeRelic": "Change relic",
"deleteRelic": "Delete relic",
"deleteRelicConfirm": "Are you sure you want to delete relic in slot",
"confirmDelete": "Confirm Delete",
"cancel": "Cancel",
"relicDeletedSuccessfully": "Relic deleted successfully",
"profileCreatedSuccessfully": "Profile created successfully",
"profileUpdatedSuccessfully": "Profile updated successfully",
"profileDeletedSuccessfully": "Profile deleted successfully",
"profileSwitched": "Profile switched to",
"deleteProfileConfirm": "Are you sure you want to delete profile",
"deleteProfile": "Delete Profile",
"copyText": "Copy Text",
"copyImage": "Copy Image",
"processing": "Processing...",
"saving": "Saving...",
"buildInfoCopied": "Build info copied to clipboard!",
"copyFailed": "Failed to copy build info!",
"imageCopiedToClipboard": "Image copied to clipboard!",
"clipboardWriteFailed": "Browser clipboard write failed!",
"imageSavedSuccessfully": "Image saved successfully!",
"failedToGenerateImage": "Failed to generate image!",
"errorGeneratingShowcaseCard": "Error generating showcase card!",
"feature": "Feature",
"fix": "Fix",
"improvement": "Improvement",
"version": "Version",
"setEffects": "Set Effects",
"details": "Details",
"normal": "Basic ATK",
@@ -259,7 +306,6 @@
"upRoll": "Up Roll",
"downRoll": "Down Roll",
"actions": "Actions",
"avatars": "Avatars",
"quickView": "Quick View",
"extraSetting": "Extra Settings",
"disableCensorship": "Disable Censorship",
+49 -2
View File
@@ -153,10 +153,28 @@
"importFreeSRDataSuccess": "Datos de FreeSR importados con éxito",
"importDatabaseSuccess": "Base de datos importada con éxito",
"getData": "Obtener Datos",
"fetchingData": "Obteniendo datos...",
"import": "Importar",
"freeSRImport": "Importar FreeSR",
"onlySupportFreeSRJsonFile": "Solo soporta archivos JSON de FreeSR",
"pickAFile": "Elegir un archivo",
"changeFile": "Cambiar archivo",
"avatars": "Personajes",
"noFreeSRDataLoaded": "No se han cargado datos de FreeSR",
"dropFreeSRJsonHint": "Arrastra tu archivo FreeSR.json aquí o haz clic para seleccionarlo.",
"step1": "PASO 1",
"step2": "PASO 2",
"step3": "PASO 3",
"exportJsonFromFreeSR": "Exportar JSON de FreeSR",
"dropOrSelectFile": "Soltar o seleccionar archivo",
"syncCharacterBuilds": "Sincronizar builds de personajes",
"enterUidPlaceholder": "Ingresar UID de 9 dígitos...",
"publicCharacters": "Personajes públicos",
"fetchFromEnka": "Obtener de Enka.Network",
"fetchEnkaHint": "Ingresa tu UID de Honkai: Star Rail arriba para sincronizar tus personajes públicos.",
"inGameProfile": "Perfil de Trazacaminos en el juego",
"displayCharactersPublicly": "Mostrar personajes públicamente",
"enterUidAndSync": "Ingresar UID y sincronizar",
"lightConeSetting": "Ajuste de Cono de Luz",
"relicMaker": "Creador de Artefactos",
"pleaseSelectAllOptions": "Por favor selecciona todas las opciones",
@@ -219,10 +237,40 @@
"searchStage": "Buscar etapa...",
"noStageFound": "No se encontró etapa",
"searchMonster": "Buscar monstruo...",
"HEAD": "Cabeza",
"HAND": "Manos",
"BODY": "Cuerpo",
"FOOT": "Pies",
"NECK": "Esfera planar",
"OBJECT": "Cuerda de unión",
"changeRelic": "Cambiar artefacto",
"deleteRelic": "Borrar artefacto",
"deleteRelicConfirm": "¿Seguro que quieres borrar el artefacto en este espacio?",
"setEffects": "Configurar Efectos",
"confirmDelete": "Confirmar eliminación",
"cancel": "Cancelar",
"relicDeletedSuccessfully": "Artefacto eliminado con éxito",
"profileCreatedSuccessfully": "Perfil creado con éxito",
"profileUpdatedSuccessfully": "Perfil actualizado con éxito",
"profileDeletedSuccessfully": "Perfil eliminado con éxito",
"profileSwitched": "Perfil cambiado a",
"deleteProfileConfirm": "¿Seguro que quieres borrar el perfil",
"deleteProfile": "Borrar perfil",
"copyText": "Copiar texto",
"copyImage": "Copiar imagen",
"processing": "Procesando...",
"saving": "Guardando...",
"buildInfoCopied": "¡Información de la build copiada al portapapeles!",
"copyFailed": "¡Error al copiar la información de la build!",
"imageCopiedToClipboard": "¡Imagen copiada al portapapeles!",
"clipboardWriteFailed": "¡Error al escribir en el portapapeles!",
"imageSavedSuccessfully": "¡Imagen guardada con éxito!",
"failedToGenerateImage": "¡Error al generar la imagen!",
"errorGeneratingShowcaseCard": "¡Error al generar la tarjeta showcase!",
"feature": "Función",
"fix": "Corrección",
"improvement": "Mejora",
"version": "Versión",
"setEffects": "Efectos de conjunto",
"details": "Detalles",
"normal": "Ataque Básico",
"bpskill": "Habilidad",
@@ -258,7 +306,6 @@
"upRoll": "Subir Mejora",
"downRoll": "Bajar Mejora",
"actions": "Acciones",
"avatars": "Avatares",
"quickView": "Vista Rápida",
"extraSetting": "Ajustes Extra",
"disableCensorship": "Desactivar Censura",
+50 -3
View File
@@ -153,10 +153,28 @@
"importFreeSRDataSuccess": "Importation FreeSR réussie",
"importDatabaseSuccess": "Importation de base de données réussie",
"getData": "Obtenir des données",
"fetchingData": "Récupération...",
"import": "Importer",
"freeSRImport": "Importation FreeSR",
"onlySupportFreeSRJsonFile": "Supporte uniquement les fichiers JSON FreeSR",
"pickAFile": "Choisir un fichier",
"changeFile": "Changer de fichier",
"avatars": "Personnages",
"noFreeSRDataLoaded": "Aucune donnée FreeSR chargée",
"dropFreeSRJsonHint": "Déposez votre fichier FreeSR.json ici ou cliquez pour le sélectionner.",
"step1": "ÉTAPE 1",
"step2": "ÉTAPE 2",
"step3": "ÉTAPE 3",
"exportJsonFromFreeSR": "Exporter le JSON depuis FreeSR",
"dropOrSelectFile": "Déposer ou sélectionner le fichier",
"syncCharacterBuilds": "Synchroniser les builds des personnages",
"enterUidPlaceholder": "Entrez l'UID à 9 chiffres...",
"publicCharacters": "Personnages publics",
"fetchFromEnka": "Récupérer depuis Enka.Network",
"fetchEnkaHint": "Entrez votre UID Honkai: Star Rail ci-dessus pour synchroniser vos personnages exposés.",
"inGameProfile": "Profil de Pionnier en jeu",
"displayCharactersPublicly": "Afficher les personnages publiquement",
"enterUidAndSync": "Entrez l'UID et synchronisez",
"lightConeSetting": "Paramètre de Cône de Lumière",
"relicMaker": "Créateur de Reliques",
"pleaseSelectAllOptions": "Veuillez sélectionner toutes les options",
@@ -218,11 +236,41 @@
"addNewWave": "Ajouter une nouvelle vague",
"searchStage": "Chercher un niveau...",
"noStageFound": "Aucun niveau trouvé",
"searchMonster": "Chercher un monstre...",
"searchMonster": "Rechercher un monstre...",
"HEAD": "Tête",
"HAND": "Mains",
"BODY": "Corps",
"FOOT": "Pieds",
"NECK": "Sphère planaire",
"OBJECT": "Corde de liaison",
"changeRelic": "Changer de relique",
"deleteRelic": "Supprimer la relique",
"deleteRelicConfirm": "Êtes-vous sûr de vouloir supprimer la relique de cet emplacement ?",
"setEffects": "Définir les effets",
"confirmDelete": "Confirmer la suppression",
"cancel": "Annuler",
"relicDeletedSuccessfully": "Relique supprimée avec succès",
"profileCreatedSuccessfully": "Profil créé avec succès",
"profileUpdatedSuccessfully": "Profil mis à jour avec succès",
"profileDeletedSuccessfully": "Profil supprimé avec succès",
"profileSwitched": "Profil changé pour",
"deleteProfileConfirm": "Êtes-vous sûr de vouloir supprimer le profil",
"deleteProfile": "Supprimer le profil",
"copyText": "Copier le texte",
"copyImage": "Copier l'image",
"processing": "Traitement...",
"saving": "Sauvegarde...",
"buildInfoCopied": "Informations du build copiées dans le presse-papier !",
"copyFailed": "Échec de la copie des informations du build !",
"imageCopiedToClipboard": "Image copiée dans le presse-papier !",
"clipboardWriteFailed": "Échec de l'écriture dans le presse-papier !",
"imageSavedSuccessfully": "Image enregistrée avec succès !",
"failedToGenerateImage": "Échec de la génération de l'image !",
"errorGeneratingShowcaseCard": "Erreur lors de la génération de la carte showcase !",
"feature": "Fonctionnalité",
"fix": "Correction",
"improvement": "Amélioration",
"version": "Version",
"setEffects": "Effets de panoplie",
"details": "Détails",
"normal": "Attaque Normale",
"bpskill": "Compétence",
@@ -258,7 +306,6 @@
"upRoll": "Augmenter l'amélioration",
"downRoll": "Diminuer l'amélioration",
"actions": "Actions",
"avatars": "Avatars",
"quickView": "Aperçu rapide",
"extraSetting": "Paramètres supplémentaires",
"disableCensorship": "Désactiver la censure",
+50 -3
View File
@@ -153,10 +153,28 @@
"importFreeSRDataSuccess": "Impor data FreeSR berhasil",
"importDatabaseSuccess": "Impor database berhasil",
"getData": "Dapatkan Data",
"fetchingData": "Mengambil data...",
"import": "Impor",
"freeSRImport": "Impor FreeSR",
"onlySupportFreeSRJsonFile": "Hanya mendukung file JSON FreeSR",
"pickAFile": "Pilih sebuah file",
"changeFile": "Ganti file",
"avatars": "Karakter",
"noFreeSRDataLoaded": "Tidak ada data FreeSR yang dimuat",
"dropFreeSRJsonHint": "Tarik file FreeSR.json Anda ke sini atau klik untuk memilih file.",
"step1": "LANGKAH 1",
"step2": "LANGKAH 2",
"step3": "LANGKAH 3",
"exportJsonFromFreeSR": "Ekspor JSON dari FreeSR",
"dropOrSelectFile": "Tarik atau pilih file",
"syncCharacterBuilds": "Sinkronkan build karakter",
"enterUidPlaceholder": "Masukkan 9 digit UID...",
"publicCharacters": "Karakter Publik",
"fetchFromEnka": "Ambil dari Enka.Network",
"fetchEnkaHint": "Masukkan UID Honkai: Star Rail Anda di atas untuk menyinkronkan karakter publik.",
"inGameProfile": "Profil Trailblazer dalam game",
"displayCharactersPublicly": "Tampilkan karakter secara publik",
"enterUidAndSync": "Masukkan UID & Sinkronkan",
"lightConeSetting": "Pengaturan Light Cone",
"relicMaker": "Pembuat Relic",
"pleaseSelectAllOptions": "Silakan pilih semua opsi",
@@ -219,10 +237,40 @@
"searchStage": "Cari tahap...",
"noStageFound": "Tahap tidak ditemukan",
"searchMonster": "Cari monster...",
"changeRelic": "Ganti relic",
"HEAD": "Kepala",
"HAND": "Tangan",
"BODY": "Badan",
"FOOT": "Kaki",
"NECK": "Bola Planar",
"OBJECT": "Tali Penghubung",
"changeRelic": "Ubah relic",
"deleteRelic": "Hapus relic",
"deleteRelicConfirm": "Yakin ingin menghapus relic di slot ini?",
"setEffects": "Atur Efek",
"confirmDelete": "Konfirmasi Hapus",
"cancel": "Batal",
"relicDeletedSuccessfully": "Relic berhasil dihapus",
"profileCreatedSuccessfully": "Profil berhasil dibuat",
"profileUpdatedSuccessfully": "Profil berhasil diperbarui",
"profileDeletedSuccessfully": "Profil berhasil dihapus",
"profileSwitched": "Profil beralih ke",
"deleteProfileConfirm": "Yakin ingin menghapus profil",
"deleteProfile": "Hapus profil",
"copyText": "Salin Teks",
"copyImage": "Salin Gambar",
"processing": "Memproses...",
"saving": "Menyimpan...",
"buildInfoCopied": "Info build berhasil disalin ke papan klip!",
"copyFailed": "Gagal menyalin info build!",
"imageCopiedToClipboard": "Gambar berhasil disalin ke papan klip!",
"clipboardWriteFailed": "Gagal menulis ke papan klip browser!",
"imageSavedSuccessfully": "Gambar berhasil disimpan!",
"failedToGenerateImage": "Gagal membuat gambar!",
"errorGeneratingShowcaseCard": "Gagal membuat kartu showcase!",
"feature": "Fitur",
"fix": "Perbaikan",
"improvement": "Peningkatan",
"version": "Versi",
"setEffects": "Efek Set",
"details": "Detail",
"normal": "Basic ATK",
"bpskill": "Skill",
@@ -258,7 +306,6 @@
"upRoll": "Up Roll",
"downRoll": "Down Roll",
"actions": "Aksi",
"avatars": "Avatar",
"quickView": "Tampilan Cepat",
"extraSetting": "Pengaturan Ekstra",
"disableCensorship": "Nonaktifkan Sensor",
+50 -3
View File
@@ -153,10 +153,28 @@
"importFreeSRDataSuccess": "FreeSR データのインポート成功",
"importDatabaseSuccess": "データベースのインポート成功",
"getData": "データ取得",
"fetchingData": "データを取得中...",
"import": "インポート",
"freeSRImport": "FreeSR インポート",
"onlySupportFreeSRJsonFile": "FreeSR JSON ファイルのみ対応",
"pickAFile": "ファイル選択",
"changeFile": "ファイルを変更",
"avatars": "キャラクター",
"noFreeSRDataLoaded": "FreeSR データが読み込まれていません",
"dropFreeSRJsonHint": "FreeSR.json ファイルをここにドラッグ&ドロップするか、クリックして選択してください。",
"step1": "ステップ 1",
"step2": "ステップ 2",
"step3": "ステップ 3",
"exportJsonFromFreeSR": "FreeSR から JSON をエクスポート",
"dropOrSelectFile": "ファイルをドロップまたは選択",
"syncCharacterBuilds": "キャラのビルドを同期",
"enterUidPlaceholder": "9桁のUIDを入力...",
"publicCharacters": "公開キャラクター",
"fetchFromEnka": "Enka.Network から取得",
"fetchEnkaHint": "上に崩壊:スターレイルのUIDを入力して、公開ショーケースのキャラクターを同期します。",
"inGameProfile": "ゲーム内の開拓者プロフィール",
"displayCharactersPublicly": "キャラクターを公開設定にする",
"enterUidAndSync": "UIDを入力して同期",
"lightConeSetting": "ライトコーン設定",
"relicMaker": "遺物製作",
"pleaseSelectAllOptions": "すべてのオプションを選択してください",
@@ -218,10 +236,40 @@
"searchStage": "ステージを検索...",
"noStageFound": "ステージが見つかりません",
"searchMonster": "敵を検索...",
"HEAD": "頭部",
"HAND": "手部",
"BODY": "胴体",
"FOOT": "脚部",
"NECK": "次元界オーブ",
"OBJECT": "連結縄",
"changeRelic": "遺物を変更",
"deleteRelic": "遺物を削除",
"deleteRelicConfirm": "この遺物を削除してもよろしいですか?",
"setEffects": "効果を設定",
"deleteRelicConfirm": "このスロットの遺物を削除してもよろしいですか?",
"confirmDelete": "削除の確認",
"cancel": "キャンセル",
"relicDeletedSuccessfully": "遺物を削除しました",
"profileCreatedSuccessfully": "プロファイルを作成しました",
"profileUpdatedSuccessfully": "プロファイルを更新しました",
"profileDeletedSuccessfully": "プロファイルを削除しました",
"profileSwitched": "プロファイルを切り替えました",
"deleteProfileConfirm": "このプロファイルを削除してもよろしいですか",
"deleteProfile": "プロファイルを削除",
"copyText": "テキストをコピー",
"copyImage": "画像をコピー",
"processing": "処理中...",
"saving": "保存中...",
"buildInfoCopied": "ビルド情報をクリップボードにコピーしました!",
"copyFailed": "ビルド情報のコピーに失敗しました!",
"imageCopiedToClipboard": "画像をクリップボードにコピーしました!",
"clipboardWriteFailed": "クリップボードへの書き込みに失敗しました!",
"imageSavedSuccessfully": "画像を保存しました!",
"failedToGenerateImage": "画像の生成に失敗しました!",
"errorGeneratingShowcaseCard": "ショーケースカードの生成中にエラーが発生しました!",
"feature": "機能",
"fix": "修正",
"improvement": "改善",
"version": "バージョン",
"setEffects": "セット効果",
"details": "詳細",
"normal": "通常攻撃",
"bpskill": "戦闘スキル",
@@ -257,7 +305,6 @@
"upRoll": "サブステータスを増やす",
"downRoll": "サブステータスを減らす",
"actions": "アクション",
"avatars": "アバター",
"quickView": "クイックビュー",
"extraSetting": "追加設定",
"disableCensorship": "検閲を無効化",
+50 -3
View File
@@ -153,10 +153,28 @@
"importFreeSRDataSuccess": "FreeSR 데이터 가져오기 성공",
"importDatabaseSuccess": "데이터베이스 가져오기 성공",
"getData": "데이터 가져오기",
"fetchingData": "데이터 가져오는 중...",
"import": "가져오기",
"freeSRImport": "FreeSR 가져오기",
"onlySupportFreeSRJsonFile": "FreeSR JSON 파일만 지원",
"pickAFile": "파일 선택",
"changeFile": "파일 변경",
"avatars": "캐릭터",
"noFreeSRDataLoaded": "FreeSR 데이터가 로드되지 않음",
"dropFreeSRJsonHint": "FreeSR.json 파일을 여기에 끌어다 놓거나 클릭하여 선택하세요.",
"step1": "1단계",
"step2": "2단계",
"step3": "3단계",
"exportJsonFromFreeSR": "FreeSR에서 JSON 내보내기",
"dropOrSelectFile": "파일을 끌어다 놓거나 선택",
"syncCharacterBuilds": "캐릭터 빌드 동기화",
"enterUidPlaceholder": "9자리 UID 입력...",
"publicCharacters": "공개 캐릭터",
"fetchFromEnka": "Enka.Network에서 가져오기",
"fetchEnkaHint": "위에 붕괴: 스타레일 UID를 입력하여 공개 쇼케이스 캐릭터를 동기화하세요.",
"inGameProfile": "인게임 개척자 프로필",
"displayCharactersPublicly": "캐릭터 공개 설정",
"enterUidAndSync": "UID 입력 및 동기화",
"lightConeSetting": "라이트콘 설정",
"relicMaker": "유물 제작",
"pleaseSelectAllOptions": "모든 옵션을 선택해주세요",
@@ -217,11 +235,41 @@
"addNewWave": "새 웨이브 추가",
"searchStage": "스테이지 검색...",
"noStageFound": "스테이지를 찾을 수 없음",
"searchMonster": "적 검색...",
"searchMonster": "몬스터 검색...",
"HEAD": "머리",
"HAND": "손",
"BODY": "몸통",
"FOOT": "발",
"NECK": "차원 구체",
"OBJECT": "연결 매듭",
"changeRelic": "유물 변경",
"deleteRelic": "유물 삭제",
"deleteRelicConfirm": "이 슬롯의 유물을 삭제하시겠습니까?",
"setEffects": "효과 설정",
"confirmDelete": "삭제 확인",
"cancel": "취소",
"relicDeletedSuccessfully": "유물이 성공적으로 삭제되었습니다",
"profileCreatedSuccessfully": "프로필이 성공적으로 생성되었습니다",
"profileUpdatedSuccessfully": "프로필이 성공적으로 수정되었습니다",
"profileDeletedSuccessfully": "프로필이 성공적으로 삭제되었습니다",
"profileSwitched": "프로필 전환됨",
"deleteProfileConfirm": "이 프로필을 삭제하시겠습니까",
"deleteProfile": "프로필 삭제",
"copyText": "텍스트 복사",
"copyImage": "이미지 복사",
"processing": "처리 중...",
"saving": "저장 중...",
"buildInfoCopied": "빌드 정보가 클립보드에 복사되었습니다!",
"copyFailed": "빌드 정보 복사 실패!",
"imageCopiedToClipboard": "이미지가 클립보드에 복사되었습니다!",
"clipboardWriteFailed": "클립보드 쓰기에 실패했습니다!",
"imageSavedSuccessfully": "이미지가 성공적으로 저장되었습니다!",
"failedToGenerateImage": "이미지 생성 실패!",
"errorGeneratingShowcaseCard": "쇼케이스 카드 생성 중 오류가 발생했습니다!",
"feature": "기능",
"fix": "수정",
"improvement": "개선",
"version": "버전",
"setEffects": "세트 효과",
"details": "상세",
"normal": "기본 공격",
"bpskill": "스킬",
@@ -257,7 +305,6 @@
"upRoll": "부옵션 추가",
"downRoll": "부옵션 감소",
"actions": "동작",
"avatars": "아바타",
"quickView": "빠른 조회",
"extraSetting": "추가 설정",
"disableCensorship": "검열 비활성화",
+50 -3
View File
@@ -153,10 +153,28 @@
"importFreeSRDataSuccess": "Dados FreeSR importados com sucesso",
"importDatabaseSuccess": "Banco de dados importado com sucesso",
"getData": "Obter Dados",
"fetchingData": "Buscando dados...",
"import": "Importar",
"freeSRImport": "Importação FreeSR",
"onlySupportFreeSRJsonFile": "Suporta apenas arquivos JSON FreeSR",
"pickAFile": "Escolher um arquivo",
"changeFile": "Alterar arquivo",
"avatars": "Personagens",
"noFreeSRDataLoaded": "Nenhum dado do FreeSR carregado",
"dropFreeSRJsonHint": "Arraste seu arquivo FreeSR.json aqui ou clique para selecionar.",
"step1": "PASSO 1",
"step2": "PASSO 2",
"step3": "PASSO 3",
"exportJsonFromFreeSR": "Exportar JSON do FreeSR",
"dropOrSelectFile": "Solte ou selecione o arquivo",
"syncCharacterBuilds": "Sincronizar builds de personagens",
"enterUidPlaceholder": "Insira o UID de 9 dígitos...",
"publicCharacters": "Personagens Públicos",
"fetchFromEnka": "Buscar do Enka.Network",
"fetchEnkaHint": "Insira seu UID de Honkai: Star Rail acima para sincronizar seus personagens públicos.",
"inGameProfile": "Perfil de Desbravador no jogo",
"displayCharactersPublicly": "Exibir personagens publicamente",
"enterUidAndSync": "Insira UID e sincronize",
"lightConeSetting": "Configuração de Cone de Luz",
"relicMaker": "Criador de Relíquias",
"pleaseSelectAllOptions": "Por favor selecione todas as opções",
@@ -219,10 +237,40 @@
"searchStage": "Buscar fase...",
"noStageFound": "Fase não encontrada",
"searchMonster": "Buscar monstro...",
"changeRelic": "Mudar relíquia",
"HEAD": "Cabeça",
"HAND": "Mãos",
"BODY": "Corpo",
"FOOT": "Pés",
"NECK": "Esfera Planar",
"OBJECT": "Corda de Ligação",
"changeRelic": "Alterar relíquia",
"deleteRelic": "Deletar relíquia",
"deleteRelicConfirm": "Tem certeza que deseja deletar a relíquia neste slot?",
"setEffects": "Definir Efeitos",
"confirmDelete": "Confirmar exclusão",
"cancel": "Cancelar",
"relicDeletedSuccessfully": "Relíquia excluída com sucesso",
"profileCreatedSuccessfully": "Perfil criado com sucesso",
"profileUpdatedSuccessfully": "Perfil atualizado com sucesso",
"profileDeletedSuccessfully": "Perfil excluído com sucesso",
"profileSwitched": "Perfil alterado para",
"deleteProfileConfirm": "Tem certeza que deseja excluir o perfil",
"deleteProfile": "Excluir perfil",
"copyText": "Copiar texto",
"copyImage": "Copiar imagem",
"processing": "Processando...",
"saving": "Salvando...",
"buildInfoCopied": "Informações da build copiadas para a área de transferência!",
"copyFailed": "Falha ao copiar informações da build!",
"imageCopiedToClipboard": "Imagem copiada para a área de transferência!",
"clipboardWriteFailed": "Falha ao gravar na área de transferência!",
"imageSavedSuccessfully": "Imagem salva com sucesso!",
"failedToGenerateImage": "Falha ao gerar imagem!",
"errorGeneratingShowcaseCard": "Erro ao gerar cartão showcase!",
"feature": "Funcionalidade",
"fix": "Correção",
"improvement": "Melhoria",
"version": "Versão",
"setEffects": "Efeitos de Conjunto",
"details": "Detalhes",
"normal": "Ataque Básico",
"bpskill": "Perícia",
@@ -258,7 +306,6 @@
"upRoll": "Aumentar Melhoria",
"downRoll": "Diminuir Melhoria",
"actions": "Ações",
"avatars": "Avatares",
"quickView": "Visualização Rápida",
"extraSetting": "Configurações Extras",
"disableCensorship": "Desativar Censura",
+49 -2
View File
@@ -153,10 +153,28 @@
"importFreeSRDataSuccess": "Успешный импорт из FreeSR",
"importDatabaseSuccess": "Успешный импорт базы данных",
"getData": "Получить данные",
"fetchingData": "Получение данных...",
"import": "Импорт",
"freeSRImport": "Импорт FreeSR",
"onlySupportFreeSRJsonFile": "Поддерживаются только JSON от FreeSR",
"pickAFile": "Выберите файл",
"changeFile": "Изменить файл",
"avatars": "Персонажи",
"noFreeSRDataLoaded": "Данные FreeSR не загружены",
"dropFreeSRJsonHint": "Перетащите файл FreeSR.json сюда или нажмите, чтобы выбрать.",
"step1": "ШАГ 1",
"step2": "ШАГ 2",
"step3": "ШАГ 3",
"exportJsonFromFreeSR": "Экспорт JSON из FreeSR",
"dropOrSelectFile": "Перетащите или выберите файл",
"syncCharacterBuilds": "Синхронизировать сборки персонажей",
"enterUidPlaceholder": "Введите 9-значный UID...",
"publicCharacters": "Публичные персонажи",
"fetchFromEnka": "Получить из Enka.Network",
"fetchEnkaHint": "Введите UID Honkai: Star Rail выше, чтобы синхронизировать персонажей.",
"inGameProfile": "Профиль Первопроходца в игре",
"displayCharactersPublicly": "Открыть персонажей в витрине",
"enterUidAndSync": "Введите UID и синхронизируйте",
"lightConeSetting": "Настройки конуса",
"relicMaker": "Создатель реликвий",
"pleaseSelectAllOptions": "Пожалуйста, выберите все параметры",
@@ -219,10 +237,40 @@
"searchStage": "Поиск стадии...",
"noStageFound": "Стадия не найдена",
"searchMonster": "Поиск монстра...",
"HEAD": "Голова",
"HAND": "Руки",
"BODY": "Тело",
"FOOT": "Ноги",
"NECK": "Планарная сфера",
"OBJECT": "Соединительная вязь",
"changeRelic": "Изменить реликвию",
"deleteRelic": "Удалить реликвию",
"deleteRelicConfirm": "Удалить реликвию в этом слоте?",
"setEffects": "Настроить эффекты",
"confirmDelete": "Подтвердить удаление",
"cancel": "Отмена",
"relicDeletedSuccessfully": "Реликвия успешно удалена",
"profileCreatedSuccessfully": "Профиль успешно создан",
"profileUpdatedSuccessfully": "Профиль успешно обновлен",
"profileDeletedSuccessfully": "Профиль успешно удален",
"profileSwitched": "Профиль изменен на",
"deleteProfileConfirm": "Вы уверены, что хотите удалить профиль",
"deleteProfile": "Удалить профиль",
"copyText": "Копировать текст",
"copyImage": "Копировать изображение",
"processing": "Обработка...",
"saving": "Сохранение...",
"buildInfoCopied": "Информация о сборке скопирована в буфер обмена!",
"copyFailed": "Не удалось скопировать информацию о сборке!",
"imageCopiedToClipboard": "Изображение скопировано в буфер обмена!",
"clipboardWriteFailed": "Не удалось записать в буфер обмена!",
"imageSavedSuccessfully": "Изображение успешно сохранено!",
"failedToGenerateImage": "Не удалось создать изображение!",
"errorGeneratingShowcaseCard": "Ошибка генерации карточки showcase!",
"feature": "Функция",
"fix": "Исправление",
"improvement": "Улучшение",
"version": "Версия",
"setEffects": "Эффекты набора",
"details": "Детали",
"normal": "Базовая атака",
"bpskill": "Навык",
@@ -258,7 +306,6 @@
"upRoll": "Повысить улучшение",
"downRoll": "Понизить улучшение",
"actions": "Действия",
"avatars": "Персонажи",
"quickView": "Быстрый просмотр",
"extraSetting": "Дополнительные настройки",
"disableCensorship": "Отключить цензуру",
+49 -2
View File
@@ -153,10 +153,28 @@
"importFreeSRDataSuccess": "นำเข้าข้อมูล FreeSR สำเร็จ",
"importDatabaseSuccess": "นำเข้าฐานข้อมูลสำเร็จ",
"getData": "รับข้อมูล",
"fetchingData": "กำลังดึงข้อมูล...",
"import": "นำเข้า",
"freeSRImport": "นำเข้า FreeSR",
"onlySupportFreeSRJsonFile": "รองรับเฉพาะไฟล์ JSON ของ FreeSR",
"pickAFile": "เลือกไฟล์",
"changeFile": "เปลี่ยนไฟล์",
"avatars": "ตัวละคร",
"noFreeSRDataLoaded": "ไม่ได้โหลดข้อมูล FreeSR",
"dropFreeSRJsonHint": "ลากไฟล์ FreeSR.json ของคุณมาที่นี่หรือคลิกเพื่อเลือกไฟล์",
"step1": "ขั้นตอนที่ 1",
"step2": "ขั้นตอนที่ 2",
"step3": "ขั้นตอนที่ 3",
"exportJsonFromFreeSR": "ส่งออก JSON จาก FreeSR",
"dropOrSelectFile": "ลากหรือเลือกไฟล์",
"syncCharacterBuilds": "ซิงค์บิลด์ตัวละคร",
"enterUidPlaceholder": "ป้อน UID 9 หลัก...",
"publicCharacters": "ตัวละครสาธารณะ",
"fetchFromEnka": "ดึงข้อมูลจาก Enka.Network",
"fetchEnkaHint": "ป้อน UID Honkai: Star Rail ของคุณด้านบนเพื่อซิงค์ตัวละครที่แสดงแบบสาธารณะ",
"inGameProfile": "โปรไฟล์ผู้บุกเบิกในเกม",
"displayCharactersPublicly": "แสดงตัวละครแบบสาธารณะ",
"enterUidAndSync": "ป้อน UID และซิงค์",
"lightConeSetting": "การตั้งค่า Light Cone",
"relicMaker": "สร้างรีลิกส์",
"pleaseSelectAllOptions": "โปรดเลือกตัวเลือกทั้งหมด",
@@ -219,10 +237,40 @@
"searchStage": "ค้นหาด่าน...",
"noStageFound": "ไม่พบด่าน",
"searchMonster": "ค้นหามอนสเตอร์...",
"HEAD": "ส่วนหัว",
"HAND": "ส่วนมือ",
"BODY": "ส่วนลำตัว",
"FOOT": "ส่วนเท้า",
"NECK": "Planar Sphere",
"OBJECT": "Link Rope",
"changeRelic": "เปลี่ยนรีลิกส์",
"deleteRelic": "ลบรีลิกส์",
"deleteRelicConfirm": "คุณแน่ใจหรือไม่ว่าต้องการลบรีลิกส์ในช่องนี้?",
"setEffects": "ตั้งค่าเอฟเฟกต์",
"confirmDelete": "ยืนยันการลบ",
"cancel": "ยกเลิก",
"relicDeletedSuccessfully": "ลบรีลิกส์สำเร็จ",
"profileCreatedSuccessfully": "สร้างโปรไฟล์สำเร็จ",
"profileUpdatedSuccessfully": "อัปเดตโปรไฟล์สำเร็จ",
"profileDeletedSuccessfully": "ลบโปรไฟล์สำเร็จ",
"profileSwitched": "สลับโปรไฟล์เป็น",
"deleteProfileConfirm": "คุณแน่ใจหรือไม่ว่าต้องการลบโปรไฟล์",
"deleteProfile": "ลบโปรไฟล์",
"copyText": "คัดลอกข้อความ",
"copyImage": "คัดลอกรูปภาพ",
"processing": "กำลังประมวลผล...",
"saving": "กำลังบันทึก...",
"buildInfoCopied": "คัดลอกข้อมูลบิลด์ไปยังคลิปบอร์ดแล้ว!",
"copyFailed": "คัดลอกข้อมูลบิลด์ไม่สำเร็จ!",
"imageCopiedToClipboard": "คัดลอกรูปภาพไปยังคลิปบอร์ดแล้ว!",
"clipboardWriteFailed": "ไม่สามารถเขียนลงคลิปบอร์ดได้!",
"imageSavedSuccessfully": "บันทึกรูปภาพสำเร็จ!",
"failedToGenerateImage": "สร้างรูปภาพไม่สำเร็จ!",
"errorGeneratingShowcaseCard": "เกิดข้อผิดพลาดในการสร้างการ์ดโชว์เคส!",
"feature": "ฟีเจอร์",
"fix": "แก้ไข",
"improvement": "ปรับปรุง",
"version": "เวอร์ชัน",
"setEffects": "เอฟเฟกต์เซ็ต",
"details": "รายละเอียด",
"normal": "โจมตีปกติ",
"bpskill": "สกิลต่อสู้",
@@ -258,7 +306,6 @@
"upRoll": "เพิ่มอัปเกรด",
"downRoll": "ลดอัปเกรด",
"actions": "แอ็กชัน",
"avatars": "อวตาร",
"quickView": "ดูแบบด่วน",
"extraSetting": "การตั้งค่าเพิ่มเติม",
"disableCensorship": "ปิดการเซ็นเซอร์",
+50 -3
View File
@@ -153,10 +153,28 @@
"importFreeSRDataSuccess": "Nhập dữ liệu FreeSR thành công",
"importDatabaseSuccess": "Nhập cơ sở dữ liệu thành công",
"getData": "Lấy dữ liệu",
"fetchingData": "Đang lấy dữ liệu...",
"import": "Nhập",
"freeSRImport": "Nhập FreeSR",
"onlySupportFreeSRJsonFile": "Chỉ hỗ trợ tệp JSON từ FreeSR",
"pickAFile": "Chọn tệp",
"changeFile": "Đổi file",
"avatars": "Nhân vật",
"noFreeSRDataLoaded": "Chưa có dữ liệu FreeSR",
"dropFreeSRJsonHint": "Kéo thả file FreeSR.json vào đây hoặc nhấn để chọn từ thiết bị của bạn.",
"step1": "BƯỚC 1",
"step2": "BƯỚC 2",
"step3": "BƯỚC 3",
"exportJsonFromFreeSR": "Xuất JSON từ FreeSR",
"dropOrSelectFile": "Kéo thả hoặc chọn file",
"syncCharacterBuilds": "Đồng bộ build nhân vật",
"enterUidPlaceholder": "Nhập UID 9 chữ số...",
"publicCharacters": "Nhân vật công khai",
"fetchFromEnka": "Lấy dữ liệu từ Enka.Network",
"fetchEnkaHint": "Nhập UID Honkai: Star Rail ở trên để đồng bộ các nhân vật trưng bày công khai.",
"inGameProfile": "Hồ sơ Nhà Khai Phá trong game",
"displayCharactersPublicly": "Bật hiển thị nhân vật công khai",
"enterUidAndSync": "Nhập UID & Đồng bộ",
"lightConeSetting": "Cài đặt Nón Ánh Sáng",
"relicMaker": "Trình tạo Thánh Di Vật",
"pleaseSelectAllOptions": "Vui lòng chọn tất cả tùy chọn",
@@ -219,10 +237,40 @@
"searchStage": "Tìm màn...",
"noStageFound": "Không tìm thấy màn",
"searchMonster": "Tìm quái...",
"HEAD": "Đầu",
"HAND": "Tay",
"BODY": "Thân",
"FOOT": "Chân",
"NECK": "Quả Cầu Vị Diện",
"OBJECT": "Dây Liên Kết",
"changeRelic": "Thay đổi di vật",
"deleteRelic": "Xóa di vật",
"deleteRelicConfirm": "Bạn có chắc chắn muốn xóa di vật trong ô này không?",
"setEffects": "Thiết lập hiệu ứng",
"deleteRelicConfirm": "Bạn có chắc chắn muốn xóa di vật trong ô",
"confirmDelete": "Xác nhận xóa",
"cancel": "Hủy",
"relicDeletedSuccessfully": "Đã xóa di vật thành công",
"profileCreatedSuccessfully": "Đã tạo hồ sơ thành công",
"profileUpdatedSuccessfully": "Đã cập nhật hồ sơ thành công",
"profileDeletedSuccessfully": "Đã xóa hồ sơ thành công",
"profileSwitched": "Đã chuyển sang hồ sơ",
"deleteProfileConfirm": "Bạn có chắc chắn muốn xóa hồ sơ",
"deleteProfile": "Xóa hồ sơ",
"copyText": "Sao chép Text",
"copyImage": "Sao chép Ảnh",
"processing": "Đang xử lý...",
"saving": "Đang lưu...",
"buildInfoCopied": "Đã sao chép thông tin build!",
"copyFailed": "Lỗi khi sao chép thông tin build!",
"imageCopiedToClipboard": "Đã sao chép ảnh vào Clipboard!",
"clipboardWriteFailed": "Trình duyệt không cho phép sao chép ảnh trực tiếp!",
"imageSavedSuccessfully": "Đã lưu ảnh thành công!",
"failedToGenerateImage": "Không thể tạo ảnh!",
"errorGeneratingShowcaseCard": "Lỗi khi tạo ảnh showcase!",
"feature": "Tính năng",
"fix": "Sửa lỗi",
"improvement": "Cải tiến",
"version": "Phiên bản",
"setEffects": "Hiệu ứng bộ",
"details": "Chi tiết",
"normal": "Đánh thường",
"bpskill": "Chiến kỹ",
@@ -257,7 +305,6 @@
"upRoll": "Tăng dòng",
"downRoll": "Giảm dòng",
"actions": "Hành động",
"avatars": "Nhân vật",
"quickView": "Xem nhanh",
"extraSetting": "Cài đặt bổ sung",
"disableCensorship": "Tắt kiểm duyệt",
+49 -2
View File
@@ -153,10 +153,28 @@
"importFreeSRDataSuccess": "导入 FreeSR 数据成功",
"importDatabaseSuccess": "导入数据库成功",
"getData": "获取数据",
"fetchingData": "正在获取数据...",
"import": "导入",
"freeSRImport": "导入 FreeSR",
"onlySupportFreeSRJsonFile": "仅支持 FreeSR JSON 文件",
"pickAFile": "选择文件",
"changeFile": "更换文件",
"avatars": "角色",
"noFreeSRDataLoaded": "未加载 FreeSR 数据",
"dropFreeSRJsonHint": "将 FreeSR.json 文件拖放到此处,或点击从设备中选择。",
"step1": "步骤 1",
"step2": "步骤 2",
"step3": "步骤 3",
"exportJsonFromFreeSR": "从 FreeSR 导出 JSON",
"dropOrSelectFile": "拖放或选择文件",
"syncCharacterBuilds": "同步角色配置",
"enterUidPlaceholder": "输入 9 位 UID...",
"publicCharacters": "公开展示角色",
"fetchFromEnka": "从 Enka.Network 获取数据",
"fetchEnkaHint": "在上方输入崩坏:星穹铁道 UID 即可同步公开展示的角色。",
"inGameProfile": "游戏内开拓者个人资料",
"displayCharactersPublicly": "将展示角色设为公开",
"enterUidAndSync": "输入 UID 并同步",
"lightConeSetting": "光锥设置",
"relicMaker": "遗器制作",
"pleaseSelectAllOptions": "请选择所有选项",
@@ -218,10 +236,40 @@
"searchStage": "搜索关卡...",
"noStageFound": "未找到关卡",
"searchMonster": "搜索怪物...",
"HEAD": "头部",
"HAND": "手部",
"BODY": "躯干",
"FOOT": "脚部",
"NECK": "位面球",
"OBJECT": "连结绳",
"changeRelic": "更换遗物",
"deleteRelic": "删除遗物",
"deleteRelicConfirm": "确定要删除插槽中的遗物吗",
"setEffects": "设置效果",
"confirmDelete": "确认删除",
"cancel": "取消",
"relicDeletedSuccessfully": "遗物删除成功",
"profileCreatedSuccessfully": "配置创建成功",
"profileUpdatedSuccessfully": "配置更新成功",
"profileDeletedSuccessfully": "配置删除成功",
"profileSwitched": "已切换至配置",
"deleteProfileConfirm": "确定要删除此配置吗",
"deleteProfile": "删除配置",
"copyText": "复制文本",
"copyImage": "复制图片",
"processing": "处理中...",
"saving": "保存中...",
"buildInfoCopied": "已复制配装信息到剪贴板!",
"copyFailed": "复制配装信息失败!",
"imageCopiedToClipboard": "已复制图片到剪贴板!",
"clipboardWriteFailed": "浏览器不支持直接写入剪贴板!",
"imageSavedSuccessfully": "图片保存成功!",
"failedToGenerateImage": "生成图片失败!",
"errorGeneratingShowcaseCard": "生成展示卡片出错!",
"feature": "新特性",
"fix": "修复",
"improvement": "改进",
"version": "版本",
"setEffects": "套装效果",
"details": "详情",
"normal": "普通攻击",
"bpskill": "技能",
@@ -257,7 +305,6 @@
"upRoll": "增加副属性",
"downRoll": "减少副属性",
"actions": "操作",
"avatars": "头像",
"quickView": "快速预览",
"extraSetting": "额外设置",
"disableCensorship": "禁用审查",
+75 -14
View File
@@ -77,6 +77,8 @@ export default function ActionBar() {
return avatar?.profileList?.[avatar?.profileSelect] ?? avatar?.profileList?.[0] ?? null;
}, [avatarSelected, avatars]);
const [profileToDelete, setProfileToDelete] = useState<{ id: number; name: string } | null>(null);
const listProfile = useMemo(() => {
if (!avatarSelected) return [];
const avatar = avatars[avatarSelected.ID];
@@ -93,13 +95,13 @@ export default function ActionBar() {
};
const newListProfile = [...listProfile, newProfile];
setAvatar({ ...avatars[avatarSelected.ID], profileList: newListProfile, profileSelect: newListProfile.length - 1 })
toast.success("Profile created successfully")
toast.success(transI18n("profileCreatedSuccessfully"))
} else if (formState === "EDIT" && profileCurrent && avatarSelected && profileEdit !== -1) {
const newListProfile = listProfile.map((p, idx) =>
idx === profileEdit ? { ...p, profile_name: profileName } : p
);
setAvatar({ ...avatars[avatarSelected.ID], profileList: newListProfile })
toast.success("Profile updated successfully")
toast.success(transI18n("profileUpdatedSuccessfully"))
}
handleCloseModal("update_profile_modal");
};
@@ -129,22 +131,33 @@ export default function ActionBar() {
setAvatar({ ...avatars[avatarSelected.ID], profileSelect: profileId })
const selectedProfile = avatars[avatarSelected.ID].profileList?.[profileId];
if (selectedProfile) {
toast.success(`Profile changed to: ${selectedProfile.profile_name}`)
toast.success(`${transI18n("profileSwitched")}: ${selectedProfile.profile_name}`)
}
}
const handleDeleteProfile = (profileId: number, e: React.MouseEvent) => {
const requestDeleteProfile = (profileId: number, e: React.MouseEvent) => {
e.stopPropagation()
if (!avatarSelected || profileId === 0 || !avatars[avatarSelected.ID]) return;
const targetProfile = avatars[avatarSelected.ID].profileList?.[profileId];
if (!targetProfile) return;
if (window.confirm(`Are you sure you want to delete profile: ${targetProfile.profile_name}?`)) {
const currentSelect = avatars[avatarSelected.ID].profileSelect;
const newListProfile = listProfile.filter((_, idx) => idx !== profileId);
const nextSelect = currentSelect >= profileId ? Math.max(0, currentSelect - 1) : currentSelect;
setAvatar({ ...avatars[avatarSelected.ID], profileList: newListProfile, profileSelect: nextSelect })
toast.success(`Profile ${targetProfile.profile_name} deleted successfully`)
setProfileToDelete({ id: profileId, name: targetProfile.profile_name });
handleShow("delete_profile_confirm_modal");
}
const handleConfirmDeleteProfile = () => {
if (!avatarSelected || !profileToDelete || !avatars[avatarSelected.ID]) return;
const currentSelect = avatars[avatarSelected.ID].profileSelect;
const newListProfile = listProfile.filter((_, idx) => idx !== profileToDelete.id);
const nextSelect = currentSelect >= profileToDelete.id ? Math.max(0, currentSelect - 1) : currentSelect;
setAvatar({ ...avatars[avatarSelected.ID], profileList: newListProfile, profileSelect: nextSelect })
toast.success(transI18n("profileDeletedSuccessfully"))
handleCloseModal("delete_profile_confirm_modal");
setProfileToDelete(null);
}
const handleCancelDeleteProfile = () => {
handleCloseModal("delete_profile_confirm_modal");
setProfileToDelete(null);
}
const handleConnectOrSyncPS = async () => {
@@ -193,7 +206,7 @@ export default function ActionBar() {
</div>
<div className="flex justify-end gap-2 pt-2">
<button className="btn btn-ghost" onClick={() => setIsOpenCreateProfile(false)}>
Cancel
{transI18n("cancel")}
</button>
<button className="btn btn-primary" onClick={handleUpdateProfile}>
{formState === "CREATE" ? transI18n("create") : transI18n("update")}
@@ -320,14 +333,14 @@ export default function ActionBar() {
setIsOpenCreateProfile(true)
handleShow("update_profile_modal")
}}
title="Edit Profile"
title={transI18n("editProfile")}
>
<Edit2 className="w-3 h-3" />
</button>
<button
className="btn btn-ghost btn-xs btn-circle text-error hover:bg-error/20"
onClick={(e) => handleDeleteProfile(index, e)}
title="Delete Profile"
onClick={(e) => requestDeleteProfile(index, e)}
title={transI18n("deleteProfile")}
>
<Trash2 className="w-3 h-3" />
</button>
@@ -490,6 +503,7 @@ export default function ActionBar() {
{title}
</h3>
<button
type="button"
className="btn btn-sm btn-circle btn-ghost text-base-content/60 hover:text-base-content hover:bg-base-200 transition-colors"
onClick={onClose}
>
@@ -505,6 +519,53 @@ export default function ActionBar() {
</form>
</dialog>
))}
{/* Delete Profile Confirmation Modal */}
<dialog id="delete_profile_confirm_modal" className="modal modal-bottom sm:modal-middle">
<div className="modal-box max-w-md bg-base-100/95 backdrop-blur-xl border border-white/10 shadow-2xl rounded-2xl p-6 overflow-hidden relative">
<div className="pointer-events-none absolute -top-12 left-1/2 -translate-x-1/2 w-32 h-32 bg-rose-500/15 rounded-full blur-2xl" />
<div className="relative z-10 text-center space-y-4">
<div className="w-12 h-12 rounded-2xl bg-rose-500/15 text-rose-400 border border-rose-500/30 flex items-center justify-center mx-auto shadow-[0_0_20px_rgba(244,63,94,0.2)]">
<Trash2 className="w-6 h-6 stroke-[1.75]" />
</div>
<div className="space-y-1.5">
<h3 className="font-black text-lg text-base-content tracking-tight">
{transI18n("deleteProfile")}
</h3>
<p className="text-xs sm:text-sm text-base-content/70 leading-relaxed">
{transI18n("deleteProfileConfirm")}{" "}
<span className="font-bold text-amber-400 font-mono">
&quot;{profileToDelete?.name}&quot;
</span>
?
</p>
</div>
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleCancelDeleteProfile}
className="flex-1 h-10 px-4 rounded-xl font-bold text-xs sm:text-sm bg-base-200 hover:bg-base-300 text-base-content/80 hover:text-base-content border border-white/10 transition-all cursor-pointer"
>
{transI18n("cancel")}
</button>
<button
type="button"
onClick={handleConfirmDeleteProfile}
className="flex-1 h-10 px-4 rounded-xl font-bold text-xs sm:text-sm bg-gradient-to-r from-rose-500 to-red-600 hover:from-rose-400 hover:to-red-500 text-white shadow-md shadow-rose-500/25 hover:shadow-rose-500/40 transition-all flex items-center justify-center gap-1.5 cursor-pointer"
>
<Trash2 className="w-4 h-4" />
<span>{transI18n("confirmDelete")}</span>
</button>
</div>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={handleCancelDeleteProfile}>close</button>
</form>
</dialog>
</div>
);
}
+50 -35
View File
@@ -8,6 +8,7 @@ import ParseText from '../parseText';
import useDetailDataStore from '@/stores/detailDataStore';
import { getLocaleName } from '@/helper/getName';
import { useShallow } from 'zustand/react/shallow';
import { Check } from 'lucide-react';
export default function CharacterInfoCard({
character,
@@ -35,15 +36,24 @@ export default function CharacterInfoCard({
return (
<div
className={`group rounded-2xl p-4 border transition-all duration-200 cursor-pointer select-none ${
className={`group relative overflow-hidden rounded-2xl p-3 sm:p-4 border transition-all duration-300 ease-out cursor-pointer select-none ${
isSelected
? 'bg-primary/10 border-primary shadow-lg shadow-primary/20 ring-2 ring-primary/40'
: 'bg-base-200/60 border-white/5 hover:border-white/20 opacity-80 hover:opacity-100 hover:bg-base-200'
? 'bg-primary/10 border-primary/80 shadow-[0_0_24px_rgba(var(--color-primary-rgb,6,182,212),0.25)] ring-1 ring-primary/60 scale-[1.01]'
: 'bg-base-200/50 hover:bg-base-200/90 border-white/10 hover:border-white/25 hover:shadow-lg hover:-translate-y-1'
}`}
onClick={() => onCharacterToggle(character)}
>
{/* Character Portrait */}
<div className="relative mb-3 aspect-4/5 rounded-xl overflow-hidden bg-base-300/40 flex items-center justify-center">
{/* Selection Checkmark Badge */}
<div className={`absolute top-2.5 right-2.5 z-20 w-6 h-6 rounded-full flex items-center justify-center transition-all duration-200 ${
isSelected
? 'bg-primary text-primary-content shadow-md scale-100 ring-2 ring-primary/40'
: 'bg-black/40 border border-white/20 text-transparent scale-90 group-hover:scale-100 group-hover:border-white/40'
}`}>
<Check className={`w-3.5 h-3.5 stroke-[3] ${isSelected ? 'opacity-100' : 'opacity-0'}`} />
</div>
{/* Character Portrait Area */}
<div className="relative mb-3 aspect-4/5 rounded-xl overflow-hidden bg-radial from-base-300/60 to-base-300/20 flex items-center justify-center">
{avatar && (
<>
<Image
@@ -53,61 +63,64 @@ export default function CharacterInfoCard({
height={512}
unoptimized
crossOrigin="anonymous"
className="w-full h-full object-contain transition-transform duration-300 group-hover:scale-105"
className="w-full h-full object-contain transition-transform duration-500 ease-out group-hover:scale-108"
/>
{/* Damage & Path Badges */}
<div className="absolute top-2 left-2 flex items-center gap-1.5 z-10">
{damageType?.[avatar.DamageType]?.Icon && (
<div className="absolute top-1.5 left-1.5 p-0.5 rounded-full bg-black/60 backdrop-blur-xs border border-white/10">
<div className="p-1 rounded-lg bg-black/65 backdrop-blur-md border border-white/15 shadow-sm" title={avatar.DamageType}>
<Image
width={24}
height={24}
width={20}
height={20}
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${damageType[avatar.DamageType].Icon}`}
className="w-5 h-5 object-contain"
className="w-4 h-4 object-contain"
alt={avatar.DamageType.toLowerCase()}
/>
</div>
)}
{baseType?.[avatar.BaseType]?.Icon && (
<div className="absolute top-1.5 right-1.5 p-0.5 rounded-full bg-black/60 backdrop-blur-xs border border-white/10">
<div className="p-1 rounded-lg bg-black/65 backdrop-blur-md border border-white/15 shadow-sm" title={avatar.BaseType}>
<Image
width={24}
height={24}
width={20}
height={20}
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${baseType[avatar.BaseType].Icon}`}
className="w-5 h-5 object-contain"
className="w-4 h-4 object-contain"
alt={avatar.BaseType.toLowerCase()}
/>
</div>
)}
</div>
</>
)}
</div>
{/* Character Name and Level Badge */}
<div className="text-center mb-3">
{/* Character Name & Badges */}
<div className="text-center mb-2.5">
<ParseText
className="text-base font-bold line-clamp-1"
className="text-sm sm:text-base font-bold line-clamp-1 tracking-tight"
text={avatar ? getLocaleName(locale, avatar.Name) : character.avatar_id.toString()}
locale={locale}
/>
<div className="flex items-center justify-center gap-2 mt-1">
<span className="text-xs font-mono font-semibold px-2 py-0.5 rounded-full bg-base-300/80 text-base-content/80">
<div className="flex items-center justify-center gap-2 mt-1.5">
<span className="text-[11px] font-mono font-semibold px-2 py-0.5 rounded-md bg-base-300/80 text-base-content/90 border border-white/5">
Lv.{character.level}
</span>
<span className="text-xs font-mono font-bold px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30">
<span className="text-[11px] font-mono font-bold px-2 py-0.5 rounded-md bg-amber-500/15 text-amber-400 border border-amber-500/30">
E{character.rank}
</span>
</div>
</div>
{/* Relics Mini-grid */}
{/* Relics Grid (2 rows of 3, no overflow) */}
{character.relics.length > 0 && (
<div className="flex flex-wrap items-center justify-center gap-1.5 mb-3 p-1.5 rounded-xl bg-base-100/50 border border-white/5">
<div className="grid grid-cols-3 gap-1.5 sm:gap-2 justify-items-center py-1.5 px-2.5 rounded-xl bg-base-300/30 w-fit mx-auto mb-2.5">
{character.relics.map((relic, index) => (
<div key={index} className="relative group/relic">
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-base-200 border border-amber-500/40">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg flex items-center justify-center bg-base-100/90 border border-amber-500/30 hover:border-amber-400 transition-colors shadow-xs">
<Image
src={`${process.env.CDN_URL}/spriteoutput/relicfigures/IconRelic_${relic.relic_set_id}_${relic.relic_id.toString()[relic.relic_id.toString().length - 1]}.png`}
alt="Relic"
@@ -115,10 +128,10 @@ export default function CharacterInfoCard({
crossOrigin="anonymous"
width={32}
height={32}
className="w-6 h-6 object-contain"
className="w-5 h-5 sm:w-6 sm:h-6 object-contain"
/>
</div>
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 bg-black/80 text-[9px] font-mono font-bold text-amber-400 px-1 rounded-full border border-white/10">
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2 bg-black/90 text-[8px] font-mono font-bold text-amber-300 px-1 rounded-full border border-white/10 leading-tight">
+{relic.level}
</div>
</div>
@@ -126,29 +139,31 @@ export default function CharacterInfoCard({
</div>
)}
{/* Light Cone */}
{/* Light Cone Preview (Seamless strip) */}
{character.lightcone && character.lightcone.item_id !== 0 && (
<div className="p-2 rounded-xl bg-base-100/50 border border-white/5 text-center">
<div className="h-28 flex items-center justify-center mb-1">
<div className="pt-2 border-t border-white/10 flex items-center gap-2.5">
<div className="h-12 w-9 shrink-0 flex items-center justify-center rounded bg-base-300/40 overflow-hidden">
<Image
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${mapLightCone?.[character.lightcone.item_id.toString()]?.Image?.ImagePath}`}
alt="Lightcone"
width={160}
height={200}
className="h-full w-auto object-contain rounded-lg"
width={72}
height={96}
className="h-full w-auto object-contain"
/>
</div>
<div className="text-xs font-bold truncate">
<div className="flex-1 min-w-0 text-left">
<div className="text-xs font-semibold truncate leading-tight">
<ParseText
text={getLocaleName(locale, mapLightCone?.[character.lightcone.item_id.toString()]?.Name)}
locale={locale}
/>
</div>
<div className="flex items-center justify-center gap-2 mt-1">
<span className="text-[11px] font-mono text-base-content/70">Lv.{character.lightcone.level}</span>
<span className="text-[11px] font-mono font-bold text-primary">S{character.lightcone.rank}</span>
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] font-mono text-base-content/70">Lv.{character.lightcone.level}</span>
<span className="text-[10px] font-mono font-bold text-primary">S{character.lightcone.rank}</span>
</div>
</div>
</div>
)}
+46 -29
View File
@@ -5,6 +5,7 @@ import useUserDataStore from "@/stores/userDataStore";
import { useShallow } from "zustand/react/shallow";
import Image from "next/image";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
interface RelicCardProps {
slot: string
@@ -26,36 +27,23 @@ const getRarityColor = (rarity: string) => {
}
};
const getSlotIconAndName = (slot: string) => {
const slotMap: Record<string, { icon: string; name: string }> = {
'1': { icon: '/relics/HEAD.png', name: 'Head' },
'2': { icon: '/relics/HAND.png', name: 'Hands' },
'3': { icon: '/relics/BODY.png', name: 'Body' },
'4': { icon: '/relics/FOOT.png', name: 'Feet' },
'5': { icon: '/relics/NECK.png', name: 'Planar Sphere' },
'6': { icon: '/relics/OBJECT.png', name: 'Link Rope' },
};
const current = slotMap[slot];
if (!current) return null;
return (
<div className="flex items-center gap-1.5 justify-center mt-2.5">
<Image
unoptimized
crossOrigin="anonymous"
src={current.icon}
alt={current.name}
width={18}
height={18}
className="w-4 h-4 object-contain theme-stat-icon opacity-85"
/>
<span className="text-xs font-bold text-base-content">{current.name}</span>
</div>
);
const slotIconMap: Record<string, { icon: string; key: string }> = {
'1': { icon: '/relics/HEAD.png', key: 'HEAD' },
'2': { icon: '/relics/HAND.png', key: 'HAND' },
'3': { icon: '/relics/BODY.png', key: 'BODY' },
'4': { icon: '/relics/FOOT.png', key: 'FOOT' },
'5': { icon: '/relics/NECK.png', key: 'NECK' },
'6': { icon: '/relics/OBJECT.png', key: 'OBJECT' },
'HEAD': { icon: '/relics/HEAD.png', key: 'HEAD' },
'HAND': { icon: '/relics/HAND.png', key: 'HAND' },
'BODY': { icon: '/relics/BODY.png', key: 'BODY' },
'FOOT': { icon: '/relics/FOOT.png', key: 'FOOT' },
'NECK': { icon: '/relics/NECK.png', key: 'NECK' },
'OBJECT': { icon: '/relics/OBJECT.png', key: 'OBJECT' },
};
export default function RelicCard({ slot, avatarId }: RelicCardProps) {
const transI18n = useTranslations("DataPage");
const { avatars } = useUserDataStore(
useShallow((state) => ({ avatars: state.avatars }))
)
@@ -63,6 +51,9 @@ export default function RelicCard({ slot, avatarId }: RelicCardProps) {
useShallow((state) => ({ selectedRelicSlot: state.selectedRelicSlot }))
)
const slotInfo = slotIconMap[slot];
const slotDisplayName = slotInfo ? (transI18n(slotInfo.key) || slotInfo.key) : slot;
const relicDetail = useMemo(() => {
const avatar = avatars[avatarId];
if (avatar) {
@@ -103,7 +94,20 @@ export default function RelicCard({ slot, avatarId }: RelicCardProps) {
</div>
</div>
{getSlotIconAndName(slot)}
{slotInfo && (
<div className="flex items-center gap-1.5 justify-center mt-2.5">
<Image
unoptimized
crossOrigin="anonymous"
src={slotInfo.icon}
alt={slotDisplayName}
width={18}
height={18}
className="w-4 h-4 object-contain theme-stat-icon opacity-85"
/>
<span className="text-xs font-bold text-base-content">{slotDisplayName}</span>
</div>
)}
</div>
) : (
<div className="flex flex-col items-center">
@@ -127,7 +131,20 @@ export default function RelicCard({ slot, avatarId }: RelicCardProps) {
</div>
</div>
{getSlotIconAndName(slot)}
{slotInfo && (
<div className="flex items-center gap-1.5 justify-center mt-2.5">
<Image
unoptimized
crossOrigin="anonymous"
src={slotInfo.icon}
alt={slotDisplayName}
width={18}
height={18}
className="w-4 h-4 object-contain theme-stat-icon opacity-85"
/>
<span className="text-xs font-bold text-base-content">{slotDisplayName}</span>
</div>
)}
</div>
)}
</div>
+7 -5
View File
@@ -1,8 +1,10 @@
import useLocaleStore from '@/stores/localeStore';
import { useShallow } from 'zustand/react/shallow';
import { Sparkles, Bug, Zap, Package, Calendar } from 'lucide-react';
import { useTranslations } from 'next-intl';
export default function ChangelogBar() {
const transI18n = useTranslations("DataPage");
const { changelog } = useLocaleStore(
useShallow((state) => ({ changelog: state.changelog }))
)
@@ -36,13 +38,13 @@ export default function ChangelogBar() {
const getTypeLabel = (type: string) => {
switch (type) {
case 'feature':
return 'Feature';
return transI18n("feature") || 'Feature';
case 'fix':
return 'Fix';
return transI18n("fix") || 'Fix';
case 'improvement':
return 'Improvement';
return transI18n("improvement") || 'Improvement';
default:
return 'Update';
return transI18n("update") || 'Update';
}
};
@@ -59,7 +61,7 @@ export default function ChangelogBar() {
{getTypeLabel(change.type)}
</span>
<h3 className="font-extrabold text-sm sm:text-base tracking-tight">
Version {change.version}
{transI18n("version") || "Version"} {change.version}
</h3>
</div>
+2 -2
View File
@@ -646,8 +646,8 @@ export default function Header() {
: 'max-w-[92vw] sm:max-w-[88vw] max-h-[90vh]'
} max-h-[90vh] flex flex-col bg-base-100 text-base-content border border-base-content/10 shadow-2xl rounded-2xl p-0 overflow-hidden`}>
{/* Modal Header */}
<div className="sticky top-0 z-20 flex items-center justify-between px-6 py-4 border-b border-base-content/10 bg-base-100 shrink-0">
<h3 className="font-bold text-xl tracking-tight bg-clip-text text-transparent bg-linear-to-r from-primary via-accent to-secondary">
<div className="sticky top-0 z-20 flex items-center justify-between px-4 py-3 sm:px-6 sm:py-4 border-b border-base-content/10 bg-base-100 shrink-0">
<h3 className="font-bold text-lg sm:text-xl tracking-tight bg-clip-text text-transparent bg-linear-to-r from-primary via-accent to-secondary">
{title}
</h3>
<button
+262 -76
View File
@@ -1,4 +1,5 @@
"use client"
import { useState } from "react";
import CharacterInfoCard from "../card/characterInfoCard";
import useEnkaStore from "@/stores/enkaStore";
@@ -13,6 +14,18 @@ import { avatarEnkaDetailSchema, enkaResponseSchema } from "@/zod";
import { z } from "zod";
import useDetailDataStore from "@/stores/detailDataStore";
import { useShallow } from "zustand/react/shallow";
import {
Search,
CheckCheck,
RotateCcw,
AlertCircle,
Sparkles,
ArrowRight,
Users,
Globe,
UserCheck,
Loader2
} from "lucide-react";
type AvatarEnkaDetailInput = z.infer<typeof avatarEnkaDetailSchema>;
@@ -51,7 +64,7 @@ export default function EnkaImport() {
setEnkaData: state.setEnkaData,
}))
);
const transI18n = useTranslations("DataPage")
const transI18n = useTranslations("DataPage");
const { avatars, setAvatar } = useUserDataStore(
useShallow((state) => ({
avatars: state.avatars,
@@ -62,48 +75,66 @@ export default function EnkaImport() {
useShallow((state) => ({
setIsOpenImport: state.setIsOpenImport,
}))
)
);
const { mapAvatar } = useDetailDataStore(
useShallow((state) => ({
mapAvatar: state.mapAvatar,
}))
)
const [isLoading, setIsLoading] = useState(false)
const [Error, setError] = useState("")
const validCharacters = enkaData?.detailInfo.avatarDetailList.filter((character) => mapAvatar?.[character.avatarId]) ?? []
);
const [isLoading, setIsLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const validCharacters = enkaData?.detailInfo.avatarDetailList.filter((character) => mapAvatar?.[character.avatarId]) ?? [];
const handlerFetchData = async () => {
if (!uidInput) {
setError(transI18n("pleaseEnterUid"))
const cleanUid = uidInput.trim();
if (!cleanUid) {
setErrorMsg(transI18n("pleaseEnterUid"));
return;
}
setIsLoading(true)
setIsLoading(true);
setErrorMsg("");
try {
const data: unknown = await SendDataThroughProxy({data: {serverUrl: "https://enka.network/api/hsr/uid/" + uidInput, method: "GET"}})
const parsed = enkaResponseSchema.safeParse(data)
const data: unknown = await SendDataThroughProxy({
data: {
serverUrl: "https://enka.network/api/hsr/uid/" + cleanUid,
method: "GET"
}
});
const parsed = enkaResponseSchema.safeParse(data);
if (!parsed.success) {
setEnkaData(null)
setSelectedCharacters([])
setError(transI18n("failedToFetchEnkaData"))
return
setEnkaData(null);
setSelectedCharacters([]);
setErrorMsg(transI18n("failedToFetchEnkaData"));
return;
}
setEnkaData(parsed.data as EnkaResponse)
setSelectedCharacters(parsed.data.detailInfo.avatarDetailList.filter((character) => mapAvatar?.[character.avatarId]).map(toCharacterInfoCard))
setError("")
setEnkaData(parsed.data as EnkaResponse);
const validChars = parsed.data.detailInfo.avatarDetailList
.filter((character) => mapAvatar?.[character.avatarId])
.map(toCharacterInfoCard);
setSelectedCharacters(validChars);
setErrorMsg("");
} catch {
setEnkaData(null)
setSelectedCharacters([])
setError(transI18n("failedToFetchEnkaData"))
setEnkaData(null);
setSelectedCharacters([]);
setErrorMsg(transI18n("failedToFetchEnkaData"));
} finally {
setIsLoading(false)
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handlerFetchData();
}
};
const handleCharacterToggle = (character: CharacterInfoCardType) => {
if (selectedCharacters.some((selectedCharacter) => selectedCharacter.key === character.key)) {
setSelectedCharacters(selectedCharacters.filter((selectedCharacter) => selectedCharacter.key !== character.key));
if (selectedCharacters.some((selected) => selected.key === character.key)) {
setSelectedCharacters(selectedCharacters.filter((selected) => selected.key !== character.key));
return;
}
setSelectedCharacters([...selectedCharacters, character]);
@@ -121,29 +152,32 @@ export default function EnkaImport() {
const handleImport = () => {
if (selectedCharacters.length === 0) {
setError(transI18n("pleaseSelectAtLeastOneCharacter"));
setErrorMsg(transI18n("pleaseSelectAtLeastOneCharacter"));
return;
}
if (!enkaData) {
setError(transI18n("noDataToImport"));
setErrorMsg(transI18n("noDataToImport"));
return;
}
setError("");
const listAvatars = { ...avatars }
const parsed = enkaResponseSchema.safeParse(enkaData)
setErrorMsg("");
const listAvatars = { ...avatars };
const parsed = enkaResponseSchema.safeParse(enkaData);
if (!parsed.success) {
setError(transI18n("failedToFetchEnkaData"));
setErrorMsg(transI18n("failedToFetchEnkaData"));
return;
}
const filterData = parsed.data.detailInfo.avatarDetailList.filter((character) => mapAvatar?.[character.avatarId] && selectedCharacters.some((selectedCharacter) => selectedCharacter.avatar_id === character.avatarId))
const filterData = parsed.data.detailInfo.avatarDetailList.filter((character) =>
mapAvatar?.[character.avatarId] && selectedCharacters.some((selected) => selected.avatar_id === character.avatarId)
);
filterData.forEach((character) => {
const currentAvatar = listAvatars[character.avatarId.toString()]
const currentAvatar = listAvatars[character.avatarId.toString()];
if (currentAvatar && Object.keys(currentAvatar).length !== 0) {
const newProfileList = [...(currentAvatar.profileList || [])]
const newProfile = converterOneEnkaDataToAvatarStore(character, newProfileList.length)
const newProfileList = [...(currentAvatar.profileList || [])];
const newProfile = converterOneEnkaDataToAvatarStore(character, newProfileList.length);
if (newProfile) {
newProfileList.push(newProfile)
newProfileList.push(newProfile);
}
const newAvatar = {
...currentAvatar,
@@ -158,83 +192,233 @@ export default function EnkaImport() {
},
profileList: newProfileList,
profileSelect: newProfile ? newProfileList.length - 1 : currentAvatar.profileSelect,
};
setAvatar(newAvatar);
}
setAvatar(newAvatar)
}
})
setIsOpenImport(false)
toast.success(transI18n("importEnkaDataSuccess"))
});
setIsOpenImport(false);
toast.success(transI18n("importEnkaDataSuccess"));
};
return (
<div className="w-full space-y-6">
{/* Header */}
<div className="space-y-4">
<h1 className="text-base-content text-xl sm:text-2xl font-bold">HSR UID</h1>
{/* Top Bar: Title & UID Input */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 sm:gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="px-2 py-0.5 text-[10px] font-bold uppercase tracking-widest rounded-md bg-emerald-500/15 text-emerald-400 border border-emerald-500/25">
Enka.Network Live
</span>
<span className="text-xs text-base-content/50 font-medium">Showcase Fetcher</span>
</div>
<h2 className="text-xl sm:text-2xl font-black tracking-tight text-base-content flex items-center gap-2">
HSR UID Showcase
</h2>
</div>
<div className="flex flex-wrap gap-3 items-center">
{/* UID Search Input Form */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2.5 w-full md:w-auto">
<div className="relative flex-1 md:w-72">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/50 pointer-events-none" />
<input
type="text"
value={uidInput}
onChange={(e) => setUidInput(e.target.value)}
className="input input-bordered w-full max-w-xs rounded-xl bg-base-200 border-base-300 text-sm focus:border-primary"
placeholder="Enter UID"
onKeyDown={handleKeyDown}
className="w-full h-10 pl-10 pr-9 rounded-xl bg-base-200/90 text-base-content border border-base-content/20 text-sm font-mono focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500 transition-all placeholder:font-sans placeholder:text-xs placeholder:text-base-content/40"
placeholder={transI18n("enterUidPlaceholder")}
/>
<button onClick={handlerFetchData} className="btn btn-sm btn-success rounded-xl font-bold">
{transI18n("getData")}
</button>
{selectedCharacters.length > 0 && (
<button onClick={handleImport} className="btn btn-sm btn-primary rounded-xl font-bold shadow-xs">
{transI18n("import")}
{uidInput && (
<button
type="button"
onClick={() => setUidInput("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-base-content text-xs p-0.5 rounded-full hover:bg-base-300 transition-colors"
>
✕
</button>
)}
</div>
{Error && (
<div className="text-error text-sm font-semibold mt-2">😭 {Error}</div>
<button
type="button"
onClick={handlerFetchData}
disabled={isLoading}
className={`h-10 min-h-[40px] px-5 rounded-xl font-bold text-xs sm:text-sm flex items-center justify-center gap-2 select-none shrink-0 transition-all duration-200 shadow-md ${
isLoading
? 'bg-cyan-600 text-white cursor-wait opacity-90 shadow-cyan-500/30'
: 'bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white shadow-cyan-500/25 hover:shadow-cyan-500/40 hover:scale-[1.02] active:scale-[0.98] cursor-pointer'
}`}
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin text-white shrink-0" />
<span className="text-white font-bold">{transI18n("fetchingData")}</span>
</>
) : (
<>
<Sparkles className="w-4 h-4 text-white shrink-0" />
<span className="text-white font-bold">{transI18n("getData")}</span>
</>
)}
</button>
</div>
</div>
{/* Error Message Banner */}
{errorMsg && (
<div className="flex items-center gap-2 px-4 py-3 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-400 text-xs font-semibold animate-in fade-in duration-200">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{errorMsg}</span>
</div>
)}
{/* Player Info */}
{/* Trailblazer Profile HUD Banner (when loaded) */}
{enkaData && (
<div className="bg-base-200 p-4 rounded-xl border border-base-300 text-base-content space-y-1">
<div className="text-sm font-semibold">Name: <span className="font-bold text-primary">{enkaData.detailInfo.nickname}</span></div>
<div className="text-sm font-semibold">UID: <span className="font-mono">{enkaData.detailInfo.uid}</span></div>
<div className="text-sm font-semibold">Level: <span className="font-bold">{enkaData.detailInfo.level}</span></div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 py-3 px-4 rounded-xl bg-base-200/50 border border-white/10">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/30 flex items-center justify-center text-primary font-bold text-base shrink-0">
<UserCheck className="w-5 h-5" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-base-content text-sm sm:text-base truncate">
{enkaData.detailInfo.nickname}
</span>
<span className="text-[10px] font-mono font-bold px-1.5 py-0.5 rounded bg-primary/15 text-primary border border-primary/25">
Lv.{enkaData.detailInfo.level}
</span>
{enkaData.detailInfo.worldLevel && (
<span className="text-[10px] font-mono text-base-content/60 px-1.5 py-0.5 rounded bg-base-300/60">
WL {enkaData.detailInfo.worldLevel}
</span>
)}
</div>
<div className="text-[11px] font-mono text-base-content/50 mt-0.5 truncate">
UID: {enkaData.detailInfo.uid}
{enkaData.detailInfo.signature && (
<span className="ml-2 font-sans italic text-base-content/40">
&ldquo;{enkaData.detailInfo.signature}&rdquo;
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 self-end sm:self-center shrink-0">
<span className="text-xs text-base-content/60">{transI18n("publicCharacters")}:</span>
<span className="badge badge-primary font-mono font-bold text-xs">
{validCharacters.length}
</span>
</div>
</div>
)}
{/* Selection Info */}
<div className="flex flex-wrap items-center justify-between gap-3 bg-base-200 border border-base-300 p-3.5 rounded-2xl shadow-xs">
{/* Floating Selection & Actions Bar */}
{enkaData && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 py-2.5 px-4 rounded-xl bg-base-200/60 backdrop-blur-md border border-white/10 shadow-xs">
{/* Left: Counter and Quick Select */}
<div className="flex items-center justify-between sm:justify-start gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-cyan-500" />
<span className="text-xs sm:text-sm font-bold text-base-content">
{transI18n("selectedCharacters")}:
</span>
<span className="badge badge-primary font-mono font-bold text-xs">
{selectedCharacters.length}
<span className="px-2 py-0.5 rounded-full bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 font-mono font-bold text-xs">
{selectedCharacters.length} / {validCharacters.length}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={clearSelection}
className="btn btn-xs sm:btn-sm btn-ghost text-error hover:bg-error/10 border border-error/30 rounded-xl"
>
{transI18n("clearAll")}
</button>
<div className="h-4 w-px bg-white/10 hidden sm:block" />
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={selectAll}
className="btn btn-xs sm:btn-sm btn-ghost text-primary hover:bg-primary/10 border border-primary/30 rounded-xl"
className="h-8 px-2.5 rounded-lg text-xs font-semibold text-cyan-400 hover:text-cyan-300 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/25 transition-all flex items-center gap-1 cursor-pointer"
>
<CheckCheck className="w-3.5 h-3.5" />
{transI18n("selectAll")}
</button>
<button
type="button"
onClick={clearSelection}
className="h-8 px-2.5 rounded-lg text-xs font-semibold text-rose-400 hover:text-rose-300 bg-rose-500/10 hover:bg-rose-500/20 border border-rose-500/25 transition-all flex items-center gap-1 cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5" />
{transI18n("clearAll")}
</button>
</div>
</div>
</div>
{isLoading && (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
{/* Right: Import CTA */}
<button
type="button"
onClick={handleImport}
disabled={selectedCharacters.length === 0}
className="h-9 px-5 rounded-xl font-bold text-xs sm:text-sm bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white shadow-md shadow-cyan-500/25 hover:shadow-cyan-500/40 disabled:opacity-40 disabled:pointer-events-none flex items-center justify-center gap-1.5 transition-all w-full sm:w-auto cursor-pointer"
>
<span className="text-white">{transI18n("import")}</span>
<span className="font-mono text-xs text-cyan-100">({selectedCharacters.length})</span>
<ArrowRight className="w-3.5 h-3.5 text-white" />
</button>
</div>
)}
{/* Loading Shimmer State */}
{isLoading && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 sm:gap-4 py-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="rounded-2xl bg-base-200/30 border border-white/5 animate-pulse p-3.5 space-y-3">
<div className="w-full aspect-4/5 rounded-xl bg-base-300/40" />
<div className="h-4 bg-base-300/60 rounded w-2/3 mx-auto" />
<div className="h-3 bg-base-300/40 rounded w-1/2 mx-auto" />
</div>
))}
</div>
)}
{/* Empty State (when no UID fetched yet) */}
{!isLoading && !enkaData && (
<div className="relative overflow-hidden py-12 sm:py-16 px-4 sm:px-6 rounded-2xl border border-dashed border-white/15 bg-base-200/20 text-center select-none">
{/* Ambient Glow */}
<div className="pointer-events-none absolute -top-12 left-1/2 -translate-x-1/2 h-40 w-40 rounded-full bg-emerald-500/10 blur-3xl" />
<div className="relative z-10 max-w-md mx-auto space-y-4">
<div className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl bg-base-300/60 border border-white/10 text-emerald-400 mx-auto flex items-center justify-center shadow-lg">
<Globe className="w-7 h-7 sm:w-8 sm:h-8 stroke-[1.5]" />
</div>
<div className="space-y-1.5">
<h3 className="text-base sm:text-lg font-bold text-base-content">
{transI18n("fetchFromEnka")}
</h3>
<p className="text-xs text-base-content/60 leading-relaxed">
{transI18n("fetchEnkaHint")}
</p>
</div>
{/* Step hints */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5 sm:gap-3 pt-3 text-left border-t border-white/5">
<div className="p-3 rounded-xl bg-base-300/30">
<span className="text-[10px] font-bold text-emerald-400 block mb-0.5">{transI18n("step1")}</span>
<p className="text-[11px] text-base-content/70 leading-tight">{transI18n("inGameProfile")}</p>
</div>
<div className="p-3 rounded-xl bg-base-300/30">
<span className="text-[10px] font-bold text-emerald-400 block mb-0.5">{transI18n("step2")}</span>
<p className="text-[11px] text-base-content/70 leading-tight">{transI18n("displayCharactersPublicly")}</p>
</div>
<div className="p-3 rounded-xl bg-base-300/30">
<span className="text-[10px] font-bold text-emerald-400 block mb-0.5">{transI18n("step3")}</span>
<p className="text-[11px] text-base-content/70 leading-tight">{transI18n("enterUidAndSync")}</p>
</div>
</div>
</div>
</div>
)}
{/* Character Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4 sm:gap-6">
{!isLoading && enkaData && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 sm:gap-4">
{validCharacters.map((character) => (
<CharacterInfoCard
key={character.avatarId}
@@ -244,6 +428,8 @@ export default function EnkaImport() {
/>
))}
</div>
)}
</div>
);
}
+289 -107
View File
@@ -4,7 +4,7 @@ import useFreeSRStore from "@/stores/freesrStore";
import useModelStore from "@/stores/modelStore";
import useUserDataStore from "@/stores/userDataStore";
import { CharacterInfoCardType } from "@/types";
import { useState } from "react";
import { useState, useRef } from "react";
import CharacterInfoCard from "../card/characterInfoCard";
import { freeSrJsonSchema } from "@/zod";
import { toast } from "react-toastify";
@@ -13,12 +13,22 @@ import { useTranslations } from "next-intl";
import useDetailDataStore from "@/stores/detailDataStore";
import { useShallow } from "zustand/react/shallow";
import { z } from "zod";
import {
UploadCloud,
FileJson,
CheckCheck,
RotateCcw,
AlertCircle,
ArrowRight,
Users,
FileCheck
} from "lucide-react";
type FreeSRJsonInput = z.infer<typeof freeSrJsonSchema>;
const toCharacterInfoCard = (data: FreeSRJsonInput, character: FreeSRJsonInput["avatars"][string]): CharacterInfoCardType => {
const lightcone = data.lightcones.find((lightcone) => lightcone.equip_avatar === character.avatar_id)
const relics = data.relics.filter((relic) => relic.equip_avatar === character.avatar_id)
const lightcone = data.lightcones?.find((lc) => lc.equip_avatar === character.avatar_id);
const relics = data.relics?.filter((relic) => relic.equip_avatar === character.avatar_id) || [];
return {
key: character.avatar_id,
@@ -35,8 +45,8 @@ const toCharacterInfoCard = (data: FreeSRJsonInput, character: FreeSRJsonInput["
relic_id: relic.relic_id,
relic_set_id: relic.relic_set_id,
})),
}
}
};
};
export default function FreeSRImport() {
const { avatars, setAvatar } = useUserDataStore(
@@ -54,9 +64,14 @@ export default function FreeSRImport() {
useShallow((state) => ({
setIsOpenImport: state.setIsOpenImport,
}))
)
const [isLoading, setIsLoading] = useState(false)
const [Error, setError] = useState("")
);
const [isLoading, setIsLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const [fileName, setFileName] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { freeSRData, setFreeSRData, selectedCharacters, setSelectedCharacters } = useFreeSRStore(
useShallow((state) => ({
freeSRData: state.freeSRData,
@@ -64,14 +79,19 @@ export default function FreeSRImport() {
selectedCharacters: state.selectedCharacters,
setSelectedCharacters: state.setSelectedCharacters,
}))
)
const transI18n = useTranslations("DataPage")
const parsedFreeSRData = freeSrJsonSchema.safeParse(freeSRData)
const validFreeSRData = parsedFreeSRData.success ? parsedFreeSRData.data : null
);
const transI18n = useTranslations("DataPage");
const parsedFreeSRData = freeSrJsonSchema.safeParse(freeSRData);
const validFreeSRData = parsedFreeSRData.success ? parsedFreeSRData.data : null;
const availableCharacters = validFreeSRData
? Object.values(validFreeSRData.avatars).filter(it => mapAvatar?.[it.avatar_id])
: [];
const handleCharacterToggle = (character: CharacterInfoCardType) => {
if (selectedCharacters.some((selectedCharacter) => selectedCharacter.key === character.key)) {
setSelectedCharacters(selectedCharacters.filter((selectedCharacter) => selectedCharacter.key !== character.key));
if (selectedCharacters.some((selected) => selected.key === character.key)) {
setSelectedCharacters(selectedCharacters.filter((selected) => selected.key !== character.key));
return;
}
setSelectedCharacters([...selectedCharacters, character]);
@@ -82,93 +102,118 @@ export default function FreeSRImport() {
};
const selectAll = () => {
const parsed = freeSrJsonSchema.safeParse(freeSRData)
if (parsed.success) {
setSelectedCharacters(Object.values(parsed.data.avatars).filter(it => mapAvatar?.[it.avatar_id]).map((character) => toCharacterInfoCard(parsed.data, character)));
if (validFreeSRData) {
setSelectedCharacters(availableCharacters.map((char) => toCharacterInfoCard(validFreeSRData, char)));
}
};
const handlerReadFile = (event: React.ChangeEvent<HTMLInputElement>) => {
setIsLoading(true)
const file = event.target.files?.[0];
if (!file) {
setSelectedCharacters([])
setFreeSRData(null)
setError(transI18n("pleaseSelectAFile"))
setIsLoading(false)
return
const processFile = (file: File) => {
setIsLoading(true);
if (!file.name.endsWith(".json") && file.type !== "application/json") {
setSelectedCharacters([]);
setFreeSRData(null);
setFileName(null);
setErrorMsg(transI18n("fileMustBeAValidJsonFile"));
setIsLoading(false);
return;
}
if (!file.name.endsWith(".json") || file.type !== "application/json") {
setSelectedCharacters([])
setFreeSRData(null)
setError(transI18n("fileMustBeAValidJsonFile"))
setIsLoading(false)
return
}
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result as string);
const parsed = freeSrJsonSchema.safeParse(data)
const raw = JSON.parse(e.target?.result as string);
const parsed = freeSrJsonSchema.safeParse(raw);
if (!parsed.success) {
setSelectedCharacters([])
setFreeSRData(null)
setError(transI18n("fileMustBeAValidJsonFile"))
return
setSelectedCharacters([]);
setFreeSRData(null);
setFileName(null);
setErrorMsg(transI18n("fileMustBeAValidJsonFile"));
return;
}
setFreeSRData(parsed.data)
setError("")
setFreeSRData(parsed.data);
setFileName(file.name);
setErrorMsg("");
setSelectedCharacters(Object.values(parsed.data.avatars).filter(it => mapAvatar?.[it.avatar_id]).map((character) => toCharacterInfoCard(parsed.data, character)));
const validChars = Object.values(parsed.data.avatars)
.filter(it => mapAvatar?.[it.avatar_id])
.map((char) => toCharacterInfoCard(parsed.data, char));
setSelectedCharacters(validChars);
} catch {
setSelectedCharacters([])
setFreeSRData(null)
setError(transI18n("fileMustBeAValidJsonFile"))
setSelectedCharacters([]);
setFreeSRData(null);
setFileName(null);
setErrorMsg(transI18n("fileMustBeAValidJsonFile"));
} finally {
setIsLoading(false)
setIsLoading(false);
}
};
reader.onerror = () => {
setSelectedCharacters([])
setFreeSRData(null)
setError(transI18n("fileMustBeAValidJsonFile"))
setIsLoading(false)
}
setSelectedCharacters([]);
setFreeSRData(null);
setFileName(null);
setErrorMsg(transI18n("fileMustBeAValidJsonFile"));
setIsLoading(false);
};
reader.readAsText(file);
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
processFile(file);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files?.[0];
if (file) {
processFile(file);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const handleImport = () => {
if (selectedCharacters.length === 0) {
setError(transI18n("pleaseSelectAtLeastOneCharacter"));
setErrorMsg(transI18n("pleaseSelectAtLeastOneCharacter"));
return;
}
if (!freeSRData) {
setError(transI18n("noDataToImport"));
setErrorMsg(transI18n("noDataToImport"));
return;
}
setError("");
setErrorMsg("");
const parsed = freeSrJsonSchema.safeParse(freeSRData)
const parsed = freeSrJsonSchema.safeParse(freeSRData);
if (!parsed.success) {
setError(transI18n("fileMustBeAValidJsonFile"));
setErrorMsg(transI18n("fileMustBeAValidJsonFile"));
return;
}
const listAvatars = { ...avatars }
const filterData = Object.values(parsed.data.avatars).filter((character) => selectedCharacters.some((selectedCharacter) => selectedCharacter.avatar_id === character.avatar_id))
const listAvatars = { ...avatars };
const filterData = Object.values(parsed.data.avatars).filter((character) =>
selectedCharacters.some((selected) => selected.avatar_id === character.avatar_id)
);
filterData.forEach((character) => {
const currentAvatar = listAvatars[character.avatar_id]
const currentAvatar = listAvatars[character.avatar_id];
if (currentAvatar && Object.keys(currentAvatar).length !== 0) {
const newProfileList = [...(currentAvatar.profileList || [])]
const newProfile = converterOneFreeSRDataToAvatarStore(parsed.data, newProfileList.length, character.avatar_id)
const newProfileList = [...(currentAvatar.profileList || [])];
const newProfile = converterOneFreeSRDataToAvatarStore(parsed.data, newProfileList.length, character.avatar_id);
if (newProfile) {
newProfileList.push(newProfile)
newProfileList.push(newProfile);
}
const newAvatar = {
...currentAvatar,
@@ -180,81 +225,218 @@ export default function FreeSRImport() {
},
profileList: newProfileList,
profileSelect: newProfile ? newProfileList.length - 1 : currentAvatar.profileSelect,
};
setAvatar(newAvatar);
}
setAvatar(newAvatar)
}
})
setIsOpenImport(false)
toast.success(transI18n("importFreeSRDataSuccess"))
}
});
setIsOpenImport(false);
toast.success(transI18n("importFreeSRDataSuccess"));
};
return (
<div className="w-full space-y-6">
{/* Header */}
<div className="space-y-4">
<h1 className="text-base-content text-xl sm:text-2xl font-bold">{transI18n("freeSRImport")}</h1>
{/* Top Bar: Title & Upload Trigger */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 sm:gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="px-2 py-0.5 text-[10px] font-bold uppercase tracking-widest rounded-md bg-primary/15 text-primary border border-primary/25">
FreeSR Protocol
</span>
<span className="text-xs text-base-content/50 font-medium">JSON Importer</span>
</div>
<h2 className="text-xl sm:text-2xl font-black tracking-tight text-base-content flex items-center gap-2">
{transI18n("freeSRImport")}
</h2>
</div>
<div className="flex flex-wrap gap-4 items-center">
<fieldset className="fieldset">
<legend className="fieldset-legend text-xs font-semibold">{transI18n("pickAFile")}</legend>
<input type="file" accept=".json" className="file-input file-input-sm file-input-bordered rounded-xl" onChange={handlerReadFile} />
<label className="label text-[11px] text-base-content/60">{transI18n("onlySupportFreeSRJsonFile")}</label>
</fieldset>
{/* File Dropzone / Active File Badge */}
<input
type="file"
ref={fileInputRef}
accept=".json,application/json"
className="hidden"
onChange={handleFileChange}
/>
{selectedCharacters.length > 0 && (
<button onClick={handleImport} className="btn btn-sm btn-primary rounded-xl font-bold shadow-xs">
{transI18n("import")}
{!validFreeSRData ? (
<div
onClick={() => fileInputRef.current?.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl border border-dashed cursor-pointer transition-all duration-200 select-none w-full sm:w-auto ${
isDragging
? 'bg-primary/15 border-primary shadow-[0_0_20px_rgba(var(--color-primary-rgb,6,182,212),0.3)] scale-[1.02]'
: 'bg-base-200/50 hover:bg-base-200/80 border-white/20 hover:border-primary/50'
}`}
>
<div className="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">
<UploadCloud className="w-4 h-4" />
</div>
<div className="text-left">
<p className="text-xs font-bold text-base-content flex items-center gap-1.5">
{transI18n("pickAFile")}
<span className="text-[10px] font-normal text-base-content/60">(.json)</span>
</p>
<p className="text-[11px] text-base-content/50 truncate max-w-[200px]">
{transI18n("onlySupportFreeSRJsonFile")}
</p>
</div>
</div>
) : (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2.5 w-full sm:w-auto">
<div className="flex items-center justify-between sm:justify-start gap-2 h-10 px-3.5 rounded-xl bg-emerald-500/10 border border-emerald-500/25 text-emerald-400 text-xs font-semibold">
<div className="flex items-center gap-2 min-w-0">
<FileCheck className="w-4 h-4 text-emerald-400 shrink-0" />
<span className="truncate max-w-[180px] font-mono text-xs">{fileName || "data.json"}</span>
</div>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 font-bold shrink-0">
{availableCharacters.length} {transI18n("avatars")}
</span>
</div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="h-10 px-3.5 rounded-xl font-semibold text-xs bg-base-200 hover:bg-base-300 text-base-content border border-base-content/15 transition-all flex items-center justify-center cursor-pointer"
>
{transI18n("changeFile")}
</button>
</div>
)}
</div>
{Error && (
<div className="text-error text-sm font-semibold mt-2">😭 {Error}</div>
{/* Error Message */}
{errorMsg && (
<div className="flex items-center gap-2 px-4 py-3 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-400 text-xs font-semibold animate-in fade-in duration-200">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{errorMsg}</span>
</div>
)}
{/* Selection Info */}
<div className="flex flex-wrap items-center justify-between gap-3 bg-base-200 border border-base-300 p-3.5 rounded-2xl shadow-xs">
{/* Floating Selection & Actions Bar */}
{validFreeSRData && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 py-2.5 px-4 rounded-xl bg-base-200/60 backdrop-blur-md border border-white/10 shadow-xs">
{/* Left: Counter and Quick Select */}
<div className="flex items-center justify-between sm:justify-start gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-cyan-500" />
<span className="text-xs sm:text-sm font-bold text-base-content">
{transI18n("selectedCharacters")}:
</span>
<span className="badge badge-primary font-mono font-bold text-xs">
{selectedCharacters.length}
<span className="px-2 py-0.5 rounded-full bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 font-mono font-bold text-xs">
{selectedCharacters.length} / {availableCharacters.length}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={clearSelection}
className="btn btn-xs sm:btn-sm btn-ghost text-error hover:bg-error/10 border border-error/30 rounded-xl"
>
{transI18n("clearAll")}
</button>
<div className="h-4 w-px bg-white/10 hidden sm:block" />
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={selectAll}
className="btn btn-xs sm:btn-sm btn-ghost text-primary hover:bg-primary/10 border border-primary/30 rounded-xl"
className="h-8 px-2.5 rounded-lg text-xs font-semibold text-cyan-400 hover:text-cyan-300 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/25 transition-all flex items-center gap-1 cursor-pointer"
>
<CheckCheck className="w-3.5 h-3.5" />
{transI18n("selectAll")}
</button>
<button
type="button"
onClick={clearSelection}
className="h-8 px-2.5 rounded-lg text-xs font-semibold text-rose-400 hover:text-rose-300 bg-rose-500/10 hover:bg-rose-500/20 border border-rose-500/25 transition-all flex items-center gap-1 cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5" />
{transI18n("clearAll")}
</button>
</div>
</div>
</div>
{isLoading && (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
{/* Right: Import CTA */}
<button
type="button"
onClick={handleImport}
disabled={selectedCharacters.length === 0}
className="h-9 px-5 rounded-xl font-bold text-xs sm:text-sm bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white shadow-md shadow-cyan-500/25 hover:shadow-cyan-500/40 disabled:opacity-40 disabled:pointer-events-none flex items-center justify-center gap-1.5 transition-all w-full sm:w-auto cursor-pointer"
>
<span className="text-white">{transI18n("import")}</span>
<span className="font-mono text-xs text-cyan-100">({selectedCharacters.length})</span>
<ArrowRight className="w-3.5 h-3.5 text-white" />
</button>
</div>
)}
{/* Loading Shimmer State */}
{isLoading && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 sm:gap-4 py-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="rounded-2xl bg-base-200/30 border border-white/5 animate-pulse p-3.5 space-y-3">
<div className="w-full aspect-4/5 rounded-xl bg-base-300/40" />
<div className="h-4 bg-base-300/60 rounded w-2/3 mx-auto" />
<div className="h-3 bg-base-300/40 rounded w-1/2 mx-auto" />
</div>
))}
</div>
)}
{/* Empty State (when no valid data is loaded) */}
{!isLoading && !validFreeSRData && (
<div
onClick={() => fileInputRef.current?.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className="relative overflow-hidden py-12 sm:py-16 px-4 sm:px-6 rounded-2xl border border-dashed border-white/15 bg-base-200/20 hover:bg-base-200/40 transition-all duration-300 text-center cursor-pointer group select-none"
>
{/* Ambient Glow */}
<div className="pointer-events-none absolute -top-12 left-1/2 -translate-x-1/2 h-40 w-40 rounded-full bg-primary/10 blur-3xl group-hover:scale-150 transition-transform duration-700" />
<div className="relative z-10 max-w-md mx-auto space-y-4">
<div className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl bg-base-300/60 border border-white/10 text-primary mx-auto flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:border-primary/40 transition-all duration-300">
<FileJson className="w-7 h-7 sm:w-8 sm:h-8 stroke-[1.5]" />
</div>
<div className="space-y-1.5">
<h3 className="text-base sm:text-lg font-bold text-base-content">
{transI18n("noFreeSRDataLoaded")}
</h3>
<p className="text-xs text-base-content/60 leading-relaxed">
{transI18n("dropFreeSRJsonHint")}
</p>
</div>
{/* Step hints */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5 sm:gap-3 pt-3 text-left border-t border-white/5">
<div className="p-3 rounded-xl bg-base-300/30">
<span className="text-[10px] font-bold text-primary block mb-0.5">{transI18n("step1")}</span>
<p className="text-[11px] text-base-content/70 leading-tight">{transI18n("exportJsonFromFreeSR")}</p>
</div>
<div className="p-3 rounded-xl bg-base-300/30">
<span className="text-[10px] font-bold text-primary block mb-0.5">{transI18n("step2")}</span>
<p className="text-[11px] text-base-content/70 leading-tight">{transI18n("dropOrSelectFile")}</p>
</div>
<div className="p-3 rounded-xl bg-base-300/30">
<span className="text-[10px] font-bold text-primary block mb-0.5">{transI18n("step3")}</span>
<p className="text-[11px] text-base-content/70 leading-tight">{transI18n("syncCharacterBuilds")}</p>
</div>
</div>
</div>
</div>
)}
{/* Character Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4 sm:gap-6">
{validFreeSRData && Object.values(validFreeSRData.avatars).filter(it => mapAvatar?.[it.avatar_id]).map((character) => {
return (
{!isLoading && validFreeSRData && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 sm:gap-4">
{availableCharacters.map((character) => (
<CharacterInfoCard
key={character.avatar_id}
character={toCharacterInfoCard(validFreeSRData, character)}
selectedCharacters={selectedCharacters}
onCharacterToggle={handleCharacterToggle}
/>
)
})}
))}
</div>
)}
</div>
)
);
}
+2 -1
View File
@@ -401,7 +401,8 @@ export default function QuickView() {
subAffix,
relicEffects,
mapRelicSet,
avatarSkillTree
avatarSkillTree,
transI18n
])
return (
+117 -21
View File
@@ -1,6 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */
"use client";
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import Image from "next/image";
import RelicMaker from "../relicBar";
import useUserDataStore from "@/stores/userDataStore";
import { useTranslations } from "next-intl";
@@ -15,6 +16,8 @@ import useDetailDataStore from "@/stores/detailDataStore";
import { getLocaleName } from '@/helper';
import useLocaleStore from "@/stores/localeStore";
import { useShallow } from 'zustand/react/shallow';
import { Trash2 } from "lucide-react";
import { toast } from "react-toastify";
export default function RelicsInfo() {
const { avatars, setAvatar } = useUserDataStore(
@@ -78,6 +81,8 @@ export default function RelicsInfo() {
}))
)
const [relicSlotToDelete, setRelicSlotToDelete] = useState<string | null>(null);
const handleShow = (modalId: string) => {
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
if (modal) {
@@ -125,6 +130,25 @@ export default function RelicsInfo() {
}
};
const requestDeleteRelic = (slot: string) => {
setRelicSlotToDelete(slot);
handleShow("delete_relic_confirm_modal");
};
const handleConfirmDelete = () => {
if (relicSlotToDelete) {
handlerDeleteRelic(relicSlotToDelete);
toast.success(transI18n("relicDeletedSuccessfully"));
handleCloseModal("delete_relic_confirm_modal");
setRelicSlotToDelete(null);
}
};
const handleCancelDelete = () => {
handleCloseModal("delete_relic_confirm_modal");
setRelicSlotToDelete(null);
};
const handlerChangeRelic = (slot: string) => {
const relic = getRelic(slot)
setSelectedRelicSlot(slot)
@@ -217,6 +241,7 @@ export default function RelicsInfo() {
for (const item of modalConfigs) {
handleCloseModal(item?.id || "")
}
handleCloseModal("delete_relic_confirm_modal")
}
};
@@ -250,22 +275,14 @@ export default function RelicsInfo() {
</div>
{/* 6-Slot Grid completely free of box-in-box */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-6 py-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 sm:gap-4">
{["1", "2", "3", "4", "5", "6"].map((item, index) => (
<div
key={index}
onClick={() => {
if (item === selectedRelicSlot) {
setSelectedRelicSlot("")
} else {
setSelectedRelicSlot(item)
}
handlerChangeRelic(item)
}}
className={`relative group flex flex-col items-center justify-center p-2 rounded-2xl transition-all duration-200 cursor-pointer ${
item === selectedRelicSlot
? "bg-primary/5 rounded-2xl ring-1.5 ring-primary/40"
: "hover:bg-base-content/5"
onClick={() => setSelectedRelicSlot(item)}
className={`group relative rounded-xl transition-all duration-200 cursor-pointer ${selectedRelicSlot === item
? "ring-2 ring-primary ring-offset-2 ring-offset-base-100 shadow-md"
: "hover:ring-1 hover:ring-base-content/20"
}`}
>
<RelicCard
@@ -276,6 +293,7 @@ export default function RelicsInfo() {
{/* Micro Actions */}
<div className="absolute top-0 right-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex gap-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handlerChangeRelic(item)
@@ -290,11 +308,10 @@ export default function RelicsInfo() {
{getRelic(item) && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
if (window.confirm(`${transI18n("deleteRelicConfirm")} ${item}?`)) {
handlerDeleteRelic(item)
}
requestDeleteRelic(item)
}}
className="btn btn-xs btn-circle btn-error shadow-xs"
title={transI18n("deleteRelic")}
@@ -312,6 +329,7 @@ export default function RelicsInfo() {
{/* Bottom Actions Bar */}
<div className="grid grid-cols-2 gap-3 pt-2">
<button
type="button"
disabled={!selectedRelicSlot}
onClick={() => handlerChangeRelic(selectedRelicSlot)}
className="btn btn-primary btn-sm rounded-xl font-bold shadow-xs"
@@ -319,14 +337,15 @@ export default function RelicsInfo() {
{transI18n("changeRelic")}
</button>
<button
disabled={!selectedRelicSlot}
type="button"
disabled={!selectedRelicSlot || !getRelic(selectedRelicSlot)}
onClick={(e) => {
e.stopPropagation()
if (window.confirm(`${transI18n("deleteRelicConfirm")} ${selectedRelicSlot}?`)) {
handlerDeleteRelic(selectedRelicSlot)
if (selectedRelicSlot) {
requestDeleteRelic(selectedRelicSlot)
}
}}
className="btn btn-ghost btn-sm text-error hover:bg-error/20 rounded-xl"
className="btn btn-ghost btn-sm text-error hover:bg-error/20 rounded-xl disabled:opacity-40 disabled:pointer-events-none"
>
{transI18n("deleteRelic")}
</button>
@@ -413,6 +432,7 @@ export default function RelicsInfo() {
{title}
</h3>
<button
type="button"
className="btn btn-sm btn-circle btn-ghost text-base-content/60 hover:text-base-content hover:bg-base-200 transition-colors"
onClick={onClose}
>
@@ -429,6 +449,82 @@ export default function RelicsInfo() {
</form>
</dialog>
))}
{/* Delete Relic Confirmation Modal */}
<dialog id="delete_relic_confirm_modal" className="modal modal-bottom sm:modal-middle">
<div className="modal-box max-w-md bg-base-100/95 backdrop-blur-xl border border-white/10 shadow-2xl rounded-2xl p-6 overflow-hidden relative">
{/* Ambient Glow */}
<div className="pointer-events-none absolute -top-12 left-1/2 -translate-x-1/2 w-32 h-32 bg-rose-500/15 rounded-full blur-2xl" />
<div className="relative z-10 text-center space-y-4">
{/* Rose Icon Badge */}
<div className="w-12 h-12 rounded-2xl bg-rose-500/15 text-rose-400 border border-rose-500/30 flex items-center justify-center mx-auto shadow-[0_0_20px_rgba(244,63,94,0.2)]">
<Trash2 className="w-6 h-6 stroke-[1.75]" />
</div>
{/* Title & Description */}
<div className="space-y-1.5">
<h3 className="font-black text-lg text-base-content tracking-tight">
{transI18n("deleteRelic")}
</h3>
<p className="text-xs sm:text-sm text-base-content/70 leading-relaxed">
{transI18n("deleteRelicConfirm")}{" "}
<span className="font-bold text-amber-400 capitalize font-mono">
{relicSlotToDelete ? (transI18n({ "1": "HEAD", "2": "HAND", "3": "BODY", "4": "FOOT", "5": "NECK", "6": "OBJECT" }[relicSlotToDelete] || relicSlotToDelete) || relicSlotToDelete) : ""}
</span>
?
</p>
</div>
{/* Relic Preview Card (if relic exists) */}
{relicSlotToDelete && getRelic(relicSlotToDelete) && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-base-200/70 border border-white/10 text-left">
<div className="w-10 h-10 rounded-lg flex items-center justify-center bg-base-300/60 border border-amber-500/30 shrink-0">
<Image
src={`${process.env.CDN_URL}/spriteoutput/relicfigures/IconRelic_${getRelic(relicSlotToDelete)?.relic_set_id}_${relicSlotToDelete}.png`}
alt="Relic"
unoptimized
crossOrigin="anonymous"
width={36}
height={36}
className="w-7 h-7 object-contain"
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold text-base-content truncate">
{getLocaleName(locale, mapRelicSet[getRelic(relicSlotToDelete)?.relic_set_id || ""]?.Name)}
</p>
<p className="text-[11px] text-base-content/60 font-mono">
+{getRelic(relicSlotToDelete)?.level} &bull; <span>{transI18n({ "1": "HEAD", "2": "HAND", "3": "BODY", "4": "FOOT", "5": "NECK", "6": "OBJECT" }[relicSlotToDelete] || relicSlotToDelete)}</span>
</p>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleCancelDelete}
className="flex-1 h-10 px-4 rounded-xl font-bold text-xs sm:text-sm bg-base-200 hover:bg-base-300 text-base-content/80 hover:text-base-content border border-white/10 transition-all cursor-pointer"
>
{transI18n("cancel")}
</button>
<button
type="button"
onClick={handleConfirmDelete}
className="flex-1 h-10 px-4 rounded-xl font-bold text-xs sm:text-sm bg-gradient-to-r from-rose-500 to-red-600 hover:from-rose-400 hover:to-red-500 text-white shadow-md shadow-rose-500/25 hover:shadow-rose-500/40 transition-all flex items-center justify-center gap-1.5 cursor-pointer"
>
<Trash2 className="w-4 h-4" />
<span>{transI18n("confirmDelete")}</span>
</button>
</div>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={handleCancelDelete}>close</button>
</form>
</dialog>
</div>
);
}
+17 -17
View File
@@ -48,7 +48,7 @@ export default function ShowCaseInfo() {
const handleSaveImage = useCallback(async () => {
if (cardRef.current === null || !avatarSelected) {
toast.error("Avatar showcase not found!");
toast.error(transI18n("noAvatarSelected") || "Avatar showcase not found!");
return;
}
if (isSaving) return;
@@ -78,7 +78,7 @@ export default function ShowCaseInfo() {
canvas.toBlob((blob) => {
if (!blob) {
toast.error("Failed to generate image!");
toast.error(transI18n("failedToGenerateImage"));
setIsSaving(false);
return;
}
@@ -89,10 +89,10 @@ export default function ShowCaseInfo() {
link.click();
URL.revokeObjectURL(url);
setIsSaving(false);
toast.success(locale === "vi" ? "Đã lưu ảnh thành công!" : "Image saved successfully!");
toast.success(transI18n("imageSavedSuccessfully"));
}, "image/png");
} catch {
toast.error("Error generating showcase card!");
toast.error(transI18n("errorGeneratingShowcaseCard"));
setIsSaving(false);
}
}, [avatarSelected, locale, transI18n, isSaving]);
@@ -638,16 +638,16 @@ export default function ShowCaseInfo() {
const textToCopy = lines.join("\n");
navigator.clipboard.writeText(textToCopy)
.then(() => {
toast.success(locale === "vi" ? "Đã sao chép thông tin build!" : "Build info copied to clipboard!");
toast.success(transI18n("buildInfoCopied"));
})
.catch(() => {
toast.error(locale === "vi" ? "Lỗi khi sao chép!" : "Failed to copy build info!");
toast.error(transI18n("copyFailed"));
});
}, [avatarSelected, avatarData, avatarProfile, mapLightCone, locale, transI18n, characterStats, relicEffects, mapRelicSet, relicStats]);
const handleCopyImage = useCallback(async () => {
if (cardRef.current === null || !avatarSelected) {
toast.error("Avatar showcase not found!");
toast.error(transI18n("noAvatarSelected") || "Avatar showcase not found!");
return;
}
if (isCopyingImage) return;
@@ -677,25 +677,25 @@ export default function ShowCaseInfo() {
canvas.toBlob((blob) => {
if (!blob) {
toast.error("Failed to create image blob");
toast.error(transI18n("failedToGenerateImage"));
setIsCopyingImage(false);
return;
}
navigator.clipboard.write([
new ClipboardItem({ "image/png": blob })
]).then(() => {
toast.success(locale === "vi" ? "Đã sao chép ảnh vào Clipboard!" : "Image copied to clipboard!");
toast.success(transI18n("imageCopiedToClipboard"));
}).catch(() => {
toast.error(locale === "vi" ? "Trình duyệt không cho phép sao chép ảnh trực tiếp!" : "Clipboard write failed!");
toast.error(transI18n("clipboardWriteFailed"));
}).finally(() => {
setIsCopyingImage(false);
});
}, "image/png");
} catch {
toast.error("Error generating showcase image!");
toast.error(transI18n("errorGeneratingShowcaseCard"));
setIsCopyingImage(false);
}
}, [avatarSelected, locale, isCopyingImage]);
}, [avatarSelected, isCopyingImage, transI18n]);
return (
<div className="w-full space-y-4">
@@ -712,23 +712,23 @@ export default function ShowCaseInfo() {
<button
className="btn btn-sm btn-ghost border border-base-content/20 rounded-xl font-bold shadow-xs active-press gap-1.5 hover:bg-base-200"
onClick={handleCopyBuildText}
title="Copy build summary as text"
title={transI18n("copyText")}
>
<Copy className="w-4 h-4" />
<span>{locale === "vi" ? "Sao chép Text" : "Copy Text"}</span>
<span>{transI18n("copyText")}</span>
</button>
<button
disabled={isCopyingImage}
className="btn btn-sm btn-ghost border border-base-content/20 rounded-xl font-bold shadow-xs active-press gap-1.5 hover:bg-base-200"
onClick={handleCopyImage}
title="Copy card image to clipboard"
title={transI18n("copyImage")}
>
{isCopyingImage ? (
<span className="loading loading-spinner loading-xs"></span>
) : (
<ImageIcon className="w-4 h-4" />
)}
<span>{isCopyingImage ? (locale === "vi" ? "Đang xử lý..." : "Processing...") : (locale === "vi" ? "Sao chép Ảnh" : "Copy Image")}</span>
<span>{isCopyingImage ? transI18n("processing") : transI18n("copyImage")}</span>
</button>
<button
disabled={isSaving}
@@ -740,7 +740,7 @@ export default function ShowCaseInfo() {
) : (
<Download className="w-4 h-4" />
)}
<span>{isSaving ? (locale === "vi" ? "Đang lưu..." : "Saving...") : transI18n("save")}</span>
<span>{isSaving ? transI18n("saving") : transI18n("save")}</span>
</button>
</div>
</div>