Merge pull request #100 from an-lee/feat-community

Feat: community
This commit is contained in:
an-lee
2024-01-13 17:51:08 +08:00
committed by GitHub
68 changed files with 2205 additions and 698 deletions

259
enjoy/src/api/client.ts Normal file
View 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
View File

@@ -0,0 +1 @@
export * from "./client";

View File

@@ -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"
}

View File

@@ -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": "取消分享失败"
}

View File

@@ -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 }),
});
}
}

View File

@@ -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) => {

View File

@@ -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 }),
});
}
}

View File

@@ -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",

View File

@@ -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());
}
}

View File

@@ -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",

View File

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

View File

@@ -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"

View File

@@ -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",

View File

@@ -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();

View File

@@ -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();

View File

@@ -204,7 +204,7 @@ class Youtubedr {
this.getYtVideoId(url);
return true;
} catch (error) {
console.error(error);
logger.warn(error);
return false;
}
};

View File

@@ -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);

View File

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

View File

@@ -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" />

View File

@@ -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) => {

View File

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

View File

@@ -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";

View File

@@ -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>
);

View File

@@ -10,6 +10,9 @@ export * from "./videos";
export * from "./medias";
export * from "./posts";
export * from "./users";
export * from "./db-state";
export * from "./layout";

View File

@@ -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">

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View 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";

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

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

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

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

View 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>
</>
);
};

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

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

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

View File

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

View File

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

View File

@@ -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>

View File

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

View File

@@ -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

View File

@@ -0,0 +1 @@
export * from './users-rankings';

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

View File

@@ -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" />

View File

@@ -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) => {

View File

@@ -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,

View File

@@ -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) {

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

View File

@@ -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, []);

View File

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

View File

@@ -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,

View File

@@ -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

View File

@@ -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]);

View File

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

View File

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

View File

@@ -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
View File

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

View File

@@ -11,6 +11,7 @@ type AudioType = {
transcribing?: boolean;
recordingsCount?: number;
recordingsDuration?: number;
isUploaded?: boolean;
uploadedAt?: Date;
createdAt: Date;
updatedAt: Date;

View File

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

View File

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

View File

@@ -3,4 +3,6 @@ type UserType = {
name: string;
avatarUrl?: string;
accessToken?: string;
recordingsCount?: number;
recordingsDuration?: number;
};

View File

@@ -12,6 +12,7 @@ type VideoType = {
transcribing: boolean;
recordingsCount?: number;
recordingsDuration?: number;
isUploaded?: boolean;
uploadedAt?: Date;
createdAt: Date;
updatedAt: Date;