UPDATE: Add muti language

This commit is contained in:
2026-04-03 18:37:01 +07:00
parent 2b0c82bd7e
commit 45d345d7cb
24 changed files with 2451 additions and 430 deletions

View File

@@ -4,7 +4,9 @@ import { useState, useRef } from 'react'
import { X, Image as ImageIcon, Plus, Upload, Check } from 'lucide-react'
import useSettingStore from '@/stores/settingStore'
import Cropper from 'react-easy-crop'
import getCroppedImg from '@/utils/cropImage'
import getCroppedImg from '@/utils/cropImage'
import { useTranslation } from 'react-i18next'
import { toast } from "react-toastify"
const initialImages = {
"bg-1": "bg-1.jpeg",
@@ -30,14 +32,26 @@ export const BackgroundSelector = () => {
const [croppedAreaPixels, setCroppedAreaPixels] = useState<any>(null)
const { background, setBackground, extraBackgrounds, setExtraBackgrounds } = useSettingStore()
const fileInputRef = useRef<HTMLInputElement>(null)
const { t } = useTranslation()
const handleSelect = (img: string) => {
setIsOpen(false)
setBackground(img)
}
const isImageUrl = (url: string) => {
return /^https?:\/\/.+\.(jpg|jpeg|png|webp|gif)(\?.*)?$/i.test(url)
}
const handleAddUrl = () => {
if (!newUrl.trim()) return setCroppingImage(newUrl)
const url = newUrl.trim()
if (!url) {
return
}
if (!isImageUrl(url)) {
toast.error(t("background.invalid_url"))
return
}
setCroppingImage(url)
setNewUrl('')
}
@@ -61,8 +75,8 @@ export const BackgroundSelector = () => {
const allBackgrounds = [...extraBackgrounds, ...Object.values(initialImages)]
return (
<div className="flex flex-col items-center justify-center gap-4">
<div className="tooltip tooltip-right" data-tip="Select Background">
<div className="flex flex-col items-center justify-center gap-4 w-full">
<div className="tooltip tooltip-right" data-tip={t("background.select_bg")}>
<button
className="group btn btn-primary btn-circle flex items-center justify-center shadow-md transition-all duration-300 hover:scale-110 hover:shadow-lg hover:bg-primary/80"
onClick={() => setIsOpen(true)}
@@ -78,19 +92,19 @@ export const BackgroundSelector = () => {
<X size={20} />
</button>
<h2 className="text-lg font-semibold mb-4">Choose Background</h2>
<h2 className="text-lg font-semibold mb-4">{t("background.choose_bg")}</h2>
{/* Add via URL */}
<div className="flex gap-2 mb-4">
<input
type="text"
placeholder="Paste image URL (https://...)"
placeholder={t("background.paste_url")}
className="input input-bordered w-full text-info"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
/>
<button className="btn btn-success flex items-center gap-1" onClick={handleAddUrl}>
<Plus size={16} /> Add
<Plus size={16} /> {t("background.add")}
</button>
</div>
@@ -100,7 +114,7 @@ export const BackgroundSelector = () => {
className="btn btn-warning flex items-center gap-1"
onClick={() => fileInputRef.current?.click()}
>
<Upload size={16} /> Upload from computer
<Upload size={16} /> {t("background.upload_from_computer")}
</button>
<input
type="file"
@@ -116,9 +130,9 @@ export const BackgroundSelector = () => {
</div>
{/* Crop Modal */}
{croppingImage && (
{(croppingImage != null && croppingImage != "") && (
<div className="fixed inset-0 z-60 flex flex-col items-center justify-center bg-black/70 p-4">
<div className="relative w-full max-w-3xl h-[400px] bg-gray-800 rounded-lg">
<div className="relative w-full max-w-5xl h-150 bg-gray-800 rounded-lg">
<Cropper
image={croppingImage}
crop={crop}
@@ -132,7 +146,7 @@ export const BackgroundSelector = () => {
className="absolute bottom-4 left-1/2 -translate-x-1/2 btn btn-success"
onClick={handleCropComplete}
>
<Check size={20} /> Done
<Check size={20} /> {t("background.done")}
</button>
<button
className="absolute top-2 right-2 btn btn-ghost btn-circle"
@@ -150,9 +164,8 @@ export const BackgroundSelector = () => {
return (
<div
key={i}
className={`relative rounded-lg overflow-hidden cursor-pointer border-2 transition-all duration-200 ${
value === background ? 'border-blue-500' : 'border-transparent hover:border-gray-500'
}`}
className={`relative rounded-lg overflow-hidden cursor-pointer border-2 transition-all duration-200 ${value === background ? 'border-blue-500' : 'border-transparent hover:border-gray-500'
}`}
onClick={() => handleSelect(value)}
>
<img src={value} alt={`bg-${i}`} loading="lazy" className="w-full h-28 object-cover" />

View File

@@ -2,6 +2,7 @@ import { motion } from "motion/react"
import { AppService } from "@bindings/firefly-launcher/internal/app-service"
import { toast } from "react-toastify"
import useSettingStore from "@/stores/settingStore"
import { useTranslation } from "react-i18next"
export default function CloseModal({
isOpen,
@@ -12,72 +13,73 @@ export default function CloseModal({
}) {
if (!isOpen) return null
const { closingOption, setClosingOption } = useSettingStore()
const { t } = useTranslation()
return (
<div className="fixed inset-0 z-50 h-full flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="relative w-[90%] max-w-2xl bg-base-100 text-base-content rounded-xl border border-purple-500/50 shadow-lg shadow-purple-500/20">
<div className="border-b border-purple-500/30 px-6 py-4 mb-4 flex justify-between items-center">
<h3 className="font-bold text-xl text-transparent bg-clip-text bg-gradient-to-r from-pink-400 to-cyan-600">
Confirm Action
</h3>
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
transition={{ duration: 0.2 }}
className="btn btn-circle btn-md btn-error absolute right-3 top-3"
onClick={onClose}
>
</motion.button>
</div>
<div className="px-6 pt-2 pb-6">
<p className="mb-4 text-lg">
Do you want to minimize the application to the system tray or close the application?
</p>
<div className="flex items-center mb-4">
<input
id="dontAskAgain"
type="checkbox"
className="checkbox checkbox-sm mr-2"
checked={!closingOption.isAsk}
onChange={(e) => setClosingOption({ isMinimize: closingOption.isMinimize, isAsk: !e.target.checked })}
/>
<label htmlFor="dontAskAgain" className="text-sm font-semibold text-accent">
Do not ask me again
</label>
<div className="relative w-[90%] max-w-2xl bg-base-100 text-base-content rounded-xl border border-purple-500/50 shadow-lg shadow-purple-500/20">
<div className="border-b border-purple-500/30 px-6 py-4 mb-4 flex justify-between items-center">
<h3 className="font-bold text-xl text-transparent bg-clip-text bg-linear-to-r from-pink-400 to-cyan-600">
{t("close.title")}
</h3>
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
transition={{ duration: 0.2 }}
className="btn btn-circle btn-md btn-error absolute right-3 top-3"
onClick={onClose}
>
</motion.button>
</div>
<div className="grid grid-cols-2 justify-end gap-3">
<button
className="btn btn-warning"
onClick={async () => {
onClose()
const [success, message] = await AppService.HideApp()
if (!success) toast.error(message)
if (!closingOption.isAsk) {
setClosingOption({ isMinimize: true, isAsk: false })
}
}}
>
Minimize
</button>
<button
className="btn btn-error btn-outline"
onClick={async () => {
onClose()
const [success, message] = await AppService.CloseApp()
if (!success) toast.error(message)
if (!closingOption.isAsk) {
setClosingOption({ isMinimize: false, isAsk: false })
}
}}
>
Close
</button>
<div className="px-6 pt-2 pb-6">
<p className="mb-4 text-lg">
{t("close.description")}
</p>
<div className="flex items-center mb-4">
<input
id="dontAskAgain"
type="checkbox"
className="checkbox checkbox-sm mr-2"
checked={!closingOption.isAsk}
onChange={(e) => setClosingOption({ isMinimize: closingOption.isMinimize, isAsk: !e.target.checked })}
/>
<label htmlFor="dontAskAgain" className="text-sm font-semibold text-accent">
{t("close.dont_ask")}
</label>
</div>
<div className="grid grid-cols-2 justify-end gap-3">
<button
className="btn btn-warning"
onClick={async () => {
onClose()
const [success, message] = await AppService.HideApp()
if (!success) toast.error(message)
if (!closingOption.isAsk) {
setClosingOption({ isMinimize: true, isAsk: false })
}
}}
>
{t("close.minimize")}
</button>
<button
className="btn btn-error btn-outline"
onClick={async () => {
onClose()
const [success, message] = await AppService.CloseApp()
if (!success) toast.error(message)
if (!closingOption.isAsk) {
setClosingOption({ isMinimize: false, isAsk: false })
}
}}
>
{t("close.close")}
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -2,26 +2,42 @@ import { Link } from "@tanstack/react-router";
import useModalStore from "@/stores/modalStore";
import { Blend, BookOpen, Diff, Home, Info, Languages, Minus, Puzzle, Settings, TrendingUpDown, Wrench, X } from "lucide-react";
import { AppService } from "@bindings/firefly-launcher/internal/app-service";
import LanguageSwitcher from "../languageSwitcher";
import { useTranslation } from "react-i18next";
import useSettingStore from "@/stores/settingStore";
export default function Header() {
const { setIsOpenSettingModal } = useModalStore()
const { setIsOpenSettingModal, setIsOpenCloseModal } = useModalStore()
const { closingOption } = useSettingStore()
const { t } = useTranslation()
const controlButtons = [
{
icon: <Settings className="w-5 h-5" />,
action: () => setIsOpenSettingModal(true),
tip: "Settings",
tip: t("header.settings"),
},
{
icon: <Minus className="w-5 h-5" />,
action: () => AppService.MinimizeApp(),
tip: "Minimize",
tip: t("header.minimize"),
},
{
icon: <X className="w-5 h-5" />,
action: () => AppService.CloseApp(),
tip: "Close",
action: () => {
if (closingOption.isAsk) {
setIsOpenCloseModal(true)
return
}
if (closingOption.isMinimize) {
AppService.HideApp()
return
}
AppService.CloseApp()
},
tip: t("header.close"),
},
]
return (
@@ -42,26 +58,26 @@ export default function Header() {
tabIndex={0}
className="menu menu-sm dropdown-content bg-black/50 backdrop-blur-md rounded-box z-1 mt-3 w-52 p-2 shadow"
>
<li><Link to="/">Home</Link></li>
<li><Link to="/">{t("header.home")}</Link></li>
<li>
<a>Tools</a>
<a>{t("header.tools")}</a>
<ul className="p-2">
<li><Link to="/language">Language</Link></li>
<li><Link to="/diff">Diff</Link></li>
<li><Link to="/language">{t("header.language")}</Link></li>
<li><Link to="/diff">{t("header.client_update")}</Link></li>
</ul>
</li>
<li>
<a>Plugins</a>
<a>{t("header.plugins")}</a>
<ul className="p-2">
<li><Link to="/analysis">Analysis (Veritas)</Link></li>
<li><Link to="/srtools">SrTools</Link></li>
<li><Link to="/analysis">{t("header.analysis")}</Link></li>
<li><Link to="/srtools">{t("header.firefly_tools")}</Link></li>
</ul>
</li>
<li><Link to="/howto">How to?</Link></li>
<li><Link to="/about">About</Link></li>
<li><Link to="/howto">{t("header.how_to")}</Link></li>
<li><Link to="/about">{t("header.about")}</Link></li>
</ul>
</div>
@@ -71,11 +87,11 @@ export default function Header() {
<div className="flex flex-col justify-center items-start">
<h1 className="text-xl font-bold">
<span className="text-emerald-500">Firefly </span>
<span className="bg-clip-text text-transparent bg-gradient-to-r from-emerald-400 via-orange-500 to-red-500">
<span className="bg-clip-text text-transparent bg-linear-to-r from-emerald-400 via-orange-500 to-red-500">
Launcher
</span>
</h1>
<p className="text-white text-sm">By Firefly Shelter</p>
<p className="text-white text-sm">{t("header.by")}</p>
</div>
</div>
</Link>
@@ -89,24 +105,24 @@ export default function Header() {
<ul className="menu menu-horizontal px-1 gap-4 text-white">
<li>
<Link to="/" className="flex items-center gap-2 hover:text-cyan-300">
<Home size={18} /> Home
<Home size={18} /> {t("header.home")}
</Link>
</li>
<li>
<details>
<summary className="flex items-center gap-2 cursor-pointer hover:text-cyan-300">
<Wrench size={18} /> Tools
<summary className="flex items-center gap-2 cursor-pointer hover:text-cyan-300 w-full">
<Wrench size={18} /> {t("header.tools")}
</summary>
<ul className="p-2 bg-black/75 text-white rounded-lg">
<ul className="p-2 bg-black/75 text-white rounded-lg min-w-40 whitespace-nowrap">
<li>
<Link to="/language" className="flex items-center gap-2 hover:text-cyan-300">
<Languages size={18} /> Language
<Languages size={18} /> {t("header.language")}
</Link>
</li>
<li>
<Link to="/diff" className="flex items-center gap-2 hover:text-cyan-300">
<Diff size={18} /> Client update
<Diff size={18} /> {t("header.client_update")}
</Link>
</li>
</ul>
@@ -115,18 +131,18 @@ export default function Header() {
<li>
<details>
<summary className="flex items-center gap-2 cursor-pointer hover:text-cyan-300">
<Puzzle size={18} /> Plugins
<summary className="flex items-center gap-2 cursor-pointer hover:text-cyan-300 w-full">
<Puzzle size={18} /> {t("header.plugins")}
</summary>
<ul className="p-2 bg-black/75 text-white rounded-lg">
<ul className="p-2 bg-black/75 text-white rounded-lg min-w-40 whitespace-nowrap">
<li>
<Link to="/analysis" className="flex items-center gap-2 hover:text-cyan-300">
<TrendingUpDown size={18} /> Analysis (Veritas)
<TrendingUpDown size={18} /> {t("header.analysis")}
</Link>
</li>
<li>
<Link to="/srtools" className="flex items-center gap-2 hover:text-cyan-300">
<Blend size={18} /> Firefly Tools
<Blend size={18} /> {t("header.firefly_tools")}
</Link>
</li>
</ul>
@@ -135,13 +151,13 @@ export default function Header() {
<li>
<Link to="/howto" className="flex items-center gap-2 hover:text-cyan-300">
<BookOpen size={18} /> How to?
<BookOpen size={18} /> {t("header.how_to")}
</Link>
</li>
<li>
<Link to="/about" className="flex items-center gap-2 hover:text-cyan-300">
<Info size={18} /> About
<Info size={18} /> {t("header.about")}
</Link>
</li>
</ul>
@@ -150,9 +166,10 @@ export default function Header() {
{/* RIGHT */}
<div
className="navbar-end flex gap-2 z-52"
style={{ '--wails-draggable': 'no-drag' } as any}
>
<div className="flex items-center gap-2 bg-black/40 backdrop-blur-sm rounded-lg">
<div className="flex items-center gap-2 bg-black/40 backdrop-blur-sm rounded-lg" style={{ '--wails-draggable': 'no-drag' } as any}>
<LanguageSwitcher />
{controlButtons.map((btn, i) => (
<div key={i} className="tooltip tooltip-bottom" data-tip={btn.tip}>
<button
@@ -167,4 +184,4 @@ export default function Header() {
</div>
</div>
)
}
}

View File

@@ -0,0 +1,110 @@
import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
const languages = [
{ code: "en", label: "EN", native: "English" },
{ code: "vi", label: "VI", native: "Tiếng Việt" },
{ code: "ja", label: "JA", native: "日本語" },
{ code: "ko", label: "KO", native: "한국어" },
{ code: "zh", label: "ZH", native: "中文" },
];
const LanguageSwitcher = () => {
const { i18n, t } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const current = languages.find((l) => l.code === i18n.language) ?? languages[0];
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const select = (code: string) => {
i18n.changeLanguage(code);
setOpen(false);
};
return (
<div ref={ref} className="relative">
{/* Trigger */}
<button
onClick={() => setOpen((v) => !v)}
className="
btn btn-ghost px-2
flex items-center gap-1
text-sm font-semibold tracking-wider
text-white/80+
select-none
tooltip tooltip-bottom
"
data-tip={t("header.language")}
>
<span className="uppercase">{current.label}</span>
{/* Chevron */}
<svg
className={`w-3 h-3 text-white/50 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Dropdown */}
{open && (
<ul
className="
absolute right-0 mt-2 w-32
bg-black/60 backdrop-blur-md
border border-white/10 rounded-xl
shadow-2xl shadow-black/50
overflow-hidden z-999
py-1
animate-in fade-in slide-in-from-top-2 duration-150
"
>
{languages.map((lang) => {
const isActive = lang.code === i18n.language;
return (
<li key={lang.code}>
<button
onClick={() => select(lang.code)}
className={`
w-full flex items-center gap-3 px-4 py-2.5 text-sm
transition-colors duration-150
${isActive
? "bg-emerald-500/20 text-emerald-400"
: "text-white/70 hover:bg-white/8 hover:text-white"
}
`}
>
<span className="flex-1 text-left">{lang.native}</span>
{isActive && (
<svg className="w-3.5 h-3.5 text-emerald-400 shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</button>
</li>
);
})}
</ul>
)}
</div>
);
};
export default LanguageSwitcher;

View File

@@ -3,6 +3,7 @@ import useModalStore from "@/stores/modalStore"
import useSettingStore from "@/stores/settingStore"
import useLauncherStore from "@/stores/launcherStore"
import { toast } from "react-toastify"
import { useTranslation } from "react-i18next"
export default function SettingModal({
isOpen,
@@ -12,7 +13,7 @@ export default function SettingModal({
onClose: () => void
}) {
if (!isOpen) return null
const { t } = useTranslation()
const { setIsOpenSelfUpdateModal } = useModalStore()
const { closingOption, setClosingOption, serverVersion,
proxyVersion, } = useSettingStore()
@@ -20,7 +21,7 @@ export default function SettingModal({
const CheckUpdate = async () => {
const launcherData = await CheckUpdateLauncher()
if (!launcherData.isUpdate) {
toast.success("Launcher is already up to date")
toast.success(t("setting.launcher_update_success"))
return
}
setUpdateData({
@@ -37,8 +38,8 @@ export default function SettingModal({
<div className="relative w-[90%] max-w-md bg-base-100 text-base-content rounded-2xl border border-purple-500/30 shadow-2xl shadow-purple-500/30 p-6">
{/* Header */}
<div className="flex justify-between items-center mb-6">
<h3 className="font-extrabold text-2xl text-transparent bg-clip-text bg-gradient-to-r from-pink-400 to-cyan-500">
Settings
<h3 className="font-extrabold text-2xl text-transparent bg-clip-text bg-linear-to-r from-pink-400 to-cyan-500">
{t("setting.title")}
</h3>
<button
className="btn btn-circle btn-sm bg-red-600 hover:bg-red-700 text-white border-none shadow-lg"
@@ -52,21 +53,21 @@ export default function SettingModal({
<div className="flex flex-col gap-6">
{/* Section 1: Launcher Update */}
<div className="p-4 bg-base-200 rounded-xl border border-purple-300 shadow-sm">
<h4 className="font-bold text-lg mb-2">Launcher Update</h4>
<h4 className="font-bold text-lg mb-2">{t("setting.launcher_update_title")}</h4>
<p className="text-sm text-info mb-3">
Check if your launcher is up to date.
{t("setting.launcher_update_desc")}
</p>
<button
className="btn btn-primary bg-gradient-to-r from-orange-500 to-red-500 hover:from-orange-400 hover:to-red-500 text-white shadow-md hover:shadow-lg transition-all duration-200"
className="btn btn-primary bg-linear-to-r from-orange-500 to-red-500 hover:from-orange-400 hover:to-red-500 text-white shadow-md hover:shadow-lg transition-all duration-200"
onClick={CheckUpdate}
>
Check for Launcher Updates
{t("setting.launcher_update_btn")}
</button>
</div>
{/* Section 2: Closing Option */}
<div className="p-4 bg-base-200 rounded-xl border border-purple-300 shadow-sm">
<h4 className="font-bold text-lg mb-2">Closing Options</h4>
<h4 className="font-bold text-lg mb-2">{t("setting.closing_options_title")}</h4>
<label className="flex items-start gap-3 cursor-pointer select-none">
<input
type="checkbox"
@@ -81,12 +82,10 @@ export default function SettingModal({
/>
<div className="flex flex-col">
<span className="text-base font-medium text-info">
Set do not ask again
{t("setting.set_dont_ask_again")}
</span>
<span className="text-sm text-accent">
Next time you close the app, it will automatically{" "}
{closingOption.isMinimize ? "minimize to system tray" : "quit the app"}{" "}
without asking.
{t('setting.closing_auto_desc', { action: closingOption.isMinimize ? t('setting.action_minimize') : t('setting.action_quit') })}
</span>
</div>
</label>
@@ -95,18 +94,18 @@ export default function SettingModal({
{/* Section 3: Launcher Version */}
<div className="p-4 bg-base-200 rounded-xl border border-purple-300 shadow-sm">
<h4 className="font-bold text-lg mb-2">Version</h4>
<h4 className="font-bold text-lg mb-2">{t("setting.version_label")}</h4>
<div className="flex flex-wrap gap-2">
<p className="text-base text-info">
Server: {serverVersion}
</p>
<p className="text-base text-info">
Proxy: {proxyVersion}
</p>
<p className="text-base text-info">
{t("setting.server_label")}: {serverVersion}
</p>
<p className="text-base text-info">
{t("setting.proxy_label")}: {proxyVersion}
</p>
<p className="text-base text-info">
Launcher: {launcherVersion}
</p>
<p className="text-base text-info">
{t("setting.launcher_label")}: {launcherVersion}
</p>
</div>
</div>
</div>

View File

@@ -28,7 +28,7 @@ export default function UpdateModal({ isOpen, title, message, buttons, onClose }
</motion.button>
<div className="border-b border-purple-500/30 px-6 py-4 mb-4">
<h3 className="font-bold text-2xl text-transparent bg-clip-text bg-gradient-to-r from-pink-400 to-cyan-400">
<h3 className="font-bold text-2xl text-transparent bg-clip-text bg-linear-to-r from-pink-400 to-cyan-400">
{title}
</h3>
</div>
@@ -46,7 +46,7 @@ export default function UpdateModal({ isOpen, title, message, buttons, onClose }
whileTap={{ scale: 0.95 }}
className={`btn ${
btn.variant === "primary"
? "btn-primary bg-gradient-to-r from-orange-200 to-red-400 border-none"
? "btn-primary bg-linear-to-r from-orange-200 to-red-400 border-none"
: btn.variant === "error"
? "btn-error"
: "btn-outline btn-error"