259
enjoy/src/api/client.ts
Normal file
259
enjoy/src/api/client.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import decamelizeKeys from "decamelize-keys";
|
||||
import camelcaseKeys from "camelcase-keys";
|
||||
|
||||
const ONE_MINUTE = 1000 * 60; // 1 minute
|
||||
|
||||
export class Client {
|
||||
public api: AxiosInstance;
|
||||
public baseUrl: string;
|
||||
public logger: any;
|
||||
|
||||
constructor(options: {
|
||||
baseUrl: string;
|
||||
accessToken?: string;
|
||||
logger?: any;
|
||||
}) {
|
||||
const { baseUrl, accessToken, logger } = options;
|
||||
this.baseUrl = baseUrl;
|
||||
this.logger = logger || console;
|
||||
|
||||
this.api = axios.create({
|
||||
baseURL: baseUrl,
|
||||
timeout: ONE_MINUTE,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
this.api.interceptors.request.use((config) => {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
|
||||
this.logger.debug(
|
||||
config.method.toUpperCase(),
|
||||
config.baseURL + config.url,
|
||||
config.data,
|
||||
config.params
|
||||
);
|
||||
return config;
|
||||
});
|
||||
this.api.interceptors.response.use(
|
||||
(response) => {
|
||||
this.logger.debug(
|
||||
response.status,
|
||||
response.config.method.toUpperCase(),
|
||||
response.config.baseURL + response.config.url
|
||||
);
|
||||
return camelcaseKeys(response.data, { deep: true });
|
||||
},
|
||||
(err) => {
|
||||
if (err.response) {
|
||||
this.logger.error(
|
||||
err.response.status,
|
||||
err.response.config.method.toUpperCase(),
|
||||
err.response.config.baseURL + err.response.config.url
|
||||
);
|
||||
this.logger.error(err.response.data);
|
||||
return Promise.reject(err.response.data);
|
||||
}
|
||||
|
||||
if (err.request) {
|
||||
this.logger.error(err.request);
|
||||
} else {
|
||||
this.logger.error(err.message);
|
||||
}
|
||||
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
auth(params: { provider: string; code: string }): Promise<UserType> {
|
||||
return this.api.post("/api/sessions", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
me(): Promise<UserType> {
|
||||
return this.api.get("/api/me");
|
||||
}
|
||||
|
||||
rankings(range: "day" | "week" | "month" | "year" | "all" = "day"): Promise<{
|
||||
rankings: UserType[];
|
||||
range: string;
|
||||
}> {
|
||||
return this.api.get("/api/users/rankings", { params: { range } });
|
||||
}
|
||||
|
||||
posts(params?: { page?: number; items?: number }): Promise<
|
||||
{
|
||||
posts: PostType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/posts", { params: decamelizeKeys(params) });
|
||||
}
|
||||
|
||||
post(id: string): Promise<PostType> {
|
||||
return this.api.get(`/api/posts/${id}`);
|
||||
}
|
||||
|
||||
createPost(params: {
|
||||
metadata?: PostType["metadata"];
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
}): Promise<PostType> {
|
||||
return this.api.post("/api/posts", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
updatePost(id: string, params: { content: string }): Promise<PostType> {
|
||||
return this.api.put(`/api/posts/${id}`, decamelizeKeys(params));
|
||||
}
|
||||
|
||||
deletePost(id: string): Promise<void> {
|
||||
return this.api.delete(`/api/posts/${id}`);
|
||||
}
|
||||
|
||||
transcriptions(params?: {
|
||||
page?: number;
|
||||
items?: number;
|
||||
targetId?: string;
|
||||
targetType?: string;
|
||||
targetMd5?: string;
|
||||
}): Promise<
|
||||
{
|
||||
transcriptions: TranscriptionType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/transcriptions", {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
syncAudio(audio: Partial<AudioType>) {
|
||||
return this.api.post("/api/mine/audios", decamelizeKeys(audio));
|
||||
}
|
||||
|
||||
syncVideo(video: Partial<VideoType>) {
|
||||
return this.api.post("/api/mine/videos", decamelizeKeys(video));
|
||||
}
|
||||
|
||||
syncTranscription(transcription: Partial<TranscriptionType>) {
|
||||
return this.api.post("/api/transcriptions", decamelizeKeys(transcription));
|
||||
}
|
||||
|
||||
syncRecording(recording: Partial<RecordingType>) {
|
||||
if (!recording) return;
|
||||
|
||||
return this.api.post("/api/mine/recordings", decamelizeKeys(recording));
|
||||
}
|
||||
|
||||
generateSpeechToken(): Promise<{ token: string; region: string }> {
|
||||
return this.api.post("/api/speech/tokens");
|
||||
}
|
||||
|
||||
syncPronunciationAssessment(
|
||||
pronunciationAssessment: Partial<PronunciationAssessmentType>
|
||||
) {
|
||||
if (!pronunciationAssessment) return;
|
||||
|
||||
return this.api.post(
|
||||
"/api/mine/pronunciation_assessments",
|
||||
decamelizeKeys(pronunciationAssessment)
|
||||
);
|
||||
}
|
||||
|
||||
recordingAssessment(id: string) {
|
||||
return this.api.get(`/api/mine/recordings/${id}/assessment`);
|
||||
}
|
||||
|
||||
lookup(params: {
|
||||
word: string;
|
||||
context: string;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
}): Promise<LookupType> {
|
||||
return this.api.post("/api/lookups", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
lookupInBatch(
|
||||
lookups: {
|
||||
word: string;
|
||||
context: string;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
}[]
|
||||
): Promise<{ successCount: number; errors: string[]; total: number }> {
|
||||
return this.api.post("/api/lookups/batch", {
|
||||
lookups: decamelizeKeys(lookups, { deep: true }),
|
||||
});
|
||||
}
|
||||
|
||||
extractVocabularyFromStory(storyId: string): Promise<string[]> {
|
||||
return this.api.post(`/api/stories/${storyId}/extract_vocabulary`);
|
||||
}
|
||||
|
||||
storyMeanings(
|
||||
storyId: string,
|
||||
params?: {
|
||||
page?: number;
|
||||
items?: number;
|
||||
storyId?: string;
|
||||
}
|
||||
): Promise<
|
||||
{
|
||||
meanings: MeaningType[];
|
||||
pendingLookups?: LookupType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get(`/api/stories/${storyId}/meanings`, {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
mineMeanings(params?: {
|
||||
page?: number;
|
||||
items?: number;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
status?: string;
|
||||
}): Promise<
|
||||
{
|
||||
meanings: MeaningType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/mine/meanings", {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
createStory(params: CreateStoryParamsType): Promise<StoryType> {
|
||||
return this.api.post("/api/stories", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
story(id: string): Promise<StoryType> {
|
||||
return this.api.get(`/api/stories/${id}`);
|
||||
}
|
||||
|
||||
stories(params?: { page: number }): Promise<
|
||||
{
|
||||
stories: StoryType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/stories", { params: decamelizeKeys(params) });
|
||||
}
|
||||
|
||||
mineStories(params?: { page: number }): Promise<
|
||||
{
|
||||
stories: StoryType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/mine/stories", {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
starStory(storyId: string): Promise<{ starred: boolean }> {
|
||||
return this.api.post(`/api/mine/stories`, decamelizeKeys({ storyId }));
|
||||
}
|
||||
|
||||
unstarStory(storyId: string): Promise<{ starred: boolean }> {
|
||||
return this.api.delete(`/api/mine/stories/${storyId}`);
|
||||
}
|
||||
}
|
||||
1
enjoy/src/api/index.ts
Normal file
1
enjoy/src/api/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./client";
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "取消分享失败"
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Conversation> {
|
||||
}
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
const replies = await Promise.all(
|
||||
response.map(async (generation) => {
|
||||
await Message.create(
|
||||
return await Message.create(
|
||||
{
|
||||
conversationId: this.id,
|
||||
role: "assistant",
|
||||
@@ -330,5 +330,7 @@ export class Conversation extends Model<Conversation> {
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
return replies.map((reply) => reply.toJSON());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -23,12 +23,19 @@ import { hashFile } from "@/utils";
|
||||
import log from "electron-log/main";
|
||||
import storage from "@main/storage";
|
||||
import Ffmpeg from "@main/ffmpeg";
|
||||
import webApi from "@main/web-api";
|
||||
import { Client } from "@/api";
|
||||
import { WEB_API_URL } from "@/constants";
|
||||
import { AzureSpeechSdk } from "@main/azure-speech-sdk";
|
||||
import camelcaseKeys from "camelcase-keys";
|
||||
|
||||
const logger = log.scope("db/models/recording");
|
||||
|
||||
const webApi = new Client({
|
||||
baseUrl: process.env.WEB_API_URL || WEB_API_URL,
|
||||
accessToken: settings.getSync("user.accessToken") as string,
|
||||
logger: log.scope("api/client"),
|
||||
});
|
||||
|
||||
@Table({
|
||||
modelName: "Recording",
|
||||
tableName: "recordings",
|
||||
@@ -36,7 +43,7 @@ const logger = log.scope("db/models/recording");
|
||||
timestamps: true,
|
||||
})
|
||||
export class Recording extends Model<Recording> {
|
||||
@IsUUID('all')
|
||||
@IsUUID("all")
|
||||
@Default(DataType.UUIDV4)
|
||||
@Column({ primaryKey: true, type: DataType.UUID })
|
||||
id: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AfterCreate,
|
||||
AfterUpdate,
|
||||
AfterDestroy,
|
||||
AfterFind,
|
||||
BelongsTo,
|
||||
Table,
|
||||
Column,
|
||||
@@ -15,9 +16,17 @@ import { Audio, Video } from "@main/db/models";
|
||||
import whisper from "@main/whisper";
|
||||
import mainWindow from "@main/window";
|
||||
import log from "electron-log/main";
|
||||
import webApi from "@main/web-api";
|
||||
import { Client } from "@/api";
|
||||
import { WEB_API_URL, PROCESS_TIMEOUT } from "@/constants";
|
||||
import settings from "@main/settings";
|
||||
|
||||
const logger = log.scope("db/models/transcription");
|
||||
const webApi = new Client({
|
||||
baseUrl: process.env.WEB_API_URL || WEB_API_URL,
|
||||
accessToken: settings.getSync("user.accessToken") as string,
|
||||
logger: log.scope("api/client"),
|
||||
});
|
||||
|
||||
@Table({
|
||||
modelName: "Transcription",
|
||||
tableName: "transcriptions",
|
||||
@@ -25,7 +34,7 @@ const logger = log.scope("db/models/transcription");
|
||||
timestamps: true,
|
||||
})
|
||||
export class Transcription extends Model<Transcription> {
|
||||
@IsUUID('all')
|
||||
@IsUUID("all")
|
||||
@Default(DataType.UUIDV4)
|
||||
@Column({ primaryKey: true, type: DataType.UUID })
|
||||
id: string;
|
||||
@@ -146,6 +155,23 @@ export class Transcription extends Model<Transcription> {
|
||||
this.notify(transcription, "destroy");
|
||||
}
|
||||
|
||||
@AfterFind
|
||||
static expireProcessingState(transcription: Transcription) {
|
||||
if (transcription?.state !== "processing") return;
|
||||
|
||||
if (transcription.updatedAt.getTime() + PROCESS_TIMEOUT < Date.now()) {
|
||||
if (transcription.result) {
|
||||
transcription.update({
|
||||
state: "finished",
|
||||
});
|
||||
} else {
|
||||
transcription.update({
|
||||
state: "pending",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static notify(
|
||||
transcription: Transcription,
|
||||
action: "create" | "update" | "destroy"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
import { ipcMain } from "electron";
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import { WEB_API_URL } from "@/constants";
|
||||
import settings from "@main/settings";
|
||||
import log from "electron-log/main";
|
||||
import decamelizeKeys from "decamelize-keys";
|
||||
import camelcaseKeys from "camelcase-keys";
|
||||
|
||||
const logger = log.scope("web-api");
|
||||
const ONE_MINUTE = 1000 * 60; // 1 minute
|
||||
class WebApi {
|
||||
public api: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.api = axios.create({
|
||||
baseURL: process.env.WEB_API_URL || WEB_API_URL,
|
||||
timeout: ONE_MINUTE,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
this.api.interceptors.request.use((config) => {
|
||||
config.headers.Authorization = `Bearer ${settings.getSync(
|
||||
"user.accessToken"
|
||||
)}`;
|
||||
|
||||
logger.info(
|
||||
config.method.toUpperCase(),
|
||||
config.baseURL + config.url,
|
||||
config.data,
|
||||
config.params
|
||||
);
|
||||
return config;
|
||||
});
|
||||
this.api.interceptors.response.use(
|
||||
(response) => {
|
||||
logger.info(
|
||||
response.status,
|
||||
response.config.method.toUpperCase(),
|
||||
response.config.baseURL + response.config.url
|
||||
);
|
||||
return camelcaseKeys(response.data, { deep: true });
|
||||
},
|
||||
(err) => {
|
||||
if (err.response) {
|
||||
logger.error(
|
||||
err.response.status,
|
||||
err.response.config.method.toUpperCase(),
|
||||
err.response.config.baseURL + err.response.config.url
|
||||
);
|
||||
logger.error(err.response.data);
|
||||
return Promise.reject(err.response.data);
|
||||
}
|
||||
|
||||
if (err.request) {
|
||||
logger.error(err.request);
|
||||
} else {
|
||||
logger.error(err.message);
|
||||
}
|
||||
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
me() {
|
||||
return this.api.get("/api/me");
|
||||
}
|
||||
|
||||
auth(params: { provider: string; code: string }): Promise<UserType> {
|
||||
return this.api.post("/api/sessions", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
syncAudio(audio: Partial<AudioType>) {
|
||||
return this.api.post("/api/mine/audios", decamelizeKeys(audio));
|
||||
}
|
||||
|
||||
syncVideo(video: Partial<VideoType>) {
|
||||
return this.api.post("/api/mine/videos", decamelizeKeys(video));
|
||||
}
|
||||
|
||||
syncTranscription(transcription: Partial<TranscriptionType>) {
|
||||
return this.api.post("/api/transcriptions", decamelizeKeys(transcription));
|
||||
}
|
||||
|
||||
syncRecording(recording: Partial<RecordingType>) {
|
||||
if (!recording) return;
|
||||
|
||||
return this.api.post("/api/mine/recordings", decamelizeKeys(recording));
|
||||
}
|
||||
|
||||
generateSpeechToken(): Promise<{ token: string; region: string }> {
|
||||
return this.api.post("/api/speech/tokens");
|
||||
}
|
||||
|
||||
syncPronunciationAssessment(
|
||||
pronunciationAssessment: Partial<PronunciationAssessmentType>
|
||||
) {
|
||||
if (!pronunciationAssessment) return;
|
||||
|
||||
return this.api.post(
|
||||
"/api/mine/pronunciation_assessments",
|
||||
decamelizeKeys(pronunciationAssessment)
|
||||
);
|
||||
}
|
||||
|
||||
recordingAssessment(id: string) {
|
||||
return this.api.get(`/api/mine/recordings/${id}/assessment`);
|
||||
}
|
||||
|
||||
lookup(params: {
|
||||
word: string;
|
||||
context: string;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
}): Promise<LookupType> {
|
||||
return this.api.post("/api/lookups", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
lookupInBatch(
|
||||
lookups: {
|
||||
word: string;
|
||||
context: string;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
}[]
|
||||
): Promise<{ successCount: number; total: number }> {
|
||||
return this.api.post("/api/lookups/batch", {
|
||||
lookups: decamelizeKeys(lookups, { deep: true }),
|
||||
});
|
||||
}
|
||||
|
||||
extractVocabularyFromStory(storyId: string): Promise<string[]> {
|
||||
return this.api.post(`/api/stories/${storyId}/extract_vocabulary`);
|
||||
}
|
||||
|
||||
storyMeanings(
|
||||
storyId: string,
|
||||
params?: {
|
||||
page?: number;
|
||||
items?: number;
|
||||
storyId?: string;
|
||||
}
|
||||
): Promise<
|
||||
{
|
||||
meanings: MeaningType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get(`/api/stories/${storyId}/meanings`, {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
mineMeanings(params?: {
|
||||
page?: number;
|
||||
items?: number;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
status?: string;
|
||||
}): Promise<
|
||||
{
|
||||
meanings: MeaningType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/mine/meanings", {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
createStory(params: CreateStoryParamsType): Promise<StoryType> {
|
||||
return this.api.post("/api/stories", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
story(id: string): Promise<StoryType> {
|
||||
return this.api.get(`/api/stories/${id}`);
|
||||
}
|
||||
|
||||
stories(params?: { page: number }): Promise<
|
||||
{
|
||||
stories: StoryType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/stories", { params: decamelizeKeys(params) });
|
||||
}
|
||||
|
||||
mineStories(params?: { page: number }): Promise<
|
||||
{
|
||||
stories: StoryType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/mine/stories", {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
starStory(storyId: string) {
|
||||
return this.api.post(`/api/mine/stories`, decamelizeKeys({ storyId }));
|
||||
}
|
||||
|
||||
unstarStory(storyId: string) {
|
||||
return this.api.delete(`/api/mine/stories/${storyId}`);
|
||||
}
|
||||
|
||||
registerIpcHandlers() {
|
||||
ipcMain.handle("web-api-auth", async (event, params) => {
|
||||
return this.auth(params)
|
||||
.then((user) => {
|
||||
return user;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-me", async (event) => {
|
||||
return this.me()
|
||||
.then((user) => {
|
||||
return user;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-lookup", async (event, params) => {
|
||||
return this.lookup(params)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-lookup-in-batch", async (event, params) => {
|
||||
return this.lookupInBatch(params)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-mine-meanings", async (event, params) => {
|
||||
return this.mineMeanings(params)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-create-story", async (event, params) => {
|
||||
return this.createStory(params)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"web-api-extract-vocabulary-from-story",
|
||||
async (event, storyId) => {
|
||||
return this.extractVocabularyFromStory(storyId)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"web-api-story-meanings",
|
||||
async (event, storyId, params) => {
|
||||
return this.storyMeanings(storyId, params)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle("web-api-stories", async (event, params) => {
|
||||
return this.stories(params)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-story", async (event, id) => {
|
||||
return this.story(id)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-mine-stories", async (event, params) => {
|
||||
return this.mineStories(params)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-star-story", async (event, id) => {
|
||||
return this.starStory(id)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-unstar-story", async (event, id) => {
|
||||
return this.unstarStory(id)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new WebApi();
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ class Youtubedr {
|
||||
this.getYtVideoId(url);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.warn(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Link } from "react-router-dom";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
|
||||
export const AudioCard = (props: {
|
||||
audio: AudioType;
|
||||
audio: Partial<AudioType>;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { audio, className } = props;
|
||||
|
||||
@@ -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<AudioType | null>(null);
|
||||
const [transcription, setTranscription] = useState<TranscriptionType>(null);
|
||||
const [initialized, setInitialized] = useState<boolean>(false);
|
||||
const [sharing, setSharing] = useState<boolean>(false);
|
||||
|
||||
// Player controls
|
||||
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||
@@ -43,6 +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)}
|
||||
/>
|
||||
|
||||
<ScrollArea className={`flex-1 relative bg-muted`}>
|
||||
@@ -146,6 +193,23 @@ export const AudioDetail = (props: { id?: string; md5?: string }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={sharing} onOpenChange={(value) => setSharing(value)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("shareAudio")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("areYouSureToShareThisAudioToCommunity")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<Button variant="default" onClick={handleShare}>
|
||||
{t("share")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{!initialized && (
|
||||
<div className="top-0 w-full h-full absolute z-30 bg-white/10 flex items-center justify-center">
|
||||
<LoaderIcon className="text-muted-foreground animate-spin w-8 h-8" />
|
||||
|
||||
@@ -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 <LoaderSpin />;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
|
||||
<AddMediaButton />
|
||||
@@ -135,6 +167,14 @@ export const AudiosComponent = () => {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{offset > -1 && (
|
||||
<div className="flex items-center justify-center my-4">
|
||||
<Button variant="link" onClick={fetchAudios}>
|
||||
{t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={!!editing}
|
||||
onOpenChange={(value) => {
|
||||
|
||||
@@ -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<ConversationType[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(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 <LoaderSpin />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea>
|
||||
{conversations.map((conversation) => {
|
||||
return (
|
||||
<div
|
||||
key={conversation.id}
|
||||
onClick={() => 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,
|
||||
}}
|
||||
>
|
||||
<div className="">
|
||||
<MessageCircleIcon className="mr-2" />
|
||||
</div>
|
||||
<div className="flex-1 truncated">{conversation.name}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { PitchContour } from "@renderer/components";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
import { Button } from "@renderer/components/ui";
|
||||
import { Button, Skeleton } from "@renderer/components/ui";
|
||||
import { PlayIcon, PauseIcon } from "lucide-react";
|
||||
import { useIntersectionObserver } from "@uidotdev/usehooks";
|
||||
import { secondsToTimestamp } from "@renderer/lib/utils";
|
||||
@@ -18,6 +18,7 @@ export const SpeechPlayer = (props: {
|
||||
threshold: 1,
|
||||
});
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
const onPlayClick = useCallback(() => {
|
||||
wavesurfer.isPlaying() ? wavesurfer.pause() : wavesurfer.play();
|
||||
@@ -69,6 +70,7 @@ export const SpeechPlayer = (props: {
|
||||
height,
|
||||
})
|
||||
);
|
||||
setInitialized(true);
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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]"
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
{!initialized && (
|
||||
<div className="col-span-9 flex flex-col justify-around h-[80px]">
|
||||
<Skeleton className="h-3 w-full rounded-full" />
|
||||
<Skeleton className="h-3 w-full rounded-full" />
|
||||
<Skeleton className="h-3 w-full rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex justify-center ${initialized ? "" : "hidden"}`}>
|
||||
<Button
|
||||
onClick={onPlayClick}
|
||||
className="aspect-square rounded-full p-2 w-12 h-12 bg-blue-600 hover:bg-blue-500"
|
||||
@@ -102,7 +112,10 @@ export const SpeechPlayer = (props: {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="col-span-8" ref={containerRef}></div>
|
||||
<div
|
||||
className={`col-span-8 ${initialized ? "" : "hidden"}`}
|
||||
ref={containerRef}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,9 @@ export * from "./videos";
|
||||
|
||||
export * from "./medias";
|
||||
|
||||
export * from "./posts";
|
||||
export * from "./users";
|
||||
|
||||
export * from "./db-state";
|
||||
|
||||
export * from "./layout";
|
||||
|
||||
@@ -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 (
|
||||
<div className="w-full max-w-sm px-6 flex flex-col space-y-4">
|
||||
|
||||
@@ -18,7 +18,7 @@ export const LookupResult = (props: {
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
if (!word) return null;
|
||||
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const lookup = (retries = 0) => {
|
||||
if (!word) return;
|
||||
@@ -28,7 +28,7 @@ export const LookupResult = (props: {
|
||||
}
|
||||
|
||||
retries += 1;
|
||||
EnjoyApp.webApi
|
||||
webApi
|
||||
.lookup({
|
||||
word,
|
||||
context,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
MinimizeIcon,
|
||||
GalleryHorizontalIcon,
|
||||
SpellCheckIcon,
|
||||
Share2Icon,
|
||||
} from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { type WaveSurferOptions } from "wavesurfer.js";
|
||||
@@ -24,7 +25,6 @@ import { Tooltip } from "react-tooltip";
|
||||
const PLAYBACK_RATE_OPTIONS = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75];
|
||||
const MIN_ZOOM_RATIO = 0.25;
|
||||
const MAX_ZOOM_RATIO = 5.0;
|
||||
const ZOOM_RATIO_STEP = 0.25;
|
||||
|
||||
export const MediaPlayerControls = (props: {
|
||||
isPlaying: boolean;
|
||||
@@ -47,6 +47,7 @@ export const MediaPlayerControls = (props: {
|
||||
setWavesurferOptions?: (options: Partial<WaveSurferOptions>) => void;
|
||||
displayInlineCaption?: boolean;
|
||||
setDisplayInlineCaption?: (display: boolean) => void;
|
||||
onShare?: () => void;
|
||||
}) => {
|
||||
const {
|
||||
isPlaying,
|
||||
@@ -67,6 +68,7 @@ export const MediaPlayerControls = (props: {
|
||||
setWavesurferOptions,
|
||||
displayInlineCaption,
|
||||
setDisplayInlineCaption,
|
||||
onShare,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@@ -244,20 +246,32 @@ export const MediaPlayerControls = (props: {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{transcriptionDirty && (
|
||||
<div className="absolute right-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className=""
|
||||
onClick={resetTranscription}
|
||||
>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
<Button onClick={saveTranscription}>{t("save")}</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-tooltip-id="media-player-controls-tooltip"
|
||||
data-tooltip-content={t("share")}
|
||||
className="relative aspect-square p-0 h-10"
|
||||
onClick={onShare}
|
||||
>
|
||||
<Share2Icon className="w-6 h-6" />
|
||||
</Button>
|
||||
|
||||
<div className="absolute right-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
{transcriptionDirty && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className=""
|
||||
onClick={resetTranscription}
|
||||
>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
<Button onClick={saveTranscription}>{t("save")}</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tooltip id="media-player-controls-tooltip" />
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -94,14 +94,14 @@ export const AssistantMessageComponent = (props: {
|
||||
<AvatarImage></AvatarImage>
|
||||
<AvatarFallback className="bg-white">AI</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-2 px-4 py-2 bg-white border rounded-lg shadow-sm w-full prose max-w-prose">
|
||||
<div className="flex flex-col gap-2 px-4 py-2 bg-white border rounded-lg shadow-sm w-full">
|
||||
{configuration?.autoSpeech && speeching ? (
|
||||
<div className="p-4">
|
||||
<LoaderIcon className="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Markdown
|
||||
className="select-text"
|
||||
className="select-text prose"
|
||||
components={{
|
||||
a({ node, children, ...props }) {
|
||||
try {
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogHeader,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
useToast,
|
||||
} from "@renderer/components/ui";
|
||||
import { SpeechPlayer } from "@renderer/components";
|
||||
import { useContext, useState } from "react";
|
||||
@@ -17,6 +28,7 @@ import {
|
||||
AlertCircleIcon,
|
||||
CopyIcon,
|
||||
CheckIcon,
|
||||
Share2Icon,
|
||||
} from "lucide-react";
|
||||
import { useCopyToClipboard } from "@uidotdev/usehooks";
|
||||
import { t } from "i18next";
|
||||
@@ -30,9 +42,34 @@ export const UserMessageComponent = (props: {
|
||||
}) => {
|
||||
const { message, onResend, onRemove } = props;
|
||||
const speech = message.speeches?.[0];
|
||||
const { user } = useContext(AppSettingsProviderContext);
|
||||
const { user, webApi } = useContext(AppSettingsProviderContext);
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
const { 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 (
|
||||
<div
|
||||
@@ -41,7 +78,7 @@ export const UserMessageComponent = (props: {
|
||||
>
|
||||
<DropdownMenu>
|
||||
<div className="flex flex-col gap-2 px-4 py-2 bg-sky-500/30 border-sky-500 rounded-lg shadow-sm w-full">
|
||||
<Markdown className="select-text">{message.content}</Markdown>
|
||||
<Markdown className="select-text prose">{message.content}</Markdown>
|
||||
|
||||
{Boolean(speech) && <SpeechPlayer speech={speech} />}
|
||||
|
||||
@@ -81,6 +118,34 @@ export const UserMessageComponent = (props: {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{message.createdAt && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Share2Icon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("share")}
|
||||
className="w-3 h-3 cursor-pointer"
|
||||
/>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("sharePrompt")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("areYouSureToShareThisPromptToCommunity")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button variant="default" onClick={handleShare}>
|
||||
{t("share")}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuContent>
|
||||
|
||||
9
enjoy/src/renderer/components/posts/index.ts
Normal file
9
enjoy/src/renderer/components/posts/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from "./posts";
|
||||
export * from "./post-audio";
|
||||
export * from "./post-card";
|
||||
export * from "./post-medium";
|
||||
export * from "./post-recording";
|
||||
export * from "./post-story";
|
||||
|
||||
export * from "./post-options";
|
||||
export * from "./post-actions";
|
||||
215
enjoy/src/renderer/components/posts/post-actions.tsx
Normal file
215
enjoy/src/renderer/components/posts/post-actions.tsx
Normal file
@@ -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<boolean>(false);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { toast } = useToast();
|
||||
const [asking, setAsking] = useState<boolean>(false);
|
||||
const [aiReplies, setAiReplies] = useState<MessageType[]>([]);
|
||||
|
||||
const handleAddMedium = async () => {
|
||||
if (post.targetType !== "Medium") return;
|
||||
const medium = post.target as MediumType;
|
||||
if (!medium) return;
|
||||
|
||||
if (medium.mediumType === "Video") {
|
||||
try {
|
||||
const video = await EnjoyApp.videos.findOne({ md5: medium.md5 });
|
||||
if (video) {
|
||||
toast({
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center space-x-2 justify-end">
|
||||
{post.target && post.targetType === "Medium" && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("addToLibary")}
|
||||
data-tooltip-place="bottom"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="px-1.5 rounded-full"
|
||||
>
|
||||
<PlusCircleIcon className="w-5 h-5 text-muted-foreground hover:text-primary" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("addRecourse")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{(post.target as MediumType).mediumType === "Video" &&
|
||||
t("areYouSureToAddThisVideoToYourLibrary")}
|
||||
|
||||
{(post.target as MediumType).mediumType === "Audio" &&
|
||||
t("areYouSureToAddThisAudioToYourLibrary")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleAddMedium}>
|
||||
{t("confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
{typeof post.metadata?.content === "string" && (
|
||||
<Button
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("copy")}
|
||||
data-tooltip-place="bottom"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="px-1.5 rounded-full"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<CopyPlusIcon
|
||||
className="w-5 h-5 text-muted-foreground hover:text-primary"
|
||||
onClick={() => {
|
||||
copyToClipboard(post.metadata.content as string);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 3000);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{post.metadata?.type === "prompt" && (
|
||||
<Dialog open={asking} onOpenChange={setAsking}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("sendToAIAssistant")}
|
||||
data-tooltip-place="bottom"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="px-1.5 rounded-full"
|
||||
>
|
||||
<BotIcon className="w-5 h-5 text-muted-foreground hover:text-primary" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sendToAIAssistant")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ConversationsShortcut
|
||||
prompt={post.metadata.content as string}
|
||||
onReply={(replies) => {
|
||||
setAiReplies([...aiReplies, ...replies]);
|
||||
setAsking(false);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<ScrollArea></ScrollArea>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{aiReplies.length > 0 && <AIReplies replies={aiReplies} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AIReplies = (props: { replies: MessageType[] }) => {
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
{props.replies.map((reply) => (
|
||||
<div key={reply.id} className="bg-muted py-2 px-4 rounded">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<BotIcon className="w-5 h-5 text-blue-500" />
|
||||
<Link to={`/conversations/${reply.conversationId}`}>
|
||||
<ChevronRightIcon className="w-5 h-5 text-muted-foreground" />
|
||||
</Link>
|
||||
</div>
|
||||
<Markdown className="prose select-text">{reply.content}</Markdown>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
160
enjoy/src/renderer/components/posts/post-audio.tsx
Normal file
160
enjoy/src/renderer/components/posts/post-audio.tsx
Normal file
@@ -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<MediumType>;
|
||||
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<number>(0);
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||
const [transcription, setTranscription] = useState<TranscriptionType>();
|
||||
|
||||
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 (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{secondsToTimestamp(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={ref}
|
||||
className="bg-white rounded-lg grid grid-cols-9 items-center relative h-[80px]"
|
||||
>
|
||||
{!initialized && (
|
||||
<div className="col-span-9 flex flex-col justify-around h-[80px]">
|
||||
<Skeleton className="h-3 w-full rounded-full" />
|
||||
<Skeleton className="h-3 w-full rounded-full" />
|
||||
<Skeleton className="h-3 w-full rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex justify-center ${initialized ? "" : "hidden"}`}>
|
||||
<Button
|
||||
onClick={onPlayClick}
|
||||
className="aspect-square rounded-full p-2 w-12 h-12 bg-blue-600 hover:bg-blue-500"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<PauseIcon className="w-6 h-6 text-white" />
|
||||
) : (
|
||||
<PlayIcon className="w-6 h-6 text-white" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`col-span-8 ${initialized ? "" : "hidden"}`}
|
||||
ref={containerRef}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{currentTranscription && (
|
||||
<div className="mt-2 bg-muted px-4 py-2 rounded">
|
||||
<div className="text-muted-foreground text-center font-serif">
|
||||
{currentTranscription.text}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{audio.coverUrl && (
|
||||
<div className="">
|
||||
<img src={audio.coverUrl} className="w-full rounded" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
81
enjoy/src/renderer/components/posts/post-card.tsx
Normal file
81
enjoy/src/renderer/components/posts/post-card.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useContext } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import {
|
||||
PostRecording,
|
||||
PostActions,
|
||||
PostMedium,
|
||||
PostStory,
|
||||
PostOptions,
|
||||
} from "@renderer/components";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@renderer/components/ui";
|
||||
import { formatDateTime } from "@renderer/lib/utils";
|
||||
import { t } from "i18next";
|
||||
import Markdown from "react-markdown";
|
||||
|
||||
export const PostCard = (props: {
|
||||
post: PostType;
|
||||
handleDelete: (id: string) => void;
|
||||
}) => {
|
||||
const { post, handleDelete } = props;
|
||||
const { user } = useContext(AppSettingsProviderContext);
|
||||
|
||||
return (
|
||||
<div className="rounded p-4 bg-white space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Avatar>
|
||||
<AvatarImage src={post.user.avatarUrl} />
|
||||
<AvatarFallback className="text-xl">
|
||||
{post.user.name[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col justify-between">
|
||||
<div className="">{post.user.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDateTime(post.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{post.user.id == user.id && (
|
||||
<PostOptions handleDelete={() => handleDelete(post.id)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{post.metadata?.type === "prompt" && (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("sharedPrompt")}
|
||||
</div>
|
||||
<Markdown className="prose prose-slate prose-pre:whitespace-normal select-text">
|
||||
{"```prompt\n" + post.metadata.content + "\n```"}
|
||||
</Markdown>
|
||||
</>
|
||||
)}
|
||||
|
||||
{post.targetType == "Medium" && (
|
||||
<PostMedium medium={post.target as MediumType} />
|
||||
)}
|
||||
|
||||
{post.targetType == "Recording" && (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("sharedRecording")}
|
||||
</div>
|
||||
<PostRecording recording={post.target as RecordingType} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{post.targetType == "Story" && (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("sharedStory")}
|
||||
</div>
|
||||
<PostStory story={post.target as StoryType} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<PostActions post={post} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
45
enjoy/src/renderer/components/posts/post-medium.tsx
Normal file
45
enjoy/src/renderer/components/posts/post-medium.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { PostAudio } from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
import { MediaPlayer, MediaProvider } from "@vidstack/react";
|
||||
import {
|
||||
DefaultVideoLayout,
|
||||
defaultLayoutIcons,
|
||||
} from "@vidstack/react/player/layouts/default";
|
||||
|
||||
export const PostMedium = (props: { medium: MediumType }) => {
|
||||
const { medium } = props;
|
||||
if (!medium.sourceUrl) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{medium.mediumType == "Video" && (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("sharedAudio")}
|
||||
</div>
|
||||
<MediaPlayer
|
||||
poster={medium.coverUrl}
|
||||
src={{
|
||||
type: `${medium.mediumType.toLowerCase()}/${
|
||||
medium.extname.replace(".", "") || "mp4"
|
||||
}`,
|
||||
src: medium.sourceUrl,
|
||||
}}
|
||||
>
|
||||
<MediaProvider />
|
||||
<DefaultVideoLayout icons={defaultLayoutIcons} />
|
||||
</MediaPlayer>
|
||||
</>
|
||||
)}
|
||||
|
||||
{medium.mediumType == "Audio" && (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("sharedAudio")}
|
||||
</div>
|
||||
<PostAudio audio={medium as Partial<AudioType>} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
63
enjoy/src/renderer/components/posts/post-options.tsx
Normal file
63
enjoy/src/renderer/components/posts/post-options.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@renderer/components/ui";
|
||||
import { MoreHorizontalIcon, Trash2Icon } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const PostOptions = (props: { handleDelete: () => void }) => {
|
||||
const { handleDelete } = props;
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontalIcon className="w-4 h-4" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className="cursor-pointer" onClick={() => setDeleting(true)}>
|
||||
<span className="text-sm mr-auto text-destructive capitalize">
|
||||
{t("delete")}
|
||||
</span>
|
||||
<Trash2Icon className="w-4 h-4 text-destructive" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<AlertDialog open={deleting} onOpenChange={setDeleting}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("removeSharing")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("areYouSureToRemoveThisSharing")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
handleDelete();
|
||||
setDeleting(false);
|
||||
}}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
133
enjoy/src/renderer/components/posts/post-recording.tsx
Normal file
133
enjoy/src/renderer/components/posts/post-recording.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { PitchContour } from "@renderer/components";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
import { Button, Skeleton } from "@renderer/components/ui";
|
||||
import { PlayIcon, PauseIcon } from "lucide-react";
|
||||
import { useIntersectionObserver } from "@uidotdev/usehooks";
|
||||
import { secondsToTimestamp } from "@renderer/lib/utils";
|
||||
|
||||
export const PostRecording = (props: {
|
||||
recording: RecordingType;
|
||||
height?: number;
|
||||
}) => {
|
||||
const { recording, height = 80 } = props;
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [wavesurfer, setWavesurfer] = useState(null);
|
||||
const containerRef = useRef();
|
||||
const [ref, entry] = useIntersectionObserver({
|
||||
threshold: 1,
|
||||
});
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
|
||||
const onPlayClick = useCallback(() => {
|
||||
wavesurfer.isPlaying() ? wavesurfer.pause() : wavesurfer.play();
|
||||
}, [wavesurfer]);
|
||||
|
||||
useEffect(() => {
|
||||
// use the intersection observer to only create the wavesurfer instance
|
||||
// when the player is visible
|
||||
if (!entry?.isIntersecting) return;
|
||||
if (!recording.src) return;
|
||||
if (wavesurfer) return;
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container: containerRef.current,
|
||||
url: recording.src,
|
||||
height,
|
||||
barWidth: 1,
|
||||
cursorWidth: 0,
|
||||
autoCenter: true,
|
||||
autoScroll: true,
|
||||
dragToSeek: true,
|
||||
hideScrollbar: true,
|
||||
minPxPerSec: 100,
|
||||
waveColor: "rgba(0, 0, 0, 0.25)",
|
||||
progressColor: "rgba(0, 0, 0, 0.5)",
|
||||
});
|
||||
|
||||
setWavesurfer(ws);
|
||||
}, [recording.src, entry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wavesurfer) return;
|
||||
|
||||
const subscriptions = [
|
||||
wavesurfer.on("play", () => {
|
||||
setIsPlaying(true);
|
||||
}),
|
||||
wavesurfer.on("pause", () => {
|
||||
setIsPlaying(false);
|
||||
}),
|
||||
wavesurfer.on("decode", () => {
|
||||
setDuration(wavesurfer.getDuration());
|
||||
const peaks = wavesurfer.getDecodedData().getChannelData(0);
|
||||
const sampleRate = wavesurfer.options.sampleRate;
|
||||
wavesurfer.renderer.getWrapper().appendChild(
|
||||
PitchContour({
|
||||
peaks,
|
||||
sampleRate,
|
||||
height,
|
||||
})
|
||||
);
|
||||
setInitialized(true);
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
subscriptions.forEach((unsub) => unsub());
|
||||
wavesurfer?.destroy();
|
||||
};
|
||||
}, [wavesurfer]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{secondsToTimestamp(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={ref}
|
||||
className="bg-sky-500/30 rounded-lg grid grid-cols-9 items-center relative h-[80px]"
|
||||
>
|
||||
{!initialized && (
|
||||
<div className="col-span-9 flex flex-col justify-around h-[80px]">
|
||||
<Skeleton className="h-2 w-full rounded-full" />
|
||||
<Skeleton className="h-2 w-full rounded-full" />
|
||||
<Skeleton className="h-2 w-full rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex justify-center ${initialized ? "" : "hidden"}`}>
|
||||
<Button
|
||||
onClick={onPlayClick}
|
||||
className="aspect-square rounded-full p-2 w-12 h-12 bg-blue-600 hover:bg-blue-500"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<PauseIcon className="w-6 h-6 text-white" />
|
||||
) : (
|
||||
<PlayIcon className="w-6 h-6 text-white" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`col-span-8 ${initialized ? "" : "hidden"}`}
|
||||
ref={containerRef}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{
|
||||
recording.referenceText && (
|
||||
<div className="mt-2 bg-muted px-4 py-2 rounded">
|
||||
<div className="text-muted-foreground text-center font-serif">
|
||||
{recording.referenceText}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
25
enjoy/src/renderer/components/posts/post-story.tsx
Normal file
25
enjoy/src/renderer/components/posts/post-story.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export const PostStory = (props: { story: StoryType }) => {
|
||||
const { story } = props;
|
||||
return (
|
||||
<Link className="block" to={`/stories/${story.id}`}>
|
||||
<div className="rounded-lg flex items-start border">
|
||||
<div className="aspect-square h-36 bg-muted">
|
||||
<img
|
||||
src={story.metadata?.image}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 py-2">
|
||||
<div className="line-clamp-2 text-lg font-semibold mb-2">
|
||||
{story.metadata?.title}
|
||||
</div>
|
||||
<div className="line-clamp-3 text-sm text-muted-foreground">
|
||||
{story.metadata?.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
84
enjoy/src/renderer/components/posts/posts.tsx
Normal file
84
enjoy/src/renderer/components/posts/posts.tsx
Normal file
@@ -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<boolean>(true);
|
||||
const [posts, setPosts] = useState<PostType[]>([]);
|
||||
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 <LoaderSpin />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-screen-sm mx-auto">
|
||||
{posts.length === 0 && (
|
||||
<div className="text-center text-gray-500">{t("noOneSharedYet")}</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} handleDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{nextPage && (
|
||||
<div className="py-4 flex justify-center">
|
||||
<Button variant="link" onClick={() => fetchPosts(nextPage)}>
|
||||
{t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<div id={id} className="flex items-center justify-end px-4 transition-all">
|
||||
<DropdownMenu>
|
||||
<div className="w-full">
|
||||
<div className="bg-white rounded-lg py-2 px-4 relative mb-1">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{secondsToTimestamp(recording.duration / 1000)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="bg-white rounded-lg py-2 px-4 relative mb-1">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{secondsToTimestamp(recording.duration / 1000)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<RecordingPlayer
|
||||
recording={recording}
|
||||
isPlaying={isPlaying}
|
||||
setIsPlaying={setIsPlaying}
|
||||
/>
|
||||
<RecordingPlayer
|
||||
recording={recording}
|
||||
isPlaying={isPlaying}
|
||||
setIsPlaying={setIsPlaying}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
onClick={onSelect}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-6"
|
||||
>
|
||||
<InfoIcon
|
||||
className={`w-4 h-4
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("pronunciationAssessment")}
|
||||
data-tooltip-place="bottom"
|
||||
onClick={onSelect}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-6"
|
||||
>
|
||||
<GaugeCircleIcon
|
||||
className={`w-4 h-4
|
||||
${
|
||||
recording.pronunciationAssessment
|
||||
? recording.pronunciationAssessment
|
||||
@@ -71,29 +113,60 @@ export const RecordingCard = (props: {
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</Button>
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("share")}
|
||||
data-tooltip-place="bottom"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-6"
|
||||
>
|
||||
<Share2Icon className="w-4 h-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("shareRecording")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("areYouSureToShareThisRecordingToCommunity")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button onClick={handleShare}>{t("share")}</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<ChevronDownIcon className="w-4 h-4 text-muted-foreground" />
|
||||
<MoreHorizontalIcon className="w-4 h-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDateTime(recording.createdAt)}
|
||||
</span>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setIsDeleteDialogOpen(true)}>
|
||||
<span className="mr-auto text-destructive capitalize">
|
||||
{t("delete")}
|
||||
</span>
|
||||
<Trash2Icon className="w-4 h-4 text-destructive" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setIsDeleteDialogOpen(true)}>
|
||||
<span className="mr-auto text-destructive capitalize">
|
||||
{t("delete")}
|
||||
</span>
|
||||
<Trash2Icon className="w-4 h-4 text-destructive" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDateTime(recording.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={isDeleteDialogOpen}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
import { PitchContour } from "@renderer/components";
|
||||
import { Button } from "@renderer/components/ui";
|
||||
import { Button, Skeleton } from "@renderer/components/ui";
|
||||
import { PlayIcon, PauseIcon } from "lucide-react";
|
||||
import { useIntersectionObserver } from "@uidotdev/usehooks";
|
||||
|
||||
@@ -30,6 +30,7 @@ export const RecordingPlayer = (props: {
|
||||
const [ref, entry] = useIntersectionObserver({
|
||||
threshold: 0,
|
||||
});
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
const onPlayClick = useCallback(() => {
|
||||
wavesurfer.isPlaying() ? wavesurfer.pause() : wavesurfer.play();
|
||||
@@ -40,6 +41,7 @@ export const RecordingPlayer = (props: {
|
||||
// when the player is visible
|
||||
if (!entry?.isIntersecting) return;
|
||||
if (!recording?.src) return;
|
||||
if (wavesurfer) return;
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container: containerRef.current,
|
||||
@@ -78,6 +80,7 @@ export const RecordingPlayer = (props: {
|
||||
height,
|
||||
})
|
||||
);
|
||||
setInitialized(true);
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -105,7 +108,15 @@ export const RecordingPlayer = (props: {
|
||||
|
||||
return (
|
||||
<div ref={ref} className="grid grid-cols-11 xl:grid-cols-12 items-center">
|
||||
<div className="flex justify-center">
|
||||
{!initialized && (
|
||||
<div className="col-span-9 flex flex-col justify-around h-[80px]">
|
||||
<Skeleton className="h-3 w-full rounded-full" />
|
||||
<Skeleton className="h-3 w-full rounded-full" />
|
||||
<Skeleton className="h-3 w-full rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex justify-center ${initialized ? "" : "hidden"}`}>
|
||||
<Button
|
||||
onClick={onPlayClick}
|
||||
className="aspect-square rounded-full p-2 w-12 h-12 bg-blue-600 hover:bg-blue-500"
|
||||
@@ -118,7 +129,10 @@ export const RecordingPlayer = (props: {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="col-span-10 xl:col-span-11" ref={containerRef}></div>
|
||||
<div
|
||||
className={`col-span-10 xl:col-span-11 ${initialized ? "" : "hidden"}`}
|
||||
ref={containerRef}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
BookMarkedIcon,
|
||||
UserIcon,
|
||||
BotIcon,
|
||||
UsersRoundIcon,
|
||||
} from "lucide-react";
|
||||
import { useLocation, Link } from "react-router-dom";
|
||||
import { t } from "i18next";
|
||||
@@ -50,6 +51,21 @@ export const Sidebar = () => {
|
||||
<span className="hidden xl:block">{t("sidebar.home")}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/community"
|
||||
data-tooltip-id="sidebar-tooltip"
|
||||
data-tooltip-content={t("sidebar.community")}
|
||||
className="block"
|
||||
>
|
||||
<Button
|
||||
variant={activeTab === "" ? "secondary" : "ghost"}
|
||||
className="w-full xl:justify-start"
|
||||
>
|
||||
<UsersRoundIcon className="xl:mr-2 h-5 w-5" />
|
||||
<span className="hidden xl:block">{t("sidebar.community")}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ import { AppSettingsProviderContext } from "@renderer/context";
|
||||
|
||||
export const StoriesSegment = () => {
|
||||
const [stories, setStorys] = useState<StoryType[]>([]);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const fetchStorys = async () => {
|
||||
EnjoyApp.webApi.mineStories().then((response) => {
|
||||
webApi.mineStories().then((response) => {
|
||||
if (response?.stories) {
|
||||
setStorys(response.stories);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,16 @@ import {
|
||||
Alert,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
Button,
|
||||
ScrollArea,
|
||||
Separator,
|
||||
Sheet,
|
||||
@@ -17,6 +27,7 @@ import {
|
||||
ScanTextIcon,
|
||||
LoaderIcon,
|
||||
StarIcon,
|
||||
Share2Icon,
|
||||
} from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
|
||||
@@ -36,6 +47,7 @@ export const StoryToolbar = (props: {
|
||||
marked?: boolean;
|
||||
toggleMarked?: () => void;
|
||||
pendingLookups?: LookupType[];
|
||||
handleShare?: () => void;
|
||||
}) => {
|
||||
const {
|
||||
starred,
|
||||
@@ -47,6 +59,7 @@ export const StoryToolbar = (props: {
|
||||
toggleMarked,
|
||||
meanings = [],
|
||||
pendingLookups = [],
|
||||
handleShare,
|
||||
} = props;
|
||||
|
||||
const [vocabularyVisible, setVocabularyVisible] = useState<boolean>(
|
||||
@@ -76,6 +89,27 @@ export const StoryToolbar = (props: {
|
||||
<ToolbarButton toggled={starred} onClick={toggleStarred}>
|
||||
<StarIcon className="w-6 h-6" />
|
||||
</ToolbarButton>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<ToolbarButton toggled={false} onClick={toggleStarred}>
|
||||
<Share2Icon className="w-6 h-6" />
|
||||
</ToolbarButton>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("shareStory")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("areYouSureToShareThisStoryToCommunity")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction>
|
||||
<Button onClick={handleShare}>{t("share")}</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</FloatingToolbar>
|
||||
|
||||
<Sheet
|
||||
|
||||
1
enjoy/src/renderer/components/users/index.ts
Normal file
1
enjoy/src/renderer/components/users/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './users-rankings';
|
||||
83
enjoy/src/renderer/components/users/users-rankings.tsx
Normal file
83
enjoy/src/renderer/components/users/users-rankings.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
Card,
|
||||
CardTitle,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
} from "@renderer/components/ui";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
import { formatDuration } from "@renderer/lib/utils";
|
||||
|
||||
export const UsersRankings = () => {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
<RankingsCard range="day" />
|
||||
<RankingsCard range="week" />
|
||||
<RankingsCard range="month" />
|
||||
<RankingsCard range="all" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RankingsCard = (props: {
|
||||
range: "day" | "week" | "month" | "year" | "all";
|
||||
}) => {
|
||||
const { range } = props;
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [rankings, setRankings] = useState<UserType[]>([]);
|
||||
|
||||
const fetchRankings = async () => {
|
||||
webApi.rankings(range).then(
|
||||
(res) => {
|
||||
setRankings(res.rankings);
|
||||
},
|
||||
(err) => {
|
||||
console.error(err);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRankings();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t(`${range}Rankings`)}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{rankings.length === 0 && (
|
||||
<div className="text-center text-gray-500">
|
||||
{t("noOneHasRecordedYet")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rankings.map((user, index) => (
|
||||
<div key={user.id} className="flex items-center space-x-4 p-2">
|
||||
<div className="font-mono text-sm">#{index + 1}</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarImage src={user.avatarUrl} />
|
||||
<AvatarFallback className="text-xl">
|
||||
{user.name[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="max-w-20 truncate">{user.name}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 font-serif text-right">
|
||||
{formatDuration(user.recordingsDuration, "millisecond")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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<VideoType | null>(null);
|
||||
const [transcription, setTranscription] = useState<TranscriptionType>(null);
|
||||
const [initialized, setInitialized] = useState<boolean>(false);
|
||||
const [sharing, setSharing] = useState<boolean>(false);
|
||||
|
||||
// Player controls
|
||||
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||
@@ -35,6 +49,8 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isLooping, setIsLooping] = useState(false);
|
||||
const [playBackRate, setPlaybackRate] = useState<number>(1);
|
||||
const [displayInlineCaption, setDisplayInlineCaption] =
|
||||
useState<boolean>(true);
|
||||
|
||||
const onTransactionUpdate = (event: CustomEvent) => {
|
||||
const { model, action, record } = event.detail || {};
|
||||
@@ -43,6 +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)}
|
||||
/>
|
||||
|
||||
<ScrollArea
|
||||
@@ -149,6 +208,23 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={sharing} onOpenChange={(value) => setSharing(value)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("shareAudio")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("areYouSureToShareThisAudioToCommunity")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<Button variant="default" onClick={handleShare}>
|
||||
{t("share")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{!initialized && (
|
||||
<div className="top-0 w-full h-full absolute z-30 bg-white/10 flex items-center justify-center">
|
||||
<LoaderIcon className="text-muted-foreground animate-spin w-8 h-8" />
|
||||
|
||||
@@ -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 <LoaderSpin />;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
|
||||
<AddMediaButton />
|
||||
@@ -135,6 +167,14 @@ export const VideosComponent = () => {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{offset > -1 && (
|
||||
<div className="flex items-center justify-center my-4">
|
||||
<Button variant="link" onClick={fetchVideos}>
|
||||
{t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={!!editing}
|
||||
onOpenChange={(value) => {
|
||||
|
||||
@@ -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<boolean>(false);
|
||||
const [version, setVersion] = useState<string>("");
|
||||
const [apiUrl, setApiUrl] = useState<string>(WEB_API_URL);
|
||||
const [webApi, setWebApi] = useState<Client>(null);
|
||||
const [user, setUser] = useState<UserType | null>(null);
|
||||
const [libraryPath, setLibraryPath] = useState("");
|
||||
const [whisperModelsPath, setWhisperModelsPath] = useState<string>("");
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
49
enjoy/src/renderer/pages/community.tsx
Normal file
49
enjoy/src/renderer/pages/community.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Button,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsContent,
|
||||
TabsTrigger,
|
||||
} from "@renderer/components/ui";
|
||||
import { UsersRankings, Posts } from "@renderer/components";
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { t } from "i18next";
|
||||
|
||||
export default () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="bg-muted h-full px-4 lg:px-8 py-6">
|
||||
<div className="max-w-screen-md mx-auto mb-6">
|
||||
<div className="flex space-x-1 items-center mb-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<span>{t("sidebar.community")}</span>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="square">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="square">{t("square")}</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="rankings"
|
||||
disabled
|
||||
className="cursor-not-allowed"
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("comingSoon")}
|
||||
>
|
||||
{t("rankings")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="square">
|
||||
<Posts />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rankings"></TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<string>("");
|
||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const [messages, dispatchMessages] = useReducer(messagesReducer, []);
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { Button } from "@renderer/components/ui";
|
||||
import { StoryForm, StoryCard, LoaderSpin } from "@renderer/components";
|
||||
import { useState, useContext, useEffect } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
|
||||
export default () => {
|
||||
const [stories, setStorys] = useState<StoryType[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [nextPage, setNextPage] = useState(1);
|
||||
|
||||
const fetchStorys = async () => {
|
||||
EnjoyApp.webApi
|
||||
const fetchStories = async (page: number = nextPage) => {
|
||||
if (!page) return;
|
||||
|
||||
webApi
|
||||
.mineStories()
|
||||
.then((response) => {
|
||||
if (response?.stories) {
|
||||
setStorys(response.stories);
|
||||
setStorys([...stories, ...response.stories]);
|
||||
}
|
||||
setNextPage(response.next);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
@@ -21,7 +27,7 @@ export default () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStorys();
|
||||
fetchStories();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -38,6 +44,14 @@ export default () => {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nextPage && (
|
||||
<div className="py-4 flex justify-center">
|
||||
<Button variant="link" onClick={() => fetchStories(nextPage)}>
|
||||
{t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<MeaningType[]>([]);
|
||||
const [marked, setMarked] = useState<boolean>(false);
|
||||
@@ -52,7 +52,7 @@ export default () => {
|
||||
const createStory = async () => {
|
||||
if (!story) return;
|
||||
|
||||
EnjoyApp.webApi
|
||||
webApi
|
||||
.createStory({
|
||||
url: story.metadata?.url || story.url,
|
||||
...story,
|
||||
|
||||
@@ -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<boolean>(true);
|
||||
const [story, setStory] = useState<StoryType>();
|
||||
const [meanings, setMeanings] = useState<MeaningType[]>([]);
|
||||
@@ -24,9 +24,10 @@ export default () => {
|
||||
const [scanning, setScanning] = useState<boolean>(false);
|
||||
const [marked, setMarked] = useState<boolean>(true);
|
||||
const [doc, setDoc] = useState<any>(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}
|
||||
/>
|
||||
|
||||
<StoryViewer
|
||||
|
||||
@@ -11,14 +11,14 @@ export default () => {
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [meanings, setMeanings] = useState<MeaningType[]>([]);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [currentIndex, setCurrentIndex] = useState<number>(0);
|
||||
const [nextPage, setNextPage] = useState(1);
|
||||
|
||||
const fetchMeanings = async (page: number = nextPage) => {
|
||||
if (!page) return;
|
||||
|
||||
EnjoyApp.webApi
|
||||
webApi
|
||||
.mineMeanings({ page, items: 10 })
|
||||
.then((response) => {
|
||||
setMeanings([...meanings, ...response.meanings]);
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
export const audiosReducer = (
|
||||
audios: AudioType[],
|
||||
action: {
|
||||
type: "create" | "update" | "destroy" | "set";
|
||||
type: "append" | "create" | "update" | "destroy" | "set";
|
||||
record?: Partial<AudioType>;
|
||||
records?: Partial<AudioType>[];
|
||||
}
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "append": {
|
||||
if (action.record) {
|
||||
return [...audios, action.record];
|
||||
} else if (action.records) {
|
||||
return [...audios, ...action.records];
|
||||
}
|
||||
}
|
||||
case "create": {
|
||||
return [action.record, ...audios];
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
export const videosReducer = (
|
||||
videos: VideoType[],
|
||||
action: {
|
||||
type: "create" | "update" | "destroy" | "set";
|
||||
type: "append" | "create" | "update" | "destroy" | "set";
|
||||
record?: Partial<VideoType>;
|
||||
records?: Partial<VideoType>[];
|
||||
}
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "append": {
|
||||
if (action.record) {
|
||||
return [...videos, action.record];
|
||||
} else if (action.records) {
|
||||
return [...videos, ...action.records];
|
||||
}
|
||||
}
|
||||
case "create": {
|
||||
return [action.record, ...videos];
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import Story from "./pages/story";
|
||||
import Books from "./pages/books";
|
||||
import Profile from "./pages/profile";
|
||||
import Home from "./pages/home";
|
||||
import Community from "./pages/community";
|
||||
import StoryPreview from "./pages/story-preview";
|
||||
|
||||
export default createHashRouter([
|
||||
@@ -23,6 +24,10 @@ export default createHashRouter([
|
||||
errorElement: <ErrorPage />,
|
||||
children: [
|
||||
{ index: true, element: <Home /> },
|
||||
{
|
||||
path: "/community",
|
||||
element: <Community />,
|
||||
},
|
||||
{
|
||||
path: "/profile",
|
||||
element: <Profile />,
|
||||
|
||||
25
enjoy/src/types.d.ts
vendored
25
enjoy/src/types.d.ts
vendored
@@ -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;
|
||||
|
||||
1
enjoy/src/types/audio.d.ts
vendored
1
enjoy/src/types/audio.d.ts
vendored
@@ -11,6 +11,7 @@ type AudioType = {
|
||||
transcribing?: boolean;
|
||||
recordingsCount?: number;
|
||||
recordingsDuration?: number;
|
||||
isUploaded?: boolean;
|
||||
uploadedAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
78
enjoy/src/types/enjoy-app.d.ts
vendored
78
enjoy/src/types/enjoy-app.d.ts
vendored
@@ -93,7 +93,7 @@ type EnjoyAppType = {
|
||||
audios: {
|
||||
findAll: (params: object) => Promise<AudioType[]>;
|
||||
findOne: (params: object) => Promise<AudioType>;
|
||||
create: (source: string, params?: object) => Promise<AudioType>;
|
||||
create: (uri: string, params?: object) => Promise<AudioType>;
|
||||
update: (id: string, params: object) => Promise<AudioType | undefined>;
|
||||
destroy: (id: string) => Promise<undefined>;
|
||||
transcribe: (id: string) => Promise<void>;
|
||||
@@ -102,8 +102,8 @@ type EnjoyAppType = {
|
||||
videos: {
|
||||
findAll: (params: object) => Promise<VideoType[]>;
|
||||
findOne: (params: object) => Promise<VideoType>;
|
||||
create: (source: string, params?: object) => Promise<VideoType>;
|
||||
update: (id: string, params: object) => Promise<VideoType | undefined>;
|
||||
create: (uri: string, params?: any) => Promise<VideoType>;
|
||||
update: (id: string, params: any) => Promise<VideoType | undefined>;
|
||||
destroy: (id: string) => Promise<undefined>;
|
||||
transcribe: (id: string) => Promise<void>;
|
||||
upload: (id: string) => Promise<void>;
|
||||
@@ -143,9 +143,9 @@ type EnjoyAppType = {
|
||||
) => Promise<SegementRecordingStatsType>;
|
||||
};
|
||||
conversations: {
|
||||
findAll: (params: object) => Promise<ConversationType[]>;
|
||||
findOne: (params: object) => Promise<ConversationType>;
|
||||
create: (params: object) => Promise<ConversationType>;
|
||||
findAll: (params: any) => Promise<ConversationType[]>;
|
||||
findOne: (params: any) => Promise<ConversationType>;
|
||||
create: (params: any) => Promise<ConversationType>;
|
||||
update: (id: string, params: object) => Promise<ConversationType>;
|
||||
destroy: (id: string) => Promise<void>;
|
||||
ask: (
|
||||
@@ -159,7 +159,7 @@ type EnjoyAppType = {
|
||||
arrayBuffer: ArrayBuffer;
|
||||
};
|
||||
}
|
||||
) => Promise<MessageType>;
|
||||
) => Promise<MessageType[]>;
|
||||
};
|
||||
messages: {
|
||||
findAll: (params: object) => Promise<MessageType[]>;
|
||||
@@ -185,70 +185,6 @@ type EnjoyAppType = {
|
||||
dashboard: () => Promise<DownloadStateType[]>;
|
||||
removeAllListeners: () => void;
|
||||
};
|
||||
webApi: {
|
||||
auth: (params: { provider: string; code: string }) => Promise<UserType>;
|
||||
me: () => Promise<UserType>;
|
||||
lookup: (params: {
|
||||
word: string;
|
||||
context?: string;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
}) => Promise<LookupType>;
|
||||
lookupInBatch: (
|
||||
params: {
|
||||
word: string;
|
||||
context?: string;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
}[]
|
||||
) => Promise<{ successCount: number; errors: string[]; total: number }>;
|
||||
mineMeanings: (params?: {
|
||||
page?: number;
|
||||
items?: number;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
}) => Promise<
|
||||
{
|
||||
meanings: MeaningType[];
|
||||
} & PagyResponseType
|
||||
>;
|
||||
createStory: (params: {
|
||||
title: string;
|
||||
content: string;
|
||||
url: string;
|
||||
metadata: {
|
||||
[key: string]: any;
|
||||
};
|
||||
}) => Promise<StoryType>;
|
||||
extractVocabularyFromStory: (id: string) => Promise<string[]>;
|
||||
story: (id: string) => Promise<StoryType>;
|
||||
stories: (params?: { page: number }) => Promise<{
|
||||
stories: StoryType[];
|
||||
page: number;
|
||||
next: number | null;
|
||||
}>;
|
||||
mineStories: (params?: { page: number }) => Promise<{
|
||||
stories: StoryType[];
|
||||
page: number;
|
||||
next: number | null;
|
||||
}>;
|
||||
storyMeanings: (
|
||||
storyId: string,
|
||||
params?: {
|
||||
page?: number;
|
||||
items?: number;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
}
|
||||
) => Promise<
|
||||
{
|
||||
meanings: MeaningType[];
|
||||
pendingLookups: LookupType[];
|
||||
} & PagyResponseType
|
||||
>;
|
||||
starStory: (id: string) => Promise<{ starred: boolean }>;
|
||||
unstarStory: (id: string) => Promise<{ starred: boolean }>;
|
||||
};
|
||||
cacheObjects: {
|
||||
get: (key: string) => Promise<any>;
|
||||
set: (key: string, value: any, ttl?: number) => Promise<void>;
|
||||
|
||||
10
enjoy/src/types/medium.d.ts
vendored
Normal file
10
enjoy/src/types/medium.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
type MediumType = {
|
||||
id: string;
|
||||
md5: string;
|
||||
mediumType: string;
|
||||
coverUrl?: string;
|
||||
sourceUrl?: string;
|
||||
extname?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
17
enjoy/src/types/post.d.ts
vendored
Normal file
17
enjoy/src/types/post.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
type PostType = {
|
||||
id: string;
|
||||
metadata: {
|
||||
type: 'text' | 'prompt' | 'llm_configuration';
|
||||
content:
|
||||
| string
|
||||
| {
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
user: UserType;
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
target?: MediumType | StoryType | RecordingType;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
6
enjoy/src/types/recording.d.ts
vendored
6
enjoy/src/types/recording.d.ts
vendored
@@ -1,12 +1,12 @@
|
||||
type RecordingType = {
|
||||
id: string;
|
||||
filename: string;
|
||||
filename?: string;
|
||||
target?: AudioType | (MessageType & any);
|
||||
targetId: string;
|
||||
targetType: string;
|
||||
pronunciationAssessment?: PronunciationAssessmentType & any;
|
||||
segmentIndex: number;
|
||||
segmentText?: string;
|
||||
referenceId: number;
|
||||
referenceText?: string;
|
||||
duration?: number;
|
||||
src?: string;
|
||||
md5: string;
|
||||
|
||||
24
enjoy/src/types/story.d.ts
vendored
Normal file
24
enjoy/src/types/story.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
type StoryType = {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
content: string;
|
||||
metadata: {
|
||||
[key: string]: string;
|
||||
};
|
||||
vocabulary?: string[];
|
||||
extracted?: boolean;
|
||||
starred?: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type CreateStoryParamsType = {
|
||||
title: string;
|
||||
content: string;
|
||||
url: string;
|
||||
html: string;
|
||||
metadata: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
2
enjoy/src/types/user.d.ts
vendored
2
enjoy/src/types/user.d.ts
vendored
@@ -3,4 +3,6 @@ type UserType = {
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
accessToken?: string;
|
||||
recordingsCount?: number;
|
||||
recordingsDuration?: number;
|
||||
};
|
||||
|
||||
1
enjoy/src/types/video.d.ts
vendored
1
enjoy/src/types/video.d.ts
vendored
@@ -12,6 +12,7 @@ type VideoType = {
|
||||
transcribing: boolean;
|
||||
recordingsCount?: number;
|
||||
recordingsDuration?: number;
|
||||
isUploaded?: boolean;
|
||||
uploadedAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
Reference in New Issue
Block a user