From e3d64dcf240f303c69092ef45dad2ae46f8258eb Mon Sep 17 00:00:00 2001 From: an-lee Date: Thu, 11 Jan 2024 12:50:40 +0800 Subject: [PATCH 01/26] update forge config publish repo --- enjoy/forge.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/enjoy/forge.config.ts b/enjoy/forge.config.ts index 84e2a805..cb1f3a21 100644 --- a/enjoy/forge.config.ts +++ b/enjoy/forge.config.ts @@ -50,8 +50,8 @@ const config: ForgeConfig = { name: "@electron-forge/publisher-github", config: { repository: { - owner: "an-lee", - name: "enjoy", + owner: "xiaolai", + name: "everyone-can-use-english", }, draft: true, }, From 94d4a0a33873abcdc4804967e39c1975f7301dcb Mon Sep 17 00:00:00 2001 From: an-lee Date: Thu, 11 Jan 2024 17:10:00 +0800 Subject: [PATCH 02/26] add community page --- enjoy/package.json | 2 +- enjoy/src/api/client.ts | 230 ++++++++++++++++++ enjoy/src/api/index.ts | 1 + enjoy/src/i18n/en.json | 13 +- enjoy/src/i18n/zh-CN.json | 11 +- enjoy/src/main/web-api.ts | 130 ++++++++-- enjoy/src/renderer/components/index.ts | 3 + enjoy/src/renderer/components/login-form.tsx | 18 +- enjoy/src/renderer/components/posts/index.ts | 1 + enjoy/src/renderer/components/posts/posts.tsx | 37 +++ enjoy/src/renderer/components/sidebar.tsx | 16 ++ enjoy/src/renderer/components/users/index.ts | 1 + .../components/users/users-rankings.tsx | 89 +++++++ .../context/app-settings-provider.tsx | 11 + enjoy/src/renderer/lib/utils.ts | 19 ++ enjoy/src/renderer/pages/community.tsx | 41 ++++ enjoy/src/renderer/router.tsx | 5 + enjoy/src/types/post.d.ts | 7 + enjoy/src/types/user.d.ts | 2 + 19 files changed, 604 insertions(+), 33 deletions(-) create mode 100644 enjoy/src/api/client.ts create mode 100644 enjoy/src/api/index.ts create mode 100644 enjoy/src/renderer/components/posts/index.ts create mode 100644 enjoy/src/renderer/components/posts/posts.tsx create mode 100644 enjoy/src/renderer/components/users/index.ts create mode 100644 enjoy/src/renderer/components/users/users-rankings.tsx create mode 100644 enjoy/src/renderer/pages/community.tsx create mode 100644 enjoy/src/types/post.d.ts diff --git a/enjoy/package.json b/enjoy/package.json index fb6e6a16..4925914f 100644 --- a/enjoy/package.json +++ b/enjoy/package.json @@ -7,7 +7,7 @@ "main": ".vite/build/main.js", "types": "./src/types.d.ts", "scripts": { - "dev": "rimraf .vite && WEB_API_URL=http://localhost:3000 electron-forge start", + "dev": "rimraf .vite && WEB_API_URL=http://192.168.31.116:3000 electron-forge start", "start": "rimraf .vite && electron-forge start", "package": "rimraf .vite && electron-forge package", "make": "rimraf .vite && electron-forge make", diff --git a/enjoy/src/api/client.ts b/enjoy/src/api/client.ts new file mode 100644 index 00000000..bf130586 --- /dev/null +++ b/enjoy/src/api/client.ts @@ -0,0 +1,230 @@ +import axios, { AxiosInstance } from "axios"; +import decamelizeKeys from "decamelize-keys"; +import camelcaseKeys from "camelcase-keys"; + +const ONE_MINUTE = 1000 * 60; // 1 minute + +export class Client { + public api: AxiosInstance; + + constructor(options: { baseUrl: string; accessToken?: string }) { + const { baseUrl, accessToken } = options; + + this.api = axios.create({ + baseURL: baseUrl, + timeout: ONE_MINUTE, + headers: { + "Content-Type": "application/json", + }, + }); + this.api.interceptors.request.use((config) => { + config.headers.Authorization = `Bearer ${accessToken}`; + + console.info( + config.method.toUpperCase(), + config.baseURL + config.url, + config.data, + config.params + ); + return config; + }); + this.api.interceptors.response.use( + (response) => { + console.info( + response.status, + response.config.method.toUpperCase(), + response.config.baseURL + response.config.url + ); + return camelcaseKeys(response.data, { deep: true }); + }, + (err) => { + if (err.response) { + console.error( + err.response.status, + err.response.config.method.toUpperCase(), + err.response.config.baseURL + err.response.config.url + ); + console.error(err.response.data); + return Promise.reject(err.response.data); + } + + if (err.request) { + console.error(err.request); + } else { + console.error(err.message); + } + + return Promise.reject(err); + } + ); + } + + auth(params: { provider: string; code: string }): Promise { + return this.api.post("/api/sessions", decamelizeKeys(params)); + } + + me() { + return this.api.get("/api/me"); + } + + rankings(range: "day" | "week" | "month" | "year" | "all" = "day"): Promise<{ + rankings: UserType[]; + range: string; + }> { + return this.api.get("/api/users/rankings", { params: { range } }); + } + + posts(params?: { page?: number; items?: number }): Promise< + { + posts: PostType[]; + } & PagyResponseType + > { + return this.api.get("/api/posts", { params: decamelizeKeys(params) }); + } + + post(id: string): Promise { + return this.api.get(`/api/posts/${id}`); + } + + createPost(params: { content: string }): Promise { + return this.api.post("/api/posts", decamelizeKeys(params)); + } + + updatePost(id: string, params: { content: string }): Promise { + return this.api.put(`/api/posts/${id}`, decamelizeKeys(params)); + } + + deletePost(id: string): Promise { + return this.api.delete(`/api/posts/${id}`); + } + + syncAudio(audio: Partial) { + return this.api.post("/api/mine/audios", decamelizeKeys(audio)); + } + + syncVideo(video: Partial) { + return this.api.post("/api/mine/videos", decamelizeKeys(video)); + } + + syncTranscription(transcription: Partial) { + return this.api.post("/api/transcriptions", decamelizeKeys(transcription)); + } + + syncRecording(recording: Partial) { + if (!recording) return; + + return this.api.post("/api/mine/recordings", decamelizeKeys(recording)); + } + + generateSpeechToken(): Promise<{ token: string; region: string }> { + return this.api.post("/api/speech/tokens"); + } + + syncPronunciationAssessment( + pronunciationAssessment: Partial + ) { + if (!pronunciationAssessment) return; + + return this.api.post( + "/api/mine/pronunciation_assessments", + decamelizeKeys(pronunciationAssessment) + ); + } + + recordingAssessment(id: string) { + return this.api.get(`/api/mine/recordings/${id}/assessment`); + } + + lookup(params: { + word: string; + context: string; + sourceId?: string; + sourceType?: string; + }): Promise { + return this.api.post("/api/lookups", decamelizeKeys(params)); + } + + lookupInBatch( + lookups: { + word: string; + context: string; + sourceId?: string; + sourceType?: string; + }[] + ): Promise<{ successCount: number; total: number }> { + return this.api.post("/api/lookups/batch", { + lookups: decamelizeKeys(lookups, { deep: true }), + }); + } + + extractVocabularyFromStory(storyId: string): Promise { + return this.api.post(`/api/stories/${storyId}/extract_vocabulary`); + } + + storyMeanings( + storyId: string, + params?: { + page?: number; + items?: number; + storyId?: string; + } + ): Promise< + { + meanings: MeaningType[]; + } & PagyResponseType + > { + return this.api.get(`/api/stories/${storyId}/meanings`, { + params: decamelizeKeys(params), + }); + } + + mineMeanings(params?: { + page?: number; + items?: number; + sourceId?: string; + sourceType?: string; + status?: string; + }): Promise< + { + meanings: MeaningType[]; + } & PagyResponseType + > { + return this.api.get("/api/mine/meanings", { + params: decamelizeKeys(params), + }); + } + + createStory(params: CreateStoryParamsType): Promise { + return this.api.post("/api/stories", decamelizeKeys(params)); + } + + story(id: string): Promise { + return this.api.get(`/api/stories/${id}`); + } + + stories(params?: { page: number }): Promise< + { + stories: StoryType[]; + } & PagyResponseType + > { + return this.api.get("/api/stories", { params: decamelizeKeys(params) }); + } + + mineStories(params?: { page: number }): Promise< + { + stories: StoryType[]; + } & PagyResponseType + > { + return this.api.get("/api/mine/stories", { + params: decamelizeKeys(params), + }); + } + + starStory(storyId: string) { + return this.api.post(`/api/mine/stories`, decamelizeKeys({ storyId })); + } + + unstarStory(storyId: string) { + return this.api.delete(`/api/mine/stories/${storyId}`); + } +} diff --git a/enjoy/src/api/index.ts b/enjoy/src/api/index.ts new file mode 100644 index 00000000..5ec76921 --- /dev/null +++ b/enjoy/src/api/index.ts @@ -0,0 +1 @@ +export * from "./client"; diff --git a/enjoy/src/i18n/en.json b/enjoy/src/i18n/en.json index 37d71e4c..feaadd24 100644 --- a/enjoy/src/i18n/en.json +++ b/enjoy/src/i18n/en.json @@ -122,6 +122,7 @@ }, "sidebar": { "home": "Home", + "community": "Community", "audios": "Audios", "videos": "Videos", "stories": "Stories", @@ -237,7 +238,7 @@ "recentlyAdded": "recently added", "recommended": "recommended", "resourcesRecommendedByEnjoy": "resources recommended by Enjoy Bot", - "fromCommunity": "from commnuity", + "fromCommunity": "from community", "videoResources": "video resources", "audioResources": "audio resources", "seeMore": "see more", @@ -320,5 +321,13 @@ "presenter": "presenter", "downloadAudio": "Download audio", "downloadVideo": "Download video", - "recordTooShort": "Record too short" + "recordTooShort": "Record too short", + "rankings": "Rankings", + "dayRankings": "Day rankings", + "weekRankings": "Week rankings", + "monthRankings": "Month rankings", + "allRankings": "All time rankings", + "noOneHasRecordedYet": "No one has recorded yet", + "activities": "Activities", + "noOneSharedYet": "No one shared yet" } diff --git a/enjoy/src/i18n/zh-CN.json b/enjoy/src/i18n/zh-CN.json index 87480325..4b330cba 100644 --- a/enjoy/src/i18n/zh-CN.json +++ b/enjoy/src/i18n/zh-CN.json @@ -122,6 +122,7 @@ }, "sidebar": { "home": "主页", + "community": "社区", "audios": "音频", "videos": "视频", "stories": "文章", @@ -320,5 +321,13 @@ "presenter": "讲者", "downloadAudio": "下载音频", "downloadVideo": "下载视频", - "recordTooShort": "录音时长太短" + "recordTooShort": "录音时长太短", + "rankings": "排行榜", + "dayRankings": "日排行榜", + "weekRankings": "周排行榜", + "monthRankings": "月排行榜", + "allRankings": "总排行榜", + "noOneHasRecordedYet": "还没有人练习", + "activities": "动态", + "noOneSharedYet": "还没有人分享" } diff --git a/enjoy/src/main/web-api.ts b/enjoy/src/main/web-api.ts index f8522f99..dc274736 100644 --- a/enjoy/src/main/web-api.ts +++ b/enjoy/src/main/web-api.ts @@ -63,12 +63,43 @@ class WebApi { ); } + auth(params: { provider: string; code: string }): Promise { + return this.api.post("/api/sessions", decamelizeKeys(params)); + } + me() { return this.api.get("/api/me"); } - auth(params: { provider: string; code: string }): Promise { - return this.api.post("/api/sessions", decamelizeKeys(params)); + rankings(range: "day" | "week" | "month" | "year" | "all" = "day"): Promise<{ + rankings: UserType[]; + range: string; + }> { + return this.api.get("/api/users/rankings", { params: { range } }); + } + + posts(params?: { page?: number; items?: number }): Promise< + { + posts: PostType[]; + } & PagyResponseType + > { + return this.api.get("/api/posts", { params: decamelizeKeys(params) }); + } + + post(id: string): Promise { + return this.api.get(`/api/posts/${id}`); + } + + createPost(params: { content: string }): Promise { + return this.api.post("/api/posts", decamelizeKeys(params)); + } + + updatePost(id: string, params: { content: string }): Promise { + return this.api.put(`/api/posts/${id}`, decamelizeKeys(params)); + } + + deletePost(id: string): Promise { + return this.api.delete(`/api/posts/${id}`); } syncAudio(audio: Partial) { @@ -228,6 +259,76 @@ class WebApi { }); }); + ipcMain.handle("web-api-rankings", async (event, range) => { + return this.rankings(range) + .then((response) => { + return response; + }) + .catch((error) => { + logger.error(error); + event.sender.send("on-notification", { + type: "error", + message: error.message, + }); + }); + }); + + ipcMain.handle("web-api-posts", async (event, params) => { + return this.posts(params) + .then((response) => { + return response; + }) + .catch((error) => { + logger.error(error); + event.sender.send("on-notification", { + type: "error", + message: error.message, + }); + }); + }); + + ipcMain.handle("web-api-post", async (event, id) => { + return this.post(id) + .then((response) => { + return response; + }) + .catch((error) => { + logger.error(error); + event.sender.send("on-notification", { + type: "error", + message: error.message, + }); + }); + }); + + ipcMain.handle("web-api-create-post", async (event, params) => { + return this.createPost(params) + .then((response) => { + return response; + }) + .catch((error) => { + logger.error(error); + event.sender.send("on-notification", { + type: "error", + message: error.message, + }); + }); + }); + + ipcMain.handle("web-api-delete-post", async (event, id) => { + return this.deletePost(id) + .then((response) => { + return response; + }) + .catch((error) => { + logger.error(error); + event.sender.send("on-notification", { + type: "error", + message: error.message, + }); + }); + }); + ipcMain.handle("web-api-lookup", async (event, params) => { return this.lookup(params) .then((response) => { @@ -296,21 +397,18 @@ class WebApi { } ); - ipcMain.handle( - "web-api-story-meanings", - async (event, storyId, params) => { - return this.storyMeanings(storyId, params) - .then((response) => { - return response; - }) - .catch((error) => { - event.sender.send("on-notification", { - type: "error", - message: error.message, - }); + ipcMain.handle("web-api-story-meanings", async (event, storyId, params) => { + return this.storyMeanings(storyId, params) + .then((response) => { + return response; + }) + .catch((error) => { + event.sender.send("on-notification", { + type: "error", + message: error.message, }); - } - ); + }); + }); ipcMain.handle("web-api-stories", async (event, params) => { return this.stories(params) diff --git a/enjoy/src/renderer/components/index.ts b/enjoy/src/renderer/components/index.ts index 03d25d33..cc9ae1fb 100644 --- a/enjoy/src/renderer/components/index.ts +++ b/enjoy/src/renderer/components/index.ts @@ -10,6 +10,9 @@ export * from "./videos"; export * from "./medias"; +export * from "./posts"; +export * from "./users"; + export * from "./db-state"; export * from "./layout"; diff --git a/enjoy/src/renderer/components/login-form.tsx b/enjoy/src/renderer/components/login-form.tsx index 6a22cac6..c71ec358 100644 --- a/enjoy/src/renderer/components/login-form.tsx +++ b/enjoy/src/renderer/components/login-form.tsx @@ -1,16 +1,14 @@ import { Button, useToast } from "@renderer/components/ui"; -import { useContext, useState, useEffect } from "react"; -import { WEB_API_URL } from "@/constants"; +import { useContext, useEffect } from "react"; import { AppSettingsProviderContext } from "@renderer/context"; import { t } from "i18next"; export const LoginForm = () => { const { toast } = useToast(); - const { EnjoyApp, login } = useContext(AppSettingsProviderContext); - const [endpoint, setEndpoint] = useState(WEB_API_URL); + const { EnjoyApp, login, apiUrl } = useContext(AppSettingsProviderContext); const handleMixinLogin = () => { - const url = `${endpoint}/sessions/new?provider=mixin`; + const url = `${apiUrl}/sessions/new?provider=mixin`; EnjoyApp.view.load(url, { x: 0, y: 0 }); }; @@ -36,7 +34,7 @@ export const LoginForm = () => { const provider = new URL(url).pathname.split("/")[2]; const code = new URL(url).searchParams.get("code"); - if (!url.startsWith(endpoint)) { + if (!url.startsWith(apiUrl)) { toast({ title: t("error"), description: t("invalidRedirectUrl"), @@ -65,12 +63,6 @@ export const LoginForm = () => { } }; - useEffect(() => { - EnjoyApp.app.apiUrl().then((url) => { - setEndpoint(url); - }); - }, []); - useEffect(() => { EnjoyApp.view.onViewState((_event, state) => onViewState(state)); @@ -78,7 +70,7 @@ export const LoginForm = () => { EnjoyApp.view.removeViewStateListeners(); EnjoyApp.view.remove(); }; - }, [endpoint]); + }, [apiUrl]); return (
diff --git a/enjoy/src/renderer/components/posts/index.ts b/enjoy/src/renderer/components/posts/index.ts new file mode 100644 index 00000000..595c3ebb --- /dev/null +++ b/enjoy/src/renderer/components/posts/index.ts @@ -0,0 +1 @@ +export * from './posts'; diff --git a/enjoy/src/renderer/components/posts/posts.tsx b/enjoy/src/renderer/components/posts/posts.tsx new file mode 100644 index 00000000..30abad1b --- /dev/null +++ b/enjoy/src/renderer/components/posts/posts.tsx @@ -0,0 +1,37 @@ +import { useContext, useEffect, useState } from "react"; +import { Client } from "@/api"; +import { AppSettingsProviderContext } from "@renderer/context"; +import { t } from "i18next"; + +export const Posts = () => { + const { apiUrl, user } = useContext(AppSettingsProviderContext); + const [posts, setPosts] = useState([]); + + const client = new Client({ + baseUrl: apiUrl, + accessToken: user.accessToken, + }); + + const fetchPosts = async () => { + client.posts().then( + (res) => { + setPosts(res.posts); + }, + (err) => { + console.error(err); + } + ); + }; + + useEffect(() => { + fetchPosts(); + }, []); + + return ( +
+ {posts.length === 0 && ( +
{t("noOneSharedYet")}
+ )} +
+ ); +}; diff --git a/enjoy/src/renderer/components/sidebar.tsx b/enjoy/src/renderer/components/sidebar.tsx index ed0dd273..0713fa68 100644 --- a/enjoy/src/renderer/components/sidebar.tsx +++ b/enjoy/src/renderer/components/sidebar.tsx @@ -14,6 +14,7 @@ import { BookMarkedIcon, UserIcon, BotIcon, + UsersRoundIcon, } from "lucide-react"; import { useLocation, Link } from "react-router-dom"; import { t } from "i18next"; @@ -50,6 +51,21 @@ export const Sidebar = () => { {t("sidebar.home")} + + + +
diff --git a/enjoy/src/renderer/components/users/index.ts b/enjoy/src/renderer/components/users/index.ts new file mode 100644 index 00000000..f9a12062 --- /dev/null +++ b/enjoy/src/renderer/components/users/index.ts @@ -0,0 +1 @@ +export * from './users-rankings'; diff --git a/enjoy/src/renderer/components/users/users-rankings.tsx b/enjoy/src/renderer/components/users/users-rankings.tsx new file mode 100644 index 00000000..04cadb66 --- /dev/null +++ b/enjoy/src/renderer/components/users/users-rankings.tsx @@ -0,0 +1,89 @@ +import { useContext, useEffect, useState } from "react"; +import { + Avatar, + AvatarImage, + AvatarFallback, + Card, + CardTitle, + CardHeader, + CardContent, +} from "@renderer/components/ui"; +import { Client } from "@/api"; +import { AppSettingsProviderContext } from "@renderer/context"; +import { t } from "i18next"; +import { formatDuration } from "@renderer/lib/utils"; + +export const UsersRankings = () => { + return ( +
+ + + + +
+ ); +}; + +const RankingsCard = (props: { + range: "day" | "week" | "month" | "year" | "all"; +}) => { + const { range } = props; + const { apiUrl, user } = useContext(AppSettingsProviderContext); + const [rankings, setRankings] = useState([]); + + const client = new Client({ + baseUrl: apiUrl, + accessToken: user.accessToken, + }); + + const fetchRankings = async () => { + client.rankings(range).then( + (res) => { + setRankings(res.rankings); + }, + (err) => { + console.error(err); + } + ); + }; + + useEffect(() => { + fetchRankings(); + }, []); + + return ( + + + {t(`${range}Rankings`)} + + + {rankings.length === 0 && ( +
+ {t("noOneHasRecordedYet")} +
+ )} + + {rankings.map((user, index) => ( +
+
#{index + 1}
+ +
+ + + + {user.name[0].toUpperCase()} + + + +
{user.name}
+
+ +
+ {formatDuration(user.recordingsDuration, "millisecond")} +
+
+ ))} +
+
+ ); +}; diff --git a/enjoy/src/renderer/context/app-settings-provider.tsx b/enjoy/src/renderer/context/app-settings-provider.tsx index 0bedb1ae..3cf18a58 100644 --- a/enjoy/src/renderer/context/app-settings-provider.tsx +++ b/enjoy/src/renderer/context/app-settings-provider.tsx @@ -1,6 +1,8 @@ import { createContext, useEffect, useState } from "react"; +import { WEB_API_URL } from "@/constants"; type AppSettingsProviderState = { + apiUrl: string; user: UserType | null; initialized: boolean; version?: string; @@ -17,6 +19,7 @@ type AppSettingsProviderState = { }; const initialState: AppSettingsProviderState = { + apiUrl: WEB_API_URL, user: null, initialized: false, }; @@ -31,6 +34,7 @@ export const AppSettingsProvider = ({ }) => { const [initialized, setInitialized] = useState(false); const [version, setVersion] = useState(""); + const [apiUrl, setApiUrl] = useState(WEB_API_URL); const [user, setUser] = useState(null); const [libraryPath, setLibraryPath] = useState(""); const [whisperModelsPath, setWhisperModelsPath] = useState(""); @@ -44,6 +48,7 @@ export const AppSettingsProvider = ({ fetchLibraryPath(); fetchModel(); fetchFfmpegConfig(); + fetchApiUrl(); }, []); useEffect(() => { @@ -107,6 +112,11 @@ export const AppSettingsProvider = ({ setWhisperModel(whisperModel); }; + const fetchApiUrl = async () => { + const apiUrl = await EnjoyApp.app.apiUrl(); + setApiUrl(apiUrl); + } + const setModelHandler = async (name: string) => { await EnjoyApp.settings.setWhisperModel(name); setWhisperModel(name); @@ -123,6 +133,7 @@ export const AppSettingsProvider = ({ value={{ EnjoyApp, version, + apiUrl, user, login, logout, diff --git a/enjoy/src/renderer/lib/utils.ts b/enjoy/src/renderer/lib/utils.ts index 582c9453..59be43bb 100644 --- a/enjoy/src/renderer/lib/utils.ts +++ b/enjoy/src/renderer/lib/utils.ts @@ -3,10 +3,12 @@ import { twMerge } from "tailwind-merge"; import dayjs from "dayjs"; import localizedFormat from "dayjs/plugin/localizedFormat"; import relativeTime from "dayjs/plugin/relativeTime"; +import duration, { type DurationUnitType } from "dayjs/plugin/duration"; import "dayjs/locale/en"; import "dayjs/locale/zh-cn"; import i18next, { t } from "i18next"; dayjs.extend(localizedFormat); +dayjs.extend(duration); dayjs.extend(relativeTime); export function cn(...inputs: ClassValue[]) { @@ -18,6 +20,23 @@ export function secondsToTimestamp(seconds: number) { return date.toISOString().substr(11, 8); } +export function humanizeDuration( + duration: number, + unit: DurationUnitType = "second" +) { + dayjs.locale(i18next.resolvedLanguage?.toLowerCase() || "en"); + return dayjs.duration(duration, unit).humanize(); +} + +export function formatDuration( + duration: number, + unit: DurationUnitType = "second", + format = "HH:mm:ss" +) { + dayjs.locale(i18next.resolvedLanguage?.toLowerCase() || "en"); + return dayjs.duration(duration, unit).format(format); +} + export function bytesToSize(bytes: number) { const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; if (bytes === 0) { diff --git a/enjoy/src/renderer/pages/community.tsx b/enjoy/src/renderer/pages/community.tsx new file mode 100644 index 00000000..97055906 --- /dev/null +++ b/enjoy/src/renderer/pages/community.tsx @@ -0,0 +1,41 @@ +import { + Button, + Tabs, + TabsList, + TabsContent, + TabsTrigger, +} from "@renderer/components/ui"; +import { UsersRankings, Posts } from "@renderer/components"; +import { ChevronLeftIcon } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { t } from "i18next"; + +export default () => { + const navigate = useNavigate(); + + return ( +
+
+ + {t("sidebar.community")} +
+ + + + {t("activities")} + {t("rankings")} + + + + + + + + + + +
+ ); +}; diff --git a/enjoy/src/renderer/router.tsx b/enjoy/src/renderer/router.tsx index 6aa8dbca..de9e900a 100644 --- a/enjoy/src/renderer/router.tsx +++ b/enjoy/src/renderer/router.tsx @@ -14,6 +14,7 @@ import Story from "./pages/story"; import Books from "./pages/books"; import Profile from "./pages/profile"; import Home from "./pages/home"; +import Community from "./pages/community"; import StoryPreview from "./pages/story-preview"; export default createHashRouter([ @@ -23,6 +24,10 @@ export default createHashRouter([ errorElement: , children: [ { index: true, element: }, + { + path: "/community", + element: , + }, { path: "/profile", element: , diff --git a/enjoy/src/types/post.d.ts b/enjoy/src/types/post.d.ts new file mode 100644 index 00000000..82589ef3 --- /dev/null +++ b/enjoy/src/types/post.d.ts @@ -0,0 +1,7 @@ +type PostType = { + id: string; + content?: string; + user: UserType; + createdAt: string; + updatedAt: string; +} diff --git a/enjoy/src/types/user.d.ts b/enjoy/src/types/user.d.ts index 46bb198b..a1fe86f0 100644 --- a/enjoy/src/types/user.d.ts +++ b/enjoy/src/types/user.d.ts @@ -3,4 +3,6 @@ type UserType = { name: string; avatarUrl?: string; accessToken?: string; + recordingsCount?: number; + recordingsDuration?: number; }; From 66cf3dd82851e9c206513047c7ea40329a5fb712 Mon Sep 17 00:00:00 2001 From: an-lee Date: Thu, 11 Jan 2024 17:36:23 +0800 Subject: [PATCH 03/26] refactor webApi --- enjoy/src/api/client.ts | 15 ++++---- enjoy/src/renderer/components/login-form.tsx | 10 +++--- .../src/renderer/components/lookup-result.tsx | 4 +-- enjoy/src/renderer/components/posts/posts.tsx | 10 ++---- .../components/stories/stories-segment.tsx | 4 +-- .../components/users/users-rankings.tsx | 10 ++---- .../context/app-settings-provider.tsx | 35 ++++++++++++++----- enjoy/src/renderer/pages/stories.tsx | 4 +-- enjoy/src/renderer/pages/story-preview.tsx | 4 +-- enjoy/src/renderer/pages/story.tsx | 14 ++++---- enjoy/src/renderer/pages/vocabulary.tsx | 4 +-- 11 files changed, 62 insertions(+), 52 deletions(-) diff --git a/enjoy/src/api/client.ts b/enjoy/src/api/client.ts index bf130586..1d76fd8c 100644 --- a/enjoy/src/api/client.ts +++ b/enjoy/src/api/client.ts @@ -6,9 +6,11 @@ const ONE_MINUTE = 1000 * 60; // 1 minute export class Client { public api: AxiosInstance; + public baseUrl: string; constructor(options: { baseUrl: string; accessToken?: string }) { const { baseUrl, accessToken } = options; + this.baseUrl = baseUrl; this.api = axios.create({ baseURL: baseUrl, @@ -20,7 +22,7 @@ export class Client { this.api.interceptors.request.use((config) => { config.headers.Authorization = `Bearer ${accessToken}`; - console.info( + console.debug( config.method.toUpperCase(), config.baseURL + config.url, config.data, @@ -30,7 +32,7 @@ export class Client { }); this.api.interceptors.response.use( (response) => { - console.info( + console.debug( response.status, response.config.method.toUpperCase(), response.config.baseURL + response.config.url @@ -63,7 +65,7 @@ export class Client { return this.api.post("/api/sessions", decamelizeKeys(params)); } - me() { + me(): Promise { return this.api.get("/api/me"); } @@ -151,7 +153,7 @@ export class Client { sourceId?: string; sourceType?: string; }[] - ): Promise<{ successCount: number; total: number }> { + ): Promise<{ successCount: number; errors: string[], total: number }> { return this.api.post("/api/lookups/batch", { lookups: decamelizeKeys(lookups, { deep: true }), }); @@ -171,6 +173,7 @@ export class Client { ): Promise< { meanings: MeaningType[]; + pendingLookups?: LookupType[]; } & PagyResponseType > { return this.api.get(`/api/stories/${storyId}/meanings`, { @@ -220,11 +223,11 @@ export class Client { }); } - starStory(storyId: string) { + starStory(storyId: string): Promise<{ starred: boolean }> { return this.api.post(`/api/mine/stories`, decamelizeKeys({ storyId })); } - unstarStory(storyId: string) { + unstarStory(storyId: string): Promise<{ starred: boolean }> { return this.api.delete(`/api/mine/stories/${storyId}`); } } diff --git a/enjoy/src/renderer/components/login-form.tsx b/enjoy/src/renderer/components/login-form.tsx index c71ec358..9fdb38b8 100644 --- a/enjoy/src/renderer/components/login-form.tsx +++ b/enjoy/src/renderer/components/login-form.tsx @@ -5,10 +5,10 @@ import { t } from "i18next"; export const LoginForm = () => { const { toast } = useToast(); - const { EnjoyApp, login, apiUrl } = useContext(AppSettingsProviderContext); + const { EnjoyApp, login, webApi } = useContext(AppSettingsProviderContext); const handleMixinLogin = () => { - const url = `${apiUrl}/sessions/new?provider=mixin`; + const url = `${webApi.baseUrl}/sessions/new?provider=mixin`; EnjoyApp.view.load(url, { x: 0, y: 0 }); }; @@ -34,7 +34,7 @@ export const LoginForm = () => { const provider = new URL(url).pathname.split("/")[2]; const code = new URL(url).searchParams.get("code"); - if (!url.startsWith(apiUrl)) { + if (!url.startsWith(webApi.baseUrl)) { toast({ title: t("error"), description: t("invalidRedirectUrl"), @@ -44,7 +44,7 @@ export const LoginForm = () => { } if (provider && code) { - EnjoyApp.webApi + webApi .auth({ provider, code }) .then((user) => { login(user); @@ -70,7 +70,7 @@ export const LoginForm = () => { EnjoyApp.view.removeViewStateListeners(); EnjoyApp.view.remove(); }; - }, [apiUrl]); + }, [webApi]); return (
diff --git a/enjoy/src/renderer/components/lookup-result.tsx b/enjoy/src/renderer/components/lookup-result.tsx index 333757e9..bc2ab41b 100644 --- a/enjoy/src/renderer/components/lookup-result.tsx +++ b/enjoy/src/renderer/components/lookup-result.tsx @@ -18,7 +18,7 @@ export const LookupResult = (props: { const [loading, setLoading] = useState(true); if (!word) return null; - const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { webApi } = useContext(AppSettingsProviderContext); const lookup = (retries = 0) => { if (!word) return; @@ -28,7 +28,7 @@ export const LookupResult = (props: { } retries += 1; - EnjoyApp.webApi + webApi .lookup({ word, context, diff --git a/enjoy/src/renderer/components/posts/posts.tsx b/enjoy/src/renderer/components/posts/posts.tsx index 30abad1b..87139641 100644 --- a/enjoy/src/renderer/components/posts/posts.tsx +++ b/enjoy/src/renderer/components/posts/posts.tsx @@ -1,19 +1,13 @@ import { useContext, useEffect, useState } from "react"; -import { Client } from "@/api"; import { AppSettingsProviderContext } from "@renderer/context"; import { t } from "i18next"; export const Posts = () => { - const { apiUrl, user } = useContext(AppSettingsProviderContext); + const { webApi } = useContext(AppSettingsProviderContext); const [posts, setPosts] = useState([]); - const client = new Client({ - baseUrl: apiUrl, - accessToken: user.accessToken, - }); - const fetchPosts = async () => { - client.posts().then( + webApi.posts().then( (res) => { setPosts(res.posts); }, diff --git a/enjoy/src/renderer/components/stories/stories-segment.tsx b/enjoy/src/renderer/components/stories/stories-segment.tsx index 365fc0b5..b5629cfa 100644 --- a/enjoy/src/renderer/components/stories/stories-segment.tsx +++ b/enjoy/src/renderer/components/stories/stories-segment.tsx @@ -7,10 +7,10 @@ import { AppSettingsProviderContext } from "@renderer/context"; export const StoriesSegment = () => { const [stories, setStorys] = useState([]); - const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { webApi } = useContext(AppSettingsProviderContext); const fetchStorys = async () => { - EnjoyApp.webApi.mineStories().then((response) => { + webApi.mineStories().then((response) => { if (response?.stories) { setStorys(response.stories); } diff --git a/enjoy/src/renderer/components/users/users-rankings.tsx b/enjoy/src/renderer/components/users/users-rankings.tsx index 04cadb66..422cac6c 100644 --- a/enjoy/src/renderer/components/users/users-rankings.tsx +++ b/enjoy/src/renderer/components/users/users-rankings.tsx @@ -8,7 +8,6 @@ import { CardHeader, CardContent, } from "@renderer/components/ui"; -import { Client } from "@/api"; import { AppSettingsProviderContext } from "@renderer/context"; import { t } from "i18next"; import { formatDuration } from "@renderer/lib/utils"; @@ -28,16 +27,11 @@ const RankingsCard = (props: { range: "day" | "week" | "month" | "year" | "all"; }) => { const { range } = props; - const { apiUrl, user } = useContext(AppSettingsProviderContext); + const { webApi } = useContext(AppSettingsProviderContext); const [rankings, setRankings] = useState([]); - const client = new Client({ - baseUrl: apiUrl, - accessToken: user.accessToken, - }); - const fetchRankings = async () => { - client.rankings(range).then( + webApi.rankings(range).then( (res) => { setRankings(res.rankings); }, diff --git a/enjoy/src/renderer/context/app-settings-provider.tsx b/enjoy/src/renderer/context/app-settings-provider.tsx index 3cf18a58..c57521fb 100644 --- a/enjoy/src/renderer/context/app-settings-provider.tsx +++ b/enjoy/src/renderer/context/app-settings-provider.tsx @@ -1,8 +1,9 @@ import { createContext, useEffect, useState } from "react"; import { WEB_API_URL } from "@/constants"; +import { Client } from "@/api"; type AppSettingsProviderState = { - apiUrl: string; + webApi: Client; user: UserType | null; initialized: boolean; version?: string; @@ -19,7 +20,7 @@ type AppSettingsProviderState = { }; const initialState: AppSettingsProviderState = { - apiUrl: WEB_API_URL, + webApi: null, user: null, initialized: false, }; @@ -35,6 +36,7 @@ export const AppSettingsProvider = ({ const [initialized, setInitialized] = useState(false); const [version, setVersion] = useState(""); const [apiUrl, setApiUrl] = useState(WEB_API_URL); + const [webApi, setWebApi] = useState(null); const [user, setUser] = useState(null); const [libraryPath, setLibraryPath] = useState(""); const [whisperModelsPath, setWhisperModelsPath] = useState(""); @@ -48,7 +50,6 @@ export const AppSettingsProvider = ({ fetchLibraryPath(); fetchModel(); fetchFfmpegConfig(); - fetchApiUrl(); }, []); useEffect(() => { @@ -59,6 +60,17 @@ export const AppSettingsProvider = ({ validate(); }, [user, libraryPath, whisperModel, ffmpegConfig]); + useEffect(() => { + if (!apiUrl) return; + + setWebApi( + new Client({ + baseUrl: apiUrl, + accessToken: user?.accessToken, + }) + ); + }, [user, apiUrl]); + const fetchFfmpegConfig = async () => { const config = await EnjoyApp.settings.getFfmpegConfig(); setFfmegConfig(config); @@ -70,10 +82,18 @@ export const AppSettingsProvider = ({ }; const fetchUser = async () => { + const apiUrl = await EnjoyApp.app.apiUrl(); + setApiUrl(apiUrl); + const currentUser = await EnjoyApp.settings.getUser(); if (!currentUser) return; - EnjoyApp.webApi.me().then((user) => { + const client = new Client({ + baseUrl: apiUrl, + accessToken: currentUser.accessToken, + }); + + client.me().then((user) => { if (user?.id) { login(currentUser); } else { @@ -113,9 +133,8 @@ export const AppSettingsProvider = ({ }; const fetchApiUrl = async () => { - const apiUrl = await EnjoyApp.app.apiUrl(); - setApiUrl(apiUrl); - } + return apiUrl; + }; const setModelHandler = async (name: string) => { await EnjoyApp.settings.setWhisperModel(name); @@ -133,7 +152,7 @@ export const AppSettingsProvider = ({ value={{ EnjoyApp, version, - apiUrl, + webApi, user, login, logout, diff --git a/enjoy/src/renderer/pages/stories.tsx b/enjoy/src/renderer/pages/stories.tsx index e513aba9..3777d131 100644 --- a/enjoy/src/renderer/pages/stories.tsx +++ b/enjoy/src/renderer/pages/stories.tsx @@ -5,10 +5,10 @@ import { AppSettingsProviderContext } from "@renderer/context"; export default () => { const [stories, setStorys] = useState([]); const [loading, setLoading] = useState(true); - const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { webApi } = useContext(AppSettingsProviderContext); const fetchStorys = async () => { - EnjoyApp.webApi + webApi .mineStories() .then((response) => { if (response?.stories) { diff --git a/enjoy/src/renderer/pages/story-preview.tsx b/enjoy/src/renderer/pages/story-preview.tsx index b1ddeb28..ccb871dd 100644 --- a/enjoy/src/renderer/pages/story-preview.tsx +++ b/enjoy/src/renderer/pages/story-preview.tsx @@ -26,7 +26,7 @@ export default () => { }); const [loading, setLoading] = useState(true); const [readable, setReadable] = useState(true); - const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext); const { toast } = useToast(); const [meanings, setMeanings] = useState([]); const [marked, setMarked] = useState(false); @@ -52,7 +52,7 @@ export default () => { const createStory = async () => { if (!story) return; - EnjoyApp.webApi + webApi .createStory({ url: story.metadata?.url || story.url, ...story, diff --git a/enjoy/src/renderer/pages/story.tsx b/enjoy/src/renderer/pages/story.tsx index dc74b6ce..2e794c59 100644 --- a/enjoy/src/renderer/pages/story.tsx +++ b/enjoy/src/renderer/pages/story.tsx @@ -16,7 +16,7 @@ nlp.plugin(paragraphs); let timeout: NodeJS.Timeout = null; export default () => { const { id } = useParams<{ id: string }>(); - const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { webApi } = useContext(AppSettingsProviderContext); const [loading, setLoading] = useState(true); const [story, setStory] = useState(); const [meanings, setMeanings] = useState([]); @@ -26,7 +26,7 @@ export default () => { const [doc, setDoc] = useState(null); const fetchStory = async () => { - EnjoyApp.webApi + webApi .story(id) .then((story) => { setStory(story); @@ -41,7 +41,7 @@ export default () => { const fetchMeanings = async () => { setScanning(true); - EnjoyApp.webApi + webApi .storyMeanings(id, { items: 500 }) .then((response) => { if (!response) return; @@ -88,14 +88,14 @@ export default () => { }); }); - EnjoyApp.webApi.lookupInBatch(vocabulary).then((response) => { + webApi.lookupInBatch(vocabulary).then((response) => { const { errors } = response; if (errors.length > 0) { console.warn(errors); return; } - EnjoyApp.webApi.extractVocabularyFromStory(id).then(() => { + webApi.extractVocabularyFromStory(id).then(() => { fetchStory(); if (pendingLookups.length > 0) return; @@ -108,11 +108,11 @@ export default () => { if (!story) return; if (story.starred) { - EnjoyApp.webApi.unstarStory(id).then((result) => { + webApi.unstarStory(id).then((result) => { setStory({ ...story, starred: result.starred }); }); } else { - EnjoyApp.webApi.starStory(id).then((result) => { + webApi.starStory(id).then((result) => { setStory({ ...story, starred: result.starred }); }); } diff --git a/enjoy/src/renderer/pages/vocabulary.tsx b/enjoy/src/renderer/pages/vocabulary.tsx index 8e05f480..73d5102a 100644 --- a/enjoy/src/renderer/pages/vocabulary.tsx +++ b/enjoy/src/renderer/pages/vocabulary.tsx @@ -11,14 +11,14 @@ export default () => { const [loading, setLoading] = useState(false); const [meanings, setMeanings] = useState([]); - const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { webApi } = useContext(AppSettingsProviderContext); const [currentIndex, setCurrentIndex] = useState(0); const [nextPage, setNextPage] = useState(1); const fetchMeanings = async (page: number = nextPage) => { if (!page) return; - EnjoyApp.webApi + webApi .mineMeanings({ page, items: 10 }) .then((response) => { setMeanings([...meanings, ...response.meanings]); From 267eee37b9860ad7e01932f377ae512d41e3bb35 Mon Sep 17 00:00:00 2001 From: an-lee Date: Thu, 11 Jan 2024 17:37:21 +0800 Subject: [PATCH 04/26] remove web-api in preload --- enjoy/src/preload.ts | 44 ----------------------- enjoy/src/types/enjoy-app.d.ts | 64 ---------------------------------- 2 files changed, 108 deletions(-) diff --git a/enjoy/src/preload.ts b/enjoy/src/preload.ts index fcabd446..425131d9 100644 --- a/enjoy/src/preload.ts +++ b/enjoy/src/preload.ts @@ -356,50 +356,6 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", { ipcRenderer.removeAllListeners("download-on-error"); }, }, - webApi: { - auth: (params: object) => { - return ipcRenderer.invoke("web-api-auth", params); - }, - me: () => { - return ipcRenderer.invoke("web-api-me"); - }, - lookup: (params: object) => { - return ipcRenderer.invoke("web-api-lookup", params); - }, - lookupInBatch: (params: object[]) => { - return ipcRenderer.invoke("web-api-lookup-in-batch", params); - }, - createStory: (params: object) => { - return ipcRenderer.invoke("web-api-create-story", params); - }, - starStory: (storyId: string) => { - return ipcRenderer.invoke("web-api-star-story", storyId); - }, - unstarStory: (storyId: string) => { - return ipcRenderer.invoke("web-api-unstar-story", storyId); - }, - extractVocabularyFromStory: (storyId: string) => { - return ipcRenderer.invoke( - "web-api-extract-vocabulary-from-story", - storyId - ); - }, - storyMeanings: (storyId: string, params: object) => { - return ipcRenderer.invoke("web-api-story-meanings", storyId, params); - }, - story: (id: string) => { - return ipcRenderer.invoke("web-api-story", id); - }, - stories: (params: object) => { - return ipcRenderer.invoke("web-api-stories", params); - }, - mineStories: (params: object) => { - return ipcRenderer.invoke("web-api-mine-stories", params); - }, - mineMeanings: (params: object) => { - return ipcRenderer.invoke("web-api-mine-meanings", params); - }, - }, cacheObjects: { get: (key: string) => { return ipcRenderer.invoke("cache-objects-get", key); diff --git a/enjoy/src/types/enjoy-app.d.ts b/enjoy/src/types/enjoy-app.d.ts index 5144b055..faf40f20 100644 --- a/enjoy/src/types/enjoy-app.d.ts +++ b/enjoy/src/types/enjoy-app.d.ts @@ -185,70 +185,6 @@ type EnjoyAppType = { dashboard: () => Promise; removeAllListeners: () => void; }; - webApi: { - auth: (params: { provider: string; code: string }) => Promise; - me: () => Promise; - lookup: (params: { - word: string; - context?: string; - sourceId?: string; - sourceType?: string; - }) => Promise; - lookupInBatch: ( - params: { - word: string; - context?: string; - sourceId?: string; - sourceType?: string; - }[] - ) => Promise<{ successCount: number; errors: string[]; total: number }>; - mineMeanings: (params?: { - page?: number; - items?: number; - sourceId?: string; - sourceType?: string; - }) => Promise< - { - meanings: MeaningType[]; - } & PagyResponseType - >; - createStory: (params: { - title: string; - content: string; - url: string; - metadata: { - [key: string]: any; - }; - }) => Promise; - extractVocabularyFromStory: (id: string) => Promise; - story: (id: string) => Promise; - stories: (params?: { page: number }) => Promise<{ - stories: StoryType[]; - page: number; - next: number | null; - }>; - mineStories: (params?: { page: number }) => Promise<{ - stories: StoryType[]; - page: number; - next: number | null; - }>; - storyMeanings: ( - storyId: string, - params?: { - page?: number; - items?: number; - sourceId?: string; - sourceType?: string; - } - ) => Promise< - { - meanings: MeaningType[]; - pendingLookups: LookupType[]; - } & PagyResponseType - >; - starStory: (id: string) => Promise<{ starred: boolean }>; - unstarStory: (id: string) => Promise<{ starred: boolean }>; - }; cacheObjects: { get: (key: string) => Promise; set: (key: string, value: any, ttl?: number) => Promise; From eda547aca154e3efcbdfad379604b44ec2e9b24a Mon Sep 17 00:00:00 2001 From: an-lee Date: Thu, 11 Jan 2024 17:44:43 +0800 Subject: [PATCH 05/26] remove main/web-api --- enjoy/src/main/db/models/audio.ts | 9 +- .../db/models/pronunciation-assessment.ts | 10 +- enjoy/src/main/db/models/recording.ts | 10 +- enjoy/src/main/db/models/transcription.ts | 11 +- enjoy/src/main/db/models/video.ts | 9 +- enjoy/src/main/web-api.ts | 480 ------------------ enjoy/src/main/window.ts | 3 - 7 files changed, 42 insertions(+), 490 deletions(-) delete mode 100644 enjoy/src/main/web-api.ts diff --git a/enjoy/src/main/db/models/audio.ts b/enjoy/src/main/db/models/audio.ts index 07973f0d..e240ce1e 100644 --- a/enjoy/src/main/db/models/audio.ts +++ b/enjoy/src/main/db/models/audio.ts @@ -25,13 +25,20 @@ import mainWindow from "@main/window"; import log from "electron-log/main"; import storage from "@main/storage"; import Ffmpeg from "@main/ffmpeg"; -import webApi from "@main/web-api"; +import { Client } from "@/api"; +import { WEB_API_URL } from "@/constants"; import { startCase } from "lodash"; import { v5 as uuidv5 } from "uuid"; const SIZE_LIMIT = 1024 * 1024 * 50; // 50MB const logger = log.scope("db/models/audio"); + +const webApi = new Client({ + baseUrl: process.env.WEB_API_URL || WEB_API_URL, + accessToken: settings.getSync("user.accessToken") as string, +}); + @Table({ modelName: "Audio", tableName: "audios", diff --git a/enjoy/src/main/db/models/pronunciation-assessment.ts b/enjoy/src/main/db/models/pronunciation-assessment.ts index 26d6132a..7794476e 100644 --- a/enjoy/src/main/db/models/pronunciation-assessment.ts +++ b/enjoy/src/main/db/models/pronunciation-assessment.ts @@ -14,7 +14,15 @@ import { } from "sequelize-typescript"; import mainWindow from "@main/window"; import { Recording } from "@main/db/models"; -import webApi from "@main/web-api"; +import { Client } from "@/api"; +import { WEB_API_URL } from "@/constants"; +import settings from "@main/settings"; + +const webApi = new Client({ + baseUrl: process.env.WEB_API_URL || WEB_API_URL, + accessToken: settings.getSync("user.accessToken") as string, +}); + @Table({ modelName: "PronunciationAssessment", diff --git a/enjoy/src/main/db/models/recording.ts b/enjoy/src/main/db/models/recording.ts index af7e313b..1b4dfe3c 100644 --- a/enjoy/src/main/db/models/recording.ts +++ b/enjoy/src/main/db/models/recording.ts @@ -23,12 +23,18 @@ import { hashFile } from "@/utils"; import log from "electron-log/main"; import storage from "@main/storage"; import Ffmpeg from "@main/ffmpeg"; -import webApi from "@main/web-api"; +import { Client } from "@/api"; +import { WEB_API_URL } from "@/constants"; import { AzureSpeechSdk } from "@main/azure-speech-sdk"; import camelcaseKeys from "camelcase-keys"; const logger = log.scope("db/models/recording"); +const webApi = new Client({ + baseUrl: process.env.WEB_API_URL || WEB_API_URL, + accessToken: settings.getSync("user.accessToken") as string, +}); + @Table({ modelName: "Recording", tableName: "recordings", @@ -36,7 +42,7 @@ const logger = log.scope("db/models/recording"); timestamps: true, }) export class Recording extends Model { - @IsUUID('all') + @IsUUID("all") @Default(DataType.UUIDV4) @Column({ primaryKey: true, type: DataType.UUID }) id: string; diff --git a/enjoy/src/main/db/models/transcription.ts b/enjoy/src/main/db/models/transcription.ts index 7bd82582..2a51f225 100644 --- a/enjoy/src/main/db/models/transcription.ts +++ b/enjoy/src/main/db/models/transcription.ts @@ -15,9 +15,16 @@ import { Audio, Video } from "@main/db/models"; import whisper from "@main/whisper"; import mainWindow from "@main/window"; import log from "electron-log/main"; -import webApi from "@main/web-api"; +import { Client } from "@/api"; +import { WEB_API_URL } from "@/constants"; +import settings from "@main/settings"; const logger = log.scope("db/models/transcription"); +const webApi = new Client({ + baseUrl: process.env.WEB_API_URL || WEB_API_URL, + accessToken: settings.getSync("user.accessToken") as string, +}); + @Table({ modelName: "Transcription", tableName: "transcriptions", @@ -25,7 +32,7 @@ const logger = log.scope("db/models/transcription"); timestamps: true, }) export class Transcription extends Model { - @IsUUID('all') + @IsUUID("all") @Default(DataType.UUIDV4) @Column({ primaryKey: true, type: DataType.UUID }) id: string; diff --git a/enjoy/src/main/db/models/video.ts b/enjoy/src/main/db/models/video.ts index c2899a7a..90e0fe81 100644 --- a/enjoy/src/main/db/models/video.ts +++ b/enjoy/src/main/db/models/video.ts @@ -25,13 +25,20 @@ import mainWindow from "@main/window"; import log from "electron-log/main"; import storage from "@main/storage"; import Ffmpeg from "@main/ffmpeg"; -import webApi from "@main/web-api"; +import { Client } from "@/api"; +import { WEB_API_URL } from "@/constants"; import { startCase } from "lodash"; import { v5 as uuidv5 } from "uuid"; const SIZE_LIMIT = 1024 * 1024 * 100; // 100MB const logger = log.scope("db/models/video"); + +const webApi = new Client({ + baseUrl: process.env.WEB_API_URL || WEB_API_URL, + accessToken: settings.getSync("user.accessToken") as string, +}); + @Table({ modelName: "Video", tableName: "videos", diff --git a/enjoy/src/main/web-api.ts b/enjoy/src/main/web-api.ts deleted file mode 100644 index dc274736..00000000 --- a/enjoy/src/main/web-api.ts +++ /dev/null @@ -1,480 +0,0 @@ -import { ipcMain } from "electron"; -import axios, { AxiosInstance } from "axios"; -import { WEB_API_URL } from "@/constants"; -import settings from "@main/settings"; -import log from "electron-log/main"; -import decamelizeKeys from "decamelize-keys"; -import camelcaseKeys from "camelcase-keys"; - -const logger = log.scope("web-api"); -const ONE_MINUTE = 1000 * 60; // 1 minute -class WebApi { - public api: AxiosInstance; - - constructor() { - this.api = axios.create({ - baseURL: process.env.WEB_API_URL || WEB_API_URL, - timeout: ONE_MINUTE, - headers: { - "Content-Type": "application/json", - }, - }); - this.api.interceptors.request.use((config) => { - config.headers.Authorization = `Bearer ${settings.getSync( - "user.accessToken" - )}`; - - logger.info( - config.method.toUpperCase(), - config.baseURL + config.url, - config.data, - config.params - ); - return config; - }); - this.api.interceptors.response.use( - (response) => { - logger.info( - response.status, - response.config.method.toUpperCase(), - response.config.baseURL + response.config.url - ); - return camelcaseKeys(response.data, { deep: true }); - }, - (err) => { - if (err.response) { - logger.error( - err.response.status, - err.response.config.method.toUpperCase(), - err.response.config.baseURL + err.response.config.url - ); - logger.error(err.response.data); - return Promise.reject(err.response.data); - } - - if (err.request) { - logger.error(err.request); - } else { - logger.error(err.message); - } - - return Promise.reject(err); - } - ); - } - - auth(params: { provider: string; code: string }): Promise { - return this.api.post("/api/sessions", decamelizeKeys(params)); - } - - me() { - return this.api.get("/api/me"); - } - - rankings(range: "day" | "week" | "month" | "year" | "all" = "day"): Promise<{ - rankings: UserType[]; - range: string; - }> { - return this.api.get("/api/users/rankings", { params: { range } }); - } - - posts(params?: { page?: number; items?: number }): Promise< - { - posts: PostType[]; - } & PagyResponseType - > { - return this.api.get("/api/posts", { params: decamelizeKeys(params) }); - } - - post(id: string): Promise { - return this.api.get(`/api/posts/${id}`); - } - - createPost(params: { content: string }): Promise { - return this.api.post("/api/posts", decamelizeKeys(params)); - } - - updatePost(id: string, params: { content: string }): Promise { - return this.api.put(`/api/posts/${id}`, decamelizeKeys(params)); - } - - deletePost(id: string): Promise { - return this.api.delete(`/api/posts/${id}`); - } - - syncAudio(audio: Partial) { - return this.api.post("/api/mine/audios", decamelizeKeys(audio)); - } - - syncVideo(video: Partial) { - return this.api.post("/api/mine/videos", decamelizeKeys(video)); - } - - syncTranscription(transcription: Partial) { - return this.api.post("/api/transcriptions", decamelizeKeys(transcription)); - } - - syncRecording(recording: Partial) { - if (!recording) return; - - return this.api.post("/api/mine/recordings", decamelizeKeys(recording)); - } - - generateSpeechToken(): Promise<{ token: string; region: string }> { - return this.api.post("/api/speech/tokens"); - } - - syncPronunciationAssessment( - pronunciationAssessment: Partial - ) { - if (!pronunciationAssessment) return; - - return this.api.post( - "/api/mine/pronunciation_assessments", - decamelizeKeys(pronunciationAssessment) - ); - } - - recordingAssessment(id: string) { - return this.api.get(`/api/mine/recordings/${id}/assessment`); - } - - lookup(params: { - word: string; - context: string; - sourceId?: string; - sourceType?: string; - }): Promise { - return this.api.post("/api/lookups", decamelizeKeys(params)); - } - - lookupInBatch( - lookups: { - word: string; - context: string; - sourceId?: string; - sourceType?: string; - }[] - ): Promise<{ successCount: number; total: number }> { - return this.api.post("/api/lookups/batch", { - lookups: decamelizeKeys(lookups, { deep: true }), - }); - } - - extractVocabularyFromStory(storyId: string): Promise { - return this.api.post(`/api/stories/${storyId}/extract_vocabulary`); - } - - storyMeanings( - storyId: string, - params?: { - page?: number; - items?: number; - storyId?: string; - } - ): Promise< - { - meanings: MeaningType[]; - } & PagyResponseType - > { - return this.api.get(`/api/stories/${storyId}/meanings`, { - params: decamelizeKeys(params), - }); - } - - mineMeanings(params?: { - page?: number; - items?: number; - sourceId?: string; - sourceType?: string; - status?: string; - }): Promise< - { - meanings: MeaningType[]; - } & PagyResponseType - > { - return this.api.get("/api/mine/meanings", { - params: decamelizeKeys(params), - }); - } - - createStory(params: CreateStoryParamsType): Promise { - return this.api.post("/api/stories", decamelizeKeys(params)); - } - - story(id: string): Promise { - return this.api.get(`/api/stories/${id}`); - } - - stories(params?: { page: number }): Promise< - { - stories: StoryType[]; - } & PagyResponseType - > { - return this.api.get("/api/stories", { params: decamelizeKeys(params) }); - } - - mineStories(params?: { page: number }): Promise< - { - stories: StoryType[]; - } & PagyResponseType - > { - return this.api.get("/api/mine/stories", { - params: decamelizeKeys(params), - }); - } - - starStory(storyId: string) { - return this.api.post(`/api/mine/stories`, decamelizeKeys({ storyId })); - } - - unstarStory(storyId: string) { - return this.api.delete(`/api/mine/stories/${storyId}`); - } - - registerIpcHandlers() { - ipcMain.handle("web-api-auth", async (event, params) => { - return this.auth(params) - .then((user) => { - return user; - }) - .catch((error) => { - event.sender.send("on-notification", { - type: "error", - message: error.message, - }); - }); - }); - - ipcMain.handle("web-api-me", async (event) => { - return this.me() - .then((user) => { - return user; - }) - .catch((error) => { - event.sender.send("on-notification", { - type: "error", - message: error.message, - }); - }); - }); - - ipcMain.handle("web-api-rankings", async (event, range) => { - return this.rankings(range) - .then((response) => { - return response; - }) - .catch((error) => { - logger.error(error); - event.sender.send("on-notification", { - type: "error", - message: error.message, - }); - }); - }); - - ipcMain.handle("web-api-posts", async (event, params) => { - return this.posts(params) - .then((response) => { - return response; - }) - .catch((error) => { - logger.error(error); - event.sender.send("on-notification", { - type: "error", - message: error.message, - }); - }); - }); - - ipcMain.handle("web-api-post", async (event, id) => { - return this.post(id) - .then((response) => { - return response; - }) - .catch((error) => { - logger.error(error); - event.sender.send("on-notification", { - type: "error", - message: error.message, - }); - }); - }); - - ipcMain.handle("web-api-create-post", async (event, params) => { - return this.createPost(params) - .then((response) => { - return response; - }) - .catch((error) => { - logger.error(error); - event.sender.send("on-notification", { - type: "error", - message: error.message, - }); - }); - }); - - ipcMain.handle("web-api-delete-post", async (event, id) => { - return this.deletePost(id) - .then((response) => { - return response; - }) - .catch((error) => { - logger.error(error); - event.sender.send("on-notification", { - type: "error", - message: error.message, - }); - }); - }); - - ipcMain.handle("web-api-lookup", async (event, params) => { - return this.lookup(params) - .then((response) => { - return response; - }) - .catch((error) => { - event.sender.send("on-notification", { - type: "error", - message: error.message, - }); - }); - }); - - ipcMain.handle("web-api-lookup-in-batch", async (event, params) => { - return this.lookupInBatch(params) - .then((response) => { - return response; - }) - .catch((error) => { - event.sender.send("on-notification", { - type: "error", - message: error.message, - }); - }); - }); - - ipcMain.handle("web-api-mine-meanings", async (event, params) => { - return this.mineMeanings(params) - .then((response) => { - return response; - }) - .catch((error) => { - event.sender.send("on-notification", { - type: "error", - message: error.message, - }); - }); - }); - - ipcMain.handle("web-api-create-story", async (event, params) => { - return this.createStory(params) - .then((response) => { - return response; - }) - .catch((error) => { - event.sender.send("on-notification", { - type: "error", - message: error.message, - }); - }); - }); - - ipcMain.handle( - "web-api-extract-vocabulary-from-story", - async (event, storyId) => { - return this.extractVocabularyFromStory(storyId) - .then((response) => { - return response; - }) - .catch((error) => { - event.sender.send("on-notification", { - type: "error", - message: error.message, - }); - }); - } - ); - - ipcMain.handle("web-api-story-meanings", async (event, storyId, params) => { - return this.storyMeanings(storyId, params) - .then((response) => { - return response; - }) - .catch((error) => { - event.sender.send("on-notification", { - type: "error", - message: error.message, - }); - }); - }); - - ipcMain.handle("web-api-stories", async (event, params) => { - return this.stories(params) - .then((response) => { - return response; - }) - .catch((error) => { - event.sender.send("on-notification", { - type: "error", - message: error.message, - }); - }); - }); - - ipcMain.handle("web-api-story", async (event, id) => { - return this.story(id) - .then((response) => { - return response; - }) - .catch((error) => { - event.sender.send("on-notification", { - type: "error", - message: error.message, - }); - }); - }); - - ipcMain.handle("web-api-mine-stories", async (event, params) => { - return this.mineStories(params) - .then((response) => { - return response; - }) - .catch((error) => { - event.sender.send("on-notification", { - type: "error", - message: error.message, - }); - }); - }); - - ipcMain.handle("web-api-star-story", async (event, id) => { - return this.starStory(id) - .then((response) => { - return response; - }) - .catch((error) => { - event.sender.send("on-notification", { - type: "error", - message: error.message, - }); - }); - }); - - ipcMain.handle("web-api-unstar-story", async (event, id) => { - return this.unstarStory(id) - .then((response) => { - return response; - }) - .catch((error) => { - event.sender.send("on-notification", { - type: "error", - message: error.message, - }); - }); - }); - } -} - -export default new WebApi(); diff --git a/enjoy/src/main/window.ts b/enjoy/src/main/window.ts index c802f85d..5d82cf9c 100644 --- a/enjoy/src/main/window.ts +++ b/enjoy/src/main/window.ts @@ -14,7 +14,6 @@ import downloader from "@main/downloader"; import whisper from "@main/whisper"; import fs from "fs-extra"; import "@main/i18n"; -import webApi from "@main/web-api"; import log from "electron-log/main"; import { WEB_API_URL } from "@/constants"; import { AudibleProvider, TedProvider } from "@main/providers"; @@ -38,8 +37,6 @@ main.init = () => { return; } - webApi.registerIpcHandlers(); - // Prepare local database db.registerIpcHandlers(); From 017b5b59e924fa05b79e85d0e22c355577d440d5 Mon Sep 17 00:00:00 2001 From: an-lee Date: Thu, 11 Jan 2024 17:53:46 +0800 Subject: [PATCH 06/26] fix inline transcription in video --- enjoy/src/renderer/components/videos/video-detail.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/enjoy/src/renderer/components/videos/video-detail.tsx b/enjoy/src/renderer/components/videos/video-detail.tsx index 9547dfaa..921203f1 100644 --- a/enjoy/src/renderer/components/videos/video-detail.tsx +++ b/enjoy/src/renderer/components/videos/video-detail.tsx @@ -35,6 +35,8 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => { const [isPlaying, setIsPlaying] = useState(false); const [isLooping, setIsLooping] = useState(false); const [playBackRate, setPlaybackRate] = useState(1); + const [displayInlineCaption, setDisplayInlineCaption] = + useState(true); const onTransactionUpdate = (event: CustomEvent) => { const { model, action, record } = event.detail || {}; @@ -109,6 +111,8 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => { setIsLooping={setIsLooping} playBackRate={playBackRate} setPlaybackRate={setPlaybackRate} + displayInlineCaption={displayInlineCaption} + setDisplayInlineCaption={setDisplayInlineCaption} /> Date: Thu, 11 Jan 2024 20:18:48 +0800 Subject: [PATCH 07/26] expire processing transcription --- enjoy/src/main/db/models/transcription.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/enjoy/src/main/db/models/transcription.ts b/enjoy/src/main/db/models/transcription.ts index 2a51f225..09c3a2e4 100644 --- a/enjoy/src/main/db/models/transcription.ts +++ b/enjoy/src/main/db/models/transcription.ts @@ -2,6 +2,7 @@ import { AfterCreate, AfterUpdate, AfterDestroy, + AfterFind, BelongsTo, Table, Column, @@ -16,7 +17,7 @@ import whisper from "@main/whisper"; import mainWindow from "@main/window"; import log from "electron-log/main"; import { Client } from "@/api"; -import { WEB_API_URL } from "@/constants"; +import { WEB_API_URL, PROCESS_TIMEOUT } from "@/constants"; import settings from "@main/settings"; const logger = log.scope("db/models/transcription"); @@ -153,6 +154,23 @@ export class Transcription extends Model { this.notify(transcription, "destroy"); } + @AfterFind + static expireProcessingState(transcription: Transcription) { + if (transcription.state !== "processing") return; + + if (transcription.updatedAt.getTime() + PROCESS_TIMEOUT < Date.now()) { + if (transcription.result) { + transcription.update({ + state: "finished", + }); + } else { + transcription.update({ + state: "pending", + }); + } + } + } + static notify( transcription: Transcription, action: "create" | "update" | "destroy" From 9635c192d51c59595913c0485847365d992fb703 Mon Sep 17 00:00:00 2001 From: an-lee Date: Thu, 11 Jan 2024 20:30:13 +0800 Subject: [PATCH 08/26] improve api/client --- enjoy/src/api/client.ts | 24 ++++++++++++------- enjoy/src/main/db/models/audio.ts | 1 + .../db/models/pronunciation-assessment.ts | 3 ++- enjoy/src/main/db/models/recording.ts | 1 + enjoy/src/main/db/models/transcription.ts | 1 + enjoy/src/main/db/models/video.ts | 1 + 6 files changed, 21 insertions(+), 10 deletions(-) diff --git a/enjoy/src/api/client.ts b/enjoy/src/api/client.ts index 1d76fd8c..a3203670 100644 --- a/enjoy/src/api/client.ts +++ b/enjoy/src/api/client.ts @@ -7,10 +7,16 @@ const ONE_MINUTE = 1000 * 60; // 1 minute export class Client { public api: AxiosInstance; public baseUrl: string; + public logger: any; - constructor(options: { baseUrl: string; accessToken?: string }) { - const { baseUrl, accessToken } = options; + constructor(options: { + baseUrl: string; + accessToken?: string; + logger?: any; + }) { + const { baseUrl, accessToken, logger } = options; this.baseUrl = baseUrl; + this.logger = logger || console; this.api = axios.create({ baseURL: baseUrl, @@ -22,7 +28,7 @@ export class Client { this.api.interceptors.request.use((config) => { config.headers.Authorization = `Bearer ${accessToken}`; - console.debug( + this.logger.debug( config.method.toUpperCase(), config.baseURL + config.url, config.data, @@ -32,7 +38,7 @@ export class Client { }); this.api.interceptors.response.use( (response) => { - console.debug( + this.logger.debug( response.status, response.config.method.toUpperCase(), response.config.baseURL + response.config.url @@ -41,19 +47,19 @@ export class Client { }, (err) => { if (err.response) { - console.error( + this.logger.error( err.response.status, err.response.config.method.toUpperCase(), err.response.config.baseURL + err.response.config.url ); - console.error(err.response.data); + this.logger.error(err.response.data); return Promise.reject(err.response.data); } if (err.request) { - console.error(err.request); + this.logger.error(err.request); } else { - console.error(err.message); + this.logger.error(err.message); } return Promise.reject(err); @@ -153,7 +159,7 @@ export class Client { sourceId?: string; sourceType?: string; }[] - ): Promise<{ successCount: number; errors: string[], total: number }> { + ): Promise<{ successCount: number; errors: string[]; total: number }> { return this.api.post("/api/lookups/batch", { lookups: decamelizeKeys(lookups, { deep: true }), }); diff --git a/enjoy/src/main/db/models/audio.ts b/enjoy/src/main/db/models/audio.ts index e240ce1e..d678f4bd 100644 --- a/enjoy/src/main/db/models/audio.ts +++ b/enjoy/src/main/db/models/audio.ts @@ -37,6 +37,7 @@ const logger = log.scope("db/models/audio"); const webApi = new Client({ baseUrl: process.env.WEB_API_URL || WEB_API_URL, accessToken: settings.getSync("user.accessToken") as string, + logger: log.scope("api/client"), }); @Table({ diff --git a/enjoy/src/main/db/models/pronunciation-assessment.ts b/enjoy/src/main/db/models/pronunciation-assessment.ts index 7794476e..11740bd8 100644 --- a/enjoy/src/main/db/models/pronunciation-assessment.ts +++ b/enjoy/src/main/db/models/pronunciation-assessment.ts @@ -17,13 +17,14 @@ import { Recording } from "@main/db/models"; import { Client } from "@/api"; import { WEB_API_URL } from "@/constants"; import settings from "@main/settings"; +import log from "electron-log/main"; const webApi = new Client({ baseUrl: process.env.WEB_API_URL || WEB_API_URL, accessToken: settings.getSync("user.accessToken") as string, + logger: log.scope("api/client"), }); - @Table({ modelName: "PronunciationAssessment", tableName: "pronunciation_assessments", diff --git a/enjoy/src/main/db/models/recording.ts b/enjoy/src/main/db/models/recording.ts index 1b4dfe3c..03cc5307 100644 --- a/enjoy/src/main/db/models/recording.ts +++ b/enjoy/src/main/db/models/recording.ts @@ -33,6 +33,7 @@ const logger = log.scope("db/models/recording"); const webApi = new Client({ baseUrl: process.env.WEB_API_URL || WEB_API_URL, accessToken: settings.getSync("user.accessToken") as string, + logger: log.scope("api/client"), }); @Table({ diff --git a/enjoy/src/main/db/models/transcription.ts b/enjoy/src/main/db/models/transcription.ts index 09c3a2e4..385b8fa4 100644 --- a/enjoy/src/main/db/models/transcription.ts +++ b/enjoy/src/main/db/models/transcription.ts @@ -24,6 +24,7 @@ const logger = log.scope("db/models/transcription"); const webApi = new Client({ baseUrl: process.env.WEB_API_URL || WEB_API_URL, accessToken: settings.getSync("user.accessToken") as string, + logger: log.scope("api/client"), }); @Table({ diff --git a/enjoy/src/main/db/models/video.ts b/enjoy/src/main/db/models/video.ts index 90e0fe81..0753e6ca 100644 --- a/enjoy/src/main/db/models/video.ts +++ b/enjoy/src/main/db/models/video.ts @@ -37,6 +37,7 @@ const logger = log.scope("db/models/video"); const webApi = new Client({ baseUrl: process.env.WEB_API_URL || WEB_API_URL, accessToken: settings.getSync("user.accessToken") as string, + logger: log.scope("api/client"), }); @Table({ From e510ed9337ceef4dd1648c06ef65a5449331c4fc Mon Sep 17 00:00:00 2001 From: an-lee Date: Thu, 11 Jan 2024 21:00:40 +0800 Subject: [PATCH 09/26] vaccum after cache clear --- enjoy/src/main/db/handlers/cache-objects-handler.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/enjoy/src/main/db/handlers/cache-objects-handler.ts b/enjoy/src/main/db/handlers/cache-objects-handler.ts index 30219351..06561c40 100644 --- a/enjoy/src/main/db/handlers/cache-objects-handler.ts +++ b/enjoy/src/main/db/handlers/cache-objects-handler.ts @@ -1,5 +1,6 @@ import { ipcMain, IpcMainEvent } from "electron"; import { CacheObject } from "@main/db/models"; +import db from "@main/db"; class CacheObjectsHandler { private async get(event: IpcMainEvent, key: string) { @@ -49,6 +50,7 @@ class CacheObjectsHandler { private async clear(event: IpcMainEvent) { return CacheObject.destroy({ where: {} }) .then(() => { + db.connection.query("VACUUM"); return; }) .catch((err) => { From f9b1c14b4ccb4581015802aa811106b05f790e70 Mon Sep 17 00:00:00 2001 From: an-lee Date: Fri, 12 Jan 2024 00:54:53 +0800 Subject: [PATCH 10/26] share audio/video & display post --- enjoy/src/api/client.ts | 6 +- enjoy/src/i18n/en.json | 8 +- enjoy/src/i18n/zh-CN.json | 8 +- enjoy/src/main/db/handlers/audios-handler.ts | 16 ++- enjoy/src/main/db/handlers/videos-handler.ts | 16 ++- enjoy/src/main/youtubedr.ts | 2 +- enjoy/src/preload.ts | 8 +- .../components/audios/audio-detail.tsx | 68 +++++++++- .../medias/media-player-controls.tsx | 42 ++++-- .../components/medias/media-player.tsx | 3 + enjoy/src/renderer/components/posts/index.ts | 3 +- .../components/posts/post-audio-player.tsx | 121 ++++++++++++++++++ enjoy/src/renderer/components/posts/posts.tsx | 74 ++++++++++- .../components/users/users-rankings.tsx | 4 +- .../components/videos/video-detail.tsx | 68 +++++++++- enjoy/src/renderer/pages/community.tsx | 44 ++++--- enjoy/src/types/audio.d.ts | 1 + enjoy/src/types/enjoy-app.d.ts | 4 +- enjoy/src/types/medium.d.ts | 9 ++ enjoy/src/types/post.d.ts | 2 + enjoy/src/types/video.d.ts | 1 + 21 files changed, 441 insertions(+), 67 deletions(-) create mode 100644 enjoy/src/renderer/components/posts/post-audio-player.tsx create mode 100644 enjoy/src/types/medium.d.ts diff --git a/enjoy/src/api/client.ts b/enjoy/src/api/client.ts index a3203670..fc0196b4 100644 --- a/enjoy/src/api/client.ts +++ b/enjoy/src/api/client.ts @@ -94,7 +94,11 @@ export class Client { return this.api.get(`/api/posts/${id}`); } - createPost(params: { content: string }): Promise { + createPost(params: { + content?: string; + targetType?: string; + targetId?: string; + }): Promise { return this.api.post("/api/posts", decamelizeKeys(params)); } diff --git a/enjoy/src/i18n/en.json b/enjoy/src/i18n/en.json index feaadd24..35cd9d0b 100644 --- a/enjoy/src/i18n/en.json +++ b/enjoy/src/i18n/en.json @@ -329,5 +329,11 @@ "allRankings": "All time rankings", "noOneHasRecordedYet": "No one has recorded yet", "activities": "Activities", - "noOneSharedYet": "No one shared yet" + "noOneSharedYet": "No one shared yet", + "sharedSuccessfully": "Shared successfully", + "shareFailed": "Share failed", + "shareAudio": "Share audio", + "areYouSureToShareThisAudioToCommunity": "Are you sure to share this audio to community?", + "shareVideo": "Share video", + "areYouSureToShareThisVideoToCommunity": "Are you sure to share this video to community?" } diff --git a/enjoy/src/i18n/zh-CN.json b/enjoy/src/i18n/zh-CN.json index 4b330cba..cf809eb7 100644 --- a/enjoy/src/i18n/zh-CN.json +++ b/enjoy/src/i18n/zh-CN.json @@ -329,5 +329,11 @@ "allRankings": "总排行榜", "noOneHasRecordedYet": "还没有人练习", "activities": "动态", - "noOneSharedYet": "还没有人分享" + "noOneSharedYet": "还没有人分享", + "sharedSuccessfully": "分享成功", + "sharedFailed": "分享失败", + "shareAudio": "分享音频", + "areYouSureToShareThisAudioToCommunity": "您确定要分享此音频到社区吗?", + "shareVideo": "分享视频", + "areYouSureToShareThisVideoToCommunity": "您确定要分享此视频到社区吗?" } diff --git a/enjoy/src/main/db/handlers/audios-handler.ts b/enjoy/src/main/db/handlers/audios-handler.ts index adf08c10..3b3ecd0a 100644 --- a/enjoy/src/main/db/handlers/audios-handler.ts +++ b/enjoy/src/main/db/handlers/audios-handler.ts @@ -90,27 +90,29 @@ class AudiosHandler { private async create( event: IpcMainEvent, - source: string, + uri: string, params: { name?: string; coverUrl?: string; } = {} ) { - let file = source; - if (source.startsWith("http")) { + let file = uri; + let source; + if (uri.startsWith("http")) { try { - if (youtubedr.validateYtURL(source)) { - file = await youtubedr.autoDownload(source); + if (youtubedr.validateYtURL(uri)) { + file = await youtubedr.autoDownload(uri); } else { - file = await downloader.download(source, { + file = await downloader.download(uri, { webContents: event.sender, }); } if (!file) throw new Error("Failed to download file"); + source = uri; } catch (err) { return event.sender.send("on-notification", { type: "error", - message: t("models.audio.failedToDownloadFile", { file: source }), + message: t("models.audio.failedToDownloadFile", { file: uri }), }); } } diff --git a/enjoy/src/main/db/handlers/videos-handler.ts b/enjoy/src/main/db/handlers/videos-handler.ts index e1e69479..48fac884 100644 --- a/enjoy/src/main/db/handlers/videos-handler.ts +++ b/enjoy/src/main/db/handlers/videos-handler.ts @@ -90,27 +90,29 @@ class VideosHandler { private async create( event: IpcMainEvent, - source: string, + uri: string, params: { name?: string; coverUrl?: string; } = {} ) { - let file = source; - if (source.startsWith("http")) { + let file = uri; + let source; + if (uri.startsWith("http")) { try { - if (youtubedr.validateYtURL(source)) { - file = await youtubedr.autoDownload(source); + if (youtubedr.validateYtURL(uri)) { + file = await youtubedr.autoDownload(uri); } else { - file = await downloader.download(source, { + file = await downloader.download(uri, { webContents: event.sender, }); } if (!file) throw new Error("Failed to download file"); + source = uri; } catch (err) { return event.sender.send("on-notification", { type: "error", - message: t("models.video.failedToDownloadFile", { file: source }), + message: t("models.video.failedToDownloadFile", { file: uri }), }); } } diff --git a/enjoy/src/main/youtubedr.ts b/enjoy/src/main/youtubedr.ts index 97909595..9d239c1b 100644 --- a/enjoy/src/main/youtubedr.ts +++ b/enjoy/src/main/youtubedr.ts @@ -204,7 +204,7 @@ class Youtubedr { this.getYtVideoId(url); return true; } catch (error) { - console.error(error); + logger.warn(error); return false; } }; diff --git a/enjoy/src/preload.ts b/enjoy/src/preload.ts index 425131d9..82af7a4c 100644 --- a/enjoy/src/preload.ts +++ b/enjoy/src/preload.ts @@ -175,8 +175,8 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", { findOne: (params: object) => { return ipcRenderer.invoke("audios-find-one", params); }, - create: (source: string, params?: object) => { - return ipcRenderer.invoke("audios-create", source, params); + create: (uri: string, params?: object) => { + return ipcRenderer.invoke("audios-create", uri, params); }, update: (id: string, params: object) => { return ipcRenderer.invoke("audios-update", id, params); @@ -201,8 +201,8 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", { findOne: (params: object) => { return ipcRenderer.invoke("videos-find-one", params); }, - create: (source: string, params?: object) => { - return ipcRenderer.invoke("videos-create", source, params); + create: (uri: string, params?: object) => { + return ipcRenderer.invoke("videos-create", uri, params); }, update: (id: string, params: object) => { return ipcRenderer.invoke("videos-update", id, params); diff --git a/enjoy/src/renderer/components/audios/audio-detail.tsx b/enjoy/src/renderer/components/audios/audio-detail.tsx index e1a4f116..c5a9276a 100644 --- a/enjoy/src/renderer/components/audios/audio-detail.tsx +++ b/enjoy/src/renderer/components/audios/audio-detail.tsx @@ -11,16 +11,30 @@ import { MediaTranscription, } from "@renderer/components"; import { LoaderIcon } from "lucide-react"; -import { ScrollArea } from "@renderer/components/ui"; +import { + AlertDialog, + AlertDialogHeader, + AlertDialogDescription, + AlertDialogTitle, + AlertDialogContent, + AlertDialogFooter, + AlertDialogCancel, + Button, + ScrollArea, + useToast, +} from "@renderer/components/ui"; +import { t } from "i18next"; export const AudioDetail = (props: { id?: string; md5?: string }) => { const { id, md5 } = props; + const { toast } = useToast(); const { addDblistener, removeDbListener } = useContext(DbProviderContext); - const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext); const [audio, setAudio] = useState(null); const [transcription, setTranscription] = useState(null); const [initialized, setInitialized] = useState(false); + const [sharing, setSharing] = useState(false); // Player controls const [currentTime, setCurrentTime] = useState(0); @@ -43,6 +57,38 @@ export const AudioDetail = (props: { id?: string; md5?: string }) => { } }; + const handleShare = async () => { + if (!audio.source && !audio.isUploaded) { + try { + await EnjoyApp.audios.upload(audio.id); + } catch (err) { + toast({ + title: t("shareFailed"), + description: err.message, + }); + return; + } + } + webApi + .createPost({ + targetType: "Audio", + targetId: audio.id, + }) + .then(() => { + toast({ + title: t("shared"), + description: t("sharedSuccessfully"), + }); + }) + .catch((err) => { + toast({ + title: t("shareFailed"), + description: err.message, + }); + }); + setSharing(false); + }; + useEffect(() => { const where = id ? { id } : { md5 }; EnjoyApp.audios.findOne(where).then((audio) => { @@ -110,6 +156,7 @@ export const AudioDetail = (props: { id?: string; md5?: string }) => { setPlaybackRate={setPlaybackRate} displayInlineCaption={displayInlineCaption} setDisplayInlineCaption={setDisplayInlineCaption} + onShare={() => setSharing(true)} /> @@ -146,6 +193,23 @@ export const AudioDetail = (props: { id?: string; md5?: string }) => {
+ setSharing(value)}> + + + {t("shareAudio")} + + {t("areYouSureToShareThisAudioToCommunity")} + + + + {t("cancel")} + + + + + {!initialized && (
diff --git a/enjoy/src/renderer/components/medias/media-player-controls.tsx b/enjoy/src/renderer/components/medias/media-player-controls.tsx index d8b579b0..00ff909f 100644 --- a/enjoy/src/renderer/components/medias/media-player-controls.tsx +++ b/enjoy/src/renderer/components/medias/media-player-controls.tsx @@ -16,6 +16,7 @@ import { MinimizeIcon, GalleryHorizontalIcon, SpellCheckIcon, + Share2Icon, } from "lucide-react"; import { t } from "i18next"; import { type WaveSurferOptions } from "wavesurfer.js"; @@ -24,7 +25,6 @@ import { Tooltip } from "react-tooltip"; const PLAYBACK_RATE_OPTIONS = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75]; const MIN_ZOOM_RATIO = 0.25; const MAX_ZOOM_RATIO = 5.0; -const ZOOM_RATIO_STEP = 0.25; export const MediaPlayerControls = (props: { isPlaying: boolean; @@ -47,6 +47,7 @@ export const MediaPlayerControls = (props: { setWavesurferOptions?: (options: Partial) => void; displayInlineCaption?: boolean; setDisplayInlineCaption?: (display: boolean) => void; + onShare?: () => void; }) => { const { isPlaying, @@ -67,6 +68,7 @@ export const MediaPlayerControls = (props: { setWavesurferOptions, displayInlineCaption, setDisplayInlineCaption, + onShare, } = props; return ( @@ -244,20 +246,32 @@ export const MediaPlayerControls = (props: { )} - {transcriptionDirty && ( -
-
- - -
+ + +
+
+ {transcriptionDirty && ( + <> + + + + )}
- )} +
diff --git a/enjoy/src/renderer/components/medias/media-player.tsx b/enjoy/src/renderer/components/medias/media-player.tsx index 5c38c681..788917c2 100644 --- a/enjoy/src/renderer/components/medias/media-player.tsx +++ b/enjoy/src/renderer/components/medias/media-player.tsx @@ -60,6 +60,7 @@ export const MediaPlayer = (props: { setPlaybackRate: (value: number) => void; displayInlineCaption?: boolean; setDisplayInlineCaption?: (value: boolean) => void; + onShare?: () => void; }) => { const { EnjoyApp } = useContext(AppSettingsProviderContext); const { @@ -88,6 +89,7 @@ export const MediaPlayer = (props: { setPlaybackRate, displayInlineCaption, setDisplayInlineCaption, + onShare, } = props; if (!mediaUrl) return; @@ -536,6 +538,7 @@ export const MediaPlayer = (props: { setWavesurferOptions={(options) => wavesurfer?.setOptions(options)} displayInlineCaption={displayInlineCaption} setDisplayInlineCaption={setDisplayInlineCaption} + onShare={onShare} />
diff --git a/enjoy/src/renderer/components/posts/index.ts b/enjoy/src/renderer/components/posts/index.ts index 595c3ebb..653291f2 100644 --- a/enjoy/src/renderer/components/posts/index.ts +++ b/enjoy/src/renderer/components/posts/index.ts @@ -1 +1,2 @@ -export * from './posts'; +export * from "./posts"; +export * from "./post-audio-player"; diff --git a/enjoy/src/renderer/components/posts/post-audio-player.tsx b/enjoy/src/renderer/components/posts/post-audio-player.tsx new file mode 100644 index 00000000..1e05ed89 --- /dev/null +++ b/enjoy/src/renderer/components/posts/post-audio-player.tsx @@ -0,0 +1,121 @@ +import { useEffect, useState, useRef, useCallback } from "react"; +import { PitchContour } from "@renderer/components"; +import WaveSurfer from "wavesurfer.js"; +import { Button, Skeleton } from "@renderer/components/ui"; +import { PlayIcon, PauseIcon } from "lucide-react"; +import { useIntersectionObserver } from "@uidotdev/usehooks"; +import { secondsToTimestamp } from "@renderer/lib/utils"; + +export const PostAudioPlayer = (props: { src: string; height?: number }) => { + const { src, height = 80 } = props; + const [initialized, setInitialized] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + const [wavesurfer, setWavesurfer] = useState(null); + const containerRef = useRef(); + const [ref, entry] = useIntersectionObserver({ + threshold: 1, + }); + const [duration, setDuration] = useState(0); + + const onPlayClick = useCallback(() => { + wavesurfer.isPlaying() ? wavesurfer.pause() : wavesurfer.play(); + }, [wavesurfer]); + + useEffect(() => { + // use the intersection observer to only create the wavesurfer instance + // when the player is visible + if (!entry?.isIntersecting) return; + if (!src) return; + if (wavesurfer) return; + + const ws = WaveSurfer.create({ + container: containerRef.current, + url: src, + height, + barWidth: 1, + cursorWidth: 0, + autoCenter: true, + autoScroll: true, + dragToSeek: true, + hideScrollbar: true, + minPxPerSec: 100, + waveColor: "#ddd", + progressColor: "rgba(0, 0, 0, 0.25)", + normalize: true, + }); + + setWavesurfer(ws); + }, [src, entry]); + + useEffect(() => { + if (!wavesurfer) return; + + const subscriptions = [ + wavesurfer.on("play", () => { + setIsPlaying(true); + }), + wavesurfer.on("pause", () => { + setIsPlaying(false); + }), + wavesurfer.on("decode", () => { + setDuration(wavesurfer.getDuration()); + const peaks = wavesurfer.getDecodedData().getChannelData(0); + const sampleRate = wavesurfer.options.sampleRate; + wavesurfer.renderer.getWrapper().appendChild( + PitchContour({ + peaks, + sampleRate, + height, + }) + ); + setInitialized(true); + }), + ]; + + return () => { + subscriptions.forEach((unsub) => unsub()); + wavesurfer?.destroy(); + }; + }, [wavesurfer]); + + return ( +
+
+ + {secondsToTimestamp(duration)} + +
+ +
+ {!initialized && ( +
+ + + +
+ )} + +
+ +
+ +
+
+
+ ); +}; diff --git a/enjoy/src/renderer/components/posts/posts.tsx b/enjoy/src/renderer/components/posts/posts.tsx index 87139641..86b62740 100644 --- a/enjoy/src/renderer/components/posts/posts.tsx +++ b/enjoy/src/renderer/components/posts/posts.tsx @@ -1,6 +1,13 @@ import { useContext, useEffect, useState } from "react"; import { AppSettingsProviderContext } from "@renderer/context"; +import { PostAudioPlayer } from "@renderer/components"; +import { Avatar, AvatarImage, AvatarFallback } from "@renderer/components/ui"; import { t } from "i18next"; +import { MediaPlayer, MediaProvider } from "@vidstack/react"; +import { + DefaultVideoLayout, + defaultLayoutIcons, +} from "@vidstack/react/player/layouts/default"; export const Posts = () => { const { webApi } = useContext(AppSettingsProviderContext); @@ -9,6 +16,7 @@ export const Posts = () => { const fetchPosts = async () => { webApi.posts().then( (res) => { + console.log(res); setPosts(res.posts); }, (err) => { @@ -22,10 +30,74 @@ export const Posts = () => { }, []); return ( -
+
{posts.length === 0 && (
{t("noOneSharedYet")}
)} + +
+ {posts.map((post) => ( + + ))} +
); }; + +const PostCard = (props: { post: PostType }) => { + const { post } = props; + + return ( +
+
+
+ + + + {post.user.name[0].toUpperCase()} + + +
{post.user.name}
+
+
+ {post.content &&
{post.content}
} + {post.targetType == "Medium" && } +
+ ); +}; + +const PostMedium = (props: { medium: MediumType }) => { + const { medium } = props; + if (!medium.sourceUrl) return null; + + return ( + <> +
+ {medium.mediumType == "Video" && ( + + + + + )} + + {medium.mediumType == "Audio" && ( + + )} +
+ + {medium.coverUrl && medium.mediumType == "Audio" && ( +
+ +
+ )} + + ); +}; diff --git a/enjoy/src/renderer/components/users/users-rankings.tsx b/enjoy/src/renderer/components/users/users-rankings.tsx index 422cac6c..733310a3 100644 --- a/enjoy/src/renderer/components/users/users-rankings.tsx +++ b/enjoy/src/renderer/components/users/users-rankings.tsx @@ -58,11 +58,11 @@ const RankingsCard = (props: { )} {rankings.map((user, index) => ( -
+
#{index + 1}
- + {user.name[0].toUpperCase()} diff --git a/enjoy/src/renderer/components/videos/video-detail.tsx b/enjoy/src/renderer/components/videos/video-detail.tsx index 921203f1..c9b28ada 100644 --- a/enjoy/src/renderer/components/videos/video-detail.tsx +++ b/enjoy/src/renderer/components/videos/video-detail.tsx @@ -11,16 +11,30 @@ import { MediaTranscription, } from "@renderer/components"; import { LoaderIcon } from "lucide-react"; -import { ScrollArea } from "@renderer/components/ui"; +import { + AlertDialog, + AlertDialogHeader, + AlertDialogDescription, + AlertDialogTitle, + AlertDialogContent, + AlertDialogFooter, + AlertDialogCancel, + Button, + ScrollArea, + useToast, +} from "@renderer/components/ui"; +import { t } from "i18next"; export const VideoDetail = (props: { id?: string; md5?: string }) => { const { id, md5 } = props; + const { toast } = useToast(); const { addDblistener, removeDbListener } = useContext(DbProviderContext); - const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext); const [video, setVideo] = useState(null); const [transcription, setTranscription] = useState(null); const [initialized, setInitialized] = useState(false); + const [sharing, setSharing] = useState(false); // Player controls const [currentTime, setCurrentTime] = useState(0); @@ -45,6 +59,38 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => { } }; + const handleShare = async () => { + if (!video.source && !video.isUploaded) { + try { + await EnjoyApp.videos.upload(video.id); + } catch (err) { + toast({ + title: t("shareFailed"), + description: err.message, + }); + return; + } + } + + webApi + .createPost({ + targetType: "Video", + targetId: video.id, + }) + .then(() => { + toast({ + description: t("sharedSuccessfully"), + }); + }) + .catch((err) => { + toast({ + title: t("shareFailed"), + description: err.message, + }); + }); + setSharing(false); + }; + useEffect(() => { const where = id ? { id } : { md5 }; EnjoyApp.videos.findOne(where).then((video) => { @@ -113,6 +159,7 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => { setPlaybackRate={setPlaybackRate} displayInlineCaption={displayInlineCaption} setDisplayInlineCaption={setDisplayInlineCaption} + onShare={() => setSharing(true)} /> {
+ setSharing(value)}> + + + {t("shareAudio")} + + {t("areYouSureToShareThisAudioToCommunity")} + + + + {t("cancel")} + + + + + {!initialized && (
diff --git a/enjoy/src/renderer/pages/community.tsx b/enjoy/src/renderer/pages/community.tsx index 97055906..2be315d7 100644 --- a/enjoy/src/renderer/pages/community.tsx +++ b/enjoy/src/renderer/pages/community.tsx @@ -14,28 +14,30 @@ export default () => { const navigate = useNavigate(); return ( -
-
- - {t("sidebar.community")} +
+
+
+ + {t("sidebar.community")} +
+ + + + {t("activities")} + {t("rankings")} + + + + + + + + + +
- - - - {t("activities")} - {t("rankings")} - - - - - - - - - -
); }; diff --git a/enjoy/src/types/audio.d.ts b/enjoy/src/types/audio.d.ts index 426d72b7..99808529 100644 --- a/enjoy/src/types/audio.d.ts +++ b/enjoy/src/types/audio.d.ts @@ -11,6 +11,7 @@ type AudioType = { transcribing?: boolean; recordingsCount?: number; recordingsDuration?: number; + isUploaded?: boolean; uploadedAt?: Date; createdAt: Date; updatedAt: Date; diff --git a/enjoy/src/types/enjoy-app.d.ts b/enjoy/src/types/enjoy-app.d.ts index faf40f20..14588882 100644 --- a/enjoy/src/types/enjoy-app.d.ts +++ b/enjoy/src/types/enjoy-app.d.ts @@ -93,7 +93,7 @@ type EnjoyAppType = { audios: { findAll: (params: object) => Promise; findOne: (params: object) => Promise; - create: (source: string, params?: object) => Promise; + create: (uri: string, params?: object) => Promise; update: (id: string, params: object) => Promise; destroy: (id: string) => Promise; transcribe: (id: string) => Promise; @@ -102,7 +102,7 @@ type EnjoyAppType = { videos: { findAll: (params: object) => Promise; findOne: (params: object) => Promise; - create: (source: string, params?: object) => Promise; + create: (uri: string, params?: object) => Promise; update: (id: string, params: object) => Promise; destroy: (id: string) => Promise; transcribe: (id: string) => Promise; diff --git a/enjoy/src/types/medium.d.ts b/enjoy/src/types/medium.d.ts new file mode 100644 index 00000000..8c742639 --- /dev/null +++ b/enjoy/src/types/medium.d.ts @@ -0,0 +1,9 @@ +type MediumType = { + id: string; + mediumType: string; + coverUrl?: string; + sourceUrl?: string; + extname?: string; + createdAt: string; + updatedAt: string; +} diff --git a/enjoy/src/types/post.d.ts b/enjoy/src/types/post.d.ts index 82589ef3..84a90544 100644 --- a/enjoy/src/types/post.d.ts +++ b/enjoy/src/types/post.d.ts @@ -2,6 +2,8 @@ type PostType = { id: string; content?: string; user: UserType; + targetType: string; + target?: MediumType; createdAt: string; updatedAt: string; } diff --git a/enjoy/src/types/video.d.ts b/enjoy/src/types/video.d.ts index 8b44b727..7804a7bd 100644 --- a/enjoy/src/types/video.d.ts +++ b/enjoy/src/types/video.d.ts @@ -12,6 +12,7 @@ type VideoType = { transcribing: boolean; recordingsCount?: number; recordingsDuration?: number; + isUploaded?: boolean; uploadedAt?: Date; createdAt: Date; updatedAt: Date; From e05f2c57eb6346c8338d190081dac8647263f613 Mon Sep 17 00:00:00 2001 From: an-lee Date: Fri, 12 Jan 2024 01:40:17 +0800 Subject: [PATCH 11/26] may share prompt --- enjoy/src/i18n/en.json | 4 +- enjoy/src/i18n/zh-CN.json | 4 +- enjoy/src/main/db/models/transcription.ts | 2 +- .../components/messages/assistant-message.tsx | 4 +- .../renderer/components/messages/message.tsx | 4 +- .../components/messages/user-message.tsx | 15 ++++- enjoy/src/renderer/components/posts/posts.tsx | 6 +- enjoy/src/renderer/pages/conversation.tsx | 60 +++++++++++++++++-- 8 files changed, 83 insertions(+), 16 deletions(-) diff --git a/enjoy/src/i18n/en.json b/enjoy/src/i18n/en.json index 35cd9d0b..8eae3080 100644 --- a/enjoy/src/i18n/en.json +++ b/enjoy/src/i18n/en.json @@ -335,5 +335,7 @@ "shareAudio": "Share audio", "areYouSureToShareThisAudioToCommunity": "Are you sure to share this audio to community?", "shareVideo": "Share video", - "areYouSureToShareThisVideoToCommunity": "Are you sure to share this video to community?" + "areYouSureToShareThisVideoToCommunity": "Are you sure to share this video to community?", + "sharePrompt": "Share prompt", + "areYouSureToShareThisPromptToCommunity": "Are you sure to share this prompt to community?" } diff --git a/enjoy/src/i18n/zh-CN.json b/enjoy/src/i18n/zh-CN.json index cf809eb7..7083abc6 100644 --- a/enjoy/src/i18n/zh-CN.json +++ b/enjoy/src/i18n/zh-CN.json @@ -335,5 +335,7 @@ "shareAudio": "分享音频", "areYouSureToShareThisAudioToCommunity": "您确定要分享此音频到社区吗?", "shareVideo": "分享视频", - "areYouSureToShareThisVideoToCommunity": "您确定要分享此视频到社区吗?" + "areYouSureToShareThisVideoToCommunity": "您确定要分享此视频到社区吗?", + "sharePrompt": "分享提示语", + "areYouSureToShareThisPromptToCommunity": "您确定要分享此提示语到社区吗?" } diff --git a/enjoy/src/main/db/models/transcription.ts b/enjoy/src/main/db/models/transcription.ts index 385b8fa4..29d1ce1d 100644 --- a/enjoy/src/main/db/models/transcription.ts +++ b/enjoy/src/main/db/models/transcription.ts @@ -157,7 +157,7 @@ export class Transcription extends Model { @AfterFind static expireProcessingState(transcription: Transcription) { - if (transcription.state !== "processing") return; + if (transcription?.state !== "processing") return; if (transcription.updatedAt.getTime() + PROCESS_TIMEOUT < Date.now()) { if (transcription.result) { diff --git a/enjoy/src/renderer/components/messages/assistant-message.tsx b/enjoy/src/renderer/components/messages/assistant-message.tsx index 59cca9c6..da61bbf9 100644 --- a/enjoy/src/renderer/components/messages/assistant-message.tsx +++ b/enjoy/src/renderer/components/messages/assistant-message.tsx @@ -94,14 +94,14 @@ export const AssistantMessageComponent = (props: { AI -
+
{configuration?.autoSpeech && speeching ? (
) : ( void; onRemove?: () => void; + onShare?: () => void; }) => { - const { message, configuration, onResend, onRemove } = props; + const { message, configuration, onResend, onRemove, onShare } = props; if (message.role === "assistant") { return ( ); } diff --git a/enjoy/src/renderer/components/messages/user-message.tsx b/enjoy/src/renderer/components/messages/user-message.tsx index c275687d..25ff3444 100644 --- a/enjoy/src/renderer/components/messages/user-message.tsx +++ b/enjoy/src/renderer/components/messages/user-message.tsx @@ -17,6 +17,7 @@ import { AlertCircleIcon, CopyIcon, CheckIcon, + Share2Icon, } from "lucide-react"; import { useCopyToClipboard } from "@uidotdev/usehooks"; import { t } from "i18next"; @@ -27,8 +28,9 @@ export const UserMessageComponent = (props: { configuration?: { [key: string]: any }; onResend?: () => void; onRemove?: () => void; + onShare?: () => void; }) => { - const { message, onResend, onRemove } = props; + const { message, onResend, onRemove, onShare } = props; const speech = message.speeches?.[0]; const { user } = useContext(AppSettingsProviderContext); const [_, copyToClipboard] = useCopyToClipboard(); @@ -41,7 +43,7 @@ export const UserMessageComponent = (props: { >
- {message.content} + {message.content} {Boolean(speech) && } @@ -81,6 +83,15 @@ export const UserMessageComponent = (props: { }} /> )} + + {message.createdAt && ( + + )}
diff --git a/enjoy/src/renderer/components/posts/posts.tsx b/enjoy/src/renderer/components/posts/posts.tsx index 86b62740..3d36c4ad 100644 --- a/enjoy/src/renderer/components/posts/posts.tsx +++ b/enjoy/src/renderer/components/posts/posts.tsx @@ -8,6 +8,7 @@ import { DefaultVideoLayout, defaultLayoutIcons, } from "@vidstack/react/player/layouts/default"; +import Markdown from "react-markdown"; export const Posts = () => { const { webApi } = useContext(AppSettingsProviderContext); @@ -16,7 +17,6 @@ export const Posts = () => { const fetchPosts = async () => { webApi.posts().then( (res) => { - console.log(res); setPosts(res.posts); }, (err) => { @@ -60,7 +60,9 @@ const PostCard = (props: { post: PostType }) => {
{post.user.name}
- {post.content &&
{post.content}
} + {post.content && ( + {post.content} + )} {post.targetType == "Medium" && }
); diff --git a/enjoy/src/renderer/pages/conversation.tsx b/enjoy/src/renderer/pages/conversation.tsx index 403ead35..61019c7d 100644 --- a/enjoy/src/renderer/pages/conversation.tsx +++ b/enjoy/src/renderer/pages/conversation.tsx @@ -1,5 +1,12 @@ import { useState, useEffect, useReducer, useContext, useRef } from "react"; import { + AlertDialog, + AlertDialogHeader, + AlertDialogDescription, + AlertDialogTitle, + AlertDialogContent, + AlertDialogFooter, + AlertDialogCancel, Button, ScrollArea, Textarea, @@ -8,11 +15,7 @@ import { SheetTrigger, useToast, } from "@renderer/components/ui"; -import { - MessageComponent, - ConversationForm, - SpeechForm, -} from "@renderer/components"; +import { MessageComponent, ConversationForm } from "@renderer/components"; import { SendIcon, BotIcon, LoaderIcon, SettingsIcon } from "lucide-react"; import { Link, useParams } from "react-router-dom"; import { t } from "i18next"; @@ -29,9 +32,11 @@ export default () => { const [editting, setEditting] = useState(false); const [conversation, setConversation] = useState(); const { addDblistener, removeDbListener } = useContext(DbProviderContext); - const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext); const [content, setConent] = useState(""); const [submitting, setSubmitting] = useState(false); + const [sharing, setSharing] = useState(); + const { toast } = useToast(); const [messages, dispatchMessages] = useReducer(messagesReducer, []); @@ -169,6 +174,28 @@ export default () => { }, 500); }; + const handleShare = async (message: MessageType) => { + if (message.role === "user") { + const content = message.content; + webApi + .createPost({ + content, + }) + .then(() => { + toast({ + description: t("sharedSuccessfully"), + }); + }) + .catch((err) => { + toast({ + title: t("shareFailed"), + description: err.message, + }); + }); + setSharing(null); + } + }; + useEffect(() => { fetchConversation(); fetchMessages(); @@ -258,6 +285,7 @@ export default () => { dispatchMessages({ type: "destroy", record: message }); }} + onShare={() => setSharing(message)} /> ))} {offset > -1 && ( @@ -278,6 +306,26 @@ export default () => {
+ setSharing(null)} + > + + + {t("sharePrompt")} + + {t("areYouSureToShareThisPromptToCommunity")} + + + + {t("cancel")} + + + + +