52 Commits

Author SHA1 Message Date
an-lee
050c577620 bump v0.1.0-alpha.3 2024-01-15 22:13:11 +08:00
an-lee
8d7a3e37ce build darwin-arm64 in ci 2024-01-15 21:55:15 +08:00
an-lee
23feb06d20 Fix: UI (#119)
* fix ui
2024-01-15 18:00:35 +08:00
an-lee
b545ea2362 Feat: save waveform as file (#118)
* package rpm

* cache waveform data as file in library

* clear waveform data in db

* fix some css
2024-01-15 16:57:44 +08:00
an-lee
187038c42e Feat: scan ffmpeg command (#116)
* package rpm

* fix model url

* scan/check ffmpeg command

* handle undefined

* add reset settings button

* add ffmpeg install instrunction for mac

* improve landing steps
2024-01-15 14:12:22 +08:00
luckrnx09
6cc9cb9da2 Fix: remove unnecessary chars 2024-01-15 10:54:58 +08:00
xiaolai
3cf168f098 typo. 2024-01-15 09:17:28 +08:00
xiaolai
2ceb122acf file order changed. 2024-01-15 08:36:57 +08:00
an-lee
3edcfc0017 bump 0.1.0-alpha.2 2024-01-14 17:00:30 +08:00
an-lee
d2510d00cb Feat: more preferences (#106)
* add ffmpeg command check

* may switch language

* tweak
2024-01-14 16:54:15 +08:00
xiaolai
afb818f215 202401011~20240114 2024-01-14 16:05:47 +08:00
an-lee
fe0542e8c6 Fix: Improve UI (#103)
* use sonner

* fix ui

* fix post audio player
2024-01-13 22:59:57 +08:00
an-lee
d6a4b24a1e Fix some bugs (#101)
* fix whisper large model download url ref: #93

* remove default baseURL for openAI

* add log for conversation
2024-01-13 19:30:58 +08:00
an-lee
befdc6744a Merge pull request #100 from an-lee/feat-community
Feat: community
2024-01-13 17:51:08 +08:00
an-lee
7f671bb709 ui improve 2024-01-13 17:25:17 +08:00
an-lee
29e12106a2 add pagy for audios/videos 2024-01-13 17:20:18 +08:00
an-lee
09b7ca40f4 add pagy for stories 2024-01-13 16:59:46 +08:00
an-lee
e77eeb6a9c add pagy for posts 2024-01-13 16:57:49 +08:00
an-lee
3d3fc17c79 improve player loading perf 2024-01-13 16:52:14 +08:00
an-lee
24236a48ff hide ranking 2024-01-13 16:45:34 +08:00
an-lee
f40df6ecd6 refactor share 2024-01-13 16:40:26 +08:00
an-lee
f67a59e756 display transcription for shared audio 2024-01-13 16:32:12 +08:00
an-lee
80fe9caa90 cannot share local video 2024-01-13 16:13:51 +08:00
an-lee
8d42c4c626 may delete post 2024-01-13 16:11:03 +08:00
an-lee
aa2334aa12 may share story 2024-01-13 15:32:15 +08:00
an-lee
0ecaf4bdff may share recording 2024-01-13 15:04:28 +08:00
an-lee
d655da9aea rename 2024-01-13 01:24:33 +08:00
an-lee
1243076bbb add audio/video & send prompt from share zone 2024-01-13 01:09:40 +08:00
xiaolai
5b50096467 reformat. 2024-01-12 22:50:59 +08:00
an-lee
1e290e9e88 Merge pull request #96 from YangZhengCQ/main
Updated usage tutorial for Windows system
2024-01-12 22:42:15 +08:00
Yangzheng
3fe209a100 Updated usage tutorial for Windows system
更新了 Windows 系统下的操作教程
2024-01-12 22:25:04 +08:00
an-lee
3dff4330a1 new post type 2024-01-12 15:47:33 +08:00
xiaolai
2db0d6c43b say what you wanna say. 2024-01-12 13:21:08 +08:00
xiaolai
91e573adef say what you wanna say. 2024-01-12 13:10:46 +08:00
xiaolai
d84973fac0 README modified. 2024-01-12 10:34:26 +08:00
xiaolai
cc47d64083 README modified. 2024-01-12 10:29:49 +08:00
an-lee
e05f2c57eb may share prompt 2024-01-12 01:40:17 +08:00
an-lee
f9b1c14b4c share audio/video & display post 2024-01-12 00:54:53 +08:00
an-lee
e510ed9337 vaccum after cache clear 2024-01-11 21:00:40 +08:00
an-lee
9635c192d5 improve api/client 2024-01-11 20:30:13 +08:00
an-lee
5414af1a06 expire processing transcription 2024-01-11 20:18:48 +08:00
an-lee
017b5b59e9 fix inline transcription in video 2024-01-11 17:53:46 +08:00
an-lee
eda547aca1 remove main/web-api 2024-01-11 17:44:43 +08:00
an-lee
267eee37b9 remove web-api in preload 2024-01-11 17:37:21 +08:00
an-lee
66cf3dd828 refactor webApi 2024-01-11 17:36:23 +08:00
an-lee
94d4a0a338 add community page 2024-01-11 17:10:00 +08:00
an-lee
551b848ade Merge branch 'xiaolai:main' into main 2024-01-11 13:21:10 +08:00
an-lee
3a87a41ce9 update forge config publish repo 2024-01-11 13:19:51 +08:00
an-lee
cee7f5238a Merge branch 'xiaolai:main' into main 2024-01-11 13:17:53 +08:00
an-lee
e4c0736784 bump v0.1.0-alpha 2024-01-11 13:08:41 +08:00
an-lee
a27d145990 Merge pull request #92 from an-lee/main
Fix CI
2024-01-11 12:57:52 +08:00
an-lee
e3d64dcf24 update forge config publish repo 2024-01-11 12:51:45 +08:00
114 changed files with 4106 additions and 937 deletions

View File

@@ -18,3 +18,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
run: yarn publish:enjoy
- if: matrix.os == 'macos-latest'
env:
GITHUB_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
run: yarn run publish --arch=arm64

View File

@@ -17,6 +17,8 @@
- [Enjoy App](./enjoy/README.md)
## * 开发者
### 本地启动
```bash
@@ -29,3 +31,88 @@ yarn start:enjoy
```bash
yarn make:enjoy
```
## * 普通小白用户
方法一:这是**最直接简单的方法**是去 [releases 页面](https://github.com/xiaolai/everyone-can-use-english/tags)下载相应的安装文件。
方法二:如果想要随时**试用更新版本**的话,请按一下步骤操作。
### MacOS 用户
1. 打开命令行工具 Terminal
2. 安装 Homebrew请参阅这篇文章《[从 Terminal 开始…](https://github.com/xiaolai/apple-computer-literacy/blob/main/start-from-terminal.md)》)
3. 安装 yarn
```bash
brew install yarn
```
4. 克隆此仓库至本地,而后安装、启动:
```bash
cd ~
mkdir github
cd github
git clone https://github.com/xiaolai/everyone-can-use-english
cd everyone-can-use-english
yarn install
yarn start:enjoy
```
### Windows 用户
系统要求Windows 10 22H2 以上版本、 [Windows PowerShell 5.1](https://aka.ms/wmf5download) 以上版本、互联网网络连接正常。
1. 将鼠标移至任务栏的 “Windows 徽标” 上单击右键,选择 “PowerShell”
> tips 1 :在最新的 Windows 11 上,你看不到 “PowerShell” 选项,只有 “终端”
>
> tips 2 :不能用管理员权限运行 PowerShell ,否则会导致 Scoop 安装失败
2. 在弹出的 PowerShell 窗口中依次执行运行以下命令安装Scoop
```powershell
# 设置 PowerShell 执行策略
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
# 下载安装脚本
irm get.scoop.sh -outfile 'install.ps1'
# 执行安装, --ScoopDir 参数指定 Scoop 安装路径
.\install.ps1 -ScoopDir 'C:\Scoop'
```
如果出现下面的错误:
> <span style="color:red">irm : 未能解析此远程名称: 'raw.githubusercontent.com'</span>
说明你的**网络连接**有问题,请自行研究解决:
3. 安装 Nodejs 和 yarn 以及其他依赖环境
```powershell
scoop install nodejs
scoop install git
npm install yarn -D
```
4. 克隆此仓库至本地,而后安装 Enjoy APP
```powershell
cd ~
mkdir github
cd github
git clone https://github.com/xiaolai/everyone-can-use-english
cd everyone-can-use-english
cd enjoy
yarn install
yarn start:enjoy
```
出现 `Completed in XXXXXXXXXX` 类似字样说明安装成功!
5. 运行 Enjoy APP ,在终端执行下列命令:
```powershell
yarn start:enjoy
```

View File

@@ -395,7 +395,7 @@ One of the reasons why many parents want to send their children to separate scho
* 名词单复数形式错误:错误地使用了名词的数,包括:使用了不可数名词的 “复数” 形式,使用了集合名词的 “复数” 形式,在应该使用复数的地方使用了单数名词(或反之)等。
* 单数可数名词未受限定:句子中出现的单数可数名词之前没有使用限定词,包括冠词、不定代词、指示代词、名词或代词所有格、数词与某些形容词性的物主代词。
* 词性错误:在选择词汇的过程中忽略了英文词性的特性,仅按照含义来使用词汇,从而发生了词性使用错误的现象。
* 修饰关系错误:违反了词汇修饰的规则,采用了不恰当的修饰关系。包括用* 形容词修饰动词、形容词修饰形容词,副词修饰名词等。
* 修饰关系错误:违反了词汇修饰的规则,采用了不恰当的修饰关系。包括用形容词修饰动词、形容词修饰形容词,副词修饰名词等。
* 搭配错误:句子中出现了不合适的词汇修饰、限制、说明现象,或者错误地使用了固有的词汇搭配形式。
* 词序错误:未使用正确的、符合习惯的表述语序来对内容进行陈述。其中包括修饰词顺序错误,该倒装时没有倒装等。
* 非谓语动词使用错误:错误地使用了现在分词、过去分词、或动词的不定式。其中包括:
@@ -445,9 +445,9 @@ Style: Toward Clarity and Grace by Joseph M. Williams
几乎所有真正有效的学习手段都是简单、廉价、往往并不直接但却真正有效的。复述,就是这样的有效手段。
每个文化中的每个人在这方面都一样 —— 终其一生绝大多数情况下都在复述别人说过的话。首先语言文字很难纯粹 “原创”,其次绝大多数情况下确实也没有 必要 “独一无二”。更为重要的是,第二语言学习者的目标绝大多数情况下不是为了从事诗人、小说家之类的职业,而是希望多掌握一门用来承载信息沟通交流的工 具 —— 这种情况下 “复述” 几乎占据了第二语言应用的全部。
每个文化中的每个人在这方面都一样 —— 终其一生绝大多数情况下都在复述别人说过的话。首先语言文字很难纯粹 “原创”,其次绝大多数情况下确实也没有必要 “独一无二”。更为重要的是,第二语言学习者的目标绝大多数情况下不是为了从事诗人、小说家之类的职业,而是希望多掌握一门用来承载信息沟通交流的工具 —— 这种情况下 “复述” 几乎占据了第二语言应用的全部。
这还真的并不是那么 “显而易见” 的事实。ETS 在设计并举办 TOEFL 考试几十年之后才 “恍然大悟” 地在新托福考试中大面积添加了 “复述能力” 的考 TOEFL 作文部分中有综合测试,要求考生先读一篇文章,然后再听一篇与刚刚读过的文章相关的讲座,而后复述讲座内容以及讲座内容是如何与阅读文章内 容相联系的;口语部分中有先听再说,先读再说,听与读之后再说 —— 无一不是在考量考生的 “复述能力”。
这还真的并不是那么 “显而易见” 的事实。ETS 在设计并举办 TOEFL 考试几十年之后才 “恍然大悟” 地在新托福考试中大面积添加了 “复述能力” 的考量TOEFL 作文部分中有综合测试,要求考生先读一篇文章,然后再听一篇与刚刚读过的文章相关的讲座,而后复述讲座内容以及讲座内容是如何与阅读文章内容相联系的;口语部分中有先听再说,先读再说,听与读之后再说 —— 无一不是在考量考生的 “复述能力”。
## 9. 貌似多余:其实连哑巴英语都并不那么坏

View File

@@ -7,7 +7,7 @@
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true
"cssVariables": false
},
"aliases": {
"components": "src/renderer/components",

View File

@@ -2,6 +2,7 @@ import type { ForgeConfig } from "@electron-forge/shared-types";
import { MakerSquirrel } from "@electron-forge/maker-squirrel";
import { MakerZIP } from "@electron-forge/maker-zip";
import { MakerDeb } from "@electron-forge/maker-deb";
import { MakerRpm } from "@electron-forge/maker-rpm";
import { VitePlugin } from "@electron-forge/plugin-vite";
import { dirname } from "node:path";
import { Walker, DepType, type Module } from "flora-colossus";
@@ -44,14 +45,22 @@ const config: ForgeConfig = {
mimeType: ["x-scheme-handler/enjoy"],
},
}),
new MakerRpm({
options: {
name: "enjoy",
productName: "Enjoy",
icon: "./assets/icon.png",
mimeType: ["x-scheme-handler/enjoy"],
},
}),
],
publishers: [
{
name: "@electron-forge/publisher-github",
config: {
repository: {
owner: "an-lee",
name: "enjoy",
owner: "xiaolai",
name: "everyone-can-use-english",
},
draft: true,
},

View File

@@ -2,7 +2,7 @@
"private": true,
"name": "enjoy",
"productName": "Enjoy",
"version": "0.1.0",
"version": "0.1.0-alpha.3",
"description": "Enjoy desktop app",
"main": ".vite/build/main.js",
"types": "./src/types.d.ts",
@@ -117,6 +117,7 @@
"lucide-react": "^0.308.0",
"mark.js": "^8.11.1",
"microsoft-cognitiveservices-speech-sdk": "^1.34.0",
"next-themes": "^0.2.1",
"openai": "^4.24.1",
"pitchfinder": "^2.3.2",
"postcss": "^8.4.33",
@@ -133,6 +134,7 @@
"rimraf": "^5.0.5",
"sequelize": "^6.35.2",
"sequelize-typescript": "^2.1.6",
"sonner": "^1.3.1",
"sqlite3": "^5.1.7",
"tailwind-scrollbar-hide": "^1.1.7",
"umzug": "^3.5.0",

259
enjoy/src/api/client.ts Normal file
View File

@@ -0,0 +1,259 @@
import axios, { AxiosInstance } from "axios";
import decamelizeKeys from "decamelize-keys";
import camelcaseKeys from "camelcase-keys";
const ONE_MINUTE = 1000 * 60; // 1 minute
export class Client {
public api: AxiosInstance;
public baseUrl: string;
public logger: any;
constructor(options: {
baseUrl: string;
accessToken?: string;
logger?: any;
}) {
const { baseUrl, accessToken, logger } = options;
this.baseUrl = baseUrl;
this.logger = logger || console;
this.api = axios.create({
baseURL: baseUrl,
timeout: ONE_MINUTE,
headers: {
"Content-Type": "application/json",
},
});
this.api.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${accessToken}`;
this.logger.debug(
config.method.toUpperCase(),
config.baseURL + config.url,
config.data,
config.params
);
return config;
});
this.api.interceptors.response.use(
(response) => {
this.logger.debug(
response.status,
response.config.method.toUpperCase(),
response.config.baseURL + response.config.url
);
return camelcaseKeys(response.data, { deep: true });
},
(err) => {
if (err.response) {
this.logger.error(
err.response.status,
err.response.config.method.toUpperCase(),
err.response.config.baseURL + err.response.config.url
);
this.logger.error(err.response.data);
return Promise.reject(err.response.data);
}
if (err.request) {
this.logger.error(err.request);
} else {
this.logger.error(err.message);
}
return Promise.reject(err);
}
);
}
auth(params: { provider: string; code: string }): Promise<UserType> {
return this.api.post("/api/sessions", decamelizeKeys(params));
}
me(): Promise<UserType> {
return this.api.get("/api/me");
}
rankings(range: "day" | "week" | "month" | "year" | "all" = "day"): Promise<{
rankings: UserType[];
range: string;
}> {
return this.api.get("/api/users/rankings", { params: { range } });
}
posts(params?: { page?: number; items?: number }): Promise<
{
posts: PostType[];
} & PagyResponseType
> {
return this.api.get("/api/posts", { params: decamelizeKeys(params) });
}
post(id: string): Promise<PostType> {
return this.api.get(`/api/posts/${id}`);
}
createPost(params: {
metadata?: PostType["metadata"];
targetType?: string;
targetId?: string;
}): Promise<PostType> {
return this.api.post("/api/posts", decamelizeKeys(params));
}
updatePost(id: string, params: { content: string }): Promise<PostType> {
return this.api.put(`/api/posts/${id}`, decamelizeKeys(params));
}
deletePost(id: string): Promise<void> {
return this.api.delete(`/api/posts/${id}`);
}
transcriptions(params?: {
page?: number;
items?: number;
targetId?: string;
targetType?: string;
targetMd5?: string;
}): Promise<
{
transcriptions: TranscriptionType[];
} & PagyResponseType
> {
return this.api.get("/api/transcriptions", {
params: decamelizeKeys(params),
});
}
syncAudio(audio: Partial<AudioType>) {
return this.api.post("/api/mine/audios", decamelizeKeys(audio));
}
syncVideo(video: Partial<VideoType>) {
return this.api.post("/api/mine/videos", decamelizeKeys(video));
}
syncTranscription(transcription: Partial<TranscriptionType>) {
return this.api.post("/api/transcriptions", decamelizeKeys(transcription));
}
syncRecording(recording: Partial<RecordingType>) {
if (!recording) return;
return this.api.post("/api/mine/recordings", decamelizeKeys(recording));
}
generateSpeechToken(): Promise<{ token: string; region: string }> {
return this.api.post("/api/speech/tokens");
}
syncPronunciationAssessment(
pronunciationAssessment: Partial<PronunciationAssessmentType>
) {
if (!pronunciationAssessment) return;
return this.api.post(
"/api/mine/pronunciation_assessments",
decamelizeKeys(pronunciationAssessment)
);
}
recordingAssessment(id: string) {
return this.api.get(`/api/mine/recordings/${id}/assessment`);
}
lookup(params: {
word: string;
context: string;
sourceId?: string;
sourceType?: string;
}): Promise<LookupType> {
return this.api.post("/api/lookups", decamelizeKeys(params));
}
lookupInBatch(
lookups: {
word: string;
context: string;
sourceId?: string;
sourceType?: string;
}[]
): Promise<{ successCount: number; errors: string[]; total: number }> {
return this.api.post("/api/lookups/batch", {
lookups: decamelizeKeys(lookups, { deep: true }),
});
}
extractVocabularyFromStory(storyId: string): Promise<string[]> {
return this.api.post(`/api/stories/${storyId}/extract_vocabulary`);
}
storyMeanings(
storyId: string,
params?: {
page?: number;
items?: number;
storyId?: string;
}
): Promise<
{
meanings: MeaningType[];
pendingLookups?: LookupType[];
} & PagyResponseType
> {
return this.api.get(`/api/stories/${storyId}/meanings`, {
params: decamelizeKeys(params),
});
}
mineMeanings(params?: {
page?: number;
items?: number;
sourceId?: string;
sourceType?: string;
status?: string;
}): Promise<
{
meanings: MeaningType[];
} & PagyResponseType
> {
return this.api.get("/api/mine/meanings", {
params: decamelizeKeys(params),
});
}
createStory(params: CreateStoryParamsType): Promise<StoryType> {
return this.api.post("/api/stories", decamelizeKeys(params));
}
story(id: string): Promise<StoryType> {
return this.api.get(`/api/stories/${id}`);
}
stories(params?: { page: number }): Promise<
{
stories: StoryType[];
} & PagyResponseType
> {
return this.api.get("/api/stories", { params: decamelizeKeys(params) });
}
mineStories(params?: { page: number }): Promise<
{
stories: StoryType[];
} & PagyResponseType
> {
return this.api.get("/api/mine/stories", {
params: decamelizeKeys(params),
});
}
starStory(storyId: string): Promise<{ starred: boolean }> {
return this.api.post(`/api/mine/stories`, decamelizeKeys({ storyId }));
}
unstarStory(storyId: string): Promise<{ starred: boolean }> {
return this.api.delete(`/api/mine/stories/${storyId}`);
}
}

1
enjoy/src/api/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from "./client";

View File

@@ -32,9 +32,9 @@ export const WHISPER_MODELS_OPTIONS = [
},
{
type: "large",
name: "ggml-large.bin",
name: "ggml-large-v3.bin",
size: "3.09 GB",
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large.bin",
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin",
},
];
@@ -46,35 +46,3 @@ export const PROCESS_TIMEOUT = 1000 * 60 * 15;
export const AI_GATEWAY_ENDPOINT =
"https://gateway.ai.cloudflare.com/v1/11d43ab275eb7e1b271ba4089ecc3864/enjoy";
export const CONVERSATION_PRESET_SCENARIOS: {
scenario: string;
autoSpeech: boolean;
prompt: string;
}[] = [
{
scenario: "translation",
autoSpeech: false,
prompt: `Act as a translation machine that converts any language input I provide into fluent, idiomatic American English. If the input is already in English, refine it to sound like native American English.
Suggestions:
Ensure that the translation maintains the original meaning and tone of the input as much as possible.
In case of English inputs, focus on enhancing clarity, grammar, and style to match American English standards.
Return the translation only, no other words needed.
`,
},
{
scenario: "vocal_coach",
autoSpeech: true,
prompt: `As an AI English vocal coach with an American accent, engage in a conversation with me to help improve my spoken English skills. Use the appropriate tone and expressions that a native American English speaker would use, keeping in mind that your responses will be converted to audio.
Suggestions:
Use common American idioms and phrases to give a more authentic experience of American English.
Provide corrections and suggestions for improvement in a supportive and encouraging manner.
Use a variety of sentence structures and vocabulary to expose me to different aspects of the language.`,
},
];

View File

@@ -86,7 +86,8 @@
"ttsVoice": "TTS voice",
"ttsBaseUrl": "TTS base URL",
"notFound": "Conversation not found",
"contentRequired": "Content required"
"contentRequired": "Content required",
"failedToGenerateResponse": "Failed to generate response"
},
"pronunciationAssessment": {
"pronunciationScore": "Pronunciation Score",
@@ -122,6 +123,7 @@
},
"sidebar": {
"home": "Home",
"community": "Community",
"audios": "Audios",
"videos": "Videos",
"stories": "Stories",
@@ -188,13 +190,27 @@
"AIModel": "AI Model",
"chooseAIModelToDownload": "Choose AI Model to download",
"ffmpegCheck": "FFmpeg Check",
"check": "Check",
"ffmpegCommandIsWorking": "FFmpeg command is working",
"ffmpegCommandIsNotWorking": "FFmpeg command is not working",
"scan": "Scan",
"checkIfFfmpegIsInstalled": "Check if FFmpeg is installed",
"ffmpegInstalled": "FFmpeg is installed",
"ffmpegNotInstalled": "FFmpeg is not installed.",
"ffmpegFoundAt": "FFmpeg found at {{path}}",
"ffmpegNotFound": "FFmpeg not found",
"ffmpegInstallSteps": "FFmpeg Install Steps",
"Install": "Install",
"runTheFollowingCommandInTerminal": "Run the following command in terminal",
"click": "Click",
"willAutomaticallyFindFFmpeg": "Enjoy will automatically find FFmpeg",
"tryingToFindValidFFmepgInTheseDirectories": "Trying to find valid FFmpeg in these directories: {{dirs}}",
"invalidFfmpegPath": "Invalid FFmpeg path",
"usingInstalledFFmpeg": "Using installed FFmpeg",
"usingDownloadedFFmpeg": "Using downloaded FFmpeg",
"downloadFfmpeg": "Download FFmpeg",
"youAreReadyToGo": "You are ready to go",
"welcomeBack": "Welcome back! {{name}}",
"download": "Download",
"downloading": "Downloading {{file}}",
"chooseAIModelDependingOnYourHardware": "Choose AI Model depending on your hardware",
"areYouSureToDownload": "Are you sure to download {{name}}?",
"yourModelsWillBeDownloadedTo": "Your models will be downloaded to {{path}}",
@@ -203,7 +219,10 @@
"reset": "Reset",
"resetAll": "Reset All",
"resetAllConfirmation": "It will remove all of your personal data, are you sure?",
"resetSettings": "Reset Settings",
"resetSettingsConfirmation": "It will reset all of your settings, are you sure? The library will not be affected.",
"logoutAndRemoveAllPersonalData": "Logout and remove all personal data",
"logoutAndRemoveAllPersonalSettings": "Logout and remove all personal settings",
"hotkeys": "Hotkeys",
"quitApp": "Quit APP",
"openPreferences": "Open preferences",
@@ -237,7 +256,7 @@
"recentlyAdded": "recently added",
"recommended": "recommended",
"resourcesRecommendedByEnjoy": "resources recommended by Enjoy Bot",
"fromCommunity": "from commnuity",
"fromCommunity": "from community",
"videoResources": "video resources",
"audioResources": "audio resources",
"seeMore": "see more",
@@ -262,8 +281,12 @@
"recordingActivity": "recording activity",
"recordingDetail": "Recording detail",
"noRecordingActivities": "no recording activities",
"basicSettings": "basic",
"advancedSettings": "advanced",
"basicSettingsShort": "Basic",
"basicSettings": "Basic settings",
"advancedSettingsShort": "Advanced",
"advancedSettings": "Advanced settings",
"advanced": "Advanced",
"language": "Language",
"sttAiModel": "STT AI model",
"relaunchIsNeededAfterChanged": "Relaunch is needed after changed",
"openaiKeySaved": "OpenAI key saved",
@@ -298,7 +321,7 @@
"score": "score",
"inputUrlToStartReading": "Input url to start reading",
"read": "read",
"add_story": "add story",
"addStory": "add story",
"context": "context",
"keyVocabulary": "key vocabulary",
"addedStories": "added stories",
@@ -320,5 +343,44 @@
"presenter": "presenter",
"downloadAudio": "Download audio",
"downloadVideo": "Download video",
"recordTooShort": "Record too short"
"recordTooShort": "Record too short",
"rankings": "Rankings",
"dayRankings": "Day rankings",
"weekRankings": "Week rankings",
"monthRankings": "Month rankings",
"allRankings": "All time rankings",
"noOneHasRecordedYet": "No one has recorded yet",
"activities": "Activities",
"square": "Square",
"noOneSharedYet": "No one shared yet",
"sharedSuccessfully": "Shared successfully",
"shareFailed": "Share failed",
"shareAudio": "Share audio",
"sharedAudio": "Shared an audio resource",
"areYouSureToShareThisAudioToCommunity": "Are you sure to share this audio to community?",
"shareVideo": "Share video",
"sharedVideo": "Shared a video resource",
"cannotShareLocalVideo": "Cannot share local video",
"areYouSureToShareThisVideoToCommunity": "Are you sure to share this video to community?",
"sharePrompt": "Share prompt",
"sharedPrompt": "Shared a prompt",
"areYouSureToShareThisPromptToCommunity": "Are you sure to share this prompt to community?",
"shareRecording": "Share recording",
"sharedRecording": "Shared a recording",
"areYouSureToShareThisRecordingToCommunity": "Are you sure to share this recording to community?",
"shareStory": "Share story",
"sharedStory": "Shared a story",
"areYouSureToShareThisStoryToCommunity": "Are you sure to share this story to community?",
"addToLibary": "Add to library",
"areYouSureToAddThisVideoToYourLibrary": "Are you sure to add this video to library?",
"areYouSureToAddThisAudioToYourLibrary": "Are you sure to add this audio to library?",
"audioAlreadyAddedToLibrary": "Audio already added to library",
"videoAlreadyAddedToLibrary": "Video already added to library",
"audioSuccessfullyAddedToLibrary": "Audio successfully added to library",
"videoSuccessfullyAddedToLibrary": "Video successfully added to library",
"sendToAIAssistant": "Send to AI assistant",
"removeSharing": "Remove sharing",
"areYouSureToRemoveThisSharing": "Are you sure to remove this sharing?",
"removeSharingSuccessfully": "Remove sharing successfully",
"removeSharingFailed": "Remove sharing failed"
}

View File

@@ -86,7 +86,8 @@
"ttsVoice": "TTS 声音",
"ttsBaseUrl": "TTS 请求地址",
"notFound": "未找到对话",
"contentRequired": "对话内容不能为空"
"contentRequired": "对话内容不能为空",
"failedToGenerateResponse": "生成失败"
},
"pronunciationAssessment": {
"pronunciationScore": "发音得分",
@@ -122,6 +123,7 @@
},
"sidebar": {
"home": "主页",
"community": "社区",
"audios": "音频",
"videos": "视频",
"stories": "文章",
@@ -188,13 +190,27 @@
"AIModel": "AI 模型",
"chooseAIModelToDownload": "选择 AI 模型下载",
"ffmpegCheck": "FFmpeg 检查",
"check": "检查",
"ffmpegCommandIsWorking": "FFmpeg 命令正常工作",
"ffmpegCommandIsNotWorking": "FFmpeg 命令无法正常工作",
"scan": "查找",
"checkIfFfmpegIsInstalled": "检查 FFmpeg 是否已正确安装",
"ffmpegInstalled": "FFmpeg 已经安装",
"ffmpegNotInstalled": "FFmpeg 未安装,软件部分功能依赖于 FFmpeg",
"ffmpegFoundAt": "检测到 FFmpeg 命令: {{path}}",
"ffmpegNotFound": "未检测到可用的 FFmpeg 命令",
"ffmpegInstallSteps": "FFmpeg 安装步骤",
"Install": "安装",
"runTheFollowingCommandInTerminal": "在终端中运行以下命令",
"click": "点击",
"willAutomaticallyFindFFmpeg": "Enjoy 将自动检测 FFmpeg 命令",
"tryingToFindValidFFmepgInTheseDirectories": "正在尝试在以下目录中查找有效的 FFmpeg 命令: {{dirs}}",
"invalidFfmpegPath": "无效的 FFmpeg 路径",
"usingInstalledFFmpeg": "使用已安装的 FFmpeg",
"usingDownloadedFFmpeg": "使用下载的 FFmpeg",
"downloadFfmpeg": "下载 FFmpeg",
"youAreReadyToGo": "您已准备就绪",
"welcomeBack": "欢迎回来, {{name}}",
"download": "下载",
"downloading": "正在下载 {{file}}",
"chooseAIModelDependingOnYourHardware": "根据您的硬件选择合适的 AI 模型",
"areYouSureToDownload": "您确定要下载 {{name}} 吗?",
"yourModelsWillBeDownloadedTo": "您的模型将下载到目录 {{path}}",
@@ -203,7 +219,10 @@
"reset": "重置",
"resetAll": "重置所有",
"resetAllConfirmation": "这将删除您的所有个人数据, 您确定要重置吗?",
"resetSettings": "重置设置选项",
"resetSettingsConfirmation": "您确定要重置个人设置选项吗?资料库不会受影响。",
"logoutAndRemoveAllPersonalData": "退出登录并删除所有个人数据",
"logoutAndRemoveAllPersonalSettings": "退出登录并删除所有个人设置选项",
"hotkeys": "快捷键",
"quitApp": "退出应用",
"openPreferences": "打开设置",
@@ -262,8 +281,11 @@
"recordingActivity": "练习活动",
"recordingDetail": "录音详情",
"noRecordingActivities": "没有练习活动",
"basicSettingsShort": "基本设置",
"basicSettings": "基本设置",
"advancedSettingsShort": "高级设置",
"advancedSettings": "高级设置",
"language": "语言",
"sttAiModel": "语音转文本 AI 模型",
"relaunchIsNeededAfterChanged": "更改后需要重新启动",
"openaiKeySaved": "OpenAI 密钥已保存",
@@ -298,7 +320,7 @@
"score": "得分",
"inputUrlToStartReading": "输入 URL 开始阅读",
"read": "阅读",
"add_story": "添加文章",
"addStory": "添加文章",
"context": "原文",
"keyVocabulary": "关键词汇",
"addedStories": "添加的文章",
@@ -320,5 +342,44 @@
"presenter": "讲者",
"downloadAudio": "下载音频",
"downloadVideo": "下载视频",
"recordTooShort": "录音时长太短"
"recordTooShort": "录音时长太短",
"rankings": "排行榜",
"dayRankings": "日排行榜",
"weekRankings": "周排行榜",
"monthRankings": "月排行榜",
"allRankings": "总排行榜",
"noOneHasRecordedYet": "还没有人练习",
"activities": "动态",
"square": "广场",
"noOneSharedYet": "还没有人分享",
"sharedSuccessfully": "分享成功",
"sharedFailed": "分享失败",
"shareAudio": "分享音频",
"sharedAudio": "分享了一个音频材料",
"areYouSureToShareThisAudioToCommunity": "您确定要分享此音频到社区吗?",
"shareVideo": "分享视频",
"sharedVideo": "分享了一个视频材料",
"cannotShareLocalVideo": "无法分享本地视频",
"areYouSureToShareThisVideoToCommunity": "您确定要分享此视频到社区吗?",
"sharePrompt": "分享提示语",
"sharedPrompt": "分享了一条提示语",
"areYouSureToShareThisPromptToCommunity": "您确定要分享此提示语到社区吗?",
"shareRecording": "分享录音",
"sharedRecording": "分享了一条录音",
"areYouSureToShareThisRecordingToCommunity": "您确定要分享此录音到社区吗?",
"shareStory": "分享文章",
"sharedStory": "分享了一篇文章",
"areYouSureToShareThisStoryToCommunity": "您确定要分享此文章到社区吗?",
"addToLibary": "添加到资源库",
"areYouSureToAddThisVideoToYourLibrary": "您确定要添加此视频到资料库吗?",
"areYouSureToAddThisAudioToYourLibrary": "您确定要添加此音频到资料库吗?",
"audioAlreadyAddedToLibrary": "资料库已经存在此音频",
"videoAlreadyAddedToLibrary": "资料库已经存在此视频",
"audioSuccessfullyAddedToLibrary": "音频成功添加到资料库",
"videoSuccessfullyAddedToLibrary": "视频成功添加到资料库",
"sendToAIAssistant": "发送到智能助手",
"removeSharing": "取消分享",
"areYouSureToRemoveThisSharing": "您确定要取消分享吗?",
"removeSharingSuccessfully": "取消分享成功",
"removeSharingFailed": "取消分享失败"
}

View File

@@ -90,27 +90,29 @@ class AudiosHandler {
private async create(
event: IpcMainEvent,
source: string,
uri: string,
params: {
name?: string;
coverUrl?: string;
} = {}
) {
let file = source;
if (source.startsWith("http")) {
let file = uri;
let source;
if (uri.startsWith("http")) {
try {
if (youtubedr.validateYtURL(source)) {
file = await youtubedr.autoDownload(source);
if (youtubedr.validateYtURL(uri)) {
file = await youtubedr.autoDownload(uri);
} else {
file = await downloader.download(source, {
file = await downloader.download(uri, {
webContents: event.sender,
});
}
if (!file) throw new Error("Failed to download file");
source = uri;
} catch (err) {
return event.sender.send("on-notification", {
type: "error",
message: t("models.audio.failedToDownloadFile", { file: source }),
message: t("models.audio.failedToDownloadFile", { file: uri }),
});
}
}

View File

@@ -1,5 +1,6 @@
import { ipcMain, IpcMainEvent } from "electron";
import { CacheObject } from "@main/db/models";
import db from "@main/db";
class CacheObjectsHandler {
private async get(event: IpcMainEvent, key: string) {
@@ -49,6 +50,7 @@ class CacheObjectsHandler {
private async clear(event: IpcMainEvent) {
return CacheObject.destroy({ where: {} })
.then(() => {
db.connection.query("VACUUM");
return;
})
.catch((err) => {

View File

@@ -90,27 +90,30 @@ class VideosHandler {
private async create(
event: IpcMainEvent,
source: string,
uri: string,
params: {
name?: string;
coverUrl?: string;
md5?: string;
} = {}
) {
let file = source;
if (source.startsWith("http")) {
let file = uri;
let source;
if (uri.startsWith("http")) {
try {
if (youtubedr.validateYtURL(source)) {
file = await youtubedr.autoDownload(source);
if (youtubedr.validateYtURL(uri)) {
file = await youtubedr.autoDownload(uri);
} else {
file = await downloader.download(source, {
file = await downloader.download(uri, {
webContents: event.sender,
});
}
if (!file) throw new Error("Failed to download file");
source = uri;
} catch (err) {
return event.sender.send("on-notification", {
type: "error",
message: t("models.video.failedToDownloadFile", { file: source }),
message: t("models.video.failedToDownloadFile", { file: uri }),
});
}
}

View File

@@ -51,9 +51,6 @@ db.connect = async () => {
db.connection = sequelize;
// vacuum the database
await sequelize.query("VACUUM");
const umzug = new Umzug({
migrations: { glob: __dirname + "/migrations/*.js" },
context: sequelize.getQueryInterface(),
@@ -68,6 +65,23 @@ db.connect = async () => {
await sequelize.sync();
await sequelize.authenticate();
// TODO:
// clear the large waveform data in DB.
// Remove this in next release
const caches = await CacheObject.findAll({
attributes: ["id", "key"],
});
const cacheIds: string[] = [];
caches.forEach((cache) => {
if (cache.key.startsWith("waveform")) {
cacheIds.push(cache.id);
}
});
await CacheObject.destroy({ where: { id: cacheIds } });
// vacuum the database
await sequelize.query("VACUUM");
// register handlers
audiosHandler.register();
cacheObjectsHandler.register();

View File

@@ -25,13 +25,21 @@ import mainWindow from "@main/window";
import log from "electron-log/main";
import storage from "@main/storage";
import Ffmpeg from "@main/ffmpeg";
import webApi from "@main/web-api";
import { Client } from "@/api";
import { WEB_API_URL } from "@/constants";
import { startCase } from "lodash";
import { v5 as uuidv5 } from "uuid";
const SIZE_LIMIT = 1024 * 1024 * 50; // 50MB
const logger = log.scope("db/models/audio");
const webApi = new Client({
baseUrl: process.env.WEB_API_URL || WEB_API_URL,
accessToken: settings.getSync("user.accessToken") as string,
logger: log.scope("api/client"),
});
@Table({
modelName: "Audio",
tableName: "audios",

View File

@@ -13,7 +13,7 @@ import {
AllowNull,
} from "sequelize-typescript";
import { Message, Speech } from "@main/db/models";
import { ChatMessageHistory , BufferMemory } from "langchain/memory";
import { ChatMessageHistory, BufferMemory } from "langchain/memory";
import { ConversationChain } from "langchain/chains";
import { ChatOpenAI } from "langchain/chat_models/openai";
import { ChatOllama } from "langchain/chat_models/ollama";
@@ -267,13 +267,14 @@ export class Conversation extends Model<Conversation> {
const chain = await this.chain();
let response: Generation[] = [];
await chain.call({ input: content }, [
const result = await chain.call({ input: content }, [
{
handleLLMEnd: async (output) => {
response = output.generations[0];
},
},
]);
logger.debug("LLM result:", result);
if (!response) {
throw new Error(t("models.conversation.failedToGenerateResponse"));
@@ -294,9 +295,9 @@ export class Conversation extends Model<Conversation> {
}
);
await Promise.all(
const replies = await Promise.all(
response.map(async (generation) => {
await Message.create(
return await Message.create(
{
conversationId: this.id,
role: "assistant",
@@ -330,5 +331,7 @@ export class Conversation extends Model<Conversation> {
}
await transaction.commit();
return replies.map((reply) => reply.toJSON());
}
}

View File

@@ -14,7 +14,16 @@ import {
} from "sequelize-typescript";
import mainWindow from "@main/window";
import { Recording } from "@main/db/models";
import webApi from "@main/web-api";
import { Client } from "@/api";
import { WEB_API_URL } from "@/constants";
import settings from "@main/settings";
import log from "electron-log/main";
const webApi = new Client({
baseUrl: process.env.WEB_API_URL || WEB_API_URL,
accessToken: settings.getSync("user.accessToken") as string,
logger: log.scope("api/client"),
});
@Table({
modelName: "PronunciationAssessment",

View File

@@ -23,12 +23,19 @@ import { hashFile } from "@/utils";
import log from "electron-log/main";
import storage from "@main/storage";
import Ffmpeg from "@main/ffmpeg";
import webApi from "@main/web-api";
import { Client } from "@/api";
import { WEB_API_URL } from "@/constants";
import { AzureSpeechSdk } from "@main/azure-speech-sdk";
import camelcaseKeys from "camelcase-keys";
const logger = log.scope("db/models/recording");
const webApi = new Client({
baseUrl: process.env.WEB_API_URL || WEB_API_URL,
accessToken: settings.getSync("user.accessToken") as string,
logger: log.scope("api/client"),
});
@Table({
modelName: "Recording",
tableName: "recordings",
@@ -36,7 +43,7 @@ const logger = log.scope("db/models/recording");
timestamps: true,
})
export class Recording extends Model<Recording> {
@IsUUID('all')
@IsUUID("all")
@Default(DataType.UUIDV4)
@Column({ primaryKey: true, type: DataType.UUID })
id: string;

View File

@@ -2,6 +2,7 @@ import {
AfterCreate,
AfterUpdate,
AfterDestroy,
AfterFind,
BelongsTo,
Table,
Column,
@@ -15,9 +16,17 @@ import { Audio, Video } from "@main/db/models";
import whisper from "@main/whisper";
import mainWindow from "@main/window";
import log from "electron-log/main";
import webApi from "@main/web-api";
import { Client } from "@/api";
import { WEB_API_URL, PROCESS_TIMEOUT } from "@/constants";
import settings from "@main/settings";
const logger = log.scope("db/models/transcription");
const webApi = new Client({
baseUrl: process.env.WEB_API_URL || WEB_API_URL,
accessToken: settings.getSync("user.accessToken") as string,
logger: log.scope("api/client"),
});
@Table({
modelName: "Transcription",
tableName: "transcriptions",
@@ -25,7 +34,7 @@ const logger = log.scope("db/models/transcription");
timestamps: true,
})
export class Transcription extends Model<Transcription> {
@IsUUID('all')
@IsUUID("all")
@Default(DataType.UUIDV4)
@Column({ primaryKey: true, type: DataType.UUID })
id: string;
@@ -146,6 +155,23 @@ export class Transcription extends Model<Transcription> {
this.notify(transcription, "destroy");
}
@AfterFind
static expireProcessingState(transcription: Transcription) {
if (transcription?.state !== "processing") return;
if (transcription.updatedAt.getTime() + PROCESS_TIMEOUT < Date.now()) {
if (transcription.result) {
transcription.update({
state: "finished",
});
} else {
transcription.update({
state: "pending",
});
}
}
}
static notify(
transcription: Transcription,
action: "create" | "update" | "destroy"

View File

@@ -25,13 +25,21 @@ import mainWindow from "@main/window";
import log from "electron-log/main";
import storage from "@main/storage";
import Ffmpeg from "@main/ffmpeg";
import webApi from "@main/web-api";
import { Client } from "@/api";
import { WEB_API_URL } from "@/constants";
import { startCase } from "lodash";
import { v5 as uuidv5 } from "uuid";
const SIZE_LIMIT = 1024 * 1024 * 100; // 100MB
const logger = log.scope("db/models/video");
const webApi = new Client({
baseUrl: process.env.WEB_API_URL || WEB_API_URL,
accessToken: settings.getSync("user.accessToken") as string,
logger: log.scope("api/client"),
});
@Table({
modelName: "Video",
tableName: "videos",

View File

@@ -7,26 +7,47 @@ import fs from "fs-extra";
import AdmZip from "adm-zip";
import downloader from "@main/downloader";
import storage from "@main/storage";
import readdirp from "readdirp";
import { t } from "i18next";
const logger = log.scope("ffmepg");
const logger = log.scope("ffmpeg");
export default class FfmpegWrapper {
public ffmpeg: Ffmpeg.FfmpegCommand;
public config: any;
constructor() {
const config = settings.ffmpegConfig();
constructor(config?: {
ffmpegPath: string;
ffprobePath: string;
commandExists?: boolean;
}) {
this.config = config || settings.ffmpegConfig();
if (config.commandExists) {
if (this.config.commandExists) {
logger.info("Using system ffmpeg");
this.ffmpeg = Ffmpeg();
} else {
logger.info("Using downloaded ffmpeg");
const ff = Ffmpeg();
ff.setFfmpegPath(config.ffmpegPath);
ff.setFfprobePath(config.ffprobePath);
ff.setFfmpegPath(this.config.ffmpegPath);
ff.setFfprobePath(this.config.ffprobePath);
this.ffmpeg = ff;
}
}
checkCommand(): Promise<boolean> {
return new Promise((resolve, _reject) => {
this.ffmpeg.getAvailableFormats((err, formats) => {
if (err) {
logger.error("Command not valid:", err);
resolve(false);
} else {
logger.info("Command valid, available formats:", formats);
resolve(true);
}
});
});
}
generateMetadata(input: string): Promise<Ffmpeg.FfprobeData> {
return new Promise((resolve, reject) => {
this.ffmpeg
@@ -292,9 +313,118 @@ export class FfmpegDownloader {
logger.error(err);
event.sender.send("on-notification", {
type: "error",
title: `FFmpeg download failed: ${err.message}`,
message: `FFmpeg download failed: ${err.message}`,
});
}
});
ipcMain.handle("ffmpeg-check-command", async (event) => {
const ffmpeg = new FfmpegWrapper();
const valid = await ffmpeg.checkCommand();
if (valid) {
event.sender.send("on-notification", {
type: "success",
message: t("ffmpegCommandIsWorking"),
});
} else {
logger.error("FFmpeg command not valid", ffmpeg.config);
event.sender.send("on-notification", {
type: "warning",
message: t("ffmpegCommandIsNotWorking"),
});
}
return valid;
});
ipcMain.handle("ffmpeg-discover-command", async (event) => {
try {
return await discoverFfmpeg();
} catch (err) {
logger.error(err);
event.sender.send("on-notification", {
type: "error",
message: `FFmpeg discover failed: ${err.message}`,
});
}
});
}
}
export const discoverFfmpeg = async () => {
const platform = process.platform;
let ffmpegPath: string;
let ffprobePath: string;
const libraryFfmpegPath = path.join(settings.libraryPath(), "ffmpeg");
const scanDirs = [...COMMAND_SCAN_DIR[platform], libraryFfmpegPath];
await Promise.all(
scanDirs.map(async (dir: string) => {
if (!fs.existsSync(dir)) return;
dir = path.resolve(dir);
log.info("FFmpeg scanning: " + dir);
const fileStream = readdirp(dir, {
depth: 3,
});
for await (const entry of fileStream) {
const appName = entry.basename
.replace(".app", "")
.replace(".exe", "")
.toLowerCase();
if (appName === "ffmpeg") {
logger.info("Found ffmpeg: ", entry.fullPath);
ffmpegPath = entry.fullPath;
}
if (appName === "ffprobe") {
logger.info("Found ffprobe: ", entry.fullPath);
ffprobePath = entry.fullPath;
}
if (ffmpegPath && ffprobePath) break;
}
})
);
let valid = false;
if (ffmpegPath && ffprobePath) {
const ffmepg = new FfmpegWrapper({ ffmpegPath, ffprobePath });
valid = await ffmepg.checkCommand();
}
if (valid) {
settings.setSync("ffmpeg", {
ffmpegPath,
ffprobePath,
});
} else {
ffmpegPath = undefined;
ffprobePath = undefined;
settings.setSync("ffmpeg", null);
}
return {
ffmpegPath,
ffprobePath,
scanDirs,
};
};
export const COMMAND_SCAN_DIR: { [key: string]: string[] } = {
darwin: [
"/Applications",
process.env.HOME + "/Applications",
"/opt/homebrew/bin",
],
linux: ["/usr/bin", "/usr/local/bin", "/snap/bin"],
win32: [
process.env.SystemDrive + "\\Program Files\\",
process.env.SystemDrive + "\\Program Files (x86)\\",
process.env.LOCALAPPDATA + "\\Apps\\2.0\\",
],
};

View File

@@ -1,6 +1,7 @@
import * as i18n from "i18next";
import en from "@/i18n/en.json";
import zh_CN from "@/i18n/zh-CN.json";
import settings from "@main/settings";
const resources = {
en: {
@@ -13,7 +14,9 @@ const resources = {
i18n.init({
resources,
lng: "zh-CN",
lng: settings.language(),
supportedLngs: ["en", "zh-CN"],
fallbackLng: "en",
interpolation: {
escapeValue: false, // react already safes from xss
},

View File

@@ -6,9 +6,25 @@ import fs from "fs-extra";
import os from "os";
import commandExists from "command-exists";
import log from "electron-log";
import * as i18n from "i18next";
const logger = log.scope("settings");
const language = () => {
const _language = settings.getSync("language");
if (!_language || typeof _language !== "string") {
settings.setSync("language", "en");
}
return settings.getSync("language") as string;
};
const switchLanguage = (language: string) => {
settings.setSync("language", language);
i18n.changeLanguage(language);
};
const libraryPath = () => {
const _library = settings.getSync("library");
@@ -80,32 +96,19 @@ const userDataPath = () => {
};
const ffmpegConfig = () => {
const _ffmpegPath = path.join(
libraryPath(),
"ffmpeg",
os.platform() === "win32" ? "ffmpeg.exe" : "ffmpeg"
);
const _ffprobePath = path.join(
libraryPath(),
"ffmpeg",
os.platform() === "win32" ? "ffprobe.exe" : "ffprobe"
);
const ffmpegPath = fs.existsSync(_ffmpegPath) ? _ffmpegPath : "";
const ffprobePath = fs.existsSync(_ffprobePath) ? _ffprobePath : "";
const ffmpegPath = settings.getSync("ffmpeg.ffmpegPath");
const ffprobePath = settings.getSync("ffmpeg.ffprobePath");
const _commandExists =
commandExists.sync("ffmpeg") && commandExists.sync("ffprobe");
const ready = Boolean(_commandExists || (ffmpegPath && ffprobePath));
const config = {
os: os.platform(),
arch: os.arch(),
commandExists: _commandExists,
ffmpegPath,
ffprobePath,
ready,
ready: Boolean(_commandExists || (ffmpegPath && ffprobePath)),
};
logger.info("ffmpeg config", config);
@@ -178,6 +181,14 @@ export default {
settings.setSync("ffmpeg.ffmpegPath", config.ffmpegPath);
settings.setSync("ffmpeg.ffprobePath", config.ffrobePath);
});
ipcMain.handle("settings-get-language", (_event) => {
return language();
});
ipcMain.handle("settings-switch-language", (_event, language) => {
switchLanguage(language);
});
},
cachePath,
libraryPath,
@@ -188,5 +199,7 @@ export default {
userDataPath,
dbPath,
ffmpegConfig,
language,
switchLanguage,
...settings,
};

View File

@@ -0,0 +1,38 @@
import { ipcMain } from "electron";
import settings from "@main/settings";
import path from "path";
import fs from "fs-extra";
export class Waveform {
public dir = path.join(settings.libraryPath(), "waveforms");
constructor() {
fs.ensureDirSync(this.dir);
}
find(id: string) {
const file = path.join(this.dir, id + ".waveform.json");
if (fs.existsSync(file)) {
return fs.readJsonSync(file);
} else {
return null;
}
}
save(id: string, data: WaveFormDataType) {
const file = path.join(this.dir, id + ".waveform.json");
fs.writeJsonSync(file, data);
}
registerIpcHandlers() {
ipcMain.handle("waveforms-find", async (_event, id) => {
return this.find(id);
});
ipcMain.handle("waveforms-save", (_event, id, data) => {
return this.save(id, data);
});
}
}

View File

@@ -1,382 +0,0 @@
import { ipcMain } from "electron";
import axios, { AxiosInstance } from "axios";
import { WEB_API_URL } from "@/constants";
import settings from "@main/settings";
import log from "electron-log/main";
import decamelizeKeys from "decamelize-keys";
import camelcaseKeys from "camelcase-keys";
const logger = log.scope("web-api");
const ONE_MINUTE = 1000 * 60; // 1 minute
class WebApi {
public api: AxiosInstance;
constructor() {
this.api = axios.create({
baseURL: process.env.WEB_API_URL || WEB_API_URL,
timeout: ONE_MINUTE,
headers: {
"Content-Type": "application/json",
},
});
this.api.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${settings.getSync(
"user.accessToken"
)}`;
logger.info(
config.method.toUpperCase(),
config.baseURL + config.url,
config.data,
config.params
);
return config;
});
this.api.interceptors.response.use(
(response) => {
logger.info(
response.status,
response.config.method.toUpperCase(),
response.config.baseURL + response.config.url
);
return camelcaseKeys(response.data, { deep: true });
},
(err) => {
if (err.response) {
logger.error(
err.response.status,
err.response.config.method.toUpperCase(),
err.response.config.baseURL + err.response.config.url
);
logger.error(err.response.data);
return Promise.reject(err.response.data);
}
if (err.request) {
logger.error(err.request);
} else {
logger.error(err.message);
}
return Promise.reject(err);
}
);
}
me() {
return this.api.get("/api/me");
}
auth(params: { provider: string; code: string }): Promise<UserType> {
return this.api.post("/api/sessions", decamelizeKeys(params));
}
syncAudio(audio: Partial<AudioType>) {
return this.api.post("/api/mine/audios", decamelizeKeys(audio));
}
syncVideo(video: Partial<VideoType>) {
return this.api.post("/api/mine/videos", decamelizeKeys(video));
}
syncTranscription(transcription: Partial<TranscriptionType>) {
return this.api.post("/api/transcriptions", decamelizeKeys(transcription));
}
syncRecording(recording: Partial<RecordingType>) {
if (!recording) return;
return this.api.post("/api/mine/recordings", decamelizeKeys(recording));
}
generateSpeechToken(): Promise<{ token: string; region: string }> {
return this.api.post("/api/speech/tokens");
}
syncPronunciationAssessment(
pronunciationAssessment: Partial<PronunciationAssessmentType>
) {
if (!pronunciationAssessment) return;
return this.api.post(
"/api/mine/pronunciation_assessments",
decamelizeKeys(pronunciationAssessment)
);
}
recordingAssessment(id: string) {
return this.api.get(`/api/mine/recordings/${id}/assessment`);
}
lookup(params: {
word: string;
context: string;
sourceId?: string;
sourceType?: string;
}): Promise<LookupType> {
return this.api.post("/api/lookups", decamelizeKeys(params));
}
lookupInBatch(
lookups: {
word: string;
context: string;
sourceId?: string;
sourceType?: string;
}[]
): Promise<{ successCount: number; total: number }> {
return this.api.post("/api/lookups/batch", {
lookups: decamelizeKeys(lookups, { deep: true }),
});
}
extractVocabularyFromStory(storyId: string): Promise<string[]> {
return this.api.post(`/api/stories/${storyId}/extract_vocabulary`);
}
storyMeanings(
storyId: string,
params?: {
page?: number;
items?: number;
storyId?: string;
}
): Promise<
{
meanings: MeaningType[];
} & PagyResponseType
> {
return this.api.get(`/api/stories/${storyId}/meanings`, {
params: decamelizeKeys(params),
});
}
mineMeanings(params?: {
page?: number;
items?: number;
sourceId?: string;
sourceType?: string;
status?: string;
}): Promise<
{
meanings: MeaningType[];
} & PagyResponseType
> {
return this.api.get("/api/mine/meanings", {
params: decamelizeKeys(params),
});
}
createStory(params: CreateStoryParamsType): Promise<StoryType> {
return this.api.post("/api/stories", decamelizeKeys(params));
}
story(id: string): Promise<StoryType> {
return this.api.get(`/api/stories/${id}`);
}
stories(params?: { page: number }): Promise<
{
stories: StoryType[];
} & PagyResponseType
> {
return this.api.get("/api/stories", { params: decamelizeKeys(params) });
}
mineStories(params?: { page: number }): Promise<
{
stories: StoryType[];
} & PagyResponseType
> {
return this.api.get("/api/mine/stories", {
params: decamelizeKeys(params),
});
}
starStory(storyId: string) {
return this.api.post(`/api/mine/stories`, decamelizeKeys({ storyId }));
}
unstarStory(storyId: string) {
return this.api.delete(`/api/mine/stories/${storyId}`);
}
registerIpcHandlers() {
ipcMain.handle("web-api-auth", async (event, params) => {
return this.auth(params)
.then((user) => {
return user;
})
.catch((error) => {
event.sender.send("on-notification", {
type: "error",
message: error.message,
});
});
});
ipcMain.handle("web-api-me", async (event) => {
return this.me()
.then((user) => {
return user;
})
.catch((error) => {
event.sender.send("on-notification", {
type: "error",
message: error.message,
});
});
});
ipcMain.handle("web-api-lookup", async (event, params) => {
return this.lookup(params)
.then((response) => {
return response;
})
.catch((error) => {
event.sender.send("on-notification", {
type: "error",
message: error.message,
});
});
});
ipcMain.handle("web-api-lookup-in-batch", async (event, params) => {
return this.lookupInBatch(params)
.then((response) => {
return response;
})
.catch((error) => {
event.sender.send("on-notification", {
type: "error",
message: error.message,
});
});
});
ipcMain.handle("web-api-mine-meanings", async (event, params) => {
return this.mineMeanings(params)
.then((response) => {
return response;
})
.catch((error) => {
event.sender.send("on-notification", {
type: "error",
message: error.message,
});
});
});
ipcMain.handle("web-api-create-story", async (event, params) => {
return this.createStory(params)
.then((response) => {
return response;
})
.catch((error) => {
event.sender.send("on-notification", {
type: "error",
message: error.message,
});
});
});
ipcMain.handle(
"web-api-extract-vocabulary-from-story",
async (event, storyId) => {
return this.extractVocabularyFromStory(storyId)
.then((response) => {
return response;
})
.catch((error) => {
event.sender.send("on-notification", {
type: "error",
message: error.message,
});
});
}
);
ipcMain.handle(
"web-api-story-meanings",
async (event, storyId, params) => {
return this.storyMeanings(storyId, params)
.then((response) => {
return response;
})
.catch((error) => {
event.sender.send("on-notification", {
type: "error",
message: error.message,
});
});
}
);
ipcMain.handle("web-api-stories", async (event, params) => {
return this.stories(params)
.then((response) => {
return response;
})
.catch((error) => {
event.sender.send("on-notification", {
type: "error",
message: error.message,
});
});
});
ipcMain.handle("web-api-story", async (event, id) => {
return this.story(id)
.then((response) => {
return response;
})
.catch((error) => {
event.sender.send("on-notification", {
type: "error",
message: error.message,
});
});
});
ipcMain.handle("web-api-mine-stories", async (event, params) => {
return this.mineStories(params)
.then((response) => {
return response;
})
.catch((error) => {
event.sender.send("on-notification", {
type: "error",
message: error.message,
});
});
});
ipcMain.handle("web-api-star-story", async (event, id) => {
return this.starStory(id)
.then((response) => {
return response;
})
.catch((error) => {
event.sender.send("on-notification", {
type: "error",
message: error.message,
});
});
});
ipcMain.handle("web-api-unstar-story", async (event, id) => {
return this.unstarStory(id)
.then((response) => {
return response;
})
.catch((error) => {
event.sender.send("on-notification", {
type: "error",
message: error.message,
});
});
});
}
}
export default new WebApi();

View File

@@ -14,11 +14,11 @@ import downloader from "@main/downloader";
import whisper from "@main/whisper";
import fs from "fs-extra";
import "@main/i18n";
import webApi from "@main/web-api";
import log from "electron-log/main";
import { WEB_API_URL } from "@/constants";
import { AudibleProvider, TedProvider } from "@main/providers";
import { FfmpegDownloader } from "@main/ffmpeg";
import { Waveform } from "./waveform";
log.initialize({ preload: true });
const logger = log.scope("window");
@@ -26,6 +26,7 @@ const logger = log.scope("window");
const audibleProvider = new AudibleProvider();
const tedProvider = new TedProvider();
const ffmpegDownloader = new FfmpegDownloader();
const waveform = new Waveform();
const main = {
win: null as BrowserWindow | null,
@@ -38,8 +39,6 @@ main.init = () => {
return;
}
webApi.registerIpcHandlers();
// Prepare local database
db.registerIpcHandlers();
@@ -49,6 +48,9 @@ main.init = () => {
// Whisper
whisper.registerIpcHandlers();
// Waveform
waveform.registerIpcHandlers();
// Downloader
downloader.registerIpcHandlers();
@@ -219,6 +221,14 @@ main.init = () => {
// App options
ipcMain.handle("app-reset", () => {
fs.removeSync(settings.userDataPath());
fs.removeSync(settings.file());
app.relaunch();
app.exit();
});
ipcMain.handle("app-reset-settings", () => {
fs.removeSync(settings.file());
app.relaunch();
app.exit();

View File

@@ -204,7 +204,7 @@ class Youtubedr {
this.getYtVideoId(url);
return true;
} catch (error) {
console.error(error);
logger.warn(error);
return false;
}
};

View File

@@ -8,6 +8,9 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
reset: () => {
ipcRenderer.invoke("app-reset");
},
resetSettings: () => {
ipcRenderer.invoke("app-reset-settings");
},
relaunch: () => {
ipcRenderer.invoke("app-relaunch");
},
@@ -143,6 +146,15 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
getFfmpegConfig: () => {
return ipcRenderer.invoke("settings-get-ffmpeg-config");
},
setFfmpegConfig: (config: FfmpegConfigType) => {
return ipcRenderer.invoke("settings-set-ffmpeg-config", config);
},
getLanguage: (language: string) => {
return ipcRenderer.invoke("settings-get-language", language);
},
switchLanguage: (language: string) => {
return ipcRenderer.invoke("settings-switch-language", language);
},
},
path: {
join: (...paths: string[]) => {
@@ -175,8 +187,8 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
findOne: (params: object) => {
return ipcRenderer.invoke("audios-find-one", params);
},
create: (source: string, params?: object) => {
return ipcRenderer.invoke("audios-create", source, params);
create: (uri: string, params?: object) => {
return ipcRenderer.invoke("audios-create", uri, params);
},
update: (id: string, params: object) => {
return ipcRenderer.invoke("audios-update", id, params);
@@ -201,8 +213,8 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
findOne: (params: object) => {
return ipcRenderer.invoke("videos-find-one", params);
},
create: (source: string, params?: object) => {
return ipcRenderer.invoke("videos-create", source, params);
create: (uri: string, params?: object) => {
return ipcRenderer.invoke("videos-create", uri, params);
},
update: (id: string, params: object) => {
return ipcRenderer.invoke("videos-update", id, params);
@@ -338,6 +350,12 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
download: () => {
return ipcRenderer.invoke("ffmpeg-download");
},
discover: () => {
return ipcRenderer.invoke("ffmpeg-discover-command");
},
check: () => {
return ipcRenderer.invoke("ffmpeg-check-command");
},
},
download: {
onState: (
@@ -356,50 +374,6 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
ipcRenderer.removeAllListeners("download-on-error");
},
},
webApi: {
auth: (params: object) => {
return ipcRenderer.invoke("web-api-auth", params);
},
me: () => {
return ipcRenderer.invoke("web-api-me");
},
lookup: (params: object) => {
return ipcRenderer.invoke("web-api-lookup", params);
},
lookupInBatch: (params: object[]) => {
return ipcRenderer.invoke("web-api-lookup-in-batch", params);
},
createStory: (params: object) => {
return ipcRenderer.invoke("web-api-create-story", params);
},
starStory: (storyId: string) => {
return ipcRenderer.invoke("web-api-star-story", storyId);
},
unstarStory: (storyId: string) => {
return ipcRenderer.invoke("web-api-unstar-story", storyId);
},
extractVocabularyFromStory: (storyId: string) => {
return ipcRenderer.invoke(
"web-api-extract-vocabulary-from-story",
storyId
);
},
storyMeanings: (storyId: string, params: object) => {
return ipcRenderer.invoke("web-api-story-meanings", storyId, params);
},
story: (id: string) => {
return ipcRenderer.invoke("web-api-story", id);
},
stories: (params: object) => {
return ipcRenderer.invoke("web-api-stories", params);
},
mineStories: (params: object) => {
return ipcRenderer.invoke("web-api-mine-stories", params);
},
mineMeanings: (params: object) => {
return ipcRenderer.invoke("web-api-mine-meanings", params);
},
},
cacheObjects: {
get: (key: string) => {
return ipcRenderer.invoke("cache-objects-get", key);
@@ -425,4 +399,12 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
return ipcRenderer.invoke("transcriptions-update", id, params);
},
},
waveforms: {
find: (id: string) => {
return ipcRenderer.invoke("waveforms-find", id);
},
save: (id: string, data: WaveFormDataType) => {
return ipcRenderer.invoke("waveforms-save", id, data);
},
}
});

View File

@@ -6,19 +6,29 @@ import {
} from "@renderer/context";
import router from "./router";
import { RouterProvider } from "react-router-dom";
import { Toaster, useToast } from "@renderer/components/ui";
import { t } from "i18next";
import { Toaster, toast } from "@renderer/components/ui";
import { Tooltip } from "react-tooltip";
import { useHotkeys } from "react-hotkeys-hook";
function App() {
const { toast } = useToast();
window.__ENJOY_APP__.onNotification((_event, notification) => {
toast({
title: t(notification.type),
description: notification.message,
variant: notification.type === "error" ? "destructive" : "default",
});
switch (notification.type) {
case "success":
toast.success(notification.message);
break;
case "error":
toast.error(notification.message);
break;
case "info":
toast.info(notification.message);
break;
case "warning":
toast.warning(notification.message);
break;
default:
toast.message(notification.message);
break;
}
});
const ControlOrCommand = navigator.platform.includes("Mac")
@@ -43,7 +53,7 @@ function App() {
<AISettingsProvider>
<DbProvider>
<RouterProvider router={router} />
<Toaster />
<Toaster richColors closeButton position="top-center" />
<Tooltip id="global-tooltip" />
</DbProvider>
</AISettingsProvider>

View File

@@ -2,7 +2,7 @@ import { Link } from "react-router-dom";
import { cn } from "@renderer/lib/utils";
export const AudioCard = (props: {
audio: AudioType;
audio: Partial<AudioType>;
className?: string;
}) => {
const { audio, className } = props;

View File

@@ -11,16 +11,29 @@ import {
MediaTranscription,
} from "@renderer/components";
import { LoaderIcon } from "lucide-react";
import { ScrollArea } from "@renderer/components/ui";
import {
AlertDialog,
AlertDialogHeader,
AlertDialogDescription,
AlertDialogTitle,
AlertDialogContent,
AlertDialogFooter,
AlertDialogCancel,
Button,
ScrollArea,
toast,
} from "@renderer/components/ui";
import { t } from "i18next";
export const AudioDetail = (props: { id?: string; md5?: string }) => {
const { id, md5 } = props;
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext);
const [audio, setAudio] = useState<AudioType | null>(null);
const [transcription, setTranscription] = useState<TranscriptionType>(null);
const [initialized, setInitialized] = useState<boolean>(false);
const [sharing, setSharing] = useState<boolean>(false);
// Player controls
const [currentTime, setCurrentTime] = useState<number>(0);
@@ -43,6 +56,35 @@ export const AudioDetail = (props: { id?: string; md5?: string }) => {
}
};
const handleShare = async () => {
if (!audio.source && !audio.isUploaded) {
try {
await EnjoyApp.audios.upload(audio.id);
} catch (err) {
toast.error(t("shareFailed"), {
description: err.message,
});
return;
}
}
webApi
.createPost({
targetType: "Audio",
targetId: audio.id,
})
.then(() => {
toast.success(t("sharedSuccessfully"), {
description: t("sharedAudio"),
});
})
.catch((err) => {
toast.error(t("shareFailed"), {
description: err.message,
});
});
setSharing(false);
};
useEffect(() => {
const where = id ? { id } : { md5 };
EnjoyApp.audios.findOne(where).then((audio) => {
@@ -90,7 +132,7 @@ export const AudioDetail = (props: { id?: string; md5?: string }) => {
mediaId={audio.id}
mediaType="Audio"
mediaUrl={audio.src}
waveformCacheKey={`waveform-audio-${audio.md5}`}
mediaMd5={audio.md5}
transcription={transcription}
currentTime={currentTime}
setCurrentTime={setCurrentTime}
@@ -110,6 +152,7 @@ export const AudioDetail = (props: { id?: string; md5?: string }) => {
setPlaybackRate={setPlaybackRate}
displayInlineCaption={displayInlineCaption}
setDisplayInlineCaption={setDisplayInlineCaption}
onShare={() => setSharing(true)}
/>
<ScrollArea className={`flex-1 relative bg-muted`}>
@@ -146,8 +189,25 @@ export const AudioDetail = (props: { id?: string; md5?: string }) => {
</div>
</div>
<AlertDialog open={sharing} onOpenChange={(value) => setSharing(value)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("shareAudio")}</AlertDialogTitle>
<AlertDialogDescription>
{t("areYouSureToShareThisAudioToCommunity")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<Button variant="default" onClick={handleShare}>
{t("share")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{!initialized && (
<div className="top-0 w-full h-full absolute z-30 bg-white/10 flex items-center justify-center">
<div className="top-0 w-full h-full absolute z-30 bg-background/10 flex items-center justify-center">
<LoaderIcon className="text-muted-foreground animate-spin w-8 h-8" />
</div>
)}

View File

@@ -4,9 +4,11 @@ import {
AddMediaButton,
AudiosTable,
AudioEditForm,
LoaderSpin,
} from "@renderer/components";
import { t } from "i18next";
import {
Button,
Tabs,
TabsContent,
TabsList,
@@ -23,6 +25,7 @@ import {
DialogContent,
DialogHeader,
DialogTitle,
toast,
} from "@renderer/components/ui";
import {
DbProviderContext,
@@ -43,28 +46,51 @@ export const AudiosComponent = () => {
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const [offset, setOffest] = useState(0);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
useEffect(() => {
fetchResources();
}, []);
useEffect(() => {
addDblistener(onAudiosUpdate);
fetchResources();
fetchAudios();
return () => {
removeDbListener(onAudiosUpdate);
};
}, []);
const fetchResources = async () => {
const audios = await EnjoyApp.audios.findAll({
limit: 10,
});
if (!audios) return;
const fetchAudios = async () => {
if (loading) return;
if (offset === -1) return;
dispatchAudios({ type: "set", records: audios });
setLoading(true);
const limit = 10;
EnjoyApp.audios
.findAll({
offset,
limit,
})
.then((_audios) => {
if (_audios.length === 0) {
setOffest(-1);
return;
}
if (_audios.length < limit) {
setOffest(-1);
} else {
setOffest(offset + _audios.length);
}
dispatchAudios({ type: "append", records: _audios });
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setLoading(false);
});
};
const onAudiosUpdate = (event: CustomEvent) => {
@@ -79,7 +105,7 @@ export const AudiosComponent = () => {
dispatchAudios({ type: "destroy", record });
}
} else if (model === "Video" && action === "create") {
navigate(`/videos/${record.id}`);
navigate(`/videos/${record.id}`);
} else if (model === "Transcription" && action === "update") {
dispatchAudios({
type: "update",
@@ -93,6 +119,8 @@ export const AudiosComponent = () => {
};
if (audios.length === 0) {
if (loading) return <LoaderSpin />;
return (
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
<AddMediaButton />
@@ -135,6 +163,14 @@ export const AudiosComponent = () => {
</Tabs>
</div>
{offset > -1 && (
<div className="flex items-center justify-center my-4">
<Button variant="link" onClick={fetchAudios}>
{t("loadMore")}
</Button>
</div>
)}
<Dialog
open={!!editing}
onOpenChange={(value) => {

View File

@@ -627,8 +627,6 @@ export const LLM_PROVIDERS: { [key: string]: any } = {
openai: {
name: "OpenAI",
description: t("youNeedToSetupApiKeyBeforeUsingOpenAI"),
baseUrl:
"https://gateway.ai.cloudflare.com/v1/11d43ab275eb7e1b271ba4089ecc3864/enjoy/openai",
models: [
"gpt-3.5-turbo-1106",
"gpt-3.5-turbo",

View File

@@ -0,0 +1,68 @@
import { useContext, useEffect, useState } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { ScrollArea, toast } from "@renderer/components/ui";
import { LoaderSpin } from "@renderer/components";
import { MessageCircleIcon } from "lucide-react";
export const ConversationsShortcut = (props: {
prompt: string;
onReply?: (reply: MessageType[]) => void;
}) => {
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const { prompt, onReply } = props;
const [conversations, setConversations] = useState<ConversationType[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const ask = (conversation: ConversationType) => {
setLoading(true);
EnjoyApp.conversations
.ask(conversation.id, {
content: prompt,
})
.then((replies) => {
onReply(replies);
})
.catch((error) => {
toast.error(error.message);
})
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
EnjoyApp.conversations.findAll({ limit: 10 }).then((conversations) => {
setConversations(conversations);
setLoading(false);
});
}, []);
if (loading) {
return <LoaderSpin />;
}
return (
<ScrollArea>
{conversations.map((conversation) => {
return (
<div
key={conversation.id}
onClick={() => ask(conversation)}
className="bg-background text-primary rounded-full w-full mb-2 py-2 px-4 hover:bg-primary hover:text-white cursor-pointer flex items-center border"
style={{
borderLeftColor: `#${conversation.id
.replaceAll("-", "")
.substr(0, 6)}`,
borderLeftWidth: 3,
}}
>
<div className="">
<MessageCircleIcon className="mr-2" />
</div>
<div className="flex-1 truncated">{conversation.name}</div>
</div>
);
})}
</ScrollArea>
);
};

View File

@@ -1,4 +1,5 @@
export * from './conversation-form';
export * from "./conversation-form";
export * from "./conversations-shortcut";
export * from './speech-form';
export * from "./speech-form";
export * from "./speech-player";

View File

@@ -1,7 +1,7 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { PitchContour } from "@renderer/components";
import WaveSurfer from "wavesurfer.js";
import { Button } from "@renderer/components/ui";
import { Button, Skeleton } from "@renderer/components/ui";
import { PlayIcon, PauseIcon } from "lucide-react";
import { useIntersectionObserver } from "@uidotdev/usehooks";
import { secondsToTimestamp } from "@renderer/lib/utils";
@@ -18,6 +18,7 @@ export const SpeechPlayer = (props: {
threshold: 1,
});
const [duration, setDuration] = useState<number>(0);
const [initialized, setInitialized] = useState(false);
const onPlayClick = useCallback(() => {
wavesurfer.isPlaying() ? wavesurfer.pause() : wavesurfer.play();
@@ -69,6 +70,7 @@ export const SpeechPlayer = (props: {
height,
})
);
setInitialized(true);
}),
];
@@ -87,9 +89,17 @@ export const SpeechPlayer = (props: {
</div>
<div
ref={ref}
className="bg-white rounded-lg grid grid-cols-9 items-center relative pl-2 h-[100px]"
className="bg-background rounded-lg grid grid-cols-9 items-center relative pl-2 h-[100px]"
>
<div className="flex justify-center">
{!initialized && (
<div className="col-span-9 flex flex-col justify-around h-[80px]">
<Skeleton className="h-3 w-full rounded-full" />
<Skeleton className="h-3 w-full rounded-full" />
<Skeleton className="h-3 w-full rounded-full" />
</div>
)}
<div className={`flex justify-center ${initialized ? "" : "hidden"}`}>
<Button
onClick={onPlayClick}
className="aspect-square rounded-full p-2 w-12 h-12 bg-blue-600 hover:bg-blue-500"
@@ -102,7 +112,10 @@ export const SpeechPlayer = (props: {
</Button>
</div>
<div className="col-span-8" ref={containerRef}></div>
<div
className={`col-span-8 ${initialized ? "" : "hidden"}`}
ref={containerRef}
></div>
</div>
</div>
);

View File

@@ -1,13 +1,19 @@
import { t } from "i18next";
import { useContext, useEffect, useState } from "react";
import { Button, Progress } from "@renderer/components/ui";
import { Button, Progress, toast } from "@renderer/components/ui";
import { AppSettingsProviderContext } from "@renderer/context";
import { CheckCircle2Icon, XCircleIcon, LoaderIcon } from "lucide-react";
import Markdown from "react-markdown";
export const FfmpegCheck = () => {
const { ffmpegConfig, setFfmegConfig, EnjoyApp } = useContext(
AppSettingsProviderContext
);
const [scanResult, setScanResult] = useState<{
ffmpegPath: string;
ffprobePath: string;
scanDirs: string[];
}>();
const [downloading, setDownloading] = useState(false);
const [progress, setProgress] = useState(0);
@@ -17,15 +23,25 @@ export const FfmpegCheck = () => {
});
};
const discoverFfmpeg = () => {
EnjoyApp.ffmpeg.discover().then((config) => {
setScanResult(config);
if (config.ffmpegPath && config.ffprobePath) {
toast.success(t("ffmpegFound"));
refreshFfmpegConfig();
} else {
toast.error(t("ffmpegNotFound"));
}
});
};
const downloadFfmpeg = () => {
listenToDownloadState();
setDownloading(true);
EnjoyApp.ffmpeg
.download()
.then((config) => {
if (config) {
setFfmegConfig(config);
}
.then(() => {
refreshFfmpegConfig();
})
.finally(() => {
setDownloading(false);
@@ -44,11 +60,11 @@ export const FfmpegCheck = () => {
}, [ffmpegConfig?.ready]);
useEffect(() => {
refreshFfmpegConfig();
discoverFfmpeg();
}, []);
return (
<div className="w-full max-w-sm px-6">
<div className="w-full max-w-screen-md mx-auto px-6">
{ffmpegConfig?.ready ? (
<>
<div className="flex justify-center items-center mb-8">
@@ -58,7 +74,7 @@ export const FfmpegCheck = () => {
<CheckCircle2Icon className="text-green-500 w-10 h-10 mb-4" />
</div>
<div className="text-center text-sm opacity-70">
{t("ffmpegInstalled")}
{t("ffmpegFoundAt", { path: ffmpegConfig.ffmpegPath })}
</div>
</>
) : (
@@ -69,24 +85,87 @@ export const FfmpegCheck = () => {
<div className="flex justify-center mb-4">
<XCircleIcon className="text-red-500 w-10 h-10" />
</div>
<div className="text-center text-sm opacity-70 mb-4">
{t("ffmpegNotInstalled")}
<div className="mb-4">
<div className="text-center text-sm mb-2">
{t("ffmpegNotFound")}
</div>
{scanResult && (
<div className="text-center text-xs text-muted-foreground mb-2">
{t("tryingToFindValidFFmepgInTheseDirectories", {
dirs: scanResult.scanDirs.join(", "),
})}
</div>
)}
</div>
<div className="flex items-center justify-center mb-4">
<Button
disabled={downloading}
className=""
onClick={downloadFfmpeg}
>
{downloading && <LoaderIcon className="animate-spin mr-2" />}
{t("downloadFfmpeg")}
<div className="flex items-center justify-center space-x-4 mb-4">
<Button onClick={discoverFfmpeg} variant="default">
{t("scan")}
</Button>
{ffmpegConfig.os === "win32" && (
<Button
variant="secondary"
disabled={downloading}
onClick={downloadFfmpeg}
>
{downloading && <LoaderIcon className="animate-spin mr-2" />}
{t("download")}
</Button>
)}
</div>
{downloading && (
<div className="w-full">
<Progress value={progress} />
</div>
)}
{ffmpegConfig.os === "darwin" && (
<div className="my-6 select-text prose mx-auto border rounded-lg p-4">
<h3 className="text-center">{t("ffmpegInstallSteps")}</h3>
<h4>
1. {t("install")}{" "}
<a
className="cursor-pointer text-blue-500 hover:underline"
onClick={() => {
EnjoyApp.shell.openExternal("https://brew.sh/");
}}
>
Homebrew
</a>
</h4>
<p>{t("runTheFollowingCommandInTerminal")} </p>
<pre>
<code>
/bin/bash -c "$(curl -fsSL
https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
</code>
</pre>
<h4>2. {t("install")} FFmpeg</h4>
<p>{t("runTheFollowingCommandInTerminal")} </p>
<pre>
<code>brew install ffmpeg</code>
</pre>
<h4>3. {t("scan")} FFmpeg</h4>
<p>
{t("click")}
<Button
onClick={discoverFfmpeg}
variant="default"
size="sm"
className="mx-2"
>
{t("scan")}
</Button>
, {t("willAutomaticallyFindFFmpeg")}
</p>
</div>
)}
</>
)}
</div>

View File

@@ -10,6 +10,9 @@ export * from "./videos";
export * from "./medias";
export * from "./posts";
export * from "./users";
export * from "./db-state";
export * from "./layout";

View File

@@ -1,16 +1,16 @@
import { Button, useToast } from "@renderer/components/ui";
import { useContext, useState, useEffect } from "react";
import { WEB_API_URL } from "@/constants";
import { Button, toast, Separator } from "@renderer/components/ui";
import { useContext, useEffect } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { t } from "i18next";
import { UserSettings, LanguageSettings } from "@renderer/components";
export const LoginForm = () => {
const { toast } = useToast();
const { EnjoyApp, login } = useContext(AppSettingsProviderContext);
const [endpoint, setEndpoint] = useState(WEB_API_URL);
const { EnjoyApp, login, webApi, user } = useContext(
AppSettingsProviderContext
);
const handleMixinLogin = () => {
const url = `${endpoint}/sessions/new?provider=mixin`;
const url = `${webApi.baseUrl}/sessions/new?provider=mixin`;
EnjoyApp.view.load(url, { x: 0, y: 0 });
};
@@ -23,11 +23,7 @@ export const LoginForm = () => {
const { state, url, error } = event;
if (error) {
toast({
title: t("error"),
description: error,
variant: "destructive",
});
toast.error(error);
EnjoyApp.view.hide();
return;
}
@@ -36,17 +32,13 @@ export const LoginForm = () => {
const provider = new URL(url).pathname.split("/")[2];
const code = new URL(url).searchParams.get("code");
if (!url.startsWith(endpoint)) {
toast({
title: t("error"),
description: t("invalidRedirectUrl"),
variant: "destructive",
});
if (!url.startsWith(webApi.baseUrl)) {
toast.error(t("invalidRedirectUrl"));
EnjoyApp.view.hide();
}
if (provider && code) {
EnjoyApp.webApi
webApi
.auth({ provider, code })
.then((user) => {
login(user);
@@ -55,22 +47,12 @@ export const LoginForm = () => {
EnjoyApp.view.hide();
});
} else {
toast({
title: t("error"),
description: t("failedToLogin"),
variant: "destructive",
});
toast.error(t("failedToLogin"));
EnjoyApp.view.hide();
}
}
};
useEffect(() => {
EnjoyApp.app.apiUrl().then((url) => {
setEndpoint(url);
});
}, []);
useEffect(() => {
EnjoyApp.view.onViewState((_event, state) => onViewState(state));
@@ -78,7 +60,17 @@ export const LoginForm = () => {
EnjoyApp.view.removeViewStateListeners();
EnjoyApp.view.remove();
};
}, [endpoint]);
}, [webApi]);
if (user) {
return (
<div className="px-4 py-2 border rounded-lg w-full max-w-sm">
<UserSettings />
<Separator />
<LanguageSettings />
</div>
);
}
return (
<div className="w-full max-w-sm px-6 flex flex-col space-y-4">

View File

@@ -18,7 +18,7 @@ export const LookupResult = (props: {
const [loading, setLoading] = useState<boolean>(true);
if (!word) return null;
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const { webApi } = useContext(AppSettingsProviderContext);
const lookup = (retries = 0) => {
if (!word) return;
@@ -28,7 +28,7 @@ export const LookupResult = (props: {
}
retries += 1;
EnjoyApp.webApi
webApi
.lookup({
word,
context,

View File

@@ -16,6 +16,7 @@ import {
MinimizeIcon,
GalleryHorizontalIcon,
SpellCheckIcon,
Share2Icon,
} from "lucide-react";
import { t } from "i18next";
import { type WaveSurferOptions } from "wavesurfer.js";
@@ -24,7 +25,6 @@ import { Tooltip } from "react-tooltip";
const PLAYBACK_RATE_OPTIONS = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75];
const MIN_ZOOM_RATIO = 0.25;
const MAX_ZOOM_RATIO = 5.0;
const ZOOM_RATIO_STEP = 0.25;
export const MediaPlayerControls = (props: {
isPlaying: boolean;
@@ -47,6 +47,7 @@ export const MediaPlayerControls = (props: {
setWavesurferOptions?: (options: Partial<WaveSurferOptions>) => void;
displayInlineCaption?: boolean;
setDisplayInlineCaption?: (display: boolean) => void;
onShare?: () => void;
}) => {
const {
isPlaying,
@@ -67,6 +68,7 @@ export const MediaPlayerControls = (props: {
setWavesurferOptions,
displayInlineCaption,
setDisplayInlineCaption,
onShare,
} = props;
return (
@@ -244,20 +246,32 @@ export const MediaPlayerControls = (props: {
</Button>
)}
{transcriptionDirty && (
<div className="absolute right-4">
<div className="flex items-center space-x-4">
<Button
variant="secondary"
className=""
onClick={resetTranscription}
>
{t("reset")}
</Button>
<Button onClick={saveTranscription}>{t("save")}</Button>
</div>
<Button
variant="ghost"
data-tooltip-id="media-player-controls-tooltip"
data-tooltip-content={t("share")}
className="relative aspect-square p-0 h-10"
onClick={onShare}
>
<Share2Icon className="w-6 h-6" />
</Button>
<div className="absolute right-4">
<div className="flex items-center space-x-4">
{transcriptionDirty && (
<>
<Button
variant="secondary"
className=""
onClick={resetTranscription}
>
{t("reset")}
</Button>
<Button onClick={saveTranscription}>{t("save")}</Button>
</>
)}
</div>
)}
</div>
<Tooltip id="media-player-controls-tooltip" />
</div>

View File

@@ -34,7 +34,7 @@ export const MediaPlayer = (props: {
mediaId: string;
mediaType: "Audio" | "Video";
mediaUrl: string;
waveformCacheKey: string;
mediaMd5?: string;
transcription: TranscriptionType;
// player controls
currentTime: number;
@@ -60,13 +60,14 @@ export const MediaPlayer = (props: {
setPlaybackRate: (value: number) => void;
displayInlineCaption?: boolean;
setDisplayInlineCaption?: (value: boolean) => void;
onShare?: () => void;
}) => {
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const {
mediaId,
mediaType,
mediaUrl,
waveformCacheKey,
mediaMd5,
transcription,
height = 200,
currentTime,
@@ -88,16 +89,12 @@ export const MediaPlayer = (props: {
setPlaybackRate,
displayInlineCaption,
setDisplayInlineCaption,
onShare,
} = props;
if (!mediaUrl) return;
const [wavesurfer, setWavesurfer] = useState(null);
const [waveform, setWaveForm] = useState<{
peaks: number[];
duration: number;
frequencies: number[];
sampleRate: number;
}>(null);
const [waveform, setWaveForm] = useState<WaveFormDataType>(null);
const containerRef = useRef<HTMLDivElement>();
const [mediaProvider, setMediaProvider] = useState<
HTMLAudioElement | HTMLVideoElement
@@ -179,7 +176,7 @@ export const MediaPlayer = (props: {
const renderPitchContour = (region: RegionType) => {
if (!region) return;
if (!waveform.frequencies.length) return;
if (!waveform?.frequencies?.length) return;
if (!wavesurfer) return;
const duration = wavesurfer.getDuration();
@@ -278,7 +275,6 @@ export const MediaPlayer = (props: {
const ws = WaveSurfer.create({
container: containerRef.current,
height,
url: mediaUrl,
waveColor: "#ddd",
progressColor: "rgba(0, 0, 0, 0.25)",
cursorColor: "#dc143c",
@@ -322,6 +318,7 @@ export const MediaPlayer = (props: {
const subscriptions = [
wavesurfer.on("play", () => setIsPlaying(true)),
wavesurfer.on("pause", () => setIsPlaying(false)),
wavesurfer.on("loading", (percent: number) => console.log(percent)),
wavesurfer.on("timeupdate", (time: number) => setCurrentTime(time)),
wavesurfer.on("decode", () => {
if (waveform?.frequencies) return;
@@ -338,7 +335,7 @@ export const MediaPlayer = (props: {
sampleRate,
frequencies: _frequencies,
};
EnjoyApp.cacheObjects.set(waveformCacheKey, _waveform);
EnjoyApp.waveforms.save(mediaMd5, _waveform);
setWaveForm(_waveform);
}),
wavesurfer.on("ready", () => {
@@ -477,10 +474,8 @@ export const MediaPlayer = (props: {
}, [wavesurfer, isPlaying]);
useEffect(() => {
EnjoyApp.cacheObjects.get(waveformCacheKey).then((cached) => {
if (!cached) return;
setWaveForm(cached);
EnjoyApp.waveforms.find(mediaMd5).then((waveform) => {
setWaveForm(waveform);
});
}, []);
@@ -536,6 +531,7 @@ export const MediaPlayer = (props: {
setWavesurferOptions={(options) => wavesurfer?.setOptions(options)}
displayInlineCaption={displayInlineCaption}
setDisplayInlineCaption={setDisplayInlineCaption}
onShare={onShare}
/>
</div>

View File

@@ -90,18 +90,18 @@ export const AssistantMessageComponent = (props: {
id={`message-${message.id}`}
className="flex items-end space-x-2 pr-10"
>
<Avatar className="w-8 h-8 bg-white avatar">
<Avatar className="w-8 h-8 bg-background avatar">
<AvatarImage></AvatarImage>
<AvatarFallback className="bg-white">AI</AvatarFallback>
<AvatarFallback className="bg-background">AI</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2 px-4 py-2 bg-white border rounded-lg shadow-sm w-full prose max-w-prose">
<div className="flex flex-col gap-2 px-4 py-2 bg-background border rounded-lg shadow-sm w-full">
{configuration?.autoSpeech && speeching ? (
<div className="p-4">
<LoaderIcon className="w-8 h-8 animate-spin" />
</div>
) : (
<Markdown
className="select-text"
className="select-text prose"
components={{
a({ node, children, ...props }) {
try {

View File

@@ -1,12 +1,23 @@
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogHeader,
AlertDialogDescription,
AlertDialogTitle,
AlertDialogContent,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
Avatar,
AvatarImage,
AvatarFallback,
Button,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
toast,
} from "@renderer/components/ui";
import { SpeechPlayer } from "@renderer/components";
import { useContext, useState } from "react";
@@ -17,9 +28,11 @@ import {
AlertCircleIcon,
CopyIcon,
CheckIcon,
Share2Icon,
} from "lucide-react";
import { useCopyToClipboard } from "@uidotdev/usehooks";
import { t } from "i18next";
import { useNavigate } from "react-router-dom";
import Markdown from "react-markdown";
export const UserMessageComponent = (props: {
@@ -30,9 +43,40 @@ export const UserMessageComponent = (props: {
}) => {
const { message, onResend, onRemove } = props;
const speech = message.speeches?.[0];
const { user } = useContext(AppSettingsProviderContext);
const { user, webApi } = useContext(AppSettingsProviderContext);
const [_, copyToClipboard] = useCopyToClipboard();
const [copied, setCopied] = useState<boolean>(false);
const navigate = useNavigate();
const handleShare = async () => {
if (message.role === "user") {
const content = message.content;
webApi
.createPost({
metadata: {
type: "prompt",
content,
},
})
.then(() => {
toast.success(t("sharedSuccessfully"), {
description: t("sharedPrompt"),
action: {
label: t("view"),
onClick: () => {
navigate("/community");
},
},
actionButtonStyle: {
backgroundColor: "var(--primary)",
},
});
})
.catch((err) => {
toast.error(t("shareFailed"), { description: err.message });
});
}
};
return (
<div
@@ -41,7 +85,7 @@ export const UserMessageComponent = (props: {
>
<DropdownMenu>
<div className="flex flex-col gap-2 px-4 py-2 bg-sky-500/30 border-sky-500 rounded-lg shadow-sm w-full">
<Markdown className="select-text">{message.content}</Markdown>
<Markdown className="select-text prose">{message.content}</Markdown>
{Boolean(speech) && <SpeechPlayer speech={speech} />}
@@ -81,6 +125,34 @@ export const UserMessageComponent = (props: {
}}
/>
)}
{message.createdAt && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Share2Icon
data-tooltip-id="global-tooltip"
data-tooltip-content={t("share")}
className="w-3 h-3 cursor-pointer"
/>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("sharePrompt")}</AlertDialogTitle>
<AlertDialogDescription>
{t("areYouSureToShareThisPromptToCommunity")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction asChild>
<Button variant="default" onClick={handleShare}>
{t("share")}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
<DropdownMenuContent>
@@ -96,7 +168,7 @@ export const UserMessageComponent = (props: {
</DropdownMenuContent>
</DropdownMenu>
<Avatar className="w-8 h-8 bg-white">
<Avatar className="w-8 h-8 bg-background">
<AvatarImage src={user.avatarUrl} />
<AvatarFallback className="bg-primary text-white capitalize">
{user.name[0]}

View File

@@ -0,0 +1,9 @@
export * from "./posts";
export * from "./post-audio";
export * from "./post-card";
export * from "./post-medium";
export * from "./post-recording";
export * from "./post-story";
export * from "./post-options";
export * from "./post-actions";

View File

@@ -0,0 +1,206 @@
import { useContext, useState } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { ConversationsShortcut } from "@renderer/components";
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogAction,
AlertDialogCancel,
AlertDialogFooter,
Button,
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
ScrollArea,
toast,
} from "@renderer/components/ui";
import { t } from "i18next";
import Markdown from "react-markdown";
import {
BotIcon,
CheckIcon,
CopyPlusIcon,
PlusCircleIcon,
ChevronRightIcon,
} from "lucide-react";
import { useCopyToClipboard } from "@uidotdev/usehooks";
import { Link } from "react-router-dom";
export const PostActions = (props: { post: PostType }) => {
const { post } = props;
const [_, copyToClipboard] = useCopyToClipboard();
const [copied, setCopied] = useState<boolean>(false);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const [asking, setAsking] = useState<boolean>(false);
const [aiReplies, setAiReplies] = useState<MessageType[]>([]);
const handleAddMedium = async () => {
if (post.targetType !== "Medium") return;
const medium = post.target as MediumType;
if (!medium) return;
if (medium.mediumType === "Video") {
try {
const video = await EnjoyApp.videos.findOne({ md5: medium.md5 });
if (video) {
toast.info(t("videoAlreadyAddedToLibrary"));
return;
}
} catch (error) {
console.error(error);
}
EnjoyApp.videos
.create(medium.sourceUrl, {
coverUrl: medium.coverUrl,
md5: medium.md5,
})
.then(() => {
toast.success(t("videoSuccessfullyAddedToLibrary"));
});
} else if (medium.mediumType === "Audio") {
try {
const audio = await EnjoyApp.audios.findOne({ md5: medium.md5 });
if (audio) {
toast.info(t("audioAlreadyAddedToLibrary"));
return;
}
} catch (error) {
toast.error(error.message);
}
EnjoyApp.audios
.create(medium.sourceUrl, {
coverUrl: medium.coverUrl,
md5: medium.md5,
})
.then(() => {
toast.success(t("audioSuccessfullyAddedToLibrary"));
});
}
};
return (
<>
<div className="flex items-center space-x-2 justify-end">
{post.target && post.targetType === "Medium" && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
data-tooltip-id="global-tooltip"
data-tooltip-content={t("addToLibary")}
data-tooltip-place="bottom"
variant="ghost"
size="sm"
className="px-1.5 rounded-full"
>
<PlusCircleIcon className="w-5 h-5 text-muted-foreground hover:text-primary" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("addRecourse")}</AlertDialogTitle>
<AlertDialogDescription>
{(post.target as MediumType).mediumType === "Video" &&
t("areYouSureToAddThisVideoToYourLibrary")}
{(post.target as MediumType).mediumType === "Audio" &&
t("areYouSureToAddThisAudioToYourLibrary")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleAddMedium}>
{t("confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{typeof post.metadata?.content === "string" && (
<Button
data-tooltip-id="global-tooltip"
data-tooltip-content={t("copy")}
data-tooltip-place="bottom"
variant="ghost"
size="sm"
className="px-1.5 rounded-full"
>
{copied ? (
<CheckIcon className="w-5 h-5 text-green-500" />
) : (
<CopyPlusIcon
className="w-5 h-5 text-muted-foreground hover:text-primary"
onClick={() => {
copyToClipboard(post.metadata.content as string);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 3000);
}}
/>
)}
</Button>
)}
{post.metadata?.type === "prompt" && (
<Dialog open={asking} onOpenChange={setAsking}>
<DialogTrigger asChild>
<Button
data-tooltip-id="global-tooltip"
data-tooltip-content={t("sendToAIAssistant")}
data-tooltip-place="bottom"
variant="ghost"
size="sm"
className="px-1.5 rounded-full"
>
<BotIcon className="w-5 h-5 text-muted-foreground hover:text-primary" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("sendToAIAssistant")}</DialogTitle>
</DialogHeader>
<ConversationsShortcut
prompt={post.metadata.content as string}
onReply={(replies) => {
setAiReplies([...aiReplies, ...replies]);
setAsking(false);
}}
/>
</DialogContent>
<ScrollArea></ScrollArea>
</Dialog>
)}
</div>
{aiReplies.length > 0 && <AIReplies replies={aiReplies} />}
</>
);
};
const AIReplies = (props: { replies: MessageType[] }) => {
return (
<div>
<div className="space-y-2">
{props.replies.map((reply) => (
<div key={reply.id} className="bg-muted py-2 px-4 rounded">
<div className="mb-2 flex items-center justify-between">
<BotIcon className="w-5 h-5 text-blue-500" />
<Link to={`/conversations/${reply.conversationId}`}>
<ChevronRightIcon className="w-5 h-5 text-muted-foreground" />
</Link>
</div>
<Markdown className="prose select-text">{reply.content}</Markdown>
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,198 @@
import { useEffect, useState, useRef, useCallback, useContext } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { PitchContour } from "@renderer/components";
import WaveSurfer from "wavesurfer.js";
import { Button, Skeleton } from "@renderer/components/ui";
import { PlayIcon, PauseIcon } from "lucide-react";
import { useIntersectionObserver } from "@uidotdev/usehooks";
import { secondsToTimestamp } from "@renderer/lib/utils";
import { MediaPlayer, MediaProvider } from "@vidstack/react";
import {
DefaultAudioLayout,
defaultLayoutIcons,
} from "@vidstack/react/player/layouts/default";
export const STORAGE_WORKER_ENDPOINT = "https://enjoy-storage.baizhiheizi.com";
export const PostAudio = (props: {
audio: Partial<MediumType>;
height?: number;
}) => {
const { audio, height = 80 } = props;
const [currentTime, setCurrentTime] = useState<number>(0);
const { webApi } = useContext(AppSettingsProviderContext);
const [transcription, setTranscription] = useState<TranscriptionType>();
const currentTranscription = (transcription?.result || []).find(
(s) =>
currentTime >= s.offsets.from / 1000.0 &&
currentTime <= s.offsets.to / 1000.0
);
useEffect(() => {
webApi
.transcriptions({
targetMd5: audio.md5,
})
.then((response) => {
setTranscription(response?.transcriptions?.[0]);
});
}, [audio.md5]);
return (
<div className="w-full">
{audio.sourceUrl.startsWith(STORAGE_WORKER_ENDPOINT) ? (
<WavesurferPlayer
currentTime={currentTime}
setCurrentTime={setCurrentTime}
audio={audio}
height={height}
/>
) : (
<MediaPlayer
onTimeUpdate={({ currentTime: _currentTime }) => {
setCurrentTime(_currentTime);
}}
src={audio.sourceUrl}
>
<MediaProvider />
<DefaultAudioLayout icons={defaultLayoutIcons} />
</MediaPlayer>
)}
{currentTranscription && (
<div className="mt-2 bg-muted px-4 py-2 rounded">
<div className="text-muted-foreground text-center font-serif">
{currentTranscription.text}
</div>
</div>
)}
{audio.coverUrl && (
<div className="mt-2">
<img src={audio.coverUrl} className="w-full rounded" />
</div>
)}
</div>
);
};
const WavesurferPlayer = (props: {
audio: Partial<MediumType>;
height?: number;
currentTime: number;
setCurrentTime: (currentTime: number) => void;
}) => {
const { audio, height = 80, currentTime, setCurrentTime } = props;
const [initialized, setInitialized] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [wavesurfer, setWavesurfer] = useState(null);
const containerRef = useRef();
const [ref, entry] = useIntersectionObserver({
threshold: 1,
});
const [duration, setDuration] = useState<number>(0);
const onPlayClick = useCallback(() => {
wavesurfer.isPlaying() ? wavesurfer.pause() : wavesurfer.play();
}, [wavesurfer]);
useEffect(() => {
// use the intersection observer to only create the wavesurfer instance
// when the player is visible
if (!entry?.isIntersecting) return;
if (!audio.sourceUrl) return;
if (wavesurfer) return;
const ws = WaveSurfer.create({
container: containerRef.current,
url: audio.sourceUrl,
height,
barWidth: 1,
cursorWidth: 0,
autoCenter: true,
autoScroll: true,
dragToSeek: true,
hideScrollbar: true,
minPxPerSec: 100,
waveColor: "#ddd",
progressColor: "rgba(0, 0, 0, 0.25)",
});
setWavesurfer(ws);
}, [audio.sourceUrl, entry]);
useEffect(() => {
if (!wavesurfer) return;
const subscriptions = [
wavesurfer.on("play", () => {
setIsPlaying(true);
}),
wavesurfer.on("pause", () => {
setIsPlaying(false);
}),
wavesurfer.on("timeupdate", (time: number) => {
setCurrentTime(time);
}),
wavesurfer.on("decode", () => {
setDuration(wavesurfer.getDuration());
const peaks = wavesurfer.getDecodedData().getChannelData(0);
const sampleRate = wavesurfer.options.sampleRate;
wavesurfer.renderer.getWrapper().appendChild(
PitchContour({
peaks,
sampleRate,
height,
})
);
setInitialized(true);
}),
];
return () => {
subscriptions.forEach((unsub) => unsub());
wavesurfer?.destroy();
};
}, [wavesurfer]);
return (
<>
<div className="flex justify-end">
<span className="text-xs text-muted-foreground">
{secondsToTimestamp(duration)}
</span>
</div>
<div
ref={ref}
className="bg-background rounded-lg grid grid-cols-9 items-center relative h-[80px]"
>
{!initialized && (
<div className="col-span-9 flex flex-col justify-around h-[80px]">
<Skeleton className="h-3 w-full rounded-full" />
<Skeleton className="h-3 w-full rounded-full" />
<Skeleton className="h-3 w-full rounded-full" />
</div>
)}
<div className={`flex justify-center ${initialized ? "" : "hidden"}`}>
<Button
onClick={onPlayClick}
className="aspect-square rounded-full p-2 w-12 h-12 bg-blue-600 hover:bg-blue-500"
>
{isPlaying ? (
<PauseIcon className="w-6 h-6 text-white" />
) : (
<PlayIcon className="w-6 h-6 text-white" />
)}
</Button>
</div>
<div
className={`col-span-8 ${initialized ? "" : "hidden"}`}
ref={containerRef}
></div>
</div>
</>
);
};

View File

@@ -0,0 +1,81 @@
import { useContext } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import {
PostRecording,
PostActions,
PostMedium,
PostStory,
PostOptions,
} from "@renderer/components";
import { Avatar, AvatarImage, AvatarFallback } from "@renderer/components/ui";
import { formatDateTime } from "@renderer/lib/utils";
import { t } from "i18next";
import Markdown from "react-markdown";
export const PostCard = (props: {
post: PostType;
handleDelete: (id: string) => void;
}) => {
const { post, handleDelete } = props;
const { user } = useContext(AppSettingsProviderContext);
return (
<div className="rounded p-4 bg-background space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Avatar>
<AvatarImage src={post.user.avatarUrl} />
<AvatarFallback className="text-xl">
{post.user.name[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col justify-between">
<div className="">{post.user.name}</div>
<div className="text-xs text-muted-foreground">
{formatDateTime(post.createdAt)}
</div>
</div>
</div>
{post.user.id == user.id && (
<PostOptions handleDelete={() => handleDelete(post.id)} />
)}
</div>
{post.metadata?.type === "prompt" && (
<>
<div className="text-xs text-muted-foreground">
{t("sharedPrompt")}
</div>
<Markdown className="prose prose-slate prose-pre:whitespace-normal select-text">
{"```prompt\n" + post.metadata.content + "\n```"}
</Markdown>
</>
)}
{post.targetType == "Medium" && (
<PostMedium medium={post.target as MediumType} />
)}
{post.targetType == "Recording" && (
<>
<div className="text-xs text-muted-foreground">
{t("sharedRecording")}
</div>
<PostRecording recording={post.target as RecordingType} />
</>
)}
{post.targetType == "Story" && (
<>
<div className="text-xs text-muted-foreground">
{t("sharedStory")}
</div>
<PostStory story={post.target as StoryType} />
</>
)}
<PostActions post={post} />
</div>
);
};

View File

@@ -0,0 +1,45 @@
import { PostAudio } from "@renderer/components";
import { t } from "i18next";
import { MediaPlayer, MediaProvider } from "@vidstack/react";
import {
DefaultVideoLayout,
defaultLayoutIcons,
} from "@vidstack/react/player/layouts/default";
export const PostMedium = (props: { medium: MediumType }) => {
const { medium } = props;
if (!medium.sourceUrl) return null;
return (
<div className="space-y-2">
{medium.mediumType == "Video" && (
<>
<div className="text-xs text-muted-foreground">
{t("sharedAudio")}
</div>
<MediaPlayer
poster={medium.coverUrl}
src={{
type: `${medium.mediumType.toLowerCase()}/${
medium.extname.replace(".", "") || "mp4"
}`,
src: medium.sourceUrl,
}}
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
</>
)}
{medium.mediumType == "Audio" && (
<>
<div className="text-xs text-muted-foreground">
{t("sharedAudio")}
</div>
<PostAudio audio={medium} />
</>
)}
</div>
);
};

View File

@@ -0,0 +1,63 @@
import { useState } from "react";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@renderer/components/ui";
import { MoreHorizontalIcon, Trash2Icon } from "lucide-react";
import { t } from "i18next";
export const PostOptions = (props: { handleDelete: () => void }) => {
const { handleDelete } = props;
const [deleting, setDeleting] = useState(false);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontalIcon className="w-4 h-4" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem className="cursor-pointer" onClick={() => setDeleting(true)}>
<span className="text-sm mr-auto text-destructive capitalize">
{t("delete")}
</span>
<Trash2Icon className="w-4 h-4 text-destructive" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={deleting} onOpenChange={setDeleting}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("removeSharing")}</AlertDialogTitle>
<AlertDialogDescription>
{t("areYouSureToRemoveThisSharing")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<Button
variant="destructive"
onClick={() => {
handleDelete();
setDeleting(false);
}}
>
{t("delete")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};

View File

@@ -0,0 +1,133 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { PitchContour } from "@renderer/components";
import WaveSurfer from "wavesurfer.js";
import { Button, Skeleton } from "@renderer/components/ui";
import { PlayIcon, PauseIcon } from "lucide-react";
import { useIntersectionObserver } from "@uidotdev/usehooks";
import { secondsToTimestamp } from "@renderer/lib/utils";
export const PostRecording = (props: {
recording: RecordingType;
height?: number;
}) => {
const { recording, height = 80 } = props;
const [initialized, setInitialized] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [wavesurfer, setWavesurfer] = useState(null);
const containerRef = useRef();
const [ref, entry] = useIntersectionObserver({
threshold: 1,
});
const [duration, setDuration] = useState<number>(0);
const onPlayClick = useCallback(() => {
wavesurfer.isPlaying() ? wavesurfer.pause() : wavesurfer.play();
}, [wavesurfer]);
useEffect(() => {
// use the intersection observer to only create the wavesurfer instance
// when the player is visible
if (!entry?.isIntersecting) return;
if (!recording.src) return;
if (wavesurfer) return;
const ws = WaveSurfer.create({
container: containerRef.current,
url: recording.src,
height,
barWidth: 1,
cursorWidth: 0,
autoCenter: true,
autoScroll: true,
dragToSeek: true,
hideScrollbar: true,
minPxPerSec: 100,
waveColor: "rgba(0, 0, 0, 0.25)",
progressColor: "rgba(0, 0, 0, 0.5)",
});
setWavesurfer(ws);
}, [recording.src, entry]);
useEffect(() => {
if (!wavesurfer) return;
const subscriptions = [
wavesurfer.on("play", () => {
setIsPlaying(true);
}),
wavesurfer.on("pause", () => {
setIsPlaying(false);
}),
wavesurfer.on("decode", () => {
setDuration(wavesurfer.getDuration());
const peaks = wavesurfer.getDecodedData().getChannelData(0);
const sampleRate = wavesurfer.options.sampleRate;
wavesurfer.renderer.getWrapper().appendChild(
PitchContour({
peaks,
sampleRate,
height,
})
);
setInitialized(true);
}),
];
return () => {
subscriptions.forEach((unsub) => unsub());
wavesurfer?.destroy();
};
}, [wavesurfer]);
return (
<div className="w-full">
<div className="flex justify-end">
<span className="text-xs text-muted-foreground">
{secondsToTimestamp(duration)}
</span>
</div>
<div
ref={ref}
className="bg-sky-500/30 rounded-lg grid grid-cols-9 items-center relative h-[80px]"
>
{!initialized && (
<div className="col-span-9 flex flex-col justify-around h-[80px]">
<Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-2 w-full rounded-full" />
</div>
)}
<div className={`flex justify-center ${initialized ? "" : "hidden"}`}>
<Button
onClick={onPlayClick}
className="aspect-square rounded-full p-2 w-12 h-12 bg-blue-600 hover:bg-blue-500"
>
{isPlaying ? (
<PauseIcon className="w-6 h-6 text-white" />
) : (
<PlayIcon className="w-6 h-6 text-white" />
)}
</Button>
</div>
<div
className={`col-span-8 ${initialized ? "" : "hidden"}`}
ref={containerRef}
></div>
</div>
{
recording.referenceText && (
<div className="mt-2 bg-muted px-4 py-2 rounded">
<div className="text-muted-foreground text-center font-serif">
{recording.referenceText}
</div>
</div>
)
}
</div>
);
};

View File

@@ -0,0 +1,25 @@
import { Link } from "react-router-dom";
export const PostStory = (props: { story: StoryType }) => {
const { story } = props;
return (
<Link className="block" to={`/stories/${story.id}`}>
<div className="rounded-lg flex items-start border">
<div className="aspect-square h-36 bg-muted">
<img
src={story.metadata?.image}
className="w-full h-full object-cover"
/>
</div>
<div className="px-4 py-2">
<div className="line-clamp-2 text-lg font-semibold mb-2">
{story.metadata?.title}
</div>
<div className="line-clamp-3 text-sm text-muted-foreground">
{story.metadata?.description}
</div>
</div>
</div>
</Link>
);
};

View File

@@ -0,0 +1,74 @@
import { useContext, useEffect, useState } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { PostCard, LoaderSpin } from "@renderer/components";
import { toast, Button } from "@renderer/components//ui";
import { t } from "i18next";
export const Posts = () => {
const { webApi } = useContext(AppSettingsProviderContext);
const [loading, setLoading] = useState<boolean>(true);
const [posts, setPosts] = useState<PostType[]>([]);
const [nextPage, setNextPage] = useState(1);
const handleDelete = (id: string) => {
webApi
.deletePost(id)
.then(() => {
toast.success(t("removeSharingSuccessfully"));
setPosts(posts.filter((post) => post.id !== id));
})
.catch((error) => {
toast.error(t("removeSharingFailed"), { description: error.message });
});
};
const fetchPosts = async (page: number = nextPage) => {
if (!page) return;
webApi
.posts({
page,
items: 10,
})
.then((res) => {
setPosts([...posts, ...res.posts]);
setNextPage(res.next);
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
fetchPosts();
}, []);
if (loading) {
return <LoaderSpin />;
}
return (
<div className="max-w-screen-sm mx-auto">
{posts.length === 0 && (
<div className="text-center text-gray-500">{t("noOneSharedYet")}</div>
)}
<div className="space-y-4">
{posts.map((post) => (
<PostCard key={post.id} post={post} handleDelete={handleDelete} />
))}
</div>
{nextPage && (
<div className="py-4 flex justify-center">
<Button variant="link" onClick={() => fetchPosts(nextPage)}>
{t("loadMore")}
</Button>
</div>
)}
</div>
);
};

View File

@@ -1,5 +1,5 @@
import { t } from "i18next";
import { Button, useToast } from "@renderer/components/ui";
import { Button, toast } from "@renderer/components/ui";
import { AppSettingsProviderContext } from "@renderer/context";
import { useState, useContext } from "react";
import { LoaderIcon } from "lucide-react";
@@ -7,14 +7,11 @@ import { LoaderIcon } from "lucide-react";
export const About = () => {
const { version } = useContext(AppSettingsProviderContext);
const [checking, setChecking] = useState<boolean>(false);
const { toast } = useToast();
const checkUpdate = () => {
setChecking(true);
setTimeout(() => {
setChecking(false);
toast({
description: t("alreadyLatestVersion"),
});
toast.info(t("alreadyLatestVersion"));
}, 1000);
};

View File

@@ -10,6 +10,35 @@ export const AdvancedSettings = () => {
{t("advancedSettings")}
</div>
<div className="flex items-start justify-between py-4">
<div className="">
<div className="mb-2">{t("resetSettings")}</div>
<div className="text-sm text-muted-foreground mb-2">
{t("logoutAndRemoveAllPersonalSettings")}
</div>
</div>
<div className="">
<div className="mb-2 flex justify-end">
<ResetAllButton>
<Button
variant="secondary"
className="text-destructive"
size="sm"
>
{t("resetSettings")}
</Button>
</ResetAllButton>
</div>
<div className="text-xs text-muted-foreground">
<InfoIcon className="mr-1 w-3 h-3 inline" />
<span>{t("relaunchIsNeededAfterChanged")}</span>
</div>
</div>
</div>
<Separator />
<div className="flex items-start justify-between py-4">
<div className="">
<div className="mb-2">{t("resetAll")}</div>
@@ -26,7 +55,7 @@ export const AdvancedSettings = () => {
className="text-destructive"
size="sm"
>
{t("reset")}
{t("resetAll")}
</Button>
</ResetAllButton>
</div>

View File

@@ -22,7 +22,12 @@ import {
Input,
Label,
Separator,
useToast,
toast,
Select,
SelectTrigger,
SelectItem,
SelectValue,
SelectContent,
} from "@renderer/components/ui";
import { WhisperModelOptions } from "@renderer/components";
import {
@@ -31,7 +36,7 @@ import {
} from "@renderer/context";
import { useContext, useState, useRef, useEffect } from "react";
import { redirect } from "react-router-dom";
import { InfoIcon } from "lucide-react";
import { InfoIcon, EditIcon } from "lucide-react";
export const BasicSettings = () => {
return (
@@ -39,8 +44,12 @@ export const BasicSettings = () => {
<div className="font-semibold mb-4 capitilized">{t("basicSettings")}</div>
<UserSettings />
<Separator />
<LanguageSettings />
<Separator />
<LibraryPathSettings />
<Separator />
<FfmpegSettings />
<Separator />
<WhisperSettings />
<Separator />
<OpenaiSettings />
@@ -51,7 +60,7 @@ export const BasicSettings = () => {
);
};
const UserSettings = () => {
export const UserSettings = () => {
const { user, logout } = useContext(AppSettingsProviderContext);
if (!user) return null;
@@ -104,6 +113,46 @@ const UserSettings = () => {
);
};
export const LanguageSettings = () => {
const { language, switchLanguage } = useContext(AppSettingsProviderContext);
return (
<div className="flex items-start justify-between py-4">
<div className="">
<div className="mb-2">{t("language")}</div>
<div className="text-sm text-muted-foreground mb-2">
{language === "en" ? "English" : "简体中文"}
</div>
</div>
<div className="">
<div className="flex items-center justify-end space-x-2 mb-2">
<Select
value={language}
onValueChange={(value: "en" | "zh-CN") => {
switchLanguage(value);
}}
>
<SelectTrigger className="text-xs">
<SelectValue>
{language === "en" ? "English" : "简体中文"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem className="text-xs" value="en">
English
</SelectItem>
<SelectItem className="text-xs" value="zh-CN">
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
);
};
const LibraryPathSettings = () => {
const { libraryPath, EnjoyApp } = useContext(AppSettingsProviderContext);
@@ -139,7 +188,11 @@ const LibraryPathSettings = () => {
<Button variant="secondary" size="sm" onClick={openLibraryPath}>
{t("open")}
</Button>
<Button variant="default" size="sm" onClick={handleChooseLibraryPath}>
<Button
variant="secondary"
size="sm"
onClick={handleChooseLibraryPath}
>
{t("edit")}
</Button>
</div>
@@ -152,6 +205,107 @@ const LibraryPathSettings = () => {
);
};
const FfmpegSettings = () => {
const { EnjoyApp, setFfmegConfig, ffmpegConfig } = useContext(
AppSettingsProviderContext
);
const [editing, setEditing] = useState(false);
const refreshFfmpegConfig = async () => {
EnjoyApp.settings.getFfmpegConfig().then((config) => {
setFfmegConfig(config);
});
};
const handleChooseFfmpeg = async () => {
const filePaths = await EnjoyApp.dialog.showOpenDialog({
properties: ["openFile"],
});
const path = filePaths?.[0];
if (!path) return;
if (path.includes("ffmpeg")) {
EnjoyApp.settings.setFfmpegConfig({
...ffmpegConfig,
ffmpegPath: path,
});
refreshFfmpegConfig();
} else if (path.includes("ffprobe")) {
EnjoyApp.settings.setFfmpegConfig({
...ffmpegConfig,
ffprobePath: path,
});
refreshFfmpegConfig();
} else {
toast.error(t("invalidFfmpegPath"));
}
};
return (
<>
<div className="flex items-start justify-between py-4">
<div className="">
<div className="mb-2">FFmpeg</div>
<div className="flex items-center space-x-4">
<span className=" text-sm text-muted-foreground">
<b>ffmpeg</b>: {ffmpegConfig?.ffmpegPath || ""}
</span>
{editing && (
<Button onClick={handleChooseFfmpeg} variant="ghost" size="icon">
<EditIcon className="w-4 h-4 text-muted-foreground" />
</Button>
)}
</div>
<div className="flex items-center space-x-4">
<span className=" text-sm text-muted-foreground">
<b>ffprobe</b>: {ffmpegConfig?.ffprobePath || ""}
</span>
{editing && (
<Button onClick={handleChooseFfmpeg} variant="ghost" size="icon">
<EditIcon className="w-4 h-4 text-muted-foreground" />
</Button>
)}
</div>
</div>
<div className="">
<div className="flex items-center justify-end space-x-2 mb-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
EnjoyApp.ffmpeg
.discover()
.then(({ ffmpegPath, ffprobePath }) => {
if (ffmpegPath && ffprobePath) {
toast.success(
t("ffmpegFoundAt", {
path: ffmpegPath + ", " + ffprobePath,
})
);
} else {
toast.warning(t("ffmpegNotFound"));
}
refreshFfmpegConfig();
});
}}
>
{t("scan")}
</Button>
<Button
variant={editing ? "outline" : "secondary"}
size="sm"
onClick={() => setEditing(!editing)}
>
{editing ? t("cancel") : t("edit")}
</Button>
</div>
</div>
</div>
</>
);
};
const WhisperSettings = () => {
const { whisperModel, whisperModelsPath } = useContext(
AppSettingsProviderContext
@@ -166,7 +320,9 @@ const WhisperSettings = () => {
<Dialog>
<DialogTrigger asChild>
<Button size="sm">{t("edit")}</Button>
<Button variant="secondary" size="sm">
{t("edit")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>{t("sttAiModel")}</DialogHeader>
@@ -196,7 +352,6 @@ const OpenaiSettings = () => {
const { openai, setOpenai } = useContext(AISettingsProviderContext);
const [editing, setEditing] = useState(false);
const ref = useRef<HTMLInputElement>();
const { toast } = useToast();
const handleSave = () => {
if (!ref.current) return;
@@ -206,10 +361,7 @@ const OpenaiSettings = () => {
});
setEditing(false);
toast({
title: t("success"),
description: t("openaiKeySaved"),
});
toast.success(t("openaiKeySaved"));
};
useEffect(() => {
@@ -247,7 +399,7 @@ const OpenaiSettings = () => {
</div>
<div className="">
<Button
variant={editing ? "secondary" : "default"}
variant={editing ? "outline" : "secondary"}
size="sm"
onClick={() => setEditing(!editing)}
>
@@ -264,7 +416,6 @@ const GoogleGenerativeAiSettings = () => {
);
const [editing, setEditing] = useState(false);
const ref = useRef<HTMLInputElement>();
const { toast } = useToast();
const handleSave = () => {
if (!ref.current) return;
@@ -274,10 +425,7 @@ const GoogleGenerativeAiSettings = () => {
});
setEditing(false);
toast({
title: t("success"),
description: t("googleGenerativeAiKeySaved"),
});
toast.success(t("googleGenerativeAiKeySaved"));
};
useEffect(() => {
@@ -315,7 +463,7 @@ const GoogleGenerativeAiSettings = () => {
</div>
<div className="">
<Button
variant={editing ? "secondary" : "default"}
variant={editing ? "outline" : "secondary"}
size="sm"
onClick={() => setEditing(!editing)}
>

View File

@@ -1,18 +1,23 @@
import { t } from "i18next";
import { Button, ScrollArea } from "@renderer/components/ui";
import { BasicSettings, AdvancedSettings, About, Hotkeys } from "@renderer/components";
import {
BasicSettings,
AdvancedSettings,
About,
Hotkeys,
} from "@renderer/components";
import { useState } from "react";
export const Preferences = () => {
const TABS = [
{
value: "basic",
label: t("basicSettings"),
label: t("basicSettingsShort"),
component: () => <BasicSettings />,
},
{
value: "advanced",
label: t("advancedSettings"),
label: t("advancedSettingsShort"),
component: () => <AdvancedSettings />,
},
{
@@ -30,8 +35,8 @@ export const Preferences = () => {
const [activeTab, setActiveTab] = useState<string>("basic");
return (
<div className="grid grid-cols-5">
<ScrollArea className="col-span-1 h-full bg-muted/50 p-4">
<div className="grid grid-cols-5 overflow-hidden h-full">
<ScrollArea className="h-full col-span-1 bg-muted/50 p-4">
<div className="py-2 text-muted-foreground mb-4">
{t("sidebar.preferences")}
</div>
@@ -50,7 +55,7 @@ export const Preferences = () => {
</Button>
))}
</ScrollArea>
<ScrollArea className="col-span-4 p-6">
<ScrollArea className="h-full col-span-4 py-6 px-10">
{TABS.find((tab) => tab.value === activeTab)?.component()}
</ScrollArea>
</div>

View File

@@ -96,7 +96,7 @@ export const PronunciationAssessmentScoreResult = (props: {
</div>
{!pronunciationScore && (
<div className="w-full h-full absolute z-30 bg-white/10 flex items-center justify-center">
<div className="w-full h-full absolute z-30 bg-background/10 flex items-center justify-center">
<Button size="lg" disabled={assessing} onClick={onAssess}>
{assessing && (
<LoaderIcon className="w-4 h-4 animate-spin inline mr-2" />

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from "react";
import RecordPlugin from "wavesurfer.js/dist/plugins/record";
import WaveSurfer from "wavesurfer.js";
import { cn } from "@renderer/lib/utils";
import { RadialProgress, useToast } from "@renderer/components/ui";
import { RadialProgress, toast } from "@renderer/components/ui";
import { useHotkeys } from "react-hotkeys-hook";
export const RecordButton = (props: {
@@ -16,7 +16,6 @@ export const RecordButton = (props: {
const { className, disabled, onRecordBegin, onRecordEnd } = props;
const [isRecording, setIsRecording] = useState<boolean>(false);
const [duration, setDuration] = useState<number>(0);
const { toast } = useToast();
useHotkeys(["command+alt+r", "control+alt+r"], () => {
if (disabled) return;
@@ -67,10 +66,7 @@ export const RecordButton = (props: {
if (duration > 1000) {
onRecordEnd(blob, duration);
} else {
toast({
description: t("recordTooShort"),
variant: "warning",
});
toast.warning(t("recordTooShort"));
}
}}
/>

View File

@@ -4,18 +4,26 @@ import { RecordingPlayer } from "@renderer/components";
import {
AlertDialog,
AlertDialogHeader,
AlertDialogTrigger,
AlertDialogDescription,
AlertDialogTitle,
AlertDialogContent,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
toast,
} from "@renderer/components/ui";
import { ChevronDownIcon, Trash2Icon, InfoIcon, Share2Icon } from "lucide-react";
import {
MoreHorizontalIcon,
Trash2Icon,
Share2Icon,
GaugeCircleIcon,
} from "lucide-react";
import { formatDateTime, secondsToTimestamp } from "@renderer/lib/utils";
import { t } from "i18next";
@@ -26,39 +34,67 @@ export const RecordingCard = (props: {
}) => {
const { recording, id, onSelect } = props;
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext);
const [isPlaying, setIsPlaying] = useState(false);
const handleDelete = () => {
EnjoyApp.recordings.destroy(recording.id);
};
const handleShare = async () => {
if (!recording.updatedAt) {
try {
await EnjoyApp.recordings.upload(recording.id);
} catch (error) {
toast.error(t("shareFailed"), { description: error.message });
return;
}
}
webApi
.createPost({
targetId: recording.id,
targetType: "Recording",
})
.then(() => {
toast.success(t("sharedSuccessfully"), {
description: t("sharedRecording"),
});
})
.catch((error) => {
toast.error(t("shareFailed"), {
description: error.message,
});
});
};
return (
<div id={id} className="flex items-center justify-end px-4 transition-all">
<DropdownMenu>
<div className="w-full">
<div className="bg-white rounded-lg py-2 px-4 relative mb-1">
<div className="flex items-center justify-end space-x-2">
<span className="text-xs text-muted-foreground">
{secondsToTimestamp(recording.duration / 1000)}
</span>
</div>
<div className="w-full">
<div className="bg-background rounded-lg py-2 px-4 relative mb-1">
<div className="flex items-center justify-end space-x-2">
<span className="text-xs text-muted-foreground">
{secondsToTimestamp(recording.duration / 1000)}
</span>
</div>
<RecordingPlayer
recording={recording}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying}
/>
<RecordingPlayer
recording={recording}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying}
/>
<div className="flex items-center justify-end space-x-2">
<Button
onClick={onSelect}
variant="ghost"
size="sm"
className="p-1 h-6"
>
<InfoIcon
className={`w-4 h-4
<div className="flex items-center justify-end space-x-2">
<Button
data-tooltip-id="global-tooltip"
data-tooltip-content={t("pronunciationAssessment")}
data-tooltip-place="bottom"
onClick={onSelect}
variant="ghost"
size="sm"
className="p-1 h-6"
>
<GaugeCircleIcon
className={`w-4 h-4
${
recording.pronunciationAssessment
? recording.pronunciationAssessment
@@ -71,29 +107,60 @@ export const RecordingCard = (props: {
: "text-muted-foreground"
}
`}
/>
</Button>
/>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
data-tooltip-id="global-tooltip"
data-tooltip-content={t("share")}
data-tooltip-place="bottom"
variant="ghost"
size="sm"
className="p-1 h-6"
>
<Share2Icon className="w-4 h-4 text-muted-foreground" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("shareRecording")}</AlertDialogTitle>
<AlertDialogDescription>
{t("areYouSureToShareThisRecordingToCommunity")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={handleShare}>{t("share")}</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DropdownMenu>
<DropdownMenuTrigger>
<ChevronDownIcon className="w-4 h-4 text-muted-foreground" />
<MoreHorizontalIcon className="w-4 h-4 text-muted-foreground" />
</DropdownMenuTrigger>
</div>
</div>
<div className="flex justify-end">
<span className="text-xs text-muted-foreground">
{formatDateTime(recording.createdAt)}
</span>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setIsDeleteDialogOpen(true)}>
<span className="mr-auto text-destructive capitalize">
{t("delete")}
</span>
<Trash2Icon className="w-4 h-4 text-destructive" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => setIsDeleteDialogOpen(true)}>
<span className="mr-auto text-destructive capitalize">
{t("delete")}
</span>
<Trash2Icon className="w-4 h-4 text-destructive" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex justify-end">
<span className="text-xs text-muted-foreground">
{formatDateTime(recording.createdAt)}
</span>
</div>
</div>
<AlertDialog
open={isDeleteDialogOpen}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState, useRef, useCallback } from "react";
import WaveSurfer from "wavesurfer.js";
import { PitchContour } from "@renderer/components";
import { Button } from "@renderer/components/ui";
import { Button, Skeleton } from "@renderer/components/ui";
import { PlayIcon, PauseIcon } from "lucide-react";
import { useIntersectionObserver } from "@uidotdev/usehooks";
@@ -30,6 +30,7 @@ export const RecordingPlayer = (props: {
const [ref, entry] = useIntersectionObserver({
threshold: 0,
});
const [initialized, setInitialized] = useState(false);
const onPlayClick = useCallback(() => {
wavesurfer.isPlaying() ? wavesurfer.pause() : wavesurfer.play();
@@ -40,6 +41,7 @@ export const RecordingPlayer = (props: {
// when the player is visible
if (!entry?.isIntersecting) return;
if (!recording?.src) return;
if (wavesurfer) return;
const ws = WaveSurfer.create({
container: containerRef.current,
@@ -78,6 +80,7 @@ export const RecordingPlayer = (props: {
height,
})
);
setInitialized(true);
}),
];
@@ -105,7 +108,15 @@ export const RecordingPlayer = (props: {
return (
<div ref={ref} className="grid grid-cols-11 xl:grid-cols-12 items-center">
<div className="flex justify-center">
{!initialized && (
<div className="col-span-9 flex flex-col justify-around h-[80px]">
<Skeleton className="h-3 w-full rounded-full" />
<Skeleton className="h-3 w-full rounded-full" />
<Skeleton className="h-3 w-full rounded-full" />
</div>
)}
<div className={`flex justify-center ${initialized ? "" : "hidden"}`}>
<Button
onClick={onPlayClick}
className="aspect-square rounded-full p-2 w-12 h-12 bg-blue-600 hover:bg-blue-500"
@@ -118,7 +129,10 @@ export const RecordingPlayer = (props: {
</Button>
</div>
<div className="col-span-10 xl:col-span-11" ref={containerRef}></div>
<div
className={`col-span-10 xl:col-span-11 ${initialized ? "" : "hidden"}`}
ref={containerRef}
></div>
</div>
);
};

View File

@@ -44,3 +44,35 @@ export const ResetAllButton = (props: { children: React.ReactNode }) => {
</AlertDialog>
);
};
export const ResetSettingsButton = (props: { children: React.ReactNode }) => {
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const reset = () => {
EnjoyApp.app.resetSettings();
};
return (
<AlertDialog>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("resetSettings")}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
{t("resetSettingsConfirmation")}
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive hover:bg-destructive-hover"
onClick={reset}
>
{t("resetSettings")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -14,6 +14,7 @@ import {
BookMarkedIcon,
UserIcon,
BotIcon,
UsersRoundIcon,
} from "lucide-react";
import { useLocation, Link } from "react-router-dom";
import { t } from "i18next";
@@ -50,6 +51,21 @@ export const Sidebar = () => {
<span className="hidden xl:block">{t("sidebar.home")}</span>
</Button>
</Link>
<Link
to="/community"
data-tooltip-id="sidebar-tooltip"
data-tooltip-content={t("sidebar.community")}
className="block"
>
<Button
variant={activeTab === "" ? "secondary" : "ghost"}
className="w-full xl:justify-start"
>
<UsersRoundIcon className="xl:mr-2 h-5 w-5" />
<span className="hidden xl:block">{t("sidebar.community")}</span>
</Button>
</Link>
</div>
</div>

View File

@@ -7,10 +7,10 @@ import { AppSettingsProviderContext } from "@renderer/context";
export const StoriesSegment = () => {
const [stories, setStorys] = useState<StoryType[]>([]);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const { webApi } = useContext(AppSettingsProviderContext);
const fetchStorys = async () => {
EnjoyApp.webApi.mineStories().then((response) => {
webApi.mineStories().then((response) => {
if (response?.stories) {
setStorys(response.stories);
}

View File

@@ -2,6 +2,16 @@ import {
Alert,
AlertTitle,
AlertDescription,
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
Button,
ScrollArea,
Separator,
Sheet,
@@ -17,6 +27,7 @@ import {
ScanTextIcon,
LoaderIcon,
StarIcon,
Share2Icon,
} from "lucide-react";
import { t } from "i18next";
@@ -36,6 +47,7 @@ export const StoryToolbar = (props: {
marked?: boolean;
toggleMarked?: () => void;
pendingLookups?: LookupType[];
handleShare?: () => void;
}) => {
const {
starred,
@@ -47,6 +59,7 @@ export const StoryToolbar = (props: {
toggleMarked,
meanings = [],
pendingLookups = [],
handleShare,
} = props;
const [vocabularyVisible, setVocabularyVisible] = useState<boolean>(
@@ -76,6 +89,27 @@ export const StoryToolbar = (props: {
<ToolbarButton toggled={starred} onClick={toggleStarred}>
<StarIcon className="w-6 h-6" />
</ToolbarButton>
<AlertDialog>
<AlertDialogTrigger asChild>
<ToolbarButton toggled={false} onClick={toggleStarred}>
<Share2Icon className="w-6 h-6" />
</ToolbarButton>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("shareStory")}</AlertDialogTitle>
<AlertDialogDescription>
{t("areYouSureToShareThisStoryToCommunity")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction>
<Button onClick={handleShare}>{t("share")}</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</FloatingToolbar>
<Sheet

View File

@@ -13,7 +13,7 @@ import { debounce , uniq } from "lodash";
import Mark from "mark.js";
export const StoryViewer = (props: {
story: StoryType & Partial<CreateStoryParamsType>;
story: Partial<StoryType> & Partial<CreateStoryParamsType>;
marked?: boolean;
meanings?: MeaningType[];
setMeanings: (meanings: MeaningType[]) => void;
@@ -96,7 +96,7 @@ export const StoryViewer = (props: {
return (
<>
<div className="w-full max-w-2xl xl:max-w-3xl mx-auto sticky bg-white top-0 z-30 px-4 py-2 border-b">
<div className="w-full max-w-2xl xl:max-w-3xl mx-auto sticky bg-background top-0 z-30 px-4 py-2 border-b">
<div className="w-full flex items-center space-x-4">
<Button
variant="ghost"
@@ -130,10 +130,10 @@ export const StoryViewer = (props: {
</div>
</div>
</div>
<div className="bg-white py-6 px-8 max-w-2xl xl:max-w-3xl mx-auto relative shadow-lg">
<div className="bg-background py-6 px-8 max-w-2xl xl:max-w-3xl mx-auto relative shadow-lg">
<article
ref={ref}
className="relative select-text prose prose-lg xl:prose-xl font-serif text-lg"
className="relative select-text prose dark:prose-invert prose-lg xl:prose-xl font-serif text-lg"
>
<h2>
{story.title.split(" ").map((word, i) => (

View File

@@ -41,8 +41,8 @@ export const ToolbarButton = (props: {
className={cn(
`rounded-full p-3 h-12 w-12 ${
toggled
? "bg-primary text-white"
: "bg-white text-muted-foreground hover:text-white "
? "bg-primary dark:bg-background text-background dark:text-foreground"
: "bg-background dark:bg-muted text-muted-foreground hover:text-background "
}`,
className
)}

View File

@@ -13,9 +13,9 @@ export * from "./input";
export * from "./avatar";
export * from "./alert-dialog";
export * from "./card";
export * from "./toast";
export * from "./use-toast";
export * from "./toaster";
// export * from "./toast";
// export * from "./use-toast";
// export * from "./toaster";
export * from "./toggle";
export * from "./radio-group";
export * from "./scroll-area";
@@ -33,3 +33,4 @@ export * from "./select";
export * from "./sheet";
export * from "./hover-card";
export * from "./floating-toolbar";
export * from "./sonner";

View File

@@ -0,0 +1,31 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner, toast } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "light" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster, toast };

View File

@@ -0,0 +1 @@
export * from './users-rankings';

View File

@@ -0,0 +1,83 @@
import { useContext, useEffect, useState } from "react";
import {
Avatar,
AvatarImage,
AvatarFallback,
Card,
CardTitle,
CardHeader,
CardContent,
} from "@renderer/components/ui";
import { AppSettingsProviderContext } from "@renderer/context";
import { t } from "i18next";
import { formatDuration } from "@renderer/lib/utils";
export const UsersRankings = () => {
return (
<div className="grid grid-cols-2 gap-6 mb-6">
<RankingsCard range="day" />
<RankingsCard range="week" />
<RankingsCard range="month" />
<RankingsCard range="all" />
</div>
);
};
const RankingsCard = (props: {
range: "day" | "week" | "month" | "year" | "all";
}) => {
const { range } = props;
const { webApi } = useContext(AppSettingsProviderContext);
const [rankings, setRankings] = useState<UserType[]>([]);
const fetchRankings = async () => {
webApi.rankings(range).then(
(res) => {
setRankings(res.rankings);
},
(err) => {
console.error(err);
}
);
};
useEffect(() => {
fetchRankings();
}, []);
return (
<Card>
<CardHeader>
<CardTitle>{t(`${range}Rankings`)}</CardTitle>
</CardHeader>
<CardContent>
{rankings.length === 0 && (
<div className="text-center text-gray-500">
{t("noOneHasRecordedYet")}
</div>
)}
{rankings.map((user, index) => (
<div key={user.id} className="flex items-center space-x-4 p-2">
<div className="font-mono text-sm">#{index + 1}</div>
<div className="flex items-center space-x-2">
<Avatar className="w-8 h-8">
<AvatarImage src={user.avatarUrl} />
<AvatarFallback className="text-xl">
{user.name[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="max-w-20 truncate">{user.name}</div>
</div>
<div className="flex-1 font-serif text-right">
{formatDuration(user.recordingsDuration, "millisecond")}
</div>
</div>
))}
</CardContent>
</Card>
);
};

View File

@@ -11,16 +11,29 @@ import {
MediaTranscription,
} from "@renderer/components";
import { LoaderIcon } from "lucide-react";
import { ScrollArea } from "@renderer/components/ui";
import {
AlertDialog,
AlertDialogHeader,
AlertDialogDescription,
AlertDialogTitle,
AlertDialogContent,
AlertDialogFooter,
AlertDialogCancel,
Button,
ScrollArea,
toast,
} from "@renderer/components/ui";
import { t } from "i18next";
export const VideoDetail = (props: { id?: string; md5?: string }) => {
const { id, md5 } = props;
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext);
const [video, setVideo] = useState<VideoType | null>(null);
const [transcription, setTranscription] = useState<TranscriptionType>(null);
const [initialized, setInitialized] = useState<boolean>(false);
const [sharing, setSharing] = useState<boolean>(false);
// Player controls
const [currentTime, setCurrentTime] = useState<number>(0);
@@ -35,6 +48,8 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
const [isPlaying, setIsPlaying] = useState(false);
const [isLooping, setIsLooping] = useState(false);
const [playBackRate, setPlaybackRate] = useState<number>(1);
const [displayInlineCaption, setDisplayInlineCaption] =
useState<boolean>(true);
const onTransactionUpdate = (event: CustomEvent) => {
const { model, action, record } = event.detail || {};
@@ -43,6 +58,39 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
}
};
const handleShare = async () => {
if (!video.source.startsWith("http")) {
toast.error(t("shareFailed"), {
description: t("cannotShareLocalVideo"),
});
return;
}
if (!video.source && !video.isUploaded) {
try {
await EnjoyApp.videos.upload(video.id);
} catch (err) {
toast.error(t("shareFailed"), { description: err.message });
return;
}
}
webApi
.createPost({
targetType: "Video",
targetId: video.id,
})
.then(() => {
toast.success(t("sharedSuccessfully"), {
description: t("sharedVideo"),
});
})
.catch((err) => {
toast.error(t("shareFailed"), { description: err.message });
});
setSharing(false);
};
useEffect(() => {
const where = id ? { id } : { md5 };
EnjoyApp.videos.findOne(where).then((video) => {
@@ -90,7 +138,7 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
mediaId={video.id}
mediaType="Video"
mediaUrl={video.src}
waveformCacheKey={`waveform-video-${video.md5}`}
mediaMd5={video.md5}
transcription={transcription}
currentTime={currentTime}
setCurrentTime={setCurrentTime}
@@ -109,6 +157,9 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
setIsLooping={setIsLooping}
playBackRate={playBackRate}
setPlaybackRate={setPlaybackRate}
displayInlineCaption={displayInlineCaption}
setDisplayInlineCaption={setDisplayInlineCaption}
onShare={() => setSharing(true)}
/>
<ScrollArea
@@ -149,8 +200,25 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
</div>
</div>
<AlertDialog open={sharing} onOpenChange={(value) => setSharing(value)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("shareAudio")}</AlertDialogTitle>
<AlertDialogDescription>
{t("areYouSureToShareThisAudioToCommunity")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<Button variant="default" onClick={handleShare}>
{t("share")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{!initialized && (
<div className="top-0 w-full h-full absolute z-30 bg-white/10 flex items-center justify-center">
<div className="top-0 w-full h-full absolute z-30 bg-background/10 flex items-center justify-center">
<LoaderIcon className="text-muted-foreground animate-spin w-8 h-8" />
</div>
)}

View File

@@ -4,6 +4,7 @@ import {
VideosTable,
VideoEditForm,
AddMediaButton,
LoaderSpin,
} from "@renderer/components";
import { t } from "i18next";
import {
@@ -19,10 +20,12 @@ import {
AlertDialogDescription,
AlertDialogCancel,
AlertDialogAction,
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
toast,
} from "@renderer/components/ui";
import {
DbProviderContext,
@@ -43,11 +46,10 @@ export const VideosComponent = () => {
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const navigate = useNavigate();
const [offset, setOffest] = useState(0);
const [loading, setLoading] = useState(false);
useEffect(() => {
fetchVideos();
}, []);
const navigate = useNavigate();
useEffect(() => {
addDblistener(onVideosUpdate);
@@ -59,12 +61,36 @@ export const VideosComponent = () => {
}, []);
const fetchVideos = async () => {
const videos = await EnjoyApp.videos.findAll({
limit: 10,
});
if (!videos) return;
if (loading) return;
if (offset === -1) return;
dispatchVideos({ type: "set", records: videos });
setLoading(true);
const limit = 10;
EnjoyApp.videos
.findAll({
offset,
limit,
})
.then((_videos) => {
if (_videos.length === 0) {
setOffest(-1);
return;
}
if (_videos.length < limit) {
setOffest(-1);
} else {
setOffest(offset + _videos.length);
}
dispatchVideos({ type: "append", records: _videos });
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setLoading(false);
});
};
const onVideosUpdate = (event: CustomEvent) => {
@@ -93,6 +119,8 @@ export const VideosComponent = () => {
};
if (videos.length === 0) {
if (loading) return <LoaderSpin />;
return (
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
<AddMediaButton />
@@ -135,6 +163,14 @@ export const VideosComponent = () => {
</Tabs>
</div>
{offset > -1 && (
<div className="flex items-center justify-center my-4">
<Button variant="link" onClick={fetchVideos}>
{t("loadMore")}
</Button>
</div>
)}
<Dialog
open={!!editing}
onOpenChange={(value) => {

View File

@@ -14,7 +14,7 @@ import {
CardContent,
CardFooter,
ScrollArea,
useToast,
toast,
Progress,
} from "@renderer/components/ui";
import { t } from "i18next";
@@ -67,10 +67,8 @@ export const WhisperModelOptionsPanel = () => {
export const WhisperModelOptions = () => {
const [selectingModel, setSelectingModel] = useState<ModelType | null>(null);
const [availableModels, setAvailableModels] = useState<ModelType[]>([]);
const { whisperModelsPath, whisperModel, setWhisperModel, EnjoyApp } = useContext(
AppSettingsProviderContext
);
const { toast } = useToast();
const { whisperModelsPath, whisperModel, setWhisperModel, EnjoyApp } =
useContext(AppSettingsProviderContext);
useEffect(() => {
updateAvailableModels();
@@ -126,10 +124,7 @@ export const WhisperModelOptions = () => {
if (option.downloaded) {
setWhisperModel(option.name);
} else if (option.downloadState) {
toast({
title: "Downloading",
description: `${option.name} is downloading...`,
});
toast.warning(t("downloading", { file: option.name }));
} else {
setSelectingModel(option);
}

View File

@@ -1,6 +1,10 @@
import { createContext, useEffect, useState } from "react";
import { WEB_API_URL } from "@/constants";
import { Client } from "@/api";
import i18n from "@renderer/i18n";
type AppSettingsProviderState = {
webApi: Client;
user: UserType | null;
initialized: boolean;
version?: string;
@@ -14,9 +18,12 @@ type AppSettingsProviderState = {
ffmpegConfig?: FfmpegConfigType;
setFfmegConfig?: (config: FfmpegConfigType) => void;
EnjoyApp?: EnjoyAppType;
language?: "en" | "zh-CN";
switchLanguage?: (language: "en" | "zh-CN") => void;
};
const initialState: AppSettingsProviderState = {
webApi: null,
user: null,
initialized: false,
};
@@ -31,11 +38,14 @@ export const AppSettingsProvider = ({
}) => {
const [initialized, setInitialized] = useState<boolean>(false);
const [version, setVersion] = useState<string>("");
const [apiUrl, setApiUrl] = useState<string>(WEB_API_URL);
const [webApi, setWebApi] = useState<Client>(null);
const [user, setUser] = useState<UserType | null>(null);
const [libraryPath, setLibraryPath] = useState("");
const [whisperModelsPath, setWhisperModelsPath] = useState<string>("");
const [whisperModel, setWhisperModel] = useState<string>(null);
const [ffmpegConfig, setFfmegConfig] = useState<FfmpegConfigType>(null);
const [language, setLanguage] = useState<"en" | "zh-CN">();
const EnjoyApp = window.__ENJOY_APP__;
useEffect(() => {
@@ -44,6 +54,7 @@ export const AppSettingsProvider = ({
fetchLibraryPath();
fetchModel();
fetchFfmpegConfig();
fetchLanguage();
}, []);
useEffect(() => {
@@ -54,6 +65,30 @@ export const AppSettingsProvider = ({
validate();
}, [user, libraryPath, whisperModel, ffmpegConfig]);
useEffect(() => {
if (!apiUrl) return;
setWebApi(
new Client({
baseUrl: apiUrl,
accessToken: user?.accessToken,
})
);
}, [user, apiUrl]);
const fetchLanguage = async () => {
const language = await EnjoyApp.settings.getLanguage();
setLanguage(language as "en" | "zh-CN");
i18n.changeLanguage(language);
};
const switchLanguage = (language: "en" | "zh-CN") => {
EnjoyApp.settings.switchLanguage(language).then(() => {
i18n.changeLanguage(language);
setLanguage(language);
});
};
const fetchFfmpegConfig = async () => {
const config = await EnjoyApp.settings.getFfmpegConfig();
setFfmegConfig(config);
@@ -65,10 +100,18 @@ export const AppSettingsProvider = ({
};
const fetchUser = async () => {
const apiUrl = await EnjoyApp.app.apiUrl();
setApiUrl(apiUrl);
const currentUser = await EnjoyApp.settings.getUser();
if (!currentUser) return;
EnjoyApp.webApi.me().then((user) => {
const client = new Client({
baseUrl: apiUrl,
accessToken: currentUser.accessToken,
});
client.me().then((user) => {
if (user?.id) {
login(currentUser);
} else {
@@ -107,6 +150,10 @@ export const AppSettingsProvider = ({
setWhisperModel(whisperModel);
};
const fetchApiUrl = async () => {
return apiUrl;
};
const setModelHandler = async (name: string) => {
await EnjoyApp.settings.setWhisperModel(name);
setWhisperModel(name);
@@ -121,8 +168,11 @@ export const AppSettingsProvider = ({
return (
<AppSettingsProviderContext.Provider
value={{
language,
switchLanguage,
EnjoyApp,
version,
webApi,
user,
login,
logout,

View File

@@ -19,10 +19,9 @@ i18n
.use(initReactI18next) // passes i18n down to react-i18next
.init({
resources,
lng: "zh-CN", // language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources
// you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage
// if you're using a language detector, do not define the lng option
lng: "en",
supportedLngs: ["en", "zh-CN"],
fallbackLng: "en",
interpolation: {
escapeValue: false, // react already safes from xss
},

View File

@@ -3,10 +3,12 @@ import { twMerge } from "tailwind-merge";
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import relativeTime from "dayjs/plugin/relativeTime";
import duration, { type DurationUnitType } from "dayjs/plugin/duration";
import "dayjs/locale/en";
import "dayjs/locale/zh-cn";
import i18next, { t } from "i18next";
dayjs.extend(localizedFormat);
dayjs.extend(duration);
dayjs.extend(relativeTime);
export function cn(...inputs: ClassValue[]) {
@@ -18,6 +20,23 @@ export function secondsToTimestamp(seconds: number) {
return date.toISOString().substr(11, 8);
}
export function humanizeDuration(
duration: number,
unit: DurationUnitType = "second"
) {
dayjs.locale(i18next.resolvedLanguage?.toLowerCase() || "en");
return dayjs.duration(duration, unit).humanize();
}
export function formatDuration(
duration: number,
unit: DurationUnitType = "second",
format = "HH:mm:ss"
) {
dayjs.locale(i18next.resolvedLanguage?.toLowerCase() || "en");
return dayjs.duration(duration, unit).format(format);
}
export function bytesToSize(bytes: number) {
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) {

View File

@@ -0,0 +1,49 @@
import {
Button,
Tabs,
TabsList,
TabsContent,
TabsTrigger,
} from "@renderer/components/ui";
import { UsersRankings, Posts } from "@renderer/components";
import { ChevronLeftIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { t } from "i18next";
export default () => {
const navigate = useNavigate();
return (
<div className="bg-muted h-full px-4 lg:px-8 py-6">
<div className="max-w-screen-md mx-auto mb-6">
<div className="flex space-x-1 items-center mb-4">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ChevronLeftIcon className="w-5 h-5" />
</Button>
<span>{t("sidebar.community")}</span>
</div>
<Tabs defaultValue="square">
<TabsList className="mb-4">
<TabsTrigger value="square">{t("square")}</TabsTrigger>
<TabsTrigger
value="rankings"
disabled
className="cursor-not-allowed"
data-tooltip-id="global-tooltip"
data-tooltip-content={t("comingSoon")}
>
{t("rankings")}
</TabsTrigger>
</TabsList>
<TabsContent value="square">
<Posts />
</TabsContent>
<TabsContent value="rankings"></TabsContent>
</Tabs>
</div>
</div>
);
};

View File

@@ -6,13 +6,9 @@ import {
Sheet,
SheetContent,
SheetTrigger,
useToast,
toast,
} from "@renderer/components/ui";
import {
MessageComponent,
ConversationForm,
SpeechForm,
} from "@renderer/components";
import { MessageComponent, ConversationForm } from "@renderer/components";
import { SendIcon, BotIcon, LoaderIcon, SettingsIcon } from "lucide-react";
import { Link, useParams } from "react-router-dom";
import { t } from "i18next";
@@ -32,7 +28,6 @@ export default () => {
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const [content, setConent] = useState<string>("");
const [submitting, setSubmitting] = useState<boolean>(false);
const { toast } = useToast();
const [messages, dispatchMessages] = useReducer(messagesReducer, []);
const [offset, setOffest] = useState(0);
@@ -82,10 +77,7 @@ export default () => {
const handleSubmit = async (text?: string, file?: string) => {
if (submitting) {
toast({
title: t("warning"),
description: t("anotherRequestIsPending"),
});
toast.warning(t("anotherRequestIsPending"));
}
text = text ? text : content;
@@ -279,7 +271,7 @@ export default () => {
</ScrollArea>
<div className="px-4 absolute w-full bottom-0 left-0 h-14 bg-muted z-50">
<div className="focus-within:bg-white px-4 py-2 flex items-center space-x-4 rounded-lg border">
<div className="focus-within:bg-background px-4 py-2 flex items-center space-x-4 rounded-lg border">
<Textarea
rows={1}
ref={inputRef}
@@ -287,7 +279,7 @@ export default () => {
value={content}
onChange={(e) => setConent(e.target.value)}
placeholder={t("pressEnterToSend")}
className="px-0 py-0 shadow-none border-none focus-visible:outline-0 focus-visible:ring-0 border-none bg-muted focus:bg-white min-h-[1.25rem] max-h-[3.5rem] !overflow-x-hidden"
className="px-0 py-0 shadow-none border-none focus-visible:outline-0 focus-visible:ring-0 border-none bg-muted focus:bg-background min-h-[1.25rem] max-h-[3.5rem] !overflow-x-hidden"
/>
<Button
type="submit"

View File

@@ -86,7 +86,7 @@ export default () => {
{conversations.map((conversation) => (
<Link key={conversation.id} to={`/conversations/${conversation.id}`}>
<div
className="bg-white text-primary rounded-full w-full mb-2 p-4 hover:bg-primary hover:text-white cursor-pointer flex items-center"
className="bg-background text-muted-foreground rounded-full w-full mb-2 p-4 hover:bg-primary hover:text-muted cursor-pointer flex items-center"
style={{
borderLeftColor: `#${conversation.id
.replaceAll("-", "")

View File

@@ -6,7 +6,6 @@ import {
LoginForm,
ChooseLibraryPathInput,
WhisperModelOptionsPanel,
UserCard,
FfmpegCheck,
} from "@renderer/components";
import { AppSettingsProviderContext } from "@renderer/context";
@@ -93,7 +92,7 @@ export default () => {
</div>
</div>
<div className="flex-1 flex justify-center items-center">
{currentStep == 1 && (user ? <UserCard user={user} /> : <LoginForm />)}
{currentStep == 1 && <LoginForm />}
{currentStep == 2 && <ChooseLibraryPathInput />}
{currentStep == 3 && <WhisperModelOptionsPanel />}
{currentStep == 4 && <FfmpegCheck />}

View File

@@ -1,19 +1,25 @@
import { Button } from "@renderer/components/ui";
import { StoryForm, StoryCard, LoaderSpin } from "@renderer/components";
import { useState, useContext, useEffect } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { t } from "i18next";
export default () => {
const [stories, setStorys] = useState<StoryType[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const { webApi } = useContext(AppSettingsProviderContext);
const [nextPage, setNextPage] = useState(1);
const fetchStorys = async () => {
EnjoyApp.webApi
const fetchStories = async (page: number = nextPage) => {
if (!page) return;
webApi
.mineStories()
.then((response) => {
if (response?.stories) {
setStorys(response.stories);
setStorys([...stories, ...response.stories]);
}
setNextPage(response.next);
})
.finally(() => {
setLoading(false);
@@ -21,7 +27,7 @@ export default () => {
};
useEffect(() => {
fetchStorys();
fetchStories();
}, []);
return (
@@ -38,6 +44,14 @@ export default () => {
))}
</div>
)}
{nextPage && (
<div className="py-4 flex justify-center">
<Button variant="link" onClick={() => fetchStories(nextPage)}>
{t("loadMore")}
</Button>
</div>
)}
</div>
);
};

View File

@@ -1,4 +1,4 @@
import { Input, Button, ScrollArea, useToast } from "@renderer/components/ui";
import { Input, Button, ScrollArea, toast } from "@renderer/components/ui";
import {
LoaderSpin,
StoryViewer,
@@ -26,8 +26,7 @@ export default () => {
});
const [loading, setLoading] = useState(true);
const [readable, setReadable] = useState(true);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const { toast } = useToast();
const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext);
const [meanings, setMeanings] = useState<MeaningType[]>([]);
const [marked, setMarked] = useState<boolean>(false);
const [doc, setDoc] = useState<any>(null);
@@ -52,7 +51,7 @@ export default () => {
const createStory = async () => {
if (!story) return;
EnjoyApp.webApi
webApi
.createStory({
url: story.metadata?.url || story.url,
...story,
@@ -73,10 +72,7 @@ export default () => {
if (state == "did-fail-load") {
setLoading(false);
if (error) {
toast({
title: error,
variant: "destructive",
});
toast.error(error);
setError(error);
}

View File

@@ -1,5 +1,5 @@
import { t } from "i18next";
import { ScrollArea } from "@renderer/components/ui";
import { ScrollArea, toast } from "@renderer/components/ui";
import {
LoaderSpin,
PagePlaceholder,
@@ -16,7 +16,7 @@ nlp.plugin(paragraphs);
let timeout: NodeJS.Timeout = null;
export default () => {
const { id } = useParams<{ id: string }>();
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const { webApi } = useContext(AppSettingsProviderContext);
const [loading, setLoading] = useState<boolean>(true);
const [story, setStory] = useState<StoryType>();
const [meanings, setMeanings] = useState<MeaningType[]>([]);
@@ -26,7 +26,7 @@ export default () => {
const [doc, setDoc] = useState<any>(null);
const fetchStory = async () => {
EnjoyApp.webApi
webApi
.story(id)
.then((story) => {
setStory(story);
@@ -41,7 +41,7 @@ export default () => {
const fetchMeanings = async () => {
setScanning(true);
EnjoyApp.webApi
webApi
.storyMeanings(id, { items: 500 })
.then((response) => {
if (!response) return;
@@ -88,14 +88,14 @@ export default () => {
});
});
EnjoyApp.webApi.lookupInBatch(vocabulary).then((response) => {
webApi.lookupInBatch(vocabulary).then((response) => {
const { errors } = response;
if (errors.length > 0) {
console.warn(errors);
return;
}
EnjoyApp.webApi.extractVocabularyFromStory(id).then(() => {
webApi.extractVocabularyFromStory(id).then(() => {
fetchStory();
if (pendingLookups.length > 0) return;
@@ -108,16 +108,29 @@ export default () => {
if (!story) return;
if (story.starred) {
EnjoyApp.webApi.unstarStory(id).then((result) => {
webApi.unstarStory(id).then((result) => {
setStory({ ...story, starred: result.starred });
});
} else {
EnjoyApp.webApi.starStory(id).then((result) => {
webApi.starStory(id).then((result) => {
setStory({ ...story, starred: result.starred });
});
}
};
const handleShare = async () => {
webApi
.createPost({ targetId: story.id, targetType: "Story" })
.then(() => {
toast.success(t("sharedStory"));
})
.catch((error) => {
toast.error(t("shareFailed"), {
description: error.message,
});
});
};
useEffect(() => {
fetchStory();
fetchMeanings();
@@ -162,6 +175,7 @@ export default () => {
starred={story.starred}
toggleStarred={toggleStarred}
pendingLookups={pendingLookups}
handleShare={handleShare}
/>
<StoryViewer

View File

@@ -11,14 +11,14 @@ export default () => {
const [loading, setLoading] = useState<boolean>(false);
const [meanings, setMeanings] = useState<MeaningType[]>([]);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const { webApi } = useContext(AppSettingsProviderContext);
const [currentIndex, setCurrentIndex] = useState<number>(0);
const [nextPage, setNextPage] = useState(1);
const fetchMeanings = async (page: number = nextPage) => {
if (!page) return;
EnjoyApp.webApi
webApi
.mineMeanings({ page, items: 10 })
.then((response) => {
setMeanings([...meanings, ...response.meanings]);
@@ -38,7 +38,8 @@ export default () => {
}
return (
<div className="h-[100vh] max-w-screen-md mx-auto px-4 py-6">
<div className="h-[100vh] bg-muted">
<div className="max-w-screen-md mx-auto px-4 py-6">
<div className="flex space-x-1 items-center mb-4">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ChevronLeftIcon className="w-5 h-5" />
@@ -62,7 +63,7 @@ export default () => {
>
<ChevronLeftIcon className="w-5 h-5" />
</Button>
<div className="flex-1 h-5/6 border p-6 rounded-xl shadow-lg">
<div className="bg-background flex-1 h-5/6 border p-6 rounded-xl shadow-lg">
<MeaningMemorizingCard meaning={meanings[currentIndex]} />
</div>
<Button
@@ -83,5 +84,6 @@ export default () => {
</div>
)}
</div>
</div>
);
};

View File

@@ -1,12 +1,19 @@
export const audiosReducer = (
audios: AudioType[],
action: {
type: "create" | "update" | "destroy" | "set";
type: "append" | "create" | "update" | "destroy" | "set";
record?: Partial<AudioType>;
records?: Partial<AudioType>[];
}
) => {
switch (action.type) {
case "append": {
if (action.record) {
return [...audios, action.record];
} else if (action.records) {
return [...audios, ...action.records];
}
}
case "create": {
return [action.record, ...audios];
}

View File

@@ -1,12 +1,19 @@
export const videosReducer = (
videos: VideoType[],
action: {
type: "create" | "update" | "destroy" | "set";
type: "append" | "create" | "update" | "destroy" | "set";
record?: Partial<VideoType>;
records?: Partial<VideoType>[];
}
) => {
switch (action.type) {
case "append": {
if (action.record) {
return [...videos, action.record];
} else if (action.records) {
return [...videos, ...action.records];
}
}
case "create": {
return [action.record, ...videos];
}

View File

@@ -14,6 +14,7 @@ import Story from "./pages/story";
import Books from "./pages/books";
import Profile from "./pages/profile";
import Home from "./pages/home";
import Community from "./pages/community";
import StoryPreview from "./pages/story-preview";
export default createHashRouter([
@@ -23,6 +24,10 @@ export default createHashRouter([
errorElement: <ErrorPage />,
children: [
{ index: true, element: <Home /> },
{
path: "/community",
element: <Community />,
},
{
path: "/profile",
element: <Profile />,

30
enjoy/src/types.d.ts vendored
View File

@@ -75,11 +75,13 @@ type TransactionStateType = {
record?: AudioType | UserType | RecordingType;
};
type FfmpegConfigType = {
os: string;
arch: string;
commandExists: boolean;
ffmpegPath?: string;
ffprobePath?: string;
scanDirs: string[];
ready: boolean;
};
@@ -105,37 +107,11 @@ type MeaningType = {
lookups: LookupType[];
};
type StoryType = {
id: string;
url: string;
title: string;
content: string;
metadata: {
[key: string]: string;
};
vocabulary?: string[];
extracted?: boolean;
starred?: boolean;
createdAt: Date;
updatedAt: Date;
};
type CreateStoryParamsType = {
title: string;
content: string;
url: string;
html: string;
metadata: {
[key: string]: string;
};
};
type PagyResponseType = {
page: number;
next: number | null;
};
type AudibleBookType = {
title: string;
subtitle: string;

View File

@@ -11,6 +11,7 @@ type AudioType = {
transcribing?: boolean;
recordingsCount?: number;
recordingsDuration?: number;
isUploaded?: boolean;
uploadedAt?: Date;
createdAt: Date;
updatedAt: Date;

View File

@@ -1,6 +1,7 @@
type EnjoyAppType = {
app: {
reset: () => Promise<void>;
resetSettings: () => Promise<void>;
relaunch: () => Promise<void>;
reload: () => Promise<void>;
isPackaged: () => Promise<boolean>;
@@ -75,7 +76,9 @@ type EnjoyAppType = {
LlmProviderType
) => Promise<void>;
getFfmpegConfig: () => Promise<FfmpegConfigType>;
setFfmpegConfig: () => Promise<void>;
setFfmpegConfig: (config: FfmpegConfigType) => Promise<void>;
getLanguage: () => Promise<string>;
switchLanguage: (language: string) => Promise<void>;
};
fs: {
ensureDir: (path: string) => Promise<boolean>;
@@ -93,7 +96,7 @@ type EnjoyAppType = {
audios: {
findAll: (params: object) => Promise<AudioType[]>;
findOne: (params: object) => Promise<AudioType>;
create: (source: string, params?: object) => Promise<AudioType>;
create: (uri: string, params?: object) => Promise<AudioType>;
update: (id: string, params: object) => Promise<AudioType | undefined>;
destroy: (id: string) => Promise<undefined>;
transcribe: (id: string) => Promise<void>;
@@ -102,8 +105,8 @@ type EnjoyAppType = {
videos: {
findAll: (params: object) => Promise<VideoType[]>;
findOne: (params: object) => Promise<VideoType>;
create: (source: string, params?: object) => Promise<VideoType>;
update: (id: string, params: object) => Promise<VideoType | undefined>;
create: (uri: string, params?: any) => Promise<VideoType>;
update: (id: string, params: any) => Promise<VideoType | undefined>;
destroy: (id: string) => Promise<undefined>;
transcribe: (id: string) => Promise<void>;
upload: (id: string) => Promise<void>;
@@ -143,9 +146,9 @@ type EnjoyAppType = {
) => Promise<SegementRecordingStatsType>;
};
conversations: {
findAll: (params: object) => Promise<ConversationType[]>;
findOne: (params: object) => Promise<ConversationType>;
create: (params: object) => Promise<ConversationType>;
findAll: (params: any) => Promise<ConversationType[]>;
findOne: (params: any) => Promise<ConversationType>;
create: (params: any) => Promise<ConversationType>;
update: (id: string, params: object) => Promise<ConversationType>;
destroy: (id: string) => Promise<void>;
ask: (
@@ -159,7 +162,7 @@ type EnjoyAppType = {
arrayBuffer: ArrayBuffer;
};
}
) => Promise<MessageType>;
) => Promise<MessageType[]>;
};
messages: {
findAll: (params: object) => Promise<MessageType[]>;
@@ -177,6 +180,12 @@ type EnjoyAppType = {
};
ffmpeg: {
download: () => Promise<FfmpegConfigType>;
check: () => Promise<boolean>;
discover: () => Promise<{
ffmpegPath: string;
ffprobePath: string;
scanDirs: string[];
}>;
};
download: {
onState: (callback: (event, state) => void) => void;
@@ -185,70 +194,6 @@ type EnjoyAppType = {
dashboard: () => Promise<DownloadStateType[]>;
removeAllListeners: () => void;
};
webApi: {
auth: (params: { provider: string; code: string }) => Promise<UserType>;
me: () => Promise<UserType>;
lookup: (params: {
word: string;
context?: string;
sourceId?: string;
sourceType?: string;
}) => Promise<LookupType>;
lookupInBatch: (
params: {
word: string;
context?: string;
sourceId?: string;
sourceType?: string;
}[]
) => Promise<{ successCount: number; errors: string[]; total: number }>;
mineMeanings: (params?: {
page?: number;
items?: number;
sourceId?: string;
sourceType?: string;
}) => Promise<
{
meanings: MeaningType[];
} & PagyResponseType
>;
createStory: (params: {
title: string;
content: string;
url: string;
metadata: {
[key: string]: any;
};
}) => Promise<StoryType>;
extractVocabularyFromStory: (id: string) => Promise<string[]>;
story: (id: string) => Promise<StoryType>;
stories: (params?: { page: number }) => Promise<{
stories: StoryType[];
page: number;
next: number | null;
}>;
mineStories: (params?: { page: number }) => Promise<{
stories: StoryType[];
page: number;
next: number | null;
}>;
storyMeanings: (
storyId: string,
params?: {
page?: number;
items?: number;
sourceId?: string;
sourceType?: string;
}
) => Promise<
{
meanings: MeaningType[];
pendingLookups: LookupType[];
} & PagyResponseType
>;
starStory: (id: string) => Promise<{ starred: boolean }>;
unstarStory: (id: string) => Promise<{ starred: boolean }>;
};
cacheObjects: {
get: (key: string) => Promise<any>;
set: (key: string, value: any, ttl?: number) => Promise<void>;
@@ -260,4 +205,8 @@ type EnjoyAppType = {
process: (params: any) => Promise<void>;
update: (id: string, params: any) => Promise<void>;
};
waveforms: {
find: (id: string) => Promise<WaveFormDataType>;
save: (id: string, data: WaveFormDataType) => Promise<void>;
};
};

10
enjoy/src/types/medium.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
type MediumType = {
id: string;
md5: string;
mediumType: string;
coverUrl?: string;
sourceUrl?: string;
extname?: string;
createdAt: string;
updatedAt: string;
}

17
enjoy/src/types/post.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
type PostType = {
id: string;
metadata: {
type: 'text' | 'prompt' | 'llm_configuration';
content:
| string
| {
[key: string]: any;
};
};
user: UserType;
targetType?: string;
targetId?: string;
target?: MediumType | StoryType | RecordingType;
createdAt: Date;
updatedAt: Date;
};

View File

@@ -1,12 +1,12 @@
type RecordingType = {
id: string;
filename: string;
filename?: string;
target?: AudioType | (MessageType & any);
targetId: string;
targetType: string;
pronunciationAssessment?: PronunciationAssessmentType & any;
segmentIndex: number;
segmentText?: string;
referenceId: number;
referenceText?: string;
duration?: number;
src?: string;
md5: string;

24
enjoy/src/types/story.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
type StoryType = {
id: string;
url: string;
title: string;
content: string;
metadata: {
[key: string]: string;
};
vocabulary?: string[];
extracted?: boolean;
starred?: boolean;
createdAt: Date;
updatedAt: Date;
};
type CreateStoryParamsType = {
title: string;
content: string;
url: string;
html: string;
metadata: {
[key: string]: string;
};
};

View File

@@ -3,4 +3,6 @@ type UserType = {
name: string;
avatarUrl?: string;
accessToken?: string;
recordingsCount?: number;
recordingsDuration?: number;
};

View File

@@ -12,6 +12,7 @@ type VideoType = {
transcribing: boolean;
recordingsCount?: number;
recordingsDuration?: number;
isUploaded?: boolean;
uploadedAt?: Date;
createdAt: Date;
updatedAt: Date;

6
enjoy/src/types/waveform.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
type WaveFormDataType = {
peaks: number[];
sampleRate: number;
duration: number;
frequencies: number[];
};

Some files were not shown because too many files have changed in this diff Show More