fix: language selector mutual exclusion with preset buttons

- When "Other" is selected, preset language buttons are deselected
- Only one option can be selected at a time
- Refactor dictionary page with zustand store
- Add custom language input option to dictionary
- Fix multiple issues in dictionary bigmodel pipeline
This commit is contained in:
2026-03-08 15:58:20 +08:00
parent b643205f72
commit 6ba5ae993a
18 changed files with 642 additions and 435 deletions

View File

@@ -241,6 +241,7 @@
"definitionLanguage": "Definition Language", "definitionLanguage": "Definition Language",
"definitionLanguageHint": "What language do you want the definitions in", "definitionLanguageHint": "What language do you want the definitions in",
"otherLanguagePlaceholder": "Or enter another language...", "otherLanguagePlaceholder": "Or enter another language...",
"other": "Other",
"currentSettings": "Current settings: Query {queryLang}, Definition {definitionLang}", "currentSettings": "Current settings: Query {queryLang}, Definition {definitionLang}",
"relookup": "Re-search", "relookup": "Re-search",
"saveToFolder": "Save to folder", "saveToFolder": "Save to folder",
@@ -267,7 +268,9 @@
"unknownUser": "Unknown User", "unknownUser": "Unknown User",
"favorite": "Favorite", "favorite": "Favorite",
"unfavorite": "Unfavorite", "unfavorite": "Unfavorite",
"pleaseLogin": "Please login first" "pleaseLogin": "Please login first",
"sortByFavorites": "Sort by favorites",
"sortByFavoritesActive": "Undo sort by favorites"
}, },
"favorites": { "favorites": {
"title": "My Favorites", "title": "My Favorites",

View File

@@ -241,6 +241,7 @@
"definitionLanguage": "释义语言", "definitionLanguage": "释义语言",
"definitionLanguageHint": "你希望用什么语言查看释义", "definitionLanguageHint": "你希望用什么语言查看释义",
"otherLanguagePlaceholder": "或输入其他语言...", "otherLanguagePlaceholder": "或输入其他语言...",
"other": "其他",
"currentSettings": "当前设置:查询 {queryLang},释义 {definitionLang}", "currentSettings": "当前设置:查询 {queryLang},释义 {definitionLang}",
"relookup": "重新查询", "relookup": "重新查询",
"saveToFolder": "保存到文件夹", "saveToFolder": "保存到文件夹",
@@ -267,7 +268,9 @@
"unknownUser": "未知用户", "unknownUser": "未知用户",
"favorite": "收藏", "favorite": "收藏",
"unfavorite": "取消收藏", "unfavorite": "取消收藏",
"pleaseLogin": "请先登录" "pleaseLogin": "请先登录",
"sortByFavorites": "按收藏数排序",
"sortByFavoritesActive": "取消按收藏数排序"
}, },
"favorites": { "favorites": {
"title": "收藏", "title": "收藏",

View File

@@ -8,7 +8,7 @@ export default async function LogoutPage(
} }
) { ) {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const redirectTo = props.searchParams ?? null; const redirectTo = searchParams.redirect ?? null;
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers() headers: await headers()

View File

@@ -0,0 +1,239 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { useRouter, useSearchParams } from "next/navigation";
import { useDictionaryStore } from "./stores/dictionaryStore";
import { PageLayout } from "@/components/ui/PageLayout";
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { Plus, RefreshCw } from "lucide-react";
import { DictionaryEntry } from "./DictionaryEntry";
import { LanguageSelector } from "./LanguageSelector";
import { authClient } from "@/lib/auth-client";
import { actionGetFoldersByUserId, actionCreatePair } from "@/modules/folder/folder-aciton";
import { TSharedFolder } from "@/shared/folder-type";
import { toast } from "sonner";
interface DictionaryClientProps {
initialFolders: TSharedFolder[];
}
export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
const t = useTranslations("dictionary");
const router = useRouter();
const searchParams = useSearchParams();
const {
query,
queryLang,
definitionLang,
searchResult,
isSearching,
setQuery,
setQueryLang,
setDefinitionLang,
search,
relookup,
syncFromUrl,
} = useDictionaryStore();
const { data: session } = authClient.useSession();
const [folders, setFolders] = useState<TSharedFolder[]>(initialFolders);
useEffect(() => {
const q = searchParams.get("q") || undefined;
const ql = searchParams.get("ql") || undefined;
const dl = searchParams.get("dl") || undefined;
syncFromUrl({ q, ql, dl });
if (q) {
search();
}
}, [searchParams, syncFromUrl, search]);
useEffect(() => {
if (session?.user?.id) {
actionGetFoldersByUserId(session.user.id).then((result) => {
if (result.success && result.data) {
setFolders(result.data);
}
});
}
}, [session?.user?.id]);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!query.trim()) return;
const params = new URLSearchParams({
q: query,
ql: queryLang,
dl: definitionLang,
});
router.push(`/dictionary?${params.toString()}`);
};
const handleSave = async () => {
if (!session) {
toast.error("Please login first");
return;
}
if (folders.length === 0) {
toast.error("Please create a folder first");
return;
}
const folderSelect = document.getElementById("folder-select") as HTMLSelectElement;
const folderId = folderSelect?.value ? Number(folderSelect.value) : folders[0]?.id;
if (!searchResult) return;
const definition = searchResult.entries.reduce((p, e) => {
return { ...p, definition: p.definition + ' | ' + e.definition };
}).definition;
try {
await actionCreatePair({
text1: searchResult.standardForm,
text2: definition,
language1: queryLang,
language2: definitionLang,
ipa1: searchResult.entries[0].ipa,
folderId: folderId,
});
const folderName = folders.find((f) => f.id === folderId)?.name || "Unknown";
toast.success(`Saved to ${folderName}`);
} catch (error) {
toast.error("Save failed");
}
};
return (
<PageLayout>
<div className="text-center mb-8">
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
{t("title")}
</h1>
<p className="text-gray-700 text-lg">
{t("description")}
</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-2">
<Input
type="text"
name="searchQuery"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("searchPlaceholder")}
variant="search"
required
/>
<LightButton
type="submit"
className="px-6 py-3 whitespace-nowrap text-center sm:min-w-30"
loading={isSearching}
>
{t("search")}
</LightButton>
</form>
<div className="mt-4 bg-white/20 rounded-lg p-4">
<div className="mb-3">
<span className="text-gray-800 font-semibold">{t("languageSettings")}</span>
</div>
<div className="space-y-4">
<LanguageSelector
label={t("queryLanguage")}
hint={t("queryLanguageHint")}
value={queryLang}
onChange={setQueryLang}
/>
<LanguageSelector
label={t("definitionLanguage")}
hint={t("definitionLanguageHint")}
value={definitionLang}
onChange={setDefinitionLang}
/>
</div>
</div>
<div className="mt-8">
{isSearching ? (
<div className="text-center py-12">
<div className="w-8 h-8 border-2 border-gray-200 border-t-primary-500 rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-gray-600">{t("searching")}</p>
</div>
) : query && !searchResult ? (
<div className="text-center py-12 bg-white/20 rounded-lg">
<p className="text-gray-800 text-xl">No results found</p>
<p className="text-gray-600 mt-2">Try other words</p>
</div>
) : searchResult ? (
<div className="bg-white rounded-lg p-6 shadow-lg">
<div className="flex items-start justify-between mb-6">
<div className="flex-1">
<h2 className="text-3xl font-bold text-gray-800 mb-2">
{searchResult.standardForm}
</h2>
</div>
<div className="flex items-center gap-2 ml-4">
{session && folders.length > 0 && (
<select
id="folder-select"
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
>
{folders.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.name}
</option>
))}
</select>
)}
<LightButton
onClick={handleSave}
className="w-10 h-10 shrink-0"
title="Save to folder"
>
<Plus />
</LightButton>
</div>
</div>
<div className="space-y-6">
{searchResult.entries.map((entry, index) => (
<div key={index} className="border-t border-gray-200 pt-4">
<DictionaryEntry entry={entry} />
</div>
))}
</div>
<div className="border-t border-gray-200 pt-4 mt-4">
<LightButton
onClick={relookup}
className="flex items-center gap-2 px-4 py-2 text-sm"
loading={isSearching}
>
<RefreshCw className="w-4 h-4" />
Re-lookup
</LightButton>
</div>
</div>
) : (
<div className="text-center py-12">
<div className="text-6xl mb-4">📚</div>
<p className="text-gray-800 text-xl mb-2">{t("welcomeTitle")}</p>
<p className="text-gray-600">{t("welcomeHint")}</p>
</div>
)}
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { POPULAR_LANGUAGES } from "./constants";
import { useTranslations } from "next-intl";
interface LanguageSelectorProps {
label: string;
hint: string;
value: string;
onChange: (value: string) => void;
}
export function LanguageSelector({ label, hint, value, onChange }: LanguageSelectorProps) {
const t = useTranslations("dictionary");
const [showCustomInput, setShowCustomInput] = useState(false);
const [customLang, setCustomLang] = useState("");
const isPresetLanguage = POPULAR_LANGUAGES.some((lang) => lang.code === value);
const handlePresetSelect = (code: string) => {
onChange(code);
setShowCustomInput(false);
setCustomLang("");
};
const handleCustomToggle = () => {
setShowCustomInput(!showCustomInput);
if (!showCustomInput && customLang.trim()) {
onChange(customLang.trim());
}
};
const handleCustomChange = (newValue: string) => {
setCustomLang(newValue);
if (newValue.trim()) {
onChange(newValue.trim());
}
};
return (
<div>
<label className="block text-gray-700 text-sm mb-2">
{label} ({hint})
</label>
<div className="flex flex-wrap gap-2 mb-2">
{POPULAR_LANGUAGES.map((lang) => (
<LightButton
key={lang.code}
type="button"
selected={isPresetLanguage && value === lang.code}
onClick={() => handlePresetSelect(lang.code)}
className="text-sm px-3 py-1"
>
{lang.nativeName}
</LightButton>
))}
<LightButton
type="button"
selected={!isPresetLanguage && !!value}
onClick={handleCustomToggle}
className="text-sm px-3 py-1"
>
{t("other")}
</LightButton>
</div>
{(showCustomInput || (!isPresetLanguage && value)) && (
<Input
type="text"
value={isPresetLanguage ? customLang : value}
onChange={(e) => handleCustomChange(e.target.value)}
placeholder={t("otherLanguagePlaceholder")}
className="text-sm"
/>
)}
</div>
);
}

View File

@@ -1,117 +0,0 @@
"use client";
import { LightButton } from "@/design-system/base/button";
import { Input } from "@/design-system/base/input";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { POPULAR_LANGUAGES } from "./constants";
interface SearchFormProps {
defaultQueryLang?: string;
defaultDefinitionLang?: string;
}
export function SearchForm({ defaultQueryLang = "english", defaultDefinitionLang = "chinese" }: SearchFormProps) {
const t = useTranslations("dictionary");
const [queryLang, setQueryLang] = useState(defaultQueryLang);
const [definitionLang, setDefinitionLang] = useState(defaultDefinitionLang);
const router = useRouter();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const searchQuery = formData.get("searchQuery") as string;
if (!searchQuery?.trim()) return;
const params = new URLSearchParams({
q: searchQuery,
ql: queryLang,
dl: definitionLang,
});
router.push(`/dictionary?${params.toString()}`);
};
return (
<>
{/* 页面标题 */}
<div className="text-center mb-8">
<h1 className="text-4xl md:text-5xl font-bold text-gray-800 mb-4">
{t("title")}
</h1>
<p className="text-gray-700 text-lg">
{t("description")}
</p>
</div>
{/* 搜索表单 */}
<form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-2">
<Input
type="text"
name="searchQuery"
defaultValue=""
placeholder={t("searchPlaceholder")}
variant="search"
required
/>
<LightButton
type="submit"
className="px-6 py-3 whitespace-nowrap text-center sm:min-w-30"
>
{t("search")}
</LightButton>
</form>
{/* 语言设置 */}
<div className="mt-4 bg-white/20 rounded-lg p-4">
<div className="mb-3">
<span className="text-gray-800 font-semibold">{t("languageSettings")}</span>
</div>
<div className="space-y-4">
{/* 查询语言 */}
<div>
<label className="block text-gray-700 text-sm mb-2">
{t("queryLanguage")} ({t("queryLanguageHint")})
</label>
<div className="flex flex-wrap gap-2">
{POPULAR_LANGUAGES.map((lang) => (
<LightButton
key={lang.code}
type="button"
selected={queryLang === lang.code}
onClick={() => setQueryLang(lang.code)}
className="text-sm px-3 py-1"
>
{lang.nativeName}
</LightButton>
))}
</div>
</div>
{/* 释义语言 */}
<div>
<label className="block text-gray-700 text-sm mb-2">
{t("definitionLanguage")} ({t("definitionLanguageHint")})
</label>
<div className="flex flex-wrap gap-2">
{POPULAR_LANGUAGES.map((lang) => (
<LightButton
key={lang.code}
type="button"
selected={definitionLang === lang.code}
onClick={() => setDefinitionLang(lang.code)}
className="text-sm px-3 py-1"
>
{lang.nativeName}
</LightButton>
))}
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,122 +0,0 @@
"use client";
import { Plus, RefreshCw } from "lucide-react";
import { CircleButton, LightButton } from "@/design-system/base/button";
import { toast } from "sonner";
import { actionCreatePair } from "@/modules/folder/folder-aciton";
import { TSharedItem } from "@/shared/dictionary-type";
import { TSharedFolder } from "@/shared/folder-type";
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
import { useRouter } from "next/navigation";
type Session = {
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
};
} | null;
interface SaveButtonClientProps {
session: Session;
folders: TSharedFolder[];
searchResult: TSharedItem;
queryLang: string;
definitionLang: string;
}
export function SaveButtonClient({ session, folders, searchResult, queryLang, definitionLang }: SaveButtonClientProps) {
const handleSave = async () => {
if (!session) {
toast.error("Please login first");
return;
}
if (folders.length === 0) {
toast.error("Please create a folder first");
return;
}
const folderSelect = document.getElementById("folder-select") as HTMLSelectElement;
const folderId = folderSelect?.value ? Number(folderSelect.value) : folders[0]?.id;
const definition = searchResult.entries.reduce((p, e) => {
return { ...p, definition: p.definition + ' | ' + e.definition };
}).definition;
try {
await actionCreatePair({
text1: searchResult.standardForm,
text2: definition,
language1: queryLang,
language2: definitionLang,
ipa1: searchResult.entries[0].ipa,
folderId: folderId,
});
const folderName = folders.find((f) => f.id === folderId)?.name || "Unknown";
toast.success(`Saved to ${folderName}`);
} catch (error) {
toast.error("Save failed");
}
};
return (
<CircleButton
onClick={handleSave}
className="w-10 h-10 shrink-0"
title="Save to folder"
>
<Plus />
</CircleButton>
);
}
interface ReLookupButtonClientProps {
searchQuery: string;
queryLang: string;
definitionLang: string;
}
export function ReLookupButtonClient({ searchQuery, queryLang, definitionLang }: ReLookupButtonClientProps) {
const router = useRouter();
const handleRelookup = async () => {
const getNativeName = (code: string): string => {
const popularLanguages: Record<string, string> = {
english: "English",
chinese: "中文",
japanese: "日本語",
korean: "한국어",
italian: "Italiano",
uyghur: "ئۇيغۇرچە",
};
return popularLanguages[code] || code;
};
try {
await actionLookUpDictionary({
text: searchQuery,
queryLang: getNativeName(queryLang),
definitionLang: getNativeName(definitionLang),
forceRelook: true
});
toast.success("Re-lookup successful");
// 刷新页面以显示新结果
router.refresh();
} catch (error) {
toast.error("Re-lookup failed");
}
};
return (
<LightButton
onClick={handleRelookup}
className="flex items-center gap-2 px-4 py-2 text-sm"
leftIcon={<RefreshCw className="w-4 h-4" />}
>
Re-lookup
</LightButton>
);
}

View File

@@ -1,93 +0,0 @@
import { auth } from "@/auth";
import { DictionaryEntry } from "./DictionaryEntry";
import { TSharedItem } from "@/shared/dictionary-type";
import { SaveButtonClient, ReLookupButtonClient } from "./SearchResult.client";
import { headers } from "next/headers";
import { actionGetFoldersByUserId } from "@/modules/folder/folder-aciton";
import { TSharedFolder } from "@/shared/folder-type";
interface SearchResultProps {
searchResult: TSharedItem | null;
searchQuery: string;
queryLang: string;
definitionLang: string;
}
export async function SearchResult({
searchResult,
searchQuery,
queryLang,
definitionLang
}: SearchResultProps) {
// 获取用户会话和文件夹
const session = await auth.api.getSession({ headers: await headers() });
let folders: TSharedFolder[] = [];
if (session?.user?.id) {
const result = await actionGetFoldersByUserId(session.user.id as string);
if (result.success && result.data) {
folders = result.data;
}
}
return (
<div className="space-y-6">
{!searchResult ? (
<div className="text-center py-12 bg-white/20 rounded-lg">
<p className="text-gray-800 text-xl">No results found</p>
<p className="text-gray-600 mt-2">Try other words</p>
</div>
) : (
<div className="bg-white rounded-lg p-6 shadow-lg">
{/* 标题和保存按钮 */}
<div className="flex items-start justify-between mb-6">
<div className="flex-1">
<h2 className="text-3xl font-bold text-gray-800 mb-2">
{searchResult.standardForm}
</h2>
</div>
<div className="flex items-center gap-2 ml-4">
{session && folders.length > 0 && (
<select
id="folder-select"
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-[#35786f]"
>
{folders.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.name}
</option>
))}
</select>
)}
<SaveButtonClient
session={session}
folders={folders}
searchResult={searchResult}
queryLang={queryLang}
definitionLang={definitionLang}
/>
</div>
</div>
{/* 条目列表 */}
<div className="space-y-6">
{searchResult.entries.map((entry, index) => (
<div key={index} className="border-t border-gray-200 pt-4">
<DictionaryEntry entry={entry} />
</div>
))}
</div>
{/* 重新查询按钮 */}
<div className="border-t border-gray-200 pt-4 mt-4">
<ReLookupButtonClient
searchQuery={searchQuery}
queryLang={queryLang}
definitionLang={definitionLang}
/>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,75 +1,20 @@
import { PageLayout } from "@/components/ui/PageLayout"; import { DictionaryClient } from "./DictionaryClient";
import { SearchForm } from "./SearchForm"; import { auth } from "@/auth";
import { SearchResult } from "./SearchResult"; import { headers } from "next/headers";
import { getTranslations } from "next-intl/server"; import { actionGetFoldersByUserId } from "@/modules/folder/folder-aciton";
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action"; import { TSharedFolder } from "@/shared/folder-type";
import { TSharedItem } from "@/shared/dictionary-type";
interface DictionaryPageProps { export default async function DictionaryPage() {
searchParams: Promise<{ q?: string; ql?: string; dl?: string; }>; const session = await auth.api.getSession({ headers: await headers() });
}
export default async function DictionaryPage({ searchParams }: DictionaryPageProps) { let folders: TSharedFolder[] = [];
const t = await getTranslations("dictionary");
// 从 searchParams 获取搜索参数 if (session?.user?.id) {
const { q: searchQuery, ql: queryLang = "english", dl: definitionLang = "chinese" } = await searchParams; const result = await actionGetFoldersByUserId(session.user.id as string);
if (result.success && result.data) {
// 如果有搜索查询,获取搜索结果 folders = result.data;
let searchResult: TSharedItem | undefined | null = null;
if (searchQuery) {
const getNativeName = (code: string): string => {
const popularLanguages: Record<string, string> = {
english: "English",
chinese: "中文",
japanese: "日本語",
korean: "한국어",
italian: "Italiano",
uyghur: "ئۇيغۇرچە",
};
return popularLanguages[code] || code;
};
const result = await actionLookUpDictionary({
text: searchQuery,
queryLang: getNativeName(queryLang),
definitionLang: getNativeName(definitionLang),
forceRelook: false
});
if (result.success && result.data) {
searchResult = result.data;
}
} }
}
return ( return <DictionaryClient initialFolders={folders} />;
<PageLayout>
{/* 搜索区域 */}
<div className="mb-8">
<SearchForm
defaultQueryLang={queryLang}
defaultDefinitionLang={definitionLang}
/>
</div>
{/* 搜索结果区域 */}
<div>
{searchQuery && (
<SearchResult
searchResult={searchResult}
searchQuery={searchQuery}
queryLang={queryLang}
definitionLang={definitionLang}
/>
)}
{!searchQuery && (
<div className="text-center py-12">
<div className="text-6xl mb-4">📚</div>
<p className="text-gray-800 text-xl mb-2">{t("welcomeTitle")}</p>
<p className="text-gray-600">{t("welcomeHint")}</p>
</div>
)}
</div>
</PageLayout>
);
} }

View File

@@ -0,0 +1,148 @@
"use client";
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { TSharedItem } from "@/shared/dictionary-type";
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
import { toast } from "sonner";
const POPULAR_LANGUAGES_MAP: Record<string, string> = {
english: "English",
chinese: "中文",
japanese: "日本語",
korean: "한국어",
italian: "Italiano",
uyghur: "ئۇيغۇرچە",
};
export function getNativeName(code: string): string {
return POPULAR_LANGUAGES_MAP[code] || code;
}
export interface DictionaryState {
query: string;
queryLang: string;
definitionLang: string;
searchResult: TSharedItem | null;
isSearching: boolean;
}
export interface DictionaryActions {
setQuery: (query: string) => void;
setQueryLang: (lang: string) => void;
setDefinitionLang: (lang: string) => void;
setSearchResult: (result: TSharedItem | null) => void;
search: () => Promise<void>;
relookup: () => Promise<void>;
syncFromUrl: (params: { q?: string; ql?: string; dl?: string }) => void;
}
export type DictionaryStore = DictionaryState & DictionaryActions;
const initialState: DictionaryState = {
query: "",
queryLang: "english",
definitionLang: "chinese",
searchResult: null,
isSearching: false,
};
export const useDictionaryStore = create<DictionaryStore>()(
devtools(
(set, get) => ({
...initialState,
setQuery: (query) => set({ query }),
setQueryLang: (queryLang) => set({ queryLang }),
setDefinitionLang: (definitionLang) => set({ definitionLang }),
setSearchResult: (searchResult) => set({ searchResult }),
search: async () => {
const { query, queryLang, definitionLang } = get();
if (!query.trim()) {
return;
}
set({ isSearching: true });
try {
const result = await actionLookUpDictionary({
text: query,
queryLang: getNativeName(queryLang),
definitionLang: getNativeName(definitionLang),
forceRelook: false,
});
if (result.success && result.data) {
set({ searchResult: result.data });
} else {
set({ searchResult: null });
if (result.message) {
toast.error(result.message);
}
}
} catch (error) {
set({ searchResult: null });
toast.error("Search failed");
} finally {
set({ isSearching: false });
}
},
relookup: async () => {
const { query, queryLang, definitionLang } = get();
if (!query.trim()) {
return;
}
set({ isSearching: true });
try {
const result = await actionLookUpDictionary({
text: query,
queryLang: getNativeName(queryLang),
definitionLang: getNativeName(definitionLang),
forceRelook: true,
});
if (result.success && result.data) {
set({ searchResult: result.data });
toast.success("Re-lookup successful");
} else {
if (result.message) {
toast.error(result.message);
}
}
} catch (error) {
toast.error("Re-lookup failed");
} finally {
set({ isSearching: false });
}
},
syncFromUrl: (params) => {
const updates: Partial<DictionaryState> = {};
if (params.q !== undefined) {
updates.query = params.q;
}
if (params.ql !== undefined) {
updates.queryLang = params.ql;
}
if (params.dl !== undefined) {
updates.definitionLang = params.dl;
}
if (Object.keys(updates).length > 0) {
set(updates);
}
},
}),
{ name: 'dictionary-store' }
)
);

View File

@@ -4,6 +4,7 @@ import {
Folder as Fd, Folder as Fd,
Heart, Heart,
Search, Search,
ArrowUpDown,
} from "lucide-react"; } from "lucide-react";
import { CircleButton } from "@/design-system/base/button"; import { CircleButton } from "@/design-system/base/button";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -61,40 +62,38 @@ const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFol
return ( return (
<div <div
className="group bg-white border-2 border-gray-200 rounded-lg p-5 hover:border-primary-300 hover:shadow-md cursor-pointer transition-all" className="group bg-white border border-gray-200 sm:border-2 rounded-lg p-3 sm:p-5 hover:border-primary-300 hover:shadow-md cursor-pointer transition-all overflow-hidden"
onClick={() => { onClick={() => {
router.push(`/explore/${folder.id}`); router.push(`/explore/${folder.id}`);
}} }}
> >
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-2 sm:mb-3">
<div className="shrink-0 w-10 h-10 rounded-lg bg-primary-50 flex items-center justify-center text-primary-500"> <div className="shrink-0 w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-primary-50 flex items-center justify-center text-primary-500">
<Fd size={22} /> <Fd size={18} className="sm:hidden" />
<Fd size={22} className="hidden sm:block" />
</div> </div>
<CircleButton <CircleButton
onClick={handleToggleFavorite} onClick={handleToggleFavorite}
title={isFavorited ? t("unfavorite") : t("favorite")} title={isFavorited ? t("unfavorite") : t("favorite")}
> >
<Heart <Heart
size={18} size={16}
className={isFavorited ? "fill-red-500 text-red-500" : ""} className={`sm:w-[18px] sm:h-[18px] sm:text-[18px] ${isFavorited ? "fill-red-500 text-red-500" : ""}`}
/> />
</CircleButton> </CircleButton>
</div> </div>
<h3 className="font-semibold text-gray-900 truncate mb-2">{folder.name}</h3> <h3 className="font-semibold text-gray-900 truncate text-sm sm:text-base mb-1 sm:mb-2">{folder.name}</h3>
<p className="text-sm text-gray-500 mb-3"> <p className="text-xs sm:text-sm text-gray-500 mb-2 sm:mb-3 line-clamp-2">
{t("folderInfo", { {t("folderInfo", {
userName: folder.userName ?? folder.userUsername ?? t("unknownUser"), userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
totalPairs: folder.totalPairs, totalPairs: folder.totalPairs,
})} })}
</p> </p>
<div className="flex items-center gap-1 text-sm text-gray-400"> <div className="flex items-center gap-1 text-xs sm:text-sm text-gray-400">
<Heart <Heart size={12} className="sm:w-3.5 sm:h-3.5" />
size={14}
className={isFavorited ? "fill-red-500 text-red-500" : ""}
/>
<span>{favoriteCount}</span> <span>{favoriteCount}</span>
</div> </div>
</div> </div>
@@ -111,6 +110,7 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders); const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [sortByFavorites, setSortByFavorites] = useState(false);
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
const currentUserId = session?.user?.id; const currentUserId = session?.user?.id;
@@ -128,6 +128,14 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
setLoading(false); setLoading(false);
}; };
const handleToggleSort = () => {
setSortByFavorites((prev) => !prev);
};
const sortedFolders = sortByFavorites
? [...publicFolders].sort((a, b) => b.favoriteCount - a.favoriteCount)
: publicFolders;
const handleUpdateFavorite = (folderId: number, _isFavorited: boolean, favoriteCount: number) => { const handleUpdateFavorite = (folderId: number, _isFavorited: boolean, favoriteCount: number) => {
setPublicFolders((prev) => setPublicFolders((prev) =>
prev.map((f) => prev.map((f) =>
@@ -152,6 +160,13 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/> />
</div> </div>
<CircleButton
onClick={handleToggleSort}
title={sortByFavorites ? t("sortByFavoritesActive") : t("sortByFavorites")}
className={sortByFavorites ? "bg-primary-100 text-primary-600 hover:bg-primary-200" : ""}
>
<ArrowUpDown size={18} />
</CircleButton>
<CircleButton onClick={handleSearch}> <CircleButton onClick={handleSearch}>
<Search size={18} /> <Search size={18} />
</CircleButton> </CircleButton>
@@ -162,7 +177,7 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div> <div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-sm text-gray-500">{t("loading")}</p> <p className="text-sm text-gray-500">{t("loading")}</p>
</div> </div>
) : publicFolders.length === 0 ? ( ) : sortedFolders.length === 0 ? (
<div className="text-center py-12 text-gray-400"> <div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center"> <div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
<Fd size={24} className="text-gray-400" /> <Fd size={24} className="text-gray-400" />
@@ -171,7 +186,7 @@ export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{publicFolders.map((folder) => ( {sortedFolders.map((folder) => (
<PublicFolderCard <PublicFolderCard
key={folder.id} key={folder.id}
folder={folder} folder={folder}

View File

@@ -115,6 +115,8 @@ export default function SrtPlayerPage() {
key={i} key={i}
href={`/dictionary?q=${s}`} href={`/dictionary?q=${s}`}
className="px-1 h-fit hover:bg-gray-200 hover:cursor-pointer" className="px-1 h-fit hover:bg-gray-200 hover:cursor-pointer"
target="_blank"
rel="noopener noreferrer"
> >
{s} {s}
</Link> </Link>

View File

@@ -143,10 +143,6 @@ export function FoldersClient({ userId }: FoldersClientProps) {
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]); const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => {
loadFolders();
}, [userId]);
const loadFolders = async () => { const loadFolders = async () => {
setLoading(true); setLoading(true);
const result = await actionGetFoldersWithTotalPairsByUserId(userId); const result = await actionGetFoldersWithTotalPairsByUserId(userId);
@@ -156,6 +152,10 @@ export function FoldersClient({ userId }: FoldersClientProps) {
setLoading(false); setLoading(false);
}; };
useEffect(() => {
loadFolders();
}, [userId]);
const handleUpdateFolder = (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => { const handleUpdateFolder = (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => {
setFolders((prev) => setFolders((prev) =>
prev.map((f) => (f.id === folderId ? { ...f, ...updates } : f)) prev.map((f) => (f.id === folderId ? { ...f, ...updates } : f))

View File

@@ -19,12 +19,12 @@ export async function executeDictionaryLookup(
if (!analysis.isValid) { if (!analysis.isValid) {
log.debug("[Stage 1] Invalid input", { reason: analysis.reason }); log.debug("[Stage 1] Invalid input", { reason: analysis.reason });
throw analysis.reason || "无效输入"; throw new LookUpError(analysis.reason || "无效输入");
} }
if (analysis.isEmpty) { if (analysis.isEmpty) {
log.debug("[Stage 1] Empty input"); log.debug("[Stage 1] Empty input");
throw "输入为空"; throw new LookUpError("输入为空");
} }
log.debug("[Stage 1] Analysis complete", { analysis }); log.debug("[Stage 1] Analysis complete", { analysis });
@@ -33,7 +33,7 @@ export async function executeDictionaryLookup(
const semanticMapping = await determineSemanticMapping( const semanticMapping = await determineSemanticMapping(
text, text,
queryLang, queryLang,
analysis.inputLanguage || text analysis.inputLanguage ?? text
); );
log.debug("[Stage 2] Semantic mapping complete", { semanticMapping }); log.debug("[Stage 2] Semantic mapping complete", { semanticMapping });

View File

@@ -74,7 +74,7 @@ b) 输入是明确、基础、可词典化的语义概念
role: "user", role: "user",
content: prompt, content: prompt,
}, },
]).then(parseAIGeneratedJSON<any>); ]).then(parseAIGeneratedJSON<SemanticMappingResult>);
// 代码层面的数据验证 // 代码层面的数据验证
if (typeof result.shouldMap !== "boolean") { if (typeof result.shouldMap !== "boolean") {

View File

@@ -66,7 +66,7 @@ ${originalInput ? `
role: "user", role: "user",
content: prompt, content: prompt,
}, },
]).then(parseAIGeneratedJSON<any>); ]).then(parseAIGeneratedJSON<StandardFormResult>);
// 代码层面的数据验证 // 代码层面的数据验证
if (!result.standardForm || result.standardForm.trim().length === 0) { if (!result.standardForm || result.standardForm.trim().length === 0) {

View File

@@ -98,10 +98,6 @@ ${isWord ? `
if (isWord && !entry.partOfSpeech) { if (isWord && !entry.partOfSpeech) {
throw new Error("阶段4单词条目缺少 partOfSpeech"); throw new Error("阶段4单词条目缺少 partOfSpeech");
} }
if (isWord && !entry.ipa) {
throw new Error("阶段4单词条目缺少 ipa");
}
} }
return result; return result;

View File

@@ -0,0 +1,108 @@
import { Visibility } from "../../../generated/prisma/enums";
export type ServiceInputCreateFolder = {
name: string;
userId: string;
};
export type ServiceInputRenameFolder = {
folderId: number;
newName: string;
};
export type ServiceInputDeleteFolder = {
folderId: number;
};
export type ServiceInputSetVisibility = {
folderId: number;
visibility: Visibility;
};
export type ServiceInputCheckOwnership = {
folderId: number;
userId: string;
};
export type ServiceInputCheckPairOwnership = {
pairId: number;
userId: string;
};
export type ServiceInputCreatePair = {
folderId: number;
text1: string;
text2: string;
language1: string;
language2: string;
};
export type ServiceInputUpdatePair = {
pairId: number;
text1?: string;
text2?: string;
language1?: string;
language2?: string;
};
export type ServiceInputDeletePair = {
pairId: number;
};
export type ServiceInputGetPublicFolders = {
limit?: number;
offset?: number;
};
export type ServiceInputSearchPublicFolders = {
query: string;
limit?: number;
};
export type ServiceInputToggleFavorite = {
folderId: number;
userId: string;
};
export type ServiceInputCheckFavorite = {
folderId: number;
userId: string;
};
export type ServiceInputGetUserFavorites = {
userId: string;
limit?: number;
offset?: number;
};
export type ServiceOutputFolder = {
id: number;
name: string;
visibility: Visibility;
createdAt: Date;
userId: string;
};
export type ServiceOutputFolderWithDetails = ServiceOutputFolder & {
userName: string | null;
userUsername: string | null;
totalPairs: number;
favoriteCount: number;
};
export type ServiceOutputFavoriteStatus = {
isFavorited: boolean;
favoriteCount: number;
};
export type ServiceOutputUserFavorite = {
id: number;
folderId: number;
folderName: string;
folderCreatedAt: Date;
folderTotalPairs: number;
folderOwnerId: string;
folderOwnerName: string | null;
folderOwnerUsername: string | null;
favoritedAt: Date;
};