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:
@@ -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",
|
||||||
|
|||||||
@@ -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": "收藏",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
239
src/app/(features)/dictionary/DictionaryClient.tsx
Normal file
239
src/app/(features)/dictionary/DictionaryClient.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/app/(features)/dictionary/LanguageSelector.tsx
Normal file
80
src/app/(features)/dictionary/LanguageSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
148
src/app/(features)/dictionary/stores/dictionaryStore.ts
Normal file
148
src/app/(features)/dictionary/stores/dictionaryStore.ts
Normal 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' }
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user