Compare commits
2 Commits
main
...
5898a6ba65
| Author | SHA1 | Date | |
|---|---|---|---|
| 5898a6ba65 | |||
| abcae1b8d1 |
@@ -193,14 +193,20 @@
|
||||
"sign_in": "Anmelden",
|
||||
"profile": "Profil",
|
||||
"folders": "Ordner",
|
||||
"explore": "Entdecken",
|
||||
"favorites": "Favoriten"
|
||||
"explore": "Erkunden",
|
||||
"favorites": "Favoriten",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "Mein Profil",
|
||||
"email": "E-Mail: {email}",
|
||||
"logout": "Abmelden"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"themeColor": "Designfarbe",
|
||||
"themeColorDescription": "Wählen Sie Ihre bevorzugte Designfarbe"
|
||||
},
|
||||
"srt_player": {
|
||||
"uploadVideo": "Video hochladen",
|
||||
"uploadSubtitle": "Untertitel hochladen",
|
||||
@@ -239,6 +245,8 @@
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "Sprache erkennen",
|
||||
"sourceLanguage": "Quellsprache",
|
||||
"auto": "Automatisch",
|
||||
"generateIPA": "IPA generieren",
|
||||
"translateInto": "übersetzen in",
|
||||
"chinese": "Chinesisch",
|
||||
|
||||
@@ -194,13 +194,19 @@
|
||||
"profile": "Profile",
|
||||
"folders": "Folders",
|
||||
"explore": "Explore",
|
||||
"favorites": "Favorites"
|
||||
"favorites": "Favorites",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "My Profile",
|
||||
"email": "Email: {email}",
|
||||
"logout": "Logout"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"themeColor": "Theme Color",
|
||||
"themeColorDescription": "Choose your preferred theme color"
|
||||
},
|
||||
"srt_player": {
|
||||
"uploadVideo": "Upload Video",
|
||||
"uploadSubtitle": "Upload Subtitle",
|
||||
@@ -239,6 +245,8 @@
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "detect language",
|
||||
"sourceLanguage": "source language",
|
||||
"auto": "Auto",
|
||||
"generateIPA": "generate ipa",
|
||||
"translateInto": "translate into",
|
||||
"chinese": "Chinese",
|
||||
@@ -346,6 +354,7 @@
|
||||
"displayName": "Display Name",
|
||||
"notSet": "Not Set",
|
||||
"memberSince": "Member Since",
|
||||
"joined": "Joined",
|
||||
"logout": "Logout",
|
||||
"folders": {
|
||||
"title": "Folders",
|
||||
@@ -356,5 +365,14 @@
|
||||
"actions": "Actions",
|
||||
"view": "View"
|
||||
}
|
||||
},
|
||||
"follow": {
|
||||
"follow": "Follow",
|
||||
"following": "Following",
|
||||
"followers": "Followers",
|
||||
"followersOf": "{username}'s Followers",
|
||||
"followingOf": "{username}'s Following",
|
||||
"noFollowers": "No followers yet",
|
||||
"noFollowing": "Not following anyone yet"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,19 +188,25 @@
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"title": "apprendre-langues",
|
||||
"title": "learn-languages",
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "Se connecter",
|
||||
"sign_in": "Connexion",
|
||||
"profile": "Profil",
|
||||
"folders": "Dossiers",
|
||||
"explore": "Explorer",
|
||||
"favorites": "Favoris"
|
||||
"favorites": "Favoris",
|
||||
"settings": "Paramètres"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "Mon profil",
|
||||
"email": "E-mail : {email}",
|
||||
"logout": "Déconnexion"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"themeColor": "Couleur du thème",
|
||||
"themeColorDescription": "Choisissez votre couleur de thème préférée"
|
||||
},
|
||||
"srt_player": {
|
||||
"uploadVideo": "Télécharger la vidéo",
|
||||
"uploadSubtitle": "Télécharger les sous-titres",
|
||||
@@ -239,6 +245,8 @@
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "détecter la langue",
|
||||
"sourceLanguage": "langue source",
|
||||
"auto": "Auto",
|
||||
"generateIPA": "générer l'api",
|
||||
"translateInto": "traduire en",
|
||||
"chinese": "Chinois",
|
||||
|
||||
@@ -188,19 +188,25 @@
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"title": "impara-lingue",
|
||||
"title": "learn-languages",
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "Accedi",
|
||||
"profile": "Profilo",
|
||||
"folders": "Cartelle",
|
||||
"explore": "Esplora",
|
||||
"favorites": "Preferiti"
|
||||
"favorites": "Preferiti",
|
||||
"settings": "Impostazioni"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "Il Mio Profilo",
|
||||
"email": "Email: {email}",
|
||||
"logout": "Esci"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni",
|
||||
"themeColor": "Colore del tema",
|
||||
"themeColorDescription": "Scegli il tuo colore del tema preferito"
|
||||
},
|
||||
"srt_player": {
|
||||
"uploadVideo": "Carica Video",
|
||||
"uploadSubtitle": "Carica Sottotitoli",
|
||||
@@ -239,6 +245,8 @@
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "rileva lingua",
|
||||
"sourceLanguage": "lingua di origine",
|
||||
"auto": "Auto",
|
||||
"generateIPA": "genera ipa",
|
||||
"translateInto": "traduci in",
|
||||
"chinese": "Cinese",
|
||||
|
||||
@@ -194,13 +194,19 @@
|
||||
"profile": "プロフィール",
|
||||
"folders": "フォルダー",
|
||||
"explore": "探索",
|
||||
"favorites": "お気に入り"
|
||||
"favorites": "お気に入り",
|
||||
"settings": "設定"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "マイプロフィール",
|
||||
"email": "メール: {email}",
|
||||
"logout": "ログアウト"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
"themeColor": "テーマカラー",
|
||||
"themeColorDescription": "お好みのテーマカラーを選択してください"
|
||||
},
|
||||
"srt_player": {
|
||||
"uploadVideo": "ビデオをアップロード",
|
||||
"uploadSubtitle": "字幕をアップロード",
|
||||
@@ -239,6 +245,8 @@
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "言語を検出",
|
||||
"sourceLanguage": "ソース言語",
|
||||
"auto": "自動",
|
||||
"generateIPA": "ipaを生成",
|
||||
"translateInto": "翻訳先",
|
||||
"chinese": "中国語",
|
||||
|
||||
@@ -194,13 +194,19 @@
|
||||
"profile": "프로필",
|
||||
"folders": "폴더",
|
||||
"explore": "탐색",
|
||||
"favorites": "즐겨찾기"
|
||||
"favorites": "즐겨찾기",
|
||||
"settings": "설정"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "내 프로필",
|
||||
"email": "이메일: {email}",
|
||||
"logout": "로그아웃"
|
||||
},
|
||||
"settings": {
|
||||
"title": "설정",
|
||||
"themeColor": "테마 색상",
|
||||
"themeColorDescription": "원하는 테마 색상을 선택하세요"
|
||||
},
|
||||
"srt_player": {
|
||||
"uploadVideo": "비디오 업로드",
|
||||
"uploadSubtitle": "자막 업로드",
|
||||
@@ -239,6 +245,8 @@
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "언어 감지",
|
||||
"sourceLanguage": "원본 언어",
|
||||
"auto": "자동",
|
||||
"generateIPA": "IPA 생성",
|
||||
"translateInto": "번역할 언어",
|
||||
"chinese": "중국어",
|
||||
|
||||
@@ -188,19 +188,25 @@
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"title": "تىل-ئۆگىنىش",
|
||||
"title": "learn-languages",
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "كىرىش",
|
||||
"profile": "شەخسىي ئۇچۇر",
|
||||
"folders": "قىسقۇچلار",
|
||||
"explore": "ئىزدىنىش",
|
||||
"favorites": "يىغىپ ساقلانغانلار"
|
||||
"favorites": "يىغىپ ساقلاش",
|
||||
"settings": "تەڭشەكلەر"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "شەخسىي ئۇچۇرۇم",
|
||||
"email": "ئېلخەت: {email}",
|
||||
"logout": "چىكىنىش"
|
||||
},
|
||||
"settings": {
|
||||
"title": "تەڭشەكلەر",
|
||||
"themeColor": "تېما رەڭگى",
|
||||
"themeColorDescription": "ياقتۇرىدىغان تېما رەڭگىڭىزنى تاللاڭ"
|
||||
},
|
||||
"srt_player": {
|
||||
"uploadVideo": "ۋىدېئو يۈكلەش",
|
||||
"uploadSubtitle": "تر پودكاست يۈكلەش",
|
||||
@@ -239,6 +245,8 @@
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "تىلنى تونۇش",
|
||||
"sourceLanguage": "مەنبە تىلى",
|
||||
"auto": "ئاپتوماتىك",
|
||||
"generateIPA": "ipa ھاسىل قىلىش",
|
||||
"translateInto": "تەرجىمە قىلىش",
|
||||
"chinese": "خەنزۇچە",
|
||||
|
||||
@@ -194,13 +194,19 @@
|
||||
"profile": "个人资料",
|
||||
"folders": "文件夹",
|
||||
"explore": "探索",
|
||||
"favorites": "收藏"
|
||||
"favorites": "收藏",
|
||||
"settings": "设置"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "我的个人资料",
|
||||
"email": "邮箱:{email}",
|
||||
"logout": "退出登录"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"themeColor": "主题色",
|
||||
"themeColorDescription": "选择您喜欢的主题色"
|
||||
},
|
||||
"srt_player": {
|
||||
"upload": "上传",
|
||||
"uploadVideo": "上传视频",
|
||||
@@ -239,6 +245,8 @@
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "检测语言",
|
||||
"sourceLanguage": "源语言",
|
||||
"auto": "自动",
|
||||
"generateIPA": "生成国际音标",
|
||||
"translateInto": "翻译为",
|
||||
"chinese": "中文",
|
||||
@@ -346,6 +354,7 @@
|
||||
"displayName": "显示名称",
|
||||
"notSet": "未设置",
|
||||
"memberSince": "注册时间",
|
||||
"joined": "加入于",
|
||||
"logout": "登出",
|
||||
"folders": {
|
||||
"title": "文件夹",
|
||||
@@ -356,5 +365,14 @@
|
||||
"actions": "操作",
|
||||
"view": "查看"
|
||||
}
|
||||
},
|
||||
"follow": {
|
||||
"follow": "关注",
|
||||
"following": "已关注",
|
||||
"followers": "粉丝",
|
||||
"followersOf": "{username} 的粉丝",
|
||||
"followingOf": "{username} 的关注",
|
||||
"noFollowers": "还没有粉丝",
|
||||
"noFollowing": "还没有关注任何人"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,15 @@ model User {
|
||||
updatedAt DateTime @updatedAt
|
||||
displayUsername String?
|
||||
username String @unique
|
||||
bio String?
|
||||
accounts Account[]
|
||||
dictionaryLookUps DictionaryLookUp[]
|
||||
folders Folder[]
|
||||
folderFavorites FolderFavorite[]
|
||||
sessions Session[]
|
||||
translationHistories TranslationHistory[]
|
||||
followers Follow[] @relation("UserFollowers")
|
||||
following Follow[] @relation("UserFollowing")
|
||||
|
||||
@@map("user")
|
||||
}
|
||||
@@ -198,3 +201,18 @@ model TranslationHistory {
|
||||
@@index([translatedText, sourceLanguage, targetLanguage])
|
||||
@@map("translation_history")
|
||||
}
|
||||
|
||||
model Follow {
|
||||
id String @id @default(cuid())
|
||||
followerId String @map("follower_id")
|
||||
followingId String @map("following_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
follower User @relation("UserFollowers", fields: [followerId], references: [id], onDelete: Cascade)
|
||||
following User @relation("UserFollowing", fields: [followingId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([followerId, followingId])
|
||||
@@index([followerId])
|
||||
@@index([followingId])
|
||||
@@map("follows")
|
||||
}
|
||||
|
||||
44
src/app/(auth)/users/[username]/followers/page.tsx
Normal file
44
src/app/(auth)/users/[username]/followers/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { UserList } from "@/components/follow/UserList";
|
||||
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
|
||||
import { actionGetFollowers } from "@/modules/follow/follow-action";
|
||||
|
||||
interface FollowersPageProps {
|
||||
params: Promise<{ username: string }>;
|
||||
}
|
||||
|
||||
export default async function FollowersPage({ params }: FollowersPageProps) {
|
||||
const { username } = await params;
|
||||
const t = await getTranslations("follow");
|
||||
|
||||
const userResult = await actionGetUserProfileByUsername({ username });
|
||||
|
||||
if (!userResult.success || !userResult.data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const user = userResult.data;
|
||||
|
||||
const followersResult = await actionGetFollowers({
|
||||
userId: user.id,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const followers = followersResult.success && followersResult.data
|
||||
? followersResult.data.followers.map((f) => f.user)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
||||
{t("followersOf", { username: user.displayUsername || user.username || "User" })}
|
||||
</h1>
|
||||
<UserList users={followers} emptyMessage={t("noFollowers")} />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
44
src/app/(auth)/users/[username]/following/page.tsx
Normal file
44
src/app/(auth)/users/[username]/following/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { UserList } from "@/components/follow/UserList";
|
||||
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
|
||||
import { actionGetFollowing } from "@/modules/follow/follow-action";
|
||||
|
||||
interface FollowingPageProps {
|
||||
params: Promise<{ username: string }>;
|
||||
}
|
||||
|
||||
export default async function FollowingPage({ params }: FollowingPageProps) {
|
||||
const { username } = await params;
|
||||
const t = await getTranslations("follow");
|
||||
|
||||
const userResult = await actionGetUserProfileByUsername({ username });
|
||||
|
||||
if (!userResult.success || !userResult.data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const user = userResult.data;
|
||||
|
||||
const followingResult = await actionGetFollowing({
|
||||
userId: user.id,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const following = followingResult.success && followingResult.data
|
||||
? followingResult.data.following.map((f) => f.user)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
||||
{t("followingOf", { username: user.displayUsername || user.username || "User" })}
|
||||
</h1>
|
||||
<UserList users={following} emptyMessage={t("noFollowing")} />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { LightButton, LinkButton } from "@/design-system/base/button";
|
||||
import { LinkButton } from "@/design-system/base/button";
|
||||
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
|
||||
import { repoGetFoldersWithTotalPairsByUserId } from "@/modules/folder/folder-repository";
|
||||
import { actionGetFollowStatus } from "@/modules/follow/follow-action";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
// import { LogoutButton } from "./LogoutButton";
|
||||
import { FollowStats } from "@/components/follow/FollowStats";
|
||||
|
||||
interface UserPageProps {
|
||||
params: Promise<{ username: string; }>;
|
||||
@@ -18,10 +19,8 @@ export default async function UserPage({ params }: UserPageProps) {
|
||||
const { username } = await params;
|
||||
const t = await getTranslations("user_profile");
|
||||
|
||||
// Get current session
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
// Get user profile
|
||||
const result = await actionGetUserProfileByUsername({ username });
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
@@ -30,150 +29,163 @@ export default async function UserPage({ params }: UserPageProps) {
|
||||
|
||||
const user = result.data;
|
||||
|
||||
// Get user's folders
|
||||
const folders = await repoGetFoldersWithTotalPairsByUserId(user.id);
|
||||
const [folders, followStatus] = await Promise.all([
|
||||
repoGetFoldersWithTotalPairsByUserId(user.id),
|
||||
actionGetFollowStatus({ targetUserId: user.id }),
|
||||
]);
|
||||
|
||||
// Check if viewing own profile
|
||||
const isOwnProfile = session?.user?.username === username || session?.user?.email === username;
|
||||
|
||||
const followersCount = followStatus.success && followStatus.data ? followStatus.data.followersCount : 0;
|
||||
const followingCount = followStatus.success && followStatus.data ? followStatus.data.followingCount : 0;
|
||||
const isFollowing = followStatus.success && followStatus.data ? followStatus.data.isFollowing : false;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div></div>
|
||||
{isOwnProfile && <LinkButton href="/logout">{t("logout")}</LinkButton>}
|
||||
</div>
|
||||
<div className="flex items-center space-x-6">
|
||||
{/* Avatar */}
|
||||
{user.image ? (
|
||||
<div className="relative w-24 h-24 rounded-full border-4 border-primary-500 overflow-hidden">
|
||||
<Image
|
||||
src={user.image}
|
||||
alt={user.displayUsername || user.username || user.email}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-primary-500 border-4 border-primary-500 flex items-center justify-center">
|
||||
<span className="text-3xl font-bold text-white">
|
||||
{(user.displayUsername || user.username || user.email)[0].toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Info */}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
{user.displayUsername || user.username || t("anonymous")}
|
||||
</h1>
|
||||
{user.username && (
|
||||
<p className="text-gray-600 text-sm mb-1">
|
||||
@{user.username}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-gray-600 text-sm mb-1">
|
||||
{user.email}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4 text-sm">
|
||||
<span className="text-gray-500">
|
||||
Joined: {new Date(user.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
{user.emailVerified && (
|
||||
<span className="flex items-center text-green-600">
|
||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.293 12.293a1 1 0 101.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Verified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div></div>
|
||||
{isOwnProfile && <LinkButton href="/logout">{t("logout")}</LinkButton>}
|
||||
</div>
|
||||
|
||||
{/* Account Info */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("accountInfo")}</h2>
|
||||
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">{t("userId")}</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 font-mono break-all">{user.id}</dd>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center space-y-4 sm:space-y-0 sm:space-x-6">
|
||||
{user.image ? (
|
||||
<div className="relative w-24 h-24 rounded-full border-4 border-primary-500 overflow-hidden flex-shrink-0">
|
||||
<Image
|
||||
src={user.image}
|
||||
alt={user.displayUsername || user.username || user.email}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">{t("username")}</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{user.username || <span className="text-gray-400">{t("notSet")}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">{t("displayName")}</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{user.displayUsername || <span className="text-gray-400">{t("notSet")}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">{t("memberSince")}</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Folders Section */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("folders.title")}</h2>
|
||||
{folders.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">{t("folders.noFolders")}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t("folders.folderName")}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t("folders.totalPairs")}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t("folders.createdAt")}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t("folders.actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{folders.map((folder) => (
|
||||
<tr key={folder.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{folder.name}</div>
|
||||
<div className="text-sm text-gray-500">ID: {folder.id}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{folder.total}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(folder.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link href={`/folders/${folder.id}`}>
|
||||
<LinkButton>
|
||||
{t("folders.view")}
|
||||
</LinkButton>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="w-24 h-24 rounded-full bg-primary-500 border-4 border-primary-500 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-3xl font-bold text-white">
|
||||
{(user.displayUsername || user.username || user.email)[0].toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
{user.displayUsername || user.username || t("anonymous")}
|
||||
</h1>
|
||||
{user.username && (
|
||||
<p className="text-gray-600 text-sm mb-1">
|
||||
@{user.username}
|
||||
</p>
|
||||
)}
|
||||
{user.bio && (
|
||||
<p className="text-gray-700 mt-2 mb-2">
|
||||
{user.bio}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm mt-3">
|
||||
<span className="text-gray-500">
|
||||
{t("joined")}: {new Date(user.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
{user.emailVerified && (
|
||||
<span className="flex items-center text-green-600">
|
||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.293 12.293a1 1 0 101.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t("verified")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<FollowStats
|
||||
userId={user.id}
|
||||
initialFollowersCount={followersCount}
|
||||
initialFollowingCount={followingCount}
|
||||
initialIsFollowing={isFollowing}
|
||||
currentUserId={session?.user?.id}
|
||||
isOwnProfile={isOwnProfile}
|
||||
username={user.username || user.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("accountInfo")}</h2>
|
||||
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">{t("userId")}</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 font-mono break-all">{user.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">{t("username")}</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{user.username || <span className="text-gray-400">{t("notSet")}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">{t("displayName")}</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{user.displayUsername || <span className="text-gray-400">{t("notSet")}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">{t("memberSince")}</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("folders.title")}</h2>
|
||||
{folders.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">{t("folders.noFolders")}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t("folders.folderName")}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t("folders.totalPairs")}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t("folders.createdAt")}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t("folders.actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{folders.map((folder) => (
|
||||
<tr key={folder.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{folder.name}</div>
|
||||
<div className="text-sm text-gray-500">ID: {folder.id}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{folder.total}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(folder.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link href={`/folders/${folder.id}`}>
|
||||
<LinkButton>
|
||||
{t("folders.view")}
|
||||
</LinkButton>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button";
|
||||
import { Select } from "@/design-system/base/select";
|
||||
import { IMAGES } from "@/config/images";
|
||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -10,16 +11,45 @@ import { toast } from "sonner";
|
||||
import { getTTSUrl, TTS_SUPPORTED_LANGUAGES } from "@/lib/bigmodel/tts";
|
||||
import { TSharedTranslationResult } from "@/shared/translator-type";
|
||||
|
||||
const SOURCE_LANGUAGES = [
|
||||
{ value: "Auto", labelKey: "auto" },
|
||||
{ value: "Chinese", labelKey: "chinese" },
|
||||
{ value: "English", labelKey: "english" },
|
||||
{ value: "Japanese", labelKey: "japanese" },
|
||||
{ value: "Korean", labelKey: "korean" },
|
||||
{ value: "French", labelKey: "french" },
|
||||
{ value: "German", labelKey: "german" },
|
||||
{ value: "Italian", labelKey: "italian" },
|
||||
{ value: "Spanish", labelKey: "spanish" },
|
||||
{ value: "Portuguese", labelKey: "portuguese" },
|
||||
{ value: "Russian", labelKey: "russian" },
|
||||
] as const;
|
||||
|
||||
const TARGET_LANGUAGES = [
|
||||
{ value: "Chinese", labelKey: "chinese" },
|
||||
{ value: "English", labelKey: "english" },
|
||||
{ value: "Japanese", labelKey: "japanese" },
|
||||
{ value: "Korean", labelKey: "korean" },
|
||||
{ value: "French", labelKey: "french" },
|
||||
{ value: "German", labelKey: "german" },
|
||||
{ value: "Italian", labelKey: "italian" },
|
||||
{ value: "Spanish", labelKey: "spanish" },
|
||||
{ value: "Portuguese", labelKey: "portuguese" },
|
||||
{ value: "Russian", labelKey: "russian" },
|
||||
] as const;
|
||||
|
||||
export default function TranslatorPage() {
|
||||
const t = useTranslations("translator");
|
||||
|
||||
const taref = useRef<HTMLTextAreaElement>(null);
|
||||
const [sourceLanguage, setSourceLanguage] = useState<string>("Auto");
|
||||
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
|
||||
const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
|
||||
const [needIpa, setNeedIpa] = useState(true);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [lastTranslation, setLastTranslation] = useState<{
|
||||
sourceText: string;
|
||||
sourceLanguage: string;
|
||||
targetLanguage: string;
|
||||
} | null>(null);
|
||||
const { load, play } = useAudioPlayer();
|
||||
@@ -63,9 +93,10 @@ export default function TranslatorPage() {
|
||||
const sourceText = taref.current.value;
|
||||
|
||||
// 判断是否需要强制重新翻译
|
||||
// 只有当源文本和目标语言都与上次相同时,才强制重新翻译
|
||||
// 只有当源文本、源语言和目标语言都与上次相同时,才强制重新翻译
|
||||
const forceRetranslate =
|
||||
lastTranslation?.sourceText === sourceText &&
|
||||
lastTranslation?.sourceLanguage === sourceLanguage &&
|
||||
lastTranslation?.targetLanguage === targetLanguage;
|
||||
|
||||
try {
|
||||
@@ -74,12 +105,14 @@ export default function TranslatorPage() {
|
||||
targetLanguage,
|
||||
forceRetranslate,
|
||||
needIpa,
|
||||
sourceLanguage: sourceLanguage === "Auto" ? undefined : sourceLanguage,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
setTranslationResult(result.data);
|
||||
setLastTranslation({
|
||||
sourceText,
|
||||
sourceLanguage,
|
||||
targetLanguage,
|
||||
});
|
||||
} else {
|
||||
@@ -132,11 +165,47 @@ export default function TranslatorPage() {
|
||||
></IconClick>
|
||||
</div>
|
||||
</div>
|
||||
<div className="option1 w-full flex flex-row justify-between items-center">
|
||||
<span>{t("detectLanguage")}</span>
|
||||
<div className="option1 w-full flex gap-1 items-center overflow-x-auto">
|
||||
<span className="shrink-0">{t("sourceLanguage")}</span>
|
||||
<LightButton
|
||||
selected={sourceLanguage === "Auto"}
|
||||
onClick={() => setSourceLanguage("Auto")}
|
||||
className="shrink-0 hidden lg:inline-flex"
|
||||
>
|
||||
{t("auto")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
selected={sourceLanguage === "Chinese"}
|
||||
onClick={() => setSourceLanguage("Chinese")}
|
||||
className="shrink-0 hidden lg:inline-flex"
|
||||
>
|
||||
{t("chinese")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
selected={sourceLanguage === "English"}
|
||||
onClick={() => setSourceLanguage("English")}
|
||||
className="shrink-0 hidden xl:inline-flex"
|
||||
>
|
||||
{t("english")}
|
||||
</LightButton>
|
||||
<Select
|
||||
value={sourceLanguage}
|
||||
onChange={(e) => setSourceLanguage(e.target.value)}
|
||||
variant="light"
|
||||
size="sm"
|
||||
className="w-auto min-w-[100px] shrink-0"
|
||||
>
|
||||
{SOURCE_LANGUAGES.map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
{t(lang.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="flex-1"></div>
|
||||
<LightButton
|
||||
selected={needIpa}
|
||||
onClick={() => setNeedIpa((prev) => !prev)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{t("generateIPA")}
|
||||
</LightButton>
|
||||
@@ -172,37 +241,42 @@ export default function TranslatorPage() {
|
||||
></IconClick>
|
||||
</div>
|
||||
</div>
|
||||
<div className="option2 w-full flex gap-1 items-center flex-wrap">
|
||||
<span>{t("translateInto")}</span>
|
||||
<div className="option2 w-full flex gap-1 items-center overflow-x-auto">
|
||||
<span className="shrink-0">{t("translateInto")}</span>
|
||||
<LightButton
|
||||
selected={targetLanguage === "Chinese"}
|
||||
onClick={() => setTargetLanguage("Chinese")}
|
||||
className="shrink-0 hidden lg:inline-flex"
|
||||
>
|
||||
{t("chinese")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
selected={targetLanguage === "English"}
|
||||
onClick={() => setTargetLanguage("English")}
|
||||
className="shrink-0 hidden lg:inline-flex"
|
||||
>
|
||||
{t("english")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
selected={targetLanguage === "Italian"}
|
||||
onClick={() => setTargetLanguage("Italian")}
|
||||
selected={targetLanguage === "Japanese"}
|
||||
onClick={() => setTargetLanguage("Japanese")}
|
||||
className="shrink-0 hidden xl:inline-flex"
|
||||
>
|
||||
{t("italian")}
|
||||
{t("japanese")}
|
||||
</LightButton>
|
||||
<LightButton
|
||||
selected={!["Chinese", "English", "Italian"].includes(targetLanguage)}
|
||||
onClick={() => {
|
||||
const newLang = prompt(t("enterLanguage"));
|
||||
if (newLang) {
|
||||
setTargetLanguage(newLang);
|
||||
}
|
||||
}}
|
||||
<Select
|
||||
value={targetLanguage}
|
||||
onChange={(e) => setTargetLanguage(e.target.value)}
|
||||
variant="light"
|
||||
size="sm"
|
||||
className="w-auto min-w-[100px] shrink-0"
|
||||
>
|
||||
{t("other")}
|
||||
</LightButton>
|
||||
{TARGET_LANGUAGES.map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
{t(lang.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,18 +5,17 @@
|
||||
* 使用 @theme 指令定义主题变量
|
||||
*/
|
||||
@theme {
|
||||
/* 主色 - Teal */
|
||||
--color-primary-50: #f0f9f8;
|
||||
--color-primary-100: #e0f2f0;
|
||||
--color-primary-200: #bce6e1;
|
||||
--color-primary-300: #8dd4cc;
|
||||
--color-primary-400: #5ec2b7;
|
||||
--color-primary-500: #35786f;
|
||||
--color-primary-600: #2a605b;
|
||||
--color-primary-700: #1f4844;
|
||||
--color-primary-800: #183835;
|
||||
--color-primary-900: #122826;
|
||||
--color-primary-950: #0a1413;
|
||||
--color-primary-50: var(--primary-50);
|
||||
--color-primary-100: var(--primary-100);
|
||||
--color-primary-200: var(--primary-200);
|
||||
--color-primary-300: var(--primary-300);
|
||||
--color-primary-400: var(--primary-400);
|
||||
--color-primary-500: var(--primary-500);
|
||||
--color-primary-600: var(--primary-600);
|
||||
--color-primary-700: var(--primary-700);
|
||||
--color-primary-800: var(--primary-800);
|
||||
--color-primary-900: var(--primary-900);
|
||||
--color-primary-950: var(--primary-950);
|
||||
|
||||
/* 中性色 */
|
||||
--color-gray-50: #f9fafb;
|
||||
@@ -100,6 +99,19 @@
|
||||
* 定义全局 CSS 变量用于主题切换和动态样式
|
||||
*/
|
||||
:root {
|
||||
/* 主题色 - 默认 Teal */
|
||||
--primary-50: #f0f9f8;
|
||||
--primary-100: #e0f2f0;
|
||||
--primary-200: #bce6e1;
|
||||
--primary-300: #8dd4cc;
|
||||
--primary-400: #5ec2b7;
|
||||
--primary-500: #35786f;
|
||||
--primary-600: #2a605b;
|
||||
--primary-700: #1f4844;
|
||||
--primary-800: #183835;
|
||||
--primary-900: #122826;
|
||||
--primary-950: #0a1413;
|
||||
|
||||
/* 基础颜色 */
|
||||
--background: #ffffff;
|
||||
--foreground: #111827;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { NextIntlClientProvider } from "next-intl";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Toaster } from "sonner";
|
||||
import { StrictMode } from "react";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
@@ -25,11 +26,13 @@ export default async function RootLayout({
|
||||
<html lang="en">
|
||||
<body className={`antialiased`}>
|
||||
<StrictMode>
|
||||
<NextIntlClientProvider>
|
||||
<Navbar></Navbar>
|
||||
{children}
|
||||
<Toaster />
|
||||
</NextIntlClientProvider>
|
||||
<ThemeProvider>
|
||||
<NextIntlClientProvider>
|
||||
<Navbar></Navbar>
|
||||
{children}
|
||||
<Toaster />
|
||||
</NextIntlClientProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
57
src/app/settings/page.tsx
Normal file
57
src/app/settings/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/utils/cn";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const t = useTranslations("settings");
|
||||
const { currentTheme, setTheme, availableThemes } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-64px)] bg-white p-4 md:p-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
{t("title")}
|
||||
</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">
|
||||
{t("themeColor")}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
{t("themeColorDescription")}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-4 sm:grid-cols-8 gap-3">
|
||||
{availableThemes.map((theme) => (
|
||||
<button
|
||||
key={theme.id}
|
||||
onClick={() => setTheme(theme.id)}
|
||||
className={cn(
|
||||
"group relative flex flex-col items-center gap-2 p-2 rounded-lg transition-all",
|
||||
currentTheme === theme.id
|
||||
? "ring-2 ring-offset-2"
|
||||
: "hover:bg-gray-50"
|
||||
)}
|
||||
style={{
|
||||
["--tw-ring-color" as string]: theme.colors[500],
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-full shadow-md ring-1 ring-black/10"
|
||||
style={{ backgroundColor: theme.colors[500] }}
|
||||
/>
|
||||
<span className="text-xs text-gray-600 group-hover:text-gray-900">
|
||||
{theme.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/components/follow/FollowButton.tsx
Normal file
47
src/components/follow/FollowButton.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { PrimaryButton, LightButton } from "@/design-system/base/button";
|
||||
import { actionToggleFollow } from "@/modules/follow/follow-action";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface FollowButtonProps {
|
||||
targetUserId: string;
|
||||
initialIsFollowing: boolean;
|
||||
onFollowChange?: (isFollowing: boolean, followersCount: number) => void;
|
||||
}
|
||||
|
||||
export function FollowButton({
|
||||
targetUserId,
|
||||
initialIsFollowing,
|
||||
onFollowChange,
|
||||
}: FollowButtonProps) {
|
||||
const [isFollowing, setIsFollowing] = useState(initialIsFollowing);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleToggleFollow = () => {
|
||||
startTransition(async () => {
|
||||
const result = await actionToggleFollow({ targetUserId });
|
||||
if (result.success && result.data) {
|
||||
setIsFollowing(result.data.isFollowing);
|
||||
onFollowChange?.(result.data.isFollowing, result.data.followersCount);
|
||||
} else {
|
||||
toast.error(result.message || "Failed to update follow status");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (isFollowing) {
|
||||
return (
|
||||
<LightButton onClick={handleToggleFollow} disabled={isPending}>
|
||||
{isPending ? "..." : "Following"}
|
||||
</LightButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PrimaryButton onClick={handleToggleFollow} disabled={isPending}>
|
||||
{isPending ? "..." : "Follow"}
|
||||
</PrimaryButton>
|
||||
);
|
||||
}
|
||||
54
src/components/follow/FollowStats.tsx
Normal file
54
src/components/follow/FollowStats.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FollowButton } from "./FollowButton";
|
||||
|
||||
interface FollowStatsProps {
|
||||
userId: string;
|
||||
initialFollowersCount: number;
|
||||
initialFollowingCount: number;
|
||||
initialIsFollowing: boolean;
|
||||
currentUserId?: string;
|
||||
isOwnProfile: boolean;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export function FollowStats({
|
||||
userId,
|
||||
initialFollowersCount,
|
||||
initialFollowingCount,
|
||||
initialIsFollowing,
|
||||
currentUserId,
|
||||
isOwnProfile,
|
||||
username,
|
||||
}: FollowStatsProps) {
|
||||
const [followersCount, setFollowersCount] = useState(initialFollowersCount);
|
||||
|
||||
const handleFollowChange = (isFollowing: boolean, count: number) => {
|
||||
setFollowersCount(count);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href={`/users/${username}/followers`}
|
||||
className="text-sm text-gray-600 hover:text-primary-500 transition-colors"
|
||||
>
|
||||
<span className="font-semibold text-gray-900">{followersCount}</span> followers
|
||||
</a>
|
||||
<a
|
||||
href={`/users/${username}/following`}
|
||||
className="text-sm text-gray-600 hover:text-primary-500 transition-colors"
|
||||
>
|
||||
<span className="font-semibold text-gray-900">{initialFollowingCount}</span> following
|
||||
</a>
|
||||
{currentUserId && !isOwnProfile && (
|
||||
<FollowButton
|
||||
targetUserId={userId}
|
||||
initialIsFollowing={initialIsFollowing}
|
||||
onFollowChange={handleFollowChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/components/follow/UserList.tsx
Normal file
68
src/components/follow/UserList.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
interface UserItem {
|
||||
id: string;
|
||||
username: string | null;
|
||||
displayUsername: string | null;
|
||||
image: string | null;
|
||||
bio: string | null;
|
||||
}
|
||||
|
||||
interface UserListProps {
|
||||
users: UserItem[];
|
||||
emptyMessage: string;
|
||||
}
|
||||
|
||||
export function UserList({ users, emptyMessage }: UserListProps) {
|
||||
if (users.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{users.map((user) => (
|
||||
<Link
|
||||
key={user.id}
|
||||
href={`/users/${user.username || user.id}`}
|
||||
className="flex items-center gap-4 p-4 bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
{user.image ? (
|
||||
<div className="relative w-12 h-12 rounded-full overflow-hidden flex-shrink-0">
|
||||
<Image
|
||||
src={user.image}
|
||||
alt={user.displayUsername || user.username || "User"}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-primary-500 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-lg font-bold text-white">
|
||||
{(user.displayUsername || user.username || "U")[0].toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-gray-900 truncate">
|
||||
{user.displayUsername || user.username || "Anonymous"}
|
||||
</div>
|
||||
{user.username && (
|
||||
<div className="text-sm text-gray-500">@{user.username}</div>
|
||||
)}
|
||||
{user.bio && (
|
||||
<div className="text-sm text-gray-600 truncate mt-1">
|
||||
{user.bio}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { GhostLightButton } from "@/design-system/base/button";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Languages } from "lucide-react";
|
||||
import { cn } from "@/utils/cn";
|
||||
|
||||
const languages = [
|
||||
{ code: "en-US", label: "English" },
|
||||
{ code: "zh-CN", label: "中文" },
|
||||
{ code: "ja-JP", label: "日本語" },
|
||||
{ code: "ko-KR", label: "한국어" },
|
||||
{ code: "de-DE", label: "Deutsch" },
|
||||
{ code: "fr-FR", label: "Français" },
|
||||
{ code: "it-IT", label: "Italiano" },
|
||||
{ code: "ug-CN", label: "ئۇيغۇرچە" },
|
||||
];
|
||||
|
||||
export function LanguageSettings() {
|
||||
const [showLanguageMenu, setShowLanguageMenu] = useState(false);
|
||||
const handleLanguageClick = () => {
|
||||
setShowLanguageMenu((prev) => !prev);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
const setLocale = async (locale: string) => {
|
||||
document.cookie = `locale=${locale}`;
|
||||
window.location.reload();
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<GhostLightButton
|
||||
size="md"
|
||||
onClick={handleLanguageClick}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setIsOpen(false);
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const setLocale = async (locale: string) => {
|
||||
document.cookie = `locale=${locale}`;
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center justify-center p-2 rounded-md text-white hover:bg-white/10 transition-colors"
|
||||
aria-label="切换语言"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<Languages size={20} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-full mt-2 w-40 rounded-lg bg-white shadow-lg ring-1 ring-black/5 overflow-hidden transition-all duration-200 origin-top-right z-50",
|
||||
isOpen
|
||||
? "opacity-100 scale-100"
|
||||
: "opacity-0 scale-95 pointer-events-none"
|
||||
)}
|
||||
role="menu"
|
||||
>
|
||||
<div className="py-1">
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => setLocale(lang.code)}
|
||||
className="w-full flex items-center px-4 py-2.5 text-gray-700 hover:bg-gray-50 hover:text-gray-900 transition-colors text-left"
|
||||
role="menuitem"
|
||||
>
|
||||
<Languages size={20} />
|
||||
</GhostLightButton>
|
||||
<div className="relative">
|
||||
{showLanguageMenu && (
|
||||
<div>
|
||||
<div className="absolute top-10 right-0 rounded-md shadow-md flex flex-col gap-2">
|
||||
<GhostLightButton
|
||||
className="w-full bg-primary-500"
|
||||
onClick={() => setLocale("en-US")}
|
||||
>
|
||||
English
|
||||
</GhostLightButton>
|
||||
<GhostLightButton
|
||||
className="w-full bg-primary-500"
|
||||
onClick={() => setLocale("zh-CN")}
|
||||
>
|
||||
中文
|
||||
</GhostLightButton>
|
||||
<GhostLightButton
|
||||
className="w-full bg-primary-500"
|
||||
onClick={() => setLocale("ja-JP")}
|
||||
>
|
||||
日本語
|
||||
</GhostLightButton>
|
||||
<GhostLightButton
|
||||
className="w-full bg-primary-500"
|
||||
onClick={() => setLocale("ko-KR")}
|
||||
>
|
||||
한국어
|
||||
</GhostLightButton>
|
||||
<GhostLightButton
|
||||
className="w-full bg-primary-500"
|
||||
onClick={() => setLocale("de-DE")}
|
||||
>
|
||||
Deutsch
|
||||
</GhostLightButton>
|
||||
<GhostLightButton
|
||||
className="w-full bg-primary-500"
|
||||
onClick={() => setLocale("fr-FR")}
|
||||
>
|
||||
Français
|
||||
</GhostLightButton>
|
||||
<GhostLightButton
|
||||
className="w-full bg-primary-500"
|
||||
onClick={() => setLocale("it-IT")}
|
||||
>
|
||||
Italiano
|
||||
</GhostLightButton>
|
||||
<GhostLightButton
|
||||
className="w-full bg-primary-500"
|
||||
onClick={() => setLocale("ug-CN")}
|
||||
>
|
||||
ئۇيغۇرچە
|
||||
</GhostLightButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div></>
|
||||
);
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
92
src/components/layout/MobileMenu.tsx
Normal file
92
src/components/layout/MobileMenu.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { cn } from "@/utils/cn";
|
||||
import type { NavigationItem } from "./Navbar";
|
||||
|
||||
interface MobileMenuProps {
|
||||
items: NavigationItem[];
|
||||
}
|
||||
|
||||
export function MobileMenu({ items }: MobileMenuProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setIsOpen(false);
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center justify-center p-2 rounded-md text-white hover:bg-white/10 transition-colors"
|
||||
aria-label={isOpen ? "关闭菜单" : "打开菜单"}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{isOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-full mt-2 w-56 rounded-lg bg-white shadow-lg ring-1 ring-black/5 overflow-hidden transition-all duration-200 origin-top-right z-50",
|
||||
isOpen
|
||||
? "opacity-100 scale-100"
|
||||
: "opacity-0 scale-95 pointer-events-none"
|
||||
)}
|
||||
role="menu"
|
||||
>
|
||||
<div className="py-1">
|
||||
{items.map((item, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={item.href}
|
||||
className="flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-gray-50 hover:text-gray-900 transition-colors"
|
||||
role="menuitem"
|
||||
onClick={() => setIsOpen(false)}
|
||||
target={item.external ? "_blank" : undefined}
|
||||
rel={item.external ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
{item.icon && <span className="shrink-0 text-gray-500">{item.icon}</span>}
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import Image from "next/image";
|
||||
import { IMAGES } from "@/config/images";
|
||||
import { Compass, Folder, Heart, Home, User } from "lucide-react";
|
||||
import { Compass, Folder, Heart, Home, Settings, User, Github } from "lucide-react";
|
||||
import { LanguageSettings } from "./LanguageSettings";
|
||||
import { MobileMenu } from "./MobileMenu";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { GhostLightButton } from "@/design-system/base/button";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface NavigationItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon?: ReactNode;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export async function Navbar() {
|
||||
const t = await getTranslations("navbar");
|
||||
@@ -13,49 +20,38 @@ export async function Navbar() {
|
||||
headers: await headers()
|
||||
});
|
||||
|
||||
const mobileMenuItems: NavigationItem[] = [
|
||||
{ label: t("folders"), href: "/folders", icon: <Folder size={18} /> },
|
||||
{ label: t("explore"), href: "/explore", icon: <Compass size={18} /> },
|
||||
...(session ? [{ label: t("favorites"), href: "/favorites", icon: <Heart size={18} /> }] : []),
|
||||
{ label: t("sourceCode"), href: "https://github.com/GoddoNebianU/learn-languages", icon: <Github size={18} />, external: true },
|
||||
{ label: t("settings"), href: "/settings", icon: <Settings size={18} /> },
|
||||
...(session
|
||||
? [{ label: t("profile"), href: "/profile", icon: <User size={18} /> }]
|
||||
: [{ label: t("sign_in"), href: "/login", icon: <User size={18} /> }]
|
||||
),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center w-full h-16 px-4 md:px-8 bg-primary-500 text-white">
|
||||
<GhostLightButton href="/" className="border-b hidden! md:block!" size="md">
|
||||
{t("title")}
|
||||
</GhostLightButton>
|
||||
<GhostLightButton className="block! md:hidden!" size="md" href={"/"}>
|
||||
<GhostLightButton className="block! md:hidden!" size="md" href="/">
|
||||
<Home size={20} />
|
||||
</GhostLightButton>
|
||||
<div className="flex gap-0.5 justify-center items-center">
|
||||
<LanguageSettings />
|
||||
<GhostLightButton
|
||||
className="md:hidden! block!"
|
||||
size="md"
|
||||
href="https://github.com/GoddoNebianU/learn-languages"
|
||||
>
|
||||
<Image
|
||||
src={IMAGES.github_mark_white}
|
||||
alt="GitHub"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</GhostLightButton>
|
||||
<GhostLightButton href="/folders" className="md:block! hidden!" size="md">
|
||||
{t("folders")}
|
||||
</GhostLightButton>
|
||||
<GhostLightButton href="/folders" className="md:hidden! block!" size="md">
|
||||
<Folder size={20} />
|
||||
</GhostLightButton>
|
||||
<GhostLightButton href="/explore" className="md:block! hidden!" size="md">
|
||||
{t("explore")}
|
||||
</GhostLightButton>
|
||||
<GhostLightButton href="/explore" className="md:hidden! block!" size="md">
|
||||
<Compass size={20} />
|
||||
</GhostLightButton>
|
||||
{session && (
|
||||
<>
|
||||
<GhostLightButton href="/favorites" className="md:block! hidden!" size="md">
|
||||
{t("favorites")}
|
||||
</GhostLightButton>
|
||||
<GhostLightButton href="/favorites" className="md:hidden! block!" size="md">
|
||||
<Heart size={20} />
|
||||
</GhostLightButton>
|
||||
</>
|
||||
<GhostLightButton href="/favorites" className="md:block! hidden!" size="md">
|
||||
{t("favorites")}
|
||||
</GhostLightButton>
|
||||
)}
|
||||
<GhostLightButton
|
||||
className="hidden! md:block!"
|
||||
@@ -64,23 +60,21 @@ export async function Navbar() {
|
||||
>
|
||||
{t("sourceCode")}
|
||||
</GhostLightButton>
|
||||
{
|
||||
(() => {
|
||||
return session &&
|
||||
<>
|
||||
<GhostLightButton href="/profile" className="hidden! md:block!" size="md">{t("profile")}</GhostLightButton>
|
||||
<GhostLightButton href="/profile" className="md:hidden! block!" size="md">
|
||||
<User size={20} />
|
||||
</GhostLightButton>
|
||||
</>
|
||||
|| <>
|
||||
<GhostLightButton href="/login" className="hidden! md:block!" size="md">{t("sign_in")}</GhostLightButton>
|
||||
<GhostLightButton href="/login" className="md:hidden! block!" size="md">
|
||||
<User size={20} />
|
||||
</GhostLightButton>
|
||||
</>;
|
||||
})()
|
||||
}
|
||||
<GhostLightButton href="/settings" className="hidden! md:block!" size="md">
|
||||
{t("settings")}
|
||||
</GhostLightButton>
|
||||
{session ? (
|
||||
<GhostLightButton href="/profile" className="hidden! md:block!" size="md">
|
||||
{t("profile")}
|
||||
</GhostLightButton>
|
||||
) : (
|
||||
<GhostLightButton href="/login" className="hidden! md:block!" size="md">
|
||||
{t("sign_in")}
|
||||
</GhostLightButton>
|
||||
)}
|
||||
<div className="md:hidden!">
|
||||
<MobileMenu items={mobileMenuItems} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
76
src/components/theme-provider.tsx
Normal file
76
src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
THEME_PRESETS,
|
||||
DEFAULT_THEME,
|
||||
getThemePreset,
|
||||
applyThemeColors,
|
||||
type ThemePreset,
|
||||
} from "@/shared/theme-presets";
|
||||
|
||||
type ThemeContextType = {
|
||||
currentTheme: string;
|
||||
themePreset: ThemePreset;
|
||||
setTheme: (themeId: string) => void;
|
||||
availableThemes: ThemePreset[];
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | null>(null);
|
||||
|
||||
const STORAGE_KEY = "theme-preset";
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [currentTheme, setCurrentTheme] = useState<string>(DEFAULT_THEME);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const savedTheme = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedTheme && getThemePreset(savedTheme)) {
|
||||
setCurrentTheme(savedTheme);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
const preset = getThemePreset(currentTheme);
|
||||
if (preset) {
|
||||
applyThemeColors(preset);
|
||||
localStorage.setItem(STORAGE_KEY, currentTheme);
|
||||
}
|
||||
}, [currentTheme, mounted]);
|
||||
|
||||
const setTheme = (themeId: string) => {
|
||||
if (getThemePreset(themeId)) {
|
||||
setCurrentTheme(themeId);
|
||||
}
|
||||
};
|
||||
|
||||
const themePreset = getThemePreset(currentTheme) || THEME_PRESETS[0];
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
currentTheme,
|
||||
themePreset,
|
||||
setTheme,
|
||||
availableThemes: THEME_PRESETS,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ const selectVariants = cva(
|
||||
default: "border-b-2 border-gray-300 bg-transparent rounded-t-md",
|
||||
bordered: "border-gray-300 bg-white",
|
||||
filled: "border-transparent bg-gray-100",
|
||||
light: "border-transparent bg-gray-100 shadow-sm hover:bg-gray-200 font-semibold cursor-pointer",
|
||||
},
|
||||
size: {
|
||||
sm: "h-9 px-3 text-sm",
|
||||
@@ -48,6 +49,11 @@ const selectVariants = cva(
|
||||
error: true,
|
||||
className: "bg-error-50",
|
||||
},
|
||||
{
|
||||
variant: "light",
|
||||
error: true,
|
||||
className: "bg-error-50 hover:bg-error-100",
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
|
||||
@@ -132,19 +132,28 @@ async function generateIPA(
|
||||
export async function executeTranslation(
|
||||
sourceText: string,
|
||||
targetLanguage: string,
|
||||
needIpa: boolean
|
||||
needIpa: boolean,
|
||||
sourceLanguage?: string
|
||||
): Promise<TranslationLLMResponse> {
|
||||
try {
|
||||
log.debug("Starting translation", { sourceText, targetLanguage, needIpa });
|
||||
log.debug("Starting translation", { sourceText, targetLanguage, needIpa, sourceLanguage });
|
||||
|
||||
log.debug("[Stage 1] Detecting source language");
|
||||
const detectionResult = await detectLanguage(sourceText);
|
||||
log.debug("[Stage 1] Detection result", { detectionResult });
|
||||
let detectedLanguage: string;
|
||||
|
||||
if (sourceLanguage) {
|
||||
log.debug("[Stage 1] Using provided source language", { sourceLanguage });
|
||||
detectedLanguage = sourceLanguage;
|
||||
} else {
|
||||
log.debug("[Stage 1] Detecting source language");
|
||||
const detectionResult = await detectLanguage(sourceText);
|
||||
log.debug("[Stage 1] Detection result", { detectionResult });
|
||||
detectedLanguage = detectionResult.sourceLanguage;
|
||||
}
|
||||
|
||||
log.debug("[Stage 2] Performing translation");
|
||||
const translatedText = await performTranslation(
|
||||
sourceText,
|
||||
detectionResult.sourceLanguage,
|
||||
detectedLanguage,
|
||||
targetLanguage
|
||||
);
|
||||
log.debug("[Stage 2] Translation complete", { translatedText });
|
||||
@@ -160,7 +169,7 @@ export async function executeTranslation(
|
||||
|
||||
if (needIpa) {
|
||||
log.debug("[Stage 3] Generating IPA");
|
||||
sourceIpa = await generateIPA(sourceText, detectionResult.sourceLanguage);
|
||||
sourceIpa = await generateIPA(sourceText, detectedLanguage);
|
||||
log.debug("[Stage 3] Source IPA", { sourceIpa });
|
||||
|
||||
targetIpa = await generateIPA(translatedText, targetLanguage);
|
||||
@@ -171,7 +180,7 @@ export async function executeTranslation(
|
||||
const finalResult: TranslationLLMResponse = {
|
||||
sourceText,
|
||||
translatedText,
|
||||
sourceLanguage: detectionResult.sourceLanguage,
|
||||
sourceLanguage: detectedLanguage,
|
||||
targetLanguage,
|
||||
sourceIpa,
|
||||
targetIpa,
|
||||
|
||||
@@ -56,6 +56,7 @@ export type ActionOutputUserProfile = {
|
||||
username: string | null;
|
||||
displayUsername: string | null;
|
||||
image: string | null;
|
||||
bio: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ export type RepoOutputUserProfile = {
|
||||
username: string | null;
|
||||
displayUsername: string | null;
|
||||
image: string | null;
|
||||
bio: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
} | null;
|
||||
|
||||
@@ -6,9 +6,6 @@ import {
|
||||
RepoOutputUserProfile
|
||||
} from "./auth-repository-dto";
|
||||
|
||||
/**
|
||||
* Find user by username
|
||||
*/
|
||||
export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername): Promise<RepoOutputUserProfile> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username: dto.username },
|
||||
@@ -19,6 +16,7 @@ export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername):
|
||||
username: true,
|
||||
displayUsername: true,
|
||||
image: true,
|
||||
bio: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
}
|
||||
@@ -27,9 +25,6 @@ export async function repoFindUserByUsername(dto: RepoInputFindUserByUsername):
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by ID
|
||||
*/
|
||||
export async function repoFindUserById(dto: RepoInputFindUserById): Promise<RepoOutputUserProfile> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: dto.id },
|
||||
@@ -40,6 +35,7 @@ export async function repoFindUserById(dto: RepoInputFindUserById): Promise<Repo
|
||||
username: true,
|
||||
displayUsername: true,
|
||||
image: true,
|
||||
bio: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
}
|
||||
@@ -48,9 +44,6 @@ export async function repoFindUserById(dto: RepoInputFindUserById): Promise<Repo
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by email
|
||||
*/
|
||||
export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promise<RepoOutputUserProfile> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: dto.email },
|
||||
@@ -61,6 +54,7 @@ export async function repoFindUserByEmail(dto: RepoInputFindUserByEmail): Promis
|
||||
username: true,
|
||||
displayUsername: true,
|
||||
image: true,
|
||||
bio: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export type ServiceOutputUserProfile = {
|
||||
username: string | null;
|
||||
displayUsername: string | null;
|
||||
image: string | null;
|
||||
bio: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
} | null;
|
||||
|
||||
33
src/modules/follow/follow-action-dto.ts
Normal file
33
src/modules/follow/follow-action-dto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const schemaActionInputToggleFollow = z.object({
|
||||
targetUserId: z.string().min(1),
|
||||
});
|
||||
|
||||
const schemaActionInputGetFollowers = z.object({
|
||||
userId: z.string().min(1),
|
||||
page: z.number().int().min(1).optional().default(1),
|
||||
limit: z.number().int().min(1).max(100).optional().default(20),
|
||||
});
|
||||
|
||||
const schemaActionInputGetFollowing = z.object({
|
||||
userId: z.string().min(1),
|
||||
page: z.number().int().min(1).optional().default(1),
|
||||
limit: z.number().int().min(1).max(100).optional().default(20),
|
||||
});
|
||||
|
||||
const schemaActionInputGetFollowStatus = z.object({
|
||||
targetUserId: z.string().min(1),
|
||||
});
|
||||
|
||||
export type ActionInputToggleFollow = z.infer<typeof schemaActionInputToggleFollow>;
|
||||
export type ActionInputGetFollowers = z.infer<typeof schemaActionInputGetFollowers>;
|
||||
export type ActionInputGetFollowing = z.infer<typeof schemaActionInputGetFollowing>;
|
||||
export type ActionInputGetFollowStatus = z.infer<typeof schemaActionInputGetFollowStatus>;
|
||||
|
||||
export {
|
||||
schemaActionInputGetFollowers,
|
||||
schemaActionInputGetFollowing,
|
||||
schemaActionInputGetFollowStatus,
|
||||
schemaActionInputToggleFollow,
|
||||
};
|
||||
84
src/modules/follow/follow-action.ts
Normal file
84
src/modules/follow/follow-action.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { validate } from "@/utils/validate";
|
||||
import { serviceGetFollowers, serviceGetFollowing, serviceGetFollowStatus, serviceToggleFollow } from "./follow-service";
|
||||
import {
|
||||
schemaActionInputGetFollowers,
|
||||
schemaActionInputGetFollowing,
|
||||
schemaActionInputGetFollowStatus,
|
||||
schemaActionInputToggleFollow,
|
||||
} from "./follow-action-dto";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("follow-action");
|
||||
|
||||
export async function actionToggleFollow(input: unknown) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, message: "Unauthorized" };
|
||||
}
|
||||
|
||||
try {
|
||||
const dto = validate(input, schemaActionInputToggleFollow);
|
||||
const result = await serviceToggleFollow({
|
||||
currentUserId: session.user.id,
|
||||
targetUserId: dto.targetUserId,
|
||||
});
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to toggle follow";
|
||||
log.error("Failed to toggle follow", { error });
|
||||
return { success: false, message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetFollowStatus(input: unknown) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
try {
|
||||
const dto = validate(input, schemaActionInputGetFollowStatus);
|
||||
const result = await serviceGetFollowStatus({
|
||||
currentUserId: session?.user?.id || "",
|
||||
targetUserId: dto.targetUserId,
|
||||
});
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to get follow status";
|
||||
log.error("Failed to get follow status", { error });
|
||||
return { success: false, message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetFollowers(input: unknown) {
|
||||
try {
|
||||
const dto = validate(input, schemaActionInputGetFollowers);
|
||||
const result = await serviceGetFollowers({
|
||||
userId: dto.userId,
|
||||
page: dto.page,
|
||||
limit: dto.limit,
|
||||
});
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to get followers";
|
||||
log.error("Failed to get followers", { error });
|
||||
return { success: false, message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetFollowing(input: unknown) {
|
||||
try {
|
||||
const dto = validate(input, schemaActionInputGetFollowing);
|
||||
const result = await serviceGetFollowing({
|
||||
userId: dto.userId,
|
||||
page: dto.page,
|
||||
limit: dto.limit,
|
||||
});
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to get following";
|
||||
log.error("Failed to get following", { error });
|
||||
return { success: false, message };
|
||||
}
|
||||
}
|
||||
51
src/modules/follow/follow-repository-dto.ts
Normal file
51
src/modules/follow/follow-repository-dto.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export type RepoInputCreateFollow = {
|
||||
followerId: string;
|
||||
followingId: string;
|
||||
};
|
||||
|
||||
export type RepoInputDeleteFollow = {
|
||||
followerId: string;
|
||||
followingId: string;
|
||||
};
|
||||
|
||||
export type RepoInputCheckFollow = {
|
||||
followerId: string;
|
||||
followingId: string;
|
||||
};
|
||||
|
||||
export type RepoInputGetFollowers = {
|
||||
userId: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type RepoInputGetFollowing = {
|
||||
userId: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type RepoOutputFollowUser = {
|
||||
id: string;
|
||||
followerId: string;
|
||||
followingId: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type RepoOutputFollowWithUser = {
|
||||
id: string;
|
||||
followerId: string;
|
||||
followingId: string;
|
||||
createdAt: Date;
|
||||
user: {
|
||||
id: string;
|
||||
username: string | null;
|
||||
displayUsername: string | null;
|
||||
image: string | null;
|
||||
bio: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type RepoOutputFollowerCount = number;
|
||||
export type RepoOutputFollowingCount = number;
|
||||
export type RepoOutputIsFollowing = boolean;
|
||||
142
src/modules/follow/follow-repository.ts
Normal file
142
src/modules/follow/follow-repository.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import type {
|
||||
RepoInputCheckFollow,
|
||||
RepoInputCreateFollow,
|
||||
RepoInputDeleteFollow,
|
||||
RepoInputGetFollowers,
|
||||
RepoInputGetFollowing,
|
||||
RepoOutputFollowerCount,
|
||||
RepoOutputFollowUser,
|
||||
RepoOutputFollowWithUser,
|
||||
RepoOutputFollowingCount,
|
||||
RepoOutputIsFollowing,
|
||||
} from "./follow-repository-dto";
|
||||
|
||||
const log = createLogger("follow-repository");
|
||||
|
||||
export async function repoCreateFollow(
|
||||
dto: RepoInputCreateFollow
|
||||
): Promise<RepoOutputFollowUser> {
|
||||
log.debug("Creating follow", { followerId: dto.followerId, followingId: dto.followingId });
|
||||
|
||||
const follow = await prisma.follow.create({
|
||||
data: {
|
||||
followerId: dto.followerId,
|
||||
followingId: dto.followingId,
|
||||
},
|
||||
});
|
||||
|
||||
log.info("Follow created", { followId: follow.id });
|
||||
return follow;
|
||||
}
|
||||
|
||||
export async function repoDeleteFollow(
|
||||
dto: RepoInputDeleteFollow
|
||||
): Promise<void> {
|
||||
log.debug("Deleting follow", { followerId: dto.followerId, followingId: dto.followingId });
|
||||
|
||||
await prisma.follow.delete({
|
||||
where: {
|
||||
followerId_followingId: {
|
||||
followerId: dto.followerId,
|
||||
followingId: dto.followingId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
log.info("Follow deleted");
|
||||
}
|
||||
|
||||
export async function repoCheckFollow(
|
||||
dto: RepoInputCheckFollow
|
||||
): Promise<RepoOutputIsFollowing> {
|
||||
const follow = await prisma.follow.findUnique({
|
||||
where: {
|
||||
followerId_followingId: {
|
||||
followerId: dto.followerId,
|
||||
followingId: dto.followingId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return !!follow;
|
||||
}
|
||||
|
||||
export async function repoGetFollowersCount(userId: string): Promise<RepoOutputFollowerCount> {
|
||||
return prisma.follow.count({
|
||||
where: { followingId: userId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function repoGetFollowingCount(userId: string): Promise<RepoOutputFollowingCount> {
|
||||
return prisma.follow.count({
|
||||
where: { followerId: userId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function repoGetFollowers(
|
||||
dto: RepoInputGetFollowers
|
||||
): Promise<RepoOutputFollowWithUser[]> {
|
||||
const { userId, page = 1, limit = 20 } = dto;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const follows = await prisma.follow.findMany({
|
||||
where: { followingId: userId },
|
||||
include: {
|
||||
follower: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayUsername: true,
|
||||
image: true,
|
||||
bio: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return follows.map((f) => ({
|
||||
id: f.id,
|
||||
followerId: f.followerId,
|
||||
followingId: f.followingId,
|
||||
createdAt: f.createdAt,
|
||||
user: f.follower,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function repoGetFollowing(
|
||||
dto: RepoInputGetFollowing
|
||||
): Promise<RepoOutputFollowWithUser[]> {
|
||||
const { userId, page = 1, limit = 20 } = dto;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const follows = await prisma.follow.findMany({
|
||||
where: { followerId: userId },
|
||||
include: {
|
||||
following: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayUsername: true,
|
||||
image: true,
|
||||
bio: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return follows.map((f) => ({
|
||||
id: f.id,
|
||||
followerId: f.followerId,
|
||||
followingId: f.followingId,
|
||||
createdAt: f.createdAt,
|
||||
user: f.following,
|
||||
}));
|
||||
}
|
||||
51
src/modules/follow/follow-service-dto.ts
Normal file
51
src/modules/follow/follow-service-dto.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export type ServiceInputToggleFollow = {
|
||||
currentUserId: string;
|
||||
targetUserId: string;
|
||||
};
|
||||
|
||||
export type ServiceInputGetFollowers = {
|
||||
userId: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type ServiceInputGetFollowing = {
|
||||
userId: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type ServiceInputCheckFollow = {
|
||||
currentUserId: string;
|
||||
targetUserId: string;
|
||||
};
|
||||
|
||||
export type ServiceOutputFollowUser = {
|
||||
id: string;
|
||||
followerId: string;
|
||||
followingId: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type ServiceOutputFollowWithUser = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
user: {
|
||||
id: string;
|
||||
username: string | null;
|
||||
displayUsername: string | null;
|
||||
image: string | null;
|
||||
bio: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type ServiceOutputFollowStatus = {
|
||||
isFollowing: boolean;
|
||||
followersCount: number;
|
||||
followingCount: number;
|
||||
};
|
||||
|
||||
export type ServiceOutputToggleFollow = {
|
||||
isFollowing: boolean;
|
||||
followersCount: number;
|
||||
};
|
||||
103
src/modules/follow/follow-service.ts
Normal file
103
src/modules/follow/follow-service.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import {
|
||||
repoCheckFollow,
|
||||
repoCreateFollow,
|
||||
repoDeleteFollow,
|
||||
repoGetFollowers,
|
||||
repoGetFollowersCount,
|
||||
repoGetFollowing,
|
||||
repoGetFollowingCount,
|
||||
} from "./follow-repository";
|
||||
import type {
|
||||
ServiceInputCheckFollow,
|
||||
ServiceInputGetFollowers,
|
||||
ServiceInputGetFollowing,
|
||||
ServiceInputToggleFollow,
|
||||
ServiceOutputFollowStatus,
|
||||
ServiceOutputFollowWithUser,
|
||||
ServiceOutputToggleFollow,
|
||||
} from "./follow-service-dto";
|
||||
|
||||
const log = createLogger("follow-service");
|
||||
|
||||
export async function serviceToggleFollow(
|
||||
dto: ServiceInputToggleFollow
|
||||
): Promise<ServiceOutputToggleFollow> {
|
||||
const { currentUserId, targetUserId } = dto;
|
||||
|
||||
if (currentUserId === targetUserId) {
|
||||
throw new Error("Cannot follow yourself");
|
||||
}
|
||||
|
||||
const isFollowing = await repoCheckFollow({
|
||||
followerId: currentUserId,
|
||||
followingId: targetUserId,
|
||||
});
|
||||
|
||||
if (isFollowing) {
|
||||
await repoDeleteFollow({
|
||||
followerId: currentUserId,
|
||||
followingId: targetUserId,
|
||||
});
|
||||
log.info("Unfollowed user", { currentUserId, targetUserId });
|
||||
} else {
|
||||
await repoCreateFollow({
|
||||
followerId: currentUserId,
|
||||
followingId: targetUserId,
|
||||
});
|
||||
log.info("Followed user", { currentUserId, targetUserId });
|
||||
}
|
||||
|
||||
const followersCount = await repoGetFollowersCount(targetUserId);
|
||||
|
||||
return {
|
||||
isFollowing: !isFollowing,
|
||||
followersCount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function serviceGetFollowStatus(
|
||||
dto: ServiceInputCheckFollow
|
||||
): Promise<ServiceOutputFollowStatus> {
|
||||
const { currentUserId, targetUserId } = dto;
|
||||
|
||||
const [isFollowing, followersCount, followingCount] = await Promise.all([
|
||||
currentUserId
|
||||
? repoCheckFollow({ followerId: currentUserId, followingId: targetUserId })
|
||||
: false,
|
||||
repoGetFollowersCount(targetUserId),
|
||||
repoGetFollowingCount(targetUserId),
|
||||
]);
|
||||
|
||||
return {
|
||||
isFollowing,
|
||||
followersCount,
|
||||
followingCount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function serviceGetFollowers(
|
||||
dto: ServiceInputGetFollowers
|
||||
): Promise<{ followers: ServiceOutputFollowWithUser[]; total: number }> {
|
||||
const { userId, page, limit } = dto;
|
||||
|
||||
const [followers, total] = await Promise.all([
|
||||
repoGetFollowers({ userId, page, limit }),
|
||||
repoGetFollowersCount(userId),
|
||||
]);
|
||||
|
||||
return { followers, total };
|
||||
}
|
||||
|
||||
export async function serviceGetFollowing(
|
||||
dto: ServiceInputGetFollowing
|
||||
): Promise<{ following: ServiceOutputFollowWithUser[]; total: number }> {
|
||||
const { userId, page, limit } = dto;
|
||||
|
||||
const [following, total] = await Promise.all([
|
||||
repoGetFollowing({ userId, page, limit }),
|
||||
repoGetFollowingCount(userId),
|
||||
]);
|
||||
|
||||
return { following, total };
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const schemaActionInputTranslateText = z.object({
|
||||
forceRetranslate: z.boolean().optional().default(false),
|
||||
needIpa: z.boolean().optional().default(true),
|
||||
userId: z.string().optional(),
|
||||
sourceLanguage: z.string().min(LENGTH_MIN_LANGUAGE).max(LENGTH_MAX_LANGUAGE).optional(),
|
||||
});
|
||||
|
||||
export type ActionInputTranslateText = z.infer<typeof schemaActionInputTranslateText>;
|
||||
|
||||
@@ -6,6 +6,7 @@ export type ServiceInputTranslateText = {
|
||||
forceRetranslate: boolean;
|
||||
needIpa: boolean;
|
||||
userId?: string;
|
||||
sourceLanguage?: string;
|
||||
};
|
||||
|
||||
export type ServiceOutputTranslateText = TSharedTranslationResult;
|
||||
|
||||
@@ -8,7 +8,7 @@ const log = createLogger("translator-service");
|
||||
export const serviceTranslateText = async (
|
||||
dto: ServiceInputTranslateText
|
||||
): Promise<ServiceOutputTranslateText> => {
|
||||
const { sourceText, targetLanguage, forceRetranslate, needIpa, userId } = dto;
|
||||
const { sourceText, targetLanguage, forceRetranslate, needIpa, userId, sourceLanguage } = dto;
|
||||
|
||||
// Check for existing translation
|
||||
const lastTranslation = await repoSelectLatestTranslation({
|
||||
@@ -21,7 +21,8 @@ export const serviceTranslateText = async (
|
||||
const response = await executeTranslation(
|
||||
sourceText,
|
||||
targetLanguage,
|
||||
needIpa
|
||||
needIpa,
|
||||
sourceLanguage
|
||||
);
|
||||
|
||||
// Save translation history asynchronously (don't block response)
|
||||
|
||||
294
src/shared/theme-presets.ts
Normal file
294
src/shared/theme-presets.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* 主题色预设
|
||||
*
|
||||
* 每个预设包含完整的 10 级色阶
|
||||
*/
|
||||
|
||||
export interface ThemePreset {
|
||||
id: string;
|
||||
name: string;
|
||||
colors: {
|
||||
50: string;
|
||||
100: string;
|
||||
200: string;
|
||||
300: string;
|
||||
400: string;
|
||||
500: string;
|
||||
600: string;
|
||||
700: string;
|
||||
800: string;
|
||||
900: string;
|
||||
950: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const THEME_PRESETS: ThemePreset[] = [
|
||||
{
|
||||
id: "teal",
|
||||
name: "青绿",
|
||||
colors: {
|
||||
50: "#f0f9f8",
|
||||
100: "#e0f2f0",
|
||||
200: "#bce6e1",
|
||||
300: "#8dd4cc",
|
||||
400: "#5ec2b7",
|
||||
500: "#35786f",
|
||||
600: "#2a605b",
|
||||
700: "#1f4844",
|
||||
800: "#183835",
|
||||
900: "#122826",
|
||||
950: "#0a1413",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "blue",
|
||||
name: "蓝色",
|
||||
colors: {
|
||||
50: "#eff6ff",
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
800: "#1e40af",
|
||||
900: "#1e3a8a",
|
||||
950: "#172554",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "violet",
|
||||
name: "紫罗兰",
|
||||
colors: {
|
||||
50: "#f5f3ff",
|
||||
100: "#ede9fe",
|
||||
200: "#ddd6fe",
|
||||
300: "#c4b5fd",
|
||||
400: "#a78bfa",
|
||||
500: "#8b5cf6",
|
||||
600: "#7c3aed",
|
||||
700: "#6d28d9",
|
||||
800: "#5b21b6",
|
||||
900: "#4c1d95",
|
||||
950: "#2e1065",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rose",
|
||||
name: "玫瑰",
|
||||
colors: {
|
||||
50: "#fff1f2",
|
||||
100: "#ffe4e6",
|
||||
200: "#fecdd3",
|
||||
300: "#fda4af",
|
||||
400: "#fb7185",
|
||||
500: "#f43f5e",
|
||||
600: "#e11d48",
|
||||
700: "#be123c",
|
||||
800: "#9f1239",
|
||||
900: "#881337",
|
||||
950: "#4c0519",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "amber",
|
||||
name: "琥珀",
|
||||
colors: {
|
||||
50: "#fffbeb",
|
||||
100: "#fef3c7",
|
||||
200: "#fde68a",
|
||||
300: "#fcd34d",
|
||||
400: "#fbbf24",
|
||||
500: "#f59e0b",
|
||||
600: "#d97706",
|
||||
700: "#b45309",
|
||||
800: "#92400e",
|
||||
900: "#78350f",
|
||||
950: "#451a03",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "emerald",
|
||||
name: "翡翠",
|
||||
colors: {
|
||||
50: "#ecfdf5",
|
||||
100: "#d1fae5",
|
||||
200: "#a7f3d0",
|
||||
300: "#6ee7b7",
|
||||
400: "#34d399",
|
||||
500: "#10b981",
|
||||
600: "#059669",
|
||||
700: "#047857",
|
||||
800: "#065f46",
|
||||
900: "#064e3b",
|
||||
950: "#022c22",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "orange",
|
||||
name: "橙色",
|
||||
colors: {
|
||||
50: "#fff7ed",
|
||||
100: "#ffedd5",
|
||||
200: "#fed7aa",
|
||||
300: "#fdba74",
|
||||
400: "#fb923c",
|
||||
500: "#f97316",
|
||||
600: "#ea580c",
|
||||
700: "#c2410c",
|
||||
800: "#9a3412",
|
||||
900: "#7c2d12",
|
||||
950: "#431407",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "indigo",
|
||||
name: "靛蓝",
|
||||
colors: {
|
||||
50: "#eef2ff",
|
||||
100: "#e0e7ff",
|
||||
200: "#c7d2fe",
|
||||
300: "#a5b4fc",
|
||||
400: "#818cf8",
|
||||
500: "#6366f1",
|
||||
600: "#4f46e5",
|
||||
700: "#4338ca",
|
||||
800: "#3730a3",
|
||||
900: "#312e81",
|
||||
950: "#1e1b4b",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "slate",
|
||||
name: "石墨",
|
||||
colors: {
|
||||
50: "#f8fafc",
|
||||
100: "#f1f5f9",
|
||||
200: "#e2e8f0",
|
||||
300: "#cbd5e1",
|
||||
400: "#94a3b8",
|
||||
500: "#64748b",
|
||||
600: "#475569",
|
||||
700: "#334155",
|
||||
800: "#1e293b",
|
||||
900: "#0f172a",
|
||||
950: "#020617",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sage",
|
||||
name: "鼠尾草",
|
||||
colors: {
|
||||
50: "#f6f7f6",
|
||||
100: "#e3e7e3",
|
||||
200: "#c7d0c7",
|
||||
300: "#a3b1a3",
|
||||
400: "#7d8e7d",
|
||||
500: "#5f715f",
|
||||
600: "#4b5a4b",
|
||||
700: "#3d483d",
|
||||
800: "#323b32",
|
||||
900: "#2a312a",
|
||||
950: "#151915",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "taupe",
|
||||
name: "暖沙",
|
||||
colors: {
|
||||
50: "#faf8f6",
|
||||
100: "#f2ede7",
|
||||
200: "#e5dbd0",
|
||||
300: "#d4c4b3",
|
||||
400: "#bfa690",
|
||||
500: "#a88c73",
|
||||
600: "#8f735d",
|
||||
700: "#755e4d",
|
||||
800: "#614e42",
|
||||
900: "#514239",
|
||||
950: "#2b221d",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mauve",
|
||||
name: "薰衣草",
|
||||
colors: {
|
||||
50: "#faf8f9",
|
||||
100: "#f3eef1",
|
||||
200: "#e8dfe4",
|
||||
300: "#d9ccd4",
|
||||
400: "#c5b0be",
|
||||
500: "#ad94a7",
|
||||
600: "#967a90",
|
||||
700: "#7d6579",
|
||||
800: "#675465",
|
||||
900: "#564755",
|
||||
950: "#2e242e",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mist",
|
||||
name: "雾蓝",
|
||||
colors: {
|
||||
50: "#f7f8fa",
|
||||
100: "#eef1f5",
|
||||
200: "#dce2eb",
|
||||
300: "#c4cdd9",
|
||||
400: "#a3b0c1",
|
||||
500: "#8594a8",
|
||||
600: "#6b7a8d",
|
||||
700: "#596474",
|
||||
800: "#4b5360",
|
||||
900: "#414850",
|
||||
950: "#22262b",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dusty",
|
||||
name: "玫瑰灰",
|
||||
colors: {
|
||||
50: "#faf8f7",
|
||||
100: "#f4efed",
|
||||
200: "#e9dfdb",
|
||||
300: "#daced0",
|
||||
400: "#c6b0a8",
|
||||
500: "#b0948c",
|
||||
600: "#967a74",
|
||||
700: "#7d6560",
|
||||
800: "#675451",
|
||||
900: "#554644",
|
||||
950: "#2c2423",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "olive",
|
||||
name: "橄榄",
|
||||
colors: {
|
||||
50: "#f7f7f4",
|
||||
100: "#eeeeda",
|
||||
200: "#dddcb6",
|
||||
300: "#c8c58c",
|
||||
400: "#b2ad64",
|
||||
500: "#9a9648",
|
||||
600: "#7d7a3b",
|
||||
700: "#656333",
|
||||
800: "#53512d",
|
||||
900: "#454429",
|
||||
950: "#252413",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_THEME = "teal";
|
||||
|
||||
export function getThemePreset(id: string): ThemePreset | undefined {
|
||||
return THEME_PRESETS.find((preset) => preset.id === id);
|
||||
}
|
||||
|
||||
export function applyThemeColors(preset: ThemePreset): void {
|
||||
const root = document.documentElement;
|
||||
Object.entries(preset.colors).forEach(([shade, color]) => {
|
||||
root.style.setProperty(`--color-primary-${shade}`, color);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user