Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion src/features/launcher/AccountGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export function AccountGrid({ selectedAccountId, onSelectAccount }: AccountGridP
const { data: accounts, isLoading } = useGameAccounts();
const refreshAccounts = useRefreshAccounts();
const [contextMenu, setContextMenu] = useState<ContextState | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);

const handleContextMenu = useCallback((e: React.MouseEvent, accountId: string) => {
e.preventDefault();
Expand All @@ -27,6 +28,13 @@ export function AccountGrid({ selectedAccountId, onSelectAccount }: AccountGridP

const closeContextMenu = useCallback(() => setContextMenu(null), []);

function handleCopyAccount(e: React.MouseEvent, accountId: string) {
e.stopPropagation();
navigator.clipboard.writeText(accountId);
setCopiedId(accountId);
setTimeout(() => setCopiedId(null), 1500);
}

return (
<div className="flex flex-1 flex-col gap-2 overflow-hidden">
<div className="flex items-center justify-between">
Expand All @@ -46,17 +54,59 @@ export function AccountGrid({ selectedAccountId, onSelectAccount }: AccountGridP
{accounts.map((account) => {
const isSelected = selectedAccountId === account.id;
const initial = account.displayName.charAt(0).toUpperCase();
const isCopied = copiedId === account.id;
return (
<button
key={account.id}
onClick={() => onSelectAccount(account)}
onContextMenu={(e) => handleContextMenu(e, account.id)}
className={`group flex flex-col items-center gap-1.5 rounded-xl border p-3 text-center backdrop-blur-sm transition-colors duration-150 ${
className={`group relative flex flex-col items-center gap-1.5 rounded-xl border p-3 text-center backdrop-blur-sm transition-colors duration-150 ${
isSelected
? "border-accent bg-[rgba(232,162,58,0.05)] shadow-[0_0_20px_rgba(232,162,58,0.15)]"
: "border-border bg-[var(--surface)] hover:border-[var(--border)] hover:bg-[var(--surface-hover)]"
}`}
>
{/* Copy button — top-right, visible on hover */}
<span
role="button"
tabIndex={-1}
onClick={(e) => handleCopyAccount(e, account.id)}
title={t("launcher.context.copy_account")}
className={`absolute top-1.5 right-1.5 rounded p-0.5 transition-all ${
isCopied
? "text-green-400 opacity-100"
: "text-text-faint opacity-0 group-hover:opacity-100 hover:text-accent"
}`}
>
{isCopied ? (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</span>
<div
className={`flex h-[38px] w-[38px] items-center justify-center rounded-full border-2 text-sm font-bold transition-colors duration-150 ${
isSelected
Expand Down
33 changes: 32 additions & 1 deletion src/features/launcher/OtpPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from "react";
import { useTranslation } from "../../lib/i18n";
import { useGameCredentials } from "../../lib/hooks/use-accounts";
import { useAuthStore } from "../../lib/stores/auth-store";
import { useErrorToastStore } from "../../lib/stores/error-toast-store";
import { commands } from "../../lib/tauri";
import type { GameCredentialsDto } from "../../lib/types";

Expand All @@ -11,12 +12,39 @@ interface OtpPanelProps {
}

export function OtpPanel({ selectedAccountId, onOtpFetched }: OtpPanelProps) {
const { t } = useTranslation();
const credentialsMutation = useGameCredentials();
const [credentials, setCredentials] = useState<GameCredentialsDto | null>(null);
const [copied, setCopied] = useState(false);
const [autoInput, setAutoInput] = useState(true);
const [pasting, setPasting] = useState(false);
const { t } = useTranslation();
const addToast = useErrorToastStore((s) => s.addToast);

function handleOtpError(error: Error) {
const msg = error.message || t("launcher.otp_error");
const isSessionGone =
msg.includes("Not authenticated") ||
msg.includes("expired") ||
msg.includes("閒置過久") ||
msg.includes("重新登入") ||
msg.includes("Invalid credentials");

if (isSessionGone) {
// Session is dead — remove it and redirect to login
const sessionId = useAuthStore.getState().activeSessionId;
if (sessionId) {
commands.logout(sessionId).catch(() => {});
useAuthStore.getState().removeSession(sessionId);
}
addToast({
message: t("errors.AUTH_SESSION_EXPIRED"),
category: "authentication",
critical: true,
});
} else {
addToast({ message: msg, category: "authentication", critical: false });
}
}

async function handleGetOtp() {
if (!selectedAccountId) return;
Expand All @@ -42,6 +70,7 @@ export function OtpPanel({ selectedAccountId, onOtpFetched }: OtpPanelProps) {
setTimeout(() => setCopied(false), 2000);
}
},
onError: handleOtpError,
});
} catch {
// Error — fall back to regular OTP
Expand All @@ -51,6 +80,7 @@ export function OtpPanel({ selectedAccountId, onOtpFetched }: OtpPanelProps) {
onOtpFetched?.(selectedAccountId, data.otp);
setCopied(false);
},
onError: handleOtpError,
});
} finally {
setPasting(false);
Expand All @@ -63,6 +93,7 @@ export function OtpPanel({ selectedAccountId, onOtpFetched }: OtpPanelProps) {
onOtpFetched?.(selectedAccountId, data.otp);
setCopied(false);
},
onError: handleOtpError,
});
}
}
Expand Down
1 change: 1 addition & 0 deletions src/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@
"launcher.otp": "One-Time Password",
"launcher.otp_placeholder": "Select an account to get OTP",
"launcher.get_otp": "Get OTP",
"launcher.otp_error": "Failed to retrieve OTP",
"launcher.fetching_otp": "Fetching...",
"launcher.auto_input": "Auto Input",
"launcher.game_info": "MapleStory",
Expand Down
1 change: 1 addition & 0 deletions src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@
"launcher.otp": "一次性密码",
"launcher.otp_placeholder": "选择账号以获取 OTP",
"launcher.get_otp": "获取 OTP",
"launcher.otp_error": "获取密码失败",
"launcher.fetching_otp": "获取中...",
"launcher.auto_input": "自动输入",
"launcher.game_info": "MapleStory",
Expand Down
1 change: 1 addition & 0 deletions src/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@
"launcher.otp": "一次性密碼",
"launcher.otp_placeholder": "選擇帳號以取得 OTP",
"launcher.get_otp": "取得 OTP",
"launcher.otp_error": "取得密碼失敗",
"launcher.fetching_otp": "取得中...",
"launcher.auto_input": "自動輸入",
"launcher.game_info": "MapleStory",
Expand Down