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; };