diff --git a/client/src/const.ts b/client/src/const.ts deleted file mode 100644 index 9999063..0000000 --- a/client/src/const.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const"; - -// Generate login URL at runtime so redirect URI reflects the current origin. -export const getLoginUrl = () => { - const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL; - const appId = import.meta.env.VITE_APP_ID; - const redirectUri = `${window.location.origin}/api/oauth/callback`; - const state = btoa(redirectUri); - - const url = new URL(`${oauthPortalUrl}/app-auth`); - url.searchParams.set("appId", appId); - url.searchParams.set("redirectUri", redirectUri); - url.searchParams.set("state", state); - url.searchParams.set("type", "signIn"); - - return url.toString(); -}; diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index 7cb6e3e..6efc2d9 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -308,6 +308,12 @@ "toastNewEqOk": "New headphone EQ saved", "toastTargetSelected": "Target selected: {{name}}", "deleteConfirm": "Delete headphone \"{{name}}\"?", + "batchDeleteConfirm": "Delete {{count}} selected preset(s)?", + "deletePresetConfirm": "Delete preset \"{{name}}\"?", + "toastRenamed": "Renamed", + "toastRenameFail": "Rename failed", + "renameEmptyName": "Name cannot be empty", + "renameNameExists": "Name already exists", "toastDeleted": "Headphone removed", "toastDeleteFail": "Failed to delete headphone", "bassBoost": "Bass boost: fc {{fc}}, q {{q}}, gain {{gain}} dB", diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index 016ce68..742aebe 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -308,6 +308,12 @@ "toastNewEqOk": "新耳機 EQ 已上報", "toastTargetSelected": "已選目標:{{name}}", "deleteConfirm": "是否刪除耳機「{{name}}」?", + "batchDeleteConfirm": "是否刪除選中的 {{count}} 個預設?", + "deletePresetConfirm": "是否刪除預設「{{name}}」?", + "toastRenamed": "已重新命名", + "toastRenameFail": "重新命名失敗", + "renameEmptyName": "名稱不能為空", + "renameNameExists": "名稱已存在", "toastDeleted": "已刪除耳機", "toastDeleteFail": "刪除耳機失敗", "bassBoost": "低頻增強:fc {{fc}},q {{q}},gain {{gain}}dB", diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index c410824..e2d700f 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -308,6 +308,12 @@ "toastNewEqOk": "新耳机 EQ 已上报", "toastTargetSelected": "已选目标:{{name}}", "deleteConfirm": "是否删除耳机「{{name}}」?", + "batchDeleteConfirm": "是否删除选中的 {{count}} 个预设?", + "deletePresetConfirm": "是否删除预设「{{name}}」?", + "toastRenamed": "已重命名", + "toastRenameFail": "重命名失败", + "renameEmptyName": "名称不能为空", + "renameNameExists": "名称已存在", "toastDeleted": "已删除耳机", "toastDeleteFail": "删除耳机失败", "bassBoost": "低频增强:fc {{fc}},q {{q}},gain {{gain}}dB", diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index 207580f..1db38c9 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -20,6 +20,7 @@ import { FeatureGate } from "@/components/FeatureGate"; import { toast } from "sonner"; import { fetchLuxsinAudioCurve, + normalizePeqFiltersForSubmit, type PeqFilter, type PeqApplyPayload, type PeqChangePayload, @@ -39,6 +40,7 @@ import { BatchEditDialog } from "./eq/components/BatchEditDialog"; import { ShareDialog } from "./eq/components/ShareDialog"; import { BrandDrawer } from "./eq/components/BrandDrawer"; import { PeqOverwriteConfirmDialog } from "./eq/components/PeqOverwriteConfirmDialog"; +import { PeqPresetManageDialog } from "./eq/components/PeqPresetManageDialog"; import { useRawCurve } from "./eq/hooks/useRawCurve"; import { BAND_FREQ_MAX, @@ -151,6 +153,7 @@ export default function EQPage() { const [brandDrawerKey, setBrandDrawerKey] = useState(0); const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); const [shareDialogKey, setShareDialogKey] = useState(0); + const [isPresetManageDialogOpen, setIsPresetManageDialogOpen] = useState(false); const [overwritePresetDialog, setOverwritePresetDialog] = useState<{ name: string; onConfirm: () => void | Promise; @@ -297,6 +300,10 @@ export default function EQPage() { setIsBatchEditDialogOpen(true); }; + const openPresetManageDialog = () => { + setIsPresetManageDialogOpen(true); + }; + const parseBatchEditText = useCallback( (raw: string) => { const t = eqUi; @@ -1406,6 +1413,13 @@ export default function EQPage() { )}
+ + ); +} + +type PeqItem = { + name: string; + filters?: any[] | string; + autoPre?: number; + preamp?: number; + canDel?: number; + brand?: string; + model?: string; + target?: string; + form?: string; +}; + +/** + * Preset management dialog. + * + * Behavior: + * - Delete (single / batch): calls onDeletePresets callback with preset names, + * which should call info.cgi with { peqRemove: names } then sync. + * - Rename: calls onRenamePreset(oldName, newName, item) callback which + * should: 1) peqChange with new name + same EQ data 2) peqRemove old name. + */ +export function PeqPresetManageDialog({ + open, + items, + selectedIdx, + onClose, + eqUi, + onDeletePresets, + onRenamePreset, +}: { + open: boolean; + items: PeqItem[]; + selectedIdx: number; + onClose: () => void; + eqUi: PeqEqUi; + /** + * Called with preset names to delete. Should call api.removePeq(names) then + * fetchEqSyncPeq + applyPeqStateToUI. Return true on success, false on failure. + */ + onDeletePresets: (names: string[]) => Promise; + /** + * Called to rename a preset. Implementation should: + * 1) upgradePeqChange({ peqChange: { name: newName, filters, ...rest } }) + * 2) api.removePeq([oldName]) + * 3) fetchEqSyncPeq + applyPeqStateToUI + * Return true on success, false on failure. + */ + onRenamePreset: (oldName: string, newName: string, item: PeqItem) => Promise; +}) { + const [checked, setChecked] = useState>({}); + const [editingIdx, setEditingIdx] = useState(null); + const [editingName, setEditingName] = useState(""); + + const [deleting, setDeleting] = useState(false); + const [renaming, setRenaming] = useState(false); + + const checkedIdxs = useMemo( + () => + Object.entries(checked) + .filter(([, v]) => v) + .map(([k]) => Number(k)), + [checked] + ); + + const allChecked = items.length > 0 && checkedIdxs.length === items.length; + + if (!open) return null; + + const toggleAll = () => { + if (allChecked) { + setChecked({}); + return; + } + const next: Record = {}; + for (let i = 0; i < items.length; i++) next[i] = true; + setChecked(next); + }; + + const toggleOne = (idx: number) => { + setChecked(prev => ({ ...prev, [idx]: !prev[idx] })); + }; + + const beginRename = (idx: number) => { + const name = items[idx]?.name ?? ""; + setEditingIdx(idx); + setEditingName(name); + }; + + const commitRename = async () => { + if (editingIdx === null) return; + const nextName = editingName.trim(); + if (!nextName) { + toast.error(eqUi.renameEmptyName); + return; + } + const oldItem = items[editingIdx]; + if (!oldItem) return; + if (nextName === oldItem.name) { + setEditingIdx(null); + setEditingName(""); + return; + } + if (items.some((it, i) => i !== editingIdx && it.name === nextName)) { + toast.error(eqUi.renameNameExists); + return; + } + + setRenaming(true); + try { + const ok = await onRenamePreset(oldItem.name, nextName, oldItem); + if (ok) { + setEditingIdx(null); + setEditingName(""); + } + } finally { + setRenaming(false); + } + }; + + const confirmAndDelete = async (idxs: number[]) => { + const uniq = Array.from(new Set(idxs)).filter( + i => i >= 0 && i < items.length + ); + if (uniq.length === 0) return; + + const names = uniq.map(i => items[i].name); + + // Confirmation dialog + const confirmMsg = + names.length === 1 + ? eqUi.deletePresetConfirm.replace("{{name}}", names[0]) + : eqUi.batchDeleteConfirm.replace("{{count}}", String(names.length)); + if (!window.confirm(confirmMsg)) return; + + setDeleting(true); + try { + const ok = await onDeletePresets(names); + if (ok) { + setChecked({}); + if (editingIdx !== null && uniq.includes(editingIdx)) { + setEditingIdx(null); + setEditingName(""); + } + } + } finally { + setDeleting(false); + } + }; + + return ( +
+
e.stopPropagation()} + > +
+

+ 管理 EQ 预设 +

+ +
+ +
+ + + +
+ +
+ {items.length === 0 ? ( +
+ 暂无预设 +
+ ) : ( + items.map((item, idx) => { + const active = idx === selectedIdx; + const isEditing = idx === editingIdx; + return ( +
+ toggleOne(idx)} + /> + +
+ {isEditing ? ( +
+ setEditingName(e.target.value)} + className="h-9 min-w-0 flex-1 rounded-[10px] border border-white/12 bg-[#15171b] px-3 text-[14px] text-white/90 outline-none placeholder:text-white/30 focus:border-[#00FFF6]/40" + /> + + +
+ ) : ( +
+ {item.name} +
+ )} +
+ + {!isEditing && ( +
+ + +
+ )} +
+ ); + }) + )} +
+
+
+ ); +} diff --git a/package.json b/package.json index 3ae6f06..d009a2e 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "build:test": "pnpm build:test:legacy && pnpm build:test:modern", "build:test:modern": "DEPLOY_ENV=test vite build", "build:test:legacy": "DEPLOY_ENV=test vite build --config vite.legacy.config.ts", - "start": "NODE_ENV=production tsx server/index.ts", "preview": "vite preview --host", "check": "tsc --noEmit", "format": "prettier --write ." @@ -51,7 +50,6 @@ "cmdk": "^1.1.1", "echarts": "^6.0.0", "embla-carousel-react": "^8.6.0", - "express": "^4.21.2", "framer-motion": "^12.23.22", "input-otp": "^1.4.2", "lucide-react": "^0.453.0", @@ -75,7 +73,6 @@ "@builder.io/vite-plugin-jsx-loc": "^0.1.1", "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "^4.1.3", - "@types/express": "4.17.21", "@types/google.maps": "^3.58.1", "@types/node": "^24.7.0", "@types/react": "^19.2.1", @@ -90,7 +87,6 @@ "tailwindcss": "^4.1.14", "tailwindcss3": "npm:tailwindcss@^3.4.17", "terser": "^5.46.1", - "tsx": "^4.19.1", "tw-animate-css": "^1.4.0", "typescript": "5.6.3", "vite": "^7.1.7", diff --git a/patches/wouter@3.7.1.patch b/patches/wouter@3.7.1.patch deleted file mode 100644 index 133e386..0000000 --- a/patches/wouter@3.7.1.patch +++ /dev/null @@ -1,28 +0,0 @@ -diff --git a/esm/index.js b/esm/index.js -index c83bc63a2c10431fb62e25b7d490656a3796f301..bcae513cc20a4be6c38dc116e0b8d9bacda62b5b 100644 ---- a/esm/index.js -+++ b/esm/index.js -@@ -338,6 +338,23 @@ const Switch = ({ children, location }) => { - const router = useRouter(); - const [originalLocation] = useLocationFromRouter(router); - -+ // Collect all route paths to window object -+ if (typeof window !== 'undefined') { -+ if (!window.__WOUTER_ROUTES__) { -+ window.__WOUTER_ROUTES__ = []; -+ } -+ -+ const allChildren = flattenChildren(children); -+ allChildren.forEach((element) => { -+ if (isValidElement(element) && element.props.path) { -+ const path = element.props.path; -+ if (!window.__WOUTER_ROUTES__.includes(path)) { -+ window.__WOUTER_ROUTES__.push(path); -+ } -+ } -+ }); -+ } -+ - for (const element of flattenChildren(children)) { - let match = 0; - diff --git a/server/index.ts b/server/index.ts deleted file mode 100644 index 9cdf613..0000000 --- a/server/index.ts +++ /dev/null @@ -1,74 +0,0 @@ -import express from "express"; -import { createServer } from "http"; -import path from "path"; -import { fileURLToPath } from "url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -async function startServer() { - const app = express(); - const server = createServer(app); - - const modernPath = path.resolve(__dirname, "..", "dist-modern"); - const legacyPath = path.resolve(__dirname, "..", "dist-legacy"); - - app.use("/modern", express.static(modernPath)); - app.use("/legacy", express.static(legacyPath)); - - function shouldUseLegacy(req: express.Request) { - // Prefer capability detection on client, but keep a UA escape hatch for very old WebViews. - const ua = req.headers["user-agent"] || ""; - // Chrome 91 WebView frequently reports like: Chrome/91.0.4472.114 - const m = String(ua).match(/Chrome\/(\d+)\./); - if (m) return Number(m[1]) <= 91; - return false; - } - - // Bootstrap entry: redirect to /legacy or /modern while preserving hash routes. - app.get(["/", "/index.html", "/i.html"], (req, res) => { - const defaultToLegacy = shouldUseLegacy(req) ? "true" : "false"; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(` - - - - - Luxsin X8 Controller - - - -`); - }); - - // Hash routing: any other path should serve the bootstrap as well. - app.get("*", (_req, res) => { - res.redirect(302, "/x8/v2/"); - }); - - const port = process.env.PORT || 3000; - - server.listen(port, () => { - console.log(`Server running on http://localhost:${port}/`); - }); -} - -startServer().catch(console.error); diff --git a/shared/const.ts b/shared/const.ts deleted file mode 100644 index 98b0123..0000000 --- a/shared/const.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const COOKIE_NAME = "app_session_id"; -export const ONE_YEAR_MS = 1000 * 60 * 60 * 24 * 365; diff --git a/tsconfig.json b/tsconfig.json index a0203ee..abee6b9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["client/src/**/*", "shared/**/*", "server/**/*"], + "include": ["client/src/**/*"], "exclude": ["node_modules", "build", "dist", "**/*.test.ts"], "compilerOptions": { "incremental": true, @@ -16,8 +16,7 @@ "baseUrl": ".", "types": ["node", "vite/client"], "paths": { - "@/*": ["./client/src/*"], - "@shared/*": ["./shared/*"] + "@/*": ["./client/src/*"] } } } diff --git a/vite.config.ts b/vite.config.ts index a5b0cc0..3c2f711 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -62,7 +62,6 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(import.meta.dirname, "client", "src"), - "@shared": path.resolve(import.meta.dirname, "shared"), "@assets": path.resolve(import.meta.dirname, "attached_assets"), }, dedupe: ["react", "react-dom"], diff --git a/vite.legacy.config.ts b/vite.legacy.config.ts index 9048e2f..85969f5 100644 --- a/vite.legacy.config.ts +++ b/vite.legacy.config.ts @@ -59,7 +59,6 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(PROJECT_ROOT, "client", "src"), - "@shared": path.resolve(PROJECT_ROOT, "shared"), "@assets": path.resolve(PROJECT_ROOT, "attached_assets"), }, dedupe: ["react", "react-dom"],