Feat: interactive courses (#736)
* add courses page * add api for courses * add course page * update course type * update client * update course page * refactor courses pages * render chapter content * shadow in course * fix video handler * update style * mark finished examples * fix media player * update locale * finish chapter * refactor * auto update chapter status * audo finish chapter * fix media provider * fix wavesurfer player * update continue btn * refactor chapters & page * minor fix * fix undefined * refactor * refactor * disable sentry in dev * clean markdown format before alignment * refactor * fix regenerate * fix transcription pre-process for `-` connector * upgrade deps * handle no chapters * add llm chat api * create llm chat * display llm message * create message * handle error * generate llm message * display llm datetime * scroll to message * tts for llm message * add course provider * refactor * translate llm message * fix llm chat introduction * refacotr * upgrade deps * refactor style * handle undefined * fix posts * update locales * update courses api * add enrollments count * upgrade yarn * upgrade deps * restore dep to fix package in mac * upgrade deps
This commit is contained in:
894
.yarn/releases/yarn-4.2.1.cjs
vendored
894
.yarn/releases/yarn-4.2.1.cjs
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,4 +4,4 @@ nmHoistingLimits: workspaces
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.2.1.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.3.1.cjs
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"markdown-it-sup": "^2.0.0",
|
||||
"mermaid": "^10.9.1",
|
||||
"sass": "^1.77.6",
|
||||
"vitepress": "^1.2.3",
|
||||
"vitepress": "^1.3.0",
|
||||
"vitepress-plugin-mermaid": "^2.0.16",
|
||||
"vue": "^3.4.30"
|
||||
},
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"@electron-forge/plugin-vite": "^7.4.0",
|
||||
"@electron-forge/publisher-github": "^7.4.0",
|
||||
"@electron/fuses": "^1.8.0",
|
||||
"@playwright/test": "^1.45.0",
|
||||
"@playwright/test": "^1.45.1",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@types/ahoy.js": "^0.4.2",
|
||||
"@types/autosize": "^4.0.3",
|
||||
@@ -52,17 +52,17 @@
|
||||
"@types/intl-tel-input": "^18.1.4",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/mark.js": "^8.11.12",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/rails__actioncable": "^6.1.11",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/validator": "^13.12.0",
|
||||
"@types/wavesurfer.js": "^6.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^7.15.0",
|
||||
"@typescript-eslint/parser": "^7.15.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.16.0",
|
||||
"@typescript-eslint/parser": "^7.16.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"electron": "^31.1.0",
|
||||
"electron": "^31.2.0",
|
||||
"electron-playwright-helpers": "^1.7.1",
|
||||
"eslint": "^9.6.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.1",
|
||||
@@ -70,22 +70,22 @@
|
||||
"flora-colossus": "^2.0.0",
|
||||
"octokit": "^4.0.2",
|
||||
"progress": "^2.0.3",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"tslib": "^2.6.3",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.2",
|
||||
"vite-plugin-static-copy": "^1.0.5",
|
||||
"zx": "^8.1.3"
|
||||
"vite": "^5.3.3",
|
||||
"vite-plugin-static-copy": "^1.0.6",
|
||||
"zx": "^8.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@andrkrn/ffprobe-static": "^5.2.0",
|
||||
"@electron-forge/publisher-s3": "^7.4.0",
|
||||
"@hookform/resolvers": "^3.6.0",
|
||||
"@langchain/community": "^0.2.16",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@langchain/community": "^0.2.17",
|
||||
"@langchain/google-genai": "^0.0.21",
|
||||
"@mozilla/readability": "^0.5.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
@@ -112,9 +112,9 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@rails/actioncable": "7.1.3",
|
||||
"@sentry/electron": "^5.1.0",
|
||||
"@sentry/electron": "^5.2.0",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@vidstack/react": "^1.11.24",
|
||||
"@vidstack/react": "^1.11.27",
|
||||
"ahoy.js": "^0.4.4",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.7.2",
|
||||
@@ -143,29 +143,30 @@
|
||||
"html-to-text": "^9.0.5",
|
||||
"https-proxy-agent": "^7.0.5",
|
||||
"i18next": "^23.11.5",
|
||||
"intl-tel-input": "^23.1.0",
|
||||
"intl-tel-input": "^23.3.0",
|
||||
"js-md5": "^0.8.3",
|
||||
"langchain": "^0.2.8",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.400.0",
|
||||
"lucide-react": "^0.407.0",
|
||||
"mark.js": "^8.11.1",
|
||||
"microsoft-cognitiveservices-speech-sdk": "^1.38.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"openai": "^4.52.2",
|
||||
"openai": "^4.52.5",
|
||||
"pitchfinder": "^2.3.2",
|
||||
"postcss": "^8.4.38",
|
||||
"proxy-agent": "^6.4.0",
|
||||
"react": "^18.3.1",
|
||||
"react-activity-calendar": "^2.2.11",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-hotkeys-hook": "^4.5.0",
|
||||
"react-i18next": "^14.1.2",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^6.24.0",
|
||||
"react-tooltip": "^5.27.0",
|
||||
"react-resizable-panels": "^2.0.20",
|
||||
"react-router-dom": "^6.24.1",
|
||||
"react-tooltip": "^5.27.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rimraf": "^5.0.7",
|
||||
"rimraf": "^6.0.1",
|
||||
"sequelize": "^6.37.3",
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"sonner": "^1.5.0",
|
||||
@@ -173,7 +174,7 @@
|
||||
"tailwind-scrollbar-hide": "^1.1.7",
|
||||
"umzug": "^3.8.1",
|
||||
"update-electron-app": "^3.0.0",
|
||||
"wavesurfer.js": "^7.8.0",
|
||||
"wavesurfer.js": "^7.8.1",
|
||||
"zod": "^3.23.8",
|
||||
"zod-to-json-schema": "^3.23.1"
|
||||
}
|
||||
|
||||
@@ -468,4 +468,108 @@ export class Client {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
courses(params?: {
|
||||
language?: string;
|
||||
page?: number;
|
||||
items?: number;
|
||||
query?: string;
|
||||
}): Promise<
|
||||
{
|
||||
courses: CourseType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/courses", { params: decamelizeKeys(params) });
|
||||
}
|
||||
|
||||
course(id: string): Promise<CourseType> {
|
||||
return this.api.get(`/api/courses/${id}`);
|
||||
}
|
||||
|
||||
createEnrollment(courseId: string): Promise<EnrollmentType> {
|
||||
return this.api.post(`/api/enrollments`, decamelizeKeys({ courseId }));
|
||||
}
|
||||
|
||||
courseChapters(
|
||||
courseId: string,
|
||||
params?: {
|
||||
page?: number;
|
||||
items?: number;
|
||||
query?: string;
|
||||
}
|
||||
): Promise<
|
||||
{
|
||||
chapters: ChapterType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get(`/api/courses/${courseId}/chapters`, {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
coursechapter(courseId: string, id: number | string): Promise<ChapterType> {
|
||||
return this.api.get(`/api/courses/${courseId}/chapters/${id}`);
|
||||
}
|
||||
|
||||
finishCourseChapter(courseId: string, id: number | string): Promise<void> {
|
||||
return this.api.post(`/api/courses/${courseId}/chapters/${id}/finish`);
|
||||
}
|
||||
|
||||
enrollments(params?: { page?: number; items?: number }): Promise<
|
||||
{
|
||||
enrollments: EnrollmentType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/enrollments", { params: decamelizeKeys(params) });
|
||||
}
|
||||
|
||||
updateEnrollment(
|
||||
id: string,
|
||||
params: {
|
||||
currentChapterId?: string;
|
||||
}
|
||||
): Promise<EnrollmentType> {
|
||||
return this.api.put(`/api/enrollments/${id}`, decamelizeKeys(params));
|
||||
}
|
||||
|
||||
createLlmChat(params: {
|
||||
agentId: string;
|
||||
agentType: string;
|
||||
}): Promise<LLmChatType> {
|
||||
return this.api.post("/api/chats", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
llmChat(id: string): Promise<LLmChatType> {
|
||||
return this.api.get(`/api/chats/${id}`);
|
||||
}
|
||||
|
||||
createLlmMessage(
|
||||
chatId: string,
|
||||
params: {
|
||||
query: string;
|
||||
agentId?: string;
|
||||
agentType?: string;
|
||||
}
|
||||
): Promise<LlmMessageType> {
|
||||
return this.api.post(
|
||||
`/api/chats/${chatId}/messages`,
|
||||
decamelizeKeys(params)
|
||||
);
|
||||
}
|
||||
|
||||
llmMessages(
|
||||
chatId: string,
|
||||
params: {
|
||||
page?: number;
|
||||
items?: number;
|
||||
}
|
||||
): Promise<
|
||||
{
|
||||
messages: LlmMessageType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get(`/api/chats/${chatId}/messages`, {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"home": "Home",
|
||||
"courses": "Courses",
|
||||
"community": "Community",
|
||||
"audios": "Audios",
|
||||
"videos": "Videos",
|
||||
@@ -632,5 +633,11 @@
|
||||
"noData": "No data",
|
||||
"selectedFiles": "Selected files",
|
||||
"moreOptions": "More options",
|
||||
"lessOptions": "Less options"
|
||||
"lessOptions": "Less options",
|
||||
"previousChapter": "Previous",
|
||||
"nextChapter": "Next",
|
||||
"examples": "Examples",
|
||||
"continueLearning": "Continue learning",
|
||||
"enrollNow": "Enroll now",
|
||||
"enrollments": "Enrollments"
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"home": "主页",
|
||||
"courses": "课程",
|
||||
"community": "社区",
|
||||
"audios": "音频",
|
||||
"videos": "视频",
|
||||
@@ -632,5 +633,11 @@
|
||||
"noData": "没有数据",
|
||||
"selectedFiles": "已选中文件",
|
||||
"moreOptions": "更多选项",
|
||||
"lessOptions": "更少选项"
|
||||
"lessOptions": "更少选项",
|
||||
"previousChapter": "上一章",
|
||||
"nextChapter": "下一章",
|
||||
"examples": "示例",
|
||||
"continueLearning": "继续练习",
|
||||
"enrollNow": "加入练习",
|
||||
"enrollments": "参加的课程"
|
||||
}
|
||||
|
||||
@@ -14,9 +14,11 @@ import { updateElectronApp, UpdateSourceType } from "update-electron-app";
|
||||
|
||||
const logger = log.scope("main");
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
});
|
||||
if (app.isPackaged) {
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
});
|
||||
}
|
||||
|
||||
app.commandLine.appendSwitch("enable-features", "SharedArrayBuffer");
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ class AudiosHandler {
|
||||
],
|
||||
where,
|
||||
...options,
|
||||
group: ["Audio.id"],
|
||||
});
|
||||
|
||||
if (!audios) {
|
||||
@@ -116,14 +117,21 @@ class AudiosHandler {
|
||||
id: string,
|
||||
params: Attributes<Audio>
|
||||
) {
|
||||
const { name, description, metadata, language } = params;
|
||||
const { name, description, metadata, language, coverUrl, source } = params;
|
||||
|
||||
const audio = await Audio.findByPk(id);
|
||||
|
||||
if (!audio) {
|
||||
throw new Error(t("models.audio.notFound"));
|
||||
}
|
||||
return await audio.update({ name, description, metadata, language });
|
||||
return await audio.update({
|
||||
name,
|
||||
description,
|
||||
metadata,
|
||||
language,
|
||||
coverUrl,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
private async destroy(_event: IpcMainEvent, id: string) {
|
||||
|
||||
@@ -4,8 +4,21 @@ import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import settings from "@main/settings";
|
||||
import { hashFile } from "@main/utils";
|
||||
import { Attributes, WhereOptions } from "sequelize";
|
||||
|
||||
class SpeechesHandler {
|
||||
private async findOne(
|
||||
_event: IpcMainEvent,
|
||||
where: WhereOptions<Attributes<Speech>>
|
||||
) {
|
||||
const speech = await Speech.findOne({ where });
|
||||
if (!speech) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return speech.toJSON();
|
||||
}
|
||||
|
||||
private async create(
|
||||
event: IpcMainEvent,
|
||||
params: {
|
||||
@@ -43,6 +56,7 @@ class SpeechesHandler {
|
||||
}
|
||||
|
||||
register() {
|
||||
ipcMain.handle("speeches-find-one", this.findOne);
|
||||
ipcMain.handle("speeches-create", this.create);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ class VideosHandler {
|
||||
],
|
||||
where,
|
||||
...options,
|
||||
group: ["Video.id"],
|
||||
});
|
||||
if (!videos) {
|
||||
return [];
|
||||
@@ -106,13 +107,13 @@ class VideosHandler {
|
||||
id: string,
|
||||
params: Attributes<Video>
|
||||
) {
|
||||
const { name, description, metadata, language } = params;
|
||||
const { name, description, metadata, language, coverUrl, source } = params;
|
||||
|
||||
const video = await Video.findByPk(id);
|
||||
if (!video) {
|
||||
throw new Error(t("models.video.notFound"));
|
||||
}
|
||||
video.update({ name, description, metadata, language });
|
||||
video.update({ name, description, metadata, language, coverUrl, source });
|
||||
}
|
||||
|
||||
private async destroy(event: IpcMainEvent, id: string) {
|
||||
|
||||
@@ -311,7 +311,7 @@ export class Audio extends Model<Audio> {
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
throw new Error(t("audioAlreadyAddedToLibrary", { file: filePath }));
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Generate ID
|
||||
|
||||
@@ -123,6 +123,7 @@ export class Speech extends Model<Speech> {
|
||||
if (!Array.isArray(findResult)) findResult = [findResult];
|
||||
|
||||
for (const instance of findResult) {
|
||||
if (!instance) continue;
|
||||
if (instance.sourceType === "Message" && instance.message !== undefined) {
|
||||
instance.source = instance.message;
|
||||
}
|
||||
|
||||
@@ -327,7 +327,7 @@ export class Video extends Model<Video> {
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
throw new Error(t("videoAlreadyAddedToLibrary", { file: filePath }));
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Generate ID
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
|
||||
import { contextBridge, ipcRenderer, IpcRendererEvent } from "electron";
|
||||
import { version } from "../package.json";
|
||||
import { callback } from "chart.js/dist/helpers/helpers.core";
|
||||
import { remove } from "lodash";
|
||||
|
||||
contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
app: {
|
||||
@@ -37,7 +35,9 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
createIssue: (title: string, body: string) => {
|
||||
return ipcRenderer.invoke("app-create-issue", title, body);
|
||||
},
|
||||
onCmdOutput: (callback: (event: IpcRendererEvent, data: string) => void) => {
|
||||
onCmdOutput: (
|
||||
callback: (event: IpcRendererEvent, data: string) => void
|
||||
) => {
|
||||
ipcRenderer.on("app-on-cmd-output", callback);
|
||||
},
|
||||
removeCmdOutputListeners: () => {
|
||||
@@ -399,6 +399,9 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
},
|
||||
},
|
||||
speeches: {
|
||||
findOne: (where: any) => {
|
||||
return ipcRenderer.invoke("speeches-find-one", where);
|
||||
},
|
||||
create: (
|
||||
params: {
|
||||
sourceId: string;
|
||||
|
||||
@@ -28,12 +28,6 @@
|
||||
|
||||
import "./index.css";
|
||||
import "./renderer/index";
|
||||
import * as Sentry from "@sentry/electron/renderer";
|
||||
import { SENTRY_DSN } from "@/constants";
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
||||
@@ -24,6 +24,7 @@ export const AudioPlayer = (props: {
|
||||
setCurrentSegmentIndex,
|
||||
getCachedSegmentIndex,
|
||||
} = useContext(MediaPlayerProviderContext);
|
||||
|
||||
const { audio } = useAudio({ id, md5 });
|
||||
|
||||
const updateCurrentSegmentIndex = async () => {
|
||||
@@ -32,15 +33,19 @@ export const AudioPlayer = (props: {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!audio) return;
|
||||
setMedia(audio);
|
||||
}, [audio]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!media) return;
|
||||
|
||||
updateCurrentSegmentIndex();
|
||||
return () => {
|
||||
setCurrentSegmentIndex(0);
|
||||
};
|
||||
}, [media]);
|
||||
|
||||
if (!audio) return null;
|
||||
if (!layout) return <LoaderSpin />;
|
||||
|
||||
return (
|
||||
|
||||
@@ -105,7 +105,6 @@ export const AudiosComponent = () => {
|
||||
order,
|
||||
where,
|
||||
query: debouncedQuery,
|
||||
|
||||
})
|
||||
.then((_audios) => {
|
||||
setHasMore(_audios.length >= limit);
|
||||
|
||||
30
enjoy/src/renderer/components/courses/chapter-card.tsx
Normal file
30
enjoy/src/renderer/components/courses/chapter-card.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { CheckCircleIcon } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export const ChapterCard = (props: {
|
||||
chapter: ChapterType;
|
||||
active?: boolean;
|
||||
}) => {
|
||||
const { chapter, active } = props;
|
||||
return (
|
||||
<Link
|
||||
to={`/courses/${chapter.courseId}/chapters/${chapter.sequence}`}
|
||||
key={chapter.id}
|
||||
className="p-4 border hover:shadow cursor-pointer rounded-lg relative"
|
||||
>
|
||||
<div className="text-center text-sm font-bold font-mono mb-2">
|
||||
# {chapter.sequence}
|
||||
</div>
|
||||
<div className="text-center font-mono line-clamp-1">{chapter.title}</div>
|
||||
<CheckCircleIcon
|
||||
className={`absolute top-2 left-2 w-4 h-4 ${
|
||||
active
|
||||
? "text-yellow-500"
|
||||
: chapter.finished
|
||||
? "text-green-600"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
114
enjoy/src/renderer/components/courses/chapter-content.tsx
Normal file
114
enjoy/src/renderer/components/courses/chapter-content.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { toast, Button } from "@renderer/components/ui";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ExampleContent, MarkdownWrapper } from "@renderer/components";
|
||||
import { CheckCircleIcon } from "lucide-react";
|
||||
|
||||
export const ChapterContent = (props: {
|
||||
chapter: ChapterType;
|
||||
onUpdate?: () => void;
|
||||
}) => {
|
||||
const { webApi, nativeLanguage } = useContext(AppSettingsProviderContext);
|
||||
const { chapter, onUpdate } = props;
|
||||
const translation = chapter?.translations?.find(
|
||||
(t) => t.language === nativeLanguage
|
||||
);
|
||||
const [audios, setAudios] = useState<AudioType[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chapter?.finished) return;
|
||||
if (audios.length === 0) return;
|
||||
if (!chapter?.examples) return;
|
||||
|
||||
const finished = audios.filter((a) => a.recordingsCount > 0);
|
||||
if (finished.length === 0) return;
|
||||
if (chapter.examples.length === 0) return;
|
||||
|
||||
if (finished.length >= chapter.examples.length) {
|
||||
webApi
|
||||
.finishCourseChapter(chapter.course.id, chapter.sequence)
|
||||
.then(() => onUpdate?.())
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}
|
||||
}, [audios]);
|
||||
|
||||
useEffect(() => {
|
||||
setAudios([]);
|
||||
}, [chapter]);
|
||||
|
||||
if (!chapter) return null;
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="">
|
||||
<CheckCircleIcon
|
||||
className={`w-4 h-4 ${chapter.finished ? "text-green-600" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{chapter.sequence > 1 && (
|
||||
<Link
|
||||
to={`/courses/${chapter.course.id}/chapters/${
|
||||
chapter.sequence - 1
|
||||
}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{t("previousChapter")}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{chapter.course.chaptersCount > chapter.sequence + 1 && (
|
||||
<Link
|
||||
to={`/courses/${chapter.course.id}/chapters/${
|
||||
chapter.sequence + 1
|
||||
}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{t("nextChapter")}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="select-text prose dark:prose-invert prose-em:font-bold prose-em:text-red-700 mx-auto">
|
||||
<h2>{chapter?.title}</h2>
|
||||
<MarkdownWrapper>{chapter?.content}</MarkdownWrapper>
|
||||
{translation && (
|
||||
<details>
|
||||
<summary>{t("translation")}</summary>
|
||||
<MarkdownWrapper>{translation.content}</MarkdownWrapper>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{chapter.examples.length > 0 && <h3>{t("examples")}</h3>}
|
||||
<div className="grid gap-4">
|
||||
{chapter.examples.map((example, index) => (
|
||||
<ExampleContent
|
||||
key={index}
|
||||
example={example}
|
||||
course={chapter.course}
|
||||
onAudio={(audio) => {
|
||||
setAudios((audios) => {
|
||||
if (!audio) return [];
|
||||
|
||||
const index = audios.findIndex((a) => a.id === audio.id);
|
||||
if (index >= 0) {
|
||||
audios[index] = audio;
|
||||
} else {
|
||||
audios.push(audio);
|
||||
}
|
||||
return [...audios];
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
101
enjoy/src/renderer/components/courses/chapters.tsx
Normal file
101
enjoy/src/renderer/components/courses/chapters.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { ChapterCard } from "@renderer/components";
|
||||
import {
|
||||
Input,
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
|
||||
const ITEMS_PER_PAGE = 30;
|
||||
export const Chapters = (props: { course: CourseType }) => {
|
||||
const { course } = props;
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [chapters, setChapters] = useState<ChapterType[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [lastPage, setLastPage] = useState<number>();
|
||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||
|
||||
const fetchCourseChapters = async (params?: { page: number }) => {
|
||||
if (!course?.id) return;
|
||||
|
||||
let { page } = params || {};
|
||||
if (!page && course.enrollment?.currentChapterSequence) {
|
||||
page = Math.ceil(
|
||||
course.enrollment.currentChapterSequence / ITEMS_PER_PAGE
|
||||
);
|
||||
}
|
||||
page = page || currentPage;
|
||||
|
||||
webApi
|
||||
.courseChapters(course.id, { page, items: ITEMS_PER_PAGE })
|
||||
.then(({ chapters, page, next, last }) => {
|
||||
setCurrentPage(page);
|
||||
setLastPage(last);
|
||||
setHasMore(!!next);
|
||||
setChapters(chapters);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!course) return;
|
||||
fetchCourseChapters();
|
||||
}, [course]);
|
||||
|
||||
if (!course) return null;
|
||||
if (!chapters || chapters.length === 0)
|
||||
return <div className="flex justify-center p-4">{t("noData")}</div>;
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="grid gap-4 grid-cols-5 mb-4">
|
||||
{chapters.map((chapter) => (
|
||||
<ChapterCard
|
||||
key={chapter.id}
|
||||
chapter={chapter}
|
||||
active={course.enrollment?.currentChapterId === chapter.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
{currentPage > 1 && (
|
||||
<PaginationPrevious
|
||||
onClick={() => fetchCourseChapters({ page: currentPage - 1 })}
|
||||
/>
|
||||
)}
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
className="w-16 text-center"
|
||||
value={currentPage}
|
||||
onChange={(e) => {
|
||||
setCurrentPage(parseInt(e.target.value));
|
||||
fetchCourseChapters({ page: parseInt(e.target.value) });
|
||||
}}
|
||||
/>
|
||||
/<span>{lastPage}</span>
|
||||
</div>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
{hasMore && (
|
||||
<PaginationNext
|
||||
onClick={() => fetchCourseChapters({ page: currentPage + 1 })}
|
||||
/>
|
||||
)}
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
36
enjoy/src/renderer/components/courses/course-card.tsx
Normal file
36
enjoy/src/renderer/components/courses/course-card.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { GraduationCapIcon } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Progress } from "../ui";
|
||||
|
||||
export const CourseCard = (props: {
|
||||
course: CourseType;
|
||||
progress?: number;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { course, progress = 0, className = "" } = props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Link to={`/courses/${course.id}`}>
|
||||
<div className="aspect-square rounded-lg border overflow-hidden flex relative">
|
||||
{course.coverUrl ? (
|
||||
<img
|
||||
src={course.coverUrl}
|
||||
crossOrigin="anonymous"
|
||||
className="hover:scale-105 object-cover w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<GraduationCapIcon className="hover:scale-105 object-cover w-1/2 h-1/2 m-auto" />
|
||||
)}
|
||||
<Progress
|
||||
className="absolute bottom-0 left-0 right-0"
|
||||
value={progress}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="text-sm font-semibold mt-2 max-w-full line-clamp-2 h-10">
|
||||
{course.title}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button, ScrollArea, ScrollBar } from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { CourseCard } from "./course-card";
|
||||
|
||||
export const EnrollmentSegment = () => {
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [enrollments, setEnrollments] = useState<EnrollmentType[]>([]);
|
||||
const fetchEnrollments = async () => {
|
||||
webApi.enrollments().then(({ enrollments }) => {
|
||||
setEnrollments(enrollments);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchEnrollments();
|
||||
}, []);
|
||||
|
||||
if (!enrollments?.length) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight capitalize">
|
||||
{t("enrollments")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="ml-auto mr-4">
|
||||
<Link to="/courses">
|
||||
<Button variant="link" className="capitalize">
|
||||
{t("seeMore")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea>
|
||||
<div className="flex items-center space-x-4 pb-4">
|
||||
{enrollments.map((enrollment) => {
|
||||
return (
|
||||
<CourseCard
|
||||
className="w-36"
|
||||
key={enrollment.id}
|
||||
course={enrollment.course}
|
||||
progress={enrollment.progress * 100}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
184
enjoy/src/renderer/components/courses/example-content.tsx
Normal file
184
enjoy/src/renderer/components/courses/example-content.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { toast } from "@renderer/components/ui";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
CourseProviderContext,
|
||||
DbProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { MarkdownWrapper, WavesurferPlayer } from "@renderer/components";
|
||||
import { DownloadIcon, LoaderIcon, MicIcon } from "lucide-react";
|
||||
|
||||
export const ExampleContent = (props: {
|
||||
example: ChapterType["examples"][0];
|
||||
course?: CourseType;
|
||||
onAudio?: (audio: AudioType) => void;
|
||||
}) => {
|
||||
const { nativeLanguage, EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { setShadowing } = useContext(CourseProviderContext);
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
const { example, course, onAudio } = props;
|
||||
const translation = example?.translations?.find(
|
||||
(t) => t.language === nativeLanguage
|
||||
);
|
||||
const [resourcing, setResourcing] = useState(false);
|
||||
const [audio, setAudio] = useState<AudioType | null>(null);
|
||||
|
||||
const onAudioUpdate = (event: CustomEvent) => {
|
||||
const { model, action, record } = event.detail || {};
|
||||
if (
|
||||
model === "Audio" &&
|
||||
action === "create" &&
|
||||
record.source === example.audioUrl
|
||||
) {
|
||||
setAudio(record);
|
||||
onAudio?.(record);
|
||||
} else if (model === "Recording" && audio?.id === record.targetId) {
|
||||
EnjoyApp.audios.findOne({ id: audio.id }).then((audio) => {
|
||||
setAudio(audio);
|
||||
onAudio?.(audio);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAudio = async () => {
|
||||
if (!example) return;
|
||||
|
||||
EnjoyApp.audios
|
||||
.findOne({
|
||||
source: example.audioUrl,
|
||||
})
|
||||
.then((audio) => {
|
||||
setAudio(audio);
|
||||
onAudio?.(audio);
|
||||
});
|
||||
};
|
||||
|
||||
const startShadow = async () => {
|
||||
if (resourcing) return;
|
||||
|
||||
if (audio) {
|
||||
setShadowing(audio);
|
||||
return;
|
||||
}
|
||||
setResourcing(true);
|
||||
|
||||
const name =
|
||||
example.keywords.join(" ") + "-" + example.audioUrl.split("/").pop();
|
||||
EnjoyApp.audios
|
||||
.create(example.audioUrl, {
|
||||
name,
|
||||
originalText: example.content,
|
||||
coverUrl: course?.coverUrl,
|
||||
})
|
||||
.then((audio) => {
|
||||
if (!audio) return;
|
||||
|
||||
setAudio(audio);
|
||||
|
||||
if (audio.source !== example.audioUrl) {
|
||||
EnjoyApp.audios
|
||||
.update(audio.id, {
|
||||
name,
|
||||
coverUrl: course.coverUrl,
|
||||
source: example.audioUrl,
|
||||
})
|
||||
.finally(() => {
|
||||
setShadowing(audio);
|
||||
});
|
||||
} else {
|
||||
setShadowing(audio);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setResourcing(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
const filename =
|
||||
example.keywords.join(" ") + "-" + example.audioUrl.split("/").pop();
|
||||
EnjoyApp.dialog
|
||||
.showSaveDialog({
|
||||
title: t("download"),
|
||||
defaultPath: filename,
|
||||
filters: [
|
||||
{
|
||||
name: "Audio",
|
||||
extensions: [example.audioUrl.split(".").pop()],
|
||||
},
|
||||
],
|
||||
})
|
||||
.then((savePath) => {
|
||||
if (!savePath) return;
|
||||
|
||||
toast.promise(
|
||||
EnjoyApp.download.start(example.audioUrl, savePath as string),
|
||||
{
|
||||
success: () => t("downloadedSuccessfully"),
|
||||
error: t("downloadFailed"),
|
||||
position: "bottom-right",
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAudio();
|
||||
}, [example?.audioUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
addDblistener(onAudioUpdate);
|
||||
|
||||
return () => {
|
||||
removeDbListener(onAudioUpdate);
|
||||
};
|
||||
}, [audio]);
|
||||
|
||||
if (!example) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-4 py-2 bg-background border rounded-lg shadow-sm w-full">
|
||||
<MarkdownWrapper>{example.content}</MarkdownWrapper>
|
||||
{translation && (
|
||||
<details>
|
||||
<summary>{t("translation")}</summary>
|
||||
<MarkdownWrapper>{translation.content}</MarkdownWrapper>
|
||||
</details>
|
||||
)}
|
||||
<WavesurferPlayer id={example.id} src={example.audioUrl} />
|
||||
<div className="flex items-center justify-start space-x-2">
|
||||
{resourcing ? (
|
||||
<LoaderIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("addingResource")}
|
||||
className="w-3 h-3 animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<MicIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("shadowingExercise")}
|
||||
data-testid="message-start-shadow"
|
||||
onClick={startShadow}
|
||||
className={`w-3 h-3 cursor-pointer ${
|
||||
audio && audio.recordingsCount > 0 ? "text-green-600" : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<DownloadIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("download")}
|
||||
data-testid="message-download-speech"
|
||||
onClick={handleDownload}
|
||||
className="w-3 h-3 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
6
enjoy/src/renderer/components/courses/index.ts
Normal file
6
enjoy/src/renderer/components/courses/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./chapter-card";
|
||||
export * from "./chapter-content";
|
||||
export * from "./chapters";
|
||||
export * from "./course-card";
|
||||
export * from "./enrollments-segment";
|
||||
export * from "./example-content";
|
||||
@@ -1,5 +1,7 @@
|
||||
export * from "./audios";
|
||||
export * from "./conversations";
|
||||
export * from "./courses";
|
||||
export * from "./llm-chats";
|
||||
export * from "./meanings";
|
||||
export * from "./messages";
|
||||
export * from "./medias";
|
||||
|
||||
2
enjoy/src/renderer/components/llm-chats/index.ts
Normal file
2
enjoy/src/renderer/components/llm-chats/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./llm-chat";
|
||||
export * from "./llm-message";
|
||||
207
enjoy/src/renderer/components/llm-chats/llm-chat.tsx
Normal file
207
enjoy/src/renderer/components/llm-chats/llm-chat.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { useContext, useEffect, useReducer, useRef, useState } from "react";
|
||||
import { Button, ScrollArea, Textarea, toast } from "@renderer/components/ui";
|
||||
import { LlmMessage, LoaderSpin } from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
import { LoaderIcon, SendIcon } from "lucide-react";
|
||||
import autosize from "autosize";
|
||||
import { llmMessagesReducer } from "@renderer/reducers";
|
||||
|
||||
export const LlmChat = (props: {
|
||||
id?: string;
|
||||
agentType?: string;
|
||||
agentId?: string;
|
||||
}) => {
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const { id, agentType, agentId } = props;
|
||||
|
||||
const [llmChat, setLlmChat] = useState<LLmChatType | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const [llmMessages, dispatchLlmMessages] = useReducer(llmMessagesReducer, []);
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const submitRef = useRef<HTMLButtonElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!llmChat) return;
|
||||
if (!query) return;
|
||||
if (submitting) return;
|
||||
|
||||
setSubmitting(true);
|
||||
webApi
|
||||
.createLlmMessage(llmChat.id, { query })
|
||||
.then((message) => {
|
||||
dispatchLlmMessages({ type: "create", record: message });
|
||||
setQuery("");
|
||||
scrollToMessage(message);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => setSubmitting(false));
|
||||
};
|
||||
|
||||
const scrollToMessage = (message: LlmMessageType) => {
|
||||
if (!message) return;
|
||||
|
||||
setTimeout(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
container
|
||||
.querySelector(`#llm-message-${message.id}-resopnse .timestamp`)
|
||||
?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
});
|
||||
|
||||
inputRef.current.focus();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const resizeTextarea = () => {
|
||||
if (!inputRef?.current) return;
|
||||
|
||||
inputRef.current.style.height = "auto";
|
||||
inputRef.current.style.height = inputRef.current.scrollHeight + "px";
|
||||
};
|
||||
|
||||
const findOrCreateChat = async () => {
|
||||
if (id) {
|
||||
setLoading(true);
|
||||
webApi
|
||||
.llmChat(id)
|
||||
.then((chat) => {
|
||||
setLlmChat(chat);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
} else if (agentId && agentType) {
|
||||
setLoading(true);
|
||||
webApi
|
||||
.createLlmChat({ agentId, agentType })
|
||||
.then((chat) => {
|
||||
setLlmChat(chat);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLlmMessages = async () => {
|
||||
if (!llmChat) return;
|
||||
|
||||
setLoading(true);
|
||||
webApi
|
||||
.llmMessages(llmChat.id, { items: 100 })
|
||||
.then(({ messages }) => {
|
||||
dispatchLlmMessages({ type: "set", records: messages });
|
||||
scrollToMessage(messages[messages.length - 1]);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!inputRef.current) return;
|
||||
|
||||
autosize(inputRef.current);
|
||||
|
||||
inputRef.current.addEventListener("keypress", (event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
submitRef.current?.click();
|
||||
}
|
||||
});
|
||||
|
||||
inputRef.current.focus();
|
||||
|
||||
return () => {
|
||||
inputRef.current?.removeEventListener("keypress", () => {});
|
||||
autosize.destroy(inputRef.current);
|
||||
};
|
||||
}, [id, inputRef.current]);
|
||||
|
||||
useEffect(() => {
|
||||
resizeTextarea();
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
findOrCreateChat();
|
||||
}, [id, agentType, agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLlmMessages();
|
||||
}, [llmChat]);
|
||||
|
||||
if (loading) return <LoaderSpin />;
|
||||
|
||||
if (!llmChat)
|
||||
return (
|
||||
<div className="flex items-center justify-center py-6">{t("noData")}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
ref={containerRef}
|
||||
className="h-full max-w-screen-lg mx-auto p-4 pb-24 relative"
|
||||
>
|
||||
<LlmMessage
|
||||
llmMessage={{
|
||||
response: llmChat.agent.introduction,
|
||||
agent: llmChat.agent,
|
||||
chat: llmChat,
|
||||
}}
|
||||
/>
|
||||
{llmMessages.map((message) => (
|
||||
<LlmMessage key={message.id} llmMessage={message} />
|
||||
))}
|
||||
<div className="bg-muted px-4 absolute w-full bottom-4 left-0 z-50">
|
||||
<div className="focus-within:bg-background pr-4 py-2 flex items-end space-x-4 rounded-lg shadow-lg border scrollbar">
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
disabled={submitting}
|
||||
placeholder={t("pressEnterToSend")}
|
||||
data-testid="llm-chat-input"
|
||||
className="text-base px-4 py-0 shadow-none focus-visible:outline-0 focus-visible:ring-0 border-none min-h-[1rem] max-h-[70vh] scrollbar-thin !overflow-x-hidden"
|
||||
/>
|
||||
<div className="h-12 py-1">
|
||||
<Button
|
||||
type="submit"
|
||||
ref={submitRef}
|
||||
disabled={submitting || !query}
|
||||
data-testid="llm-chat-submit"
|
||||
onClick={() => handleSubmit()}
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("send")}
|
||||
className="h-10"
|
||||
>
|
||||
{submitting ? (
|
||||
<LoaderIcon className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<SendIcon className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
322
enjoy/src/renderer/components/llm-chats/llm-message.tsx
Normal file
322
enjoy/src/renderer/components/llm-chats/llm-message.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
ConversationShortcuts,
|
||||
MarkdownWrapper,
|
||||
SpeechPlayer,
|
||||
} from "@renderer/components";
|
||||
import { formatDateTime } from "@renderer/lib/utils";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { useCopyToClipboard } from "@uidotdev/usehooks";
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
ForwardIcon,
|
||||
LanguagesIcon,
|
||||
LoaderIcon,
|
||||
MicIcon,
|
||||
SpeechIcon,
|
||||
} from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { useAiCommand, useConversation } from "@renderer/hooks";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
CourseProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { md5 } from "js-md5";
|
||||
|
||||
export const LlmMessage = (props: { llmMessage: LlmMessageType }) => {
|
||||
const { llmMessage } = props;
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { setShadowing } = useContext(CourseProviderContext);
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
const [speech, setSpeech] = useState<Partial<SpeechType>>();
|
||||
const [speeching, setSpeeching] = useState<boolean>(false);
|
||||
const [resourcing, setResourcing] = useState<boolean>(false);
|
||||
const { tts } = useConversation();
|
||||
const { summarizeTopic } = useAiCommand();
|
||||
|
||||
const createSpeech = () => {
|
||||
if (speeching) return;
|
||||
|
||||
setSpeeching(true);
|
||||
|
||||
tts({
|
||||
sourceType: "LlmMessage",
|
||||
sourceId: llmMessage.id,
|
||||
text: llmMessage.response,
|
||||
configuration: {
|
||||
engine: "enjoyai",
|
||||
model: "tts-1",
|
||||
voice: "alloy",
|
||||
},
|
||||
})
|
||||
.then((speech) => {
|
||||
setSpeech(speech);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setSpeeching(false);
|
||||
});
|
||||
};
|
||||
|
||||
const findSpeech = () => {
|
||||
if (!llmMessage.id) return;
|
||||
EnjoyApp.speeches
|
||||
.findOne({
|
||||
sourceType: "LlmMessage",
|
||||
sourceId: llmMessage.id,
|
||||
})
|
||||
.then((speech) => {
|
||||
setSpeech(speech);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
const startShadow = async () => {
|
||||
if (resourcing) return;
|
||||
|
||||
const audio = await EnjoyApp.audios.findOne({
|
||||
md5: speech.md5,
|
||||
});
|
||||
|
||||
if (!audio) {
|
||||
setResourcing(true);
|
||||
let title =
|
||||
speech.text.length > 20
|
||||
? speech.text.substring(0, 17).trim() + "..."
|
||||
: speech.text;
|
||||
|
||||
try {
|
||||
title = await summarizeTopic(speech.text);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
|
||||
EnjoyApp.audios
|
||||
.create(speech.filePath, {
|
||||
name: title,
|
||||
originalText: speech.text,
|
||||
})
|
||||
.then((audio) => setShadowing(audio))
|
||||
.catch((err) => toast.error(t(err.message)))
|
||||
.finally(() => {
|
||||
setResourcing(false);
|
||||
});
|
||||
}
|
||||
|
||||
setShadowing(audio);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
EnjoyApp.dialog
|
||||
.showSaveDialog({
|
||||
title: t("download"),
|
||||
defaultPath: speech.filename,
|
||||
filters: [
|
||||
{
|
||||
name: "Audio",
|
||||
extensions: [speech.filename.split(".").pop()],
|
||||
},
|
||||
],
|
||||
})
|
||||
.then((savePath) => {
|
||||
if (!savePath) return;
|
||||
|
||||
toast.promise(EnjoyApp.download.start(speech.src, savePath as string), {
|
||||
success: () => t("downloadedSuccessfully"),
|
||||
error: t("downloadFailed"),
|
||||
position: "bottom-right",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
const [translation, setTranslation] = useState<string>();
|
||||
const [translating, setTranslating] = useState<boolean>(false);
|
||||
const { translate } = useAiCommand();
|
||||
|
||||
const handleTranslate = async () => {
|
||||
if (translating) return;
|
||||
if (!llmMessage.response) return;
|
||||
|
||||
const cacheKey = `translate-${md5(llmMessage.response)}`;
|
||||
try {
|
||||
const cached = await EnjoyApp.cacheObjects.get(cacheKey);
|
||||
|
||||
if (cached && !translation) {
|
||||
setTranslation(cached);
|
||||
} else {
|
||||
setTranslating(true);
|
||||
const result = await translate(llmMessage.response, cacheKey);
|
||||
setTranslation(result);
|
||||
setTranslating(false);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
setTranslating(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
findSpeech();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{llmMessage.query && (
|
||||
<div id={`llm-message-${llmMessage.id}-query`} className="mb-6">
|
||||
<div className="flex items-center space-x-2 justify-end mb-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{llmMessage.user.name}
|
||||
</div>
|
||||
<Avatar className="w-8 h-8 bg-background avatar">
|
||||
<AvatarImage src={llmMessage.user.avatarUrl}></AvatarImage>
|
||||
<AvatarFallback className="bg-background">
|
||||
{llmMessage.user.name}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 px-4 py-2 mb-2 bg-sky-500/30 border-sky-500 rounded-lg shadow-sm max-w-full">
|
||||
<MarkdownWrapper className="select-text prose dark:prose-invert">
|
||||
{llmMessage.query}
|
||||
</MarkdownWrapper>
|
||||
</div>
|
||||
{llmMessage.createdAt && (
|
||||
<div className="flex justify-end text-xs text-muted-foreground timestamp">
|
||||
{formatDateTime(llmMessage.createdAt)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{llmMessage.response && (
|
||||
<div id={`llm-message-${llmMessage.id}-response`} className="mb-6">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Avatar className="w-8 h-8 bg-background avatar">
|
||||
<AvatarImage src={llmMessage.agent.avatarUrl}></AvatarImage>
|
||||
<AvatarFallback className="bg-background">
|
||||
{llmMessage.agent.name}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{llmMessage.agent.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-4 py-2 mb-2 bg-background border rounded-lg shadow-sm max-w-full">
|
||||
<MarkdownWrapper className="select-text prose dark:prose-invert">
|
||||
{llmMessage.response}
|
||||
</MarkdownWrapper>
|
||||
{translation && (
|
||||
<MarkdownWrapper className="select-text prose dark:prose-invert">
|
||||
{translation}
|
||||
</MarkdownWrapper>
|
||||
)}
|
||||
{Boolean(speech) && <SpeechPlayer speech={speech} />}
|
||||
<div className="flex items-center space-x-4">
|
||||
{translating ? (
|
||||
<LoaderIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("creatingSpeech")}
|
||||
className="w-4 h-4 animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<LanguagesIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("translation")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={handleTranslate}
|
||||
/>
|
||||
)}
|
||||
{llmMessage.id &&
|
||||
!speech &&
|
||||
(speeching ? (
|
||||
<LoaderIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("creatingSpeech")}
|
||||
className="w-4 h-4 animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<SpeechIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("textToSpeech")}
|
||||
data-testid="message-create-speech"
|
||||
onClick={createSpeech}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
))}
|
||||
{copied ? (
|
||||
<CheckIcon className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("copyText")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
copyToClipboard(llmMessage.response);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 3000);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ConversationShortcuts
|
||||
prompt={llmMessage.response}
|
||||
excludedIds={[]}
|
||||
trigger={
|
||||
<ForwardIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("forward")}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{Boolean(speech) &&
|
||||
(resourcing ? (
|
||||
<LoaderIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("addingResource")}
|
||||
className="w-4 h-4 animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<MicIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("shadowingExercise")}
|
||||
data-testid="message-start-shadow"
|
||||
onClick={startShadow}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
))}
|
||||
{Boolean(speech) && (
|
||||
<DownloadIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("download")}
|
||||
data-testid="llm-message-download-speech"
|
||||
onClick={handleDownload}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{llmMessage.createdAt && (
|
||||
<div className="flex justify-start text-xs text-muted-foreground timestamp">
|
||||
{formatDateTime(llmMessage.createdAt)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -328,6 +328,10 @@ export const MediaPlayerControls = () => {
|
||||
if (!wavesurfer) return;
|
||||
|
||||
const segment = transcription.result.timeline[currentSegmentIndex];
|
||||
if (!segment) {
|
||||
setCurrentSegmentIndex(0);
|
||||
return;
|
||||
}
|
||||
wavesurfer.seekTo(
|
||||
Math.floor((segment.startTime / wavesurfer.getDuration()) * 1e8) / 1e8
|
||||
);
|
||||
|
||||
@@ -73,6 +73,7 @@ export const MediaTranscriptionGenerateButton = (props: {
|
||||
toast.error(e.message);
|
||||
});
|
||||
}}
|
||||
originalText=""
|
||||
transcribing={transcribing}
|
||||
transcribingProgress={transcribingProgress}
|
||||
transcribingOutput={transcribingOutput}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SpeechPlayer,
|
||||
AudioPlayer,
|
||||
ConversationShortcuts,
|
||||
MarkdownWrapper,
|
||||
} from "@renderer/components";
|
||||
import { useState, useEffect, useContext } from "react";
|
||||
import {
|
||||
@@ -33,8 +34,8 @@ import {
|
||||
import { useCopyToClipboard } from "@uidotdev/usehooks";
|
||||
import { t } from "i18next";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import Markdown from "react-markdown";
|
||||
import { useConversation, useAiCommand } from "@renderer/hooks";
|
||||
import { formatDateTime } from "@renderer/lib/utils";
|
||||
|
||||
export const AssistantMessageComponent = (props: {
|
||||
message: MessageType;
|
||||
@@ -152,15 +153,19 @@ export const AssistantMessageComponent = (props: {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`message-${message.id}`}
|
||||
className="ai-message flex items-end space-x-2 pr-10"
|
||||
>
|
||||
<Avatar className="w-8 h-8 bg-background avatar">
|
||||
<AvatarImage></AvatarImage>
|
||||
<AvatarFallback className="bg-background">AI</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-2 px-4 py-2 bg-background border rounded-lg shadow-sm w-full">
|
||||
<div id={`message-${message.id}`} className="ai-message">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Avatar className="w-8 h-8 bg-muted avatar">
|
||||
<AvatarImage></AvatarImage>
|
||||
<AvatarFallback className="bg-muted capitalize">
|
||||
{configuration?.model?.[0] || "AI"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{configuration?.model}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 px-4 py-2 bg-background border rounded-lg shadow-sm w-full mb-2">
|
||||
{configuration.type === "tts" &&
|
||||
(speeching ? (
|
||||
<div className="text-muted-foreground text-sm py-2">
|
||||
@@ -176,36 +181,25 @@ export const AssistantMessageComponent = (props: {
|
||||
))}
|
||||
|
||||
{configuration.type === "gpt" && (
|
||||
<Markdown
|
||||
<MarkdownWrapper
|
||||
className="message-content select-text prose dark:prose-invert"
|
||||
data-source-type="Message"
|
||||
data-source-id={message.id}
|
||||
components={{
|
||||
a({ node, children, ...props }) {
|
||||
try {
|
||||
new URL(props.href ?? "");
|
||||
props.target = "_blank";
|
||||
props.rel = "noopener noreferrer";
|
||||
} catch (e) {}
|
||||
|
||||
return <a {...props}>{children}</a>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</Markdown>
|
||||
</MarkdownWrapper>
|
||||
)}
|
||||
|
||||
{Boolean(speech) && <SpeechPlayer speech={speech} />}
|
||||
|
||||
<DropdownMenu>
|
||||
<div className="flex items-center justify-start space-x-2">
|
||||
<div className="flex items-center justify-start space-x-4">
|
||||
{!speech &&
|
||||
(speeching ? (
|
||||
<LoaderIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("creatingSpeech")}
|
||||
className="w-3 h-3 animate-spin"
|
||||
className="w-4 h-4 animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<SpeechIcon
|
||||
@@ -213,19 +207,19 @@ export const AssistantMessageComponent = (props: {
|
||||
data-tooltip-content={t("textToSpeech")}
|
||||
data-testid="message-create-speech"
|
||||
onClick={createSpeech}
|
||||
className="w-3 h-3 cursor-pointer"
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
))}
|
||||
|
||||
{configuration.type === "gpt" && (
|
||||
<>
|
||||
{copied ? (
|
||||
<CheckIcon className="w-3 h-3 text-green-500" />
|
||||
<CheckIcon className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("copyText")}
|
||||
className="w-3 h-3 cursor-pointer"
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
copyToClipboard(message.content);
|
||||
setCopied(true);
|
||||
@@ -242,7 +236,7 @@ export const AssistantMessageComponent = (props: {
|
||||
<ForwardIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("forward")}
|
||||
className="w-3 h-3 cursor-pointer"
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -254,7 +248,7 @@ export const AssistantMessageComponent = (props: {
|
||||
<LoaderIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("addingResource")}
|
||||
className="w-3 h-3 animate-spin"
|
||||
className="w-4 h-4 animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<MicIcon
|
||||
@@ -262,7 +256,7 @@ export const AssistantMessageComponent = (props: {
|
||||
data-tooltip-content={t("shadowingExercise")}
|
||||
data-testid="message-start-shadow"
|
||||
onClick={startShadow}
|
||||
className="w-3 h-3 cursor-pointer"
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
))}
|
||||
{Boolean(speech) && (
|
||||
@@ -271,12 +265,12 @@ export const AssistantMessageComponent = (props: {
|
||||
data-tooltip-content={t("download")}
|
||||
data-testid="message-download-speech"
|
||||
onClick={handleDownload}
|
||||
className="w-3 h-3 cursor-pointer"
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenuTrigger>
|
||||
<MoreVerticalIcon className="w-3 h-3" />
|
||||
<MoreVerticalIcon className="w-4 h-4" />
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
|
||||
@@ -290,6 +284,10 @@ export const AssistantMessageComponent = (props: {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start text-xs text-muted-foreground timestamp">
|
||||
{formatDateTime(message.createdAt)}
|
||||
</div>
|
||||
|
||||
<Sheet
|
||||
modal={false}
|
||||
open={shadowing}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useCopyToClipboard } from "@uidotdev/usehooks";
|
||||
import { t } from "i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Markdown from "react-markdown";
|
||||
import { formatDateTime } from "@renderer/lib/utils";
|
||||
|
||||
export const UserMessageComponent = (props: {
|
||||
message: MessageType;
|
||||
@@ -81,11 +82,17 @@ export const UserMessageComponent = (props: {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`message-${message.id}`}
|
||||
className="flex items-end justify-end space-x-2 pl-10"
|
||||
>
|
||||
<div className="flex flex-col gap-2 px-4 py-2 bg-sky-500/30 border-sky-500 rounded-lg shadow-sm w-full">
|
||||
<div id={`message-${message.id}`} className="">
|
||||
<div className="flex items-center justify-end space-x-2 mb-2">
|
||||
<div className="text-sm text-muted-foreground">{user.name}</div>
|
||||
<Avatar className="w-8 h-8 bg-background">
|
||||
<AvatarImage src={user.avatarUrl} />
|
||||
<AvatarFallback className="bg-primary text-white capitalize">
|
||||
{user.name?.[0] ?? "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 px-4 py-2 bg-sky-500/30 border-sky-500 rounded-lg shadow-sm w-full mb-2">
|
||||
<Markdown
|
||||
className="select-text prose dark:prose-invert"
|
||||
components={{
|
||||
@@ -106,33 +113,33 @@ export const UserMessageComponent = (props: {
|
||||
{Boolean(speech) && <SpeechPlayer speech={speech} />}
|
||||
|
||||
<DropdownMenu>
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<div className="flex items-center justify-end space-x-4">
|
||||
{message.createdAt ? (
|
||||
<CheckCircleIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("sent")}
|
||||
className="w-3 h-3"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
) : message.status === "pending" ? (
|
||||
<LoaderIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("sending")}
|
||||
className="w-3 h-3 animate-spin"
|
||||
className="w-4 h-4 animate-spin"
|
||||
/>
|
||||
) : (
|
||||
message.status === "error" && (
|
||||
<DropdownMenuTrigger>
|
||||
<AlertCircleIcon className="w-3 h-3 text-destructive" />
|
||||
<AlertCircleIcon className="w-4 h-4 text-destructive" />
|
||||
</DropdownMenuTrigger>
|
||||
)
|
||||
)}
|
||||
{copied ? (
|
||||
<CheckIcon className="w-3 h-3 text-green-500" />
|
||||
<CheckIcon className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("copy")}
|
||||
className="w-3 h-3 cursor-pointer"
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
copyToClipboard(message.content);
|
||||
setCopied(true);
|
||||
@@ -150,7 +157,7 @@ export const UserMessageComponent = (props: {
|
||||
<ForwardIcon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("forward")}
|
||||
className="w-3 h-3 cursor-pointer"
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -161,7 +168,7 @@ export const UserMessageComponent = (props: {
|
||||
<Share2Icon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("share")}
|
||||
className="w-3 h-3 cursor-pointer"
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
@@ -184,7 +191,7 @@ export const UserMessageComponent = (props: {
|
||||
)}
|
||||
|
||||
<DropdownMenuTrigger>
|
||||
<MoreVerticalIcon className="w-3 h-3" />
|
||||
<MoreVerticalIcon className="w-4 h-4" />
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
|
||||
@@ -201,13 +208,9 @@ export const UserMessageComponent = (props: {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<Avatar className="w-8 h-8 bg-background">
|
||||
<AvatarImage src={user.avatarUrl} />
|
||||
<AvatarFallback className="bg-primary text-white capitalize">
|
||||
{user.name[0]}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex justify-end text-xs text-muted-foreground timestamp">
|
||||
{formatDateTime(message.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from "./layout";
|
||||
export * from "./loader-spin";
|
||||
export * from "./login-form";
|
||||
export * from "./github-login-form";
|
||||
export * from "./markdown-wrapper";
|
||||
export * from "./mixin-login-form";
|
||||
export * from "./no-records-found";
|
||||
export * from "./page-placeholder";
|
||||
|
||||
30
enjoy/src/renderer/components/misc/markdown-wrapper.tsx
Normal file
30
enjoy/src/renderer/components/misc/markdown-wrapper.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import Markdown from "react-markdown";
|
||||
|
||||
export const MarkdownWrapper = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
children: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<Markdown
|
||||
className={className}
|
||||
components={{
|
||||
a({ node, children, ...props }) {
|
||||
try {
|
||||
new URL(props.href ?? "");
|
||||
props.target = "_blank";
|
||||
props.rel = "noopener noreferrer";
|
||||
} catch (e) {}
|
||||
|
||||
return <a {...props}>{children}</a>;
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Markdown>
|
||||
);
|
||||
};
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
ExternalLinkIcon,
|
||||
NotebookPenIcon,
|
||||
SpeechIcon,
|
||||
GraduationCapIcon,
|
||||
} from "lucide-react";
|
||||
import { useLocation, Link } from "react-router-dom";
|
||||
import { t } from "i18next";
|
||||
@@ -71,6 +72,14 @@ export const Sidebar = () => {
|
||||
Icon={HomeIcon}
|
||||
/>
|
||||
|
||||
<SidebarItem
|
||||
href="/courses"
|
||||
label={t("sidebar.courses")}
|
||||
tooltip={t("sidebar.courses")}
|
||||
active={activeTab.startsWith("/courses")}
|
||||
Icon={GraduationCapIcon}
|
||||
/>
|
||||
|
||||
<Separator className="hidden xl:block" />
|
||||
|
||||
<SidebarItem
|
||||
|
||||
@@ -49,12 +49,9 @@ export const WavesurferPlayer = (props: {
|
||||
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;
|
||||
const initialize = () => {
|
||||
if (!containerRef.current) return;
|
||||
if (!src) return;
|
||||
if (wavesurfer) return;
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container: containerRef.current,
|
||||
@@ -73,7 +70,14 @@ export const WavesurferPlayer = (props: {
|
||||
});
|
||||
|
||||
setWavesurfer(ws);
|
||||
}, [src, entry]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!entry?.isIntersecting) return;
|
||||
if (wavesurfer?.options?.url === src) return;
|
||||
|
||||
initialize();
|
||||
}, [src, entry, containerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wavesurfer) return;
|
||||
|
||||
@@ -13,14 +13,16 @@ import {
|
||||
} from "@renderer/components//ui";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const Posts = (props: { userId?: string }) => {
|
||||
export const Posts = (props: { userId?: string; by?: string }) => {
|
||||
const { userId } = props;
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [type, setType] = useState<
|
||||
"all" | "recording" | "medium" | "story" | "prompt" | "gpt" | "note"
|
||||
>("all");
|
||||
const [by, setBy] = useState<"all" | "following">("following");
|
||||
const [by, setBy] = useState<"all" | "following">(
|
||||
userId ? "all" : "following"
|
||||
);
|
||||
const [posts, setPosts] = useState<PostType[]>([]);
|
||||
const [nextPage, setNextPage] = useState(1);
|
||||
|
||||
|
||||
115
enjoy/src/renderer/components/ui/breadcrumb.tsx
Normal file
115
enjoy/src/renderer/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import * as React from "react";
|
||||
import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<"nav"> & {
|
||||
separator?: React.ReactNode;
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
|
||||
Breadcrumb.displayName = "Breadcrumb";
|
||||
|
||||
const BreadcrumbList = React.forwardRef<
|
||||
HTMLOListElement,
|
||||
React.ComponentPropsWithoutRef<"ol">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbList.displayName = "BreadcrumbList";
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentPropsWithoutRef<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem";
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<"a"> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage";
|
||||
|
||||
const BreadcrumbSeparator = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon />}
|
||||
</li>
|
||||
);
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
|
||||
|
||||
const BreadcrumbEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<DotsHorizontalIcon className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
@@ -4,6 +4,7 @@ export * from "./alert-dialog";
|
||||
export * from "./aspect-ratio";
|
||||
export * from "./avatar";
|
||||
export * from "./badage";
|
||||
export * from "./breadcrumb";
|
||||
export * from "./button";
|
||||
export * from "./card";
|
||||
export * from "./collapsible";
|
||||
@@ -16,11 +17,13 @@ export * from "./hover-card";
|
||||
export * from "./input";
|
||||
export * from "./label";
|
||||
export * from "./menubar";
|
||||
export * from "./pagination";
|
||||
export * from "./ping-point";
|
||||
export * from "./popover";
|
||||
export * from "./progress";
|
||||
export * from "./radial-progress";
|
||||
export * from "./radio-group";
|
||||
export * from "./resizable";
|
||||
export * from "./scroll-area";
|
||||
export * from "./select";
|
||||
export * from "./separator";
|
||||
|
||||
121
enjoy/src/renderer/components/ui/pagination.tsx
Normal file
121
enjoy/src/renderer/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DotsHorizontalIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
import { ButtonProps, buttonVariants } from "@renderer/components/ui/button";
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Pagination.displayName = "Pagination";
|
||||
|
||||
const PaginationContent = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
PaginationContent.displayName = "PaginationContent";
|
||||
|
||||
const PaginationItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn("", className)} {...props} />
|
||||
));
|
||||
PaginationItem.displayName = "PaginationItem";
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean;
|
||||
} & Pick<ButtonProps, "size"> &
|
||||
React.ComponentProps<"a">;
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
PaginationLink.displayName = "PaginationLink";
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
{props.children && <span>{props.children}</span>}
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationPrevious.displayName = "PaginationPrevious";
|
||||
|
||||
const PaginationNext = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{props.children && <span>{props.children}</span>}
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationNext.displayName = "PaginationNext";
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<DotsHorizontalIcon className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
);
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis";
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
};
|
||||
45
enjoy/src/renderer/components/ui/resizable.tsx
Normal file
45
enjoy/src/renderer/components/ui/resizable.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { DragHandleDots2Icon } from "@radix-ui/react-icons";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel;
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean;
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<DragHandleDots2Icon className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
);
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
@@ -32,16 +32,19 @@ export const VideoPlayer = (props: {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!video) return;
|
||||
|
||||
setMedia(video);
|
||||
}, [video]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!media) return;
|
||||
updateCurrentSegmentIndex();
|
||||
|
||||
return () => {
|
||||
setCurrentSegmentIndex(0);
|
||||
};
|
||||
}, [media]);
|
||||
|
||||
if (!video) return null;
|
||||
if (!layout) return <LoaderSpin />;
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
ScrollArea,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { useAiCommand } from "@renderer/hooks";
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Client } from "@/api";
|
||||
import i18n from "@renderer/i18n";
|
||||
import ahoy from "ahoy.js";
|
||||
import { type Consumer, createConsumer } from "@rails/actioncable";
|
||||
import * as Sentry from "@sentry/electron/renderer";
|
||||
import { SENTRY_DSN } from "@/constants";
|
||||
|
||||
type AppSettingsProviderState = {
|
||||
webApi: Client;
|
||||
@@ -59,41 +61,15 @@ export const AppSettingsProvider = ({
|
||||
IPA_MAPPINGS
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVersion();
|
||||
fetchUser();
|
||||
fetchLibraryPath();
|
||||
fetchLanguages();
|
||||
fetchProxyConfig();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiUrl) return;
|
||||
|
||||
setWebApi(
|
||||
new Client({
|
||||
baseUrl: apiUrl,
|
||||
accessToken: user?.accessToken,
|
||||
locale: language,
|
||||
})
|
||||
);
|
||||
}, [user, apiUrl, language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiUrl) return;
|
||||
|
||||
ahoy.configure({
|
||||
urlPrefix: apiUrl,
|
||||
const initSentry = () => {
|
||||
EnjoyApp.app.isPackaged().then((isPackaged) => {
|
||||
if (isPackaged) {
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [apiUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webApi) return;
|
||||
|
||||
webApi.config("ipa_mappings").then((mappings) => {
|
||||
if (mappings) setIpaMappings(mappings);
|
||||
});
|
||||
}, [webApi]);
|
||||
};
|
||||
|
||||
const fetchLanguages = async () => {
|
||||
const language = await EnjoyApp.settings.getLanguage();
|
||||
@@ -196,6 +172,43 @@ export const AppSettingsProvider = ({
|
||||
setCable(consumer);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchVersion();
|
||||
fetchUser();
|
||||
fetchLibraryPath();
|
||||
fetchLanguages();
|
||||
fetchProxyConfig();
|
||||
initSentry();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiUrl) return;
|
||||
|
||||
setWebApi(
|
||||
new Client({
|
||||
baseUrl: apiUrl,
|
||||
accessToken: user?.accessToken,
|
||||
locale: language,
|
||||
})
|
||||
);
|
||||
}, [user, apiUrl, language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiUrl) return;
|
||||
|
||||
ahoy.configure({
|
||||
urlPrefix: apiUrl,
|
||||
});
|
||||
}, [apiUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webApi) return;
|
||||
|
||||
webApi.config("ipa_mappings").then((mappings) => {
|
||||
if (mappings) setIpaMappings(mappings);
|
||||
});
|
||||
}, [webApi]);
|
||||
|
||||
return (
|
||||
<AppSettingsProviderContext.Provider
|
||||
value={{
|
||||
|
||||
92
enjoy/src/renderer/context/course-provider.tsx
Normal file
92
enjoy/src/renderer/context/course-provider.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { createContext, useEffect, useState, useContext } from "react";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
MediaPlayerProvider,
|
||||
AppSettingsProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { AudioPlayer } from "@renderer/components";
|
||||
|
||||
type CourseProviderState = {
|
||||
course?: CourseType;
|
||||
currentChapter?: ChapterType;
|
||||
setCurrentChapter?: (chapter: ChapterType) => void;
|
||||
shadowing?: AudioType;
|
||||
setShadowing?: (audio: AudioType) => void;
|
||||
};
|
||||
|
||||
const initialState: CourseProviderState = {};
|
||||
|
||||
export const CourseProviderContext =
|
||||
createContext<CourseProviderState>(initialState);
|
||||
|
||||
export const CourseProvider = ({
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [course, setCourse] = useState<CourseType>(null);
|
||||
const [currentChapter, setCurrentChapter] = useState<ChapterType>(null);
|
||||
const [shadowing, setShadowing] = useState<AudioType>(null);
|
||||
|
||||
const fetchCourse = async (id: string) => {
|
||||
webApi
|
||||
.course(id)
|
||||
.then((course) => setCourse(course))
|
||||
.catch((err) => toast.error(err.message));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourse(id);
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<CourseProviderContext.Provider
|
||||
value={{
|
||||
course,
|
||||
currentChapter,
|
||||
setCurrentChapter,
|
||||
shadowing,
|
||||
setShadowing,
|
||||
}}
|
||||
>
|
||||
<MediaPlayerProvider>
|
||||
{children}
|
||||
<Sheet
|
||||
modal={false}
|
||||
open={Boolean(shadowing)}
|
||||
onOpenChange={(value) => {
|
||||
if (!value) setShadowing(null);
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className="h-screen p-0"
|
||||
displayClose={false}
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<SheetHeader className="flex items-center justify-center h-14">
|
||||
<SheetTitle className="sr-only">Shadow</SheetTitle>
|
||||
<SheetClose>
|
||||
<ChevronDownIcon />
|
||||
</SheetClose>
|
||||
</SheetHeader>
|
||||
|
||||
<AudioPlayer id={shadowing?.id} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</MediaPlayerProvider>
|
||||
</CourseProviderContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
export * from "./ai-settings-provider";
|
||||
export * from "./app-settings-provider";
|
||||
export * from "./course-provider";
|
||||
export * from "./db-provider";
|
||||
export * from './hotkeys-settings-provider'
|
||||
export * from "./media-player-provider";
|
||||
export * from "./theme-provider";
|
||||
export * from "./wavesurfer-provider";
|
||||
export * from "./media-player-provider";
|
||||
export * from './hotkeys-settings-provider'
|
||||
|
||||
@@ -53,15 +53,6 @@ type MediaPlayerContextType = {
|
||||
setActiveRegion: (region: RegionType) => void;
|
||||
editingRegion: boolean;
|
||||
setEditingRegion: (editing: boolean) => void;
|
||||
renderPitchContour: (
|
||||
region: RegionType,
|
||||
options?: {
|
||||
repaint?: boolean;
|
||||
canvasId?: string;
|
||||
containerClassNames?: string[];
|
||||
data?: Chart["data"];
|
||||
}
|
||||
) => void;
|
||||
pitchChart: Chart;
|
||||
// Transcription
|
||||
transcription: TranscriptionType;
|
||||
@@ -263,6 +254,9 @@ export const MediaPlayerProvider = ({
|
||||
if (!waveform?.frequencies?.length) return;
|
||||
if (!wavesurfer) return;
|
||||
|
||||
const caption = transcription?.result?.timeline?.[currentSegmentIndex];
|
||||
if (!caption) return;
|
||||
|
||||
const { repaint = true, containerClassNames = [] } = options || {};
|
||||
const duration = wavesurfer.getDuration();
|
||||
const fromIndex = Math.round(
|
||||
@@ -319,7 +313,6 @@ export const MediaPlayerProvider = ({
|
||||
const regionDuration = region.end - region.start;
|
||||
|
||||
const labels = new Array(data.length).fill("");
|
||||
const caption = transcription?.result?.timeline?.[currentSegmentIndex];
|
||||
if (region.id.startsWith("segment-region")) {
|
||||
caption.timeline.forEach((segment: TimelineEntry) => {
|
||||
const index = Math.round(
|
||||
@@ -520,6 +513,7 @@ export const MediaPlayerProvider = ({
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!activeRegion) return;
|
||||
if (!wavesurfer) return;
|
||||
|
||||
renderPitchContour(activeRegion);
|
||||
}, [wavesurfer, activeRegion]);
|
||||
@@ -605,7 +599,6 @@ export const MediaPlayerProvider = ({
|
||||
minPxPerSec,
|
||||
transcription,
|
||||
regions,
|
||||
renderPitchContour,
|
||||
pitchChart,
|
||||
activeRegion,
|
||||
setActiveRegion,
|
||||
|
||||
@@ -25,7 +25,9 @@ export const useAudio = (options: { id?: string; md5?: string }) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const where = id ? { id } : { md5 };
|
||||
const where = id ? { id } : md5 ? { md5 } : null;
|
||||
if (!where) return;
|
||||
|
||||
EnjoyApp.audios.findOne(where).then((audio) => {
|
||||
if (audio) {
|
||||
setAudio(audio);
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as sdk from "microsoft-cognitiveservices-speech-sdk";
|
||||
import axios from "axios";
|
||||
import { AlignmentResult } from "echogarden/dist/api/API.d.js";
|
||||
import { useAiCommand } from "./use-ai-command";
|
||||
import { toast } from "@renderer/components/ui";
|
||||
|
||||
export const useTranscribe = () => {
|
||||
const { EnjoyApp, user, webApi } = useContext(AppSettingsProviderContext);
|
||||
@@ -83,10 +84,12 @@ export const useTranscribe = () => {
|
||||
let transcript = originalText || result.text;
|
||||
|
||||
// Remove all content inside `()`, `[]`, `{}` and trim the text
|
||||
// remove all markdown formatting
|
||||
transcript = transcript
|
||||
.replace(/\(.*?\)/g, "")
|
||||
.replace(/\[.*?\]/g, "")
|
||||
.replace(/\{.*?\}/g, "")
|
||||
.replace(/[*_`]/g, "")
|
||||
.trim();
|
||||
|
||||
// if the transcript does not contain any punctuation, use AI command to add punctuation
|
||||
@@ -94,6 +97,7 @@ export const useTranscribe = () => {
|
||||
try {
|
||||
transcript = await punctuateText(transcript);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
console.warn(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ export const useTranscriptions = (media: AudioType | VideoType) => {
|
||||
}
|
||||
|
||||
for (let k = j + 1; k <= sentence.timeline.length - 1; k++) {
|
||||
if (word.includes(sentence.timeline[k].text.toLowerCase())) {
|
||||
while (word.includes(sentence.timeline[k]?.text?.toLowerCase())) {
|
||||
let connector = "";
|
||||
if (match[0] === "-") {
|
||||
connector = "-";
|
||||
@@ -204,9 +204,8 @@ export const useTranscriptions = (media: AudioType | VideoType) => {
|
||||
];
|
||||
token.endTime = sentence.timeline[k].endTime;
|
||||
sentence.timeline.splice(k, 1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export const useVideo = (options: { id?: string; md5?: string }) => {
|
||||
const [video, setVideo] = useState<VideoType>(null);
|
||||
const throttledVideo = useThrottle(video, 500);
|
||||
|
||||
const onAudioUpdate = (event: CustomEvent) => {
|
||||
const onVideoUpdate = (event: CustomEvent) => {
|
||||
const { model, action, record } = event.detail || {};
|
||||
if (model !== "Video") return;
|
||||
if (id && record.id !== id) return;
|
||||
@@ -25,7 +25,9 @@ export const useVideo = (options: { id?: string; md5?: string }) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const where = id ? { id } : { md5 };
|
||||
const where = id ? { id } : md5 ? { md5 } : null;
|
||||
if (!where) return;
|
||||
|
||||
EnjoyApp.videos.findOne(where).then((video) => {
|
||||
if (video) {
|
||||
setVideo(video);
|
||||
@@ -34,9 +36,9 @@ export const useVideo = (options: { id?: string; md5?: string }) => {
|
||||
}
|
||||
});
|
||||
|
||||
addDblistener(onAudioUpdate);
|
||||
addDblistener(onVideoUpdate);
|
||||
return () => {
|
||||
removeDbListener(onAudioUpdate);
|
||||
removeDbListener(onVideoUpdate);
|
||||
};
|
||||
}, [id, md5]);
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export function bytesToSize(bytes: number) {
|
||||
return Math.round(bytes / Math.pow(1024, i)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
export function formatDateTime(date: Date) {
|
||||
export function formatDateTime(date: Date | string) {
|
||||
dayjs.locale(i18next.resolvedLanguage?.toLowerCase() || "en");
|
||||
const now = dayjs();
|
||||
const then = dayjs(date);
|
||||
|
||||
@@ -46,12 +46,12 @@ export default () => {
|
||||
ttsPreset: {
|
||||
key: "tts",
|
||||
name: "TTS",
|
||||
engine: currentEngine.name,
|
||||
engine: currentEngine?.name,
|
||||
configuration: {
|
||||
type: "tts",
|
||||
tts: {
|
||||
engine: currentEngine.name,
|
||||
model: currentEngine.name === "enjoyai" ? "openai/tts-1" : "tts-1",
|
||||
engine: currentEngine?.name,
|
||||
model: currentEngine?.name === "enjoyai" ? "openai/tts-1" : "tts-1",
|
||||
voice: "alloy",
|
||||
},
|
||||
},
|
||||
|
||||
80
enjoy/src/renderer/pages/courses/chapter.tsx
Normal file
80
enjoy/src/renderer/pages/courses/chapter.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
ScrollArea,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { AppSettingsProviderContext, CourseProvider } from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { ChapterContent, LlmChat } from "@renderer/components";
|
||||
|
||||
export default () => {
|
||||
const { id, sequence } = useParams<{ id: string; sequence: string }>();
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [chapter, setChapter] = useState<ChapterType | null>(null);
|
||||
|
||||
const fetchChapter = async (id: string, sequence: string) => {
|
||||
webApi
|
||||
.coursechapter(id, sequence)
|
||||
.then((chapter) => setChapter(chapter))
|
||||
.catch((err) => toast.error(err.message));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchChapter(id, sequence);
|
||||
}, [id, sequence]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chapter) return;
|
||||
webApi.updateEnrollment(chapter.enrollment.id, {
|
||||
currentChapterId: chapter.id,
|
||||
});
|
||||
}, [chapter]);
|
||||
|
||||
return (
|
||||
<CourseProvider id={id}>
|
||||
<div className="flex flex-col h-screen px-4 xl:px-6 py-6">
|
||||
<Breadcrumb className="mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to="/courses">{t("sidebar.courses")}</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbPage>
|
||||
<Link to={`/courses/${id}`}>{chapter?.course?.title || id}</Link>
|
||||
</BreadcrumbPage>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbPage>{chapter?.title || sequence}</BreadcrumbPage>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="flex-1 h-[calc(100vh-5.75rem)] border rounded-lg">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel defaultSize={50}>
|
||||
<ScrollArea className="px-4 py-3 h-full relative bg-muted">
|
||||
<ChapterContent
|
||||
chapter={chapter}
|
||||
onUpdate={() => fetchChapter(id, sequence)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel className="bg-muted">
|
||||
<LlmChat agentType="Chapter" agentId={chapter?.id} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
</CourseProvider>
|
||||
);
|
||||
};
|
||||
67
enjoy/src/renderer/pages/courses/index.tsx
Normal file
67
enjoy/src/renderer/pages/courses/index.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { Button, toast } from "@renderer/components/ui";
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { t } from "i18next";
|
||||
import { CourseCard } from "@renderer/components";
|
||||
|
||||
export default () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="h-full max-w-5xl 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" />
|
||||
</Button>
|
||||
<span>{t("sidebar.courses")}</span>
|
||||
</div>
|
||||
<div className="">
|
||||
<CoursesList />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CoursesList = () => {
|
||||
const { webApi, learningLanguage } = useContext(AppSettingsProviderContext);
|
||||
const [courses, setCourses] = useState<CourseType[]>([]);
|
||||
const [nextPage, setNextPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchCourses = async () => {
|
||||
if (loading) return;
|
||||
|
||||
webApi
|
||||
.courses({ page: nextPage, language: learningLanguage })
|
||||
.then(({ courses = [], next }) => {
|
||||
setCourses(courses);
|
||||
setNextPage(next);
|
||||
})
|
||||
.catch((err) => toast.error(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourses();
|
||||
}, []);
|
||||
|
||||
if (!courses.length) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div>
|
||||
<span>{t("noData")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-5">
|
||||
{courses.map((course) => (
|
||||
<CourseCard key={course.id} course={course} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
123
enjoy/src/renderer/pages/courses/show.tsx
Normal file
123
enjoy/src/renderer/pages/courses/show.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
Button,
|
||||
Progress,
|
||||
Separator,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { t } from "i18next";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { LoaderIcon, UsersIcon } from "lucide-react";
|
||||
import { Chapters } from "@renderer/components";
|
||||
|
||||
export default () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [course, setCourse] = useState<CourseType | null>(null);
|
||||
|
||||
const fetchCourse = async (id: string) => {
|
||||
webApi
|
||||
.course(id)
|
||||
.then((course) => setCourse(course))
|
||||
.catch((err) => toast.error(err.message));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourse(id);
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<div className="h-full max-w-5xl mx-auto px-4 py-6">
|
||||
<Breadcrumb className="mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to="/courses">{t("sidebar.courses")}</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbPage>{course?.title || id}</BreadcrumbPage>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<div className="mb-6">
|
||||
<CourseDetail course={course} onUpdate={() => fetchCourse(course.id)} />
|
||||
</div>
|
||||
<Separator className="mb-6" />
|
||||
<div className="mb-6">
|
||||
<Chapters course={course} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CourseDetail = (props: { course: CourseType; onUpdate: () => void }) => {
|
||||
const { course, onUpdate } = props;
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [enrolling, setEnrolling] = useState(false);
|
||||
const handleEnroll = async () => {
|
||||
if (!course) return;
|
||||
if (course.enrolled) return;
|
||||
|
||||
setEnrolling(true);
|
||||
webApi
|
||||
.createEnrollment(course.id)
|
||||
.then(() => onUpdate())
|
||||
.catch((err) => toast.error(err.message))
|
||||
.finally(() => setEnrolling(false));
|
||||
};
|
||||
|
||||
if (!course) return null;
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-4 gap-4">
|
||||
<div className="h-48 aspect-square bg-black/10 rounded">
|
||||
<img
|
||||
src={course.coverUrl}
|
||||
alt={course.title}
|
||||
className="object-cover w-full h-full rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-h-48">
|
||||
<div className="text-2xl font-bold mb-4">{course.title}</div>
|
||||
<div className="mb-4">{course.description}</div>
|
||||
{course.enrolled && (
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<span>{(course.enrollment.progress * 100).toFixed(2)}%</span>
|
||||
<Progress value={course.enrollment.progress * 100} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
{course.enrolled ? (
|
||||
<Link
|
||||
to={`/courses/${course.id}/chapters/${
|
||||
course.enrollment.currentChapterSequence || 1
|
||||
}`}
|
||||
>
|
||||
<Button onClick={handleEnroll}>{t("continueLearning")}</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button disabled={course.enrolled} onClick={handleEnroll}>
|
||||
{enrolling && (
|
||||
<LoaderIcon className="w-4 h-4 mr-2 animate-spin" />
|
||||
)}
|
||||
{t("enrollNow")}
|
||||
</Button>
|
||||
)}
|
||||
{course.enrollmentsCount > 0 && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<UsersIcon className="w-4 h-4" />
|
||||
<span className="text-sm text-muted-foreground">{course.enrollmentsCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
StoriesSegment,
|
||||
VideosSegment,
|
||||
YoutubeVideosSegment,
|
||||
EnrollmentSegment,
|
||||
} from "@renderer/components";
|
||||
|
||||
export default () => {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-6 lg:px-8">
|
||||
<div className="space-y-4">
|
||||
<EnrollmentSegment />
|
||||
<AudiosSegment />
|
||||
<VideosSegment />
|
||||
<StoriesSegment />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './audios-reducer';
|
||||
export * from './conversations-reducer';
|
||||
export * from './messages-reducer';
|
||||
export * from './recordings-reducer';
|
||||
export * from './videos-reducer';
|
||||
export * from "./audios-reducer";
|
||||
export * from "./conversations-reducer";
|
||||
export * from "./messages-reducer";
|
||||
export * from "./llm-messages-reducer";
|
||||
export * from "./recordings-reducer";
|
||||
export * from "./videos-reducer";
|
||||
|
||||
39
enjoy/src/renderer/reducers/llm-messages-reducer.ts
Normal file
39
enjoy/src/renderer/reducers/llm-messages-reducer.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export const llmMessagesReducer = (
|
||||
state: LlmMessageType[],
|
||||
action: {
|
||||
type: "create" | "update" | "destroy" | "set";
|
||||
record?: LlmMessageType;
|
||||
records?: LlmMessageType[];
|
||||
}
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "create": {
|
||||
const index = state.findIndex((c) => c.id === action.record.id);
|
||||
if (index === -1) {
|
||||
state.push(action.record);
|
||||
} else {
|
||||
state[index] = action.record;
|
||||
}
|
||||
|
||||
return [...state];
|
||||
}
|
||||
case "update": {
|
||||
return state.map((c) => {
|
||||
if (c.id === action.record.id) {
|
||||
return action.record;
|
||||
} else {
|
||||
return c;
|
||||
}
|
||||
});
|
||||
}
|
||||
case "destroy": {
|
||||
return state.filter((c) => c.id !== action.record.id);
|
||||
}
|
||||
case "set": {
|
||||
return action.records || [];
|
||||
}
|
||||
default: {
|
||||
throw Error(`Unknown action: ${action.type}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -20,6 +20,9 @@ import StoryPreview from "./pages/story-preview";
|
||||
import Notes from "./pages/notes";
|
||||
import PronunciationAssessmentsIndex from "./pages/pronunciation-assessments/index";
|
||||
import PronunciationAssessmentsNew from "./pages/pronunciation-assessments/new";
|
||||
import Courses from "./pages/courses/index";
|
||||
import Course from "./pages/courses/show";
|
||||
import Chapter from "./pages/courses/chapter";
|
||||
|
||||
export default createHashRouter([
|
||||
{
|
||||
@@ -28,6 +31,18 @@ export default createHashRouter([
|
||||
errorElement: <ErrorPage />,
|
||||
children: [
|
||||
{ index: true, element: <Home /> },
|
||||
{
|
||||
path: "/courses",
|
||||
element: <Courses />,
|
||||
},
|
||||
{
|
||||
path: "/courses/:id",
|
||||
element: <Course />,
|
||||
},
|
||||
{
|
||||
path: "/courses/:id/chapters/:sequence",
|
||||
element: <Chapter />,
|
||||
},
|
||||
{
|
||||
path: "/community",
|
||||
element: <Community />,
|
||||
|
||||
26
enjoy/src/types/chapter.d.ts
vendored
Normal file
26
enjoy/src/types/chapter.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
type ChapterType = {
|
||||
id: string;
|
||||
courseId: string;
|
||||
sequence: number;
|
||||
keywords: string[];
|
||||
title: string;
|
||||
content: string;
|
||||
course?: CourseType;
|
||||
translations?: {
|
||||
language: string;
|
||||
content: string;
|
||||
}[];
|
||||
finished?: boolean;
|
||||
examples?: {
|
||||
id: string;
|
||||
keywords: string[];
|
||||
language: string;
|
||||
content: string;
|
||||
audioUrl?: string;
|
||||
translations?: {
|
||||
language: string;
|
||||
content: string;
|
||||
}[];
|
||||
}[];
|
||||
enrollment?: EnrollmentType;
|
||||
};
|
||||
11
enjoy/src/types/course.d.ts
vendored
Normal file
11
enjoy/src/types/course.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
type CourseType = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
chaptersCount: number;
|
||||
enrollmentsCount: number;
|
||||
coverUrl?: string;
|
||||
chapters?: ChapterType[];
|
||||
enrolled?: boolean;
|
||||
enrollment?: EnrollmentType;
|
||||
};
|
||||
1
enjoy/src/types/enjoy-app.d.ts
vendored
1
enjoy/src/types/enjoy-app.d.ts
vendored
@@ -227,6 +227,7 @@ type EnjoyAppType = {
|
||||
createSpeech: (id: string, configuration?: any) => Promise<SpeechType>;
|
||||
};
|
||||
speeches: {
|
||||
findOne: (where: any) => Promise<SpeechType>;
|
||||
create: (
|
||||
params: {
|
||||
sourceId: string;
|
||||
|
||||
11
enjoy/src/types/enrollment.d.ts
vendored
Normal file
11
enjoy/src/types/enrollment.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
type EnrollmentType = {
|
||||
id: string;
|
||||
courseId: string;
|
||||
state: string;
|
||||
progress: number;
|
||||
currentChapterId?: string;
|
||||
currentChapterSequence?: number;
|
||||
course?: CourseType;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
1
enjoy/src/types/index.d.ts
vendored
1
enjoy/src/types/index.d.ts
vendored
@@ -117,6 +117,7 @@ type MeaningType = {
|
||||
type PagyResponseType = {
|
||||
page: number;
|
||||
next: number | null;
|
||||
last: number;
|
||||
};
|
||||
|
||||
type AudibleBookType = {
|
||||
|
||||
6
enjoy/src/types/llm-agent.d.ts
vendored
Normal file
6
enjoy/src/types/llm-agent.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
type LLmAgentType = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
introduction: string;
|
||||
};
|
||||
5
enjoy/src/types/llm-chat.d.ts
vendored
Normal file
5
enjoy/src/types/llm-chat.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
type LLmChatType = {
|
||||
id: string;
|
||||
user: UserType;
|
||||
agent: LLmAgentType;
|
||||
};
|
||||
10
enjoy/src/types/llm-message.d.ts
vendored
Normal file
10
enjoy/src/types/llm-message.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
type LlmMessageType = {
|
||||
id?: string;
|
||||
query?: string;
|
||||
response?: string;
|
||||
agent: LLmAgentType;
|
||||
user?: UserType;
|
||||
chat: LLmChatType;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
@@ -24,7 +24,7 @@
|
||||
"docs:preview": "yarn workspace 1000-hours preview",
|
||||
"portal:generate": "yarn workspace 1000h-portal generate"
|
||||
},
|
||||
"packageManager": "yarn@4.2.1",
|
||||
"packageManager": "yarn@4.3.1",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user