diff --git a/enjoy/src/api/client.ts b/enjoy/src/api/client.ts new file mode 100644 index 00000000..ebf90218 --- /dev/null +++ b/enjoy/src/api/client.ts @@ -0,0 +1,259 @@ +import axios, { AxiosInstance } from "axios"; +import decamelizeKeys from "decamelize-keys"; +import camelcaseKeys from "camelcase-keys"; + +const ONE_MINUTE = 1000 * 60; // 1 minute + +export class Client { + public api: AxiosInstance; + public baseUrl: string; + public logger: any; + + constructor(options: { + baseUrl: string; + accessToken?: string; + logger?: any; + }) { + const { baseUrl, accessToken, logger } = options; + this.baseUrl = baseUrl; + this.logger = logger || console; + + this.api = axios.create({ + baseURL: baseUrl, + timeout: ONE_MINUTE, + headers: { + "Content-Type": "application/json", + }, + }); + this.api.interceptors.request.use((config) => { + config.headers.Authorization = `Bearer ${accessToken}`; + + this.logger.debug( + config.method.toUpperCase(), + config.baseURL + config.url, + config.data, + config.params + ); + return config; + }); + this.api.interceptors.response.use( + (response) => { + this.logger.debug( + response.status, + response.config.method.toUpperCase(), + response.config.baseURL + response.config.url + ); + return camelcaseKeys(response.data, { deep: true }); + }, + (err) => { + if (err.response) { + this.logger.error( + err.response.status, + err.response.config.method.toUpperCase(), + err.response.config.baseURL + err.response.config.url + ); + this.logger.error(err.response.data); + return Promise.reject(err.response.data); + } + + if (err.request) { + this.logger.error(err.request); + } else { + this.logger.error(err.message); + } + + return Promise.reject(err); + } + ); + } + + auth(params: { provider: string; code: string }): Promise { + return this.api.post("/api/sessions", decamelizeKeys(params)); + } + + me(): Promise { + 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: { + metadata?: PostType["metadata"]; + targetType?: string; + targetId?: 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}`); + } + + transcriptions(params?: { + page?: number; + items?: number; + targetId?: string; + targetType?: string; + targetMd5?: string; + }): Promise< + { + transcriptions: TranscriptionType[]; + } & PagyResponseType + > { + return this.api.get("/api/transcriptions", { + params: decamelizeKeys(params), + }); + } + + syncAudio(audio: Partial) { + 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; errors: string[]; 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[]; + pendingLookups?: LookupType[]; + } & PagyResponseType + > { + return this.api.get(`/api/stories/${storyId}/meanings`, { + params: decamelizeKeys(params), + }); + } + + mineMeanings(params?: { + page?: number; + items?: number; + sourceId?: string; + sourceType?: string; + status?: string; + }): Promise< + { + meanings: MeaningType[]; + } & PagyResponseType + > { + return this.api.get("/api/mine/meanings", { + params: decamelizeKeys(params), + }); + } + + createStory(params: CreateStoryParamsType): Promise { + 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): Promise<{ starred: boolean }> { + return this.api.post(`/api/mine/stories`, decamelizeKeys({ storyId })); + } + + unstarStory(storyId: string): Promise<{ starred: boolean }> { + return this.api.delete(`/api/mine/stories/${storyId}`); + } +} 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..ffc69b7f 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,44 @@ "presenter": "presenter", "downloadAudio": "Download audio", "downloadVideo": "Download video", - "recordTooShort": "Record too short" + "recordTooShort": "Record too short", + "rankings": "Rankings", + "dayRankings": "Day rankings", + "weekRankings": "Week rankings", + "monthRankings": "Month rankings", + "allRankings": "All time rankings", + "noOneHasRecordedYet": "No one has recorded yet", + "activities": "Activities", + "square": "Square", + "noOneSharedYet": "No one shared yet", + "sharedSuccessfully": "Shared successfully", + "shareFailed": "Share failed", + "shareAudio": "Share audio", + "sharedAudio": "Shared an audio resource", + "areYouSureToShareThisAudioToCommunity": "Are you sure to share this audio to community?", + "shareVideo": "Share video", + "sharedVideo": "Shared a video resource", + "cannotShareLocalVideo": "Cannot share local video", + "areYouSureToShareThisVideoToCommunity": "Are you sure to share this video to community?", + "sharePrompt": "Share prompt", + "sharedPrompt": "Shared a prompt", + "areYouSureToShareThisPromptToCommunity": "Are you sure to share this prompt to community?", + "shareRecording": "Share recording", + "sharedRecording": "Shared a recording", + "areYouSureToShareThisRecordingToCommunity": "Are you sure to share this recording to community?", + "shareStory": "Share story", + "sharedStory": "Shared a story", + "areYouSureToShareThisStoryToCommunity": "Are you sure to share this story to community?", + "addToLibary": "Add to library", + "areYouSureToAddThisVideoToYourLibrary": "Are you sure to add this video to library?", + "areYouSureToAddThisAudioToYourLibrary": "Are you sure to add this audio to library?", + "audioAlreadyAddedToLibrary": "Audio already added to library", + "videoAlreadyAddedToLibrary": "Video already added to library", + "audioSuccessfullyAddedToLibrary": "Audio successfully added to library", + "videoSuccessfullyAddedToLibrary": "Video successfully added to library", + "sendToAIAssistant": "Send to AI assistant", + "removeSharing": "Remove sharing", + "areYouSureToRemoveThisSharing": "Are you sure to remove this sharing?", + "removeSharingSuccessfully": "Remove sharing successfully", + "removeSharingFailed": "Remove sharing failed" } diff --git a/enjoy/src/i18n/zh-CN.json b/enjoy/src/i18n/zh-CN.json index 87480325..9521b805 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,44 @@ "presenter": "讲者", "downloadAudio": "下载音频", "downloadVideo": "下载视频", - "recordTooShort": "录音时长太短" + "recordTooShort": "录音时长太短", + "rankings": "排行榜", + "dayRankings": "日排行榜", + "weekRankings": "周排行榜", + "monthRankings": "月排行榜", + "allRankings": "总排行榜", + "noOneHasRecordedYet": "还没有人练习", + "activities": "动态", + "square": "广场", + "noOneSharedYet": "还没有人分享", + "sharedSuccessfully": "分享成功", + "sharedFailed": "分享失败", + "shareAudio": "分享音频", + "sharedAudio": "分享了一个音频材料", + "areYouSureToShareThisAudioToCommunity": "您确定要分享此音频到社区吗?", + "shareVideo": "分享视频", + "sharedVideo": "分享了一个视频材料", + "cannotShareLocalVideo": "无法分享本地视频", + "areYouSureToShareThisVideoToCommunity": "您确定要分享此视频到社区吗?", + "sharePrompt": "分享提示语", + "sharedPrompt": "分享了一条提示语", + "areYouSureToShareThisPromptToCommunity": "您确定要分享此提示语到社区吗?", + "shareRecording": "分享录音", + "sharedRecording": "分享了一条录音", + "areYouSureToShareThisRecordingToCommunity": "您确定要分享此录音到社区吗?", + "shareStory": "分享文章", + "sharedStory": "分享了一篇文章", + "areYouSureToShareThisStoryToCommunity": "您确定要分享此文章到社区吗?", + "addToLibary": "添加到资源库", + "areYouSureToAddThisVideoToYourLibrary": "您确定要添加此视频到资料库吗?", + "areYouSureToAddThisAudioToYourLibrary": "您确定要添加此音频到资料库吗?", + "audioAlreadyAddedToLibrary": "资料库已经存在此音频", + "videoAlreadyAddedToLibrary": "资料库已经存在此视频", + "audioSuccessfullyAddedToLibrary": "音频成功添加到资料库", + "videoSuccessfullyAddedToLibrary": "视频成功添加到资料库", + "sendToAIAssistant": "发送到智能助手", + "removeSharing": "取消分享", + "areYouSureToRemoveThisSharing": "您确定要取消分享吗?", + "removeSharingSuccessfully": "取消分享成功", + "removeSharingFailed": "取消分享失败" } 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/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) => { diff --git a/enjoy/src/main/db/handlers/videos-handler.ts b/enjoy/src/main/db/handlers/videos-handler.ts index e1e69479..546e7a88 100644 --- a/enjoy/src/main/db/handlers/videos-handler.ts +++ b/enjoy/src/main/db/handlers/videos-handler.ts @@ -90,27 +90,30 @@ class VideosHandler { private async create( event: IpcMainEvent, - source: string, + uri: string, params: { name?: string; coverUrl?: string; + md5?: string; } = {} ) { - let file = source; - if (source.startsWith("http")) { + let file = uri; + let source; + if (uri.startsWith("http")) { try { - if (youtubedr.validateYtURL(source)) { - file = await youtubedr.autoDownload(source); + if (youtubedr.validateYtURL(uri)) { + file = await youtubedr.autoDownload(uri); } else { - file = await downloader.download(source, { + file = await downloader.download(uri, { webContents: event.sender, }); } if (!file) throw new Error("Failed to download file"); + source = uri; } catch (err) { return event.sender.send("on-notification", { type: "error", - message: t("models.video.failedToDownloadFile", { file: source }), + message: t("models.video.failedToDownloadFile", { file: uri }), }); } } diff --git a/enjoy/src/main/db/models/audio.ts b/enjoy/src/main/db/models/audio.ts index 07973f0d..d678f4bd 100644 --- a/enjoy/src/main/db/models/audio.ts +++ b/enjoy/src/main/db/models/audio.ts @@ -25,13 +25,21 @@ import mainWindow from "@main/window"; import log from "electron-log/main"; import storage from "@main/storage"; import Ffmpeg from "@main/ffmpeg"; -import webApi from "@main/web-api"; +import { Client } from "@/api"; +import { WEB_API_URL } from "@/constants"; import { startCase } from "lodash"; import { v5 as uuidv5 } from "uuid"; const SIZE_LIMIT = 1024 * 1024 * 50; // 50MB const logger = log.scope("db/models/audio"); + +const webApi = new Client({ + baseUrl: process.env.WEB_API_URL || WEB_API_URL, + accessToken: settings.getSync("user.accessToken") as string, + logger: log.scope("api/client"), +}); + @Table({ modelName: "Audio", tableName: "audios", diff --git a/enjoy/src/main/db/models/conversation.ts b/enjoy/src/main/db/models/conversation.ts index 3ae977c4..62a468a6 100644 --- a/enjoy/src/main/db/models/conversation.ts +++ b/enjoy/src/main/db/models/conversation.ts @@ -13,7 +13,7 @@ import { AllowNull, } from "sequelize-typescript"; import { Message, Speech } from "@main/db/models"; -import { ChatMessageHistory , BufferMemory } from "langchain/memory"; +import { ChatMessageHistory, BufferMemory } from "langchain/memory"; import { ConversationChain } from "langchain/chains"; import { ChatOpenAI } from "langchain/chat_models/openai"; import { ChatOllama } from "langchain/chat_models/ollama"; @@ -294,9 +294,9 @@ export class Conversation extends Model { } ); - await Promise.all( + const replies = await Promise.all( response.map(async (generation) => { - await Message.create( + return await Message.create( { conversationId: this.id, role: "assistant", @@ -330,5 +330,7 @@ export class Conversation extends Model { } await transaction.commit(); + + return replies.map((reply) => reply.toJSON()); } } diff --git a/enjoy/src/main/db/models/pronunciation-assessment.ts b/enjoy/src/main/db/models/pronunciation-assessment.ts index 26d6132a..11740bd8 100644 --- a/enjoy/src/main/db/models/pronunciation-assessment.ts +++ b/enjoy/src/main/db/models/pronunciation-assessment.ts @@ -14,7 +14,16 @@ import { } from "sequelize-typescript"; import mainWindow from "@main/window"; import { Recording } from "@main/db/models"; -import webApi from "@main/web-api"; +import { Client } from "@/api"; +import { WEB_API_URL } from "@/constants"; +import settings from "@main/settings"; +import log from "electron-log/main"; + +const webApi = new Client({ + baseUrl: process.env.WEB_API_URL || WEB_API_URL, + accessToken: settings.getSync("user.accessToken") as string, + logger: log.scope("api/client"), +}); @Table({ modelName: "PronunciationAssessment", diff --git a/enjoy/src/main/db/models/recording.ts b/enjoy/src/main/db/models/recording.ts index af7e313b..03cc5307 100644 --- a/enjoy/src/main/db/models/recording.ts +++ b/enjoy/src/main/db/models/recording.ts @@ -23,12 +23,19 @@ import { hashFile } from "@/utils"; import log from "electron-log/main"; import storage from "@main/storage"; import Ffmpeg from "@main/ffmpeg"; -import webApi from "@main/web-api"; +import { Client } from "@/api"; +import { WEB_API_URL } from "@/constants"; import { AzureSpeechSdk } from "@main/azure-speech-sdk"; import camelcaseKeys from "camelcase-keys"; const logger = log.scope("db/models/recording"); +const webApi = new Client({ + baseUrl: process.env.WEB_API_URL || WEB_API_URL, + accessToken: settings.getSync("user.accessToken") as string, + logger: log.scope("api/client"), +}); + @Table({ modelName: "Recording", tableName: "recordings", @@ -36,7 +43,7 @@ const logger = log.scope("db/models/recording"); timestamps: true, }) export class Recording extends Model { - @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..29d1ce1d 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, @@ -15,9 +16,17 @@ import { Audio, Video } from "@main/db/models"; import whisper from "@main/whisper"; import mainWindow from "@main/window"; import log from "electron-log/main"; -import webApi from "@main/web-api"; +import { Client } from "@/api"; +import { WEB_API_URL, PROCESS_TIMEOUT } from "@/constants"; +import settings from "@main/settings"; const logger = log.scope("db/models/transcription"); +const webApi = new Client({ + baseUrl: process.env.WEB_API_URL || WEB_API_URL, + accessToken: settings.getSync("user.accessToken") as string, + logger: log.scope("api/client"), +}); + @Table({ modelName: "Transcription", tableName: "transcriptions", @@ -25,7 +34,7 @@ const logger = log.scope("db/models/transcription"); timestamps: true, }) export class Transcription extends Model { - @IsUUID('all') + @IsUUID("all") @Default(DataType.UUIDV4) @Column({ primaryKey: true, type: DataType.UUID }) id: string; @@ -146,6 +155,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" diff --git a/enjoy/src/main/db/models/video.ts b/enjoy/src/main/db/models/video.ts index c2899a7a..0753e6ca 100644 --- a/enjoy/src/main/db/models/video.ts +++ b/enjoy/src/main/db/models/video.ts @@ -25,13 +25,21 @@ import mainWindow from "@main/window"; import log from "electron-log/main"; import storage from "@main/storage"; import Ffmpeg from "@main/ffmpeg"; -import webApi from "@main/web-api"; +import { Client } from "@/api"; +import { WEB_API_URL } from "@/constants"; import { startCase } from "lodash"; import { v5 as uuidv5 } from "uuid"; const SIZE_LIMIT = 1024 * 1024 * 100; // 100MB const logger = log.scope("db/models/video"); + +const webApi = new Client({ + baseUrl: process.env.WEB_API_URL || WEB_API_URL, + accessToken: settings.getSync("user.accessToken") as string, + logger: log.scope("api/client"), +}); + @Table({ modelName: "Video", tableName: "videos", diff --git a/enjoy/src/main/web-api.ts b/enjoy/src/main/web-api.ts deleted file mode 100644 index f8522f99..00000000 --- a/enjoy/src/main/web-api.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { ipcMain } from "electron"; -import axios, { AxiosInstance } from "axios"; -import { WEB_API_URL } from "@/constants"; -import settings from "@main/settings"; -import log from "electron-log/main"; -import decamelizeKeys from "decamelize-keys"; -import camelcaseKeys from "camelcase-keys"; - -const logger = log.scope("web-api"); -const ONE_MINUTE = 1000 * 60; // 1 minute -class WebApi { - public api: AxiosInstance; - - constructor() { - this.api = axios.create({ - baseURL: process.env.WEB_API_URL || WEB_API_URL, - timeout: ONE_MINUTE, - headers: { - "Content-Type": "application/json", - }, - }); - this.api.interceptors.request.use((config) => { - config.headers.Authorization = `Bearer ${settings.getSync( - "user.accessToken" - )}`; - - logger.info( - config.method.toUpperCase(), - config.baseURL + config.url, - config.data, - config.params - ); - return config; - }); - this.api.interceptors.response.use( - (response) => { - logger.info( - response.status, - response.config.method.toUpperCase(), - response.config.baseURL + response.config.url - ); - return camelcaseKeys(response.data, { deep: true }); - }, - (err) => { - if (err.response) { - logger.error( - err.response.status, - err.response.config.method.toUpperCase(), - err.response.config.baseURL + err.response.config.url - ); - logger.error(err.response.data); - return Promise.reject(err.response.data); - } - - if (err.request) { - logger.error(err.request); - } else { - logger.error(err.message); - } - - return Promise.reject(err); - } - ); - } - - me() { - return this.api.get("/api/me"); - } - - auth(params: { provider: string; code: string }): Promise { - return this.api.post("/api/sessions", decamelizeKeys(params)); - } - - 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-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(); 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 fcabd446..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); @@ -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/renderer/components/audios/audio-card.tsx b/enjoy/src/renderer/components/audios/audio-card.tsx index b61012e6..2304fb87 100644 --- a/enjoy/src/renderer/components/audios/audio-card.tsx +++ b/enjoy/src/renderer/components/audios/audio-card.tsx @@ -2,7 +2,7 @@ import { Link } from "react-router-dom"; import { cn } from "@renderer/lib/utils"; export const AudioCard = (props: { - audio: AudioType; + audio: Partial; className?: string; }) => { const { audio, className } = props; diff --git a/enjoy/src/renderer/components/audios/audio-detail.tsx b/enjoy/src/renderer/components/audios/audio-detail.tsx index e1a4f116..f8559845 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("sharedAudio"), + }); + }) + .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/audios/audios-component.tsx b/enjoy/src/renderer/components/audios/audios-component.tsx index 66ca2c24..0f6974bb 100644 --- a/enjoy/src/renderer/components/audios/audios-component.tsx +++ b/enjoy/src/renderer/components/audios/audios-component.tsx @@ -4,9 +4,11 @@ import { AddMediaButton, AudiosTable, AudioEditForm, + LoaderSpin, } from "@renderer/components"; import { t } from "i18next"; import { + Button, Tabs, TabsContent, TabsList, @@ -23,6 +25,7 @@ import { DialogContent, DialogHeader, DialogTitle, + useToast, } from "@renderer/components/ui"; import { DbProviderContext, @@ -43,28 +46,55 @@ export const AudiosComponent = () => { const { addDblistener, removeDbListener } = useContext(DbProviderContext); const { EnjoyApp } = useContext(AppSettingsProviderContext); + const [offset, setOffest] = useState(0); + const [loading, setLoading] = useState(false); + const { toast } = useToast(); + const navigate = useNavigate(); - useEffect(() => { - fetchResources(); - }, []); - useEffect(() => { addDblistener(onAudiosUpdate); - fetchResources(); + fetchAudios(); return () => { removeDbListener(onAudiosUpdate); }; }, []); - const fetchResources = async () => { - const audios = await EnjoyApp.audios.findAll({ - limit: 10, - }); - if (!audios) return; + const fetchAudios = async () => { + if (loading) return; + if (offset === -1) return; - dispatchAudios({ type: "set", records: audios }); + setLoading(true); + const limit = 10; + EnjoyApp.audios + .findAll({ + offset, + limit, + }) + .then((_audios) => { + if (_audios.length === 0) { + setOffest(-1); + return; + } + + if (_audios.length < limit) { + setOffest(-1); + } else { + setOffest(offset + _audios.length); + } + + dispatchAudios({ type: "append", records: _audios }); + }) + .catch((err) => { + toast({ + description: err.message, + variant: "destructive", + }); + }) + .finally(() => { + setLoading(false); + }); }; const onAudiosUpdate = (event: CustomEvent) => { @@ -79,7 +109,7 @@ export const AudiosComponent = () => { dispatchAudios({ type: "destroy", record }); } } else if (model === "Video" && action === "create") { - navigate(`/videos/${record.id}`); + navigate(`/videos/${record.id}`); } else if (model === "Transcription" && action === "update") { dispatchAudios({ type: "update", @@ -93,6 +123,8 @@ export const AudiosComponent = () => { }; if (audios.length === 0) { + if (loading) return ; + return (
@@ -135,6 +167,14 @@ export const AudiosComponent = () => {
+ {offset > -1 && ( +
+ +
+ )} + { diff --git a/enjoy/src/renderer/components/conversations/conversations-shortcut.tsx b/enjoy/src/renderer/components/conversations/conversations-shortcut.tsx new file mode 100644 index 00000000..da7da3d6 --- /dev/null +++ b/enjoy/src/renderer/components/conversations/conversations-shortcut.tsx @@ -0,0 +1,69 @@ +import { useContext, useEffect, useState } from "react"; +import { AppSettingsProviderContext } from "@renderer/context"; +import { ScrollArea } from "@renderer/components/ui"; +import { LoaderSpin } from "@renderer/components"; +import { MessageCircleIcon } from "lucide-react"; + +export const ConversationsShortcut = (props: { + prompt: string; + onReply?: (reply: MessageType[]) => void; +}) => { + const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { prompt, onReply } = props; + const [conversations, setConversations] = useState([]); + const [loading, setLoading] = useState(false); + + const ask = (conversation: ConversationType) => { + setLoading(true); + EnjoyApp.conversations + .ask(conversation.id, { + content: prompt, + }) + .then((replies) => { + console.log(replies); + onReply(replies); + }) + .catch((error) => { + console.error(error); + }) + .finally(() => { + setLoading(false); + }); + }; + + useEffect(() => { + EnjoyApp.conversations.findAll({ limit: 10 }).then((conversations) => { + setConversations(conversations); + setLoading(false); + }); + }, []); + + if (loading) { + return ; + } + + return ( + + {conversations.map((conversation) => { + return ( +
ask(conversation)} + className="bg-white text-primary rounded-full w-full mb-2 py-2 px-4 hover:bg-primary hover:text-white cursor-pointer flex items-center border" + style={{ + borderLeftColor: `#${conversation.id + .replaceAll("-", "") + .substr(0, 6)}`, + borderLeftWidth: 3, + }} + > +
+ +
+
{conversation.name}
+
+ ); + })} +
+ ); +}; diff --git a/enjoy/src/renderer/components/conversations/index.ts b/enjoy/src/renderer/components/conversations/index.ts index 98c1abbc..477f078a 100644 --- a/enjoy/src/renderer/components/conversations/index.ts +++ b/enjoy/src/renderer/components/conversations/index.ts @@ -1,4 +1,5 @@ -export * from './conversation-form'; +export * from "./conversation-form"; +export * from "./conversations-shortcut"; -export * from './speech-form'; +export * from "./speech-form"; export * from "./speech-player"; diff --git a/enjoy/src/renderer/components/conversations/speech-player.tsx b/enjoy/src/renderer/components/conversations/speech-player.tsx index 6fb6beb0..af646183 100644 --- a/enjoy/src/renderer/components/conversations/speech-player.tsx +++ b/enjoy/src/renderer/components/conversations/speech-player.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, useRef, useCallback } from "react"; import { PitchContour } from "@renderer/components"; import WaveSurfer from "wavesurfer.js"; -import { Button } from "@renderer/components/ui"; +import { Button, Skeleton } from "@renderer/components/ui"; import { PlayIcon, PauseIcon } from "lucide-react"; import { useIntersectionObserver } from "@uidotdev/usehooks"; import { secondsToTimestamp } from "@renderer/lib/utils"; @@ -18,6 +18,7 @@ export const SpeechPlayer = (props: { threshold: 1, }); const [duration, setDuration] = useState(0); + const [initialized, setInitialized] = useState(false); const onPlayClick = useCallback(() => { wavesurfer.isPlaying() ? wavesurfer.pause() : wavesurfer.play(); @@ -69,6 +70,7 @@ export const SpeechPlayer = (props: { height, }) ); + setInitialized(true); }), ]; @@ -89,7 +91,15 @@ export const SpeechPlayer = (props: { ref={ref} className="bg-white rounded-lg grid grid-cols-9 items-center relative pl-2 h-[100px]" > -
+ {!initialized && ( +
+ + + +
+ )} + +
-
+
); 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..9fdb38b8 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, webApi } = useContext(AppSettingsProviderContext); const handleMixinLogin = () => { - const url = `${endpoint}/sessions/new?provider=mixin`; + const url = `${webApi.baseUrl}/sessions/new?provider=mixin`; EnjoyApp.view.load(url, { x: 0, y: 0 }); }; @@ -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(webApi.baseUrl)) { toast({ title: t("error"), description: t("invalidRedirectUrl"), @@ -46,7 +44,7 @@ export const LoginForm = () => { } if (provider && code) { - EnjoyApp.webApi + webApi .auth({ provider, code }) .then((user) => { login(user); @@ -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]); + }, [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/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/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 ? (
) : ( { const { message, onResend, onRemove } = props; const speech = message.speeches?.[0]; - const { user } = useContext(AppSettingsProviderContext); + const { user, webApi } = useContext(AppSettingsProviderContext); const [_, copyToClipboard] = useCopyToClipboard(); const [copied, setCopied] = useState(false); + const { toast } = useToast(); + + const handleShare = async () => { + if (message.role === "user") { + const content = message.content; + webApi + .createPost({ + metadata: { + type: "prompt", + content, + }, + }) + .then(() => { + toast({ + description: t("sharedPrompt"), + }); + }) + .catch((err) => { + toast({ + title: t("shareFailed"), + description: err.message, + }); + }); + } + }; return (
- {message.content} + {message.content} {Boolean(speech) && } @@ -81,6 +118,34 @@ export const UserMessageComponent = (props: { }} /> )} + + {message.createdAt && ( + + + + + + + {t("sharePrompt")} + + {t("areYouSureToShareThisPromptToCommunity")} + + + + {t("cancel")} + + + + + + + )}
diff --git a/enjoy/src/renderer/components/posts/index.ts b/enjoy/src/renderer/components/posts/index.ts new file mode 100644 index 00000000..c7893219 --- /dev/null +++ b/enjoy/src/renderer/components/posts/index.ts @@ -0,0 +1,9 @@ +export * from "./posts"; +export * from "./post-audio"; +export * from "./post-card"; +export * from "./post-medium"; +export * from "./post-recording"; +export * from "./post-story"; + +export * from "./post-options"; +export * from "./post-actions"; diff --git a/enjoy/src/renderer/components/posts/post-actions.tsx b/enjoy/src/renderer/components/posts/post-actions.tsx new file mode 100644 index 00000000..44d7d914 --- /dev/null +++ b/enjoy/src/renderer/components/posts/post-actions.tsx @@ -0,0 +1,215 @@ +import { useContext, useEffect, useState } from "react"; +import { AppSettingsProviderContext } from "@renderer/context"; +import { ConversationsShortcut } from "@renderer/components"; +import { + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogDescription, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogAction, + AlertDialogCancel, + AlertDialogFooter, + Button, + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + ScrollArea, + useToast, +} from "@renderer/components/ui"; +import { t } from "i18next"; +import Markdown from "react-markdown"; +import { + BotIcon, + CheckIcon, + CopyPlusIcon, + PlusCircleIcon, + ChevronRightIcon, +} from "lucide-react"; +import { useCopyToClipboard } from "@uidotdev/usehooks"; +import { Link } from "react-router-dom"; + +export const PostActions = (props: { post: PostType }) => { + const { post } = props; + const [_, copyToClipboard] = useCopyToClipboard(); + const [copied, setCopied] = useState(false); + const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { toast } = useToast(); + const [asking, setAsking] = useState(false); + const [aiReplies, setAiReplies] = useState([]); + + const handleAddMedium = async () => { + if (post.targetType !== "Medium") return; + const medium = post.target as MediumType; + if (!medium) return; + + if (medium.mediumType === "Video") { + try { + const video = await EnjoyApp.videos.findOne({ md5: medium.md5 }); + if (video) { + toast({ + description: t("videoAlreadyAddedToLibrary"), + }); + return; + } + } catch (error) { + console.error(error); + } + + EnjoyApp.videos + .create(medium.sourceUrl, { + coverUrl: medium.coverUrl, + md5: medium.md5, + }) + .then(() => { + toast({ + description: t("videoSuccessfullyAddedToLibrary"), + }); + }); + } else if (medium.mediumType === "Audio") { + try { + const audio = await EnjoyApp.audios.findOne({ md5: medium.md5 }); + if (audio) { + toast({ + description: t("audioAlreadyAddedToLibrary"), + }); + return; + } + } catch (error) { + console.error(error); + } + + EnjoyApp.audios + .create(medium.sourceUrl, { + coverUrl: medium.coverUrl, + md5: medium.md5, + }) + .then(() => { + toast({ + description: t("audioSuccessfullyAddedToLibrary"), + }); + }); + } + }; + + return ( + <> +
+ {post.target && post.targetType === "Medium" && ( + + + + + + + {t("addRecourse")} + + {(post.target as MediumType).mediumType === "Video" && + t("areYouSureToAddThisVideoToYourLibrary")} + + {(post.target as MediumType).mediumType === "Audio" && + t("areYouSureToAddThisAudioToYourLibrary")} + + + + {t("cancel")} + + {t("confirm")} + + + + + )} + + {typeof post.metadata?.content === "string" && ( + + )} + {post.metadata?.type === "prompt" && ( + + + + + + + {t("sendToAIAssistant")} + + { + setAiReplies([...aiReplies, ...replies]); + setAsking(false); + }} + /> + + + + )} +
+ + {aiReplies.length > 0 && } + + ); +}; + +const AIReplies = (props: { replies: MessageType[] }) => { + return ( +
+
+ {props.replies.map((reply) => ( +
+
+ + + + +
+ {reply.content} +
+ ))} +
+
+ ); +}; diff --git a/enjoy/src/renderer/components/posts/post-audio.tsx b/enjoy/src/renderer/components/posts/post-audio.tsx new file mode 100644 index 00000000..f681c43d --- /dev/null +++ b/enjoy/src/renderer/components/posts/post-audio.tsx @@ -0,0 +1,160 @@ +import { useEffect, useState, useRef, useCallback, useContext } from "react"; +import { AppSettingsProviderContext } from "@renderer/context"; +import { PitchContour } from "@renderer/components"; +import WaveSurfer from "wavesurfer.js"; +import { Button, Skeleton } from "@renderer/components/ui"; +import { PlayIcon, PauseIcon } from "lucide-react"; +import { useIntersectionObserver } from "@uidotdev/usehooks"; +import { secondsToTimestamp } from "@renderer/lib/utils"; + +export const PostAudio = (props: { + audio: Partial; + height?: number; +}) => { + const { audio, 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 { webApi } = useContext(AppSettingsProviderContext); + const [currentTime, setCurrentTime] = useState(0); + const [transcription, setTranscription] = useState(); + + const currentTranscription = (transcription?.result || []).find( + (s) => + currentTime >= s.offsets.from / 1000.0 && + currentTime <= s.offsets.to / 1000.0 + ); + + const onPlayClick = useCallback(() => { + wavesurfer.isPlaying() ? wavesurfer.pause() : wavesurfer.play(); + }, [wavesurfer]); + + useEffect(() => { + // use the intersection observer to only create the wavesurfer instance + // when the player is visible + if (!entry?.isIntersecting) return; + if (!audio.sourceUrl) return; + if (wavesurfer) return; + + const ws = WaveSurfer.create({ + container: containerRef.current, + url: audio.sourceUrl, + height, + barWidth: 1, + cursorWidth: 0, + autoCenter: true, + autoScroll: true, + dragToSeek: true, + hideScrollbar: true, + minPxPerSec: 100, + waveColor: "#ddd", + progressColor: "rgba(0, 0, 0, 0.25)", + }); + + setWavesurfer(ws); + }, [audio.sourceUrl, entry]); + + useEffect(() => { + if (!wavesurfer) return; + + const subscriptions = [ + wavesurfer.on("play", () => { + setIsPlaying(true); + }), + wavesurfer.on("pause", () => { + setIsPlaying(false); + }), + wavesurfer.on("timeupdate", (time: number) => { + setCurrentTime(time); + }), + wavesurfer.on("decode", () => { + setDuration(wavesurfer.getDuration()); + const peaks = wavesurfer.getDecodedData().getChannelData(0); + const sampleRate = wavesurfer.options.sampleRate; + wavesurfer.renderer.getWrapper().appendChild( + PitchContour({ + peaks, + sampleRate, + height, + }) + ); + setInitialized(true); + }), + ]; + + return () => { + subscriptions.forEach((unsub) => unsub()); + wavesurfer?.destroy(); + }; + }, [wavesurfer]); + + useEffect(() => { + webApi + .transcriptions({ + targetMd5: audio.md5, + }) + .then((response) => { + setTranscription(response?.transcriptions?.[0]); + }); + }, [audio.md5]); + + return ( +
+
+ + {secondsToTimestamp(duration)} + +
+ +
+ {!initialized && ( +
+ + + +
+ )} + +
+ +
+ +
+
+ + {currentTranscription && ( +
+
+ {currentTranscription.text} +
+
+ )} + + {audio.coverUrl && ( +
+ +
+ )} +
+ ); +}; diff --git a/enjoy/src/renderer/components/posts/post-card.tsx b/enjoy/src/renderer/components/posts/post-card.tsx new file mode 100644 index 00000000..50da0fb2 --- /dev/null +++ b/enjoy/src/renderer/components/posts/post-card.tsx @@ -0,0 +1,81 @@ +import { useContext } from "react"; +import { AppSettingsProviderContext } from "@renderer/context"; +import { + PostRecording, + PostActions, + PostMedium, + PostStory, + PostOptions, +} from "@renderer/components"; +import { Avatar, AvatarImage, AvatarFallback } from "@renderer/components/ui"; +import { formatDateTime } from "@renderer/lib/utils"; +import { t } from "i18next"; +import Markdown from "react-markdown"; + +export const PostCard = (props: { + post: PostType; + handleDelete: (id: string) => void; +}) => { + const { post, handleDelete } = props; + const { user } = useContext(AppSettingsProviderContext); + + return ( +
+
+
+ + + + {post.user.name[0].toUpperCase()} + + +
+
{post.user.name}
+
+ {formatDateTime(post.createdAt)} +
+
+
+ + {post.user.id == user.id && ( + handleDelete(post.id)} /> + )} +
+ + {post.metadata?.type === "prompt" && ( + <> +
+ {t("sharedPrompt")} +
+ + {"```prompt\n" + post.metadata.content + "\n```"} + + + )} + + {post.targetType == "Medium" && ( + + )} + + {post.targetType == "Recording" && ( + <> +
+ {t("sharedRecording")} +
+ + + )} + + {post.targetType == "Story" && ( + <> +
+ {t("sharedStory")} +
+ + + )} + + +
+ ); +}; diff --git a/enjoy/src/renderer/components/posts/post-medium.tsx b/enjoy/src/renderer/components/posts/post-medium.tsx new file mode 100644 index 00000000..bcb6280e --- /dev/null +++ b/enjoy/src/renderer/components/posts/post-medium.tsx @@ -0,0 +1,45 @@ +import { PostAudio } from "@renderer/components"; +import { t } from "i18next"; +import { MediaPlayer, MediaProvider } from "@vidstack/react"; +import { + DefaultVideoLayout, + defaultLayoutIcons, +} from "@vidstack/react/player/layouts/default"; + +export const PostMedium = (props: { medium: MediumType }) => { + const { medium } = props; + if (!medium.sourceUrl) return null; + + return ( +
+ {medium.mediumType == "Video" && ( + <> +
+ {t("sharedAudio")} +
+ + + + + + )} + + {medium.mediumType == "Audio" && ( + <> +
+ {t("sharedAudio")} +
+ } /> + + )} +
+ ); +}; diff --git a/enjoy/src/renderer/components/posts/post-options.tsx b/enjoy/src/renderer/components/posts/post-options.tsx new file mode 100644 index 00000000..86dc504b --- /dev/null +++ b/enjoy/src/renderer/components/posts/post-options.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@renderer/components/ui"; +import { MoreHorizontalIcon, Trash2Icon } from "lucide-react"; +import { t } from "i18next"; + +export const PostOptions = (props: { handleDelete: () => void }) => { + const { handleDelete } = props; + const [deleting, setDeleting] = useState(false); + + return ( + <> + + + + + + + setDeleting(true)}> + + {t("delete")} + + + + + + + + + {t("removeSharing")} + + {t("areYouSureToRemoveThisSharing")} + + + + {t("cancel")} + + + + + + ); +}; diff --git a/enjoy/src/renderer/components/posts/post-recording.tsx b/enjoy/src/renderer/components/posts/post-recording.tsx new file mode 100644 index 00000000..0142b8b6 --- /dev/null +++ b/enjoy/src/renderer/components/posts/post-recording.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState, useRef, useCallback } from "react"; +import { PitchContour } from "@renderer/components"; +import WaveSurfer from "wavesurfer.js"; +import { Button, Skeleton } from "@renderer/components/ui"; +import { PlayIcon, PauseIcon } from "lucide-react"; +import { useIntersectionObserver } from "@uidotdev/usehooks"; +import { secondsToTimestamp } from "@renderer/lib/utils"; + +export const PostRecording = (props: { + recording: RecordingType; + height?: number; +}) => { + const { recording, height = 80 } = props; + const [initialized, setInitialized] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + const [wavesurfer, setWavesurfer] = useState(null); + const containerRef = useRef(); + const [ref, entry] = useIntersectionObserver({ + threshold: 1, + }); + const [duration, setDuration] = useState(0); + + const onPlayClick = useCallback(() => { + wavesurfer.isPlaying() ? wavesurfer.pause() : wavesurfer.play(); + }, [wavesurfer]); + + useEffect(() => { + // use the intersection observer to only create the wavesurfer instance + // when the player is visible + if (!entry?.isIntersecting) return; + if (!recording.src) return; + if (wavesurfer) return; + + const ws = WaveSurfer.create({ + container: containerRef.current, + url: recording.src, + height, + barWidth: 1, + cursorWidth: 0, + autoCenter: true, + autoScroll: true, + dragToSeek: true, + hideScrollbar: true, + minPxPerSec: 100, + waveColor: "rgba(0, 0, 0, 0.25)", + progressColor: "rgba(0, 0, 0, 0.5)", + }); + + setWavesurfer(ws); + }, [recording.src, entry]); + + useEffect(() => { + if (!wavesurfer) return; + + const subscriptions = [ + wavesurfer.on("play", () => { + setIsPlaying(true); + }), + wavesurfer.on("pause", () => { + setIsPlaying(false); + }), + wavesurfer.on("decode", () => { + setDuration(wavesurfer.getDuration()); + const peaks = wavesurfer.getDecodedData().getChannelData(0); + const sampleRate = wavesurfer.options.sampleRate; + wavesurfer.renderer.getWrapper().appendChild( + PitchContour({ + peaks, + sampleRate, + height, + }) + ); + setInitialized(true); + }), + ]; + + return () => { + subscriptions.forEach((unsub) => unsub()); + wavesurfer?.destroy(); + }; + }, [wavesurfer]); + + return ( +
+
+ + {secondsToTimestamp(duration)} + +
+ +
+ {!initialized && ( +
+ + + +
+ )} + +
+ +
+ +
+
+ + { + recording.referenceText && ( +
+
+ {recording.referenceText} +
+
+ ) + } +
+ ); +}; diff --git a/enjoy/src/renderer/components/posts/post-story.tsx b/enjoy/src/renderer/components/posts/post-story.tsx new file mode 100644 index 00000000..e98c3833 --- /dev/null +++ b/enjoy/src/renderer/components/posts/post-story.tsx @@ -0,0 +1,25 @@ +import { Link } from "react-router-dom"; + +export const PostStory = (props: { story: StoryType }) => { + const { story } = props; + return ( + +
+
+ +
+
+
+ {story.metadata?.title} +
+
+ {story.metadata?.description} +
+
+
+ + ); +}; diff --git a/enjoy/src/renderer/components/posts/posts.tsx b/enjoy/src/renderer/components/posts/posts.tsx new file mode 100644 index 00000000..8ef6f9f6 --- /dev/null +++ b/enjoy/src/renderer/components/posts/posts.tsx @@ -0,0 +1,84 @@ +import { useContext, useEffect, useState } from "react"; +import { AppSettingsProviderContext } from "@renderer/context"; +import { PostCard, LoaderSpin } from "@renderer/components"; +import { useToast, Button } from "@renderer/components//ui"; +import { t } from "i18next"; + +export const Posts = () => { + const { webApi } = useContext(AppSettingsProviderContext); + const [loading, setLoading] = useState(true); + const [posts, setPosts] = useState([]); + const [nextPage, setNextPage] = useState(1); + const { toast } = useToast(); + + const handleDelete = (id: string) => { + webApi + .deletePost(id) + .then(() => { + toast({ + description: t("removeSharingSuccessfully"), + }); + setPosts(posts.filter((post) => post.id !== id)); + }) + .catch((error) => { + toast({ + title: t("removeSharingFailed"), + description: error.message, + variant: "destructive", + }); + }); + }; + + const fetchPosts = async (page: number = nextPage) => { + if (!page) return; + + webApi + .posts({ + page, + items: 10, + }) + .then((res) => { + setPosts([...posts, ...res.posts]); + setNextPage(res.next); + }) + .catch((err) => { + toast({ + description: err.message, + variant: "destructive", + }); + }) + .finally(() => { + setLoading(false); + }); + }; + + useEffect(() => { + fetchPosts(); + }, []); + + if (loading) { + return ; + } + + return ( +
+ {posts.length === 0 && ( +
{t("noOneSharedYet")}
+ )} + +
+ {posts.map((post) => ( + + ))} +
+ + {nextPage && ( +
+ +
+ )} +
+ ); +}; diff --git a/enjoy/src/renderer/components/recordings/recording-card.tsx b/enjoy/src/renderer/components/recordings/recording-card.tsx index a829366a..d910cd57 100644 --- a/enjoy/src/renderer/components/recordings/recording-card.tsx +++ b/enjoy/src/renderer/components/recordings/recording-card.tsx @@ -4,18 +4,26 @@ import { RecordingPlayer } from "@renderer/components"; import { AlertDialog, AlertDialogHeader, + AlertDialogTrigger, AlertDialogDescription, AlertDialogTitle, AlertDialogContent, AlertDialogFooter, AlertDialogCancel, + AlertDialogAction, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + useToast, } from "@renderer/components/ui"; -import { ChevronDownIcon, Trash2Icon, InfoIcon, Share2Icon } from "lucide-react"; +import { + MoreHorizontalIcon, + Trash2Icon, + Share2Icon, + GaugeCircleIcon, +} from "lucide-react"; import { formatDateTime, secondsToTimestamp } from "@renderer/lib/utils"; import { t } from "i18next"; @@ -26,39 +34,73 @@ export const RecordingCard = (props: { }) => { const { recording, id, onSelect } = props; const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext); const [isPlaying, setIsPlaying] = useState(false); + const { toast } = useToast(); const handleDelete = () => { EnjoyApp.recordings.destroy(recording.id); }; + const handleShare = async () => { + if (!recording.updatedAt) { + try { + await EnjoyApp.recordings.upload(recording.id); + } catch (error) { + toast({ + title: t("shareFailed"), + description: error.message, + variant: "destructive", + }); + return; + } + } + + webApi + .createPost({ + targetId: recording.id, + targetType: "Recording", + }) + .then(() => { + toast({ + description: t("sharedRecording"), + }); + }) + .catch((error) => { + toast({ + description: error.message, + variant: "destructive", + }); + }); + }; return (
- -
-
-
- - {secondsToTimestamp(recording.duration / 1000)} - -
+
+
+
+ + {secondsToTimestamp(recording.duration / 1000)} + +
- + -
- + /> + + + + + + + + + {t("shareRecording")} + + {t("areYouSureToShareThisRecordingToCommunity")} + + + + {t("cancel")} + + + + + + + + - + -
-
-
- - {formatDateTime(recording.createdAt)} - + + + setIsDeleteDialogOpen(true)}> + + {t("delete")} + + + + +
- - - setIsDeleteDialogOpen(true)}> - - {t("delete")} - - - - - +
+ + {formatDateTime(recording.createdAt)} + +
+
{ wavesurfer.isPlaying() ? wavesurfer.pause() : wavesurfer.play(); @@ -40,6 +41,7 @@ export const RecordingPlayer = (props: { // when the player is visible if (!entry?.isIntersecting) return; if (!recording?.src) return; + if (wavesurfer) return; const ws = WaveSurfer.create({ container: containerRef.current, @@ -78,6 +80,7 @@ export const RecordingPlayer = (props: { height, }) ); + setInitialized(true); }), ]; @@ -105,7 +108,15 @@ export const RecordingPlayer = (props: { return (
-
+ {!initialized && ( +
+ + + +
+ )} + +
-
+
); }; 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/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/stories/story-toolbar.tsx b/enjoy/src/renderer/components/stories/story-toolbar.tsx index 6e7b4a77..b3740b3e 100644 --- a/enjoy/src/renderer/components/stories/story-toolbar.tsx +++ b/enjoy/src/renderer/components/stories/story-toolbar.tsx @@ -2,6 +2,16 @@ import { Alert, AlertTitle, AlertDescription, + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, + AlertDialogAction, + Button, ScrollArea, Separator, Sheet, @@ -17,6 +27,7 @@ import { ScanTextIcon, LoaderIcon, StarIcon, + Share2Icon, } from "lucide-react"; import { t } from "i18next"; @@ -36,6 +47,7 @@ export const StoryToolbar = (props: { marked?: boolean; toggleMarked?: () => void; pendingLookups?: LookupType[]; + handleShare?: () => void; }) => { const { starred, @@ -47,6 +59,7 @@ export const StoryToolbar = (props: { toggleMarked, meanings = [], pendingLookups = [], + handleShare, } = props; const [vocabularyVisible, setVocabularyVisible] = useState( @@ -76,6 +89,27 @@ export const StoryToolbar = (props: { + + + + + + + + + {t("shareStory")} + + {t("areYouSureToShareThisStoryToCommunity")} + + + + {t("cancel")} + + + + + + { + return ( +
+ + + + +
+ ); +}; + +const RankingsCard = (props: { + range: "day" | "week" | "month" | "year" | "all"; +}) => { + const { range } = props; + const { webApi } = useContext(AppSettingsProviderContext); + const [rankings, setRankings] = useState([]); + + const fetchRankings = async () => { + webApi.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/components/videos/video-detail.tsx b/enjoy/src/renderer/components/videos/video-detail.tsx index 9547dfaa..edfa8ae3 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); @@ -35,6 +49,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 || {}; @@ -43,6 +59,46 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => { } }; + const handleShare = async () => { + if (!video.source.startsWith("http")) { + toast({ + title: t("shareFailed"), + description: t("cannotShareLocalVideo"), + }); + return; + } + + 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("sharedVideo"), + }); + }) + .catch((err) => { + toast({ + title: t("shareFailed"), + description: err.message, + }); + }); + setSharing(false); + }; + useEffect(() => { const where = id ? { id } : { md5 }; EnjoyApp.videos.findOne(where).then((video) => { @@ -109,6 +165,9 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => { setIsLooping={setIsLooping} playBackRate={playBackRate} setPlaybackRate={setPlaybackRate} + displayInlineCaption={displayInlineCaption} + setDisplayInlineCaption={setDisplayInlineCaption} + onShare={() => setSharing(true)} /> {
+ setSharing(value)}> + + + {t("shareAudio")} + + {t("areYouSureToShareThisAudioToCommunity")} + + + + {t("cancel")} + + + + + {!initialized && (
diff --git a/enjoy/src/renderer/components/videos/videos-component.tsx b/enjoy/src/renderer/components/videos/videos-component.tsx index 4d16115b..9516e82c 100644 --- a/enjoy/src/renderer/components/videos/videos-component.tsx +++ b/enjoy/src/renderer/components/videos/videos-component.tsx @@ -4,6 +4,7 @@ import { VideosTable, VideoEditForm, AddMediaButton, + LoaderSpin, } from "@renderer/components"; import { t } from "i18next"; import { @@ -19,10 +20,12 @@ import { AlertDialogDescription, AlertDialogCancel, AlertDialogAction, + Button, Dialog, DialogContent, DialogHeader, DialogTitle, + useToast, } from "@renderer/components/ui"; import { DbProviderContext, @@ -43,11 +46,11 @@ export const VideosComponent = () => { const { addDblistener, removeDbListener } = useContext(DbProviderContext); const { EnjoyApp } = useContext(AppSettingsProviderContext); - const navigate = useNavigate(); + const [offset, setOffest] = useState(0); + const [loading, setLoading] = useState(false); + const { toast } = useToast(); - useEffect(() => { - fetchVideos(); - }, []); + const navigate = useNavigate(); useEffect(() => { addDblistener(onVideosUpdate); @@ -59,12 +62,39 @@ export const VideosComponent = () => { }, []); const fetchVideos = async () => { - const videos = await EnjoyApp.videos.findAll({ - limit: 10, - }); - if (!videos) return; + if (loading) return; + if (offset === -1) return; - dispatchVideos({ type: "set", records: videos }); + setLoading(true); + const limit = 10; + EnjoyApp.videos + .findAll({ + offset, + limit, + }) + .then((_videos) => { + if (_videos.length === 0) { + setOffest(-1); + return; + } + + if (_videos.length < limit) { + setOffest(-1); + } else { + setOffest(offset + _videos.length); + } + + dispatchVideos({ type: "append", records: _videos }); + }) + .catch((err) => { + toast({ + description: err.message, + variant: "destructive", + }); + }) + .finally(() => { + setLoading(false); + }); }; const onVideosUpdate = (event: CustomEvent) => { @@ -93,6 +123,8 @@ export const VideosComponent = () => { }; if (videos.length === 0) { + if (loading) return ; + return (
@@ -135,6 +167,14 @@ export const VideosComponent = () => {
+ {offset > -1 && ( +
+ +
+ )} + { diff --git a/enjoy/src/renderer/context/app-settings-provider.tsx b/enjoy/src/renderer/context/app-settings-provider.tsx index 0bedb1ae..c57521fb 100644 --- a/enjoy/src/renderer/context/app-settings-provider.tsx +++ b/enjoy/src/renderer/context/app-settings-provider.tsx @@ -1,6 +1,9 @@ import { createContext, useEffect, useState } from "react"; +import { WEB_API_URL } from "@/constants"; +import { Client } from "@/api"; type AppSettingsProviderState = { + webApi: Client; user: UserType | null; initialized: boolean; version?: string; @@ -17,6 +20,7 @@ type AppSettingsProviderState = { }; const initialState: AppSettingsProviderState = { + webApi: null, user: null, initialized: false, }; @@ -31,6 +35,8 @@ 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(""); @@ -54,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); @@ -65,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 { @@ -107,6 +132,10 @@ export const AppSettingsProvider = ({ setWhisperModel(whisperModel); }; + const fetchApiUrl = async () => { + return apiUrl; + }; + const setModelHandler = async (name: string) => { await EnjoyApp.settings.setWhisperModel(name); setWhisperModel(name); @@ -123,6 +152,7 @@ export const AppSettingsProvider = ({ value={{ EnjoyApp, version, + webApi, 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..3bf4c2f2 --- /dev/null +++ b/enjoy/src/renderer/pages/community.tsx @@ -0,0 +1,49 @@ +import { + Button, + Tabs, + TabsList, + TabsContent, + TabsTrigger, +} from "@renderer/components/ui"; +import { UsersRankings, Posts } from "@renderer/components"; +import { ChevronLeftIcon } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { t } from "i18next"; + +export default () => { + const navigate = useNavigate(); + + return ( +
+
+
+ + {t("sidebar.community")} +
+ + + + {t("square")} + + {t("rankings")} + + + + + + + + + +
+
+ ); +}; diff --git a/enjoy/src/renderer/pages/conversation.tsx b/enjoy/src/renderer/pages/conversation.tsx index 403ead35..ff60e62e 100644 --- a/enjoy/src/renderer/pages/conversation.tsx +++ b/enjoy/src/renderer/pages/conversation.tsx @@ -8,11 +8,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"; @@ -32,6 +28,7 @@ export default () => { const { EnjoyApp } = useContext(AppSettingsProviderContext); const [content, setConent] = useState(""); const [submitting, setSubmitting] = useState(false); + const { toast } = useToast(); const [messages, dispatchMessages] = useReducer(messagesReducer, []); diff --git a/enjoy/src/renderer/pages/stories.tsx b/enjoy/src/renderer/pages/stories.tsx index e513aba9..34a12084 100644 --- a/enjoy/src/renderer/pages/stories.tsx +++ b/enjoy/src/renderer/pages/stories.tsx @@ -1,19 +1,25 @@ +import { Button } from "@renderer/components/ui"; import { StoryForm, StoryCard, LoaderSpin } from "@renderer/components"; import { useState, useContext, useEffect } from "react"; import { AppSettingsProviderContext } from "@renderer/context"; +import { t } from "i18next"; export default () => { const [stories, setStorys] = useState([]); const [loading, setLoading] = useState(true); - const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { webApi } = useContext(AppSettingsProviderContext); + const [nextPage, setNextPage] = useState(1); - const fetchStorys = async () => { - EnjoyApp.webApi + const fetchStories = async (page: number = nextPage) => { + if (!page) return; + + webApi .mineStories() .then((response) => { if (response?.stories) { - setStorys(response.stories); + setStorys([...stories, ...response.stories]); } + setNextPage(response.next); }) .finally(() => { setLoading(false); @@ -21,7 +27,7 @@ export default () => { }; useEffect(() => { - fetchStorys(); + fetchStories(); }, []); return ( @@ -38,6 +44,14 @@ export default () => { ))}
)} + + {nextPage && ( +
+ +
+ )}
); }; 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..3384e2b9 100644 --- a/enjoy/src/renderer/pages/story.tsx +++ b/enjoy/src/renderer/pages/story.tsx @@ -1,5 +1,5 @@ import { t } from "i18next"; -import { ScrollArea } from "@renderer/components/ui"; +import { ScrollArea, useToast } from "@renderer/components/ui"; import { LoaderSpin, PagePlaceholder, @@ -16,7 +16,7 @@ nlp.plugin(paragraphs); let timeout: NodeJS.Timeout = null; export default () => { const { id } = useParams<{ id: string }>(); - const { EnjoyApp } = useContext(AppSettingsProviderContext); + const { webApi } = useContext(AppSettingsProviderContext); const [loading, setLoading] = useState(true); const [story, setStory] = useState(); const [meanings, setMeanings] = useState([]); @@ -24,9 +24,10 @@ export default () => { const [scanning, setScanning] = useState(false); const [marked, setMarked] = useState(true); const [doc, setDoc] = useState(null); + const { toast } = useToast(); const fetchStory = async () => { - EnjoyApp.webApi + webApi .story(id) .then((story) => { setStory(story); @@ -41,7 +42,7 @@ export default () => { const fetchMeanings = async () => { setScanning(true); - EnjoyApp.webApi + webApi .storyMeanings(id, { items: 500 }) .then((response) => { if (!response) return; @@ -88,14 +89,14 @@ export default () => { }); }); - EnjoyApp.webApi.lookupInBatch(vocabulary).then((response) => { + webApi.lookupInBatch(vocabulary).then((response) => { const { errors } = response; if (errors.length > 0) { console.warn(errors); return; } - EnjoyApp.webApi.extractVocabularyFromStory(id).then(() => { + webApi.extractVocabularyFromStory(id).then(() => { fetchStory(); if (pendingLookups.length > 0) return; @@ -108,16 +109,33 @@ export default () => { if (!story) return; if (story.starred) { - EnjoyApp.webApi.unstarStory(id).then((result) => { + webApi.unstarStory(id).then((result) => { setStory({ ...story, starred: result.starred }); }); } else { - EnjoyApp.webApi.starStory(id).then((result) => { + webApi.starStory(id).then((result) => { setStory({ ...story, starred: result.starred }); }); } }; + const handleShare = async () => { + webApi + .createPost({ targetId: story.id, targetType: "Story" }) + .then(() => { + toast({ + description: t("sharedStory"), + }); + }) + .catch((error) => { + toast({ + title: t("shareFailed"), + description: error.message, + variant: "destructive", + }); + }); + }; + useEffect(() => { fetchStory(); fetchMeanings(); @@ -162,6 +180,7 @@ export default () => { starred={story.starred} toggleStarred={toggleStarred} pendingLookups={pendingLookups} + handleShare={handleShare} /> { 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]); diff --git a/enjoy/src/renderer/reducers/audios-reducer.ts b/enjoy/src/renderer/reducers/audios-reducer.ts index 6062c40a..f99a7d06 100644 --- a/enjoy/src/renderer/reducers/audios-reducer.ts +++ b/enjoy/src/renderer/reducers/audios-reducer.ts @@ -1,12 +1,19 @@ export const audiosReducer = ( audios: AudioType[], action: { - type: "create" | "update" | "destroy" | "set"; + type: "append" | "create" | "update" | "destroy" | "set"; record?: Partial; records?: Partial[]; } ) => { switch (action.type) { + case "append": { + if (action.record) { + return [...audios, action.record]; + } else if (action.records) { + return [...audios, ...action.records]; + } + } case "create": { return [action.record, ...audios]; } diff --git a/enjoy/src/renderer/reducers/videos-reducer.ts b/enjoy/src/renderer/reducers/videos-reducer.ts index d4f61884..456099ff 100644 --- a/enjoy/src/renderer/reducers/videos-reducer.ts +++ b/enjoy/src/renderer/reducers/videos-reducer.ts @@ -1,12 +1,19 @@ export const videosReducer = ( videos: VideoType[], action: { - type: "create" | "update" | "destroy" | "set"; + type: "append" | "create" | "update" | "destroy" | "set"; record?: Partial; records?: Partial[]; } ) => { switch (action.type) { + case "append": { + if (action.record) { + return [...videos, action.record]; + } else if (action.records) { + return [...videos, ...action.records]; + } + } case "create": { return [action.record, ...videos]; } 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.d.ts b/enjoy/src/types.d.ts index 10192b1b..74eac327 100644 --- a/enjoy/src/types.d.ts +++ b/enjoy/src/types.d.ts @@ -105,31 +105,6 @@ type MeaningType = { lookups: LookupType[]; }; -type StoryType = { - id: string; - url: string; - title: string; - content: string; - metadata: { - [key: string]: string; - }; - vocabulary?: string[]; - extracted?: boolean; - starred?: boolean; - createdAt: Date; - updatedAt: Date; -}; - -type CreateStoryParamsType = { - title: string; - content: string; - url: string; - html: string; - metadata: { - [key: string]: string; - }; -}; - type PagyResponseType = { page: number; next: number | null; 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 5144b055..55139ea2 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,8 +102,8 @@ type EnjoyAppType = { videos: { findAll: (params: object) => Promise; findOne: (params: object) => Promise; - create: (source: string, params?: object) => Promise; - update: (id: string, params: object) => Promise; + create: (uri: string, params?: any) => Promise; + update: (id: string, params: any) => Promise; destroy: (id: string) => Promise; transcribe: (id: string) => Promise; upload: (id: string) => Promise; @@ -143,9 +143,9 @@ type EnjoyAppType = { ) => Promise; }; conversations: { - findAll: (params: object) => Promise; - findOne: (params: object) => Promise; - create: (params: object) => Promise; + findAll: (params: any) => Promise; + findOne: (params: any) => Promise; + create: (params: any) => Promise; update: (id: string, params: object) => Promise; destroy: (id: string) => Promise; ask: ( @@ -159,7 +159,7 @@ type EnjoyAppType = { arrayBuffer: ArrayBuffer; }; } - ) => Promise; + ) => Promise; }; messages: { findAll: (params: object) => Promise; @@ -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; diff --git a/enjoy/src/types/medium.d.ts b/enjoy/src/types/medium.d.ts new file mode 100644 index 00000000..94777951 --- /dev/null +++ b/enjoy/src/types/medium.d.ts @@ -0,0 +1,10 @@ +type MediumType = { + id: string; + md5: 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 new file mode 100644 index 00000000..0ab9943d --- /dev/null +++ b/enjoy/src/types/post.d.ts @@ -0,0 +1,17 @@ +type PostType = { + id: string; + metadata: { + type: 'text' | 'prompt' | 'llm_configuration'; + content: + | string + | { + [key: string]: any; + }; + }; + user: UserType; + targetType?: string; + targetId?: string; + target?: MediumType | StoryType | RecordingType; + createdAt: Date; + updatedAt: Date; +}; diff --git a/enjoy/src/types/recording.d.ts b/enjoy/src/types/recording.d.ts index 0a87019b..f9d72cef 100644 --- a/enjoy/src/types/recording.d.ts +++ b/enjoy/src/types/recording.d.ts @@ -1,12 +1,12 @@ type RecordingType = { id: string; - filename: string; + filename?: string; target?: AudioType | (MessageType & any); targetId: string; targetType: string; pronunciationAssessment?: PronunciationAssessmentType & any; - segmentIndex: number; - segmentText?: string; + referenceId: number; + referenceText?: string; duration?: number; src?: string; md5: string; diff --git a/enjoy/src/types/story.d.ts b/enjoy/src/types/story.d.ts new file mode 100644 index 00000000..f3c63e02 --- /dev/null +++ b/enjoy/src/types/story.d.ts @@ -0,0 +1,24 @@ +type StoryType = { + id: string; + url: string; + title: string; + content: string; + metadata: { + [key: string]: string; + }; + vocabulary?: string[]; + extracted?: boolean; + starred?: boolean; + createdAt: Date; + updatedAt: Date; +}; + +type CreateStoryParamsType = { + title: string; + content: string; + url: string; + html: string; + metadata: { + [key: string]: string; + }; +}; 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; }; 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;