Compare commits
22 Commits
ca33d4353f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b9fba254d | |||
| 0cb240791b | |||
| d9fd09c13d | |||
| 5406543cbe | |||
| d2a3d32376 | |||
| 436d58be52 | |||
| 11a265d52e | |||
| fb4346377a | |||
| c83aefabfa | |||
| 020744b353 | |||
| 719aef5a7f | |||
| 6c811a77db | |||
| 3652e350e6 | |||
| 6ba5ae993a | |||
| b643205f72 | |||
| c6878ed1e5 | |||
| e74cd80fac | |||
| c01c94abd0 | |||
| 0881846717 | |||
| d7149366e9 | |||
| b0fa1a4201 | |||
| b407783d61 |
@@ -2,6 +2,8 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: learn-languages
|
||||
concurrency:
|
||||
limit: 1
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
|
||||
@@ -13,3 +13,11 @@ DATABASE_URL=
|
||||
|
||||
// DashScore
|
||||
DASHSCORE_API_KEY=
|
||||
|
||||
// SMTP Email - Resend (https://resend.com)
|
||||
SMTP_HOST=smtp.resend.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=resend
|
||||
SMTP_PASS=re_your_resend_api_key
|
||||
SMTP_FROM=onboarding@resend.dev
|
||||
|
||||
30
AGENTS.md
30
AGENTS.md
@@ -1,7 +1,7 @@
|
||||
# LEARN-LANGUAGES 知识库
|
||||
|
||||
**生成时间:** 2026-03-08
|
||||
**提交:** 91c59c3
|
||||
**提交:** 6ba5ae9
|
||||
**分支:** dev
|
||||
|
||||
## 概述
|
||||
@@ -93,6 +93,17 @@ if (!session?.user?.id) return { success: false, message: "未授权" };
|
||||
// 变更前检查所有权
|
||||
```
|
||||
|
||||
### 日志
|
||||
```typescript
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("folder-repository");
|
||||
|
||||
log.debug("Fetching public folders");
|
||||
log.info("Fetched folders", { count: folders.length });
|
||||
log.error("Failed to fetch folders", { error });
|
||||
```
|
||||
|
||||
## 反模式 (本项目)
|
||||
|
||||
- ❌ `index.ts` barrel exports
|
||||
@@ -100,7 +111,8 @@ if (!session?.user?.id) return { success: false, message: "未授权" };
|
||||
- ❌ 用 API routes 做数据操作 (使用 Server Actions)
|
||||
- ❌ Server Component 可行时用 Client Component
|
||||
- ❌ npm 或 yarn (使用 pnpm)
|
||||
- ❌ 生产代码中使用 `console.log`
|
||||
- ❌ 生产代码中使用 `console.log` (使用 winston logger)
|
||||
- ❌ 擅自运行 `pnpm dev` (不需要,用 `pnpm build` 验证即可)
|
||||
|
||||
## 独特风格
|
||||
|
||||
@@ -132,6 +144,20 @@ pnpm lint # ESLint
|
||||
pnpm prisma studio # 数据库 GUI
|
||||
```
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
**必须使用 `prisma migrate dev`,禁止使用 `db push`:**
|
||||
|
||||
```bash
|
||||
# 修改 schema 后创建迁移
|
||||
DATABASE_URL=your_db_url pnpm prisma migrate dev --name your_migration_name
|
||||
|
||||
# 生成 Prisma Client
|
||||
DATABASE_URL=your_db_url pnpm prisma generate
|
||||
```
|
||||
|
||||
`db push` 会绕过迁移历史,导致生产环境无法正确迁移。
|
||||
|
||||
## 备注
|
||||
|
||||
- Tailwind CSS v4 (无 tailwind.config.ts)
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Node.js 23+
|
||||
- Node.js 24+
|
||||
- PostgreSQL 14+
|
||||
- pnpm 8+ (推荐) 或 npm/yarn
|
||||
|
||||
|
||||
@@ -1,37 +1,57 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "Bitte wählen Sie die Zeichen aus, die Sie lernen möchten",
|
||||
"chooseAlphabetHint": "Wählen Sie ein Alphabet, um mit dem Lernen zu beginnen",
|
||||
"japanese": "Japanische Kana",
|
||||
"english": "Englisches Alphabet",
|
||||
"uyghur": "Uigurisches Alphabet",
|
||||
"esperanto": "Esperanto-Alphabet",
|
||||
"loading": "Laden...",
|
||||
"loading": "Wird geladen...",
|
||||
"loadFailed": "Laden fehlgeschlagen, bitte versuchen Sie es erneut",
|
||||
"hideLetter": "Zeichen ausblenden",
|
||||
"showLetter": "Zeichen anzeigen",
|
||||
"hideLetter": "Buchstabe ausblenden",
|
||||
"showLetter": "Buchstabe anzeigen",
|
||||
"hideIPA": "IPA ausblenden",
|
||||
"showIPA": "IPA anzeigen",
|
||||
"roman": "Romanisierung",
|
||||
"letter": "Zeichen",
|
||||
"random": "Zufälliger Modus",
|
||||
"randomNext": "Zufällig weiter"
|
||||
"letter": "Buchstabe",
|
||||
"random": "Zufallsmodus",
|
||||
"randomNext": "Zufällig weiter",
|
||||
"previousLetter": "Vorheriger Buchstabe",
|
||||
"nextLetter": "Nächster Buchstabe",
|
||||
"keyboardHint": "Verwenden Sie die Pfeiltasten links/rechts oder Leertaste für Zufall, ESC zum Zurückgehen",
|
||||
"swipeHint": "Verwenden Sie die Pfeiltasten links/rechts oder wischen Sie zum Navigieren, ESC zum Zurückgehen"
|
||||
},
|
||||
"folders": {
|
||||
"title": "Ordner",
|
||||
"subtitle": "Verwalten Sie Ihre Sammlungen",
|
||||
"newFolder": "Neuer Ordner",
|
||||
"creating": "Erstellen...",
|
||||
"noFoldersYet": "Noch keine Ordner",
|
||||
"creating": "Wird erstellt...",
|
||||
"noFoldersYet": "Noch keine Ordner vorhanden",
|
||||
"folderInfo": "ID: {id} • {totalPairs} Paare",
|
||||
"enterFolderName": "Ordnernamen eingeben:",
|
||||
"confirmDelete": "Geben Sie \"{name}\" ein, um zu löschen:"
|
||||
"confirmDelete": "Geben Sie \"{name}\" zum Löschen ein:",
|
||||
"myFolders": "Meine Ordner",
|
||||
"publicFolders": "Öffentliche Ordner",
|
||||
"public": "Öffentlich",
|
||||
"private": "Privat",
|
||||
"setPublic": "Öffentlich machen",
|
||||
"setPrivate": "Privat machen",
|
||||
"publicFolderInfo": "{userName} • {totalPairs} Paare",
|
||||
"searchPlaceholder": "Öffentliche Ordner durchsuchen...",
|
||||
"loading": "Wird geladen...",
|
||||
"noPublicFolders": "Keine öffentlichen Ordner gefunden",
|
||||
"unknownUser": "Unbekannter Benutzer",
|
||||
"enterNewName": "Neuen Namen eingeben:",
|
||||
"favorite": "Favorisieren",
|
||||
"unfavorite": "Aus Favoriten entfernen",
|
||||
"pleaseLogin": "Bitte melden Sie sich zuerst an"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "Sie sind nicht der Eigentümer dieses Ordners",
|
||||
"unauthorized": "Sie sind nicht der Besitzer dieses Ordners",
|
||||
"back": "Zurück",
|
||||
"textPairs": "Textpaare",
|
||||
"itemsCount": "{count} Elemente",
|
||||
"memorize": "Einprägen",
|
||||
"itemsCount": "{count} Einträge",
|
||||
"memorize": "Auswendig lernen",
|
||||
"loadingTextPairs": "Textpaare werden geladen...",
|
||||
"noTextPairs": "Keine Textpaare in diesem Ordner",
|
||||
"addNewTextPair": "Neues Textpaar hinzufügen",
|
||||
@@ -42,14 +62,14 @@
|
||||
"text2": "Text 2",
|
||||
"language1": "Sprache 1",
|
||||
"language2": "Sprache 2",
|
||||
"enterLanguageName": "Bitte geben Sie den Sprachennamen ein",
|
||||
"enterLanguageName": "Bitte Sprachnamen eingeben",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"permissionDenied": "Sie haben keine Berechtigung, diese Aktion auszuführen",
|
||||
"permissionDenied": "Sie haben keine Berechtigung für diese Aktion",
|
||||
"error": {
|
||||
"update": "Sie haben keine Berechtigung, dieses Element zu aktualisieren.",
|
||||
"delete": "Sie haben keine Berechtigung, dieses Element zu löschen.",
|
||||
"add": "Sie haben keine Berechtigung, Elemente zu diesem Ordner hinzuzufügen.",
|
||||
"update": "Sie haben keine Berechtigung, diesen Eintrag zu aktualisieren.",
|
||||
"delete": "Sie haben keine Berechtigung, diesen Eintrag zu löschen.",
|
||||
"add": "Sie haben keine Berechtigung, Einträge zu diesem Ordner hinzuzufügen.",
|
||||
"rename": "Sie haben keine Berechtigung, diesen Ordner umzubenennen.",
|
||||
"deleteFolder": "Sie haben keine Berechtigung, diesen Ordner zu löschen."
|
||||
}
|
||||
@@ -57,48 +77,51 @@
|
||||
"home": {
|
||||
"title": "Sprachen lernen",
|
||||
"description": "Hier ist eine sehr nützliche Website, die Ihnen hilft, fast jede Sprache der Welt zu lernen, einschließlich konstruierter Sprachen.",
|
||||
"explore": "Erkunden",
|
||||
"explore": "Entdecken",
|
||||
"fortune": {
|
||||
"quote": "Bleib hungrig, bleiv dumm.",
|
||||
"quote": "Stay hungry, stay foolish.",
|
||||
"author": "— Steve Jobs"
|
||||
},
|
||||
"translator": {
|
||||
"name": "Übersetzer",
|
||||
"description": "In jede Sprache übersetzen und mit Internationalem Phonetischem Alphabet (IPA) annotieren"
|
||||
"description": "In jede Sprache übersetzen und mit dem Internationalen Phonetischen Alphabet (IPA) annotieren"
|
||||
},
|
||||
"textSpeaker": {
|
||||
"name": "Text-Sprecher",
|
||||
"name": "Textvorleser",
|
||||
"description": "Text erkennen und vorlesen, unterstützt Schleifenwiedergabe und Geschwindigkeitsanpassung"
|
||||
},
|
||||
"srtPlayer": {
|
||||
"name": "SRT-Videoplayer",
|
||||
"description": "Videos basierend auf SRT-Untertiteldateien satzweise abspielen, um die Aussprache von Muttersprachlern zu imitieren"
|
||||
"description": "Videos Satz für Satz basierend auf SRT-Untertiteldateien abspielen, um die Aussprache von Muttersprachlern nachzuahmen"
|
||||
},
|
||||
"alphabet": {
|
||||
"name": "Alphabet",
|
||||
"description": "Beginnen Sie mit dem Erlernen einer neuen Sprache mit dem Alphabet"
|
||||
"description": "Beginnen Sie mit dem Lernen einer neuen Sprache vom Alphabet aus"
|
||||
},
|
||||
"memorize": {
|
||||
"name": "Einprägen",
|
||||
"name": "Auswendig lernen",
|
||||
"description": "Sprache A zu Sprache B, Sprache B zu Sprache A, unterstützt Diktat"
|
||||
},
|
||||
"dictionary": {
|
||||
"name": "Wörterbuch",
|
||||
"description": "Wörter und Redewendungen nachschlagen mit detaillierten Definitionen und Beispielen"
|
||||
"description": "Wörter und Ausdrücke mit detaillierten Definitionen und Beispielen nachschlagen"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "Weitere Funktionen",
|
||||
"description": "In Entwicklung, bleiben Sie dran"
|
||||
"description": "In Entwicklung, bleiben Sie gespannt"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authentifizierung",
|
||||
"title": "Anmelden",
|
||||
"signUpTitle": "Registrieren",
|
||||
"signIn": "Anmelden",
|
||||
"signUp": "Registrieren",
|
||||
"email": "E-Mail",
|
||||
"password": "Passwort",
|
||||
"confirmPassword": "Passwort bestätigen",
|
||||
"name": "Name",
|
||||
"username": "Benutzername",
|
||||
"emailOrUsername": "E-Mail oder Benutzername",
|
||||
"signInButton": "Anmelden",
|
||||
"signUpButton": "Registrieren",
|
||||
"noAccount": "Haben Sie kein Konto?",
|
||||
@@ -107,16 +130,47 @@
|
||||
"signUpWithGitHub": "Mit GitHub registrieren",
|
||||
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"passwordTooShort": "Das Passwort muss mindestens 8 Zeichen lang sein",
|
||||
"passwordsNotMatch": "Passwörter stimmen nicht überein",
|
||||
"passwordsNotMatch": "Die Passwörter stimmen nicht überein",
|
||||
"nameRequired": "Bitte geben Sie Ihren Namen ein",
|
||||
"usernameRequired": "Bitte geben Sie einen Benutzernamen ein",
|
||||
"usernameTooShort": "Der Benutzername muss mindestens 3 Zeichen lang sein",
|
||||
"usernameInvalid": "Der Benutzername darf nur Buchstaben, Zahlen und Unterstriche enthalten",
|
||||
"emailRequired": "Bitte geben Sie Ihre E-Mail ein",
|
||||
"identifierRequired": "Bitte geben Sie Ihre E-Mail oder Ihren Benutzernamen ein",
|
||||
"passwordRequired": "Bitte geben Sie Ihr Passwort ein",
|
||||
"confirmPasswordRequired": "Bitte bestätigen Sie Ihr Passwort",
|
||||
"loading": "Laden..."
|
||||
"loading": "Wird geladen...",
|
||||
"confirm": "Bestätigen",
|
||||
"noAccountLink": "Haben Sie kein Konto? Registrieren Sie sich",
|
||||
"hasAccountLink": "Haben Sie bereits ein Konto? Anmelden",
|
||||
"usernamePlaceholder": "Benutzername",
|
||||
"emailPlaceholder": "E-Mail-Adresse",
|
||||
"passwordPlaceholder": "Passwort",
|
||||
"usernameOrEmailPlaceholder": "Benutzername oder E-Mail",
|
||||
"loginFailed": "Anmeldung fehlgeschlagen",
|
||||
"signUpFailed": "Registrierung fehlgeschlagen",
|
||||
"fillAllFields": "Bitte füllen Sie alle Felder aus",
|
||||
"enterCredentials": "Bitte geben Sie Benutzername und Passwort ein",
|
||||
"forgotPassword": "Passwort vergessen",
|
||||
"forgotPasswordHint": "Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link zum Zurücksetzen Ihres Passworts.",
|
||||
"sendResetEmail": "Reset-E-Mail senden",
|
||||
"resetPasswordFailed": "Reset-E-Mail konnte nicht gesendet werden",
|
||||
"resetPasswordEmailSent": "Reset-E-Mail erfolgreich gesendet",
|
||||
"resetPasswordEmailSentHint": "Wir haben einen Link zum Zurücksetzen Ihres Passworts an Ihre E-Mail-Adresse gesendet. Bitte überprüfen Sie Ihren Posteingang.",
|
||||
"checkYourEmail": "Überprüfen Sie Ihre E-Mail",
|
||||
"backToLogin": "Zurück zur Anmeldung",
|
||||
"resetPassword": "Passwort zurücksetzen",
|
||||
"newPassword": "Neues Passwort",
|
||||
"invalidToken": "Ungültiger oder abgelaufener Link",
|
||||
"invalidTokenHint": "Dieser Link zum Zurücksetzen des Passworts ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen an.",
|
||||
"requestNewToken": "Neuen Reset-Link anfordern",
|
||||
"resetPasswordSuccess": "Passwort erfolgreich zurückgesetzt",
|
||||
"resetPasswordSuccessTitle": "Passwort-Zurücksetzung abgeschlossen",
|
||||
"resetPasswordSuccessHint": "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "Wählen Sie einen Ordner aus",
|
||||
"selectFolder": "Wählen Sie einen Ordner",
|
||||
"noFolders": "Keine Ordner gefunden",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
},
|
||||
@@ -138,7 +192,9 @@
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "Anmelden",
|
||||
"profile": "Profil",
|
||||
"folders": "Ordner"
|
||||
"folders": "Ordner",
|
||||
"explore": "Entdecken",
|
||||
"favorites": "Favoriten"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "Mein Profil",
|
||||
@@ -164,21 +220,27 @@
|
||||
"uploaded": "Hochgeladen",
|
||||
"notUploaded": "Nicht hochgeladen",
|
||||
"upload": "Hochladen",
|
||||
"uploadVideoButton": "Video hochladen",
|
||||
"uploadSubtitleButton": "Untertitel hochladen",
|
||||
"subtitleUploaded": "Untertitel hochgeladen ({count} Einträge)",
|
||||
"subtitleNotUploaded": "Untertitel nicht hochgeladen",
|
||||
"autoPauseStatus": "Auto-Pause: {enabled}",
|
||||
"on": "Ein",
|
||||
"off": "Aus",
|
||||
"videoUploadFailed": "Video-Upload fehlgeschlagen",
|
||||
"subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen"
|
||||
"subtitleUploadFailed": "Untertitel-Upload fehlgeschlagen",
|
||||
"subtitleLoadSuccess": "Untertitel erfolgreich geladen",
|
||||
"subtitleLoadFailed": "Laden der Untertitel fehlgeschlagen"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "IPA generieren",
|
||||
"viewSavedItems": "Gespeicherte Elemente anzeigen",
|
||||
"viewSavedItems": "Gespeicherte Einträge anzeigen",
|
||||
"confirmDeleteAll": "Sind Sie sicher, dass Sie alles löschen möchten? (J/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "Sprache erkennen",
|
||||
"generateIPA": "IPA generieren",
|
||||
"translateInto": "Übersetzen in",
|
||||
"translateInto": "übersetzen in",
|
||||
"chinese": "Chinesisch",
|
||||
"english": "Englisch",
|
||||
"french": "Französisch",
|
||||
@@ -190,49 +252,88 @@
|
||||
"russian": "Russisch",
|
||||
"spanish": "Spanisch",
|
||||
"other": "Andere",
|
||||
"translating": "Übersetzung läuft...",
|
||||
"translate": "Übersetzen",
|
||||
"translating": "wird übersetzt...",
|
||||
"translate": "übersetzen",
|
||||
"inputLanguage": "Geben Sie eine Sprache ein.",
|
||||
"history": "Verlauf",
|
||||
"enterLanguage": "Sprache eingeben",
|
||||
"add_to_folder": {
|
||||
"notAuthenticated": "Sie sind nicht authentifiziert",
|
||||
"chooseFolder": "Wählen Sie einen Ordner zum Hinzufügen aus",
|
||||
"chooseFolder": "Wählen Sie einen Ordner zum Hinzufügen",
|
||||
"noFolders": "Keine Ordner gefunden",
|
||||
"folderInfo": "{id}. {name}",
|
||||
"close": "Schließen",
|
||||
"success": "Textpaar zum Ordner hinzugefügt",
|
||||
"error": "Textpaar konnte nicht zum Ordner hinzugefügt werden"
|
||||
"error": "Fehler beim Hinzufügen des Textpaars zum Ordner"
|
||||
},
|
||||
"autoSave": "Automatisch speichern"
|
||||
"autoSave": "Autom. Speichern"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "Wörterbuch",
|
||||
"description": "Wörter und Ausdrücke mit detaillierten Definitionen und Beispielen nachschlagen",
|
||||
"searchPlaceholder": "Wort oder Ausdruck zum Nachschlagen eingeben...",
|
||||
"searching": "Suche...",
|
||||
"searchPlaceholder": "Geben Sie ein Wort oder einen Ausdruck zum Nachschlagen ein...",
|
||||
"searching": "Suche läuft...",
|
||||
"search": "Suchen",
|
||||
"languageSettings": "Spracheinstellungen",
|
||||
"queryLanguage": "Abfragesprache",
|
||||
"queryLanguageHint": "Welche Sprache hat das Wort/die Phrase, die Sie nachschlagen möchten",
|
||||
"queryLanguageHint": "In welcher Sprache ist das Wort/der Ausdruck, den Sie nachschlagen möchten",
|
||||
"definitionLanguage": "Definitionssprache",
|
||||
"definitionLanguageHint": "In welcher Sprache möchten Sie die Definitionen sehen",
|
||||
"otherLanguagePlaceholder": "Oder eine andere Sprache eingeben...",
|
||||
"definitionLanguageHint": "In welcher Sprache möchten Sie die Definitionen",
|
||||
"otherLanguagePlaceholder": "Oder geben Sie eine andere Sprache ein...",
|
||||
"other": "Andere",
|
||||
"currentSettings": "Aktuelle Einstellungen: Abfrage {queryLang}, Definition {definitionLang}",
|
||||
"relookup": "Neu suchen",
|
||||
"relookup": "Erneut suchen",
|
||||
"saveToFolder": "In Ordner speichern",
|
||||
"loading": "Laden...",
|
||||
"loading": "Wird geladen...",
|
||||
"noResults": "Keine Ergebnisse gefunden",
|
||||
"tryOtherWords": "Versuchen Sie andere Wörter oder Ausdrücke",
|
||||
"welcomeTitle": "Willkommen beim Wörterbuch",
|
||||
"welcomeHint": "Geben Sie oben im Suchfeld ein Wort oder einen Ausdruck ein, um zu suchen",
|
||||
"lookupFailed": "Suche fehlgeschlagen, bitte später erneut versuchen",
|
||||
"relookupSuccess": "Erfolgreich neu gesucht",
|
||||
"relookupFailed": "Wörterbuch Neu-Suche fehlgeschlagen",
|
||||
"welcomeTitle": "Willkommen im Wörterbuch",
|
||||
"welcomeHint": "Geben Sie oben in das Suchfeld ein Wort oder einen Ausdruck ein, um mit dem Nachschlagen zu beginnen",
|
||||
"lookupFailed": "Suche fehlgeschlagen, bitte versuchen Sie es später erneut",
|
||||
"relookupSuccess": "Erneute Suche erfolgreich",
|
||||
"relookupFailed": "Erneute Wörterbuchsuche fehlgeschlagen",
|
||||
"pleaseLogin": "Bitte melden Sie sich zuerst an",
|
||||
"pleaseCreateFolder": "Bitte erstellen Sie zuerst einen Ordner",
|
||||
"savedToFolder": "Im Ordner gespeichert: {folderName}",
|
||||
"saveFailed": "Speichern fehlgeschlagen, bitte später erneut versuchen"
|
||||
"savedToFolder": "In Ordner gespeichert: {folderName}",
|
||||
"saveFailed": "Speichern fehlgeschlagen, bitte versuchen Sie es später erneut",
|
||||
"definition": "Definition",
|
||||
"example": "Beispiel"
|
||||
},
|
||||
"explore": {
|
||||
"title": "Entdecken",
|
||||
"subtitle": "Öffentliche Ordner entdecken",
|
||||
"searchPlaceholder": "Öffentliche Ordner durchsuchen...",
|
||||
"loading": "Wird geladen...",
|
||||
"noFolders": "Keine öffentlichen Ordner gefunden",
|
||||
"folderInfo": "{userName} • {totalPairs} Paare",
|
||||
"unknownUser": "Unbekannter Benutzer",
|
||||
"favorite": "Favorisieren",
|
||||
"unfavorite": "Aus Favoriten entfernen",
|
||||
"pleaseLogin": "Bitte melden Sie sich zuerst an",
|
||||
"sortByFavorites": "Nach Favoriten sortieren",
|
||||
"sortByFavoritesActive": "Sortierung nach Favoriten aufheben"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "Ordnerdetails",
|
||||
"createdBy": "Erstellt von: {name}",
|
||||
"unknownUser": "Unbekannter Benutzer",
|
||||
"totalPairs": "Gesamtpaare",
|
||||
"favorites": "Favoriten",
|
||||
"createdAt": "Erstellt am",
|
||||
"viewContent": "Inhalt anzeigen",
|
||||
"favorite": "Favorisieren",
|
||||
"unfavorite": "Aus Favoriten entfernen",
|
||||
"favorited": "Favorisiert",
|
||||
"unfavorited": "Aus Favoriten entfernt",
|
||||
"pleaseLogin": "Bitte melden Sie sich zuerst an"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Meine Favoriten",
|
||||
"subtitle": "Ordner, die Sie favorisiert haben",
|
||||
"loading": "Wird geladen...",
|
||||
"noFavorites": "Noch keine Favoriten",
|
||||
"folderInfo": "{userName} • {totalPairs} Paare",
|
||||
"unknownUser": "Unbekannter Benutzer"
|
||||
},
|
||||
"user_profile": {
|
||||
"anonymous": "Anonym",
|
||||
@@ -245,14 +346,15 @@
|
||||
"displayName": "Anzeigename",
|
||||
"notSet": "Nicht festgelegt",
|
||||
"memberSince": "Mitglied seit",
|
||||
"logout": "Abmelden",
|
||||
"folders": {
|
||||
"title": "Ordner",
|
||||
"noFolders": "Noch keine Ordner",
|
||||
"folderName": "Ordnername",
|
||||
"totalPairs": "Anzahl der Paare",
|
||||
"totalPairs": "Gesamtpaare",
|
||||
"createdAt": "Erstellt am",
|
||||
"actions": "Aktionen",
|
||||
"view": "Ansehen"
|
||||
"view": "Anzeigen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "Please select the characters you want to learn",
|
||||
"chooseAlphabetHint": "Select an alphabet to start learning",
|
||||
"japanese": "Japanese Kana",
|
||||
"english": "English Alphabet",
|
||||
"uyghur": "Uyghur Alphabet",
|
||||
@@ -14,7 +15,11 @@
|
||||
"roman": "Romanization",
|
||||
"letter": "Letter",
|
||||
"random": "Random Mode",
|
||||
"randomNext": "Random Next"
|
||||
"randomNext": "Random Next",
|
||||
"previousLetter": "Previous letter",
|
||||
"nextLetter": "Next letter",
|
||||
"keyboardHint": "Use left/right arrow keys or space for random, ESC to go back",
|
||||
"swipeHint": "Use left/right arrow keys or swipe to navigate, ESC to go back"
|
||||
},
|
||||
"folders": {
|
||||
"title": "Folders",
|
||||
@@ -24,7 +29,22 @@
|
||||
"noFoldersYet": "No folders yet",
|
||||
"folderInfo": "ID: {id} • {totalPairs} pairs",
|
||||
"enterFolderName": "Enter folder name:",
|
||||
"confirmDelete": "Type \"{name}\" to delete:"
|
||||
"confirmDelete": "Type \"{name}\" to delete:",
|
||||
"myFolders": "My Folders",
|
||||
"publicFolders": "Public Folders",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"setPublic": "Set Public",
|
||||
"setPrivate": "Set Private",
|
||||
"publicFolderInfo": "{userName} • {totalPairs} pairs",
|
||||
"searchPlaceholder": "Search public folders...",
|
||||
"loading": "Loading...",
|
||||
"noPublicFolders": "No public folders found",
|
||||
"unknownUser": "Unknown User",
|
||||
"enterNewName": "Enter new name:",
|
||||
"favorite": "Favorite",
|
||||
"unfavorite": "Unfavorite",
|
||||
"pleaseLogin": "Please login first"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "You are not the owner of this folder",
|
||||
@@ -92,7 +112,8 @@
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authentication",
|
||||
"title": "Sign In",
|
||||
"signUpTitle": "Sign Up",
|
||||
"signIn": "Sign In",
|
||||
"signUp": "Sign Up",
|
||||
"email": "Email",
|
||||
@@ -118,7 +139,34 @@
|
||||
"identifierRequired": "Please enter your email or username",
|
||||
"passwordRequired": "Please enter your password",
|
||||
"confirmPasswordRequired": "Please confirm your password",
|
||||
"loading": "Loading..."
|
||||
"loading": "Loading...",
|
||||
"confirm": "Confirm",
|
||||
"noAccountLink": "Don't have an account? Sign up",
|
||||
"hasAccountLink": "Already have an account? Sign in",
|
||||
"usernamePlaceholder": "Username",
|
||||
"emailPlaceholder": "Email address",
|
||||
"passwordPlaceholder": "Password",
|
||||
"usernameOrEmailPlaceholder": "Username or email",
|
||||
"loginFailed": "Login failed",
|
||||
"signUpFailed": "Sign up failed",
|
||||
"fillAllFields": "Please fill in all fields",
|
||||
"enterCredentials": "Please enter username and password",
|
||||
"forgotPassword": "Forgot Password",
|
||||
"forgotPasswordHint": "Enter your email address and we'll send you a link to reset your password.",
|
||||
"sendResetEmail": "Send Reset Email",
|
||||
"resetPasswordFailed": "Failed to send reset email",
|
||||
"resetPasswordEmailSent": "Reset email sent successfully",
|
||||
"resetPasswordEmailSentHint": "We've sent a password reset link to your email address. Please check your inbox.",
|
||||
"checkYourEmail": "Check Your Email",
|
||||
"backToLogin": "Back to Login",
|
||||
"resetPassword": "Reset Password",
|
||||
"newPassword": "New Password",
|
||||
"invalidToken": "Invalid or Expired Link",
|
||||
"invalidTokenHint": "This password reset link is invalid or has expired. Please request a new one.",
|
||||
"requestNewToken": "Request New Reset Link",
|
||||
"resetPasswordSuccess": "Password reset successfully",
|
||||
"resetPasswordSuccessTitle": "Password Reset Complete",
|
||||
"resetPasswordSuccessHint": "Your password has been reset successfully. You can now log in with your new password."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
@@ -144,7 +192,9 @@
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "Sign In",
|
||||
"profile": "Profile",
|
||||
"folders": "Folders"
|
||||
"folders": "Folders",
|
||||
"explore": "Explore",
|
||||
"favorites": "Favorites"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "My Profile",
|
||||
@@ -170,11 +220,17 @@
|
||||
"uploaded": "Uploaded",
|
||||
"notUploaded": "Not Uploaded",
|
||||
"upload": "Upload",
|
||||
"uploadVideoButton": "Upload Video",
|
||||
"uploadSubtitleButton": "Upload Subtitle",
|
||||
"subtitleUploaded": "Subtitle Uploaded ({count} entries)",
|
||||
"subtitleNotUploaded": "Subtitle Not Uploaded",
|
||||
"autoPauseStatus": "Auto Pause: {enabled}",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"videoUploadFailed": "Video upload failed",
|
||||
"subtitleUploadFailed": "Subtitle upload failed"
|
||||
"subtitleUploadFailed": "Subtitle upload failed",
|
||||
"subtitleLoadSuccess": "Subtitle loaded successfully",
|
||||
"subtitleLoadFailed": "Subtitle load failed"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "Generate IPA",
|
||||
@@ -224,6 +280,7 @@
|
||||
"definitionLanguage": "Definition Language",
|
||||
"definitionLanguageHint": "What language do you want the definitions in",
|
||||
"otherLanguagePlaceholder": "Or enter another language...",
|
||||
"other": "Other",
|
||||
"currentSettings": "Current settings: Query {queryLang}, Definition {definitionLang}",
|
||||
"relookup": "Re-search",
|
||||
"saveToFolder": "Save to folder",
|
||||
@@ -238,7 +295,45 @@
|
||||
"pleaseLogin": "Please log in first",
|
||||
"pleaseCreateFolder": "Please create a folder first",
|
||||
"savedToFolder": "Saved to folder: {folderName}",
|
||||
"saveFailed": "Save failed, please try again later"
|
||||
"saveFailed": "Save failed, please try again later",
|
||||
"definition": "Definition",
|
||||
"example": "Example"
|
||||
},
|
||||
"explore": {
|
||||
"title": "Explore",
|
||||
"subtitle": "Discover public folders",
|
||||
"searchPlaceholder": "Search public folders...",
|
||||
"loading": "Loading...",
|
||||
"noFolders": "No public folders found",
|
||||
"folderInfo": "{userName} • {totalPairs} pairs",
|
||||
"unknownUser": "Unknown User",
|
||||
"favorite": "Favorite",
|
||||
"unfavorite": "Unfavorite",
|
||||
"pleaseLogin": "Please login first",
|
||||
"sortByFavorites": "Sort by favorites",
|
||||
"sortByFavoritesActive": "Undo sort by favorites"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "Folder Details",
|
||||
"createdBy": "Created by: {name}",
|
||||
"unknownUser": "Unknown User",
|
||||
"totalPairs": "Total Pairs",
|
||||
"favorites": "Favorites",
|
||||
"createdAt": "Created At",
|
||||
"viewContent": "View Content",
|
||||
"favorite": "Favorite",
|
||||
"unfavorite": "Unfavorite",
|
||||
"favorited": "Favorited",
|
||||
"unfavorited": "Unfavorited",
|
||||
"pleaseLogin": "Please login first"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "My Favorites",
|
||||
"subtitle": "Folders you've favorited",
|
||||
"loading": "Loading...",
|
||||
"noFavorites": "No favorites yet",
|
||||
"folderInfo": "{userName} • {totalPairs} pairs",
|
||||
"unknownUser": "Unknown User"
|
||||
},
|
||||
"user_profile": {
|
||||
"anonymous": "Anonymous",
|
||||
@@ -251,6 +346,7 @@
|
||||
"displayName": "Display Name",
|
||||
"notSet": "Not Set",
|
||||
"memberSince": "Member Since",
|
||||
"logout": "Logout",
|
||||
"folders": {
|
||||
"title": "Folders",
|
||||
"noFolders": "No folders yet",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "Veuillez sélectionner les caractères que vous souhaitez apprendre",
|
||||
"chooseAlphabetHint": "Sélectionnez un alphabet pour commencer à apprendre",
|
||||
"japanese": "Kana japonais",
|
||||
"english": "Alphabet anglais",
|
||||
"uyghur": "Alphabet ouïghour",
|
||||
@@ -14,29 +15,48 @@
|
||||
"roman": "Romanisation",
|
||||
"letter": "Lettre",
|
||||
"random": "Mode aléatoire",
|
||||
"randomNext": "Suivant aléatoire"
|
||||
"randomNext": "Suivant aléatoire",
|
||||
"previousLetter": "Lettre précédente",
|
||||
"nextLetter": "Lettre suivante",
|
||||
"keyboardHint": "Utilisez les touches fléchées gauche/droite ou espace pour aléatoire, ÉCHAP pour revenir",
|
||||
"swipeHint": "Utilisez les touches fléchées gauche/droite ou balayez pour naviguer, ÉCHAP pour revenir"
|
||||
},
|
||||
"folders": {
|
||||
"title": "Dossiers",
|
||||
"subtitle": "Gérez vos collections",
|
||||
"newFolder": "Nouveau dossier",
|
||||
"creating": "Création...",
|
||||
"noFoldersYet": "Aucun dossier pour le moment",
|
||||
"folderInfo": "ID: {id} • {totalPairs} paires",
|
||||
"enterFolderName": "Entrez le nom du dossier:",
|
||||
"confirmDelete": "Tapez \"{name}\" pour supprimer:"
|
||||
"noFoldersYet": "Pas encore de dossiers",
|
||||
"folderInfo": "ID : {id} • {totalPairs} paires",
|
||||
"enterFolderName": "Entrez le nom du dossier :",
|
||||
"confirmDelete": "Tapez \"{name}\" pour supprimer :",
|
||||
"myFolders": "Mes dossiers",
|
||||
"publicFolders": "Dossiers publics",
|
||||
"public": "Public",
|
||||
"private": "Privé",
|
||||
"setPublic": "Définir comme public",
|
||||
"setPrivate": "Définir comme privé",
|
||||
"publicFolderInfo": "{userName} • {totalPairs} paires",
|
||||
"searchPlaceholder": "Rechercher des dossiers publics...",
|
||||
"loading": "Chargement...",
|
||||
"noPublicFolders": "Aucun dossier public trouvé",
|
||||
"unknownUser": "Utilisateur inconnu",
|
||||
"enterNewName": "Entrez le nouveau nom :",
|
||||
"favorite": "Favori",
|
||||
"unfavorite": "Retirer des favoris",
|
||||
"pleaseLogin": "Veuillez vous connecter d'abord"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "Vous n'êtes pas le propriétaire de ce dossier",
|
||||
"back": "Retour",
|
||||
"textPairs": "Paires de textes",
|
||||
"textPairs": "Paires de texte",
|
||||
"itemsCount": "{count} éléments",
|
||||
"memorize": "Mémoriser",
|
||||
"loadingTextPairs": "Chargement des paires de textes...",
|
||||
"noTextPairs": "Aucune paire de textes dans ce dossier",
|
||||
"addNewTextPair": "Ajouter une nouvelle paire de textes",
|
||||
"loadingTextPairs": "Chargement des paires de texte...",
|
||||
"noTextPairs": "Aucune paire de texte dans ce dossier",
|
||||
"addNewTextPair": "Ajouter une nouvelle paire de texte",
|
||||
"add": "Ajouter",
|
||||
"updateTextPair": "Mettre à jour la paire de textes",
|
||||
"updateTextPair": "Mettre à jour la paire de texte",
|
||||
"update": "Mettre à jour",
|
||||
"text1": "Texte 1",
|
||||
"text2": "Texte 2",
|
||||
@@ -56,15 +76,15 @@
|
||||
},
|
||||
"home": {
|
||||
"title": "Apprendre les langues",
|
||||
"description": "Voici un site web très utile pour vous aider à apprendre presque toutes les langues du monde, y compris les langues construites.",
|
||||
"description": "Voici un site Web très utile pour vous aider à apprendre presque toutes les langues du monde, y compris les langues construites.",
|
||||
"explore": "Explorer",
|
||||
"fortune": {
|
||||
"quote": "Stay hungry, stay foolish.",
|
||||
"quote": "Restez affamés, restez fous.",
|
||||
"author": "— Steve Jobs"
|
||||
},
|
||||
"translator": {
|
||||
"name": "Traducteur",
|
||||
"description": "Traduire dans n'importe quelle langue et annoter avec l'alphabet phonétique international (API)"
|
||||
"description": "Traduire vers n'importe quelle langue et annoter avec l'Alphabet Phonétique International (API)"
|
||||
},
|
||||
"textSpeaker": {
|
||||
"name": "Lecteur de texte",
|
||||
@@ -76,15 +96,15 @@
|
||||
},
|
||||
"alphabet": {
|
||||
"name": "Alphabet",
|
||||
"description": "Commencer à apprendre une nouvelle langue par l'alphabet"
|
||||
"description": "Commencez à apprendre une nouvelle langue à partir de l'alphabet"
|
||||
},
|
||||
"memorize": {
|
||||
"name": "Mémoriser",
|
||||
"description": "Langue A vers langue B, langue B vers langue A, prend en charge la dictée"
|
||||
"description": "Langue A vers Langue B, Langue B vers Langue A, prend en charge la dictée"
|
||||
},
|
||||
"dictionary": {
|
||||
"name": "Dictionnaire",
|
||||
"description": "Rechercher des mots et des phrases avec des définitions détaillées et des exemples"
|
||||
"description": "Rechercher des mots et des expressions avec des définitions détaillées et des exemples"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "Plus de fonctionnalités",
|
||||
@@ -92,27 +112,61 @@
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authentification",
|
||||
"title": "Se connecter",
|
||||
"signUpTitle": "S'inscrire",
|
||||
"signIn": "Se connecter",
|
||||
"signUp": "S'inscrire",
|
||||
"email": "E-mail",
|
||||
"password": "Mot de passe",
|
||||
"confirmPassword": "Confirmer le mot de passe",
|
||||
"name": "Nom",
|
||||
"username": "Nom d'utilisateur",
|
||||
"emailOrUsername": "E-mail ou nom d'utilisateur",
|
||||
"signInButton": "Se connecter",
|
||||
"signUpButton": "S'inscrire",
|
||||
"noAccount": "Vous n'avez pas de compte?",
|
||||
"hasAccount": "Vous avez déjà un compte?",
|
||||
"noAccount": "Vous n'avez pas de compte ?",
|
||||
"hasAccount": "Vous avez déjà un compte ?",
|
||||
"signInWithGitHub": "Se connecter avec GitHub",
|
||||
"signUpWithGitHub": "S'inscrire avec GitHub",
|
||||
"invalidEmail": "Veuillez entrer une adresse e-mail valide",
|
||||
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères",
|
||||
"passwordsNotMatch": "Les mots de passe ne correspondent pas",
|
||||
"nameRequired": "Veuillez entrer votre nom",
|
||||
"usernameRequired": "Veuillez entrer un nom d'utilisateur",
|
||||
"usernameTooShort": "Le nom d'utilisateur doit contenir au moins 3 caractères",
|
||||
"usernameInvalid": "Le nom d'utilisateur ne peut contenir que des lettres, des chiffres et des underscores",
|
||||
"emailRequired": "Veuillez entrer votre e-mail",
|
||||
"identifierRequired": "Veuillez entrer votre e-mail ou nom d'utilisateur",
|
||||
"passwordRequired": "Veuillez entrer votre mot de passe",
|
||||
"confirmPasswordRequired": "Veuillez confirmer votre mot de passe",
|
||||
"loading": "Chargement..."
|
||||
"loading": "Chargement...",
|
||||
"confirm": "Confirmer",
|
||||
"noAccountLink": "Vous n'avez pas de compte ? Inscrivez-vous",
|
||||
"hasAccountLink": "Vous avez déjà un compte ? Connectez-vous",
|
||||
"usernamePlaceholder": "Nom d'utilisateur",
|
||||
"emailPlaceholder": "Adresse e-mail",
|
||||
"passwordPlaceholder": "Mot de passe",
|
||||
"usernameOrEmailPlaceholder": "Nom d'utilisateur ou e-mail",
|
||||
"loginFailed": "Échec de la connexion",
|
||||
"signUpFailed": "Échec de l'inscription",
|
||||
"fillAllFields": "Veuillez remplir tous les champs",
|
||||
"enterCredentials": "Veuillez entrer le nom d'utilisateur et le mot de passe",
|
||||
"forgotPassword": "Mot de passe oublié",
|
||||
"forgotPasswordHint": "Entrez votre adresse e-mail et nous vous enverrons un lien pour réinitialiser votre mot de passe.",
|
||||
"sendResetEmail": "Envoyer l'e-mail de réinitialisation",
|
||||
"resetPasswordFailed": "Échec de l'envoi de l'e-mail de réinitialisation",
|
||||
"resetPasswordEmailSent": "E-mail de réinitialisation envoyé avec succès",
|
||||
"resetPasswordEmailSentHint": "Nous avons envoyé un lien de réinitialisation de mot de passe à votre adresse e-mail. Veuillez vérifier votre boîte de réception.",
|
||||
"checkYourEmail": "Vérifiez votre e-mail",
|
||||
"backToLogin": "Retour à la connexion",
|
||||
"resetPassword": "Réinitialiser le mot de passe",
|
||||
"newPassword": "Nouveau mot de passe",
|
||||
"invalidToken": "Lien invalide ou expiré",
|
||||
"invalidTokenHint": "Ce lien de réinitialisation de mot de passe est invalide ou a expiré. Veuillez en demander un nouveau.",
|
||||
"requestNewToken": "Demander un nouveau lien de réinitialisation",
|
||||
"resetPasswordSuccess": "Mot de passe réinitialisé avec succès",
|
||||
"resetPasswordSuccessTitle": "Réinitialisation du mot de passe terminée",
|
||||
"resetPasswordSuccessHint": "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
@@ -125,7 +179,7 @@
|
||||
"next": "Suivant",
|
||||
"reverse": "Inverser",
|
||||
"dictation": "Dictée",
|
||||
"noTextPairs": "Aucune paire de textes disponible",
|
||||
"noTextPairs": "Aucune paire de texte disponible",
|
||||
"disorder": "Désordre",
|
||||
"previous": "Précédent"
|
||||
},
|
||||
@@ -134,46 +188,54 @@
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"title": "learn-languages",
|
||||
"title": "apprendre-langues",
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "Se connecter",
|
||||
"profile": "Profil",
|
||||
"folders": "Dossiers"
|
||||
"folders": "Dossiers",
|
||||
"explore": "Explorer",
|
||||
"favorites": "Favoris"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "Mon profil",
|
||||
"email": "E-mail: {email}",
|
||||
"logout": "Se déconnecter"
|
||||
"email": "E-mail : {email}",
|
||||
"logout": "Déconnexion"
|
||||
},
|
||||
"srt_player": {
|
||||
"uploadVideo": "Télécharger une vidéo",
|
||||
"uploadSubtitle": "Télécharger des sous-titres",
|
||||
"uploadVideo": "Télécharger la vidéo",
|
||||
"uploadSubtitle": "Télécharger les sous-titres",
|
||||
"pause": "Pause",
|
||||
"play": "Lire",
|
||||
"play": "Lecture",
|
||||
"previous": "Précédent",
|
||||
"next": "Suivant",
|
||||
"restart": "Redémarrer",
|
||||
"restart": "Recommencer",
|
||||
"autoPause": "Pause automatique ({enabled})",
|
||||
"uploadVideoAndSubtitle": "Veuillez télécharger des fichiers vidéo et de sous-titres",
|
||||
"uploadVideoFile": "Veuillez télécharger un fichier vidéo",
|
||||
"uploadSubtitleFile": "Veuillez télécharger un fichier de sous-titres",
|
||||
"uploadVideoAndSubtitle": "Veuillez télécharger les fichiers vidéo et sous-titres",
|
||||
"uploadVideoFile": "Veuillez télécharger le fichier vidéo",
|
||||
"uploadSubtitleFile": "Veuillez télécharger le fichier de sous-titres",
|
||||
"processingSubtitle": "Traitement du fichier de sous-titres...",
|
||||
"needBothFiles": "Les fichiers vidéo et de sous-titres sont requis pour commencer l'apprentissage",
|
||||
"needBothFiles": "Les fichiers vidéo et sous-titres sont tous deux requis pour commencer l'apprentissage",
|
||||
"videoFile": "Fichier vidéo",
|
||||
"subtitleFile": "Fichier de sous-titres",
|
||||
"uploaded": "Téléchargé",
|
||||
"notUploaded": "Non téléchargé",
|
||||
"upload": "Télécharger",
|
||||
"autoPauseStatus": "Pause automatique: {enabled}",
|
||||
"uploadVideoButton": "Télécharger la vidéo",
|
||||
"uploadSubtitleButton": "Télécharger les sous-titres",
|
||||
"subtitleUploaded": "Sous-titres téléchargés ({count} entrées)",
|
||||
"subtitleNotUploaded": "Sous-titres non téléchargés",
|
||||
"autoPauseStatus": "Pause automatique : {enabled}",
|
||||
"on": "Activé",
|
||||
"off": "Désactivé",
|
||||
"videoUploadFailed": "Échec du téléchargement de la vidéo",
|
||||
"subtitleUploadFailed": "Échec du téléchargement des sous-titres"
|
||||
"subtitleUploadFailed": "Échec du téléchargement des sous-titres",
|
||||
"subtitleLoadSuccess": "Sous-titres chargés avec succès",
|
||||
"subtitleLoadFailed": "Échec du chargement des sous-titres"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "Générer l'API",
|
||||
"viewSavedItems": "Voir les éléments enregistrés",
|
||||
"confirmDeleteAll": "Êtes-vous sûr de vouloir tout supprimer? (O/N)"
|
||||
"confirmDeleteAll": "Êtes-vous sûr de vouloir tout supprimer ? (O/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "détecter la langue",
|
||||
@@ -194,45 +256,84 @@
|
||||
"translate": "traduire",
|
||||
"inputLanguage": "Entrez une langue.",
|
||||
"history": "Historique",
|
||||
"enterLanguage": "Entrer la langue",
|
||||
"enterLanguage": "Entrez la langue",
|
||||
"add_to_folder": {
|
||||
"notAuthenticated": "Vous n'êtes pas authentifié",
|
||||
"chooseFolder": "Choisir un dossier à ajouter",
|
||||
"chooseFolder": "Choisissez un dossier à ajouter",
|
||||
"noFolders": "Aucun dossier trouvé",
|
||||
"folderInfo": "{id}. {name}",
|
||||
"close": "Fermer",
|
||||
"success": "Paire de textes ajoutée au dossier",
|
||||
"error": "Échec de l'ajout de la paire de textes au dossier"
|
||||
"success": "Paire de texte ajoutée au dossier",
|
||||
"error": "Échec de l'ajout de la paire de texte au dossier"
|
||||
},
|
||||
"autoSave": "Sauvegarde automatique"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "Dictionnaire",
|
||||
"description": "Rechercher des mots et des phrases avec des définitions détaillées et des exemples",
|
||||
"searchPlaceholder": "Entrez un mot ou une phrase à rechercher...",
|
||||
"description": "Rechercher des mots et des expressions avec des définitions détaillées et des exemples",
|
||||
"searchPlaceholder": "Entrez un mot ou une expression à rechercher...",
|
||||
"searching": "Recherche...",
|
||||
"search": "Rechercher",
|
||||
"languageSettings": "Paramètres linguistiques",
|
||||
"languageSettings": "Paramètres de langue",
|
||||
"queryLanguage": "Langue de requête",
|
||||
"queryLanguageHint": "Quelle est la langue du mot/phrase que vous souhaitez rechercher",
|
||||
"queryLanguageHint": "Dans quelle langue est le mot/l'expression que vous voulez rechercher",
|
||||
"definitionLanguage": "Langue de définition",
|
||||
"definitionLanguageHint": "Dans quelle langue souhaitez-vous voir les définitions",
|
||||
"definitionLanguageHint": "Dans quelle langue voulez-vous les définitions",
|
||||
"otherLanguagePlaceholder": "Ou entrez une autre langue...",
|
||||
"other": "Autre",
|
||||
"currentSettings": "Paramètres actuels : Requête {queryLang}, Définition {definitionLang}",
|
||||
"relookup": "Rechercher à nouveau",
|
||||
"saveToFolder": "Enregistrer dans le dossier",
|
||||
"loading": "Chargement...",
|
||||
"noResults": "Aucun résultat trouvé",
|
||||
"tryOtherWords": "Essayez d'autres mots ou phrases",
|
||||
"tryOtherWords": "Essayez d'autres mots ou expressions",
|
||||
"welcomeTitle": "Bienvenue dans le dictionnaire",
|
||||
"welcomeHint": "Entrez un mot ou une phrase dans la zone de recherche ci-dessus pour commencer",
|
||||
"lookupFailed": "Recherche échouée, veuillez réessayer plus tard",
|
||||
"relookupSuccess": "Recherche répétée avec succès",
|
||||
"relookupFailed": "Nouvelle recherche de dictionnaire échouée",
|
||||
"pleaseLogin": "Veuillez d'abord vous connecter",
|
||||
"pleaseCreateFolder": "Veuillez d'abord créer un dossier",
|
||||
"welcomeHint": "Entrez un mot ou une expression dans la zone de recherche ci-dessus pour commencer la recherche",
|
||||
"lookupFailed": "La recherche a échoué, veuillez réessayer plus tard",
|
||||
"relookupSuccess": "Recherche effectuée avec succès",
|
||||
"relookupFailed": "La nouvelle recherche dans le dictionnaire a échoué",
|
||||
"pleaseLogin": "Veuillez vous connecter d'abord",
|
||||
"pleaseCreateFolder": "Veuillez créer un dossier d'abord",
|
||||
"savedToFolder": "Enregistré dans le dossier : {folderName}",
|
||||
"saveFailed": "Échec de l'enregistrement, veuillez réessayer plus tard"
|
||||
"saveFailed": "Échec de l'enregistrement, veuillez réessayer plus tard",
|
||||
"definition": "Définition",
|
||||
"example": "Exemple"
|
||||
},
|
||||
"explore": {
|
||||
"title": "Explorer",
|
||||
"subtitle": "Découvrir les dossiers publics",
|
||||
"searchPlaceholder": "Rechercher des dossiers publics...",
|
||||
"loading": "Chargement...",
|
||||
"noFolders": "Aucun dossier public trouvé",
|
||||
"folderInfo": "{userName} • {totalPairs} paires",
|
||||
"unknownUser": "Utilisateur inconnu",
|
||||
"favorite": "Favori",
|
||||
"unfavorite": "Retirer des favoris",
|
||||
"pleaseLogin": "Veuillez vous connecter d'abord",
|
||||
"sortByFavorites": "Trier par favoris",
|
||||
"sortByFavoritesActive": "Annuler le tri par favoris"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "Détails du dossier",
|
||||
"createdBy": "Créé par : {name}",
|
||||
"unknownUser": "Utilisateur inconnu",
|
||||
"totalPairs": "Total des paires",
|
||||
"favorites": "Favoris",
|
||||
"createdAt": "Créé le",
|
||||
"viewContent": "Voir le contenu",
|
||||
"favorite": "Favori",
|
||||
"unfavorite": "Retirer des favoris",
|
||||
"favorited": "Ajouté aux favoris",
|
||||
"unfavorited": "Retiré des favoris",
|
||||
"pleaseLogin": "Veuillez vous connecter d'abord"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "Mes favoris",
|
||||
"subtitle": "Les dossiers que vous avez mis en favoris",
|
||||
"loading": "Chargement...",
|
||||
"noFavorites": "Pas encore de favoris",
|
||||
"folderInfo": "{userName} • {totalPairs} paires",
|
||||
"unknownUser": "Utilisateur inconnu"
|
||||
},
|
||||
"user_profile": {
|
||||
"anonymous": "Anonyme",
|
||||
@@ -245,11 +346,12 @@
|
||||
"displayName": "Nom d'affichage",
|
||||
"notSet": "Non défini",
|
||||
"memberSince": "Membre depuis",
|
||||
"logout": "Déconnexion",
|
||||
"folders": {
|
||||
"title": "Dossiers",
|
||||
"noFolders": "Aucun dossier pour le moment",
|
||||
"noFolders": "Pas encore de dossiers",
|
||||
"folderName": "Nom du dossier",
|
||||
"totalPairs": "Nombre de paires",
|
||||
"totalPairs": "Total des paires",
|
||||
"createdAt": "Créé le",
|
||||
"actions": "Actions",
|
||||
"view": "Voir"
|
||||
|
||||
@@ -1,48 +1,68 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "Seleziona i caratteri che desideri imparare",
|
||||
"japanese": "Kana giapponese",
|
||||
"english": "Alfabeto inglese",
|
||||
"uyghur": "Alfabeto uiguro",
|
||||
"esperanto": "Alfabeto esperanto",
|
||||
"chooseCharacters": "Seleziona i caratteri che vuoi imparare",
|
||||
"chooseAlphabetHint": "Seleziona un alfabeto per iniziare a imparare",
|
||||
"japanese": "Kana Giapponese",
|
||||
"english": "Alfabeto Inglese",
|
||||
"uyghur": "Alfabeto Uiguro",
|
||||
"esperanto": "Alfabeto Esperanto",
|
||||
"loading": "Caricamento...",
|
||||
"loadFailed": "Caricamento fallito, riprova",
|
||||
"hideLetter": "Nascondi lettera",
|
||||
"showLetter": "Mostra lettera",
|
||||
"hideLetter": "Nascondi Lettera",
|
||||
"showLetter": "Mostra Lettera",
|
||||
"hideIPA": "Nascondi IPA",
|
||||
"showIPA": "Mostra IPA",
|
||||
"roman": "Romanizzazione",
|
||||
"letter": "Lettera",
|
||||
"random": "Modalità casuale",
|
||||
"randomNext": "Successivo casuale"
|
||||
"random": "Modalità Casuale",
|
||||
"randomNext": "Prossimo Casuale",
|
||||
"previousLetter": "Lettera precedente",
|
||||
"nextLetter": "Lettera successiva",
|
||||
"keyboardHint": "Usa le frecce sinistra/destra o spazio per casuale, ESC per tornare indietro",
|
||||
"swipeHint": "Usa le frecce sinistra/destra o scorri per navigare, ESC per tornare indietro"
|
||||
},
|
||||
"folders": {
|
||||
"title": "Cartelle",
|
||||
"subtitle": "Gestisci le tue collezioni",
|
||||
"newFolder": "Nuova cartella",
|
||||
"newFolder": "Nuova Cartella",
|
||||
"creating": "Creazione...",
|
||||
"noFoldersYet": "Nessuna cartella ancora",
|
||||
"folderInfo": "ID: {id} • {totalPairs} coppie",
|
||||
"enterFolderName": "Inserisci nome cartella:",
|
||||
"confirmDelete": "Digita \"{name}\" per eliminare:"
|
||||
"enterFolderName": "Inserisci il nome della cartella:",
|
||||
"confirmDelete": "Digita \"{name}\" per eliminare:",
|
||||
"myFolders": "Le Mie Cartelle",
|
||||
"publicFolders": "Cartelle Pubbliche",
|
||||
"public": "Pubblica",
|
||||
"private": "Privata",
|
||||
"setPublic": "Imposta Pubblica",
|
||||
"setPrivate": "Imposta Privata",
|
||||
"publicFolderInfo": "{userName} • {totalPairs} coppie",
|
||||
"searchPlaceholder": "Cerca cartelle pubbliche...",
|
||||
"loading": "Caricamento...",
|
||||
"noPublicFolders": "Nessuna cartella pubblica trovata",
|
||||
"unknownUser": "Utente Sconosciuto",
|
||||
"enterNewName": "Inserisci nuovo nome:",
|
||||
"favorite": "Preferito",
|
||||
"unfavorite": "Rimuovi dai preferiti",
|
||||
"pleaseLogin": "Per favore accedi prima"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "Non sei il proprietario di questa cartella",
|
||||
"back": "Indietro",
|
||||
"textPairs": "Coppie di testi",
|
||||
"textPairs": "Coppie di Testo",
|
||||
"itemsCount": "{count} elementi",
|
||||
"memorize": "Memorizza",
|
||||
"loadingTextPairs": "Caricamento coppie di testi...",
|
||||
"noTextPairs": "Nessuna coppia di testi in questa cartella",
|
||||
"addNewTextPair": "Aggiungi nuova coppia di testi",
|
||||
"loadingTextPairs": "Caricamento coppie di testo...",
|
||||
"noTextPairs": "Nessuna coppia di testo in questa cartella",
|
||||
"addNewTextPair": "Aggiungi Nuova Coppia di Testo",
|
||||
"add": "Aggiungi",
|
||||
"updateTextPair": "Aggiorna coppia di testi",
|
||||
"updateTextPair": "Aggiorna Coppia di Testo",
|
||||
"update": "Aggiorna",
|
||||
"text1": "Testo 1",
|
||||
"text2": "Testo 2",
|
||||
"language1": "Lingua 1",
|
||||
"language2": "Lingua 2",
|
||||
"enterLanguageName": "Inserisci il nome della lingua",
|
||||
"language1": "Locale 1",
|
||||
"language2": "Locale 2",
|
||||
"enterLanguageName": "Per favore inserisci il nome della lingua",
|
||||
"edit": "Modifica",
|
||||
"delete": "Elimina",
|
||||
"permissionDenied": "Non hai il permesso di eseguire questa azione",
|
||||
@@ -55,8 +75,8 @@
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"title": "Impara le lingue",
|
||||
"description": "Questo è un sito web molto utile che ti aiuta a imparare quasi tutte le lingue del mondo, incluse quelle costruite.",
|
||||
"title": "Impara le Lingue",
|
||||
"description": "Ecco un sito molto utile per aiutarti a imparare quasi tutte le lingue del mondo, incluse quelle costruite.",
|
||||
"explore": "Esplora",
|
||||
"fortune": {
|
||||
"quote": "Stay hungry, stay foolish.",
|
||||
@@ -64,15 +84,15 @@
|
||||
},
|
||||
"translator": {
|
||||
"name": "Traduttore",
|
||||
"description": "Traduci in qualsiasi lingua e annota con l'alfabeto fonetico internazionale (IPA)"
|
||||
"description": "Traduci in qualsiasi lingua e annota con l'Alfabeto Fonetico Internazionale (IPA)"
|
||||
},
|
||||
"textSpeaker": {
|
||||
"name": "Lettore di testo",
|
||||
"description": "Riconosce e legge il testo ad alta voce, supporta la riproduzione in loop e la regolazione della velocità"
|
||||
"name": "Lettore Testo",
|
||||
"description": "Riconosci e leggi il testo ad alta voce, supporta riproduzione in loop e regolazione della velocità"
|
||||
},
|
||||
"srtPlayer": {
|
||||
"name": "Lettore video SRT",
|
||||
"description": "Riproduci video frase per frase basandoti su file di sottotitoli SRT per imitare la pronuncia dei madrelingua"
|
||||
"name": "Lettore Video SRT",
|
||||
"description": "Riproduci video frase per frase basandoti sui file di sottotitoli SRT per imitare la pronuncia dei madrelingua"
|
||||
},
|
||||
"alphabet": {
|
||||
"name": "Alfabeto",
|
||||
@@ -80,39 +100,73 @@
|
||||
},
|
||||
"memorize": {
|
||||
"name": "Memorizza",
|
||||
"description": "Lingua A verso lingua B, lingua B verso lingua A, supporta dettatura"
|
||||
"description": "Lingua A a Lingua B, Lingua B a Lingua A, supporta dettatura"
|
||||
},
|
||||
"dictionary": {
|
||||
"name": "Dizionario",
|
||||
"description": "Cerca parole e frasi con definizioni dettagliate ed esempi"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "Altre funzionalità",
|
||||
"description": "In sviluppo, rimani sintonizzato"
|
||||
"name": "Altre Funzionalità",
|
||||
"description": "In sviluppo, resta sintonizzato"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "Autenticazione",
|
||||
"title": "Accedi",
|
||||
"signUpTitle": "Registrati",
|
||||
"signIn": "Accedi",
|
||||
"signUp": "Registrati",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Conferma password",
|
||||
"confirmPassword": "Conferma Password",
|
||||
"name": "Nome",
|
||||
"username": "Nome Utente",
|
||||
"emailOrUsername": "Email o Nome Utente",
|
||||
"signInButton": "Accedi",
|
||||
"signUpButton": "Registrati",
|
||||
"noAccount": "Non hai un account?",
|
||||
"hasAccount": "Hai già un account?",
|
||||
"signInWithGitHub": "Accedi con GitHub",
|
||||
"signUpWithGitHub": "Registrati con GitHub",
|
||||
"invalidEmail": "Inserisci un indirizzo email valido",
|
||||
"invalidEmail": "Per favore inserisci un indirizzo email valido",
|
||||
"passwordTooShort": "La password deve essere di almeno 8 caratteri",
|
||||
"passwordsNotMatch": "Le password non corrispondono",
|
||||
"nameRequired": "Inserisci il tuo nome",
|
||||
"emailRequired": "Inserisci la tua email",
|
||||
"passwordRequired": "Inserisci la tua password",
|
||||
"confirmPasswordRequired": "Conferma la tua password",
|
||||
"loading": "Caricamento..."
|
||||
"nameRequired": "Per favore inserisci il tuo nome",
|
||||
"usernameRequired": "Per favore inserisci un nome utente",
|
||||
"usernameTooShort": "Il nome utente deve essere di almeno 3 caratteri",
|
||||
"usernameInvalid": "Il nome utente può contenere solo lettere, numeri e trattini bassi",
|
||||
"emailRequired": "Per favore inserisci la tua email",
|
||||
"identifierRequired": "Per favore inserisci la tua email o nome utente",
|
||||
"passwordRequired": "Per favore inserisci la tua password",
|
||||
"confirmPasswordRequired": "Per favore conferma la tua password",
|
||||
"loading": "Caricamento...",
|
||||
"confirm": "Conferma",
|
||||
"noAccountLink": "Non hai un account? Registrati",
|
||||
"hasAccountLink": "Hai già un account? Accedi",
|
||||
"usernamePlaceholder": "Nome utente",
|
||||
"emailPlaceholder": "Indirizzo email",
|
||||
"passwordPlaceholder": "Password",
|
||||
"usernameOrEmailPlaceholder": "Nome utente o email",
|
||||
"loginFailed": "Accesso fallito",
|
||||
"signUpFailed": "Registrazione fallita",
|
||||
"fillAllFields": "Per favore compila tutti i campi",
|
||||
"enterCredentials": "Per favore inserisci nome utente e password",
|
||||
"forgotPassword": "Password Dimenticata",
|
||||
"forgotPasswordHint": "Inserisci il tuo indirizzo email e ti invieremo un link per reimpostare la password.",
|
||||
"sendResetEmail": "Invia Email di Reset",
|
||||
"resetPasswordFailed": "Impossibile inviare email di reset",
|
||||
"resetPasswordEmailSent": "Email di reset inviata con successo",
|
||||
"resetPasswordEmailSentHint": "Abbiamo inviato un link per reimpostare la password al tuo indirizzo email. Controlla la tua casella di posta.",
|
||||
"checkYourEmail": "Controlla la tua Email",
|
||||
"backToLogin": "Torna al Login",
|
||||
"resetPassword": "Reimposta Password",
|
||||
"newPassword": "Nuova Password",
|
||||
"invalidToken": "Link Non Valido o Scaduto",
|
||||
"invalidTokenHint": "Questo link per reimpostare la password non è valido o è scaduto. Richiedine uno nuovo.",
|
||||
"requestNewToken": "Richiedi Nuovo Link di Reset",
|
||||
"resetPasswordSuccess": "Password reimpostata con successo",
|
||||
"resetPasswordSuccessTitle": "Reimpostazione Password Completata",
|
||||
"resetPasswordSuccessHint": "La tua password è stata reimpostata con successo. Ora puoi accedere con la tua nuova password."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
@@ -125,8 +179,8 @@
|
||||
"next": "Successivo",
|
||||
"reverse": "Inverti",
|
||||
"dictation": "Dettatura",
|
||||
"noTextPairs": "Nessuna coppia di testi disponibile",
|
||||
"disorder": "Disordine",
|
||||
"noTextPairs": "Nessuna coppia di testo disponibile",
|
||||
"disorder": "Disordina",
|
||||
"previous": "Precedente"
|
||||
},
|
||||
"page": {
|
||||
@@ -134,45 +188,53 @@
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"title": "learn-languages",
|
||||
"title": "impara-lingue",
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "Accedi",
|
||||
"profile": "Profilo",
|
||||
"folders": "Cartelle"
|
||||
"folders": "Cartelle",
|
||||
"explore": "Esplora",
|
||||
"favorites": "Preferiti"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "Il mio profilo",
|
||||
"myProfile": "Il Mio Profilo",
|
||||
"email": "Email: {email}",
|
||||
"logout": "Esci"
|
||||
},
|
||||
"srt_player": {
|
||||
"uploadVideo": "Carica video",
|
||||
"uploadSubtitle": "Carica sottotitoli",
|
||||
"uploadVideo": "Carica Video",
|
||||
"uploadSubtitle": "Carica Sottotitoli",
|
||||
"pause": "Pausa",
|
||||
"play": "Riproduci",
|
||||
"previous": "Precedente",
|
||||
"next": "Successivo",
|
||||
"restart": "Riavvia",
|
||||
"autoPause": "Pausa automatica ({enabled})",
|
||||
"uploadVideoAndSubtitle": "Carica i file video e sottotitoli",
|
||||
"uploadVideoFile": "Carica un file video",
|
||||
"uploadSubtitleFile": "Carica un file di sottotitoli",
|
||||
"autoPause": "Pausa Automatica ({enabled})",
|
||||
"uploadVideoAndSubtitle": "Per favore carica file video e sottotitoli",
|
||||
"uploadVideoFile": "Per favore carica il file video",
|
||||
"uploadSubtitleFile": "Per favore carica il file sottotitoli",
|
||||
"processingSubtitle": "Elaborazione file sottotitoli...",
|
||||
"needBothFiles": "Sono richiesti sia i file video che i sottotitoli per iniziare l'apprendimento",
|
||||
"videoFile": "File video",
|
||||
"subtitleFile": "File sottotitoli",
|
||||
"needBothFiles": "Sono richiesti sia il file video che quello dei sottotitoli per iniziare a imparare",
|
||||
"videoFile": "File Video",
|
||||
"subtitleFile": "File Sottotitoli",
|
||||
"uploaded": "Caricato",
|
||||
"notUploaded": "Non caricato",
|
||||
"notUploaded": "Non Caricato",
|
||||
"upload": "Carica",
|
||||
"autoPauseStatus": "Pausa automatica: {enabled}",
|
||||
"uploadVideoButton": "Carica Video",
|
||||
"uploadSubtitleButton": "Carica Sottotitoli",
|
||||
"subtitleUploaded": "Sottotitoli Caricati ({count} voci)",
|
||||
"subtitleNotUploaded": "Sottotitoli Non Caricati",
|
||||
"autoPauseStatus": "Pausa Automatica: {enabled}",
|
||||
"on": "Attivo",
|
||||
"off": "Disattivo",
|
||||
"videoUploadFailed": "Caricamento video fallito",
|
||||
"subtitleUploadFailed": "Caricamento sottotitoli fallito"
|
||||
"subtitleUploadFailed": "Caricamento sottotitoli fallito",
|
||||
"subtitleLoadSuccess": "Sottotitoli caricati con successo",
|
||||
"subtitleLoadFailed": "Caricamento sottotitoli fallito"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "Genera IPA",
|
||||
"viewSavedItems": "Visualizza elementi salvati",
|
||||
"viewSavedItems": "Visualizza Elementi Salvati",
|
||||
"confirmDeleteAll": "Sei sicuro di voler eliminare tutto? (S/N)"
|
||||
},
|
||||
"translator": {
|
||||
@@ -197,14 +259,14 @@
|
||||
"enterLanguage": "Inserisci lingua",
|
||||
"add_to_folder": {
|
||||
"notAuthenticated": "Non sei autenticato",
|
||||
"chooseFolder": "Scegli una cartella a cui aggiungere",
|
||||
"chooseFolder": "Scegli una Cartella a cui Aggiungere",
|
||||
"noFolders": "Nessuna cartella trovata",
|
||||
"folderInfo": "{id}. {name}",
|
||||
"close": "Chiudi",
|
||||
"success": "Coppia di testi aggiunta alla cartella",
|
||||
"error": "Impossibile aggiungere la coppia di testi alla cartella"
|
||||
"success": "Coppia di testo aggiunta alla cartella",
|
||||
"error": "Impossibile aggiungere coppia di testo alla cartella"
|
||||
},
|
||||
"autoSave": "Salvataggio automatico"
|
||||
"autoSave": "Salvataggio Automatico"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "Dizionario",
|
||||
@@ -212,45 +274,85 @@
|
||||
"searchPlaceholder": "Inserisci una parola o frase da cercare...",
|
||||
"searching": "Ricerca...",
|
||||
"search": "Cerca",
|
||||
"languageSettings": "Impostazioni lingua",
|
||||
"queryLanguage": "Lingua di interrogazione",
|
||||
"queryLanguageHint": "Quale è la lingua della parola/frase che vuoi cercare",
|
||||
"definitionLanguage": "Lingua di definizione",
|
||||
"definitionLanguageHint": "In quale lingua vuoi vedere le definizioni",
|
||||
"languageSettings": "Impostazioni Lingua",
|
||||
"queryLanguage": "Lingua di Query",
|
||||
"queryLanguageHint": "In che lingua è la parola/frase che vuoi cercare",
|
||||
"definitionLanguage": "Lingua delle Definizioni",
|
||||
"definitionLanguageHint": "In che lingua vuoi le definizioni",
|
||||
"otherLanguagePlaceholder": "Oppure inserisci un'altra lingua...",
|
||||
"currentSettings": "Impostazioni attuali: Interrogazione {queryLang}, Definizione {definitionLang}",
|
||||
"other": "Altro",
|
||||
"currentSettings": "Impostazioni attuali: Query {queryLang}, Definizione {definitionLang}",
|
||||
"relookup": "Ricerca di nuovo",
|
||||
"saveToFolder": "Salva nella cartella",
|
||||
"loading": "Caricamento...",
|
||||
"noResults": "Nessun risultato trovato",
|
||||
"tryOtherWords": "Prova altre parole o frasi",
|
||||
"welcomeTitle": "Benvenuto nel dizionario",
|
||||
"welcomeHint": "Inserisci una parola o frase nella casella di ricerca sopra per iniziare",
|
||||
"welcomeTitle": "Benvenuto nel Dizionario",
|
||||
"welcomeHint": "Inserisci una parola o frase nella casella di ricerca sopra per iniziare a cercare",
|
||||
"lookupFailed": "Ricerca fallita, riprova più tardi",
|
||||
"relookupSuccess": "Ricerca ripetuta con successo",
|
||||
"relookupFailed": "Nuova ricerca del dizionario fallita",
|
||||
"pleaseLogin": "Accedi prima",
|
||||
"pleaseCreateFolder": "Crea prima una cartella",
|
||||
"relookupSuccess": "Ricerca effettuata con successo",
|
||||
"relookupFailed": "Ricerca dizionario fallita",
|
||||
"pleaseLogin": "Per favore accedi prima",
|
||||
"pleaseCreateFolder": "Per favore crea prima una cartella",
|
||||
"savedToFolder": "Salvato nella cartella: {folderName}",
|
||||
"saveFailed": "Salvataggio fallito, riprova più tardi"
|
||||
"saveFailed": "Salvataggio fallito, riprova più tardi",
|
||||
"definition": "Definizione",
|
||||
"example": "Esempio"
|
||||
},
|
||||
"explore": {
|
||||
"title": "Esplora",
|
||||
"subtitle": "Scopri cartelle pubbliche",
|
||||
"searchPlaceholder": "Cerca cartelle pubbliche...",
|
||||
"loading": "Caricamento...",
|
||||
"noFolders": "Nessuna cartella pubblica trovata",
|
||||
"folderInfo": "{userName} • {totalPairs} coppie",
|
||||
"unknownUser": "Utente Sconosciuto",
|
||||
"favorite": "Preferito",
|
||||
"unfavorite": "Rimuovi dai preferiti",
|
||||
"pleaseLogin": "Per favore accedi prima",
|
||||
"sortByFavorites": "Ordina per preferiti",
|
||||
"sortByFavoritesActive": "Annulla ordinamento per preferiti"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "Dettagli Cartella",
|
||||
"createdBy": "Creata da: {name}",
|
||||
"unknownUser": "Utente Sconosciuto",
|
||||
"totalPairs": "Coppie Totali",
|
||||
"favorites": "Preferiti",
|
||||
"createdAt": "Creata Il",
|
||||
"viewContent": "Visualizza Contenuto",
|
||||
"favorite": "Preferito",
|
||||
"unfavorite": "Rimuovi dai preferiti",
|
||||
"favorited": "Aggiunto ai preferiti",
|
||||
"unfavorited": "Rimosso dai preferiti",
|
||||
"pleaseLogin": "Per favore accedi prima"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "I Miei Preferiti",
|
||||
"subtitle": "Cartelle che hai aggiunto ai preferiti",
|
||||
"loading": "Caricamento...",
|
||||
"noFavorites": "Nessun preferito ancora",
|
||||
"folderInfo": "{userName} • {totalPairs} coppie",
|
||||
"unknownUser": "Utente Sconosciuto"
|
||||
},
|
||||
"user_profile": {
|
||||
"anonymous": "Anonimo",
|
||||
"email": "Email",
|
||||
"verified": "Verificato",
|
||||
"unverified": "Non verificato",
|
||||
"accountInfo": "Informazioni account",
|
||||
"userId": "ID utente",
|
||||
"username": "Nome utente",
|
||||
"displayName": "Nome visualizzato",
|
||||
"notSet": "Non impostato",
|
||||
"memberSince": "Membro dal",
|
||||
"unverified": "Non Verificato",
|
||||
"accountInfo": "Informazioni Account",
|
||||
"userId": "ID Utente",
|
||||
"username": "Nome Utente",
|
||||
"displayName": "Nome Visualizzato",
|
||||
"notSet": "Non Impostato",
|
||||
"memberSince": "Membro Dal",
|
||||
"logout": "Esci",
|
||||
"folders": {
|
||||
"title": "Cartelle",
|
||||
"noFolders": "Nessuna cartella ancora",
|
||||
"folderName": "Nome cartella",
|
||||
"totalPairs": "Numero di coppie",
|
||||
"createdAt": "Creato il",
|
||||
"folderName": "Nome Cartella",
|
||||
"totalPairs": "Coppie Totali",
|
||||
"createdAt": "Creata Il",
|
||||
"actions": "Azioni",
|
||||
"view": "Visualizza"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "学習したい文字を選択してください",
|
||||
"chooseAlphabetHint": "学習を始めるアルファベットを選択してください",
|
||||
"japanese": "日本語仮名",
|
||||
"english": "英語アルファベット",
|
||||
"uyghur": "ウイグル文字",
|
||||
"esperanto": "エスペラント文字",
|
||||
"uyghur": "ウイグル語アルファベット",
|
||||
"esperanto": "エスペラント語アルファベット",
|
||||
"loading": "読み込み中...",
|
||||
"loadFailed": "読み込みに失敗しました。もう一度お試しください",
|
||||
"hideLetter": "文字を非表示",
|
||||
@@ -14,23 +15,42 @@
|
||||
"roman": "ローマ字",
|
||||
"letter": "文字",
|
||||
"random": "ランダムモード",
|
||||
"randomNext": "ランダムで次へ"
|
||||
"randomNext": "ランダム次へ",
|
||||
"previousLetter": "前の文字",
|
||||
"nextLetter": "次の文字",
|
||||
"keyboardHint": "左右の矢印キーまたはスペースキーでランダム移動、ESCで戻る",
|
||||
"swipeHint": "左右の矢印キーまたはスワイプで移動、ESCで戻る"
|
||||
},
|
||||
"folders": {
|
||||
"title": "フォルダー",
|
||||
"subtitle": "コレクションを管理",
|
||||
"newFolder": "新規フォルダー",
|
||||
"creating": "作成中...",
|
||||
"noFoldersYet": "フォルダーがありません",
|
||||
"folderInfo": "ID: {id} • {totalPairs}組",
|
||||
"noFoldersYet": "まだフォルダーがありません",
|
||||
"folderInfo": "ID: {id} • {totalPairs} ペア",
|
||||
"enterFolderName": "フォルダー名を入力:",
|
||||
"confirmDelete": "削除するには「{name}」と入力してください:"
|
||||
"confirmDelete": "削除するには「{name}」と入力してください:",
|
||||
"myFolders": "マイフォルダー",
|
||||
"publicFolders": "公開フォルダー",
|
||||
"public": "公開",
|
||||
"private": "非公開",
|
||||
"setPublic": "公開に設定",
|
||||
"setPrivate": "非公開に設定",
|
||||
"publicFolderInfo": "{userName} • {totalPairs} ペア",
|
||||
"searchPlaceholder": "公開フォルダーを検索...",
|
||||
"loading": "読み込み中...",
|
||||
"noPublicFolders": "公開フォルダーが見つかりません",
|
||||
"unknownUser": "不明なユーザー",
|
||||
"enterNewName": "新しい名前を入力:",
|
||||
"favorite": "お気に入り",
|
||||
"unfavorite": "お気に入り解除",
|
||||
"pleaseLogin": "まずログインしてください"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "あなたはこのフォルダーの所有者ではありません",
|
||||
"unauthorized": "このフォルダーの所有者ではありません",
|
||||
"back": "戻る",
|
||||
"textPairs": "テキストペア",
|
||||
"itemsCount": "{count}項目",
|
||||
"itemsCount": "{count} 項目",
|
||||
"memorize": "暗記",
|
||||
"loadingTextPairs": "テキストペアを読み込み中...",
|
||||
"noTextPairs": "このフォルダーにはテキストペアがありません",
|
||||
@@ -45,34 +65,34 @@
|
||||
"enterLanguageName": "言語名を入力してください",
|
||||
"edit": "編集",
|
||||
"delete": "削除",
|
||||
"permissionDenied": "この操作を実行する権限がありません",
|
||||
"permissionDenied": "このアクションを実行する権限がありません",
|
||||
"error": {
|
||||
"update": "この項目を更新する権限がありません。",
|
||||
"delete": "この項目を削除する権限がありません。",
|
||||
"add": "このフォルダーに項目を追加する権限がありません。",
|
||||
"rename": "このフォルダー名を変更する権限がありません。",
|
||||
"rename": "このフォルダーの名前を変更する権限がありません。",
|
||||
"deleteFolder": "このフォルダーを削除する権限がありません。"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"title": "言語を学ぶ",
|
||||
"description": "これは、人工言語を含む世界中のほぼすべての言語を学ぶのに役立つ非常に便利なウェブサイトです。",
|
||||
"description": "ここは世界のほぼすべての言語(人工言語を含む)を学ぶのに役立つ非常に便利なウェブサイトです。",
|
||||
"explore": "探索",
|
||||
"fortune": {
|
||||
"quote": "Stay hungry, stay foolish.",
|
||||
"author": "— スティーブ・ジョブズ"
|
||||
"author": "— Steve Jobs"
|
||||
},
|
||||
"translator": {
|
||||
"name": "翻訳",
|
||||
"description": "任意の言語に翻訳し、国際音声記号(IPA)で注釈を付けます"
|
||||
"name": "翻訳者",
|
||||
"description": "あらゆる言語に翻訳し、国際音声記号(IPA)で注釈を付けます"
|
||||
},
|
||||
"textSpeaker": {
|
||||
"name": "テキストスピーカー",
|
||||
"description": "テキストを認識して音読します。ループ再生と速度調整をサポート"
|
||||
"description": "テキストを認識して読み上げ、ループ再生と速度調整をサポート"
|
||||
},
|
||||
"srtPlayer": {
|
||||
"name": "SRTビデオプレーヤー",
|
||||
"description": "SRT字幕ファイルに基づいてビデオを文ごとに再生し、ネイティブスピーカーの発音を模倣します"
|
||||
"description": "SRT字幕ファイルに基づいて文ごとにビデオを再生し、ネイティブスピーカーの発音を模倣"
|
||||
},
|
||||
"alphabet": {
|
||||
"name": "アルファベット",
|
||||
@@ -80,39 +100,73 @@
|
||||
},
|
||||
"memorize": {
|
||||
"name": "暗記",
|
||||
"description": "言語Aから言語B、言語Bから言語A、ディクテーションをサポート"
|
||||
"description": "言語Aから言語B、言語Bから言語A、書き取りをサポート"
|
||||
},
|
||||
"dictionary": {
|
||||
"name": "辞書",
|
||||
"description": "単語やフレーズを調べ、詳細な定義と例を表示"
|
||||
"description": "詳細な定義と例文で単語やフレーズを検索"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "その他の機能",
|
||||
"description": "開発中です。お楽しみに"
|
||||
"description": "開発中、お楽しみに"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "認証",
|
||||
"signIn": "ログイン",
|
||||
"title": "サインイン",
|
||||
"signUpTitle": "新規登録",
|
||||
"signIn": "サインイン",
|
||||
"signUp": "新規登録",
|
||||
"email": "メールアドレス",
|
||||
"password": "パスワード",
|
||||
"confirmPassword": "パスワード(確認)",
|
||||
"confirmPassword": "パスワード確認",
|
||||
"name": "名前",
|
||||
"signInButton": "ログイン",
|
||||
"username": "ユーザー名",
|
||||
"emailOrUsername": "メールアドレスまたはユーザー名",
|
||||
"signInButton": "サインイン",
|
||||
"signUpButton": "新規登録",
|
||||
"noAccount": "アカウントをお持ちでないですか?",
|
||||
"hasAccount": "すでにアカウントをお持ちですか?",
|
||||
"signInWithGitHub": "GitHubでログイン",
|
||||
"signInWithGitHub": "GitHubでサインイン",
|
||||
"signUpWithGitHub": "GitHubで新規登録",
|
||||
"invalidEmail": "有効なメールアドレスを入力してください",
|
||||
"passwordTooShort": "パスワードは8文字以上である必要があります",
|
||||
"passwordsNotMatch": "パスワードが一致しません",
|
||||
"nameRequired": "名前を入力してください",
|
||||
"usernameRequired": "ユーザー名を入力してください",
|
||||
"usernameTooShort": "ユーザー名は3文字以上である必要があります",
|
||||
"usernameInvalid": "ユーザー名には文字、数字、アンダースコアのみ使用できます",
|
||||
"emailRequired": "メールアドレスを入力してください",
|
||||
"identifierRequired": "メールアドレスまたはユーザー名を入力してください",
|
||||
"passwordRequired": "パスワードを入力してください",
|
||||
"confirmPasswordRequired": "パスワード(確認)を入力してください",
|
||||
"loading": "読み込み中..."
|
||||
"confirmPasswordRequired": "パスワードを確認してください",
|
||||
"loading": "読み込み中...",
|
||||
"confirm": "確認",
|
||||
"noAccountLink": "アカウントをお持ちでないですか? 新規登録",
|
||||
"hasAccountLink": "すでにアカウントをお持ちですか? サインイン",
|
||||
"usernamePlaceholder": "ユーザー名",
|
||||
"emailPlaceholder": "メールアドレス",
|
||||
"passwordPlaceholder": "パスワード",
|
||||
"usernameOrEmailPlaceholder": "ユーザー名またはメールアドレス",
|
||||
"loginFailed": "ログインに失敗しました",
|
||||
"signUpFailed": "新規登録に失敗しました",
|
||||
"fillAllFields": "すべてのフィールドに入力してください",
|
||||
"enterCredentials": "ユーザー名とパスワードを入力してください",
|
||||
"forgotPassword": "パスワードをお忘れですか",
|
||||
"forgotPasswordHint": "メールアドレスを入力してください。パスワードリセット用のリンクをお送りします。",
|
||||
"sendResetEmail": "リセットメールを送信",
|
||||
"resetPasswordFailed": "リセットメールの送信に失敗しました",
|
||||
"resetPasswordEmailSent": "リセットメールを送信しました",
|
||||
"resetPasswordEmailSentHint": "パスワードリセット用のリンクをメールでお送りしました。受信トレイをご確認ください。",
|
||||
"checkYourEmail": "メールをご確認ください",
|
||||
"backToLogin": "ログインに戻る",
|
||||
"resetPassword": "パスワードをリセット",
|
||||
"newPassword": "新しいパスワード",
|
||||
"invalidToken": "無効または期限切れのリンク",
|
||||
"invalidTokenHint": "このパスワードリセットリンクは無効または期限切れです。新しいものをリクエストしてください。",
|
||||
"requestNewToken": "新しいリセットリンクをリクエスト",
|
||||
"resetPasswordSuccess": "パスワードのリセットに成功しました",
|
||||
"resetPasswordSuccessTitle": "パスワードリセット完了",
|
||||
"resetPasswordSuccessHint": "パスワードが正常にリセットされました。新しいパスワードでログインできます。"
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
@@ -121,12 +175,12 @@
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
},
|
||||
"memorize": {
|
||||
"answer": "回答",
|
||||
"answer": "答え",
|
||||
"next": "次へ",
|
||||
"reverse": "逆順",
|
||||
"dictation": "ディクテーション",
|
||||
"dictation": "書き取り",
|
||||
"noTextPairs": "利用可能なテキストペアがありません",
|
||||
"disorder": "ランダム",
|
||||
"disorder": "シャッフル",
|
||||
"previous": "前へ"
|
||||
},
|
||||
"page": {
|
||||
@@ -136,13 +190,15 @@
|
||||
"navbar": {
|
||||
"title": "learn-languages",
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "ログイン",
|
||||
"sign_in": "サインイン",
|
||||
"profile": "プロフィール",
|
||||
"folders": "フォルダー"
|
||||
"folders": "フォルダー",
|
||||
"explore": "探索",
|
||||
"favorites": "お気に入り"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "マイプロフィール",
|
||||
"email": "メールアドレス: {email}",
|
||||
"email": "メール: {email}",
|
||||
"logout": "ログアウト"
|
||||
},
|
||||
"srt_player": {
|
||||
@@ -164,21 +220,27 @@
|
||||
"uploaded": "アップロード済み",
|
||||
"notUploaded": "未アップロード",
|
||||
"upload": "アップロード",
|
||||
"uploadVideoButton": "ビデオをアップロード",
|
||||
"uploadSubtitleButton": "字幕をアップロード",
|
||||
"subtitleUploaded": "字幕をアップロード済み ({count} エントリ)",
|
||||
"subtitleNotUploaded": "字幕がアップロードされていません",
|
||||
"autoPauseStatus": "自動一時停止: {enabled}",
|
||||
"on": "オン",
|
||||
"off": "オフ",
|
||||
"videoUploadFailed": "ビデオのアップロードに失敗しました",
|
||||
"subtitleUploadFailed": "字幕のアップロードに失敗しました"
|
||||
"subtitleUploadFailed": "字幕のアップロードに失敗しました",
|
||||
"subtitleLoadSuccess": "字幕の読み込みに成功しました",
|
||||
"subtitleLoadFailed": "字幕の読み込みに失敗しました"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "IPAを生成",
|
||||
"viewSavedItems": "保存済みアイテムを表示",
|
||||
"confirmDeleteAll": "本当にすべて削除しますか? (Y/N)"
|
||||
"viewSavedItems": "保存済み項目を表示",
|
||||
"confirmDeleteAll": "すべて削除してもよろしいですか? (Y/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "言語を検出",
|
||||
"generateIPA": "IPAを生成",
|
||||
"translateInto": "翻訳",
|
||||
"generateIPA": "ipaを生成",
|
||||
"translateInto": "翻訳先",
|
||||
"chinese": "中国語",
|
||||
"english": "英語",
|
||||
"french": "フランス語",
|
||||
@@ -201,38 +263,77 @@
|
||||
"noFolders": "フォルダーが見つかりません",
|
||||
"folderInfo": "{id}. {name}",
|
||||
"close": "閉じる",
|
||||
"success": "テキストペアをフォルダーに追加しました",
|
||||
"error": "テキストペアの追加に失敗しました"
|
||||
"success": "テキストペアがフォルダーに追加されました",
|
||||
"error": "テキストペアをフォルダーに追加できませんでした"
|
||||
},
|
||||
"autoSave": "自動保存"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "辞書",
|
||||
"description": "詳細な定義と例で単語やフレーズを検索",
|
||||
"description": "詳細な定義と例文で単語やフレーズを検索",
|
||||
"searchPlaceholder": "検索する単語やフレーズを入力...",
|
||||
"searching": "検索中...",
|
||||
"search": "検索",
|
||||
"languageSettings": "言語設定",
|
||||
"queryLanguage": "クエリ言語",
|
||||
"queryLanguageHint": "検索する単語/フレーズの言語",
|
||||
"queryLanguageHint": "検索したい単語/フレーズの言語",
|
||||
"definitionLanguage": "定義言語",
|
||||
"definitionLanguageHint": "定義を表示する言語",
|
||||
"otherLanguagePlaceholder": "または他の言語を入力...",
|
||||
"currentSettings": "現在の設定:クエリ {queryLang}、定義 {definitionLang}",
|
||||
"otherLanguagePlaceholder": "または別の言語を入力...",
|
||||
"other": "その他",
|
||||
"currentSettings": "現在の設定: クエリ {queryLang}, 定義 {definitionLang}",
|
||||
"relookup": "再検索",
|
||||
"saveToFolder": "フォルダに保存",
|
||||
"saveToFolder": "フォルダーに保存",
|
||||
"loading": "読み込み中...",
|
||||
"noResults": "結果が見つかりません",
|
||||
"tryOtherWords": "他の単語やフレーズを試してください",
|
||||
"tryOtherWords": "別の単語やフレーズを試してください",
|
||||
"welcomeTitle": "辞書へようこそ",
|
||||
"welcomeHint": "上の検索ボックスに単語やフレーズを入力して検索を開始",
|
||||
"welcomeHint": "上の検索ボックスに単語やフレーズを入力して検索を始めましょう",
|
||||
"lookupFailed": "検索に失敗しました。後でもう一度お試しください",
|
||||
"relookupSuccess": "再検索しました",
|
||||
"relookupSuccess": "再検索に成功しました",
|
||||
"relookupFailed": "辞書の再検索に失敗しました",
|
||||
"pleaseLogin": "まずログインしてください",
|
||||
"pleaseCreateFolder": "まずフォルダを作成してください",
|
||||
"savedToFolder": "フォルダに保存しました:{folderName}",
|
||||
"saveFailed": "保存に失敗しました。後でもう一度お試しください"
|
||||
"pleaseCreateFolder": "まずフォルダーを作成してください",
|
||||
"savedToFolder": "フォルダーに保存しました: {folderName}",
|
||||
"saveFailed": "保存に失敗しました。後でもう一度お試しください",
|
||||
"definition": "定義",
|
||||
"example": "例文"
|
||||
},
|
||||
"explore": {
|
||||
"title": "探索",
|
||||
"subtitle": "公開フォルダーを発見",
|
||||
"searchPlaceholder": "公開フォルダーを検索...",
|
||||
"loading": "読み込み中...",
|
||||
"noFolders": "公開フォルダーが見つかりません",
|
||||
"folderInfo": "{userName} • {totalPairs} ペア",
|
||||
"unknownUser": "不明なユーザー",
|
||||
"favorite": "お気に入り",
|
||||
"unfavorite": "お気に入り解除",
|
||||
"pleaseLogin": "まずログインしてください",
|
||||
"sortByFavorites": "お気に入り順に並べ替え",
|
||||
"sortByFavoritesActive": "お気に入り順の並べ替えを解除"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "フォルダー詳細",
|
||||
"createdBy": "作成者: {name}",
|
||||
"unknownUser": "不明なユーザー",
|
||||
"totalPairs": "合計ペア数",
|
||||
"favorites": "お気に入り",
|
||||
"createdAt": "作成日",
|
||||
"viewContent": "コンテンツを表示",
|
||||
"favorite": "お気に入り",
|
||||
"unfavorite": "お気に入り解除",
|
||||
"favorited": "お気に入りに追加しました",
|
||||
"unfavorited": "お気に入りから削除しました",
|
||||
"pleaseLogin": "まずログインしてください"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "マイお気に入り",
|
||||
"subtitle": "お気に入りに追加したフォルダー",
|
||||
"loading": "読み込み中...",
|
||||
"noFavorites": "まだお気に入りがありません",
|
||||
"folderInfo": "{userName} • {totalPairs} ペア",
|
||||
"unknownUser": "不明なユーザー"
|
||||
},
|
||||
"user_profile": {
|
||||
"anonymous": "匿名",
|
||||
@@ -245,13 +346,14 @@
|
||||
"displayName": "表示名",
|
||||
"notSet": "未設定",
|
||||
"memberSince": "登録日",
|
||||
"logout": "ログアウト",
|
||||
"folders": {
|
||||
"title": "フォルダー",
|
||||
"noFolders": "フォルダーがありません",
|
||||
"noFolders": "まだフォルダーがありません",
|
||||
"folderName": "フォルダー名",
|
||||
"totalPairs": "テキストペア数",
|
||||
"totalPairs": "合計ペア数",
|
||||
"createdAt": "作成日",
|
||||
"actions": "操作",
|
||||
"actions": "アクション",
|
||||
"view": "表示"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "학습할 문자를 선택하세요",
|
||||
"chooseCharacters": "배우고 싶은 문자를 선택하세요",
|
||||
"chooseAlphabetHint": "학습을 시작할 알파벳을 선택하세요",
|
||||
"japanese": "일본어 가나",
|
||||
"english": "영문 알파벳",
|
||||
"uyghur": "위구르 문자",
|
||||
"esperanto": "에스페란토 문자",
|
||||
"english": "영어 알파벳",
|
||||
"uyghur": "위구르어 알파벳",
|
||||
"esperanto": "에스페란토 알파벳",
|
||||
"loading": "로딩 중...",
|
||||
"loadFailed": "로딩 실패, 다시 시도해 주세요",
|
||||
"loadFailed": "로딩 실패, 다시 시도해주세요",
|
||||
"hideLetter": "문자 숨기기",
|
||||
"showLetter": "문자 표시",
|
||||
"hideIPA": "IPA 숨기기",
|
||||
@@ -14,17 +15,36 @@
|
||||
"roman": "로마자 표기",
|
||||
"letter": "문자",
|
||||
"random": "무작위 모드",
|
||||
"randomNext": "무작위 다음"
|
||||
"randomNext": "무작위 다음",
|
||||
"previousLetter": "이전 문자",
|
||||
"nextLetter": "다음 문자",
|
||||
"keyboardHint": "왼쪽/오른쪽 화살표 키 또는 스페이스바로 무작위, ESC로 뒤로가기",
|
||||
"swipeHint": "왼쪽/오른쪽 화살표 키 또는 스와이프로 탐색, ESC로 뒤로가기"
|
||||
},
|
||||
"folders": {
|
||||
"title": "폴더",
|
||||
"subtitle": "컬렉션 관리",
|
||||
"newFolder": "새 폴더",
|
||||
"creating": "생성 중...",
|
||||
"noFoldersYet": "폴더가 없습니다",
|
||||
"folderInfo": "ID: {id} • {totalPairs}쌍",
|
||||
"noFoldersYet": "아직 폴더가 없습니다",
|
||||
"folderInfo": "ID: {id} • {totalPairs} 쌍",
|
||||
"enterFolderName": "폴더 이름 입력:",
|
||||
"confirmDelete": "삭제하려면 \"{name}\"을(를) 입력하세요:"
|
||||
"confirmDelete": "삭제하려면 \"{name}\"을(를) 입력하세요:",
|
||||
"myFolders": "내 폴더",
|
||||
"publicFolders": "공개 폴더",
|
||||
"public": "공개",
|
||||
"private": "비공개",
|
||||
"setPublic": "공개로 설정",
|
||||
"setPrivate": "비공개로 설정",
|
||||
"publicFolderInfo": "{userName} • {totalPairs} 쌍",
|
||||
"searchPlaceholder": "공개 폴더 검색...",
|
||||
"loading": "로딩 중...",
|
||||
"noPublicFolders": "공개 폴더를 찾을 수 없습니다",
|
||||
"unknownUser": "알 수 없는 사용자",
|
||||
"enterNewName": "새 이름 입력:",
|
||||
"favorite": "즐겨찾기",
|
||||
"unfavorite": "즐겨찾기 해제",
|
||||
"pleaseLogin": "먼저 로그인해주세요"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "이 폴더의 소유자가 아닙니다",
|
||||
@@ -36,39 +56,39 @@
|
||||
"noTextPairs": "이 폴더에 텍스트 쌍이 없습니다",
|
||||
"addNewTextPair": "새 텍스트 쌍 추가",
|
||||
"add": "추가",
|
||||
"updateTextPair": "텍스트 쌍 업데이트",
|
||||
"update": "업데이트",
|
||||
"updateTextPair": "텍스트 쌍 수정",
|
||||
"update": "수정",
|
||||
"text1": "텍스트 1",
|
||||
"text2": "텍스트 2",
|
||||
"language1": "언어 1",
|
||||
"language2": "언어 2",
|
||||
"language1": "로캘 1",
|
||||
"language2": "로캘 2",
|
||||
"enterLanguageName": "언어 이름을 입력하세요",
|
||||
"edit": "편집",
|
||||
"delete": "삭제",
|
||||
"permissionDenied": "이 작업을 수행할 권한이 없습니다",
|
||||
"error": {
|
||||
"update": "이 항목을 업데이트할 권한이 없습니다.",
|
||||
"update": "이 항목을 수정할 권한이 없습니다.",
|
||||
"delete": "이 항목을 삭제할 권한이 없습니다.",
|
||||
"add": "이 폴더에 항목을 추가할 권한이 없습니다.",
|
||||
"rename": "이 폴더 이름을 변경할 권한이 없습니다.",
|
||||
"rename": "이 폴더의 이름을 변경할 권한이 없습니다.",
|
||||
"deleteFolder": "이 폴더를 삭제할 권한이 없습니다."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"title": "언어 학습",
|
||||
"description": "인공 언어를 포함하여 세상의 거의 모든 언어를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.",
|
||||
"title": "언어 배우기",
|
||||
"description": "세계의 거의 모든 언어(인공어 포함)를 배우는 데 도움이 되는 매우 유용한 웹사이트입니다.",
|
||||
"explore": "탐색",
|
||||
"fortune": {
|
||||
"quote": "Stay hungry, stay foolish.",
|
||||
"author": "— 스티브 잡스"
|
||||
"author": "— Steve Jobs"
|
||||
},
|
||||
"translator": {
|
||||
"name": "번역기",
|
||||
"description": "모든 언어로 번역하고 국제 음성 기호(IPA)로 주석 추가"
|
||||
"description": "모든 언어로 번역하고 국제 음성 기호(IPA)로 주석 달기"
|
||||
},
|
||||
"textSpeaker": {
|
||||
"name": "텍스트 스피커",
|
||||
"description": "텍스트를 인식하고 읽어줍니다. 반복 재생 및 속도 조정 지원"
|
||||
"description": "텍스트 인식 및 낭독, 반복 재생 및 속도 조절 지원"
|
||||
},
|
||||
"srtPlayer": {
|
||||
"name": "SRT 비디오 플레이어",
|
||||
@@ -84,21 +104,24 @@
|
||||
},
|
||||
"dictionary": {
|
||||
"name": "사전",
|
||||
"description": "단어와 구문을 조회하고 자세한 정의와 예제 제공"
|
||||
"description": "상세한 정의와 예문으로 단어 및 구문 검색"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "더 많은 기능",
|
||||
"description": "개발 중, 기대해 주세요"
|
||||
"description": "개발 중, 기대해주세요"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "인증",
|
||||
"title": "로그인",
|
||||
"signUpTitle": "회원가입",
|
||||
"signIn": "로그인",
|
||||
"signUp": "회원가입",
|
||||
"email": "이메일",
|
||||
"password": "비밀번호",
|
||||
"confirmPassword": "비밀번호 확인",
|
||||
"name": "이름",
|
||||
"username": "사용자명",
|
||||
"emailOrUsername": "이메일 또는 사용자명",
|
||||
"signInButton": "로그인",
|
||||
"signUpButton": "회원가입",
|
||||
"noAccount": "계정이 없으신가요?",
|
||||
@@ -109,10 +132,41 @@
|
||||
"passwordTooShort": "비밀번호는 최소 8자 이상이어야 합니다",
|
||||
"passwordsNotMatch": "비밀번호가 일치하지 않습니다",
|
||||
"nameRequired": "이름을 입력하세요",
|
||||
"usernameRequired": "사용자명을 입력하세요",
|
||||
"usernameTooShort": "사용자명은 최소 3자 이상이어야 합니다",
|
||||
"usernameInvalid": "사용자명은 문자, 숫자, 밑줄만 포함할 수 있습니다",
|
||||
"emailRequired": "이메일을 입력하세요",
|
||||
"identifierRequired": "이메일 또는 사용자명을 입력하세요",
|
||||
"passwordRequired": "비밀번호를 입력하세요",
|
||||
"confirmPasswordRequired": "비밀번호 확인을 입력하세요",
|
||||
"loading": "로딩 중..."
|
||||
"confirmPasswordRequired": "비밀번호를 확인하세요",
|
||||
"loading": "로딩 중...",
|
||||
"confirm": "확인",
|
||||
"noAccountLink": "계정이 없으신가요? 회원가입",
|
||||
"hasAccountLink": "이미 계정이 있으신가요? 로그인",
|
||||
"usernamePlaceholder": "사용자명",
|
||||
"emailPlaceholder": "이메일 주소",
|
||||
"passwordPlaceholder": "비밀번호",
|
||||
"usernameOrEmailPlaceholder": "사용자명 또는 이메일",
|
||||
"loginFailed": "로그인 실패",
|
||||
"signUpFailed": "회원가입 실패",
|
||||
"fillAllFields": "모든 필드를 입력하세요",
|
||||
"enterCredentials": "사용자명과 비밀번호를 입력하세요",
|
||||
"forgotPassword": "비밀번호 찾기",
|
||||
"forgotPasswordHint": "이메일 주소를 입력하시면 비밀번호 재설정 링크를 보내드립니다.",
|
||||
"sendResetEmail": "재설정 이메일 보내기",
|
||||
"resetPasswordFailed": "재설정 이메일 전송 실패",
|
||||
"resetPasswordEmailSent": "재설정 이메일이 전송되었습니다",
|
||||
"resetPasswordEmailSentHint": "비밀번호 재설정 링크를 이메일로 보냈습니다. 받은 편지함을 확인해주세요.",
|
||||
"checkYourEmail": "이메일을 확인하세요",
|
||||
"backToLogin": "로그인으로 돌아가기",
|
||||
"resetPassword": "비밀번호 재설정",
|
||||
"newPassword": "새 비밀번호",
|
||||
"invalidToken": "유효하지 않거나 만료된 링크",
|
||||
"invalidTokenHint": "이 비밀번호 재설정 링크는 유효하지 않거나 만료되었습니다. 새로 요청해 주세요.",
|
||||
"requestNewToken": "새 재설정 링크 요청",
|
||||
"resetPasswordSuccess": "비밀번호 재설정 성공",
|
||||
"resetPasswordSuccessTitle": "비밀번호 재설정 완료",
|
||||
"resetPasswordSuccessHint": "비밀번호가 성공적으로 재설정되었습니다. 새 비밀번호로 로그인할 수 있습니다."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
@@ -130,7 +184,7 @@
|
||||
"previous": "이전"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "이 폴더에 액세스할 권한이 없습니다"
|
||||
"unauthorized": "이 폴더에 접근할 권한이 없습니다"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
@@ -138,7 +192,9 @@
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "로그인",
|
||||
"profile": "프로필",
|
||||
"folders": "폴더"
|
||||
"folders": "폴더",
|
||||
"explore": "탐색",
|
||||
"favorites": "즐겨찾기"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "내 프로필",
|
||||
@@ -152,7 +208,7 @@
|
||||
"play": "재생",
|
||||
"previous": "이전",
|
||||
"next": "다음",
|
||||
"restart": "처음부터",
|
||||
"restart": "다시 시작",
|
||||
"autoPause": "자동 일시정지 ({enabled})",
|
||||
"uploadVideoAndSubtitle": "비디오와 자막 파일을 업로드하세요",
|
||||
"uploadVideoFile": "비디오 파일을 업로드하세요",
|
||||
@@ -164,21 +220,27 @@
|
||||
"uploaded": "업로드됨",
|
||||
"notUploaded": "업로드되지 않음",
|
||||
"upload": "업로드",
|
||||
"uploadVideoButton": "비디오 업로드",
|
||||
"uploadSubtitleButton": "자막 업로드",
|
||||
"subtitleUploaded": "자막 업로드됨 ({count}개 항목)",
|
||||
"subtitleNotUploaded": "자막 업로드되지 않음",
|
||||
"autoPauseStatus": "자동 일시정지: {enabled}",
|
||||
"on": "켜기",
|
||||
"off": "끄기",
|
||||
"videoUploadFailed": "비디오 업로드 실패",
|
||||
"subtitleUploadFailed": "자막 업로드 실패"
|
||||
"subtitleUploadFailed": "자막 업로드 실패",
|
||||
"subtitleLoadSuccess": "자막 로드 성공",
|
||||
"subtitleLoadFailed": "자막 로드 실패"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "IPA 생성",
|
||||
"viewSavedItems": "저장된 항목 보기",
|
||||
"confirmDeleteAll": "정말 모두 삭제하시겠습니까? (Y/N)"
|
||||
"confirmDeleteAll": "모든 것을 삭제하시겠습니까? (Y/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "언어 감지",
|
||||
"generateIPA": "IPA 생성",
|
||||
"translateInto": "번역",
|
||||
"translateInto": "번역할 언어",
|
||||
"chinese": "중국어",
|
||||
"english": "영어",
|
||||
"french": "프랑스어",
|
||||
@@ -201,38 +263,77 @@
|
||||
"noFolders": "폴더를 찾을 수 없습니다",
|
||||
"folderInfo": "{id}. {name}",
|
||||
"close": "닫기",
|
||||
"success": "텍스트 쌍을 폴더에 추가했습니다",
|
||||
"error": "텍스트 쌍 추가 실패"
|
||||
"success": "텍스트 쌍이 폴더에 추가됨",
|
||||
"error": "폴더에 텍스트 쌍 추가 실패"
|
||||
},
|
||||
"autoSave": "자동 저장"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "사전",
|
||||
"description": "상세한 정의와 예제로 단어 및 구문 검색",
|
||||
"description": "상세한 정의와 예문으로 단어 및 구문 검색",
|
||||
"searchPlaceholder": "검색할 단어나 구문을 입력하세요...",
|
||||
"searching": "검색 중...",
|
||||
"search": "검색",
|
||||
"languageSettings": "언어 설정",
|
||||
"queryLanguage": "쿼리 언어",
|
||||
"queryLanguageHint": "검색하려는 단어/구문의 언어",
|
||||
"queryLanguage": "질의 언어",
|
||||
"queryLanguageHint": "검색할 단어/구문의 언어",
|
||||
"definitionLanguage": "정의 언어",
|
||||
"definitionLanguageHint": "정의를 표시할 언어",
|
||||
"otherLanguagePlaceholder": "또는 다른 언어를 입력하세요...",
|
||||
"currentSettings": "현재 설정: 쿼리 {queryLang}, 정의 {definitionLang}",
|
||||
"relookup": "재검색",
|
||||
"otherLanguagePlaceholder": "또는 다른 언어 입력...",
|
||||
"other": "기타",
|
||||
"currentSettings": "현재 설정: 질의 {queryLang}, 정의 {definitionLang}",
|
||||
"relookup": "다시 검색",
|
||||
"saveToFolder": "폴더에 저장",
|
||||
"loading": "로드 중...",
|
||||
"noResults": "결과를 찾을 수 없습니다",
|
||||
"loading": "로딩 중...",
|
||||
"noResults": "검색 결과 없음",
|
||||
"tryOtherWords": "다른 단어나 구문을 시도하세요",
|
||||
"welcomeTitle": "사전에 오신 것을 환영합니다",
|
||||
"welcomeHint": "위 검색 상자에 단어나 구문을 입력하여 검색을 시작하세요",
|
||||
"welcomeHint": "위의 검색 상자에 단어나 구문을 입력하여 검색을 시작하세요",
|
||||
"lookupFailed": "검색 실패, 나중에 다시 시도하세요",
|
||||
"relookupSuccess": "재검색했습니다",
|
||||
"relookupFailed": "사전 재검색 실패",
|
||||
"relookupSuccess": "다시 검색 성공",
|
||||
"relookupFailed": "사전 다시 검색 실패",
|
||||
"pleaseLogin": "먼저 로그인하세요",
|
||||
"pleaseCreateFolder": "먼저 폴더를 만드세요",
|
||||
"pleaseCreateFolder": "먼저 폴더를 생성하세요",
|
||||
"savedToFolder": "폴더에 저장됨: {folderName}",
|
||||
"saveFailed": "저장 실패, 나중에 다시 시도하세요"
|
||||
"saveFailed": "저장 실패, 나중에 다시 시도하세요",
|
||||
"definition": "정의",
|
||||
"example": "예문"
|
||||
},
|
||||
"explore": {
|
||||
"title": "탐색",
|
||||
"subtitle": "공개 폴더 발견",
|
||||
"searchPlaceholder": "공개 폴더 검색...",
|
||||
"loading": "로딩 중...",
|
||||
"noFolders": "공개 폴더를 찾을 수 없습니다",
|
||||
"folderInfo": "{userName} • {totalPairs} 쌍",
|
||||
"unknownUser": "알 수 없는 사용자",
|
||||
"favorite": "즐겨찾기",
|
||||
"unfavorite": "즐겨찾기 해제",
|
||||
"pleaseLogin": "먼저 로그인해주세요",
|
||||
"sortByFavorites": "즐겨찾기순 정렬",
|
||||
"sortByFavoritesActive": "즐겨찾기순 정렬 해제"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "폴더 상세",
|
||||
"createdBy": "생성자: {name}",
|
||||
"unknownUser": "알 수 없는 사용자",
|
||||
"totalPairs": "총 쌍",
|
||||
"favorites": "즐겨찾기",
|
||||
"createdAt": "생성일",
|
||||
"viewContent": "내용 보기",
|
||||
"favorite": "즐겨찾기",
|
||||
"unfavorite": "즐겨찾기 해제",
|
||||
"favorited": "즐겨찾기됨",
|
||||
"unfavorited": "즐겨찾기 해제됨",
|
||||
"pleaseLogin": "먼저 로그인해주세요"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "내 즐겨찾기",
|
||||
"subtitle": "즐겨찾기한 폴더",
|
||||
"loading": "로딩 중...",
|
||||
"noFavorites": "아직 즐겨찾기가 없습니다",
|
||||
"folderInfo": "{userName} • {totalPairs} 쌍",
|
||||
"unknownUser": "알 수 없는 사용자"
|
||||
},
|
||||
"user_profile": {
|
||||
"anonymous": "익명",
|
||||
@@ -245,11 +346,12 @@
|
||||
"displayName": "표시 이름",
|
||||
"notSet": "설정되지 않음",
|
||||
"memberSince": "가입일",
|
||||
"logout": "로그아웃",
|
||||
"folders": {
|
||||
"title": "폴더",
|
||||
"noFolders": "폴더가 없습니다",
|
||||
"noFolders": "아직 폴더가 없습니다",
|
||||
"folderName": "폴더 이름",
|
||||
"totalPairs": "텍스트 쌍 수",
|
||||
"totalPairs": "총 쌍",
|
||||
"createdAt": "생성일",
|
||||
"actions": "작업",
|
||||
"view": "보기"
|
||||
|
||||
@@ -1,122 +1,176 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "ئۆگىنەرلىك ھەرپلەرنى تاللاڭ",
|
||||
"japanese": "ياپونىيە كانا",
|
||||
"english": "ئىنگلىز ئېلىپبې",
|
||||
"uyghur": "ئۇيغۇر ئېلىپبېسى",
|
||||
"esperanto": "ئېسپېرانتو ئېلىپبېسى",
|
||||
"loading": "چىقىرىۋېتىلىۋاتىدۇ...",
|
||||
"loadFailed": "چىقىرىش مەغلۇب بولدى، قايتا سىناڭ",
|
||||
"hideLetter": "ھەرپنى يوشۇرۇش",
|
||||
"showLetter": "ھەرپنى كۆرسىتىش",
|
||||
"hideIPA": "IPA نى يوشۇرۇش",
|
||||
"showIPA": "IPA نى كۆرسىتىش",
|
||||
"roman": "روماللاشتۇرۇش",
|
||||
"chooseCharacters": "ئۆگەنمەكچى بولغان ھەرپلەرنى تاللاڭ",
|
||||
"chooseAlphabetHint": "ئۆگىنىشنى باشلاش ئۈچۈن بىر ئېلىپبە تاللاڭ",
|
||||
"japanese": "ياپون يېزىقى",
|
||||
"english": "ئىنگلىز ئېلىپبەسى",
|
||||
"uyghur": "ئۇيغۇر ئېلىپبەسى",
|
||||
"esperanto": "ئېسپېرانتو ئېلىپبەسى",
|
||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||
"loadFailed": "يۈكلەش مەغلۇپ بولدى، قايتا سىناڭ",
|
||||
"hideLetter": "ھەرپنى يوشۇر",
|
||||
"showLetter": "ھەرپنى كۆرسەت",
|
||||
"hideIPA": "IPA نى يوشۇر",
|
||||
"showIPA": "IPA نى كۆرسەت",
|
||||
"roman": "لاتىن يېزىقى",
|
||||
"letter": "ھەرپ",
|
||||
"random": "ئىختىيارىي ھالەت",
|
||||
"randomNext": "ئىختىيارىي كېيىنكى"
|
||||
"randomNext": "ئىختىيارىي كېيىنكى",
|
||||
"previousLetter": "ئالدىنقى ھەرپ",
|
||||
"nextLetter": "كېيىنكى ھەرپ",
|
||||
"keyboardHint": "سول/ئوڭ يا ئوق كۇنۇپكىلىرىنى ياكى بوشلۇق كۇنۇپكىسىنى ئىختىيارىي ئالماشتۇرۇش ئۈچۈن ئىشلىتىڭ، ESC قايتىش ئۈچۈن",
|
||||
"swipeHint": "سول/ئوڭ يا ئوق كۇنۇپكىلىرىنى ياكى سىيرىشنى ئىشلىتىپ يۆنىلىڭ، ESC قايتىش ئۈچۈن"
|
||||
},
|
||||
"folders": {
|
||||
"title": "قىسقۇچلار",
|
||||
"subtitle": "توپلىمىڭىزنى باشقۇرۇڭ",
|
||||
"subtitle": "يىغىپ ساقلاشلىرىڭىزنى باشقۇرۇڭ",
|
||||
"newFolder": "يېڭى قىسقۇچ",
|
||||
"creating": "قۇرۇۋاتىدۇ...",
|
||||
"noFoldersYet": "قىسقۇچ يوق",
|
||||
"folderInfo": "كود: {id} • {totalPairs} جۈپ",
|
||||
"enterFolderName": "قىسقۇچ نامىنى كىرگۈزۈڭ:",
|
||||
"confirmDelete": "ئۆچۈرۈش ئۈچۈن «{name}» نى كىرگۈزۈڭ:"
|
||||
"noFoldersYet": "تېخى قىسقۇچ يوق",
|
||||
"folderInfo": "كىملىك: {id} • {totalPairs} جۈپ",
|
||||
"enterFolderName": "قىسقۇچ ئاتىنى كىرگۈزۈڭ:",
|
||||
"confirmDelete": "ئۆچۈرۈش ئۈچۈن \"{name}\" نى كىرگۈزۈڭ:",
|
||||
"myFolders": "قىسقۇچلىرىم",
|
||||
"publicFolders": "ئاممىۋى قىسقۇچلار",
|
||||
"public": "ئاممىۋى",
|
||||
"private": "شەخسىي",
|
||||
"setPublic": "ئاممىۋى قىلىپ تەڭشە",
|
||||
"setPrivate": "شەخسىي قىلىپ تەڭشە",
|
||||
"publicFolderInfo": "{userName} • {totalPairs} جۈپ",
|
||||
"searchPlaceholder": "ئاممىۋى قىسقۇچلارنى ئىزدەڭ...",
|
||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||
"noPublicFolders": "ئاممىۋى قىسقۇچ تېپىلمىدى",
|
||||
"unknownUser": "نامەلۇم ئىشلەتكۈچى",
|
||||
"enterNewName": "يېڭى ئات كىرگۈزۈڭ:",
|
||||
"favorite": "يىغىپ ساقلا",
|
||||
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
|
||||
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "سىز بۇ قىسقۇچنىڭ ئىگىسى ئەمەس",
|
||||
"back": "كەينىگە",
|
||||
"unauthorized": "بۇ قىسقۇچنىڭ ئىگىسى ئەمەسسىز",
|
||||
"back": "قايتىش",
|
||||
"textPairs": "تېكىست جۈپلىرى",
|
||||
"itemsCount": "{count} تۈر",
|
||||
"memorize": "ئەستە ساقلاش",
|
||||
"loadingTextPairs": "تېكىست جۈپلىرى چىقىرىۋېتىلىۋاتىدۇ...",
|
||||
"memorize": "يادلاش",
|
||||
"loadingTextPairs": "تېكىست جۈپلىرى يۈكلىنىۋاتىدۇ...",
|
||||
"noTextPairs": "بۇ قىسقۇچتا تېكىست جۈپى يوق",
|
||||
"addNewTextPair": "يېڭى تېكىست جۈپى قوشۇڭ",
|
||||
"addNewTextPair": "يېڭى تېكىست جۈپى قوشۇش",
|
||||
"add": "قوشۇش",
|
||||
"updateTextPair": "تېكىست جۈپىنى يېڭىلاڭ",
|
||||
"updateTextPair": "تېكىست جۈپىنى يېڭىلاش",
|
||||
"update": "يېڭىلاش",
|
||||
"text1": "تېكىست 1",
|
||||
"text2": "تېكىست 2",
|
||||
"language1": "تىل 1",
|
||||
"language2": "تىل 2",
|
||||
"enterLanguageName": "تىل نامىنى كىرگۈزۈڭ",
|
||||
"enterLanguageName": "تىل ئاتىنى كىرگۈزۈڭ",
|
||||
"edit": "تەھرىرلەش",
|
||||
"delete": "ئۆچۈرۈش",
|
||||
"permissionDenied": "بۇ مەشغۇلاتنى ئىجرا قىلىش ھوقۇقىڭىز يوق",
|
||||
"permissionDenied": "بۇ مەشغۇلاتنى ئېلىپ بېرىش ھوقۇقىڭىز يوق",
|
||||
"error": {
|
||||
"update": "بۇ تۈرنى يېڭىلاش ھوقۇقىڭىز يوق.",
|
||||
"delete": "بۇ تۈرنى ئۆچۈرۈش ھوقۇقىڭىز يوق.",
|
||||
"add": "بۇ قىسقۇچقا تۈر قوشۇش ھوقۇقىڭىز يوق.",
|
||||
"rename": "بۇ قىسقۇچنىڭ نامىنى ئۆزگەرتىش ھوقۇقىڭىز يوق.",
|
||||
"rename": "بۇ قىسقۇچنىڭ ئاتىنى ئۆزگەرتىش ھوقۇقىڭىز يوق.",
|
||||
"deleteFolder": "بۇ قىسقۇچنى ئۆچۈرۈش ھوقۇقىڭىز يوق."
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
"title": "تىل ئۆگىنىڭ",
|
||||
"description": "بۇ سىزنى دۇنيادىكى ھەممە تىلنى، جۈملىدىن سۈنئىي تىللارنىمۇ ئۆگىنىشىڭىزغا ياردەم بېرىدىغان ناھايىتى پايدىلىق تور بېكەت.",
|
||||
"title": "تىل ئۆگىنىش",
|
||||
"description": "بۇ دۇنيادىكى almost ھەر بىر تىلنى، جۈملىدىن سۈنئىي تىللارنى ئۆگىنىشىڭىزگە ياردەم بېرىدىغان ئىنتايىن قوللىنىشلىق تور بېكەت.",
|
||||
"explore": "ئىزدىنىش",
|
||||
"fortune": {
|
||||
"quote": "Stay hungry, stay foolish.",
|
||||
"author": "— ستىۋ جوۋبس"
|
||||
"quote": "ئاچ قورساق، ئەخمەق بولۇپ تۇرۇڭ.",
|
||||
"author": "— Steve Jobs"
|
||||
},
|
||||
"translator": {
|
||||
"name": "تەرجىمە",
|
||||
"description": "خالىغان تىلغا تەرجىمە قىلىپ خەلقئارالىق فونېتىك ئېلىپبې (IPA) بىلەن ئىزاھاتلاش"
|
||||
"name": "تەرجىمان",
|
||||
"description": "ھەر قانداق تىلغا تەرجىمە قىلىڭ ۋە خەلقئارالىق فونېتىكىلىق ئېلىپبە (IPA) بىلەن ئىزاھلاڭ"
|
||||
},
|
||||
"textSpeaker": {
|
||||
"name": "تېكىست ئوقۇغۇچى",
|
||||
"description": "تېكىستنى پەرقلەندۈرۈپ ئوقىيدۇ، دەۋرىي ئوقۇش ۋە سۈرئەت تەڭشەشنى قوللايدۇ"
|
||||
"description": "تېكىستنى تونۇپ ۋە ئۈنلۈك ئوقۇپ بېرىدۇ، دەۋرىي قويۇش ۋە سۈرئەت تەڭشەشنى قوللايدۇ"
|
||||
},
|
||||
"srtPlayer": {
|
||||
"name": "SRT سىن ئوپىراتورى",
|
||||
"description": "SRT خەت ئاستى فايلى ئاساسىدا سىننى جۈملە-جۈملە قويۇپ، يەرلىك ئىخچام ئاۋازنى ئىمتىلايدۇ"
|
||||
"name": "SRT ۋىدېئو قويغۇچ",
|
||||
"description": "SRT تر پودكاست ھۆججەتلىرىگە ئاساسەن ۋىدېئولارنى جۈمە بويىچە قويۇپ، ئانا تىللىقلارنىڭ تەلەپپۇزىنى دوراڭ"
|
||||
},
|
||||
"alphabet": {
|
||||
"name": "ئېلىپبې",
|
||||
"description": "ئېلىپبېدىن يېڭى تىل ئۆگىنىشنى باشلاڭ"
|
||||
"name": "ئېلىپبە",
|
||||
"description": "يېڭى بىر تىلنى ئېلىپبەدىن باشلاپ ئۆگىنىڭ"
|
||||
},
|
||||
"memorize": {
|
||||
"name": "ئەستە ساقلاش",
|
||||
"description": "تىل A دىن تىل غا، تىل B دىن تىل A غا، دىكتات قوللايدۇ"
|
||||
"name": "يادلاش",
|
||||
"description": "تىل A دىن تىل B گە، تىل B دىن تىل A غا، دىكتات قىلىشنى قوللايدۇ"
|
||||
},
|
||||
"dictionary": {
|
||||
"name": "لۇغەت",
|
||||
"description": "سۆز ۋە سۆزنى ئىزدەپ، تەپسىلىي ئىزاھات ۋە مىساللار بىلەن تەمىنلەيدۇ"
|
||||
"description": "سۆزلەر ۋە ئىبارىلەرنى تەپسىلىي ئېنىقلىما ۋە مىساللار بىلەن ئىزدەڭ"
|
||||
},
|
||||
"moreFeatures": {
|
||||
"name": "تېخىمۇ كۆپ ئىقتىدار",
|
||||
"description": "ئىشلەۋاتىدۇ، كۈتكۈن بولۇڭ"
|
||||
"name": "تېخىمۇ كۆپ ئىقتىدارلار",
|
||||
"description": "تەرەققىيات ئاستىدا، دىققەت قىلىپ تۇرۇڭ"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "دەلىللەش",
|
||||
"title": "كىرىش",
|
||||
"signUpTitle": "تىزىملىتىش",
|
||||
"signIn": "كىرىش",
|
||||
"signUp": "تىزىملىتىش",
|
||||
"email": "ئېلخەت",
|
||||
"password": "ئىم",
|
||||
"confirmPassword": "ئىمنى جەزملەش",
|
||||
"name": "نام",
|
||||
"password": "پارول",
|
||||
"confirmPassword": "پارولنى جەزىملەڭ",
|
||||
"name": "ئىسىم",
|
||||
"username": "ئىشلەتكۈچى ئاتى",
|
||||
"emailOrUsername": "ئېلخەت ياكى ئىشلەتكۈچى ئاتى",
|
||||
"signInButton": "كىرىش",
|
||||
"signUpButton": "تىزىملىتىش",
|
||||
"noAccount": "ھېساباتىڭىز يوقمۇ؟",
|
||||
"hasAccount": "ھېساباتىڭىز بارمۇ؟",
|
||||
"signInWithGitHub": "GitHub بىلەن كىرىڭ",
|
||||
"signUpWithGitHub": "GitHub بىلەن تىزىملىتىڭ",
|
||||
"invalidEmail": "ئىناۋەتلىك ئېلخەت ئادرېسى كىرگۈزۈڭ",
|
||||
"passwordTooShort": "ئىم كەم دېگەندە 8 ھەرپتىن تۇرۇشى كېرەك",
|
||||
"passwordsNotMatch": "ئىم ماس كەلمەيدۇ",
|
||||
"nameRequired": "نامىڭىزنى كىرگۈزۈڭ",
|
||||
"emailRequired": "ئېلخىتىڭىزنى كىرگۈزۈڭ",
|
||||
"passwordRequired": "ئىمىڭىزنى كىرگۈزۈڭ",
|
||||
"confirmPasswordRequired": "ئىمىڭىزنى جەزملەڭ",
|
||||
"loading": "چىقىرىۋېتىلىۋاتىدۇ..."
|
||||
"signInWithGitHub": "GitHub بىلەن كىرىش",
|
||||
"signUpWithGitHub": "GitHub بىلەن تىزىملىتىش",
|
||||
"invalidEmail": "ئۈنۈملۈك ئېلخەت ئادرېسى كىرگۈزۈڭ",
|
||||
"passwordTooShort": "پارول ئەڭ ئاز 8 ھەرپ بولۇشى كېرەك",
|
||||
"passwordsNotMatch": "پاروللار ماس كەلمەيدۇ",
|
||||
"nameRequired": "ئىسىمىڭىزنى كىرگۈزۈڭ",
|
||||
"usernameRequired": "ئىشلەتكۈچى ئاتىنى كىرگۈزۈڭ",
|
||||
"usernameTooShort": "ئىشلەتكۈچى ئاتى ئەڭ ئاز 3 ھەرپ بولۇشى كېرەك",
|
||||
"usernameInvalid": "ئىشلەتكۈچى ئاتى پەقەت ھەرپ، سان ۋە ئاستى سىزىقنى ئۆز ئىچىگە ئالىدۇ",
|
||||
"emailRequired": "ئېلخەت كىرگۈزۈڭ",
|
||||
"identifierRequired": "ئېلخەت ياكى ئىشلەتكۈچى ئاتىنى كىرگۈزۈڭ",
|
||||
"passwordRequired": "پارول كىرگۈزۈڭ",
|
||||
"confirmPasswordRequired": "پارولنى جەزىملەڭ",
|
||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||
"confirm": "جەزىملەش",
|
||||
"noAccountLink": "ھېساباتىڭىز يوقمۇ؟ تىزىملىتىڭ",
|
||||
"hasAccountLink": "ھېساباتىڭىز بارمۇ؟ كىرىڭ",
|
||||
"usernamePlaceholder": "ئىشلەتكۈچى ئاتى",
|
||||
"emailPlaceholder": "ئېلخەت ئادرېسى",
|
||||
"passwordPlaceholder": "پارول",
|
||||
"usernameOrEmailPlaceholder": "ئىشلەتكۈچى ئاتى ياكى ئېلخەت",
|
||||
"loginFailed": "كىرىش مەغلۇپ بولدى",
|
||||
"signUpFailed": "تىزىملىتىش مەغلۇپ بولدى",
|
||||
"fillAllFields": "ھەممە بۆلەكلەرنى تولدۇرۇڭ",
|
||||
"enterCredentials": "ئىشلەتكۈچى ئاتى ۋە پارول كىرگۈزۈڭ",
|
||||
"forgotPassword": "پارولنى ئۇنتۇپ قالدىڭىزمۇ",
|
||||
"forgotPasswordHint": "ئېلخەت ئادرېسىڭىزنى كىرگۈزۈڭ، بىز سىزگە پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسى ئەۋەتىمىز.",
|
||||
"sendResetEmail": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش",
|
||||
"resetPasswordFailed": "ئەسلىگە قايتۇرۇش ئېلخېتى ئەۋەتىش مەغلۇپ بولدى",
|
||||
"resetPasswordEmailSent": "ئەسلىگە قايتۇرۇش ئېلخېتى مۇۋەپپەقىيەتلىك ئەۋەتىلدى",
|
||||
"resetPasswordEmailSentHint": "پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسىنى ئېلخەت ئادرېسىڭىزغا ئەۋەتتۇق. ئېلخەت ساندۇقىڭىزنى تەكشۈرۈڭ.",
|
||||
"checkYourEmail": "ئېلخېتىڭىزنى تەكشۈرۈڭ",
|
||||
"backToLogin": "كىرىشكە قايتىش",
|
||||
"resetPassword": "پارولنى ئەسلىگە قايتۇرۇش",
|
||||
"newPassword": "يېڭى پارول",
|
||||
"invalidToken": "ئۇلانما ئىناۋەتسىز ياكى ۋاقتى ئۆتكەن",
|
||||
"invalidTokenHint": "بۇ پارولنى ئەسلىگە قايتۇرۇش ئۇلانمىسى ئىناۋەتسىز ياكى ۋاقتى ئۆتكەن. يېڭىدىن سوراڭ.",
|
||||
"requestNewToken": "يېڭى ئەسلىگە قايتۇرۇش ئۇلانمىسى سوراش",
|
||||
"resetPasswordSuccess": "پارول مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى",
|
||||
"resetPasswordSuccessTitle": "پارول ئەسلىگە قايتۇرۇش تاماملاندى",
|
||||
"resetPasswordSuccessHint": "پارولىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە قايتۇرۇلدى. يېڭى پارول بىلەن كىرسىڭىز بولىدۇ."
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
"selectFolder": "قىسقۇچ تاللاڭ",
|
||||
"selectFolder": "بىر قىسقۇچ تاللاڭ",
|
||||
"noFolders": "قىسقۇچ تېپىلمىدى",
|
||||
"folderInfo": "{id}. {name} ({count})"
|
||||
},
|
||||
@@ -125,63 +179,71 @@
|
||||
"next": "كېيىنكى",
|
||||
"reverse": "تەتۈر",
|
||||
"dictation": "دىكتات",
|
||||
"noTextPairs": "ئىشلەتكىلى بولىدىغان تېكىست جۈپى يوق",
|
||||
"disorder": "بەت ئارلاش",
|
||||
"previous": "ئىلگىرىكى"
|
||||
"noTextPairs": "تېكىست جۈپى يوق",
|
||||
"disorder": "قالايمىقانلاشتۇرۇش",
|
||||
"previous": "ئالدىنقى"
|
||||
},
|
||||
"page": {
|
||||
"unauthorized": "بۇ قىسقۇچنى زىيارەت قىلىشقا ھوقۇقىڭىز يوق"
|
||||
"unauthorized": "بۇ قىسقۇچنى زىيارەت قىلىش ھوقۇقىڭىز يوق"
|
||||
}
|
||||
},
|
||||
"navbar": {
|
||||
"title": "تىل ئۆگىنىش",
|
||||
"title": "تىل-ئۆگىنىش",
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "كىرىش",
|
||||
"profile": "پروفىل",
|
||||
"folders": "قىسقۇچلار"
|
||||
"profile": "شەخسىي ئۇچۇر",
|
||||
"folders": "قىسقۇچلار",
|
||||
"explore": "ئىزدىنىش",
|
||||
"favorites": "يىغىپ ساقلانغانلار"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "مېنىڭ پروفىلىم",
|
||||
"myProfile": "شەخسىي ئۇچۇرۇم",
|
||||
"email": "ئېلخەت: {email}",
|
||||
"logout": "چىقىش"
|
||||
"logout": "چىكىنىش"
|
||||
},
|
||||
"srt_player": {
|
||||
"uploadVideo": "سىن يۈكلەڭ",
|
||||
"uploadSubtitle": "خەت ئاستى يۈكلەڭ",
|
||||
"uploadVideo": "ۋىدېئو يۈكلەش",
|
||||
"uploadSubtitle": "تر پودكاست يۈكلەش",
|
||||
"pause": "ۋاقىتلىق توختىتىش",
|
||||
"play": "قويۇش",
|
||||
"previous": "ئىلگىرىكى",
|
||||
"previous": "ئالدىنقى",
|
||||
"next": "كېيىنكى",
|
||||
"restart": "قايتا باشلاش",
|
||||
"autoPause": "ئاپتوماتىك توختىتىش ({enabled})",
|
||||
"uploadVideoAndSubtitle": "سىن ھەم خەت ئاستى فايلىنى يۈكلەڭ",
|
||||
"uploadVideoFile": "سىن فايلى يۈكلەڭ",
|
||||
"uploadSubtitleFile": "خەت ئاستى فايلى يۈكلەڭ",
|
||||
"processingSubtitle": "خەت ئاستى فايلى بىر تەرەپ قىلىۋاتىدۇ...",
|
||||
"needBothFiles": "ئۆگىنىشنى باشلاش ئۈچۈن سىن ھەم خەت ئاستى فايلىنىڭ ھەممىسى لازىم",
|
||||
"videoFile": "سىن فايلى",
|
||||
"subtitleFile": "خەت ئاستى فايلى",
|
||||
"uploadVideoAndSubtitle": "ۋىدېئو ۋە تر پودكاست ھۆججەتلىرىنى يۈكلەڭ",
|
||||
"uploadVideoFile": "ۋىدېئو ھۆججىتى يۈكلەڭ",
|
||||
"uploadSubtitleFile": "تر پودكاست ھۆججىتى يۈكلەڭ",
|
||||
"processingSubtitle": "تر پودكاست ھۆججىتى بىر تەرەپ قىلىنىۋاتىدۇ...",
|
||||
"needBothFiles": "ئۆگىنىشنى باشلاش ئۈچۈن ۋىدېئو ۋە تر پودكاست ھۆججەتلىرى كېرەك",
|
||||
"videoFile": "ۋىدېئو ھۆججىتى",
|
||||
"subtitleFile": "تر پودكاست ھۆججىتى",
|
||||
"uploaded": "يۈكلەندى",
|
||||
"notUploaded": "يۈكلەنمىدى",
|
||||
"upload": "يۈكلەش",
|
||||
"uploadVideoButton": "ۋىدېئو يۈكلەش",
|
||||
"uploadSubtitleButton": "تر پودكاست يۈكلەش",
|
||||
"subtitleUploaded": "تر پودكاست يۈكلەندى ({count} تۈر)",
|
||||
"subtitleNotUploaded": "تر پودكاست يۈكلەنمىدى",
|
||||
"autoPauseStatus": "ئاپتوماتىك توختىتىش: {enabled}",
|
||||
"on": "ئوچۇق",
|
||||
"off": "تاقاق",
|
||||
"videoUploadFailed": "سىن يۈكلەش مەغلۇب بولدى",
|
||||
"subtitleUploadFailed": "خەت ئاستى يۈكلەش مەغلۇب بولدى"
|
||||
"videoUploadFailed": "ۋىدېئو يۈكلەش مەغلۇپ بولدى",
|
||||
"subtitleUploadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى",
|
||||
"subtitleLoadSuccess": "تر پودكاست مۇۋەپپەقىيەتلىك يۈكلەندى",
|
||||
"subtitleLoadFailed": "تر پودكاست يۈكلەش مەغلۇپ بولدى"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "IPA ھاسىل قىلىش",
|
||||
"viewSavedItems": "ساقلانغان تۈرلەرنى كۆرۈش",
|
||||
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (H/Y)"
|
||||
"confirmDeleteAll": "ھەممىنى ئۆچۈرەمسىز؟ (Y/N)"
|
||||
},
|
||||
"translator": {
|
||||
"detectLanguage": "تىل پەرقلەندۈرۈش",
|
||||
"generateIPA": "IPA ھاسىل قىلىش",
|
||||
"detectLanguage": "تىلنى تونۇش",
|
||||
"generateIPA": "ipa ھاسىل قىلىش",
|
||||
"translateInto": "تەرجىمە قىلىش",
|
||||
"chinese": "خەنزۇچە",
|
||||
"english": "ئىنگلىزچە",
|
||||
"french": "فرانسۇزچە",
|
||||
"french": "فىرانسۇزچە",
|
||||
"german": "گېرمانچە",
|
||||
"italian": "ئىتاليانچە",
|
||||
"japanese": "ياپونچە",
|
||||
@@ -190,68 +252,108 @@
|
||||
"russian": "رۇسچە",
|
||||
"spanish": "ئىسپانچە",
|
||||
"other": "باشقا",
|
||||
"translating": "تەرجىمە قىلىۋاتىدۇ...",
|
||||
"translating": "تەرجىمە قىلىنىۋاتىدۇ...",
|
||||
"translate": "تەرجىمە قىلىش",
|
||||
"inputLanguage": "بىر تىل كىرگۈزۈڭ.",
|
||||
"history": "تارىخ",
|
||||
"enterLanguage": "تىل كىرگۈزۈڭ",
|
||||
"add_to_folder": {
|
||||
"notAuthenticated": "دەلىتلەنمىدىڭىز",
|
||||
"chooseFolder": "قوشۇلىدىغان قىسقۇچنى تاللاڭ",
|
||||
"notAuthenticated": "تىزىملىتىلمىدىڭىز",
|
||||
"chooseFolder": "قوشۇش ئۈچۈن قىسقۇچ تاللاڭ",
|
||||
"noFolders": "قىسقۇچ تېپىلمىدى",
|
||||
"folderInfo": "{id}. {name}",
|
||||
"close": "تاقاش",
|
||||
"success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى",
|
||||
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇب بولدى"
|
||||
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇپ بولدى"
|
||||
},
|
||||
"autoSave": "ئاپتوماتىك ساقلاش"
|
||||
},
|
||||
"dictionary": {
|
||||
"title": "لۇغەت",
|
||||
"description": "تەپسىلىي ئىلمىيى ۋە مىساللار بىلەن سۆز ۋە ئىبارە ئىزدەش",
|
||||
"searchPlaceholder": "ئىزدەيدىغان سۆز ياكى ئىبارە كىرگۈزۈڭ...",
|
||||
"description": "سۆزلەر ۋە ئىبارىلەرنى تەپسىلىي ئېنىقلىما ۋە مىساللار بىلەن ئىزدەڭ",
|
||||
"searchPlaceholder": "ئىزدەش ئۈچۈن سۆز ياكى ئىبارە كىرگۈزۈڭ...",
|
||||
"searching": "ئىزدەۋاتىدۇ...",
|
||||
"search": "ئىزدە",
|
||||
"languageSettings": "تىل تەڭشىكى",
|
||||
"queryLanguage": "سۈرەشتۈرۈش تىلى",
|
||||
"queryLanguageHint": "ئىزدەمدەكچى بولغان سۆز/ئىبارە قايسى تىلدا",
|
||||
"definitionLanguage": "ئىلمىيى تىلى",
|
||||
"definitionLanguageHint": "ئىلمىيىنى قايسى تىلدا كۆرۈشنى ئويلىشىسىز",
|
||||
"search": "ئىزدەش",
|
||||
"languageSettings": "تىل تەڭشەكلىرى",
|
||||
"queryLanguage": "سۈرۈشتۈرۈش تىلى",
|
||||
"queryLanguageHint": "ئىزدىمەكچى بولغان سۆز/ئىبارە قايسى تىلدا",
|
||||
"definitionLanguage": "ئېنىقلىما تىلى",
|
||||
"definitionLanguageHint": "ئېنىقلىمىلارنى قايسى تىلدا كۆرمەكچى",
|
||||
"otherLanguagePlaceholder": "ياكى باشقا تىل كىرگۈزۈڭ...",
|
||||
"currentSettings": "نۆۋەتتىكى تەڭشەك: سۈرەشتۈرۈش {queryLang}، ئىلمىيى {definitionLang}",
|
||||
"relookup": "قايتا ئىزدە",
|
||||
"saveToFolder": "قىسقۇچقا ساقلا",
|
||||
"loading": "يۈكلىۋاتىدۇ...",
|
||||
"other": "باشقا",
|
||||
"currentSettings": "نۆۋەتتىكى تەڭشەكلەر: سۈرۈشتۈرۈش {queryLang}، ئېنىقلىما {definitionLang}",
|
||||
"relookup": "قايتا ئىزدەش",
|
||||
"saveToFolder": "قىسقۇچقا ساقلاش",
|
||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||
"noResults": "نەتىجە تېپىلمىدى",
|
||||
"tryOtherWords": "باشقا سۆز ياكى ئىبارە سىناڭ",
|
||||
"welcomeTitle": "لۇغەتكە مەرھەمەت",
|
||||
"tryOtherWords": "باشقا سۆز ياكى ئىبارىلەرنى سىناڭ",
|
||||
"welcomeTitle": "لۇغەتكە خۇش كەلدىڭىز",
|
||||
"welcomeHint": "ئىزدەشنى باشلاش ئۈچۈن يۇقىرىدىكى ئىزدەش رامكىسىغا سۆز ياكى ئىبارە كىرگۈزۈڭ",
|
||||
"lookupFailed": "ئىزدەش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ",
|
||||
"relookupSuccess": "مۇۋەپپەقىيەتلىك قايتا ئىزدىدى",
|
||||
"relookupFailed": "لۇغەت قايتا ئىزدىشى مەغلۇب بولدى",
|
||||
"pleaseLogin": "ئاۋۋال تىزىملىتىڭ",
|
||||
"pleaseCreateFolder": "ئاۋۋال قىسقۇچ قۇرۇڭ",
|
||||
"lookupFailed": "ئىزدەش مەغلۇپ بولدى، كېيىن قايتا سىناڭ",
|
||||
"relookupSuccess": "مۇۋەپپەقىيەتلىك قايتا ئىزدەلدى",
|
||||
"relookupFailed": "لۇغەت قايتا ئىزدەش مەغلۇپ بولدى",
|
||||
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
|
||||
"pleaseCreateFolder": "ئاۋۋال بىر قىسقۇچ قۇرۇڭ",
|
||||
"savedToFolder": "قىسقۇچقا ساقلاندى: {folderName}",
|
||||
"saveFailed": "ساقلاش مەغلۇب بولدى، كېيىنرەك قايتا سىناڭ"
|
||||
"saveFailed": "ساقلاش مەغلۇپ بولدى، كېيىن قايتا سىناڭ",
|
||||
"definition": "ئېنىقلىما",
|
||||
"example": "مىسال"
|
||||
},
|
||||
"explore": {
|
||||
"title": "ئىزدىنىش",
|
||||
"subtitle": "ئاممىۋى قىسقۇچلارنى بايقاڭ",
|
||||
"searchPlaceholder": "ئاممىۋى قىسقۇچلارنى ئىزدەڭ...",
|
||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||
"noFolders": "ئاممىۋى قىسقۇچ تېپىلمىدى",
|
||||
"folderInfo": "{userName} • {totalPairs} جۈپ",
|
||||
"unknownUser": "نامەلۇم ئىشلەتكۈچى",
|
||||
"favorite": "يىغىپ ساقلا",
|
||||
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
|
||||
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ",
|
||||
"sortByFavorites": "يىغىپ ساقلاش بويىچە تەرتىپلەش",
|
||||
"sortByFavoritesActive": "يىغىپ ساقلاش بويىچە تەرتىپلەشنى بىكار قىلىش"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "قىسقۇچ تەپسىلاتلىرى",
|
||||
"createdBy": "قۇرغۇچى: {name}",
|
||||
"unknownUser": "نامەلۇم ئىشلەتكۈچى",
|
||||
"totalPairs": "جەمئىي جۈپ",
|
||||
"favorites": "يىغىپ ساقلانغانلار",
|
||||
"createdAt": "قۇرۇلغان ۋاقتى",
|
||||
"viewContent": "مەزمۇننى كۆرۈش",
|
||||
"favorite": "يىغىپ ساقلا",
|
||||
"unfavorite": "يىغىپ ساقلاشنى بىكار قىل",
|
||||
"favorited": "يىغىپ ساقلاندى",
|
||||
"unfavorited": "يىغىپ ساقلاش بىكار قىلىندى",
|
||||
"pleaseLogin": "ئاۋۋال تىزىمغا كىرىڭ"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "يىغىپ ساقلىغانلىرىم",
|
||||
"subtitle": "يىغىپ ساقلىغان قىسقۇچلىرىڭىز",
|
||||
"loading": "يۈكلىنىۋاتىدۇ...",
|
||||
"noFavorites": "تېخى يىغىپ ساقلانمىغان",
|
||||
"folderInfo": "{userName} • {totalPairs} جۈپ",
|
||||
"unknownUser": "نامەلۇم ئىشلەتكۈچى"
|
||||
},
|
||||
"user_profile": {
|
||||
"anonymous": "ئىسىمسىز",
|
||||
"anonymous": "نامسىز",
|
||||
"email": "ئېلخەت",
|
||||
"verified": "دەلىللەندى",
|
||||
"unverified": "دەلىتلەنمىدى",
|
||||
"accountInfo": "ھېسابات ئۇچۇرى",
|
||||
"userId": "ئىشلەتكۈچى كودى",
|
||||
"username": "ئىشلەتكۈچى نامى",
|
||||
"displayName": "كۆرسىتىلىدىغان نام",
|
||||
"verified": "دەلىللەنگەن",
|
||||
"unverified": "دەلىللەنمىگەن",
|
||||
"accountInfo": "ھېسابات ئۇچۇرلىرى",
|
||||
"userId": "ئىشلەتكۈچى كىملىكى",
|
||||
"username": "ئىشلەتكۈچى ئاتى",
|
||||
"displayName": "كۆرسىتىش ئاتى",
|
||||
"notSet": "تەڭشەلمىگەن",
|
||||
"memberSince": "تىزىملاتقان ۋاقىت",
|
||||
"memberSince": "ئەزا بولغاندىن بېرى",
|
||||
"logout": "چىكىنىش",
|
||||
"folders": {
|
||||
"title": "قىسقۇچلار",
|
||||
"noFolders": "قىسقۇچ يوق",
|
||||
"folderName": "قىسقۇچ نامى",
|
||||
"totalPairs": "تېكىست جۈپ سانى",
|
||||
"createdAt": "قۇرۇلغان ۋاقىت",
|
||||
"actions": "مەشغۇلات",
|
||||
"noFolders": "تېخى قىسقۇچ يوق",
|
||||
"folderName": "قىسقۇچ ئاتى",
|
||||
"totalPairs": "جەمئىي جۈپ",
|
||||
"createdAt": "قۇرۇلغان ۋاقتى",
|
||||
"actions": "مەشغۇلاتلار",
|
||||
"view": "كۆرۈش"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"alphabet": {
|
||||
"chooseCharacters": "请选择您想学习的字符",
|
||||
"chooseAlphabetHint": "选择一种语言的字母表开始学习",
|
||||
"japanese": "日语假名",
|
||||
"english": "英文字母",
|
||||
"uyghur": "维吾尔字母",
|
||||
@@ -14,7 +15,11 @@
|
||||
"roman": "罗马音",
|
||||
"letter": "字母",
|
||||
"random": "随机模式",
|
||||
"randomNext": "随机下一个"
|
||||
"randomNext": "随机下一个",
|
||||
"previousLetter": "上一个字母",
|
||||
"nextLetter": "下一个字母",
|
||||
"keyboardHint": "使用左右箭头键或空格键随机切换,ESC键返回",
|
||||
"swipeHint": "使用左右箭头键或滑动切换字母"
|
||||
},
|
||||
"folders": {
|
||||
"title": "文件夹",
|
||||
@@ -24,7 +29,22 @@
|
||||
"noFoldersYet": "还没有文件夹",
|
||||
"folderInfo": "ID: {id} • {totalPairs} 个文本对",
|
||||
"enterFolderName": "输入文件夹名称:",
|
||||
"confirmDelete": "输入 \"{name}\" 以删除:"
|
||||
"confirmDelete": "输入 \"{name}\" 以删除:",
|
||||
"myFolders": "我的文件夹",
|
||||
"publicFolders": "公开文件夹",
|
||||
"public": "公开",
|
||||
"private": "私有",
|
||||
"setPublic": "设为公开",
|
||||
"setPrivate": "设为私有",
|
||||
"publicFolderInfo": "{userName} • {totalPairs} 个文本对",
|
||||
"searchPlaceholder": "搜索公开文件夹...",
|
||||
"loading": "加载中...",
|
||||
"noPublicFolders": "没有找到公开文件夹",
|
||||
"unknownUser": "未知用户",
|
||||
"enterNewName": "输入新名称:",
|
||||
"favorite": "收藏",
|
||||
"unfavorite": "取消收藏",
|
||||
"pleaseLogin": "请先登录"
|
||||
},
|
||||
"folder_id": {
|
||||
"unauthorized": "您不是此文件夹的所有者",
|
||||
@@ -93,6 +113,7 @@
|
||||
},
|
||||
"auth": {
|
||||
"title": "登录",
|
||||
"signUpTitle": "注册",
|
||||
"signIn": "登录",
|
||||
"signUp": "注册",
|
||||
"email": "邮箱",
|
||||
@@ -118,7 +139,34 @@
|
||||
"identifierRequired": "请输入邮箱或用户名",
|
||||
"passwordRequired": "请输入密码",
|
||||
"confirmPasswordRequired": "请确认密码",
|
||||
"loading": "加载中..."
|
||||
"loading": "加载中...",
|
||||
"confirm": "确认",
|
||||
"noAccountLink": "没有账号?去注册",
|
||||
"hasAccountLink": "已有账号?去登录",
|
||||
"usernamePlaceholder": "用户名",
|
||||
"emailPlaceholder": "邮箱地址",
|
||||
"passwordPlaceholder": "密码",
|
||||
"usernameOrEmailPlaceholder": "用户名或邮箱地址",
|
||||
"loginFailed": "登录失败",
|
||||
"signUpFailed": "注册失败",
|
||||
"fillAllFields": "请填写所有字段",
|
||||
"enterCredentials": "请输入用户名和密码",
|
||||
"forgotPassword": "忘记密码",
|
||||
"forgotPasswordHint": "输入您的邮箱地址,我们将向您发送重置密码的链接。",
|
||||
"sendResetEmail": "发送重置邮件",
|
||||
"resetPasswordFailed": "发送重置邮件失败",
|
||||
"resetPasswordEmailSent": "重置邮件已发送",
|
||||
"resetPasswordEmailSentHint": "我们已向您的邮箱发送了密码重置链接,请查收。",
|
||||
"checkYourEmail": "请查收邮件",
|
||||
"backToLogin": "返回登录",
|
||||
"resetPassword": "重置密码",
|
||||
"newPassword": "新密码",
|
||||
"invalidToken": "链接无效或已过期",
|
||||
"invalidTokenHint": "此密码重置链接无效或已过期,请重新申请。",
|
||||
"requestNewToken": "重新申请重置链接",
|
||||
"resetPasswordSuccess": "密码重置成功",
|
||||
"resetPasswordSuccessTitle": "密码重置完成",
|
||||
"resetPasswordSuccessHint": "您的密码已成功重置,现在可以使用新密码登录了。"
|
||||
},
|
||||
"memorize": {
|
||||
"folder_selector": {
|
||||
@@ -144,7 +192,9 @@
|
||||
"sourceCode": "源码",
|
||||
"sign_in": "登录",
|
||||
"profile": "个人资料",
|
||||
"folders": "文件夹"
|
||||
"folders": "文件夹",
|
||||
"explore": "探索",
|
||||
"favorites": "收藏"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "我的个人资料",
|
||||
@@ -170,11 +220,17 @@
|
||||
"subtitleFile": "字幕文件",
|
||||
"uploaded": "已上传",
|
||||
"notUploaded": "未上传",
|
||||
"uploadVideoButton": "上传视频",
|
||||
"uploadSubtitleButton": "上传字幕",
|
||||
"subtitleUploaded": "字幕已上传 ({count} 条)",
|
||||
"subtitleNotUploaded": "字幕未上传",
|
||||
"autoPauseStatus": "自动暂停: {enabled}",
|
||||
"on": "开",
|
||||
"off": "关",
|
||||
"videoUploadFailed": "视频上传失败",
|
||||
"subtitleUploadFailed": "字幕上传失败"
|
||||
"subtitleUploadFailed": "字幕上传失败",
|
||||
"subtitleLoadSuccess": "字幕加载成功",
|
||||
"subtitleLoadFailed": "字幕加载失败"
|
||||
},
|
||||
"text_speaker": {
|
||||
"generateIPA": "生成IPA",
|
||||
@@ -224,6 +280,7 @@
|
||||
"definitionLanguage": "释义语言",
|
||||
"definitionLanguageHint": "你希望用什么语言查看释义",
|
||||
"otherLanguagePlaceholder": "或输入其他语言...",
|
||||
"other": "其他",
|
||||
"currentSettings": "当前设置:查询 {queryLang},释义 {definitionLang}",
|
||||
"relookup": "重新查询",
|
||||
"saveToFolder": "保存到文件夹",
|
||||
@@ -238,7 +295,45 @@
|
||||
"pleaseLogin": "请先登录",
|
||||
"pleaseCreateFolder": "请先创建文件夹",
|
||||
"savedToFolder": "已保存到文件夹:{folderName}",
|
||||
"saveFailed": "保存失败,请稍后重试"
|
||||
"saveFailed": "保存失败,请稍后重试",
|
||||
"definition": "释义",
|
||||
"example": "例句"
|
||||
},
|
||||
"explore": {
|
||||
"title": "探索",
|
||||
"subtitle": "发现公开文件夹",
|
||||
"searchPlaceholder": "搜索公开文件夹...",
|
||||
"loading": "加载中...",
|
||||
"noFolders": "没有找到公开文件夹",
|
||||
"folderInfo": "{userName} • {totalPairs} 个文本对",
|
||||
"unknownUser": "未知用户",
|
||||
"favorite": "收藏",
|
||||
"unfavorite": "取消收藏",
|
||||
"pleaseLogin": "请先登录",
|
||||
"sortByFavorites": "按收藏数排序",
|
||||
"sortByFavoritesActive": "取消按收藏数排序"
|
||||
},
|
||||
"exploreDetail": {
|
||||
"title": "文件夹详情",
|
||||
"createdBy": "创建者:{name}",
|
||||
"unknownUser": "未知用户",
|
||||
"totalPairs": "词对数量",
|
||||
"favorites": "收藏数",
|
||||
"createdAt": "创建时间",
|
||||
"viewContent": "查看内容",
|
||||
"favorite": "收藏",
|
||||
"unfavorite": "取消收藏",
|
||||
"favorited": "已收藏",
|
||||
"unfavorited": "已取消收藏",
|
||||
"pleaseLogin": "请先登录"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "我的收藏",
|
||||
"subtitle": "收藏的公开文件夹",
|
||||
"loading": "加载中...",
|
||||
"noFavorites": "还没有收藏任何文件夹",
|
||||
"folderInfo": "{userName} • {totalPairs} 个文本对",
|
||||
"unknownUser": "未知用户"
|
||||
},
|
||||
"user_profile": {
|
||||
"anonymous": "匿名",
|
||||
@@ -251,6 +346,7 @@
|
||||
"displayName": "显示名称",
|
||||
"notSet": "未设置",
|
||||
"memberSince": "注册时间",
|
||||
"logout": "登出",
|
||||
"folders": {
|
||||
"title": "文件夹",
|
||||
"noFolders": "还没有文件夹",
|
||||
|
||||
10
package.json
10
package.json
@@ -11,8 +11,8 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.2.0",
|
||||
"@prisma/client": "^7.2.0",
|
||||
"@prisma/adapter-pg": "^7.4.2",
|
||||
"@prisma/client": "7.4.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "^1.4.10",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -21,12 +21,15 @@
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"next-intl": "^4.7.0",
|
||||
"nodemailer": "^8.0.2",
|
||||
"openai": "^6.27.0",
|
||||
"pg": "^8.16.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"unstorage": "^1.17.3",
|
||||
"winston": "^3.19.0",
|
||||
"zod": "^4.3.5",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
@@ -35,6 +38,7 @@
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
||||
@@ -43,7 +47,7 @@
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "16.1.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"prisma": "^7.2.0",
|
||||
"prisma": "^7.4.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
|
||||
576
pnpm-lock.yaml
generated
576
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,120 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "pairs" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"locale1" VARCHAR(10) NOT NULL,
|
||||
"locale2" VARCHAR(10) NOT NULL,
|
||||
"text1" TEXT NOT NULL,
|
||||
"text2" TEXT NOT NULL,
|
||||
"ipa1" TEXT,
|
||||
"ipa2" TEXT,
|
||||
"folder_id" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "pairs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "folders" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "folders_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"image" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"ipAddress" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "account" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"providerId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"accessToken" TEXT,
|
||||
"refreshToken" TEXT,
|
||||
"idToken" TEXT,
|
||||
"accessTokenExpiresAt" TIMESTAMP(3),
|
||||
"refreshTokenExpiresAt" TIMESTAMP(3),
|
||||
"scope" TEXT,
|
||||
"password" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "verification" (
|
||||
"id" TEXT NOT NULL,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pairs_folder_id_idx" ON "pairs"("folder_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pairs_folder_id_locale1_locale2_text1_key" ON "pairs"("folder_id", "locale1", "locale2", "text1");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folders_user_id_idx" ON "folders"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "session_userId_idx" ON "session"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "account_userId_idx" ON "account"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pairs" ADD CONSTRAINT "pairs_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,138 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `ipa1` on the `pairs` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `ipa2` on the `pairs` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
-- 重命名并修改类型为 TEXT
|
||||
ALTER TABLE "pairs"
|
||||
RENAME COLUMN "locale1" TO "language1";
|
||||
|
||||
ALTER TABLE "pairs"
|
||||
ALTER COLUMN "language1" SET DATA TYPE VARCHAR(20);
|
||||
|
||||
ALTER TABLE "pairs"
|
||||
RENAME COLUMN "locale2" TO "language2";
|
||||
|
||||
ALTER TABLE "pairs"
|
||||
ALTER COLUMN "language2" SET DATA TYPE VARCHAR(20);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_lookups" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"text" TEXT NOT NULL,
|
||||
"query_lang" TEXT NOT NULL,
|
||||
"definition_lang" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"dictionary_word_id" INTEGER,
|
||||
"dictionary_phrase_id" INTEGER,
|
||||
|
||||
CONSTRAINT "dictionary_lookups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_words" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"standard_form" TEXT NOT NULL,
|
||||
"query_lang" TEXT NOT NULL,
|
||||
"definition_lang" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_words_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_phrases" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"standard_form" TEXT NOT NULL,
|
||||
"query_lang" TEXT NOT NULL,
|
||||
"definition_lang" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_phrases_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_word_entries" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"word_id" INTEGER NOT NULL,
|
||||
"ipa" TEXT NOT NULL,
|
||||
"definition" TEXT NOT NULL,
|
||||
"part_of_speech" TEXT NOT NULL,
|
||||
"example" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_word_entries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_phrase_entries" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"phrase_id" INTEGER NOT NULL,
|
||||
"definition" TEXT NOT NULL,
|
||||
"example" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_phrase_entries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_lookups_user_id_idx" ON "dictionary_lookups"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_lookups_created_at_idx" ON "dictionary_lookups"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_lookups_text_query_lang_definition_lang_idx" ON "dictionary_lookups"("text", "query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_words_standard_form_idx" ON "dictionary_words"("standard_form");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_words_query_lang_definition_lang_idx" ON "dictionary_words"("query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "dictionary_words_standard_form_query_lang_definition_lang_key" ON "dictionary_words"("standard_form", "query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_phrases_standard_form_idx" ON "dictionary_phrases"("standard_form");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_phrases_query_lang_definition_lang_idx" ON "dictionary_phrases"("query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "dictionary_phrases_standard_form_query_lang_definition_lang_key" ON "dictionary_phrases"("standard_form", "query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_word_entries_word_id_idx" ON "dictionary_word_entries"("word_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_word_entries_created_at_idx" ON "dictionary_word_entries"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_phrase_entries_phrase_id_idx" ON "dictionary_phrase_entries"("phrase_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_phrase_entries_created_at_idx" ON "dictionary_phrase_entries"("created_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_word_id_fkey" FOREIGN KEY ("dictionary_word_id") REFERENCES "dictionary_words"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_phrase_id_fkey" FOREIGN KEY ("dictionary_phrase_id") REFERENCES "dictionary_phrases"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_word_entries" ADD CONSTRAINT "dictionary_word_entries_word_id_fkey" FOREIGN KEY ("word_id") REFERENCES "dictionary_words"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_phrase_entries" ADD CONSTRAINT "dictionary_phrase_entries_phrase_id_fkey" FOREIGN KEY ("phrase_id") REFERENCES "dictionary_phrases"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,8 +0,0 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "dictionary_phrases_standard_form_query_lang_definition_lang_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "dictionary_words_standard_form_query_lang_definition_lang_key";
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "pairs_folder_id_locale1_locale2_text1_key" RENAME TO "pairs_folder_id_language1_language2_text1_key";
|
||||
@@ -1,30 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "translation_history" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"source_text" TEXT NOT NULL,
|
||||
"source_language" VARCHAR(20) NOT NULL,
|
||||
"target_language" VARCHAR(20) NOT NULL,
|
||||
"translated_text" TEXT NOT NULL,
|
||||
"source_ipa" TEXT,
|
||||
"target_ipa" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "translation_history_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_user_id_idx" ON "translation_history"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_created_at_idx" ON "translation_history"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_source_text_target_language_idx" ON "translation_history"("source_text", "target_language");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_translated_text_source_language_target__idx" ON "translation_history"("translated_text", "source_language", "target_language");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "translation_history" ADD CONSTRAINT "translation_history_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[folder_id,language1,language2,text1,text2]` on the table `pairs` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- DropIndex
|
||||
DROP INDEX "pairs_folder_id_language1_language2_text1_key";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pairs_folder_id_language1_language2_text1_text2_key" ON "pairs"("folder_id", "language1", "language2", "text1", "text2");
|
||||
@@ -1,7 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "pairs" ALTER COLUMN "language1" SET DATA TYPE TEXT,
|
||||
ALTER COLUMN "language2" SET DATA TYPE TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "translation_history" ALTER COLUMN "source_language" SET DATA TYPE TEXT,
|
||||
ALTER COLUMN "target_language" SET DATA TYPE TEXT;
|
||||
@@ -1,94 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `dictionary_phrase_id` on the `dictionary_lookups` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `dictionary_word_id` on the `dictionary_lookups` table. All the data in the column will be lost.
|
||||
- You are about to drop the `dictionary_phrase_entries` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `dictionary_phrases` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `dictionary_word_entries` table. If the table is not empty, all the data it contains will be lost.
|
||||
- You are about to drop the `dictionary_words` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "dictionary_lookups" DROP CONSTRAINT "dictionary_lookups_dictionary_phrase_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "dictionary_lookups" DROP CONSTRAINT "dictionary_lookups_dictionary_word_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "dictionary_phrase_entries" DROP CONSTRAINT "dictionary_phrase_entries_phrase_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "dictionary_word_entries" DROP CONSTRAINT "dictionary_word_entries_word_id_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "dictionary_lookups_text_query_lang_definition_lang_idx";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "dictionary_lookups" DROP COLUMN "dictionary_phrase_id",
|
||||
DROP COLUMN "dictionary_word_id",
|
||||
ADD COLUMN "dictionary_item_id" INTEGER,
|
||||
ADD COLUMN "normalized_text" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "dictionary_phrase_entries";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "dictionary_phrases";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "dictionary_word_entries";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "dictionary_words";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_items" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"frequency" INTEGER NOT NULL DEFAULT 1,
|
||||
"standard_form" TEXT NOT NULL,
|
||||
"query_lang" TEXT NOT NULL,
|
||||
"definition_lang" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_entries" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"item_id" INTEGER NOT NULL,
|
||||
"ipa" TEXT,
|
||||
"definition" TEXT NOT NULL,
|
||||
"part_of_speech" TEXT,
|
||||
"example" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_entries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_items_standard_form_idx" ON "dictionary_items"("standard_form");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_items_query_lang_definition_lang_idx" ON "dictionary_items"("query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "dictionary_items_standard_form_query_lang_definition_lang_key" ON "dictionary_items"("standard_form", "query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_entries_item_id_idx" ON "dictionary_entries"("item_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_entries_created_at_idx" ON "dictionary_entries"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_lookups_normalized_text_idx" ON "dictionary_lookups"("normalized_text");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_item_id_fkey" FOREIGN KEY ("dictionary_item_id") REFERENCES "dictionary_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_entries" ADD CONSTRAINT "dictionary_entries_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "dictionary_items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[username]` on the table `user` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "user" ADD COLUMN "displayUsername" TEXT,
|
||||
ADD COLUMN "username" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_username_key" ON "user"("username");
|
||||
262
prisma/migrations/20260310014042_init/migration.sql
Normal file
262
prisma/migrations/20260310014042_init/migration.sql
Normal file
@@ -0,0 +1,262 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Visibility" AS ENUM ('PRIVATE', 'PUBLIC');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
||||
"image" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"displayUsername" TEXT,
|
||||
"username" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"ipAddress" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "account" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"providerId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"accessToken" TEXT,
|
||||
"refreshToken" TEXT,
|
||||
"idToken" TEXT,
|
||||
"accessTokenExpiresAt" TIMESTAMP(3),
|
||||
"refreshTokenExpiresAt" TIMESTAMP(3),
|
||||
"scope" TEXT,
|
||||
"password" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "verification" (
|
||||
"id" TEXT NOT NULL,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "pairs" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"language1" TEXT NOT NULL,
|
||||
"language2" TEXT NOT NULL,
|
||||
"text1" TEXT NOT NULL,
|
||||
"text2" TEXT NOT NULL,
|
||||
"ipa1" TEXT,
|
||||
"ipa2" TEXT,
|
||||
"folder_id" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "pairs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "folders" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "folders_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "folder_favorites" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"folder_id" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "folder_favorites_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_lookups" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"text" TEXT NOT NULL,
|
||||
"query_lang" TEXT NOT NULL,
|
||||
"definition_lang" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"dictionary_item_id" INTEGER,
|
||||
"normalized_text" TEXT NOT NULL DEFAULT '',
|
||||
|
||||
CONSTRAINT "dictionary_lookups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_items" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"frequency" INTEGER NOT NULL DEFAULT 1,
|
||||
"standard_form" TEXT NOT NULL,
|
||||
"query_lang" TEXT NOT NULL,
|
||||
"definition_lang" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dictionary_entries" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"item_id" INTEGER NOT NULL,
|
||||
"ipa" TEXT,
|
||||
"definition" TEXT NOT NULL,
|
||||
"part_of_speech" TEXT,
|
||||
"example" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dictionary_entries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "translation_history" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"source_text" TEXT NOT NULL,
|
||||
"source_language" TEXT NOT NULL,
|
||||
"target_language" TEXT NOT NULL,
|
||||
"translated_text" TEXT NOT NULL,
|
||||
"source_ipa" TEXT,
|
||||
"target_ipa" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "translation_history_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_username_key" ON "user"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "session_userId_idx" ON "session"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "account_userId_idx" ON "account"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "pairs_folder_id_idx" ON "pairs"("folder_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "pairs_folder_id_language1_language2_text1_text2_key" ON "pairs"("folder_id", "language1", "language2", "text1", "text2");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folders_user_id_idx" ON "folders"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folders_visibility_idx" ON "folders"("visibility");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folder_favorites_user_id_idx" ON "folder_favorites"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folder_favorites_folder_id_idx" ON "folder_favorites"("folder_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "folder_favorites_user_id_folder_id_key" ON "folder_favorites"("user_id", "folder_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_lookups_user_id_idx" ON "dictionary_lookups"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_lookups_created_at_idx" ON "dictionary_lookups"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_lookups_normalized_text_idx" ON "dictionary_lookups"("normalized_text");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_items_standard_form_idx" ON "dictionary_items"("standard_form");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_items_query_lang_definition_lang_idx" ON "dictionary_items"("query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "dictionary_items_standard_form_query_lang_definition_lang_key" ON "dictionary_items"("standard_form", "query_lang", "definition_lang");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_entries_item_id_idx" ON "dictionary_entries"("item_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dictionary_entries_created_at_idx" ON "dictionary_entries"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_user_id_idx" ON "translation_history"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_created_at_idx" ON "translation_history"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_source_text_target_language_idx" ON "translation_history"("source_text", "target_language");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "translation_history_translated_text_source_language_target__idx" ON "translation_history"("translated_text", "source_language", "target_language");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "pairs" ADD CONSTRAINT "pairs_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "folders" ADD CONSTRAINT "folders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "folder_favorites" ADD CONSTRAINT "folder_favorites_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "folder_favorites" ADD CONSTRAINT "folder_favorites_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_dictionary_item_id_fkey" FOREIGN KEY ("dictionary_item_id") REFERENCES "dictionary_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_lookups" ADD CONSTRAINT "dictionary_lookups_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dictionary_entries" ADD CONSTRAINT "dictionary_entries_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "dictionary_items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "translation_history" ADD CONSTRAINT "translation_history_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -16,10 +16,11 @@ model User {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
displayUsername String?
|
||||
username String? @unique
|
||||
username String @unique
|
||||
accounts Account[]
|
||||
dictionaryLookUps DictionaryLookUp[]
|
||||
folders Folder[]
|
||||
folderFavorites FolderFavorite[]
|
||||
sessions Session[]
|
||||
translationHistories TranslationHistory[]
|
||||
|
||||
@@ -91,19 +92,42 @@ model Pair {
|
||||
@@map("pairs")
|
||||
}
|
||||
|
||||
enum Visibility {
|
||||
PRIVATE
|
||||
PUBLIC
|
||||
}
|
||||
|
||||
model Folder {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
userId String @map("user_id")
|
||||
visibility Visibility @default(PRIVATE)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
pairs Pair[]
|
||||
favorites FolderFavorite[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([visibility])
|
||||
@@map("folders")
|
||||
}
|
||||
|
||||
model FolderFavorite {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String @map("user_id")
|
||||
folderId Int @map("folder_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, folderId])
|
||||
@@index([userId])
|
||||
@@index([folderId])
|
||||
@@map("folder_favorites")
|
||||
}
|
||||
|
||||
model DictionaryLookUp {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String? @map("user_id")
|
||||
|
||||
102
src/app/(auth)/forgot-password/page.tsx
Normal file
102
src/app/(auth)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardBody } from "@/design-system/base/card";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { PrimaryButton } from "@/design-system/base/button";
|
||||
import { VStack } from "@/design-system/layout/stack";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const t = useTranslations("auth");
|
||||
const [email, setEmail] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const handleResetRequest = async () => {
|
||||
if (!email) {
|
||||
toast.error(t("emailRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const { error } = await authClient.requestPasswordReset({
|
||||
email,
|
||||
redirectTo: "/reset-password",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message ?? t("resetPasswordFailed"));
|
||||
} else {
|
||||
setSent(true);
|
||||
toast.success(t("resetPasswordEmailSent"));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-2xl font-bold text-center w-full">
|
||||
{t("checkYourEmail")}
|
||||
</h1>
|
||||
<p className="text-center text-gray-600">
|
||||
{t("resetPasswordEmailSentHint")}
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary-500 hover:underline"
|
||||
>
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-3xl font-bold text-center w-full">
|
||||
{t("forgotPassword")}
|
||||
</h1>
|
||||
<p className="text-center text-gray-600 text-sm">
|
||||
{t("forgotPasswordHint")}
|
||||
</p>
|
||||
<VStack gap={0} align="center" justify="center" className="w-full">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</VStack>
|
||||
<PrimaryButton
|
||||
onClick={handleResetRequest}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
>
|
||||
{t("sendResetEmail")}
|
||||
</PrimaryButton>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-center text-primary-500 hover:underline"
|
||||
>
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardBody } from "@/design-system/base/card";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { PrimaryButton } from "@/design-system/base/button";
|
||||
import { VStack } from "@/design-system/layout/stack";
|
||||
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations("auth");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -19,37 +20,43 @@ export default function LoginPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const redirectTo = searchParams.get("redirect");
|
||||
|
||||
const session = authClient.useSession().data;
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
router.push(redirectTo ?? "/profile");
|
||||
if (!isPending && session?.user?.username && !redirectTo) {
|
||||
router.push("/folders");
|
||||
}
|
||||
}, [session, router, redirectTo]);
|
||||
}, [session, isPending, router, redirectTo]);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!username || !password) {
|
||||
toast.error("请输入用户名和密码");
|
||||
toast.error(t("enterCredentials"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (username.includes("@")) {
|
||||
await authClient.signIn.email({
|
||||
const { error } = await authClient.signIn.email({
|
||||
email: username,
|
||||
password: username
|
||||
password: password,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message ?? t("loginFailed"));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await authClient.signIn.username({
|
||||
const { error } = await authClient.signIn.username({
|
||||
username: username,
|
||||
password: password,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message ?? t("loginFailed"));
|
||||
return;
|
||||
}
|
||||
router.push(redirectTo ?? "/profile");
|
||||
} catch (error) {
|
||||
toast.error("登录失败");
|
||||
}
|
||||
router.push(redirectTo ?? "/folders");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -57,39 +64,46 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-80">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-3xl font-bold text-center w-full">登录</h1>
|
||||
<h1 className="text-3xl font-bold text-center w-full">{t("title")}</h1>
|
||||
|
||||
<VStack gap={0} align="center" justify="center" className="w-full">
|
||||
<Input
|
||||
placeholder="用户名或邮箱地址"
|
||||
placeholder={t("usernameOrEmailPlaceholder")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-gray-500 hover:text-primary-500 self-end"
|
||||
>
|
||||
{t("forgotPassword")}
|
||||
</Link>
|
||||
|
||||
<PrimaryButton
|
||||
onClick={handleLogin}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
>
|
||||
确认
|
||||
{t("confirm")}
|
||||
</PrimaryButton>
|
||||
|
||||
<Link
|
||||
href={"/signup" + (redirectTo ? `?redirect=${redirectTo}` : "")}
|
||||
className="text-center text-primary-500 hover:underline"
|
||||
>
|
||||
没有账号?去注册
|
||||
{t("noAccountLink")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
|
||||
@@ -8,7 +8,7 @@ export default async function LogoutPage(
|
||||
}
|
||||
) {
|
||||
const searchParams = await props.searchParams;
|
||||
const redirectTo = props.searchParams ?? null;
|
||||
const redirectTo = searchParams.redirect ?? null;
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers()
|
||||
|
||||
@@ -5,9 +5,9 @@ import { headers } from "next/headers";
|
||||
export default async function ProfilePage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session) {
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login?redirect=/profile");
|
||||
}
|
||||
|
||||
redirect(`/users/${session.user.username}`);
|
||||
redirect(session.user.username ? `/users/${session.user.username}` : "/folders");
|
||||
}
|
||||
|
||||
154
src/app/(auth)/reset-password/page.tsx
Normal file
154
src/app/(auth)/reset-password/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardBody } from "@/design-system/base/card";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { PrimaryButton } from "@/design-system/base/button";
|
||||
import { VStack } from "@/design-system/layout/stack";
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const t = useTranslations("auth");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!password || !confirmPassword) {
|
||||
toast.error(t("fillAllFields"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast.error(t("passwordsNotMatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
toast.error(t("passwordTooShort"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
toast.error(t("invalidToken"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const { error } = await authClient.resetPassword({
|
||||
newPassword: password,
|
||||
token,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message ?? t("resetPasswordFailed"));
|
||||
} else {
|
||||
setSuccess(true);
|
||||
toast.success(t("resetPasswordSuccess"));
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 2000);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-2xl font-bold text-center w-full">
|
||||
{t("resetPasswordSuccessTitle")}
|
||||
</h1>
|
||||
<p className="text-center text-gray-600">
|
||||
{t("resetPasswordSuccessHint")}
|
||||
</p>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary-500 hover:underline"
|
||||
>
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-2xl font-bold text-center w-full">
|
||||
{t("invalidToken")}
|
||||
</h1>
|
||||
<p className="text-center text-gray-600">
|
||||
{t("invalidTokenHint")}
|
||||
</p>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-primary-500 hover:underline"
|
||||
>
|
||||
{t("requestNewToken")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-3xl font-bold text-center w-full">
|
||||
{t("resetPassword")}
|
||||
</h1>
|
||||
<VStack gap={0} align="center" justify="center" className="w-full">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("newPassword")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("confirmPassword")}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</VStack>
|
||||
<PrimaryButton
|
||||
onClick={handleResetPassword}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
>
|
||||
{t("resetPassword")}
|
||||
</PrimaryButton>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-center text-primary-500 hover:underline"
|
||||
>
|
||||
{t("backToLogin")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,14 @@ import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardBody } from "@/design-system/base/card";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { PrimaryButton } from "@/design-system/base/button";
|
||||
import { VStack } from "@/design-system/layout/stack";
|
||||
|
||||
export default function SignUpPage() {
|
||||
const t = useTranslations("auth");
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -20,32 +22,34 @@ export default function SignUpPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const redirectTo = searchParams.get("redirect");
|
||||
|
||||
const session = authClient.useSession().data;
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
router.push(redirectTo ?? "/profile");
|
||||
if (!isPending && session?.user?.username && !redirectTo) {
|
||||
router.push("/folders");
|
||||
}
|
||||
}, [session, router, redirectTo]);
|
||||
}, [session, isPending, router, redirectTo]);
|
||||
|
||||
const handleSignUp = async () => {
|
||||
if (!username || !email || !password) {
|
||||
toast.error("请填写所有字段");
|
||||
toast.error(t("fillAllFields"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await authClient.signUp.email({
|
||||
const { error } = await authClient.signUp.email({
|
||||
email: email,
|
||||
name: username,
|
||||
username: username,
|
||||
password: password,
|
||||
});
|
||||
router.push(redirectTo ?? "/profile");
|
||||
} catch (error) {
|
||||
toast.error("注册失败");
|
||||
if (error) {
|
||||
toast.error(error.message ?? t("signUpFailed"));
|
||||
return;
|
||||
}
|
||||
router.push(redirectTo ?? "/folders");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -53,28 +57,28 @@ export default function SignUpPage() {
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-80">
|
||||
<Card className="w-96">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-3xl font-bold text-center w-full">注册</h1>
|
||||
<h1 className="text-3xl font-bold text-center w-full">{t("signUpTitle")}</h1>
|
||||
|
||||
<VStack gap={0} align="center" justify="center" className="w-full">
|
||||
<Input
|
||||
placeholder="用户名"
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="邮箱地址"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
@@ -85,14 +89,14 @@ export default function SignUpPage() {
|
||||
loading={loading}
|
||||
fullWidth
|
||||
>
|
||||
确认
|
||||
{t("confirm")}
|
||||
</PrimaryButton>
|
||||
|
||||
<Link
|
||||
href={"/login" + (redirectTo ? `?redirect=${redirectTo}` : "")}
|
||||
className="text-center text-primary-500 hover:underline"
|
||||
>
|
||||
已有账号?去登录
|
||||
{t("hasAccountLink")}
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
|
||||
@@ -42,7 +42,7 @@ export default async function UserPage({ params }: UserPageProps) {
|
||||
<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">登出</LinkButton>}
|
||||
{isOwnProfile && <LinkButton href="/logout">{t("logout")}</LinkButton>}
|
||||
</div>
|
||||
<div className="flex items-center space-x-6">
|
||||
{/* Avatar */}
|
||||
|
||||
@@ -54,8 +54,8 @@ export default function Alphabet() {
|
||||
{t("chooseCharacters")}
|
||||
</h1>
|
||||
{/* 副标题说明 */}
|
||||
<p className="text-gray-600 mb-8 text-lg">
|
||||
选择一种语言的字母表开始学习
|
||||
<p className="text-lg text-gray-600 text-center">
|
||||
{t("chooseAlphabetHint")}
|
||||
</p>
|
||||
|
||||
{/* 语言选择按钮网格 */}
|
||||
|
||||
240
src/app/(features)/dictionary/DictionaryClient.tsx
Normal file
240
src/app/(features)/dictionary/DictionaryClient.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"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-action";
|
||||
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?.entries?.length) return;
|
||||
|
||||
const definition = searchResult.entries
|
||||
.map((e) => e.definition)
|
||||
.join(" | ");
|
||||
|
||||
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
|
||||
containerClassName="flex-1"
|
||||
/>
|
||||
<LightButton
|
||||
type="submit"
|
||||
className="h-10 px-6 rounded-full whitespace-nowrap"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { TSharedEntry } from "@/shared/dictionary-type";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DictionaryEntryProps {
|
||||
entry: TSharedEntry;
|
||||
}
|
||||
|
||||
export function DictionaryEntry({ entry }: DictionaryEntryProps) {
|
||||
const t = useTranslations("dictionary");
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 音标和词性 */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{entry.ipa && (
|
||||
<span className="text-gray-600 text-lg">
|
||||
@@ -21,19 +23,17 @@ export function DictionaryEntry({ entry }: DictionaryEntryProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 释义 */}
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
释义
|
||||
{t("definition")}
|
||||
</h3>
|
||||
<p className="text-gray-800">{entry.definition}</p>
|
||||
</div>
|
||||
|
||||
{/* 例句 */}
|
||||
{entry.example && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-1">
|
||||
例句
|
||||
{t("example")}
|
||||
</h3>
|
||||
<p className="text-gray-700 pl-4 border-l-4 border-[#35786f]">
|
||||
{entry.example}
|
||||
|
||||
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 { SearchForm } from "./SearchForm";
|
||||
import { SearchResult } from "./SearchResult";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { actionLookUpDictionary } from "@/modules/dictionary/dictionary-action";
|
||||
import { TSharedItem } from "@/shared/dictionary-type";
|
||||
import { DictionaryClient } from "./DictionaryClient";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { actionGetFoldersByUserId } from "@/modules/folder/folder-action";
|
||||
import { TSharedFolder } from "@/shared/folder-type";
|
||||
|
||||
interface DictionaryPageProps {
|
||||
searchParams: Promise<{ q?: string; ql?: string; dl?: string; }>;
|
||||
}
|
||||
export default async function DictionaryPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
export default async function DictionaryPage({ searchParams }: DictionaryPageProps) {
|
||||
const t = await getTranslations("dictionary");
|
||||
|
||||
// 从 searchParams 获取搜索参数
|
||||
const { q: searchQuery, ql: queryLang = "english", dl: definitionLang = "chinese" } = await searchParams;
|
||||
|
||||
// 如果有搜索查询,获取搜索结果
|
||||
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
|
||||
});
|
||||
let folders: TSharedFolder[] = [];
|
||||
|
||||
if (session?.user?.id) {
|
||||
const result = await actionGetFoldersByUserId(session.user.id as string);
|
||||
if (result.success && result.data) {
|
||||
searchResult = result.data;
|
||||
folders = result.data;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return <DictionaryClient initialFolders={folders} />;
|
||||
}
|
||||
|
||||
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' }
|
||||
)
|
||||
);
|
||||
201
src/app/(features)/explore/ExploreClient.tsx
Normal file
201
src/app/(features)/explore/ExploreClient.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Folder as Fd,
|
||||
Heart,
|
||||
Search,
|
||||
ArrowUpDown,
|
||||
} from "lucide-react";
|
||||
import { CircleButton } from "@/design-system/base/button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import {
|
||||
actionSearchPublicFolders,
|
||||
actionToggleFavorite,
|
||||
actionCheckFavorite,
|
||||
} from "@/modules/folder/folder-action";
|
||||
import { TPublicFolder } from "@/shared/folder-type";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
interface PublicFolderCardProps {
|
||||
folder: TPublicFolder;
|
||||
currentUserId?: string;
|
||||
onUpdateFavorite: (folderId: number, isFavorited: boolean, favoriteCount: number) => void;
|
||||
}
|
||||
|
||||
const PublicFolderCard = ({ folder, currentUserId, onUpdateFavorite }: PublicFolderCardProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("explore");
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUserId) {
|
||||
actionCheckFavorite(folder.id).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [folder.id, currentUserId]);
|
||||
|
||||
const handleToggleFavorite = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!currentUserId) {
|
||||
toast.error(t("pleaseLogin"));
|
||||
return;
|
||||
}
|
||||
const result = await actionToggleFavorite(folder.id);
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
onUpdateFavorite(folder.id, result.data.isFavorited, result.data.favoriteCount);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
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={() => {
|
||||
router.push(`/explore/${folder.id}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2 sm:mb-3">
|
||||
<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={18} className="sm:hidden" />
|
||||
<Fd size={22} className="hidden sm:block" />
|
||||
</div>
|
||||
<CircleButton
|
||||
onClick={handleToggleFavorite}
|
||||
title={isFavorited ? t("unfavorite") : t("favorite")}
|
||||
>
|
||||
<Heart
|
||||
size={16}
|
||||
className={`sm:w-[18px] sm:h-[18px] sm:text-[18px] ${isFavorited ? "fill-red-500 text-red-500" : ""}`}
|
||||
/>
|
||||
</CircleButton>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-gray-900 truncate text-sm sm:text-base mb-1 sm:mb-2">{folder.name}</h3>
|
||||
|
||||
<p className="text-xs sm:text-sm text-gray-500 mb-2 sm:mb-3 line-clamp-2">
|
||||
{t("folderInfo", {
|
||||
userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
|
||||
totalPairs: folder.totalPairs,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-1 text-xs sm:text-sm text-gray-400">
|
||||
<Heart size={12} className="sm:w-3.5 sm:h-3.5" />
|
||||
<span>{favoriteCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ExploreClientProps {
|
||||
initialPublicFolders: TPublicFolder[];
|
||||
}
|
||||
|
||||
export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
|
||||
const t = useTranslations("explore");
|
||||
const router = useRouter();
|
||||
const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortByFavorites, setSortByFavorites] = useState(false);
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setPublicFolders(initialPublicFolders);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const result = await actionSearchPublicFolders(searchQuery.trim());
|
||||
if (result.success && result.data) {
|
||||
setPublicFolders(result.data);
|
||||
}
|
||||
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) => {
|
||||
setPublicFolders((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === folderId ? { ...f, favoriteCount } : f
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
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>
|
||||
<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}>
|
||||
<Search size={18} />
|
||||
</CircleButton>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<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>
|
||||
</div>
|
||||
) : sortedFolders.length === 0 ? (
|
||||
<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">
|
||||
<Fd size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm">{t("noFolders")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{sortedFolders.map((folder) => (
|
||||
<PublicFolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
currentUserId={currentUserId}
|
||||
onUpdateFavorite={handleUpdateFavorite}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
146
src/app/(features)/explore/[id]/ExploreDetailClient.tsx
Normal file
146
src/app/(features)/explore/[id]/ExploreDetailClient.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { Folder as Fd, Heart, ExternalLink, ArrowLeft } from "lucide-react";
|
||||
import { CircleButton } from "@/design-system/base/button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
actionToggleFavorite,
|
||||
actionCheckFavorite,
|
||||
} from "@/modules/folder/folder-action";
|
||||
import { ActionOutputPublicFolder } from "@/modules/folder/folder-action-dto";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
interface ExploreDetailClientProps {
|
||||
folder: ActionOutputPublicFolder;
|
||||
}
|
||||
|
||||
export function ExploreDetailClient({ folder }: ExploreDetailClientProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("exploreDetail");
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUserId) {
|
||||
actionCheckFavorite(folder.id).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [folder.id, currentUserId]);
|
||||
|
||||
const handleToggleFavorite = async () => {
|
||||
if (!currentUserId) {
|
||||
toast.error(t("pleaseLogin"));
|
||||
return;
|
||||
}
|
||||
const result = await actionToggleFavorite(folder.id);
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
toast.success(
|
||||
result.data.isFavorited ? t("favorited") : t("unfavorited")
|
||||
);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-3xl mx-auto px-4 py-6 sm:py-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<CircleButton onClick={() => router.push("/explore")}>
|
||||
<ArrowLeft size={18} />
|
||||
</CircleButton>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-gray-900">
|
||||
{t("title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 sm:p-8 shadow-sm">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 sm:w-16 sm:h-16 rounded-xl bg-primary-50 flex items-center justify-center text-primary-500">
|
||||
<Fd size={28} className="sm:w-8 sm:h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900">
|
||||
{folder.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{t("createdBy", {
|
||||
name: folder.userName ?? folder.userUsername ?? t("unknownUser"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<CircleButton
|
||||
onClick={handleToggleFavorite}
|
||||
title={isFavorited ? t("unfavorite") : t("favorite")}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Heart
|
||||
size={20}
|
||||
className={isFavorited ? "fill-red-500 text-red-500" : ""}
|
||||
/>
|
||||
</CircleButton>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-6 py-4 border-y border-gray-100">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl sm:text-3xl font-bold text-primary-600">
|
||||
{folder.totalPairs}
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-gray-500 mt-1">
|
||||
{t("totalPairs")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center border-x border-gray-100">
|
||||
<div className="text-2xl sm:text-3xl font-bold text-red-500 flex items-center justify-center gap-1">
|
||||
<Heart size={18} className={isFavorited ? "fill-red-500" : ""} />
|
||||
{favoriteCount}
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-gray-500 mt-1">
|
||||
{t("favorites")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-lg sm:text-xl font-semibold text-gray-700">
|
||||
{formatDate(folder.createdAt)}
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-gray-500 mt-1">
|
||||
{t("createdAt")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/folders/${folder.id}`}
|
||||
className="flex items-center justify-center gap-2 w-full py-3 px-4 bg-primary-500 hover:bg-primary-600 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
{t("viewContent")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/app/(features)/explore/[id]/page.tsx
Normal file
23
src/app/(features)/explore/[id]/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { ExploreDetailClient } from "./ExploreDetailClient";
|
||||
import { actionGetPublicFolderById } from "@/modules/folder/folder-action";
|
||||
|
||||
export default async function ExploreFolderPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
if (!id) {
|
||||
redirect("/explore");
|
||||
}
|
||||
|
||||
const result = await actionGetPublicFolderById(Number(id));
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
redirect("/explore");
|
||||
}
|
||||
|
||||
return <ExploreDetailClient folder={result.data} />;
|
||||
}
|
||||
9
src/app/(features)/explore/page.tsx
Normal file
9
src/app/(features)/explore/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ExploreClient } from "./ExploreClient";
|
||||
import { actionGetPublicFolders } from "@/modules/folder/folder-action";
|
||||
|
||||
export default async function ExplorePage() {
|
||||
const publicFoldersResult = await actionGetPublicFolders();
|
||||
const publicFolders = publicFoldersResult.success ? publicFoldersResult.data ?? [] : [];
|
||||
|
||||
return <ExploreClient initialPublicFolders={publicFolders} />;
|
||||
}
|
||||
143
src/app/(features)/favorites/FavoritesClient.tsx
Normal file
143
src/app/(features)/favorites/FavoritesClient.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronRight,
|
||||
Folder as Fd,
|
||||
Heart,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { CardList } from "@/components/ui/CardList";
|
||||
import { actionGetUserFavorites, actionToggleFavorite } from "@/modules/folder/folder-action";
|
||||
|
||||
type UserFavorite = {
|
||||
id: number;
|
||||
folderId: number;
|
||||
folderName: string;
|
||||
folderCreatedAt: Date;
|
||||
folderTotalPairs: number;
|
||||
folderOwnerId: string;
|
||||
folderOwnerName: string | null;
|
||||
folderOwnerUsername: string | null;
|
||||
favoritedAt: Date;
|
||||
};
|
||||
|
||||
interface FavoriteCardProps {
|
||||
favorite: UserFavorite;
|
||||
onRemoveFavorite: (folderId: number) => void;
|
||||
}
|
||||
|
||||
const FavoriteCard = ({ favorite, onRemoveFavorite }: FavoriteCardProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("favorites");
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
|
||||
const handleRemoveFavorite = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isRemoving) return;
|
||||
|
||||
setIsRemoving(true);
|
||||
const result = await actionToggleFavorite(favorite.folderId);
|
||||
if (result.success) {
|
||||
onRemoveFavorite(favorite.folderId);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
setIsRemoving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
router.push(`/explore/${favorite.folderId}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="shrink-0 text-primary-500">
|
||||
<Fd size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{favorite.folderName}</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{t("folderInfo", {
|
||||
userName: favorite.folderOwnerName ?? favorite.folderOwnerUsername ?? t("unknownUser"),
|
||||
totalPairs: favorite.folderTotalPairs,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Heart
|
||||
size={18}
|
||||
className="fill-red-500 text-red-500 cursor-pointer hover:scale-110 transition-transform"
|
||||
onClick={handleRemoveFavorite}
|
||||
/>
|
||||
<ChevronRight size={20} className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FavoritesClientProps {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export function FavoritesClient({ userId }: FavoritesClientProps) {
|
||||
const t = useTranslations("favorites");
|
||||
const [favorites, setFavorites] = useState<UserFavorite[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadFavorites();
|
||||
}, [userId]);
|
||||
|
||||
const loadFavorites = async () => {
|
||||
setLoading(true);
|
||||
const result = await actionGetUserFavorites();
|
||||
if (result.success && result.data) {
|
||||
setFavorites(result.data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleRemoveFavorite = (folderId: number) => {
|
||||
setFavorites((prev) => prev.filter((f) => f.folderId !== folderId));
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<CardList>
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<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>
|
||||
</div>
|
||||
) : favorites.length === 0 ? (
|
||||
<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">
|
||||
<Heart size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm">{t("noFavorites")}</p>
|
||||
</div>
|
||||
) : (
|
||||
favorites.map((favorite) => (
|
||||
<FavoriteCard
|
||||
key={favorite.id}
|
||||
favorite={favorite}
|
||||
onRemoveFavorite={handleRemoveFavorite}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardList>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
14
src/app/(features)/favorites/page.tsx
Normal file
14
src/app/(features)/favorites/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { FavoritesClient } from "./FavoritesClient";
|
||||
|
||||
export default async function FavoritesPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session) {
|
||||
redirect("/login?redirect=/favorites");
|
||||
}
|
||||
|
||||
return <FavoritesClient userId={session.user.id} />;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { FolderSelector } from "./FolderSelector";
|
||||
import { Memorize } from "./Memorize";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { actionGetFoldersWithTotalPairsByUserId, actionGetPairsByFolderId } from "@/modules/folder/folder-aciton";
|
||||
import { actionGetFoldersWithTotalPairsByUserId, actionGetPairsByFolderId } from "@/modules/folder/folder-action";
|
||||
|
||||
export default async function MemorizePage({
|
||||
searchParams,
|
||||
|
||||
@@ -115,6 +115,8 @@ export default function SrtPlayerPage() {
|
||||
key={i}
|
||||
href={`/dictionary?q=${s}`}
|
||||
className="px-1 h-fit hover:bg-gray-200 hover:cursor-pointer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{s}
|
||||
</Link>
|
||||
@@ -125,21 +127,21 @@ export default function SrtPlayerPage() {
|
||||
<div className="border-gray-200 border-2 flex items p-2 justify-between items-center rounded gap-8">
|
||||
<div className="flex items-center flex-col">
|
||||
<Video size={16} />
|
||||
<span className="text-sm">视频文件</span>
|
||||
<span className="text-sm">{srtT("videoFile")}</span>
|
||||
</div>
|
||||
<LightButton onClick={handleVideoUpload} disabled={!!videoUrl}>
|
||||
{videoUrl ? '已上传' : '上传视频'}
|
||||
{videoUrl ? srtT("uploaded") : srtT("uploadVideoButton")}
|
||||
</LightButton>
|
||||
</div>
|
||||
<div className="border-gray-200 border-2 flex items p-2 justify-between items-center rounded gap-8">
|
||||
<div className="flex items-center flex-col">
|
||||
<FileText size={16} />
|
||||
<span className="text-sm">
|
||||
{subtitleData.length > 0 ? `字幕已上传 (${subtitleData.length} 条)` : "字幕未上传"}
|
||||
{subtitleData.length > 0 ? srtT("subtitleUploaded", { count: subtitleData.length }) : srtT("subtitleNotUploaded")}
|
||||
</span>
|
||||
</div>
|
||||
<LightButton onClick={handleSubtitleUpload} disabled={!!subtitleUrl}>
|
||||
{subtitleUrl ? '已上传' : '上传字幕'}
|
||||
{subtitleUrl ? srtT("uploaded") : srtT("uploadSubtitleButton")}
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,13 +62,12 @@ export function getNearestIndex(
|
||||
): number | null {
|
||||
for (let i = 0; i < subtitles.length; i++) {
|
||||
const subtitle = subtitles[i];
|
||||
const isBefore = currentTime - subtitle.start >= 0;
|
||||
const isAfter = currentTime - subtitle.end >= 0;
|
||||
const isWithin = currentTime >= subtitle.start && currentTime <= subtitle.end;
|
||||
|
||||
if (!isBefore || !isAfter) return i - 1;
|
||||
if (isBefore && !isAfter) return i;
|
||||
if (isWithin) return i;
|
||||
if (currentTime < subtitle.start) return i > 0 ? i - 1 : null;
|
||||
}
|
||||
return null;
|
||||
return subtitles.length > 0 ? subtitles.length - 1 : null;
|
||||
}
|
||||
|
||||
export function getCurrentSubtitle(
|
||||
|
||||
@@ -60,11 +60,12 @@ export function SaveList({ show = false, handleUse }: SaveListProps) {
|
||||
const [data, setData] = useState(getFromLocalStorage());
|
||||
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
|
||||
const current_data = getFromLocalStorage();
|
||||
if (!current_data) return;
|
||||
|
||||
current_data.splice(
|
||||
current_data.findIndex((v) => v.text === item.text),
|
||||
1,
|
||||
);
|
||||
const index = current_data.findIndex((v) => v.text === item.text);
|
||||
if (index === -1) return;
|
||||
|
||||
current_data.splice(index, 1);
|
||||
setIntoLocalStorage(current_data);
|
||||
refresh();
|
||||
};
|
||||
@@ -78,33 +79,25 @@ export function SaveList({ show = false, handleUse }: SaveListProps) {
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
if (show)
|
||||
if (show && data)
|
||||
return (
|
||||
<div
|
||||
className="my-4 p-2 mx-4 md:mx-32 border border-gray-200 rounded-lg"
|
||||
style={{ fontFamily: "Times New Roman, serif" }}
|
||||
>
|
||||
<div className="flex flex-row justify-center gap-8 items-center">
|
||||
<IconClick
|
||||
src={IMAGES.refresh}
|
||||
alt="refresh"
|
||||
onClick={refresh}
|
||||
size="lg"
|
||||
className=""
|
||||
></IconClick>
|
||||
<IconClick
|
||||
src={IMAGES.delete}
|
||||
alt="delete"
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<p className="text-sm text-gray-600">{t("saved")}</p>
|
||||
<button
|
||||
onClick={handleDeleteAll}
|
||||
size="lg"
|
||||
className=""
|
||||
></IconClick>
|
||||
className="text-xs text-gray-500 hover:text-gray-800"
|
||||
>
|
||||
{t("clearAll")}
|
||||
</button>
|
||||
</div>
|
||||
<ul>
|
||||
{data.map((v) => (
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{data.map((item, i) => (
|
||||
<TextCard
|
||||
item={v}
|
||||
key={crypto.randomUUID()}
|
||||
key={i}
|
||||
item={item}
|
||||
handleUse={handleUse}
|
||||
handleDel={handleDel}
|
||||
></TextCard>
|
||||
|
||||
@@ -48,8 +48,8 @@ export default function TextSpeakerPage() {
|
||||
const handleEnded = () => {
|
||||
if (autopause) {
|
||||
setPause(true);
|
||||
} else {
|
||||
load(objurlRef.current!);
|
||||
} else if (objurlRef.current) {
|
||||
load(objurlRef.current);
|
||||
play();
|
||||
}
|
||||
};
|
||||
@@ -187,7 +187,7 @@ export default function TextSpeakerPage() {
|
||||
theIPA = tmp_ipa;
|
||||
}
|
||||
|
||||
const save = getFromLocalStorage();
|
||||
const save = getFromLocalStorage() ?? [];
|
||||
const oldIndex = save.findIndex((v) => v.text === textRef.current);
|
||||
if (oldIndex !== -1) {
|
||||
const oldItem = save[oldIndex];
|
||||
@@ -293,7 +293,7 @@ export default function TextSpeakerPage() {
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
setAutopause(!autopause);
|
||||
if (objurlRef) {
|
||||
if (objurlRef.current) {
|
||||
stop();
|
||||
}
|
||||
setPause(true);
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
Folder as Fd,
|
||||
FolderPen,
|
||||
FolderPlus,
|
||||
Globe,
|
||||
Lock,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { CircleButton, LightButton } from "@/design-system/base/button";
|
||||
@@ -15,18 +17,62 @@ import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { CardList } from "@/components/ui/CardList";
|
||||
import { actionCreateFolder, actionDeleteFolderById, actionGetFoldersWithTotalPairsByUserId, actionRenameFolderById } from "@/modules/folder/folder-aciton";
|
||||
import {
|
||||
actionCreateFolder,
|
||||
actionDeleteFolderById,
|
||||
actionGetFoldersWithTotalPairsByUserId,
|
||||
actionRenameFolderById,
|
||||
actionSetFolderVisibility,
|
||||
} from "@/modules/folder/folder-action";
|
||||
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||
|
||||
interface FolderProps {
|
||||
interface FolderCardProps {
|
||||
folder: TSharedFolderWithTotalPairs;
|
||||
refresh: () => void;
|
||||
onUpdateFolder: (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => void;
|
||||
onDeleteFolder: (folderId: number) => void;
|
||||
}
|
||||
|
||||
const FolderCard = ({ folder, refresh }: FolderProps) => {
|
||||
const FolderCard = ({ folder, onUpdateFolder, onDeleteFolder }: FolderCardProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("folders");
|
||||
|
||||
const handleToggleVisibility = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const newVisibility = folder.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC";
|
||||
const result = await actionSetFolderVisibility(folder.id, newVisibility);
|
||||
if (result.success) {
|
||||
onUpdateFolder(folder.id, { visibility: newVisibility });
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const newName = prompt(t("enterNewName"))?.trim();
|
||||
if (newName && newName.length > 0) {
|
||||
const result = await actionRenameFolderById(folder.id, newName);
|
||||
if (result.success) {
|
||||
onUpdateFolder(folder.id, { name: newName });
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const confirm = prompt(t("confirmDelete", { name: folder.name }));
|
||||
if (confirm === folder.name) {
|
||||
const result = await actionDeleteFolderById(folder.id);
|
||||
if (result.success) {
|
||||
onDeleteFolder(folder.id);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
@@ -40,7 +86,17 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3>
|
||||
<span className="flex items-center gap-1 text-xs text-gray-400">
|
||||
{folder.visibility === "PUBLIC" ? (
|
||||
<Globe size={12} />
|
||||
) : (
|
||||
<Lock size={12} />
|
||||
)}
|
||||
{folder.visibility === "PUBLIC" ? t("public") : t("private")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{t("folderInfo", {
|
||||
id: folder.id,
|
||||
@@ -53,138 +109,110 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
|
||||
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
<CircleButton
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const newName = prompt("Input a new name.")?.trim();
|
||||
if (newName && newName.length > 0) {
|
||||
actionRenameFolderById(folder.id, newName)
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
refresh();
|
||||
}
|
||||
else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClick={handleToggleVisibility}
|
||||
title={folder.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")}
|
||||
>
|
||||
{folder.visibility === "PUBLIC" ? (
|
||||
<Lock size={18} />
|
||||
) : (
|
||||
<Globe size={18} />
|
||||
)}
|
||||
</CircleButton>
|
||||
<CircleButton onClick={handleRename}>
|
||||
<FolderPen size={18} />
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const confirm = prompt(t("confirmDelete", { name: folder.name }));
|
||||
if (confirm === folder.name) {
|
||||
actionDeleteFolderById(folder.id)
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
refresh();
|
||||
}
|
||||
else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
|
||||
onClick={handleDelete}
|
||||
className="hover:text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</CircleButton>
|
||||
<ChevronRight size={20} className="text-gray-400 ml-1" />
|
||||
<ChevronRight size={20} className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function FoldersClient({ userId }: { userId: string; }) {
|
||||
interface FoldersClientProps {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export function FoldersClient({ userId }: FoldersClientProps) {
|
||||
const t = useTranslations("folders");
|
||||
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>(
|
||||
[],
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadFolders = async () => {
|
||||
setLoading(true);
|
||||
const result = await actionGetFoldersWithTotalPairsByUserId(userId);
|
||||
if (result.success && result.data) {
|
||||
setFolders(result.data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
actionGetFoldersWithTotalPairsByUserId(userId)
|
||||
.then((folders) => {
|
||||
if (folders.success && folders.data) {
|
||||
setFolders(folders.data);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
loadFolders();
|
||||
}, [userId]);
|
||||
|
||||
const updateFolders = async () => {
|
||||
setLoading(true);
|
||||
await actionGetFoldersWithTotalPairsByUserId(userId)
|
||||
.then(async result => {
|
||||
if (!result.success) toast.error(result.message);
|
||||
else await actionGetFoldersWithTotalPairsByUserId(userId)
|
||||
.then((folders) => {
|
||||
if (folders.success && folders.data) {
|
||||
setFolders(folders.data);
|
||||
const handleUpdateFolder = (folderId: number, updates: Partial<TSharedFolderWithTotalPairs>) => {
|
||||
setFolders((prev) =>
|
||||
prev.map((f) => (f.id === folderId ? { ...f, ...updates } : f))
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteFolder = (folderId: number) => {
|
||||
setFolders((prev) => prev.filter((f) => f.id !== folderId));
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
const folderName = prompt(t("enterFolderName"));
|
||||
if (!folderName?.trim()) return;
|
||||
|
||||
const result = await actionCreateFolder(userId, folderName.trim());
|
||||
if (result.success) {
|
||||
loadFolders();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
{/* 新建文件夹按钮 */}
|
||||
<LightButton
|
||||
onClick={async () => {
|
||||
const folderName = prompt(t("enterFolderName"));
|
||||
if (!folderName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await actionCreateFolder(userId, folderName)
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
updateFolders();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
className="w-full border-dashed"
|
||||
>
|
||||
<FolderPlus size={20} />
|
||||
<span>{loading ? t("creating") : t("newFolder")}</span>
|
||||
<div className="mb-4">
|
||||
<LightButton onClick={handleCreateFolder}>
|
||||
<FolderPlus size={18} />
|
||||
{t("newFolder")}
|
||||
</LightButton>
|
||||
</div>
|
||||
|
||||
{/* 文件夹列表 */}
|
||||
<div className="mt-4">
|
||||
<CardList>
|
||||
{folders.length === 0 ? (
|
||||
// 空状态
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<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>
|
||||
</div>
|
||||
) : folders.length === 0 ? (
|
||||
<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">
|
||||
<FolderPlus size={24} className="text-gray-400" />
|
||||
<Fd size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm">{t("noFoldersYet")}</p>
|
||||
</div>
|
||||
) : (
|
||||
// 文件夹卡片列表
|
||||
folders
|
||||
.toSorted((a, b) => a.id - b.id)
|
||||
.map((folder) => (
|
||||
folders.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
refresh={updateFolders}
|
||||
onUpdateFolder={handleUpdateFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardList>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTranslations } from "next-intl";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PrimaryButton, CircleButton, LinkButton } from "@/design-system/base/button";
|
||||
import { CardList } from "@/components/ui/CardList";
|
||||
import { actionCreatePair, actionDeletePairById, actionGetPairsByFolderId } from "@/modules/folder/folder-aciton";
|
||||
import { actionCreatePair, actionDeletePairById, actionGetPairsByFolderId } from "@/modules/folder/folder-action";
|
||||
import { TSharedPair } from "@/shared/folder-type";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -26,10 +26,14 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
|
||||
setLoading(true);
|
||||
await actionGetPairsByFolderId(folderId)
|
||||
.then(result => {
|
||||
if (!result.success || !result.data) throw result.message;
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.message || "Failed to load text pairs");
|
||||
}
|
||||
return result.data;
|
||||
}).then(setTextPairs)
|
||||
.catch(toast.error)
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
@@ -40,10 +44,14 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
|
||||
const refreshTextPairs = async () => {
|
||||
await actionGetPairsByFolderId(folderId)
|
||||
.then(result => {
|
||||
if (!result.success || !result.data) throw result.message;
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.message || "Failed to refresh text pairs");
|
||||
}
|
||||
return result.data;
|
||||
}).then(setTextPairs)
|
||||
.catch(toast.error);
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -119,9 +127,11 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
|
||||
onDel={() => {
|
||||
actionDeletePairById(textPair.id)
|
||||
.then(result => {
|
||||
if (!result.success) throw result.message;
|
||||
if (!result.success) throw new Error(result.message || "Delete failed");
|
||||
}).then(refreshTextPairs)
|
||||
.catch(toast.error);
|
||||
.catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error");
|
||||
});
|
||||
}}
|
||||
refreshTextPairs={refreshTextPairs}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CircleButton } from "@/design-system/base/button";
|
||||
import { UpdateTextPairModal } from "./UpdateTextPairModal";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { TSharedPair } from "@/shared/folder-type";
|
||||
import { actionUpdatePairById } from "@/modules/folder/folder-aciton";
|
||||
import { actionUpdatePairById } from "@/modules/folder/folder-action";
|
||||
import { ActionInputUpdatePairById } from "@/modules/folder/folder-action-dto";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import { InFolder } from "./InFolder";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { actionGetUserIdByFolderId } from "@/modules/folder/folder-aciton";
|
||||
import { actionGetFolderVisibility } from "@/modules/folder/folder-action";
|
||||
|
||||
export default async function FoldersPage({
|
||||
params,
|
||||
@@ -18,9 +18,19 @@ export default async function FoldersPage({
|
||||
redirect("/folders");
|
||||
}
|
||||
|
||||
// Allow non-authenticated users to view folders (read-only mode)
|
||||
const folderUserId = (await actionGetUserIdByFolderId(Number(folder_id))).data;
|
||||
const isOwner = session?.user?.id === folderUserId;
|
||||
const folderInfo = (await actionGetFolderVisibility(Number(folder_id))).data;
|
||||
|
||||
if (!folderInfo) {
|
||||
redirect("/folders");
|
||||
}
|
||||
|
||||
const isOwner = session?.user?.id === folderInfo.userId;
|
||||
const isPublic = folderInfo.visibility === "PUBLIC";
|
||||
|
||||
if (!isOwner && !isPublic) {
|
||||
redirect("/folders");
|
||||
}
|
||||
|
||||
const isReadOnly = !isOwner;
|
||||
|
||||
return <InFolder folderId={Number(folder_id)} isReadOnly={isReadOnly} />;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { auth } from "@/auth";
|
||||
import { FoldersClient } from "./FoldersClient";
|
||||
import { redirect } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function FoldersPage() {
|
||||
const session = await auth.api.getSession(
|
||||
{ headers: await headers() }
|
||||
);
|
||||
if (!session) redirect(`/login?redirect=/folders`);
|
||||
|
||||
if (!session) {
|
||||
redirect("/login?redirect=/folders");
|
||||
}
|
||||
|
||||
return <FoldersClient userId={session.user.id} />;
|
||||
}
|
||||
|
||||
46
src/auth.ts
46
src/auth.ts
@@ -1,21 +1,57 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||
import { nextCookies } from "better-auth/next-js";
|
||||
import { prisma } from "./lib/db";
|
||||
import { username } from "better-auth/plugins";
|
||||
import { createAuthMiddleware, APIError } from "better-auth/api";
|
||||
import { prisma } from "./lib/db";
|
||||
import {
|
||||
sendEmail,
|
||||
generateVerificationEmailHtml,
|
||||
generateResetPasswordEmailHtml,
|
||||
} from "./lib/email";
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: "postgresql"
|
||||
provider: "postgresql",
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true
|
||||
enabled: true,
|
||||
requireEmailVerification: true,
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
void sendEmail({
|
||||
to: user.email,
|
||||
subject: "重置您的密码 - Learn Languages",
|
||||
html: generateResetPasswordEmailHtml(url, user.name || "用户"),
|
||||
});
|
||||
},
|
||||
},
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
void sendEmail({
|
||||
to: user.email,
|
||||
subject: "验证您的邮箱 - Learn Languages",
|
||||
html: generateVerificationEmailHtml(url, user.name || "用户"),
|
||||
});
|
||||
},
|
||||
},
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID as string,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET as string
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
|
||||
},
|
||||
},
|
||||
plugins: [nextCookies(), username()]
|
||||
plugins: [nextCookies(), username()],
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
if (ctx.path !== "/sign-up/email" && ctx.path !== "/update-user") return;
|
||||
|
||||
const body = ctx.body as { username?: string };
|
||||
if (!body.username || body.username.trim() === "") {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Username is required",
|
||||
});
|
||||
}
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Image from "next/image";
|
||||
import { IMAGES } from "@/config/images";
|
||||
import { Folder, Home, User } from "lucide-react";
|
||||
import { Compass, Folder, Heart, Home, User } from "lucide-react";
|
||||
import { LanguageSettings } from "./LanguageSettings";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
@@ -41,6 +41,22 @@ export async function Navbar() {
|
||||
<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
|
||||
className="hidden! md:block!"
|
||||
size="md"
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
# 词典查询模块化架构
|
||||
# 词典查询架构
|
||||
|
||||
本目录包含词典查询系统的**多阶段 LLM 调用**实现,将查询过程拆分为 4 个独立的 LLM 调用,每个阶段之间有代码层面的数据验证,只要有一环失败,直接返回错误。
|
||||
2 次 LLM 调用的词典查询系统。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
dictionary/
|
||||
├── index.ts # 主导出文件
|
||||
├── orchestrator.ts # 主编排器,串联所有阶段
|
||||
├── types.ts # 类型定义
|
||||
├── stage1-inputAnalysis.ts # 阶段1:输入解析与语言识别
|
||||
├── stage2-semanticMapping.ts # 阶段2:跨语言语义映射决策
|
||||
├── stage3-standardForm.ts # 阶段3:standardForm 生成与规范化
|
||||
└── stage4-entriesGeneration.ts # 阶段4:释义与词条生成
|
||||
├── orchestrator.ts # 编排器
|
||||
├── stage1-preprocess.ts # 阶段1:预处理(输入分析+语义映射+标准形式)
|
||||
├── stage4-entriesGeneration.ts # 阶段2:词条生成
|
||||
└── types.ts # 类型定义
|
||||
```
|
||||
|
||||
## 工作流程
|
||||
@@ -20,187 +17,22 @@ dictionary/
|
||||
```
|
||||
用户输入
|
||||
↓
|
||||
[阶段1] 输入分析 → 代码验证 → 失败则返回错误
|
||||
[阶段1] 预处理(1次LLM)→ isValid, standardForm, inputType
|
||||
↓
|
||||
[阶段2] 语义映射 → 代码验证 → 失败则保守处理(不映射)
|
||||
↓
|
||||
[阶段3] 标准形式 → 代码验证 → 失败则返回错误
|
||||
↓
|
||||
[阶段4] 词条生成 → 代码验证 → 失败则返回错误
|
||||
[阶段2] 词条生成(1次LLM)→ entries
|
||||
↓
|
||||
最终结果
|
||||
```
|
||||
|
||||
## 各阶段详细说明
|
||||
## 性能
|
||||
|
||||
### 阶段 1:输入分析
|
||||
- 原 4 次 LLM 调用 → 现 2 次
|
||||
- 预期耗时:8-13s(原 33s)
|
||||
|
||||
**文件**: `stage1-inputAnalysis.ts`
|
||||
|
||||
**目的**:
|
||||
- 判断输入是否有效
|
||||
- 判断是「单词」还是「短语」
|
||||
- 识别输入语言
|
||||
|
||||
**返回**: `InputAnalysisResult`
|
||||
|
||||
**代码验证**:
|
||||
- `isValid` 必须是 boolean
|
||||
- 输入为空或无效时立即返回错误
|
||||
|
||||
### 阶段 2:语义映射
|
||||
|
||||
**文件**: `stage2-semanticMapping.ts`
|
||||
|
||||
**目的**:
|
||||
- 决定是否启用"语义级查询"
|
||||
- **严格条件**:只有输入符合"明确、基础、可词典化的语义概念"且语言不一致时才映射
|
||||
- 不符合条件则**直接失败**(快速失败)
|
||||
|
||||
**返回**: `SemanticMappingResult`
|
||||
|
||||
**代码验证**:
|
||||
- `shouldMap` 必须是 boolean
|
||||
- 如果 `shouldMap=true`,必须有 `mappedQuery`
|
||||
- 如果不应该映射,**抛出异常**(不符合条件直接失败)
|
||||
- **失败则直接返回错误响应**,不继续后续阶段
|
||||
|
||||
**映射条件**(必须同时满足):
|
||||
a) 输入语言 ≠ 查询语言
|
||||
b) 输入是明确、基础、可词典化的语义概念(如常见动词、名词、形容词)
|
||||
|
||||
**不符合条件的例子**:
|
||||
- 复杂句子:"我喜欢吃苹果"
|
||||
- 专业术语
|
||||
- 无法确定语义的词汇
|
||||
|
||||
### 阶段 3:标准形式生成
|
||||
|
||||
**文件**: `stage3-standardForm.ts`
|
||||
|
||||
**目的**:
|
||||
- 确定最终词条的"标准形"(整个系统的锚点)
|
||||
- 修正拼写错误
|
||||
- 还原为词典形式(动词原形、辞书形等)
|
||||
- **如果进行了语义映射**:基于映射结果生成标准形式,同时参考原始输入的语义上下文
|
||||
|
||||
**参数**:
|
||||
- `inputText`: 用于生成标准形式的文本(可能是映射后的结果)
|
||||
- `queryLang`: 查询语言
|
||||
- `originalInput`: (可选)原始用户输入,用于语义参考
|
||||
|
||||
**返回**: `StandardFormResult`
|
||||
|
||||
**代码验证**:
|
||||
- `standardForm` 不能为空
|
||||
- `confidence` 必须是 "high" | "medium" | "low"
|
||||
- 失败时使用原输入作为标准形式
|
||||
|
||||
**特殊逻辑**:
|
||||
- 当进行了语义映射时(即提供了 `originalInput`),阶段 3 会:
|
||||
1. 基于 `inputText`(映射结果)生成标准形式
|
||||
2. 参考 `originalInput` 的语义上下文,确保标准形式符合用户的真实查询意图
|
||||
3. 例如:原始输入 "吃"(中文)→ 映射为 "to eat"(英语)→ 标准形式 "eat"
|
||||
|
||||
### 阶段 4:词条生成
|
||||
|
||||
**文件**: `stage4-entriesGeneration.ts`
|
||||
|
||||
**目的**:
|
||||
- 生成真正的词典内容
|
||||
- 根据类型生成单词或短语条目
|
||||
|
||||
**返回**: `EntriesGenerationResult`
|
||||
|
||||
**代码验证**:
|
||||
- `entries` 必须是非空数组
|
||||
- 每个条目必须有 `definition` 和 `example`
|
||||
- 单词条目必须有 `partOfSpeech`
|
||||
- **失败则抛出异常**(核心阶段)
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 基本使用
|
||||
## 使用
|
||||
|
||||
```typescript
|
||||
import { lookUp } from "@/lib/server/bigmodel/dictionaryActions";
|
||||
import { executeDictionaryLookup } from "@/lib/bigmodel/dictionary/orchestrator";
|
||||
|
||||
const result = await lookUp({
|
||||
text: "hello",
|
||||
queryLang: "English",
|
||||
definitionLang: "中文"
|
||||
});
|
||||
const result = await executeDictionaryLookup("hello", "English", "中文");
|
||||
```
|
||||
|
||||
### 高级使用(直接调用编排器)
|
||||
|
||||
```typescript
|
||||
import { executeDictionaryLookup } from "@/lib/server/bigmodel/dictionary";
|
||||
|
||||
const result = await executeDictionaryLookup(
|
||||
"hello",
|
||||
"English",
|
||||
"中文"
|
||||
);
|
||||
```
|
||||
|
||||
### 单独测试某个阶段
|
||||
|
||||
```typescript
|
||||
import { analyzeInput } from "@/lib/server/bigmodel/dictionary";
|
||||
|
||||
const analysis = await analyzeInput("hello");
|
||||
console.log(analysis);
|
||||
```
|
||||
|
||||
## 设计优势
|
||||
|
||||
### 1. 代码层面的数据验证
|
||||
每个阶段完成后都有严格的类型检查和数据验证,确保数据质量。
|
||||
|
||||
### 2. 快速失败
|
||||
只要有一个阶段失败,立即返回错误,不浪费后续的 LLM 调用。
|
||||
|
||||
### 3. 可观测性
|
||||
每个阶段都有 console.log 输出,方便调试和追踪问题。
|
||||
|
||||
### 4. 模块化
|
||||
每个阶段独立文件,可以单独测试、修改或替换。
|
||||
|
||||
### 5. 容错性
|
||||
非核心阶段(阶段2、3)失败时有降级策略,不会导致整个查询失败。
|
||||
|
||||
## 日志示例
|
||||
|
||||
```
|
||||
[阶段1] 开始输入分析...
|
||||
[阶段1] 输入分析完成: { isValid: true, inputType: 'word', inputLanguage: 'English' }
|
||||
[阶段2] 开始语义映射...
|
||||
[阶段2] 语义映射完成: { shouldMap: false }
|
||||
[阶段3] 开始生成标准形式...
|
||||
[阶段3] 标准形式生成完成: { standardForm: 'hello', confidence: 'high' }
|
||||
[阶段4] 开始生成词条...
|
||||
[阶段4] 词条生成完成: { entries: [...] }
|
||||
[完成] 词典查询成功
|
||||
```
|
||||
|
||||
## 扩展建议
|
||||
|
||||
### 添加缓存
|
||||
对阶段1、3的结果进行缓存,避免重复调用 LLM。
|
||||
|
||||
### 添加指标
|
||||
记录每个阶段的耗时和成功率,用于性能优化。
|
||||
|
||||
### 并行化
|
||||
某些阶段可以并行执行(如果有依赖关系允许的话)。
|
||||
|
||||
### A/B 测试
|
||||
为某个阶段创建不同版本的实现,进行效果对比。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 每个阶段都是独立的 LLM 调用,会增加总耗时
|
||||
- 需要控制 token 使用量,避免成本过高
|
||||
- 错误处理要完善,避免某个阶段卡住整个流程
|
||||
- 日志记录要清晰,方便问题排查
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ServiceOutputLookUp } from "@/modules/dictionary/dictionary-service-dto";
|
||||
import { analyzeInput } from "./stage1-inputAnalysis";
|
||||
import { determineSemanticMapping } from "./stage2-semanticMapping";
|
||||
import { generateStandardForm } from "./stage3-standardForm";
|
||||
import { preprocessInput } from "./stage1-preprocess";
|
||||
import { generateEntries } from "./stage4-entriesGeneration";
|
||||
import { LookUpError } from "@/lib/errors";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("dictionary-orchestrator");
|
||||
|
||||
export async function executeDictionaryLookup(
|
||||
text: string,
|
||||
@@ -11,80 +12,36 @@ export async function executeDictionaryLookup(
|
||||
definitionLang: string
|
||||
): Promise<ServiceOutputLookUp> {
|
||||
try {
|
||||
// ========== 阶段 1:输入分析 ==========
|
||||
console.log("[阶段1] 开始输入分析...");
|
||||
const analysis = await analyzeInput(text);
|
||||
log.debug("[Stage 1] Preprocessing input");
|
||||
const preprocessed = await preprocessInput(text, queryLang);
|
||||
|
||||
// 代码层面验证:输入是否有效
|
||||
if (!analysis.isValid) {
|
||||
console.log("[阶段1] 输入无效:", analysis.reason);
|
||||
throw analysis.reason || "无效输入";
|
||||
if (!preprocessed.isValid) {
|
||||
log.debug("[Stage 1] Invalid input", { reason: preprocessed.reason });
|
||||
throw new LookUpError(preprocessed.reason || "无效输入");
|
||||
}
|
||||
|
||||
if (analysis.isEmpty) {
|
||||
console.log("[阶段1] 输入为空");
|
||||
throw "输入为空";
|
||||
}
|
||||
log.debug("[Stage 1] Preprocess complete", { preprocessed });
|
||||
|
||||
console.log("[阶段1] 输入分析完成:", analysis);
|
||||
|
||||
// ========== 阶段 2:语义映射 ==========
|
||||
console.log("[阶段2] 开始语义映射...");
|
||||
const semanticMapping = await determineSemanticMapping(
|
||||
text,
|
||||
queryLang,
|
||||
analysis.inputLanguage || text
|
||||
);
|
||||
|
||||
console.log("[阶段2] 语义映射完成:", semanticMapping);
|
||||
|
||||
// ========== 阶段 3:生成标准形式 ==========
|
||||
console.log("[阶段3] 开始生成标准形式...");
|
||||
|
||||
// 如果进行了语义映射,标准形式要基于映射后的结果
|
||||
// 同时传递原始输入作为语义参考
|
||||
const shouldUseMapping = semanticMapping.shouldMap && semanticMapping.mappedQuery;
|
||||
const inputForStandardForm = shouldUseMapping ? semanticMapping.mappedQuery! : text;
|
||||
|
||||
const standardFormResult = await generateStandardForm(
|
||||
inputForStandardForm,
|
||||
queryLang,
|
||||
shouldUseMapping ? text : undefined // 如果进行了映射,传递原始输入作为语义参考
|
||||
);
|
||||
|
||||
// 代码层面验证:标准形式不能为空
|
||||
if (!standardFormResult.standardForm) {
|
||||
console.error("[阶段3] 标准形式为空");
|
||||
throw "无法生成标准形式";
|
||||
}
|
||||
|
||||
console.log("[阶段3] 标准形式生成完成:", standardFormResult);
|
||||
|
||||
// ========== 阶段 4:生成词条 ==========
|
||||
console.log("[阶段4] 开始生成词条...");
|
||||
log.debug("[Stage 2] Generating entries");
|
||||
const entriesResult = await generateEntries(
|
||||
standardFormResult.standardForm,
|
||||
preprocessed.standardForm,
|
||||
queryLang,
|
||||
definitionLang,
|
||||
analysis.inputType === "unknown"
|
||||
? (standardFormResult.standardForm.includes(" ") ? "phrase" : "word")
|
||||
: analysis.inputType
|
||||
preprocessed.inputType
|
||||
);
|
||||
|
||||
console.log("[阶段4] 词条生成完成:", entriesResult);
|
||||
log.debug("[Stage 2] Entries complete", { entriesResult });
|
||||
|
||||
// ========== 组装最终结果 ==========
|
||||
const finalResult: ServiceOutputLookUp = {
|
||||
standardForm: standardFormResult.standardForm,
|
||||
standardForm: preprocessed.standardForm,
|
||||
entries: entriesResult.entries,
|
||||
};
|
||||
|
||||
console.log("[完成] 词典查询成功");
|
||||
log.info("Dictionary lookup completed successfully");
|
||||
return finalResult;
|
||||
|
||||
} catch (error) {
|
||||
console.error("[错误] 词典查询失败:", error);
|
||||
|
||||
log.error("Dictionary lookup failed", { error: error instanceof Error ? error.message : String(error) });
|
||||
const errorMessage = error instanceof Error ? error.message : "未知错误";
|
||||
throw new LookUpError(errorMessage);
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { getAnswer } from "../zhipu";
|
||||
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||
import { InputAnalysisResult } from "./types";
|
||||
|
||||
/**
|
||||
* 阶段 1:输入解析与语言识别
|
||||
*
|
||||
* 独立的 LLM 调用,分析输入文本
|
||||
*/
|
||||
|
||||
export async function analyzeInput(text: string): Promise<InputAnalysisResult> {
|
||||
const prompt = `
|
||||
你是一个输入分析器。分析用户输入并返回 JSON 结果。
|
||||
|
||||
用户输入位于 <text> 标签内:
|
||||
<text>${text}</text>
|
||||
|
||||
你的任务是:
|
||||
1. 判断输入是否为空或明显非法
|
||||
2. 判断输入是「单词」还是「短语」
|
||||
3. 识别输入所属语言
|
||||
|
||||
返回 JSON 格式:
|
||||
{
|
||||
"isValid": true/false,
|
||||
"isEmpty": true/false,
|
||||
"isNaturalLanguage": true/false,
|
||||
"inputLanguage": "检测到的语言名称(如 English、中文、日本語等)",
|
||||
"inputType": "word/phrase/unknown",
|
||||
"reason": "错误原因,成功时为空字符串\"\""
|
||||
}
|
||||
|
||||
若输入为空、非自然语言或无法识别语言,设置 isValid 为 false,并在 reason 中说明原因。
|
||||
若输入有效,设置 isValid 为 true,reason 为空字符串 ""。
|
||||
只返回 JSON,不要任何其他文字。
|
||||
`.trim();
|
||||
|
||||
try {
|
||||
const result = await getAnswer([
|
||||
{
|
||||
role: "system",
|
||||
content: "你是一个输入分析器,只返回 JSON 格式的分析结果。",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
]).then(parseAIGeneratedJSON<InputAnalysisResult>);
|
||||
|
||||
// 代码层面的数据验证
|
||||
if (typeof result.isValid !== "boolean") {
|
||||
throw new Error("阶段1:isValid 字段类型错误");
|
||||
}
|
||||
|
||||
// 确保 reason 字段存在
|
||||
if (typeof result.reason !== "string") {
|
||||
result.reason = "";
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("阶段1失败:", error);
|
||||
// 失败时抛出错误,包含 reason
|
||||
throw new Error("输入分析失败:无法识别输入类型或语言");
|
||||
}
|
||||
}
|
||||
87
src/lib/bigmodel/dictionary/stage1-preprocess.ts
Normal file
87
src/lib/bigmodel/dictionary/stage1-preprocess.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { getAnswer } from "../llm";
|
||||
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||
import { PreprocessResult } from "./types";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("dictionary-preprocess");
|
||||
|
||||
export async function preprocessInput(
|
||||
text: string,
|
||||
queryLang: string
|
||||
): Promise<PreprocessResult> {
|
||||
const prompt = `
|
||||
你是一个词典预处理系统。分析输入并生成标准形式。
|
||||
|
||||
用户输入:<input>${text}</input>
|
||||
查询语言:<queryLang>${queryLang}</queryLang>
|
||||
|
||||
任务:
|
||||
1. 判断输入是否有效(非空、是自然语言)
|
||||
2. 识别输入类型(单词/短语)
|
||||
3. 将输入转换为查询语言的对应词(语义映射)
|
||||
4. 生成标准形式(必须是查询语言)
|
||||
|
||||
重要规则:
|
||||
- standardForm 必须是查询语言的词汇
|
||||
- 例如:查询语言=维吾尔语,输入="japanese" → standardForm="ياپونىيە"
|
||||
- 例如:查询语言=中文,输入="japanese" → standardForm="日语"
|
||||
- 例如:查询语言=English,输入="日语" → standardForm="Japanese"
|
||||
- 如果输入本身就是查询语言,则保持不变
|
||||
- 只做词典形式还原,不纠正拼写
|
||||
|
||||
返回 JSON:
|
||||
{
|
||||
"isValid": boolean,
|
||||
"inputType": "word" | "phrase",
|
||||
"standardForm": "查询语言对应的标准形式",
|
||||
"confidence": "high" | "medium" | "low",
|
||||
"reason": "错误原因,成功时为空字符串"
|
||||
}
|
||||
|
||||
注意:
|
||||
- isValid=false 时,在 reason 中说明原因
|
||||
- 成功时 reason 为空字符串 ""
|
||||
- 只返回 JSON,不要其他文字
|
||||
`.trim();
|
||||
|
||||
try {
|
||||
const result = await getAnswer([
|
||||
{
|
||||
role: "system",
|
||||
content: "你是词典预处理系统,只返回 JSON。",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
]).then(parseAIGeneratedJSON<PreprocessResult>);
|
||||
|
||||
if (typeof result.isValid !== "boolean") {
|
||||
throw new Error("预处理:isValid 字段类型错误");
|
||||
}
|
||||
|
||||
if (!result.standardForm || result.standardForm.trim().length === 0) {
|
||||
throw new Error(result.reason || "预处理:standardForm 为空");
|
||||
}
|
||||
|
||||
if (!["word", "phrase"].includes(result.inputType)) {
|
||||
result.inputType = result.standardForm.includes(" ") ? "phrase" : "word";
|
||||
}
|
||||
|
||||
let confidence: "high" | "medium" | "low" = "low";
|
||||
const cv = result.confidence?.toLowerCase();
|
||||
if (cv === "高" || cv === "high") confidence = "high";
|
||||
else if (cv === "中" || cv === "medium") confidence = "medium";
|
||||
|
||||
return {
|
||||
isValid: result.isValid,
|
||||
inputType: result.inputType as "word" | "phrase",
|
||||
standardForm: result.standardForm,
|
||||
confidence,
|
||||
reason: typeof result.reason === "string" ? result.reason : "",
|
||||
};
|
||||
} catch (error) {
|
||||
log.error("Preprocess failed", { error: error instanceof Error ? error.message : String(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { getAnswer } from "../zhipu";
|
||||
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||
import { SemanticMappingResult } from "./types";
|
||||
|
||||
/**
|
||||
* 阶段 2:跨语言语义映射决策
|
||||
*
|
||||
* 独立的 LLM 调用,决定是否需要语义映射
|
||||
* 如果输入不符合"明确、基础、可词典化的语义概念"且语言不一致,直接返回失败
|
||||
*/
|
||||
|
||||
export async function determineSemanticMapping(
|
||||
text: string,
|
||||
queryLang: string,
|
||||
inputLanguage: string
|
||||
): Promise<SemanticMappingResult> {
|
||||
// 如果输入语言就是查询语言,不需要映射
|
||||
if (inputLanguage.toLowerCase() === queryLang.toLowerCase()) {
|
||||
return {
|
||||
shouldMap: false,
|
||||
reason: "输入语言与查询语言一致",
|
||||
};
|
||||
}
|
||||
|
||||
const prompt = `
|
||||
你是一个语义映射决策器。判断是否需要对输入进行跨语言语义映射。
|
||||
|
||||
查询语言:${queryLang}
|
||||
输入语言:${inputLanguage}
|
||||
用户输入:${text}
|
||||
|
||||
判断规则:
|
||||
1. 若输入表达一个**明确、基础、可词典化的语义概念**(如常见动词、名词、形容词),则应该映射
|
||||
2. 若输入不符合上述条件(如复杂句子、专业术语、无法确定语义的词汇),则不应该映射
|
||||
|
||||
映射条件必须同时满足:
|
||||
a) 输入语言 ≠ 查询语言
|
||||
b) 输入是明确、基础、可词典化的语义概念
|
||||
|
||||
例如:
|
||||
- 查询语言=English,输入="吃"(中文)→ 应该映射 → coreSemantic="to eat"
|
||||
- 查询语言=Italiano,输入="run"(English)→ 应该映射 → coreSemantic="correre"
|
||||
- 查询语言=中文,输入="hello"(English)→ 应该映射 → coreSemantic="你好"
|
||||
- 查询语言=English,输入="我喜欢吃苹果"(中文,复杂句子)→ 不应该映射 → canMap=false
|
||||
|
||||
返回 JSON 格式:
|
||||
{
|
||||
"shouldMap": true/false,
|
||||
"canMap": true/false,
|
||||
"coreSemantic": "提取的核心语义(用${queryLang}表达)",
|
||||
"mappedQuery": "映射到${queryLang}的标准表达",
|
||||
"reason": "错误原因,成功时为空字符串\"\""
|
||||
}
|
||||
|
||||
- canMap=true 表示输入符合"明确、基础、可词典化的语义概念"
|
||||
- shouldMap=true 表示需要进行映射
|
||||
- 只有 canMap=true 且语言不一致时,shouldMap 才为 true
|
||||
- 如果 shouldMap=false,在 reason 中说明原因
|
||||
- 如果 shouldMap=true,reason 为空字符串 ""
|
||||
|
||||
只返回 JSON,不要任何其他文字。
|
||||
`.trim();
|
||||
|
||||
try {
|
||||
const result = await getAnswer([
|
||||
{
|
||||
role: "system",
|
||||
content: `你是一个语义映射决策器,只返回 JSON 格式的结果。`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
]).then(parseAIGeneratedJSON<any>);
|
||||
|
||||
// 代码层面的数据验证
|
||||
if (typeof result.shouldMap !== "boolean") {
|
||||
throw new Error("阶段2:shouldMap 字段类型错误");
|
||||
}
|
||||
|
||||
// 确保 reason 字段存在
|
||||
if (typeof result.reason !== "string") {
|
||||
result.reason = "";
|
||||
}
|
||||
|
||||
// 如果不应该映射,返回错误
|
||||
if (!result.shouldMap) {
|
||||
throw new Error(result.reason || "输入不符合可词典化的语义概念,无法进行跨语言查询");
|
||||
}
|
||||
|
||||
if (!result.mappedQuery || result.mappedQuery.trim().length === 0) {
|
||||
throw new Error("语义映射失败:映射结果为空");
|
||||
}
|
||||
|
||||
return {
|
||||
shouldMap: result.shouldMap,
|
||||
coreSemantic: result.coreSemantic,
|
||||
mappedQuery: result.mappedQuery,
|
||||
reason: result.reason,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("阶段2失败:", error);
|
||||
// 失败时直接抛出错误,让编排器返回错误响应
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { getAnswer } from "../zhipu";
|
||||
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||
import { StandardFormResult } from "./types";
|
||||
|
||||
/**
|
||||
* 阶段 3:standardForm 生成与规范化
|
||||
*
|
||||
* 独立的 LLM 调用,生成标准形式
|
||||
*/
|
||||
|
||||
export async function generateStandardForm(
|
||||
inputText: string,
|
||||
queryLang: string,
|
||||
originalInput?: string
|
||||
): Promise<StandardFormResult> {
|
||||
const prompt = `
|
||||
你是一个词典标准形式生成器。为输入生成该语言下的标准形式。
|
||||
|
||||
查询语言:${queryLang}
|
||||
当前输入:${inputText}
|
||||
${originalInput ? `原始输入(语义参考):${originalInput}` : ''}
|
||||
|
||||
${originalInput ? `
|
||||
**重要说明**:
|
||||
- 当前输入是经过语义映射后的结果(从原始语言映射到查询语言)
|
||||
- 原始输入提供了语义上下文,帮助你理解用户的真实查询意图
|
||||
- 你需要基于**当前输入**生成标准形式,但要参考**原始输入的语义**以确保准确性
|
||||
|
||||
例如:
|
||||
- 原始输入:"吃"(中文),当前输入:"to eat"(英语)→ 标准形式应为 "eat"
|
||||
- 原始输入:"走"(中文),当前输入:"to walk"(英语)→ 标准形式应为 "walk"
|
||||
` : ''}
|
||||
|
||||
规则:
|
||||
1. 尝试修正明显拼写错误
|
||||
2. 还原为该语言中**最常见、最自然、最标准**的形式:
|
||||
* 英语:动词原形、名词单数
|
||||
* 日语:辞书形
|
||||
* 意大利语:不定式或最常见规范形式
|
||||
* 维吾尔语:标准拉丁化或阿拉伯字母形式
|
||||
* 中文:标准简化字
|
||||
3. ${originalInput ? '参考原始输入的语义,确保标准形式符合用户的真实查询意图':'若无法确定或输入本身已规范,则保持不变'}
|
||||
|
||||
返回 JSON 格式:
|
||||
{
|
||||
"standardForm": "标准形式",
|
||||
"confidence": "high/medium/low",
|
||||
"reason": "错误原因,成功时为空字符串\"\""
|
||||
}
|
||||
|
||||
成功生成标准形式时,reason 为空字符串 ""。
|
||||
失败时,在 reason 中说明失败原因。
|
||||
只返回 JSON,不要任何其他文字。
|
||||
`.trim();
|
||||
|
||||
try {
|
||||
const result = await getAnswer([
|
||||
{
|
||||
role: "system",
|
||||
content: "你是一个词典标准形式生成器,只返回 JSON 格式的结果。",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
]).then(parseAIGeneratedJSON<any>);
|
||||
|
||||
// 代码层面的数据验证
|
||||
if (!result.standardForm || result.standardForm.trim().length === 0) {
|
||||
throw new Error(result.reason || "阶段3:standardForm 为空");
|
||||
}
|
||||
|
||||
// 处理 confidence 可能是中文或英文的情况
|
||||
let confidence: "high" | "medium" | "low" = "low";
|
||||
const confidenceValue = result.confidence?.toLowerCase();
|
||||
if (confidenceValue === "高" || confidenceValue === "high") {
|
||||
confidence = "high";
|
||||
} else if (confidenceValue === "中" || confidenceValue === "medium") {
|
||||
confidence = "medium";
|
||||
} else if (confidenceValue === "低" || confidenceValue === "low") {
|
||||
confidence = "low";
|
||||
}
|
||||
|
||||
// 确保 reason 字段存在
|
||||
const reason = typeof result.reason === "string" ? result.reason : "";
|
||||
|
||||
return {
|
||||
standardForm: result.standardForm,
|
||||
confidence,
|
||||
reason,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("阶段3失败:", error);
|
||||
// 失败时抛出错误
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import { getAnswer } from "../zhipu";
|
||||
import { getAnswer } from "../llm";
|
||||
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||
import { EntriesGenerationResult } from "./types";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
/**
|
||||
* 阶段 4:释义与词条生成
|
||||
*
|
||||
* 独立的 LLM 调用,生成词典条目
|
||||
*/
|
||||
const log = createLogger("dictionary-entries");
|
||||
|
||||
export async function generateEntries(
|
||||
standardForm: string,
|
||||
@@ -17,93 +14,42 @@ export async function generateEntries(
|
||||
const isWord = inputType === "word";
|
||||
|
||||
const prompt = `
|
||||
你是一个词典条目生成器。为标准形式生成词典条目。
|
||||
生成词典条目。词语:"${standardForm}"(${queryLang})。用${definitionLang}释义。
|
||||
|
||||
标准形式:${standardForm}
|
||||
查询语言:${queryLang}
|
||||
释义语言:${definitionLang}
|
||||
词条类型:${isWord ? "单词" : "短语"}
|
||||
返回 JSON:
|
||||
${isWord ? `{"entries":[{"ipa":"音标","partOfSpeech":"词性","definition":"释义","example":"例句"}]}` : `{"entries":[{"definition":"释义","example":"例句"}]}`}
|
||||
|
||||
${isWord ? `
|
||||
单词条目要求:
|
||||
- ipa:音标(如适用)
|
||||
- partOfSpeech:词性
|
||||
- definition:释义(使用 ${definitionLang})
|
||||
- example:例句(使用 ${queryLang})
|
||||
` : `
|
||||
短语条目要求:
|
||||
- definition:短语释义(使用 ${definitionLang})
|
||||
- example:例句(使用 ${queryLang})
|
||||
`}
|
||||
|
||||
生成 1-3 个条目,返回 JSON 格式:
|
||||
{
|
||||
"entries": [
|
||||
${isWord ? `
|
||||
{
|
||||
"ipa": "音标",
|
||||
"partOfSpeech": "词性",
|
||||
"definition": "释义",
|
||||
"example": "例句"
|
||||
}` : `
|
||||
{
|
||||
"definition": "释义",
|
||||
"example": "例句"
|
||||
}`}
|
||||
]
|
||||
}
|
||||
|
||||
只返回 JSON,不要任何其他文字。
|
||||
只返回 JSON。
|
||||
`.trim();
|
||||
|
||||
try {
|
||||
const result = await getAnswer([
|
||||
{
|
||||
role: "system",
|
||||
content: `你是一个词典条目生成器,只返回 JSON 格式的结果。`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
{ role: "system", content: "词典条目生成器,只返回 JSON。" },
|
||||
{ role: "user", content: prompt },
|
||||
]).then(parseAIGeneratedJSON<EntriesGenerationResult>);
|
||||
|
||||
// 代码层面的数据验证
|
||||
if (!result.entries || !Array.isArray(result.entries) || result.entries.length === 0) {
|
||||
throw new Error("阶段4:entries 为空或不是数组");
|
||||
if (!result.entries?.length) {
|
||||
throw new Error("词条生成失败:结果为空");
|
||||
}
|
||||
|
||||
// 处理每个条目,清理 IPA 格式
|
||||
for (const entry of result.entries) {
|
||||
// 清理 IPA:删除两端可能包含的方括号、斜杠等字符
|
||||
if (entry.ipa) {
|
||||
entry.ipa = entry.ipa.trim();
|
||||
// 删除开头的 [ / /
|
||||
entry.ipa = entry.ipa.replace(/^[\[\/]/, '');
|
||||
// 删除结尾的 ] / /
|
||||
entry.ipa = entry.ipa.replace(/[\]\/]$/, '');
|
||||
entry.ipa = entry.ipa.trim().replace(/^[\[\/]/, '').replace(/[\]\/]$/, '');
|
||||
}
|
||||
|
||||
if (!entry.definition || entry.definition.trim().length === 0) {
|
||||
throw new Error("阶段4:条目缺少 definition");
|
||||
if (!entry.definition?.trim()) {
|
||||
throw new Error("词条缺少释义");
|
||||
}
|
||||
|
||||
if (!entry.example || entry.example.trim().length === 0) {
|
||||
throw new Error("阶段4:条目缺少 example");
|
||||
if (!entry.example?.trim()) {
|
||||
throw new Error("词条缺少例句");
|
||||
}
|
||||
|
||||
if (isWord && !entry.partOfSpeech) {
|
||||
throw new Error("阶段4:单词条目缺少 partOfSpeech");
|
||||
}
|
||||
|
||||
if (isWord && !entry.ipa) {
|
||||
throw new Error("阶段4:单词条目缺少 ipa");
|
||||
throw new Error("单词条目缺少词性");
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("阶段4失败:", error);
|
||||
throw error; // 阶段4失败应该返回错误,因为这个阶段是核心
|
||||
log.error("Entries generation failed", { error: error instanceof Error ? error.message : String(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,21 @@
|
||||
/**
|
||||
* 词典查询的类型定义
|
||||
*/
|
||||
|
||||
export interface DictionaryContext {
|
||||
queryLang: string;
|
||||
definitionLang: string;
|
||||
}
|
||||
|
||||
// 阶段1:输入分析结果
|
||||
export interface InputAnalysisResult {
|
||||
export interface PreprocessResult {
|
||||
isValid: boolean;
|
||||
isEmpty: boolean;
|
||||
isNaturalLanguage: boolean;
|
||||
inputLanguage?: string;
|
||||
inputType: "word" | "phrase" | "unknown";
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// 阶段2:语义映射结果
|
||||
export interface SemanticMappingResult {
|
||||
shouldMap: boolean;
|
||||
coreSemantic?: string;
|
||||
mappedQuery?: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// 阶段3:标准形式结果
|
||||
export interface StandardFormResult {
|
||||
inputType: "word" | "phrase";
|
||||
standardForm: string;
|
||||
confidence: "high" | "medium" | "low";
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// 阶段4:词条生成结果
|
||||
export interface EntriesGenerationResult {
|
||||
entries: Array<{
|
||||
ipa?: string;
|
||||
definition: string;
|
||||
partOfSpeech?: string;
|
||||
example: string; // example 必需
|
||||
example: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
37
src/lib/bigmodel/llm.ts
Normal file
37
src/lib/bigmodel/llm.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
"use server";
|
||||
|
||||
import OpenAI from "openai";
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.ZHIPU_API_KEY,
|
||||
baseURL: "https://open.bigmodel.cn/api/paas/v4",
|
||||
});
|
||||
|
||||
type Messages = Array<
|
||||
| { role: "system"; content: string }
|
||||
| { role: "user"; content: string }
|
||||
| { role: "assistant"; content: string }
|
||||
>;
|
||||
|
||||
async function getAnswer(prompt: string): Promise<string>;
|
||||
async function getAnswer(prompt: Messages): Promise<string>;
|
||||
async function getAnswer(prompt: string | Messages): Promise<string> {
|
||||
const messages: Messages = typeof prompt === "string"
|
||||
? [{ role: "user", content: prompt }]
|
||||
: prompt;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: process.env.ZHIPU_MODEL_NAME || "glm-4",
|
||||
messages: messages as OpenAI.Chat.Completions.ChatCompletionMessageParam[],
|
||||
temperature: 0.2,
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error("AI API 返回空响应");
|
||||
}
|
||||
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
export { getAnswer };
|
||||
@@ -1,6 +1,9 @@
|
||||
import { getAnswer } from "../zhipu";
|
||||
import { getAnswer } from "../llm";
|
||||
import { parseAIGeneratedJSON } from "@/utils/json";
|
||||
import { LanguageDetectionResult, TranslationLLMResponse } from "./types";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("translator-orchestrator");
|
||||
|
||||
async function detectLanguage(text: string): Promise<LanguageDetectionResult> {
|
||||
const prompt = `
|
||||
@@ -40,7 +43,7 @@ async function detectLanguage(text: string): Promise<LanguageDetectionResult> {
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Language detection failed:", error);
|
||||
log.error("Language detection failed", { error });
|
||||
throw new Error("Failed to detect source language");
|
||||
}
|
||||
}
|
||||
@@ -82,7 +85,7 @@ async function performTranslation(
|
||||
|
||||
return result.trim();
|
||||
} catch (error) {
|
||||
console.error("Translation failed:", error);
|
||||
log.error("Translation failed", { error });
|
||||
throw new Error("Translation failed");
|
||||
}
|
||||
}
|
||||
@@ -121,7 +124,7 @@ async function generateIPA(
|
||||
|
||||
return result.trim();
|
||||
} catch (error) {
|
||||
console.error("IPA generation failed:", error);
|
||||
log.error("IPA generation failed", { error });
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -132,24 +135,19 @@ export async function executeTranslation(
|
||||
needIpa: boolean
|
||||
): Promise<TranslationLLMResponse> {
|
||||
try {
|
||||
console.log("[翻译] 开始翻译流程...");
|
||||
console.log("[翻译] 源文本:", sourceText);
|
||||
console.log("[翻译] 目标语言:", targetLanguage);
|
||||
console.log("[翻译] 需要 IPA:", needIpa);
|
||||
log.debug("Starting translation", { sourceText, targetLanguage, needIpa });
|
||||
|
||||
// Stage 1: Detect source language
|
||||
console.log("[阶段1] 检测源语言...");
|
||||
log.debug("[Stage 1] Detecting source language");
|
||||
const detectionResult = await detectLanguage(sourceText);
|
||||
console.log("[阶段1] 检测结果:", detectionResult);
|
||||
log.debug("[Stage 1] Detection result", { detectionResult });
|
||||
|
||||
// Stage 2: Perform translation
|
||||
console.log("[阶段2] 执行翻译...");
|
||||
log.debug("[Stage 2] Performing translation");
|
||||
const translatedText = await performTranslation(
|
||||
sourceText,
|
||||
detectionResult.sourceLanguage,
|
||||
targetLanguage
|
||||
);
|
||||
console.log("[阶段2] 翻译完成:", translatedText);
|
||||
log.debug("[Stage 2] Translation complete", { translatedText });
|
||||
|
||||
// Validate translation result
|
||||
if (!translatedText) {
|
||||
@@ -161,12 +159,12 @@ export async function executeTranslation(
|
||||
let targetIpa: string | undefined;
|
||||
|
||||
if (needIpa) {
|
||||
console.log("[阶段3] 生成 IPA...");
|
||||
log.debug("[Stage 3] Generating IPA");
|
||||
sourceIpa = await generateIPA(sourceText, detectionResult.sourceLanguage);
|
||||
console.log("[阶段3] 源文本 IPA:", sourceIpa);
|
||||
log.debug("[Stage 3] Source IPA", { sourceIpa });
|
||||
|
||||
targetIpa = await generateIPA(translatedText, targetLanguage);
|
||||
console.log("[阶段3] 目标文本 IPA:", targetIpa);
|
||||
log.debug("[Stage 3] Target IPA", { targetIpa });
|
||||
}
|
||||
|
||||
// Assemble final result
|
||||
@@ -179,10 +177,10 @@ export async function executeTranslation(
|
||||
targetIpa,
|
||||
};
|
||||
|
||||
console.log("[完成] 翻译流程成功");
|
||||
log.info("Translation completed successfully");
|
||||
return finalResult;
|
||||
} catch (error) {
|
||||
console.error("[错误] 翻译失败:", error);
|
||||
log.error("Translation failed", { error });
|
||||
const errorMessage = error instanceof Error ? error.message : "未知错误";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("tts");
|
||||
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
/**
|
||||
@@ -147,7 +151,7 @@ class QwenTTSService {
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
console.error('语音合成请求失败:', error);
|
||||
log.error("TTS request failed", { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -157,11 +161,7 @@ export type TTS_SUPPORTED_LANGUAGES = 'Auto' | 'Chinese' | 'English' | 'German'
|
||||
export async function getTTSUrl(text: string, lang: TTS_SUPPORTED_LANGUAGES) {
|
||||
try {
|
||||
if (!process.env.DASHSCORE_API_KEY) {
|
||||
console.warn(
|
||||
`⚠️ 环境变量 DASHSCORE_API_KEY 未设置\n` +
|
||||
` 请在 .env 文件中设置或直接传入API Key\n` +
|
||||
` 获取API Key: https://help.aliyun.com/zh/model-studio/get-api-key`
|
||||
);
|
||||
log.warn("DASHSCORE_API_KEY not set");
|
||||
throw "API Key设置错误";
|
||||
}
|
||||
const ttsService = new QwenTTSService(
|
||||
@@ -176,7 +176,7 @@ export async function getTTSUrl(text: string, lang: TTS_SUPPORTED_LANGUAGES) {
|
||||
);
|
||||
return result.output.audio.url;
|
||||
} catch (error) {
|
||||
console.error('TTS合成失败:', error instanceof Error ? error.message : error);
|
||||
log.error("TTS synthesis failed", { error: error instanceof Error ? error.message : error });
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"use server";
|
||||
|
||||
type Messages = { role: string; content: string; }[];
|
||||
|
||||
async function callZhipuAPI(
|
||||
messages: Messages,
|
||||
model = process.env.ZHIPU_MODEL_NAME,
|
||||
) {
|
||||
const url = "https://open.bigmodel.cn/api/paas/v4/chat/completions";
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer " + process.env.ZHIPU_API_KEY,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
messages: messages,
|
||||
temperature: 0.2,
|
||||
thinking: {
|
||||
type: "disabled",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API 调用失败: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function getAnswer(prompt: string): Promise<string>;
|
||||
async function getAnswer(prompt: Messages): Promise<string>;
|
||||
async function getAnswer(prompt: string | Messages): Promise<string> {
|
||||
const messages = typeof prompt === "string"
|
||||
? [{ role: "user", content: prompt }]
|
||||
: prompt;
|
||||
|
||||
const response = await callZhipuAPI(messages);
|
||||
return response.choices[0].message.content.trim() as string;
|
||||
}
|
||||
|
||||
export { getAnswer };
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
interface LocalStorageOperator<T> {
|
||||
get: () => T;
|
||||
get: () => T | null;
|
||||
set: (value: T) => void;
|
||||
}
|
||||
|
||||
@@ -9,22 +11,29 @@ export function getLocalStorageOperator<T extends z.ZodType>(
|
||||
key: string,
|
||||
schema: T
|
||||
): LocalStorageOperator<z.infer<T>> {
|
||||
const get = (): z.infer<T> => {
|
||||
const get = (): z.infer<T> | null => {
|
||||
if (typeof window === "undefined") {
|
||||
return [] as unknown as z.infer<T>;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
if (item === null) {
|
||||
return [] as unknown as z.infer<T>;
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(item);
|
||||
return schema.parse(parsed);
|
||||
const result = schema.safeParse(parsed);
|
||||
|
||||
if (!result.success) {
|
||||
console.warn(`[localStorage] Schema validation failed for key "${key}":`, result.error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error(`Error reading from localStorage key "${key}":`, error);
|
||||
return [] as unknown as z.infer<T>;
|
||||
console.error(`[localStorage] Error reading key "${key}":`, error instanceof Error ? error.message : String(error));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -36,7 +45,7 @@ export function getLocalStorageOperator<T extends z.ZodType>(
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.error(`Error writing to localStorage key "${key}":`, error);
|
||||
console.error(`[localStorage] Error writing key "${key}":`, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
104
src/lib/email.ts
Normal file
104
src/lib/email.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("email");
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT) || 587,
|
||||
secure: process.env.SMTP_SECURE === "true",
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
});
|
||||
|
||||
interface SendEmailOptions {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export async function sendEmail({ to, subject, html, text }: SendEmailOptions) {
|
||||
try {
|
||||
const info = await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM || process.env.SMTP_USER,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
log.info("Email sent", { to, subject, messageId: info.messageId });
|
||||
return { success: true, messageId: info.messageId };
|
||||
} catch (error) {
|
||||
log.error("Failed to send email", { to, subject, error });
|
||||
return { success: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
export function generateVerificationEmailHtml(url: string, userName: string) {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.button { display: inline-block; padding: 12px 24px; background-color: #3b82f6; color: white; text-decoration: none; border-radius: 6px; }
|
||||
.footer { margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>验证您的邮箱地址</h1>
|
||||
<p>您好,${userName}!</p>
|
||||
<p>感谢您注册。请点击下方按钮验证您的邮箱地址:</p>
|
||||
<p>
|
||||
<a href="${url}" class="button">验证邮箱</a>
|
||||
</p>
|
||||
<p>或者复制以下链接到浏览器:</p>
|
||||
<p style="word-break: break-all; color: #666;">${url}</p>
|
||||
<p>此链接将在 24 小时后过期。</p>
|
||||
<div class="footer">
|
||||
<p>如果您没有注册此账户,请忽略此邮件。</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
export function generateResetPasswordEmailHtml(url: string, userName: string) {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.button { display: inline-block; padding: 12px 24px; background-color: #3b82f6; color: white; text-decoration: none; border-radius: 6px; }
|
||||
.footer { margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>重置您的密码</h1>
|
||||
<p>您好,${userName}!</p>
|
||||
<p>我们收到了重置您账户密码的请求。请点击下方按钮设置新密码:</p>
|
||||
<p>
|
||||
<a href="${url}" class="button">重置密码</a>
|
||||
</p>
|
||||
<p>或者复制以下链接到浏览器:</p>
|
||||
<p style="word-break: break-all; color: #666;">${url}</p>
|
||||
<p>此链接将在 1 小时后过期。</p>
|
||||
<div class="footer">
|
||||
<p>如果您没有请求重置密码,请忽略此邮件,您的密码不会被更改。</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
16
src/lib/logger/index.ts
Normal file
16
src/lib/logger/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { logger } from "./logger";
|
||||
|
||||
export { logger };
|
||||
|
||||
export function createLogger(context: string) {
|
||||
return {
|
||||
debug: (message: string, meta?: object) =>
|
||||
logger.debug(`[${context}] ${message}`, meta),
|
||||
info: (message: string, meta?: object) =>
|
||||
logger.info(`[${context}] ${message}`, meta),
|
||||
warn: (message: string, meta?: object) =>
|
||||
logger.warn(`[${context}] ${message}`, meta),
|
||||
error: (message: string, meta?: object) =>
|
||||
logger.error(`[${context}] ${message}`, meta),
|
||||
};
|
||||
}
|
||||
9
src/lib/logger/logger.ts
Normal file
9
src/lib/logger/logger.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import winston from "winston";
|
||||
import { devTransport, prodTransport } from "./transports";
|
||||
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: isDev ? "debug" : "info",
|
||||
transports: [isDev ? devTransport : prodTransport],
|
||||
});
|
||||
20
src/lib/logger/transports.ts
Normal file
20
src/lib/logger/transports.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { transports, format } from "winston";
|
||||
|
||||
const { combine, timestamp, printf, colorize, json } = format;
|
||||
|
||||
const customFormat = printf(({ level, message, timestamp, ...metadata }) => {
|
||||
const metaStr = Object.keys(metadata).length ? JSON.stringify(metadata) : "";
|
||||
return `${timestamp} [${level}]: ${message} ${metaStr}`;
|
||||
});
|
||||
|
||||
export const devTransport = new transports.Console({
|
||||
format: combine(
|
||||
colorize(),
|
||||
timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
|
||||
customFormat
|
||||
),
|
||||
});
|
||||
|
||||
export const prodTransport = new transports.Console({
|
||||
format: combine(timestamp(), json()),
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import {
|
||||
ActionInputGetUserProfileByUsername,
|
||||
ActionInputSignIn,
|
||||
@@ -23,6 +24,8 @@ import {
|
||||
// Re-export types for use in components
|
||||
export type { ActionOutputAuth, ActionOutputUserProfile } from "./auth-action-dto";
|
||||
|
||||
const log = createLogger("auth-action");
|
||||
|
||||
/**
|
||||
* Sign up action
|
||||
* Creates a new user account
|
||||
@@ -68,7 +71,7 @@ export async function actionSignUp(prevState: ActionOutputAuth | undefined, form
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
console.error("Sign up error:", e);
|
||||
log.error("Sign up failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: "Registration failed. Please try again later.",
|
||||
@@ -121,7 +124,7 @@ export async function actionSignIn(_prevState: ActionOutputAuth | undefined, for
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
console.error("Sign in error:", e);
|
||||
log.error("Sign in failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: "Sign in failed. Please check your credentials.",
|
||||
@@ -144,7 +147,7 @@ export async function signOutAction() {
|
||||
if (e instanceof Error && e.message.includes('NEXT_REDIRECT')) {
|
||||
throw e;
|
||||
}
|
||||
console.error("Sign out error:", e);
|
||||
log.error("Sign out failed", { error: e });
|
||||
redirect("/login");
|
||||
}
|
||||
}
|
||||
@@ -170,7 +173,7 @@ export async function actionGetUserProfileByUsername(dto: ActionInputGetUserProf
|
||||
data: userProfile,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Get user profile error:", e);
|
||||
log.error("Get user profile failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to retrieve user profile",
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { ActionInputLookUpDictionary, ActionOutputLookUpDictionary, validateActionInputLookUpDictionary } from "./dictionary-action-dto";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { serviceLookUp } from "./dictionary-service";
|
||||
|
||||
const log = createLogger("dictionary-action");
|
||||
|
||||
export const actionLookUpDictionary = async (dto: ActionInputLookUpDictionary): Promise<ActionOutputLookUpDictionary> => {
|
||||
try {
|
||||
return {
|
||||
@@ -18,7 +21,7 @@ export const actionLookUpDictionary = async (dto: ActionInputLookUpDictionary):
|
||||
message: e.message
|
||||
};
|
||||
}
|
||||
console.log(e);
|
||||
log.error("Dictionary lookup failed", { error: e instanceof Error ? e.message : String(e) });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { executeDictionaryLookup } from "@/lib/bigmodel/dictionary/orchestrator";
|
||||
import { repoCreateLookUp, repoCreateLookUpWithItemAndEntries, repoSelectLastLookUpResult } from "./dictionary-repository";
|
||||
import { ServiceInputLookUp } from "./dictionary-service-dto";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("dictionary-service");
|
||||
|
||||
export const serviceLookUp = async (dto: ServiceInputLookUp) => {
|
||||
const {
|
||||
@@ -39,7 +42,7 @@ export const serviceLookUp = async (dto: ServiceInputLookUp) => {
|
||||
},
|
||||
response.entries
|
||||
).catch(error => {
|
||||
console.error('Failed to save dictionary data:', error);
|
||||
log.error("Failed to save dictionary data", { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
|
||||
return response;
|
||||
@@ -51,7 +54,7 @@ export const serviceLookUp = async (dto: ServiceInputLookUp) => {
|
||||
definitionLang: definitionLang,
|
||||
dictionaryItemId: lastLookUpResult.id
|
||||
}).catch(error => {
|
||||
console.error('Failed to save dictionary data:', error);
|
||||
log.error("Failed to save dictionary data", { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
return {
|
||||
standardForm: lastLookUpResult.standardForm,
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import { ActionInputCreatePair, ActionInputUpdatePairById, ActionOutputGetFoldersWithTotalPairsByUserId, validateActionInputCreatePair, validateActionInputUpdatePairById } from "./folder-action-dto";
|
||||
import { repoCreateFolder, repoCreatePair, repoDeleteFolderById, repoDeletePairById, repoGetFolderIdByPairId, repoGetFoldersByUserId, repoGetFoldersWithTotalPairsByUserId, repoGetPairsByFolderId, repoGetUserIdByFolderId, repoRenameFolderById, repoUpdatePairById } from "./folder-repository";
|
||||
import { validate } from "@/utils/validate";
|
||||
import z from "zod";
|
||||
import { LENGTH_MAX_FOLDER_NAME, LENGTH_MIN_FOLDER_NAME } from "@/shared/constant";
|
||||
|
||||
/**
|
||||
* Helper function to check if the current user is the owner of a folder
|
||||
*/
|
||||
async function checkFolderOwnership(folderId: number): Promise<boolean> {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) return false;
|
||||
|
||||
const folderOwnerId = await repoGetUserIdByFolderId(folderId);
|
||||
return folderOwnerId === session.user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check if the current user is the owner of a pair's folder
|
||||
*/
|
||||
async function checkPairOwnership(pairId: number): Promise<boolean> {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) return false;
|
||||
|
||||
const folderId = await repoGetFolderIdByPairId(pairId);
|
||||
if (!folderId) return false;
|
||||
|
||||
const folderOwnerId = await repoGetUserIdByFolderId(folderId);
|
||||
return folderOwnerId === session.user.id;
|
||||
}
|
||||
|
||||
export async function actionGetPairsByFolderId(folderId: number) {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: await repoGetPairsByFolderId(folderId)
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionUpdatePairById(id: number, dto: ActionInputUpdatePairById) {
|
||||
try {
|
||||
// Check ownership
|
||||
const isOwner = await checkPairOwnership(id);
|
||||
if (!isOwner) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You do not have permission to update this item.',
|
||||
};
|
||||
}
|
||||
|
||||
const validatedDto = validateActionInputUpdatePairById(dto);
|
||||
await repoUpdatePairById(id, validatedDto);
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetUserIdByFolderId(folderId: number) {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: await repoGetUserIdByFolderId(folderId)
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionDeleteFolderById(folderId: number) {
|
||||
try {
|
||||
// Check ownership
|
||||
const isOwner = await checkFolderOwnership(folderId);
|
||||
if (!isOwner) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You do not have permission to delete this folder.',
|
||||
};
|
||||
}
|
||||
|
||||
await repoDeleteFolderById(folderId);
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionDeletePairById(id: number) {
|
||||
try {
|
||||
// Check ownership
|
||||
const isOwner = await checkPairOwnership(id);
|
||||
if (!isOwner) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You do not have permission to delete this item.',
|
||||
};
|
||||
}
|
||||
|
||||
await repoDeletePairById(id);
|
||||
return {
|
||||
success: true,
|
||||
message: 'success'
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetFoldersWithTotalPairsByUserId(id: string): Promise<ActionOutputGetFoldersWithTotalPairsByUserId> {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: await repoGetFoldersWithTotalPairsByUserId(id)
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetFoldersByUserId(userId: string) {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: await repoGetFoldersByUserId(userId)
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionCreatePair(dto: ActionInputCreatePair) {
|
||||
try {
|
||||
// Check ownership
|
||||
const isOwner = await checkFolderOwnership(dto.folderId);
|
||||
if (!isOwner) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You do not have permission to add items to this folder.',
|
||||
};
|
||||
}
|
||||
|
||||
const validatedDto = validateActionInputCreatePair(dto);
|
||||
await repoCreatePair(validatedDto);
|
||||
return {
|
||||
success: true,
|
||||
message: 'success'
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return {
|
||||
success: false,
|
||||
message: e.message
|
||||
};
|
||||
}
|
||||
console.log(e);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionCreateFolder(userId: string, folderName: string) {
|
||||
try {
|
||||
const validatedFolderName = validate(folderName,
|
||||
z.string()
|
||||
.trim()
|
||||
.min(LENGTH_MIN_FOLDER_NAME)
|
||||
.max(LENGTH_MAX_FOLDER_NAME));
|
||||
await repoCreateFolder({
|
||||
name: validatedFolderName,
|
||||
userId: userId
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: 'success'
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return {
|
||||
success: false,
|
||||
message: e.message
|
||||
};
|
||||
}
|
||||
console.log(e);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionRenameFolderById(id: number, newName: string) {
|
||||
try {
|
||||
// Check ownership
|
||||
const isOwner = await checkFolderOwnership(id);
|
||||
if (!isOwner) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You do not have permission to rename this folder.',
|
||||
};
|
||||
}
|
||||
|
||||
const validatedNewName = validate(
|
||||
newName,
|
||||
z.string()
|
||||
.min(LENGTH_MIN_FOLDER_NAME)
|
||||
.max(LENGTH_MAX_FOLDER_NAME)
|
||||
.trim());
|
||||
await repoRenameFolderById(id, validatedNewName);
|
||||
return {
|
||||
success: true,
|
||||
message: 'success'
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return {
|
||||
success: false,
|
||||
message: e.message
|
||||
};
|
||||
}
|
||||
console.log(e);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -32,3 +32,79 @@ export type ActionOutputGetFoldersWithTotalPairsByUserId = {
|
||||
success: boolean,
|
||||
data?: TSharedFolderWithTotalPairs[];
|
||||
};
|
||||
|
||||
export const schemaActionInputSetFolderVisibility = z.object({
|
||||
folderId: z.number().int().positive(),
|
||||
visibility: z.enum(["PRIVATE", "PUBLIC"]),
|
||||
});
|
||||
export type ActionInputSetFolderVisibility = z.infer<typeof schemaActionInputSetFolderVisibility>;
|
||||
|
||||
export const schemaActionInputSearchPublicFolders = z.object({
|
||||
query: z.string().min(1).max(100),
|
||||
});
|
||||
export type ActionInputSearchPublicFolders = z.infer<typeof schemaActionInputSearchPublicFolders>;
|
||||
|
||||
export type ActionOutputPublicFolder = {
|
||||
id: number;
|
||||
name: string;
|
||||
visibility: "PRIVATE" | "PUBLIC";
|
||||
createdAt: Date;
|
||||
userId: string;
|
||||
userName: string | null;
|
||||
userUsername: string | null;
|
||||
totalPairs: number;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export type ActionOutputGetPublicFolders = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputPublicFolder[];
|
||||
};
|
||||
|
||||
export type ActionOutputGetPublicFolderById = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputPublicFolder;
|
||||
};
|
||||
|
||||
export type ActionOutputSetFolderVisibility = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
export type ActionOutputToggleFavorite = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: {
|
||||
isFavorited: boolean;
|
||||
favoriteCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ActionOutputCheckFavorite = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: {
|
||||
isFavorited: boolean;
|
||||
favoriteCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ActionOutputUserFavorite = {
|
||||
id: number;
|
||||
folderId: number;
|
||||
folderName: string;
|
||||
folderCreatedAt: Date;
|
||||
folderTotalPairs: number;
|
||||
folderOwnerId: string;
|
||||
folderOwnerName: string | null;
|
||||
folderOwnerUsername: string | null;
|
||||
favoritedAt: Date;
|
||||
};
|
||||
|
||||
export type ActionOutputGetUserFavorites = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputUserFavorite[];
|
||||
};
|
||||
|
||||
527
src/modules/folder/folder-action.ts
Normal file
527
src/modules/folder/folder-action.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("folder-action");
|
||||
import {
|
||||
ActionInputCreatePair,
|
||||
ActionInputUpdatePairById,
|
||||
ActionOutputGetFoldersWithTotalPairsByUserId,
|
||||
ActionOutputGetPublicFolders,
|
||||
ActionOutputGetPublicFolderById,
|
||||
ActionOutputSetFolderVisibility,
|
||||
ActionOutputToggleFavorite,
|
||||
ActionOutputCheckFavorite,
|
||||
ActionOutputGetUserFavorites,
|
||||
ActionOutputUserFavorite,
|
||||
validateActionInputCreatePair,
|
||||
validateActionInputUpdatePairById,
|
||||
} from "./folder-action-dto";
|
||||
import {
|
||||
repoCreateFolder,
|
||||
repoCreatePair,
|
||||
repoDeleteFolderById,
|
||||
repoDeletePairById,
|
||||
repoGetFolderIdByPairId,
|
||||
repoGetFolderVisibility,
|
||||
repoGetFoldersByUserId,
|
||||
repoGetFoldersWithTotalPairsByUserId,
|
||||
repoGetPairsByFolderId,
|
||||
repoGetPublicFolders,
|
||||
repoGetPublicFolderById,
|
||||
repoGetUserIdByFolderId,
|
||||
repoRenameFolderById,
|
||||
repoSearchPublicFolders,
|
||||
repoUpdateFolderVisibility,
|
||||
repoUpdatePairById,
|
||||
repoToggleFavorite,
|
||||
repoCheckFavorite,
|
||||
repoGetUserFavorites,
|
||||
} from "./folder-repository";
|
||||
import { validate } from "@/utils/validate";
|
||||
import z from "zod";
|
||||
import { LENGTH_MAX_FOLDER_NAME, LENGTH_MIN_FOLDER_NAME } from "@/shared/constant";
|
||||
import { Visibility } from "../../../generated/prisma/enums";
|
||||
|
||||
async function checkFolderOwnership(folderId: number): Promise<boolean> {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) return false;
|
||||
|
||||
const folderOwnerId = await repoGetUserIdByFolderId(folderId);
|
||||
return folderOwnerId === session.user.id;
|
||||
}
|
||||
|
||||
async function checkPairOwnership(pairId: number): Promise<boolean> {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) return false;
|
||||
|
||||
const folderId = await repoGetFolderIdByPairId(pairId);
|
||||
if (!folderId) return false;
|
||||
|
||||
const folderOwnerId = await repoGetUserIdByFolderId(folderId);
|
||||
return folderOwnerId === session.user.id;
|
||||
}
|
||||
|
||||
export async function actionGetPairsByFolderId(folderId: number) {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: await repoGetPairsByFolderId(folderId)
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionUpdatePairById(id: number, dto: ActionInputUpdatePairById) {
|
||||
try {
|
||||
const isOwner = await checkPairOwnership(id);
|
||||
if (!isOwner) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You do not have permission to update this item.',
|
||||
};
|
||||
}
|
||||
|
||||
const validatedDto = validateActionInputUpdatePairById(dto);
|
||||
await repoUpdatePairById(id, validatedDto);
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetUserIdByFolderId(folderId: number) {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: await repoGetUserIdByFolderId(folderId)
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetFolderVisibility(folderId: number) {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: await repoGetFolderVisibility(folderId)
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionDeleteFolderById(folderId: number) {
|
||||
try {
|
||||
const isOwner = await checkFolderOwnership(folderId);
|
||||
if (!isOwner) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You do not have permission to delete this folder.',
|
||||
};
|
||||
}
|
||||
|
||||
await repoDeleteFolderById(folderId);
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionDeletePairById(id: number) {
|
||||
try {
|
||||
const isOwner = await checkPairOwnership(id);
|
||||
if (!isOwner) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You do not have permission to delete this item.',
|
||||
};
|
||||
}
|
||||
|
||||
await repoDeletePairById(id);
|
||||
return {
|
||||
success: true,
|
||||
message: 'success'
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetFoldersWithTotalPairsByUserId(id: string): Promise<ActionOutputGetFoldersWithTotalPairsByUserId> {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: await repoGetFoldersWithTotalPairsByUserId(id)
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetFoldersByUserId(userId: string) {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: await repoGetFoldersByUserId(userId)
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionCreatePair(dto: ActionInputCreatePair) {
|
||||
try {
|
||||
const isOwner = await checkFolderOwnership(dto.folderId);
|
||||
if (!isOwner) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You do not have permission to add items to this folder.',
|
||||
};
|
||||
}
|
||||
|
||||
const validatedDto = validateActionInputCreatePair(dto);
|
||||
await repoCreatePair(validatedDto);
|
||||
return {
|
||||
success: true,
|
||||
message: 'success'
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return {
|
||||
success: false,
|
||||
message: e.message
|
||||
};
|
||||
}
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionCreateFolder(userId: string, folderName: string) {
|
||||
try {
|
||||
const validatedFolderName = validate(folderName,
|
||||
z.string()
|
||||
.trim()
|
||||
.min(LENGTH_MIN_FOLDER_NAME)
|
||||
.max(LENGTH_MAX_FOLDER_NAME));
|
||||
await repoCreateFolder({
|
||||
name: validatedFolderName,
|
||||
userId: userId
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: 'success'
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return {
|
||||
success: false,
|
||||
message: e.message
|
||||
};
|
||||
}
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionRenameFolderById(id: number, newName: string) {
|
||||
try {
|
||||
const isOwner = await checkFolderOwnership(id);
|
||||
if (!isOwner) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You do not have permission to rename this folder.',
|
||||
};
|
||||
}
|
||||
|
||||
const validatedNewName = validate(
|
||||
newName,
|
||||
z.string()
|
||||
.min(LENGTH_MIN_FOLDER_NAME)
|
||||
.max(LENGTH_MAX_FOLDER_NAME)
|
||||
.trim());
|
||||
await repoRenameFolderById(id, validatedNewName);
|
||||
return {
|
||||
success: true,
|
||||
message: 'success'
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof ValidateError) {
|
||||
return {
|
||||
success: false,
|
||||
message: e.message
|
||||
};
|
||||
}
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionSetFolderVisibility(
|
||||
folderId: number,
|
||||
visibility: "PRIVATE" | "PUBLIC",
|
||||
): Promise<ActionOutputSetFolderVisibility> {
|
||||
try {
|
||||
const isOwner = await checkFolderOwnership(folderId);
|
||||
if (!isOwner) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'You do not have permission to change this folder visibility.',
|
||||
};
|
||||
}
|
||||
|
||||
await repoUpdateFolderVisibility({
|
||||
folderId,
|
||||
visibility: visibility as Visibility,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetPublicFolders(): Promise<ActionOutputGetPublicFolders> {
|
||||
try {
|
||||
const data = await repoGetPublicFolders({});
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: data.map((folder) => ({
|
||||
...folder,
|
||||
visibility: folder.visibility as "PRIVATE" | "PUBLIC",
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionSearchPublicFolders(query: string): Promise<ActionOutputGetPublicFolders> {
|
||||
try {
|
||||
const data = await repoSearchPublicFolders({ query, limit: 50 });
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: data.map((folder) => ({
|
||||
...folder,
|
||||
visibility: folder.visibility as "PRIVATE" | "PUBLIC",
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetPublicFolderById(folderId: number): Promise<ActionOutputGetPublicFolderById> {
|
||||
try {
|
||||
const folder = await repoGetPublicFolderById(folderId);
|
||||
if (!folder) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Folder not found.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: {
|
||||
...folder,
|
||||
visibility: folder.visibility as "PRIVATE" | "PUBLIC",
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionToggleFavorite(
|
||||
folderId: number,
|
||||
): Promise<ActionOutputToggleFavorite> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unauthorized',
|
||||
};
|
||||
}
|
||||
|
||||
const isFavorited = await repoToggleFavorite({
|
||||
folderId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
const { favoriteCount } = await repoCheckFavorite({
|
||||
folderId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: {
|
||||
isFavorited,
|
||||
favoriteCount,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionCheckFavorite(
|
||||
folderId: number,
|
||||
): Promise<ActionOutputCheckFavorite> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: {
|
||||
isFavorited: false,
|
||||
favoriteCount: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { isFavorited, favoriteCount } = await repoCheckFavorite({
|
||||
folderId,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: {
|
||||
isFavorited,
|
||||
favoriteCount,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetUserFavorites(): Promise<ActionOutputGetUserFavorites> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unauthorized',
|
||||
};
|
||||
}
|
||||
|
||||
const favorites = await repoGetUserFavorites({
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: favorites.map((fav) => ({
|
||||
id: fav.id,
|
||||
folderId: fav.folderId,
|
||||
folderName: fav.folderName,
|
||||
folderCreatedAt: fav.folderCreatedAt,
|
||||
folderTotalPairs: fav.folderTotalPairs,
|
||||
folderOwnerId: fav.folderOwnerId,
|
||||
folderOwnerName: fav.folderOwnerName,
|
||||
folderOwnerUsername: fav.folderOwnerUsername,
|
||||
favoritedAt: fav.favoritedAt,
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Operation failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Visibility } from "../../../generated/prisma/enums";
|
||||
|
||||
export interface RepoInputCreateFolder {
|
||||
name: string;
|
||||
userId: string;
|
||||
@@ -21,3 +23,69 @@ export interface RepoInputUpdatePair {
|
||||
ipa1?: string;
|
||||
ipa2?: string;
|
||||
}
|
||||
|
||||
export interface RepoInputUpdateFolderVisibility {
|
||||
folderId: number;
|
||||
visibility: Visibility;
|
||||
}
|
||||
|
||||
export interface RepoInputSearchPublicFolders {
|
||||
query: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface RepoInputGetPublicFolders {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: "createdAt" | "name";
|
||||
}
|
||||
|
||||
export type RepoOutputPublicFolder = {
|
||||
id: number;
|
||||
name: string;
|
||||
visibility: Visibility;
|
||||
createdAt: Date;
|
||||
userId: string;
|
||||
userName: string | null;
|
||||
userUsername: string | null;
|
||||
totalPairs: number;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export type RepoOutputFolderVisibility = {
|
||||
visibility: Visibility;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export interface RepoInputToggleFavorite {
|
||||
folderId: number;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface RepoInputCheckFavorite {
|
||||
folderId: number;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export type RepoOutputFavoriteStatus = {
|
||||
isFavorited: boolean;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export interface RepoInputGetUserFavorites {
|
||||
userId: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export type RepoOutputUserFavorite = {
|
||||
id: number;
|
||||
folderId: number;
|
||||
folderName: string;
|
||||
folderCreatedAt: Date;
|
||||
folderTotalPairs: number;
|
||||
folderOwnerId: string;
|
||||
folderOwnerName: string | null;
|
||||
folderOwnerUsername: string | null;
|
||||
favoritedAt: Date;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { RepoInputCreateFolder, RepoInputCreatePair, RepoInputUpdatePair } from "./folder-repository-dto";
|
||||
import {
|
||||
RepoInputCreateFolder,
|
||||
RepoInputCreatePair,
|
||||
RepoInputUpdatePair,
|
||||
RepoInputUpdateFolderVisibility,
|
||||
RepoInputSearchPublicFolders,
|
||||
RepoInputGetPublicFolders,
|
||||
RepoOutputPublicFolder,
|
||||
RepoOutputFolderVisibility,
|
||||
RepoInputToggleFavorite,
|
||||
RepoInputCheckFavorite,
|
||||
RepoOutputFavoriteStatus,
|
||||
RepoInputGetUserFavorites,
|
||||
RepoOutputUserFavorite,
|
||||
} from "./folder-repository-dto";
|
||||
import { Visibility } from "../../../generated/prisma/enums";
|
||||
|
||||
export async function repoCreatePair(data: RepoInputCreatePair) {
|
||||
return (await prisma.pair.create({
|
||||
@@ -63,7 +78,8 @@ export async function repoGetFoldersByUserId(userId: string) {
|
||||
return {
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
userId: v.userId
|
||||
userId: v.userId,
|
||||
visibility: v.visibility,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -95,6 +111,7 @@ export async function repoGetFoldersWithTotalPairsByUserId(userId: string) {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
userId: folder.userId,
|
||||
visibility: folder.visibility,
|
||||
total: folder._count?.pairs ?? 0,
|
||||
createdAt: folder.createdAt,
|
||||
}));
|
||||
@@ -134,3 +151,183 @@ export async function repoGetFolderIdByPairId(pairId: number) {
|
||||
});
|
||||
return pair?.folderId;
|
||||
}
|
||||
|
||||
export async function repoUpdateFolderVisibility(
|
||||
input: RepoInputUpdateFolderVisibility,
|
||||
): Promise<void> {
|
||||
await prisma.folder.update({
|
||||
where: { id: input.folderId },
|
||||
data: { visibility: input.visibility },
|
||||
});
|
||||
}
|
||||
|
||||
export async function repoGetFolderVisibility(
|
||||
folderId: number,
|
||||
): Promise<RepoOutputFolderVisibility | null> {
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: { id: folderId },
|
||||
select: { visibility: true, userId: true },
|
||||
});
|
||||
return folder;
|
||||
}
|
||||
|
||||
export async function repoGetPublicFolderById(
|
||||
folderId: number,
|
||||
): Promise<RepoOutputPublicFolder | null> {
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: { id: folderId, visibility: Visibility.PUBLIC },
|
||||
include: {
|
||||
_count: { select: { pairs: true, favorites: true } },
|
||||
user: { select: { name: true, username: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) return null;
|
||||
|
||||
return {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
visibility: folder.visibility,
|
||||
createdAt: folder.createdAt,
|
||||
userId: folder.userId,
|
||||
userName: folder.user?.name ?? "Unknown",
|
||||
userUsername: folder.user?.username ?? "unknown",
|
||||
totalPairs: folder._count.pairs,
|
||||
favoriteCount: folder._count.favorites,
|
||||
};
|
||||
}
|
||||
|
||||
export async function repoGetPublicFolders(
|
||||
input: RepoInputGetPublicFolders = {},
|
||||
): Promise<RepoOutputPublicFolder[]> {
|
||||
const { limit = 50, offset = 0, orderBy = "createdAt" } = input;
|
||||
|
||||
const folders = await prisma.folder.findMany({
|
||||
where: { visibility: Visibility.PUBLIC },
|
||||
include: {
|
||||
_count: { select: { pairs: true, favorites: true } },
|
||||
user: { select: { name: true, username: true } },
|
||||
},
|
||||
orderBy: { [orderBy]: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
return folders.map((folder) => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
visibility: folder.visibility,
|
||||
createdAt: folder.createdAt,
|
||||
userId: folder.userId,
|
||||
userName: folder.user?.name ?? "Unknown",
|
||||
userUsername: folder.user?.username ?? "unknown",
|
||||
totalPairs: folder._count.pairs,
|
||||
favoriteCount: folder._count.favorites,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function repoSearchPublicFolders(
|
||||
input: RepoInputSearchPublicFolders,
|
||||
): Promise<RepoOutputPublicFolder[]> {
|
||||
const { query, limit = 50 } = input;
|
||||
const folders = await prisma.folder.findMany({
|
||||
where: {
|
||||
visibility: Visibility.PUBLIC,
|
||||
name: { contains: query, mode: "insensitive" },
|
||||
},
|
||||
include: {
|
||||
_count: { select: { pairs: true, favorites: true } },
|
||||
user: { select: { name: true, username: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
return folders.map((folder) => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
visibility: folder.visibility,
|
||||
createdAt: folder.createdAt,
|
||||
userId: folder.userId,
|
||||
userName: folder.user?.name ?? "Unknown",
|
||||
userUsername: folder.user?.username ?? "unknown",
|
||||
totalPairs: folder._count.pairs,
|
||||
favoriteCount: folder._count.favorites,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function repoToggleFavorite(
|
||||
input: RepoInputToggleFavorite,
|
||||
): Promise<boolean> {
|
||||
const existing = await prisma.folderFavorite.findUnique({
|
||||
where: {
|
||||
userId_folderId: {
|
||||
userId: input.userId,
|
||||
folderId: input.folderId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
await prisma.folderFavorite.delete({
|
||||
where: { id: existing.id },
|
||||
});
|
||||
return false;
|
||||
} else {
|
||||
await prisma.folderFavorite.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
folderId: input.folderId,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function repoCheckFavorite(
|
||||
input: RepoInputCheckFavorite,
|
||||
): Promise<RepoOutputFavoriteStatus> {
|
||||
const favorite = await prisma.folderFavorite.findUnique({
|
||||
where: {
|
||||
userId_folderId: {
|
||||
userId: input.userId,
|
||||
folderId: input.folderId,
|
||||
},
|
||||
},
|
||||
});
|
||||
const count = await prisma.folderFavorite.count({
|
||||
where: { folderId: input.folderId },
|
||||
});
|
||||
return {
|
||||
isFavorited: !!favorite,
|
||||
favoriteCount: count,
|
||||
};
|
||||
}
|
||||
|
||||
export async function repoGetUserFavorites(input: RepoInputGetUserFavorites) {
|
||||
const { userId, limit = 50, offset = 0 } = input;
|
||||
|
||||
const favorites = await prisma.folderFavorite.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
folder: {
|
||||
include: {
|
||||
_count: { select: { pairs: true } },
|
||||
user: { select: { name: true, username: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
return favorites.map((fav) => ({
|
||||
id: fav.id,
|
||||
folderId: fav.folderId,
|
||||
folderName: fav.folder.name,
|
||||
folderCreatedAt: fav.folder.createdAt,
|
||||
folderTotalPairs: fav.folder._count.pairs,
|
||||
folderOwnerId: fav.folder.userId,
|
||||
folderOwnerName: fav.folder.user?.name ?? "Unknown",
|
||||
folderOwnerUsername: fav.folder.user?.username ?? "unknown",
|
||||
favoritedAt: fav.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
validateActionInputTranslateText,
|
||||
} from "./translator-action-dto";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { serviceTranslateText } from "./translator-service";
|
||||
import { getAnswer } from "@/lib/bigmodel/zhipu";
|
||||
import { getAnswer } from "@/lib/bigmodel/llm";
|
||||
|
||||
const log = createLogger("translator-action");
|
||||
|
||||
export const actionTranslateText = async (
|
||||
dto: ActionInputTranslateText
|
||||
@@ -25,7 +28,7 @@ export const actionTranslateText = async (
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
console.log(e);
|
||||
log.error("Translation action failed", { error: e });
|
||||
return {
|
||||
success: false,
|
||||
message: "Unknown error occurred.",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { executeTranslation } from "@/lib/bigmodel/translator/orchestrator";
|
||||
import { repoCreateTranslationHistory, repoSelectLatestTranslation } from "./translator-repository";
|
||||
import { ServiceInputTranslateText, ServiceOutputTranslateText } from "./translator-service-dto";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
|
||||
const log = createLogger("translator-service");
|
||||
|
||||
export const serviceTranslateText = async (
|
||||
dto: ServiceInputTranslateText
|
||||
@@ -31,7 +34,7 @@ export const serviceTranslateText = async (
|
||||
sourceIpa: needIpa ? response.sourceIpa : undefined,
|
||||
targetIpa: needIpa ? response.targetIpa : undefined,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to save translation data:", error);
|
||||
log.error("Failed to save translation data", { error });
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -54,7 +57,7 @@ export const serviceTranslateText = async (
|
||||
sourceIpa: lastTranslation.sourceIpa || undefined,
|
||||
targetIpa: lastTranslation.targetIpa || undefined,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to save translation data:", error);
|
||||
log.error("Failed to save translation data", { error });
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,12 +2,14 @@ export type TSharedFolder = {
|
||||
id: number,
|
||||
name: string,
|
||||
userId: string;
|
||||
visibility: "PRIVATE" | "PUBLIC";
|
||||
};
|
||||
|
||||
export type TSharedFolderWithTotalPairs = {
|
||||
id: number,
|
||||
name: string,
|
||||
userId: string,
|
||||
visibility: "PRIVATE" | "PUBLIC";
|
||||
total: number;
|
||||
};
|
||||
|
||||
@@ -21,3 +23,15 @@ export type TSharedPair = {
|
||||
id: number;
|
||||
folderId: number;
|
||||
};
|
||||
|
||||
export type TPublicFolder = {
|
||||
id: number;
|
||||
name: string;
|
||||
visibility: "PRIVATE" | "PUBLIC";
|
||||
createdAt: Date;
|
||||
userId: string;
|
||||
userName: string | null;
|
||||
userUsername: string | null;
|
||||
totalPairs: number;
|
||||
favoriteCount: number;
|
||||
};
|
||||
Reference in New Issue
Block a user