Support document(epub) (#1160)
* add document model * may add document * document card * basic document renderer * may render epub * basic layout * handle book href * refactor epub renderer * refactor translate button * toggle player * cache/restore last read position * refactor * add more columns to speeches * start shadow from document * add compact layout for media shadow * refactor * refactor * refactor * add document config * locales * auto translate * selected notify for update document * refactor * add document provider * fix perf issue * refactor * refactor * may toggle player * clean * refactor * clean code * auto play speech * fix document config update * refactor * fix epub image * fix epub image * html document * refactor * ui * save document source * fix document source * update document model * cache translation remote * update UI * fix package * refactor * fix * support text/markdown files * fix auto speech
This commit is contained in:
@@ -69,8 +69,8 @@ export class Client {
|
||||
this.logger.error(
|
||||
err.response.status,
|
||||
err.response.config.method.toUpperCase(),
|
||||
err.response.config.baseURL + err.response.config.url,
|
||||
err.response.data
|
||||
err.response.config.baseURL + err.response.config.url
|
||||
// err.response.data
|
||||
);
|
||||
|
||||
if (err.response.data) {
|
||||
@@ -86,7 +86,6 @@ export class Client {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
this.logger.error(err.message);
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
@@ -616,4 +615,37 @@ export class Client {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
syncDocument(document: Partial<DocumentEType>) {
|
||||
return this.api.post("/api/mine/documents", decamelizeKeys(document));
|
||||
}
|
||||
|
||||
deleteDocument(id: string) {
|
||||
return this.api.delete(`/api/mine/documents/${id}`);
|
||||
}
|
||||
|
||||
translations(params?: {
|
||||
md5?: string;
|
||||
translatedLanguage?: string;
|
||||
engine?: string;
|
||||
}): Promise<
|
||||
{
|
||||
translations: TranslationType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/translations", {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
createTranslation(params: {
|
||||
md5: string;
|
||||
content: string;
|
||||
translatedContent: string;
|
||||
language: string;
|
||||
translatedLanguage: string;
|
||||
engine: string;
|
||||
}): Promise<TranslationType> {
|
||||
return this.api.post("/api/translations", decamelizeKeys(params));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ export const AudioFormats = ["mp3", "wav", "ogg", "flac", "m4a", "wma", "aac"];
|
||||
|
||||
export const VideoFormats = ["mp4", "mkv", "avi", "mov", "wmv", "flv", "webm"];
|
||||
|
||||
export const DocumentFormats = ["epub", "md", "markdown", "html", "txt"];
|
||||
|
||||
export const PROCESS_TIMEOUT = 1000 * 60 * 15;
|
||||
|
||||
export const NOT_SUPPORT_JSON_FORMAT_MODELS = [
|
||||
|
||||
@@ -189,6 +189,11 @@
|
||||
"onlyGPTAgentCanBeAddedToThisChat": "Only GPT agent can be added to this chat",
|
||||
"invalidAgentType": "Invalid agent type",
|
||||
"invalidMembers": "Invalid members"
|
||||
},
|
||||
"document": {
|
||||
"fileNotFound": "File not found {{file}}",
|
||||
"fileNotSupported": "File not supported {{file}}",
|
||||
"failedToCopyFile": "Failed to copy file {{file}}"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -199,6 +204,7 @@
|
||||
"audios": "Audios",
|
||||
"videos": "Videos",
|
||||
"stories": "Stories",
|
||||
"documents": "Documents",
|
||||
"books": "Books",
|
||||
"vocabulary": "Vocabulary",
|
||||
"library": "Library",
|
||||
@@ -529,6 +535,8 @@
|
||||
"addedStories": "added stories",
|
||||
"addedAudios": "added audios",
|
||||
"addedVideos": "added videos",
|
||||
"addedDocuments": "added documents",
|
||||
"document": "document",
|
||||
"frontSide": "front side",
|
||||
"backSide": "back side",
|
||||
"aiExtractVocabulary": "AI extract vocabulary",
|
||||
@@ -891,5 +899,13 @@
|
||||
"diskUsageDescription": "The disk usage of Enjoy App.",
|
||||
"releaseDiskSpace": "Release",
|
||||
"bulkDeleteRecordings": "Delete Recordings",
|
||||
"bulkDeleteAborted": "Bulk delete aborted"
|
||||
"bulkDeleteAborted": "Bulk delete aborted",
|
||||
"notFound": "Not found",
|
||||
"saved": "Saved",
|
||||
"autoTranslate": "Auto translate",
|
||||
"autoNextSpeech": "Auto speech",
|
||||
"failedToLoadLink": "Failed to load link",
|
||||
"refreshSpeech": "Refresh speech",
|
||||
"locateParagraph": "Locate paragraph",
|
||||
"close": "Close"
|
||||
}
|
||||
|
||||
@@ -189,6 +189,11 @@
|
||||
"onlyGPTAgentCanBeAddedToThisChat": "只有 GPT 智能体可以添加到此聊天",
|
||||
"invalidAgentType": "无效的智能体类型",
|
||||
"invalidMembers": "无效的成员"
|
||||
},
|
||||
"document": {
|
||||
"fileNotFound": "文件未找到 {{file}}",
|
||||
"fileNotSupported": "文件格式不支持 {{file}}",
|
||||
"failedToCopyFile": "复制文件失败 {{file}}"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
@@ -199,6 +204,7 @@
|
||||
"audios": "音频",
|
||||
"videos": "视频",
|
||||
"stories": "文章",
|
||||
"documents": "文档",
|
||||
"books": "电子书",
|
||||
"vocabulary": "生词本",
|
||||
"library": "资料库",
|
||||
@@ -529,6 +535,8 @@
|
||||
"addedStories": "添加的文章",
|
||||
"addedAudios": "添加的音频",
|
||||
"addedVideos": "添加的视频",
|
||||
"addedDocuments": "添加的文档",
|
||||
"document": "文档",
|
||||
"frontSide": "正面",
|
||||
"backSide": "反面",
|
||||
"aiExtractVocabulary": "AI 提取生词",
|
||||
@@ -891,5 +899,13 @@
|
||||
"diskUsageDescription": "Enjoy App 的磁盘使用情况",
|
||||
"releaseDiskSpace": "释放磁盘",
|
||||
"bulkDeleteRecordings": "删除录音",
|
||||
"bulkDeleteAborted": "批量删除已中止"
|
||||
"bulkDeleteAborted": "批量删除已中止",
|
||||
"notFound": "未找到",
|
||||
"saved": "已保存",
|
||||
"autoTranslate": "自动翻译",
|
||||
"autoNextSpeech": "连续朗读",
|
||||
"failedToLoadLink": "加载链接失败",
|
||||
"refreshSpeech": "刷新语音",
|
||||
"locateParagraph": "定位段落",
|
||||
"close": "关闭"
|
||||
}
|
||||
|
||||
@@ -117,7 +117,11 @@ app.on("ready", async () => {
|
||||
|
||||
protocol.handle("enjoy", (request) => {
|
||||
let url = request.url.replace("enjoy://", "");
|
||||
if (url.match(/library\/(audios|videos|recordings|speeches|segments)/g)) {
|
||||
if (
|
||||
url.match(
|
||||
/library\/(audios|videos|recordings|speeches|segments|documents)/g
|
||||
)
|
||||
) {
|
||||
url = url.replace("library/", "");
|
||||
url = path.join(settings.userDataPath(), url);
|
||||
} else if (url.startsWith("library")) {
|
||||
|
||||
158
enjoy/src/main/db/handlers/documents-handler.ts
Normal file
158
enjoy/src/main/db/handlers/documents-handler.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { ipcMain, IpcMainEvent } from "electron";
|
||||
import { Document } from "@main/db/models";
|
||||
import { FindOptions, WhereOptions, Attributes, Op } from "sequelize";
|
||||
import downloader from "@main/downloader";
|
||||
import log from "@main/logger";
|
||||
import { t } from "i18next";
|
||||
|
||||
const logger = log.scope("db/handlers/documents-handler");
|
||||
|
||||
class DocumentsHandler {
|
||||
private async findAll(
|
||||
_event: IpcMainEvent,
|
||||
options: FindOptions<Attributes<Document>> & { query?: string }
|
||||
) {
|
||||
const { query, where = {} } = options || {};
|
||||
delete options.query;
|
||||
delete options.where;
|
||||
|
||||
if (query) {
|
||||
(where as any).title = {
|
||||
[Op.like]: `%${query}%`,
|
||||
};
|
||||
}
|
||||
const documents = await Document.findAll({
|
||||
order: [
|
||||
["lastReadAt", "DESC"],
|
||||
["updatedAt", "DESC"],
|
||||
],
|
||||
where,
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!documents) {
|
||||
return [];
|
||||
}
|
||||
return documents.map((document) => document.toJSON());
|
||||
}
|
||||
|
||||
private async findOne(
|
||||
_event: IpcMainEvent,
|
||||
where: WhereOptions<Attributes<Document>>
|
||||
) {
|
||||
const document = await Document.findOne({
|
||||
where: {
|
||||
...where,
|
||||
},
|
||||
});
|
||||
if (!document) return;
|
||||
|
||||
if (!document.isSynced) {
|
||||
document.sync().catch(() => {});
|
||||
}
|
||||
|
||||
return document.toJSON();
|
||||
}
|
||||
|
||||
private async create(
|
||||
event: IpcMainEvent,
|
||||
params: {
|
||||
uri: string;
|
||||
title?: string;
|
||||
config?: Record<string, any>;
|
||||
source?: string;
|
||||
}
|
||||
) {
|
||||
let { uri, title, config, source } = params;
|
||||
if (uri.startsWith("http")) {
|
||||
uri = await downloader.download(uri, {
|
||||
webContents: event.sender,
|
||||
});
|
||||
if (!uri) throw new Error("Failed to download file");
|
||||
}
|
||||
|
||||
try {
|
||||
const document = await Document.buildFromLocalFile(uri, {
|
||||
title,
|
||||
config,
|
||||
source,
|
||||
});
|
||||
|
||||
return document.toJSON();
|
||||
} catch (err) {
|
||||
logger.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async update(
|
||||
_event: IpcMainEvent,
|
||||
id: string,
|
||||
params: Attributes<Document>
|
||||
) {
|
||||
const { title, metadata, lastReadPosition, lastReadAt, config } = params;
|
||||
|
||||
const document = await Document.findByPk(id);
|
||||
|
||||
if (!document) {
|
||||
throw new Error(t("models.document.notFound"));
|
||||
}
|
||||
return await document.update({
|
||||
title,
|
||||
metadata,
|
||||
lastReadPosition,
|
||||
lastReadAt,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
private async destroy(_event: IpcMainEvent, id: string) {
|
||||
const document = await Document.findByPk(id);
|
||||
|
||||
if (!document) {
|
||||
throw new Error(t("models.document.notFound"));
|
||||
}
|
||||
return await document.destroy();
|
||||
}
|
||||
|
||||
private async upload(event: IpcMainEvent, id: string) {
|
||||
const document = await Document.findByPk(id);
|
||||
if (!document) {
|
||||
throw new Error(t("models.document.notFound"));
|
||||
}
|
||||
|
||||
return await document.upload();
|
||||
}
|
||||
|
||||
private async cleanUp() {
|
||||
const documents = await Document.findAll();
|
||||
|
||||
for (const document of documents) {
|
||||
if (!document.src) {
|
||||
document.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register() {
|
||||
ipcMain.handle("documents-find-all", this.findAll);
|
||||
ipcMain.handle("documents-find-one", this.findOne);
|
||||
ipcMain.handle("documents-create", this.create);
|
||||
ipcMain.handle("documents-update", this.update);
|
||||
ipcMain.handle("documents-destroy", this.destroy);
|
||||
ipcMain.handle("documents-upload", this.upload);
|
||||
ipcMain.handle("documents-clean-up", this.cleanUp);
|
||||
}
|
||||
|
||||
unregister() {
|
||||
ipcMain.removeHandler("documents-find-all");
|
||||
ipcMain.removeHandler("documents-find-one");
|
||||
ipcMain.removeHandler("documents-create");
|
||||
ipcMain.removeHandler("documents-update");
|
||||
ipcMain.removeHandler("documents-destroy");
|
||||
ipcMain.removeHandler("documents-upload");
|
||||
ipcMain.removeHandler("documents-clean-up");
|
||||
}
|
||||
}
|
||||
|
||||
export const documentsHandler = new DocumentsHandler();
|
||||
@@ -14,3 +14,4 @@ export * from "./segments-handler";
|
||||
export * from "./transcriptions-handler";
|
||||
export * from "./user-settings-handler";
|
||||
export * from "./videos-handler";
|
||||
export * from "./documents-handler";
|
||||
|
||||
@@ -25,6 +25,8 @@ class SpeechesHandler {
|
||||
sourceId: string;
|
||||
sourceType: string;
|
||||
text: string;
|
||||
section?: number;
|
||||
segment?: number;
|
||||
configuration: {
|
||||
engine: string;
|
||||
model: string;
|
||||
@@ -55,14 +57,20 @@ class SpeechesHandler {
|
||||
});
|
||||
}
|
||||
|
||||
private async delete(event: IpcMainEvent, id: string) {
|
||||
await Speech.destroy({ where: { id } });
|
||||
}
|
||||
|
||||
register() {
|
||||
ipcMain.handle("speeches-find-one", this.findOne);
|
||||
ipcMain.handle("speeches-create", this.create);
|
||||
ipcMain.handle("speeches-delete", this.delete);
|
||||
}
|
||||
|
||||
unregister() {
|
||||
ipcMain.removeHandler("speeches-find-one");
|
||||
ipcMain.removeHandler("speeches-create");
|
||||
ipcMain.removeHandler("speeches-delete");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ChatMember,
|
||||
ChatMessage,
|
||||
Conversation,
|
||||
Document,
|
||||
Message,
|
||||
Note,
|
||||
PronunciationAssessment,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
chatMessagesHandler,
|
||||
chatsHandler,
|
||||
conversationsHandler,
|
||||
documentsHandler,
|
||||
messagesHandler,
|
||||
notesHandler,
|
||||
pronunciationAssessmentsHandler,
|
||||
@@ -68,6 +70,7 @@ const handlers = [
|
||||
chatMessagesHandler,
|
||||
chatsHandler,
|
||||
conversationsHandler,
|
||||
documentsHandler,
|
||||
messagesHandler,
|
||||
notesHandler,
|
||||
pronunciationAssessmentsHandler,
|
||||
@@ -107,6 +110,7 @@ db.connect = async () => {
|
||||
ChatMember,
|
||||
ChatMessage,
|
||||
Conversation,
|
||||
Document,
|
||||
Message,
|
||||
Note,
|
||||
PronunciationAssessment,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { DataTypes } from "sequelize";
|
||||
|
||||
async function up({ context: queryInterface }) {
|
||||
queryInterface.createTable(
|
||||
"documents",
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
},
|
||||
title: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
md5: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
language: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
cover_url: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
source: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: false,
|
||||
defaultValue: {},
|
||||
},
|
||||
config: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: false,
|
||||
defaultValue: {},
|
||||
},
|
||||
last_read_position: {
|
||||
type: DataTypes.JSON,
|
||||
defaultValue: {},
|
||||
},
|
||||
last_read_at: {
|
||||
type: DataTypes.DATE,
|
||||
},
|
||||
synced_at: {
|
||||
type: DataTypes.DATE,
|
||||
},
|
||||
uploaded_at: {
|
||||
type: DataTypes.DATE,
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{
|
||||
fields: ["md5"],
|
||||
unique: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function down({ context: queryInterface }) {
|
||||
queryInterface.dropTable("documents");
|
||||
}
|
||||
|
||||
export { up, down };
|
||||
@@ -0,0 +1,21 @@
|
||||
import { DataTypes } from "sequelize";
|
||||
|
||||
async function up({ context: queryInterface }) {
|
||||
await queryInterface.addColumn("speeches", "section", {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
});
|
||||
await queryInterface.addColumn("speeches", "segment", {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function down({ context: queryInterface }) {
|
||||
await queryInterface.removeColumn("speeches", "section");
|
||||
await queryInterface.removeColumn("speeches", "segment");
|
||||
}
|
||||
|
||||
export { up, down };
|
||||
344
enjoy/src/main/db/models/document.ts
Normal file
344
enjoy/src/main/db/models/document.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import {
|
||||
AfterUpdate,
|
||||
AfterDestroy,
|
||||
Table,
|
||||
Column,
|
||||
Default,
|
||||
IsUUID,
|
||||
Model,
|
||||
DataType,
|
||||
AfterCreate,
|
||||
AfterFind,
|
||||
Unique,
|
||||
} from "sequelize-typescript";
|
||||
import mainWindow from "@main/window";
|
||||
import log from "@main/logger";
|
||||
import { Client } from "@/api";
|
||||
import settings from "@main/settings";
|
||||
import { UserSetting } from "@main/db/models";
|
||||
import fs from "fs-extra";
|
||||
import { t } from "i18next";
|
||||
import path from "path";
|
||||
import { DocumentFormats } from "@/constants";
|
||||
import { enjoyUrlToPath, hashFile } from "@/main/utils";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import { fileTypeFromFile } from "file-type";
|
||||
import mime from "mime-types";
|
||||
import storage from "@/main/storage";
|
||||
|
||||
const logger = log.scope("db/models/document");
|
||||
@Table({
|
||||
modelName: "Document",
|
||||
tableName: "documents",
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
})
|
||||
export class Document extends Model<Document> {
|
||||
@IsUUID("all")
|
||||
@Default(DataType.UUIDV4)
|
||||
@Column({ primaryKey: true, type: DataType.UUID })
|
||||
id: string;
|
||||
|
||||
@Column(DataType.STRING)
|
||||
language: string;
|
||||
|
||||
@Unique
|
||||
@Column(DataType.STRING)
|
||||
md5: string;
|
||||
|
||||
@Column(DataType.STRING)
|
||||
title: string;
|
||||
|
||||
@Column(DataType.STRING)
|
||||
coverUrl: string;
|
||||
|
||||
@Column(DataType.STRING)
|
||||
source: string;
|
||||
|
||||
@Column(DataType.JSON)
|
||||
metadata: Record<string, any>;
|
||||
|
||||
@Column(DataType.JSON)
|
||||
config: Record<string, any>;
|
||||
|
||||
@Column(DataType.JSON)
|
||||
lastReadPosition: Record<string, any>;
|
||||
|
||||
@Column(DataType.DATE)
|
||||
lastReadAt: Date;
|
||||
|
||||
@Column(DataType.DATE)
|
||||
syncedAt: Date;
|
||||
|
||||
@Column(DataType.DATE)
|
||||
uploadedAt: Date;
|
||||
|
||||
@Column(DataType.VIRTUAL)
|
||||
get autoTranslate(): boolean {
|
||||
return this.config.autoTranslate || false;
|
||||
}
|
||||
|
||||
@Column(DataType.VIRTUAL)
|
||||
get autoNextSpeech(): boolean {
|
||||
return this.config.autoNextSpeech || false;
|
||||
}
|
||||
|
||||
@Column(DataType.VIRTUAL)
|
||||
get ttsConfig(): Record<string, any> {
|
||||
return this.config.tts || {};
|
||||
}
|
||||
|
||||
@Column(DataType.VIRTUAL)
|
||||
get filePath(): string {
|
||||
const file = path.join(
|
||||
settings.userDataPath(),
|
||||
"documents",
|
||||
`${this.md5}.${this.metadata.extension}`
|
||||
);
|
||||
if (fs.existsSync(file)) {
|
||||
return file;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Column(DataType.VIRTUAL)
|
||||
get src(): string {
|
||||
if (!this.filePath) return null;
|
||||
|
||||
return `enjoy://${path.posix.join(
|
||||
"library",
|
||||
"documents",
|
||||
`${this.md5}.${this.metadata.extension}`
|
||||
)}`;
|
||||
}
|
||||
|
||||
@Column(DataType.VIRTUAL)
|
||||
get isSynced(): boolean {
|
||||
return Boolean(this.syncedAt) && this.syncedAt >= this.updatedAt;
|
||||
}
|
||||
|
||||
@Column(DataType.VIRTUAL)
|
||||
get isUploaded(): boolean {
|
||||
return Boolean(this.uploadedAt) && this.uploadedAt >= this.updatedAt;
|
||||
}
|
||||
|
||||
async sync(): Promise<void> {
|
||||
if (this.isSynced) return;
|
||||
|
||||
const webApi = new Client({
|
||||
baseUrl: settings.apiUrl(),
|
||||
accessToken: (await UserSetting.accessToken()) as string,
|
||||
logger,
|
||||
});
|
||||
|
||||
return webApi.syncDocument(this.toJSON()).then(() => {
|
||||
const now = new Date();
|
||||
this.update({ syncedAt: now, updatedAt: now });
|
||||
});
|
||||
}
|
||||
|
||||
async upload(force: boolean = false): Promise<void> {
|
||||
if (this.isUploaded && !force) return;
|
||||
|
||||
return storage
|
||||
.put(this.md5, this.filePath)
|
||||
.then((result) => {
|
||||
logger.debug("upload result:", result.data);
|
||||
if (result.data.success) {
|
||||
this.update({ uploadedAt: new Date() });
|
||||
} else {
|
||||
throw new Error(result.data);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("upload failed:", err.message);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
@AfterFind
|
||||
static async syncAfterFind(documents: Document[]) {
|
||||
if (!documents?.length) return;
|
||||
|
||||
const unsyncedDocuments = documents.filter(
|
||||
(document) => document.id && !document.isSynced
|
||||
);
|
||||
if (!unsyncedDocuments.length) return;
|
||||
|
||||
unsyncedDocuments.forEach((document) => {
|
||||
document.sync().catch((err) => {
|
||||
logger.error(err.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@AfterCreate
|
||||
static syncAndUploadAfterCreate(document: Document) {
|
||||
document.sync().catch((err) => {
|
||||
logger.error(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
@AfterCreate
|
||||
static notifyForCreate(document: Document) {
|
||||
this.notify(document, "create");
|
||||
}
|
||||
|
||||
@AfterUpdate
|
||||
static notifyForUpdate(document: Document) {
|
||||
if (document.changed("config") || document.changed("title")) {
|
||||
this.notify(document, "update");
|
||||
}
|
||||
}
|
||||
|
||||
@AfterUpdate
|
||||
static syncAfterUpdate(document: Document) {
|
||||
document.sync().catch((err) => {
|
||||
logger.error(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
@AfterDestroy
|
||||
static async destroyRemote(document: Document) {
|
||||
const webApi = new Client({
|
||||
baseUrl: settings.apiUrl(),
|
||||
accessToken: (await UserSetting.accessToken()) as string,
|
||||
logger,
|
||||
});
|
||||
|
||||
webApi.deleteDocument(document.id).catch((err) => {
|
||||
logger.error("delete remote document failed:", err.message);
|
||||
});
|
||||
}
|
||||
|
||||
@AfterDestroy
|
||||
static notifyForDestroy(document: Document) {
|
||||
this.notify(document, "destroy");
|
||||
}
|
||||
|
||||
static async buildFromLocalFile(
|
||||
filePath: string,
|
||||
params: {
|
||||
title?: string;
|
||||
config?: Record<string, any>;
|
||||
source?: string;
|
||||
}
|
||||
): Promise<Document> {
|
||||
// Check if file exists
|
||||
if (filePath.startsWith("enjoy://")) {
|
||||
filePath = enjoyUrlToPath(filePath);
|
||||
}
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.R_OK);
|
||||
} catch (error) {
|
||||
throw new Error(t("models.document.fileNotFound", { file: filePath }));
|
||||
}
|
||||
|
||||
// calculate md5
|
||||
const md5 = await hashFile(filePath, { algo: "md5" });
|
||||
|
||||
const existing = await Document.findOne({
|
||||
where: {
|
||||
md5,
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Check if file format is supported
|
||||
let mimeType: string;
|
||||
let extension: string;
|
||||
const fileType = await fileTypeFromFile(filePath);
|
||||
if (fileType) {
|
||||
mimeType = fileType.mime;
|
||||
extension = fileType.ext;
|
||||
} else {
|
||||
mimeType = mime.lookup(filePath) || "";
|
||||
extension = mime.extension(mimeType) || "";
|
||||
}
|
||||
|
||||
logger.debug("detected file type", filePath, mimeType, extension);
|
||||
if (!DocumentFormats.includes(extension)) {
|
||||
logger.error("unsupported file type", filePath, extension);
|
||||
throw new Error(
|
||||
t("models.document.fileNotSupported", { file: filePath })
|
||||
);
|
||||
}
|
||||
|
||||
// get file's metadata
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
|
||||
const metadata = {
|
||||
size: stat.size,
|
||||
created: stat.birthtime,
|
||||
modified: stat.mtime,
|
||||
mimeType,
|
||||
extension,
|
||||
extname: extension,
|
||||
};
|
||||
|
||||
// generate ID
|
||||
const userId = settings.getSync("user.id");
|
||||
const id = uuidv5(`${userId}/${md5}`, uuidv5.URL);
|
||||
|
||||
const destDir = path.join(settings.userDataPath(), "documents");
|
||||
fs.ensureDirSync(destDir);
|
||||
const destFile = path.join(destDir, `${md5}.${extension}`);
|
||||
|
||||
try {
|
||||
// copy file to library
|
||||
fs.copyFileSync(filePath, destFile);
|
||||
} catch (error) {
|
||||
logger.error("failed to copy file", filePath, error);
|
||||
throw new Error(
|
||||
t("models.document.failedToCopyFile", { file: filePath })
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
title = path.basename(filePath, `.${extension}`),
|
||||
config = {
|
||||
autoTranslate: false,
|
||||
autoNextSpeech: true,
|
||||
tts: {
|
||||
engine: "enjoyai",
|
||||
model: "openai/tts-1",
|
||||
voice: "alloy",
|
||||
},
|
||||
},
|
||||
source,
|
||||
} = params || {};
|
||||
|
||||
const record = this.build({
|
||||
id,
|
||||
md5,
|
||||
title,
|
||||
metadata,
|
||||
config,
|
||||
source,
|
||||
});
|
||||
|
||||
return record.save().catch((err) => {
|
||||
// remove copied file
|
||||
fs.removeSync(destFile);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
static async notify(
|
||||
document: Document,
|
||||
action: "create" | "update" | "destroy"
|
||||
) {
|
||||
if (!mainWindow.win) return;
|
||||
|
||||
const record = document.toJSON();
|
||||
|
||||
mainWindow.win.webContents.send("db-on-transaction", {
|
||||
model: "Document",
|
||||
id: document.id,
|
||||
action,
|
||||
record,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14,3 +14,4 @@ export * from "./speech";
|
||||
export * from "./user-setting";
|
||||
export * from "./transcription";
|
||||
export * from "./video";
|
||||
export * from "./document";
|
||||
|
||||
@@ -21,7 +21,7 @@ import settings from "@main/settings";
|
||||
import OpenAI, { type ClientOptions } from "openai";
|
||||
import { t } from "i18next";
|
||||
import { hashFile } from "@main/utils";
|
||||
import { Audio, Message, UserSetting } from "@main/db/models";
|
||||
import { Audio, Document, Message, UserSetting } from "@main/db/models";
|
||||
import log from "@main/logger";
|
||||
import proxyAgent from "@main/proxy-agent";
|
||||
|
||||
@@ -55,11 +55,14 @@ export class Speech extends Model<Speech> {
|
||||
sourceType: string;
|
||||
|
||||
@Column(DataType.VIRTUAL)
|
||||
source: Message;
|
||||
source: Message | Document;
|
||||
|
||||
@BelongsTo(() => Message, { foreignKey: "sourceId", constraints: false })
|
||||
message: Message;
|
||||
|
||||
@BelongsTo(() => Document, { foreignKey: "sourceId", constraints: false })
|
||||
document: Document;
|
||||
|
||||
@HasOne(() => Audio, "md5")
|
||||
audio: Audio;
|
||||
|
||||
@@ -67,6 +70,14 @@ export class Speech extends Model<Speech> {
|
||||
@Column(DataType.TEXT)
|
||||
text: string;
|
||||
|
||||
@AllowNull(true)
|
||||
@Column(DataType.INTEGER)
|
||||
section: number;
|
||||
|
||||
@AllowNull(true)
|
||||
@Column(DataType.INTEGER)
|
||||
segment: number;
|
||||
|
||||
@AllowNull(false)
|
||||
@Column(DataType.JSON)
|
||||
configuration: any;
|
||||
@@ -125,9 +136,15 @@ export class Speech extends Model<Speech> {
|
||||
if (!instance) continue;
|
||||
if (instance.sourceType === "Message" && instance.message !== undefined) {
|
||||
instance.source = instance.message;
|
||||
} else if (
|
||||
instance.sourceType === "Document" &&
|
||||
instance.document !== undefined
|
||||
) {
|
||||
instance.source = instance.document;
|
||||
}
|
||||
// To prevent mistakes:
|
||||
delete instance.dataValues.message;
|
||||
delete instance.dataValues.document;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -257,7 +257,32 @@ main.init = async () => {
|
||||
view.setVisible(false);
|
||||
mainWindow.contentView.addChildView(view);
|
||||
|
||||
// Add timeout handler
|
||||
const timeout = setTimeout(() => {
|
||||
logger.debug("view-scrape timeout", url);
|
||||
event.sender.send("view-on-state", {
|
||||
state: "did-fail-load",
|
||||
error: "Request timed out",
|
||||
url: url,
|
||||
});
|
||||
(view.webContents as any)?.destroy();
|
||||
mainWindow.contentView.removeChildView(view);
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
view.webContents.on("did-start-loading", () => {
|
||||
logger.debug("view-scrape did-start-loading", url);
|
||||
});
|
||||
|
||||
view.webContents.on("did-stop-loading", () => {
|
||||
logger.debug("view-scrape did-stop-loading", url);
|
||||
});
|
||||
|
||||
view.webContents.on("dom-ready", () => {
|
||||
logger.debug("view-scrape dom-ready", url);
|
||||
});
|
||||
|
||||
view.webContents.on("did-navigate", (_event, url) => {
|
||||
clearTimeout(timeout);
|
||||
event.sender.send("view-on-state", {
|
||||
state: "did-navigate",
|
||||
url,
|
||||
@@ -266,6 +291,7 @@ main.init = async () => {
|
||||
view.webContents.on(
|
||||
"did-fail-load",
|
||||
(_event, _errorCode, errrorDescription, validatedURL) => {
|
||||
clearTimeout(timeout);
|
||||
event.sender.send("view-on-state", {
|
||||
state: "did-fail-load",
|
||||
error: errrorDescription,
|
||||
@@ -276,19 +302,31 @@ main.init = async () => {
|
||||
}
|
||||
);
|
||||
view.webContents.on("did-finish-load", () => {
|
||||
clearTimeout(timeout);
|
||||
logger.debug("view-scrape did-finish-load", url);
|
||||
view.webContents
|
||||
.executeJavaScript(`document.documentElement.innerHTML`)
|
||||
.then((html) => {
|
||||
event.sender.send("view-on-state", {
|
||||
state: "did-finish-load",
|
||||
html,
|
||||
url,
|
||||
});
|
||||
(view.webContents as any).destroy();
|
||||
mainWindow.contentView.removeChildView(view);
|
||||
});
|
||||
});
|
||||
|
||||
view.webContents.loadURL(url);
|
||||
view.webContents.loadURL(url).catch((err) => {
|
||||
logger.error("view-scrape loadURL error", err);
|
||||
(view.webContents as any).destroy();
|
||||
mainWindow.contentView.removeChildView(view);
|
||||
event.sender.send("view-on-state", {
|
||||
state: "did-fail-load",
|
||||
error: err.message,
|
||||
url: url,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// App options
|
||||
|
||||
@@ -457,6 +457,9 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
) => {
|
||||
return ipcRenderer.invoke("speeches-create", params, blob);
|
||||
},
|
||||
delete: (id: string) => {
|
||||
return ipcRenderer.invoke("speeches-delete", id);
|
||||
},
|
||||
},
|
||||
audiowaveform: {
|
||||
generate: (
|
||||
@@ -699,4 +702,27 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
return ipcRenderer.invoke("chat-messages-destroy", id);
|
||||
},
|
||||
},
|
||||
documents: {
|
||||
findAll: (params: any) => {
|
||||
return ipcRenderer.invoke("documents-find-all", params);
|
||||
},
|
||||
findOne: (params: any) => {
|
||||
return ipcRenderer.invoke("documents-find-one", params);
|
||||
},
|
||||
create: (params: any) => {
|
||||
return ipcRenderer.invoke("documents-create", params);
|
||||
},
|
||||
update: (id: string, params: any) => {
|
||||
return ipcRenderer.invoke("documents-update", id, params);
|
||||
},
|
||||
destroy: (id: string) => {
|
||||
return ipcRenderer.invoke("documents-destroy", id);
|
||||
},
|
||||
upload: (id: string) => {
|
||||
return ipcRenderer.invoke("documents-upload", id);
|
||||
},
|
||||
cleanUp: () => {
|
||||
return ipcRenderer.invoke("documents-clean-up");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ export const AudiosSegment = (props: { limit?: number }) => {
|
||||
|
||||
{audios.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
|
||||
<MediaAddButton />
|
||||
<MediaAddButton type="Audio" />
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea>
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { ChatTTSForm } from "@renderer/components";
|
||||
import { TTSForm } from "@renderer/components";
|
||||
import {
|
||||
AISettingsProviderContext,
|
||||
AppSettingsProviderContext,
|
||||
@@ -366,7 +366,7 @@ export const ChatAgentForm = (props: {
|
||||
)}
|
||||
|
||||
{form.watch("type") === ChatAgentTypeEnum.TTS && (
|
||||
<ChatTTSForm form={form} />
|
||||
<TTSForm form={form} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-4">
|
||||
|
||||
@@ -174,7 +174,7 @@ const ChatAgentMessageActions = (props: {
|
||||
setTranslation,
|
||||
autoSpeech,
|
||||
} = props;
|
||||
const { chat, setShadowing, deleteMessage } = useContext(
|
||||
const { setShadowing, deleteMessage } = useContext(
|
||||
ChatSessionProviderContext
|
||||
);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
@@ -43,8 +43,8 @@ import {
|
||||
SttEngineOptionEnum,
|
||||
} from "@/types/enums";
|
||||
import { ChevronDownIcon, ChevronUpIcon, RefreshCwIcon } from "lucide-react";
|
||||
import { useAiCommand } from "@/renderer/hooks";
|
||||
import { cn } from "@/renderer/lib/utils";
|
||||
import { useAiCommand } from "@renderer/hooks";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
|
||||
export const ChatForm = (props: { chat: ChatType; onFinish?: () => void }) => {
|
||||
const { chat, onFinish } = props;
|
||||
|
||||
@@ -30,7 +30,7 @@ import { t } from "i18next";
|
||||
import { useContext } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import Mustache from "mustache";
|
||||
import { ChatGPTForm, ChatTTSForm } from "@renderer/components";
|
||||
import { GPTForm, TTSForm } from "@renderer/components";
|
||||
|
||||
export const ChatMemberForm = (props: {
|
||||
chat: ChatType;
|
||||
@@ -164,7 +164,7 @@ export const ChatMemberForm = (props: {
|
||||
{t("models.chatMember.gptSettings")}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 px-2">
|
||||
<ChatGPTForm form={form} />
|
||||
<GPTForm form={form} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -173,7 +173,7 @@ export const ChatMemberForm = (props: {
|
||||
{t("models.chatMember.ttsSettings")}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 px-2">
|
||||
<ChatTTSForm form={form} />
|
||||
<TTSForm form={form} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
|
||||
@@ -13,7 +13,5 @@ export * from "./chat-agent-card";
|
||||
export * from "./chat-settings";
|
||||
export * from "./chat-member-form";
|
||||
export * from "./chat-header";
|
||||
export * from "./chat-tts-form";
|
||||
export * from "./chat-gpt-form";
|
||||
export * from "./chat-suggestion-button";
|
||||
export * from "./chat-mentioning";
|
||||
|
||||
176
enjoy/src/renderer/components/documents/document-add-button.tsx
Normal file
176
enjoy/src/renderer/components/documents/document-add-button.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
Input,
|
||||
Button,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { PlusCircleIcon, LoaderIcon } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { useState, useContext, useEffect } from "react";
|
||||
import { DocumentFormats } from "@/constants";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Readability } from "@mozilla/readability";
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
export const DocumentAddButton = () => {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const navigate = useNavigate();
|
||||
const [uri, setUri] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleOpen = (value: boolean) => {
|
||||
if (submitting) {
|
||||
setOpen(true);
|
||||
} else {
|
||||
setOpen(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!uri) return;
|
||||
|
||||
setSubmitting(true);
|
||||
if (uri.startsWith("http")) {
|
||||
EnjoyApp.view.scrape(uri);
|
||||
} else {
|
||||
createFromLocalFile(uri, uri);
|
||||
}
|
||||
};
|
||||
|
||||
const createFromLocalFile = async (path: string, source?: string) => {
|
||||
EnjoyApp.documents
|
||||
.create({
|
||||
uri: path,
|
||||
config: {
|
||||
autoTranslate: false,
|
||||
autoNextSpeech: true,
|
||||
tts: {
|
||||
engine: "enjoyai",
|
||||
model: "openai/tts-1",
|
||||
voice: "alloy",
|
||||
},
|
||||
},
|
||||
source,
|
||||
})
|
||||
.then((doc) => {
|
||||
navigate(`/documents/${doc.id}`);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setSubmitting(false);
|
||||
setOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
const onViewState = async (event: {
|
||||
state: string;
|
||||
url?: string;
|
||||
error?: string;
|
||||
html?: string;
|
||||
}) => {
|
||||
const { state, html, error, url } = event;
|
||||
if (state === "did-finish-load") {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const reader = new Readability(doc);
|
||||
const article = reader.parse();
|
||||
|
||||
const file = await EnjoyApp.cacheObjects.writeFile(
|
||||
`${doc.title}.html`,
|
||||
Buffer.from(article.content)
|
||||
);
|
||||
createFromLocalFile(file, url);
|
||||
} else if (state === "did-fail-load") {
|
||||
setSubmitting(false);
|
||||
toast.error(error || t("failedToLoadLink"));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
EnjoyApp.view.onViewState((_event, state) => onViewState(state));
|
||||
|
||||
return () => {
|
||||
EnjoyApp.view.removeViewStateListeners();
|
||||
EnjoyApp.view.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="capitalize">
|
||||
<PlusCircleIcon className="mr-2 h-4 w-4" />
|
||||
{t("addResource")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("addResource")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("addResourceFromUrlOrLocal")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
placeholder="https://"
|
||||
value={uri}
|
||||
disabled={submitting}
|
||||
onChange={(element) => {
|
||||
setUri(element.target.value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="capitalize min-w-max"
|
||||
disabled={submitting}
|
||||
onClick={async () => {
|
||||
const selected = await EnjoyApp.dialog.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{
|
||||
name: t("documents"),
|
||||
extensions: DocumentFormats,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected) {
|
||||
setUri(selected[0]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("localFile")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!uri || submitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting && <LoaderIcon className="animate-spin w-4 mr-2" />}
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
104
enjoy/src/renderer/components/documents/document-card.tsx
Normal file
104
enjoy/src/renderer/components/documents/document-card.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
AlertDialogFooter,
|
||||
} from "@renderer/components/ui";
|
||||
import { MoreVerticalIcon, TrashIcon } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { useState } from "react";
|
||||
|
||||
export const DocumentCard = (props: {
|
||||
document: DocumentEType;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { document, className } = props;
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const handleDelete = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
setDeleting(true);
|
||||
};
|
||||
return (
|
||||
<div className={cn("w-full hover:scale-105 transition-all", className)}>
|
||||
<Link to={`/documents/${document.id}`}>
|
||||
<div className="aspect-[3/4] rounded overflow-hidden shadow-md relative flex flex-col">
|
||||
{/* Book body */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #${document.md5.slice(
|
||||
0,
|
||||
6
|
||||
)}22, #${document.md5.slice(-6)}44)`,
|
||||
}}
|
||||
></div>
|
||||
|
||||
{/* Book spine */}
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-4 shadow-inner"
|
||||
style={{
|
||||
backgroundColor: `#${document.md5.slice(0, 6)}`,
|
||||
}}
|
||||
></div>
|
||||
|
||||
{/* Book title */}
|
||||
<div className="relative flex-grow flex items-center justify-center py-4 pl-6 pr-4 z-10">
|
||||
<h3 className="text-center font-bold text-gray-800 break-words overflow-hidden">
|
||||
{document.title}
|
||||
</h3>
|
||||
</div>
|
||||
{/* drop menu */}
|
||||
<div className="absolute right-1 top-1 z-10">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-transparent w-6 h-6"
|
||||
>
|
||||
<MoreVerticalIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={handleDelete}>
|
||||
<TrashIcon className="w-4 h-4 text-destructive" />
|
||||
<span className="ml-2 text-destructive text-sm">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-1 bottom-1 z-10 bg-black/50 text-xs text-white px-1 rounded-sm">
|
||||
{document.metadata?.extension}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<AlertDialog open={deleting} onOpenChange={setDeleting}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("delete")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("deleteConfirm")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
<Button variant="destructive">{t("delete")}</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import { useContext, useState } from "react";
|
||||
import { DocumentConfigForm } from "@renderer/components";
|
||||
import { AppSettingsProviderContext } from "@/renderer/context";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const DocumentConfigButton = (props: { document: DocumentEType }) => {
|
||||
const { document } = props;
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
return (
|
||||
<Popover open={configOpen} onOpenChange={setConfigOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="w-6 h-6">
|
||||
<SettingsIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="start">
|
||||
<DocumentConfigForm
|
||||
config={document.config}
|
||||
onSubmit={(data: any) => {
|
||||
return EnjoyApp.documents
|
||||
.update(document.id, {
|
||||
...data,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("saved"));
|
||||
setConfigOpen(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
110
enjoy/src/renderer/components/documents/document-config-form.tsx
Normal file
110
enjoy/src/renderer/components/documents/document-config-form.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
Switch,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { TTSForm } from "@renderer/components";
|
||||
import { LoaderIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
const documentConfigSchema = z.object({
|
||||
config: z.object({
|
||||
autoTranslate: z.boolean(),
|
||||
autoNextSpeech: z.boolean(),
|
||||
tts: z.object({
|
||||
engine: z.string(),
|
||||
model: z.string(),
|
||||
voice: z.string(),
|
||||
language: z.string(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const DocumentConfigForm = (props: {
|
||||
config?: DocumentEType["config"];
|
||||
onSubmit: (data: z.infer<typeof documentConfigSchema>) => Promise<void>;
|
||||
}) => {
|
||||
const { config, onSubmit } = props;
|
||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||
|
||||
const form = useForm<z.infer<typeof documentConfigSchema>>({
|
||||
resolver: zodResolver(documentConfigSchema),
|
||||
defaultValues: config
|
||||
? { config }
|
||||
: {
|
||||
config: {
|
||||
autoTranslate: true,
|
||||
autoNextSpeech: true,
|
||||
tts: {
|
||||
engine: "openai",
|
||||
model: "openai/tts-1",
|
||||
language: "en-US",
|
||||
voice: "alloy",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
setSubmitting(true);
|
||||
onSubmit(data).finally(() => {
|
||||
setSubmitting(false);
|
||||
});
|
||||
})}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.autoTranslate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>{t("autoTranslate")}</FormLabel>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.autoNextSpeech"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<FormLabel>{t("autoNextSpeech")}</FormLabel>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<TTSForm form={form} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end my-4">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting && <LoaderIcon className="w-4 h-4 animate-spin mr-2" />}
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
DocumentConfigButton,
|
||||
LoaderSpin,
|
||||
MarkdownWrapper,
|
||||
} from "@renderer/components";
|
||||
import { makeBook } from "foliate-js/view.js";
|
||||
import { EPUB } from "foliate-js/epub.js";
|
||||
import { blobToDataUrl } from "@renderer/lib/utils";
|
||||
import Turndown from "turndown";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
Button,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { ChevronLeftIcon, ChevronRightIcon, MenuIcon } from "lucide-react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
DocumentProviderContext,
|
||||
} from "@renderer/context";
|
||||
|
||||
export const DocumentEpubRenderer = () => {
|
||||
const {
|
||||
ref,
|
||||
document,
|
||||
onSpeech,
|
||||
section,
|
||||
setSection,
|
||||
onSegmentVisible,
|
||||
content,
|
||||
setContent,
|
||||
} = useContext(DocumentProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const [book, setBook] = useState<typeof EPUB>();
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
const refreshBookMetadata = () => {
|
||||
if (!book) return;
|
||||
|
||||
if (document.title !== book.metadata.title) {
|
||||
EnjoyApp.documents.update(document.id, {
|
||||
title: book.metadata.title,
|
||||
language: book.metadata.language,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderCurrentSection = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const sectionDoc = await book.sections[section].createDocument();
|
||||
const tocItem = book.toc.find((item: any) => item.href === sectionDoc.id);
|
||||
setTitle(tocItem?.label || sectionDoc.title);
|
||||
|
||||
for (const img of sectionDoc.body.querySelectorAll("img")) {
|
||||
let image: any;
|
||||
if (img.src) {
|
||||
image = book.resources.manifest.find((resource: any) =>
|
||||
resource.href.endsWith(new URL(img.src).pathname)
|
||||
);
|
||||
} else if (img.id) {
|
||||
image = book.resources.manifest.find(
|
||||
(resource: any) => resource.id === img.id
|
||||
);
|
||||
}
|
||||
if (!image) continue;
|
||||
|
||||
const blob = new Blob([await book.loadBlob(image.href)], {
|
||||
type: image.mediaType,
|
||||
});
|
||||
const url = await blobToDataUrl(blob);
|
||||
img.setAttribute("src", url);
|
||||
}
|
||||
|
||||
const markdownContent = new Turndown().turndown(
|
||||
sectionDoc.body.innerHTML
|
||||
);
|
||||
setContent(markdownContent);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevSection = () => {
|
||||
if (section === 0) return;
|
||||
if (!book) return;
|
||||
|
||||
setSection(section - 1);
|
||||
};
|
||||
|
||||
const handleNextSection = () => {
|
||||
if (section === book.sections.length - 1) return;
|
||||
if (!book) return;
|
||||
|
||||
setSection(section + 1);
|
||||
};
|
||||
|
||||
const handleSectionClick = useCallback(
|
||||
(id: string) => {
|
||||
const sec = book.sections.findIndex((sec: any) => sec.id.endsWith(id));
|
||||
if (sec === -1) return;
|
||||
|
||||
setSection(sec);
|
||||
},
|
||||
[book]
|
||||
);
|
||||
|
||||
const handleLinkClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
handleSectionClick(new URL(e.currentTarget.href).pathname);
|
||||
e.currentTarget.blur();
|
||||
},
|
||||
[handleSectionClick]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
makeBook(document.src).then((epub: typeof EPUB) => {
|
||||
setBook(epub);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [document?.src]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!book) return;
|
||||
|
||||
refreshBookMetadata();
|
||||
renderCurrentSection();
|
||||
}, [book, section]);
|
||||
|
||||
if (!book) return <LoaderSpin />;
|
||||
|
||||
return (
|
||||
<div className="select-text relative">
|
||||
<div className="flex items-center justify-between space-x-2 sticky top-0 z-10 bg-background py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="w-6 h-6">
|
||||
<MenuIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-64 max-h-96 overflow-y-auto"
|
||||
>
|
||||
{(book?.toc as any[]).map((item: any) => (
|
||||
<div key={item.href}>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer text-sm"
|
||||
key={item.href}
|
||||
onClick={() => handleSectionClick(item.href)}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
{(item.subitems || []).map((subitem: any) => (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer pl-4 text-sm text-muted-foreground"
|
||||
key={subitem.href}
|
||||
onClick={() => handleSectionClick(subitem.href)}
|
||||
>
|
||||
{subitem.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DocumentConfigButton document={document} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{title}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handlePrevSection}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNextSection}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<ChevronRightIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="start-anchor" />
|
||||
{loading ? (
|
||||
<LoaderSpin />
|
||||
) : (
|
||||
<MarkdownWrapper
|
||||
className="mx-auto max-w-full"
|
||||
onLinkClick={handleLinkClick}
|
||||
onSegmentVisible={onSegmentVisible}
|
||||
autoTranslate={document.config.autoTranslate}
|
||||
onSpeech={onSpeech}
|
||||
translatable={true}
|
||||
section={section}
|
||||
>
|
||||
{content}
|
||||
</MarkdownWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Readability } from "@mozilla/readability";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
DocumentConfigButton,
|
||||
LoaderSpin,
|
||||
MarkdownWrapper,
|
||||
} from "@renderer/components";
|
||||
import Turndown from "turndown";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
DocumentProviderContext,
|
||||
} from "@/renderer/context";
|
||||
import { Button } from "../ui";
|
||||
import { LinkIcon } from "lucide-react";
|
||||
|
||||
export const DocumentHtmlRenderer = () => {
|
||||
const { document, onSpeech, onSegmentVisible, content, setContent } =
|
||||
useContext(DocumentProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [title, setTitle] = useState<string>("");
|
||||
|
||||
const fetchContent = async () => {
|
||||
const res = await fetch(document.src);
|
||||
const text = await res.text();
|
||||
const doc = new DOMParser().parseFromString(text, "text/html");
|
||||
setTitle(doc.title || document.title);
|
||||
const readability = new Readability(doc);
|
||||
const article = readability.parse();
|
||||
const markdownContent = new Turndown().turndown(article.content);
|
||||
setContent(markdownContent);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchContent();
|
||||
}, [document.src]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!title) return;
|
||||
|
||||
if (document.title !== title) {
|
||||
EnjoyApp.documents.update(document.id, {
|
||||
title,
|
||||
});
|
||||
}
|
||||
}, [title]);
|
||||
|
||||
if (!content) return <LoaderSpin />;
|
||||
|
||||
return (
|
||||
<div className="select-text relative">
|
||||
<div className="flex items-center justify-between space-x-2 sticky top-0 z-10 bg-background py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<DocumentConfigButton document={document} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground max-w-full truncate">
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{document.metadata?.source && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-6 h-6"
|
||||
onClick={() => {
|
||||
EnjoyApp.shell.openExternal(document.metadata.source);
|
||||
}}
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<MarkdownWrapper
|
||||
className="mx-auto max-w-full"
|
||||
autoTranslate={document.config.autoTranslate}
|
||||
onSpeech={onSpeech}
|
||||
onSegmentVisible={onSegmentVisible}
|
||||
translatable={true}
|
||||
>
|
||||
{content}
|
||||
</MarkdownWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
238
enjoy/src/renderer/components/documents/document-player.tsx
Normal file
238
enjoy/src/renderer/components/documents/document-player.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
DocumentProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { useSpeech } from "@renderer/hooks";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Button, toast } from "@renderer/components/ui";
|
||||
import {
|
||||
AudioPlayer,
|
||||
LoaderSpin,
|
||||
WavesurferPlayer,
|
||||
} from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
LoaderIcon,
|
||||
LocateFixedIcon,
|
||||
RefreshCcwIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export const DocumentPlayer = () => {
|
||||
const {
|
||||
ref,
|
||||
document,
|
||||
section,
|
||||
togglePlayingSegment,
|
||||
locateSegment,
|
||||
playingSegment,
|
||||
nextSegment,
|
||||
} = useContext(DocumentProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [speech, setSpeech] = useState<SpeechType | null>(null);
|
||||
const [speeching, setSpeeching] = useState(false);
|
||||
const [resourcing, setResourcing] = useState(false);
|
||||
const { tts } = useSpeech();
|
||||
const [audio, setAudio] = useState<AudioType | null>(null);
|
||||
|
||||
const startShadow = async () => {
|
||||
if (!speech) return;
|
||||
|
||||
const audio = await EnjoyApp.audios.findOne({
|
||||
md5: speech.md5,
|
||||
});
|
||||
|
||||
if (audio) {
|
||||
setAudio(audio);
|
||||
} else {
|
||||
setResourcing(true);
|
||||
EnjoyApp.audios
|
||||
.create(speech.filePath, {
|
||||
name: `[S${section}P${playingSegment.index}]-${document.title}`,
|
||||
originalText: speech.text,
|
||||
})
|
||||
.then((audio) => setAudio(audio))
|
||||
.catch((err) => toast.error(err.message))
|
||||
.finally(() => setResourcing(false));
|
||||
}
|
||||
};
|
||||
|
||||
const findOrCreateSpeech = async () => {
|
||||
if (typeof section !== "number" || !playingSegment) return;
|
||||
|
||||
const existingSpeech = await EnjoyApp.speeches.findOne({
|
||||
sourceId: document.id,
|
||||
sourceType: "Document",
|
||||
section,
|
||||
segment: playingSegment.index,
|
||||
});
|
||||
|
||||
if (existingSpeech) {
|
||||
setSpeech(existingSpeech);
|
||||
} else {
|
||||
createSpeech(playingSegment);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSpeech = async () => {
|
||||
if (speech) {
|
||||
await EnjoyApp.speeches.delete(speech.id);
|
||||
setSpeech(null);
|
||||
}
|
||||
findOrCreateSpeech();
|
||||
};
|
||||
|
||||
const createSpeech = async (segment: { index: number; text: string }) => {
|
||||
if (speeching) return;
|
||||
const { index, text } = segment;
|
||||
|
||||
setSpeeching(true);
|
||||
tts({
|
||||
sourceId: document.id,
|
||||
sourceType: "Document",
|
||||
section,
|
||||
segment: index,
|
||||
text,
|
||||
configuration: document.config.tts,
|
||||
})
|
||||
.then((res) => {
|
||||
setSpeech(res);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setSpeeching(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof section !== "number" || !playingSegment) return;
|
||||
findOrCreateSpeech();
|
||||
|
||||
return () => {
|
||||
setSpeech(null);
|
||||
setAudio(null);
|
||||
};
|
||||
}, [playingSegment]);
|
||||
|
||||
// Close the player when the section changes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
togglePlayingSegment(null);
|
||||
};
|
||||
}, [section]);
|
||||
|
||||
if (typeof section !== "number" || !playingSegment) {
|
||||
return <LoaderSpin />;
|
||||
}
|
||||
|
||||
if (speeching) {
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center h-full">
|
||||
<div className="flex items-center justify-center mb-2">
|
||||
<LoaderIcon className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t("creatingSpeech")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (resourcing) {
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center h-full">
|
||||
<div className="flex items-center justify-center mb-2">
|
||||
<LoaderIcon className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t("preparingAudio")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!speech) {
|
||||
return (
|
||||
<div className="flex justify-center items-center space-x-4 h-full">
|
||||
<Button onClick={findOrCreateSpeech}>{t("textToSpeech")}</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => togglePlayingSegment(null)}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!audio) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 justify-center items-center h-full">
|
||||
<WavesurferPlayer
|
||||
id={speech.id}
|
||||
src={speech.src}
|
||||
autoplay={true}
|
||||
onEnded={() => {
|
||||
if (nextSegment) {
|
||||
togglePlayingSegment(nextSegment.id);
|
||||
}
|
||||
}}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
<div className="flex justify-center space-x-4">
|
||||
<Button
|
||||
data-tooltip-content={t("refreshSpeech")}
|
||||
data-tooltip-place="bottom"
|
||||
data-tooltip-id="global-tooltip"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={refreshSpeech}
|
||||
>
|
||||
<RefreshCcwIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
data-tooltip-content={t("locateParagraph")}
|
||||
data-tooltip-place="bottom"
|
||||
data-tooltip-id="global-tooltip"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => locateSegment(playingSegment.id)}
|
||||
>
|
||||
<LocateFixedIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button onClick={startShadow}>{t("shadowingExercise")}</Button>
|
||||
<Button
|
||||
data-tooltip-content={t("close")}
|
||||
data-tooltip-place="bottom"
|
||||
data-tooltip-id="global-tooltip"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => togglePlayingSegment(null)}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex space-x-1 items-center mb-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => togglePlayingSegment(null)}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="text-sm line-clamp-1">{audio.name}</span>
|
||||
</div>
|
||||
<AudioPlayer id={audio.id} md5={audio.md5} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useContext, useEffect } from "react";
|
||||
import {
|
||||
DocumentConfigButton,
|
||||
LoaderSpin,
|
||||
MarkdownWrapper,
|
||||
} from "@renderer/components";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
DocumentProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { Button } from "@renderer/components/ui";
|
||||
import { LinkIcon } from "lucide-react";
|
||||
|
||||
export const DocumentTextRenderer = () => {
|
||||
const { document, onSpeech, onSegmentVisible, content, setContent } =
|
||||
useContext(DocumentProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const fetchContent = async () => {
|
||||
const res = await fetch(document.src);
|
||||
console.log("res", res);
|
||||
const text = await res.text();
|
||||
console.log("text", text);
|
||||
setContent(text);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchContent();
|
||||
}, [document.src]);
|
||||
|
||||
if (!content) return <LoaderSpin />;
|
||||
|
||||
return (
|
||||
<div className="select-text relative">
|
||||
<div className="flex items-center justify-between space-x-2 sticky top-0 z-10 bg-background py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<DocumentConfigButton document={document} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground max-w-full truncate">
|
||||
{document.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{document.metadata?.source && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-6 h-6"
|
||||
onClick={() => {
|
||||
EnjoyApp.shell.openExternal(document.metadata.source);
|
||||
}}
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<MarkdownWrapper
|
||||
className="mx-auto max-w-full"
|
||||
autoTranslate={document.config.autoTranslate}
|
||||
onSpeech={onSpeech}
|
||||
onSegmentVisible={onSegmentVisible}
|
||||
translatable={true}
|
||||
>
|
||||
{content}
|
||||
</MarkdownWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { DocumentCard } from "@renderer/components";
|
||||
import { Button, ScrollArea, ScrollBar } from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useState, useContext, useEffect } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
|
||||
export const DocumentsSegment = () => {
|
||||
const [documents, setDocuments] = useState<DocumentEType[]>([]);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const fetchDocuments = async () => {
|
||||
EnjoyApp.documents.findAll({ limit: 10 }).then((docs) => {
|
||||
setDocuments(docs);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments();
|
||||
}, []);
|
||||
|
||||
if (documents.length == 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-semibold tracking-tight capitalize">
|
||||
{t("addedDocuments")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="ml-auto mr-4">
|
||||
<Link to="/documents">
|
||||
<Button variant="link" className="capitalize">
|
||||
{t("seeMore")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea>
|
||||
<div className="flex items-center space-x-4 pb-4">
|
||||
{documents.map((document) => (
|
||||
<DocumentCard
|
||||
key={document.id}
|
||||
document={document}
|
||||
className="w-48"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
9
enjoy/src/renderer/components/documents/index.ts
Normal file
9
enjoy/src/renderer/components/documents/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from "./document-card";
|
||||
export * from "./document-html-renderer";
|
||||
export * from "./document-text-renderer";
|
||||
export * from "./document-epub-renderer";
|
||||
export * from "./document-player";
|
||||
export * from "./document-config-form";
|
||||
export * from "./document-add-button";
|
||||
export * from "./document-config-button";
|
||||
export * from "./documents-segment";
|
||||
@@ -3,6 +3,7 @@ export * from "./chats";
|
||||
export * from "./conversations";
|
||||
export * from "./copilots";
|
||||
export * from "./courses";
|
||||
export * from "./documents";
|
||||
export * from "./llm-chats";
|
||||
export * from "./meanings";
|
||||
export * from "./messages";
|
||||
|
||||
@@ -547,68 +547,7 @@ export const MediaCurrentRecording = () => {
|
||||
];
|
||||
|
||||
if (isRecording || isPaused) {
|
||||
return (
|
||||
<div className="w-full h-full flex justify-center items-center gap-4 border rounded-xl shadow">
|
||||
<LiveAudioVisualizer
|
||||
mediaRecorder={mediaRecorder}
|
||||
barWidth={2}
|
||||
gap={2}
|
||||
width={480}
|
||||
height="100%"
|
||||
fftSize={512}
|
||||
maxDecibels={-10}
|
||||
minDecibels={-80}
|
||||
smoothingTimeConstant={0.4}
|
||||
/>
|
||||
<span className="serif text-muted-foreground text-sm">
|
||||
{Math.floor(recordingTime / 60)}:
|
||||
{String(recordingTime % 60).padStart(2, "0")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("cancel")}
|
||||
onClick={cancelRecording}
|
||||
className="rounded-full shadow w-8 h-8 bg-red-500 hover:bg-red-600"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
>
|
||||
<XIcon fill="white" className="w-4 h-4 text-white" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={togglePauseResume}
|
||||
className="rounded-full shadow w-8 h-8"
|
||||
size="icon"
|
||||
>
|
||||
{isPaused ? (
|
||||
<PlayIcon
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("continue")}
|
||||
fill="white"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
) : (
|
||||
<PauseIcon
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("pause")}
|
||||
fill="white"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
id="media-record-button"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("finish")}
|
||||
onClick={stopRecording}
|
||||
className="rounded-full bg-green-500 hover:bg-green-600 shadow w-8 h-8"
|
||||
size="icon"
|
||||
>
|
||||
<CheckIcon className="w-4 h-4 text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <MediaRecorder />;
|
||||
}
|
||||
|
||||
if (!currentRecording?.src)
|
||||
@@ -801,3 +740,108 @@ export const MediaRecordButton = () => {
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const MediaRecorder = () => {
|
||||
const {
|
||||
mediaRecorder,
|
||||
recordingTime,
|
||||
isRecording,
|
||||
isPaused,
|
||||
cancelRecording,
|
||||
togglePauseResume,
|
||||
stopRecording,
|
||||
} = useContext(MediaShadowProviderContext);
|
||||
const ref = useRef(null);
|
||||
const [size, setSize] = useState<{ width: number; height: number } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const calContainerSize = () => {
|
||||
const size = ref?.current?.getBoundingClientRect();
|
||||
if (!size) return;
|
||||
|
||||
setSize({ width: size.width, height: size.height });
|
||||
};
|
||||
const debouncedCalContainerSize = debounce(calContainerSize, 100);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref?.current) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
debouncedCalContainerSize();
|
||||
});
|
||||
observer.observe(ref.current);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="w-full h-full flex justify-center items-center gap-4 border rounded-xl shadow"
|
||||
>
|
||||
{size?.width && size?.width > 1024 && (
|
||||
<LiveAudioVisualizer
|
||||
mediaRecorder={mediaRecorder}
|
||||
barWidth={2}
|
||||
gap={2}
|
||||
width={480}
|
||||
height="100%"
|
||||
fftSize={512}
|
||||
maxDecibels={-10}
|
||||
minDecibels={-80}
|
||||
smoothingTimeConstant={0.4}
|
||||
/>
|
||||
)}
|
||||
<span className="serif text-muted-foreground text-sm">
|
||||
{Math.floor(recordingTime / 60)}:
|
||||
{String(recordingTime % 60).padStart(2, "0")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("cancel")}
|
||||
onClick={cancelRecording}
|
||||
className="rounded-full shadow w-8 h-8 bg-red-500 hover:bg-red-600"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
>
|
||||
<XIcon fill="white" className="w-4 h-4 text-white" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={togglePauseResume}
|
||||
className="rounded-full shadow w-8 h-8"
|
||||
size="icon"
|
||||
>
|
||||
{isPaused ? (
|
||||
<PlayIcon
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("continue")}
|
||||
fill="white"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
) : (
|
||||
<PauseIcon
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("pause")}
|
||||
fill="white"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
id="media-record-button"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("finish")}
|
||||
onClick={stopRecording}
|
||||
className="rounded-full bg-green-500 hover:bg-green-600 shadow w-8 h-8"
|
||||
size="icon"
|
||||
>
|
||||
<CheckIcon className="w-4 h-4 text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -269,6 +269,7 @@ export const MediaWaveform = () => {
|
||||
variant={`${action.active ? "secondary" : "ghost"}`}
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={action.label}
|
||||
data-tooltip-place="left"
|
||||
className="relative p-0 w-full h-full rounded-none"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
@@ -284,6 +285,7 @@ export const MediaWaveform = () => {
|
||||
size="icon"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("more")}
|
||||
data-tooltip-place="left"
|
||||
className="relative p-0 w-full h-full rounded-none"
|
||||
>
|
||||
<MoreHorizontalIcon className="w-4 h-4" />
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MediaRecordings,
|
||||
} from "@renderer/components";
|
||||
import {
|
||||
Button,
|
||||
ScrollArea,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
@@ -14,9 +15,15 @@ import {
|
||||
TabsTrigger,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
import { ArrowLeftRightIcon } from "lucide-react";
|
||||
|
||||
export const MediaLeftPanel = () => {
|
||||
const { media, decoded } = useContext(MediaShadowProviderContext);
|
||||
export const MediaLeftPanel = (props: {
|
||||
className?: string;
|
||||
setDisplayPanel?: (displayPanel: "left" | "right" | null) => void;
|
||||
}) => {
|
||||
const { className, setDisplayPanel } = props;
|
||||
const { media, decoded, layout } = useContext(MediaShadowProviderContext);
|
||||
const [tab, setTab] = useState("provider");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -28,36 +35,53 @@ export const MediaLeftPanel = () => {
|
||||
if (!media) return null;
|
||||
|
||||
return (
|
||||
<Tabs value={tab} onValueChange={setTab} className="h-full flex flex-col">
|
||||
<TabsList
|
||||
className={`grid gap-4 rounded-none w-full px-4 ${
|
||||
media?.mediaType === "Video" ? "grid-cols-4" : "grid-cols-3"
|
||||
}`}
|
||||
>
|
||||
{media?.mediaType === "Video" && (
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
className={cn("h-full flex flex-col", className)}
|
||||
>
|
||||
<div className="flex items-center bg-muted px-4">
|
||||
{layout === "compact" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mr-2"
|
||||
onClick={() => setDisplayPanel?.("right")}
|
||||
>
|
||||
<ArrowLeftRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<TabsList
|
||||
className={`grid gap-4 rounded-none w-full ${
|
||||
media?.mediaType === "Video" ? "grid-cols-4" : "grid-cols-3"
|
||||
}`}
|
||||
>
|
||||
{media?.mediaType === "Video" && (
|
||||
<TabsTrigger
|
||||
value="provider"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("player")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger
|
||||
value="provider"
|
||||
value="transcription"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("player")}
|
||||
{t("transcription")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger
|
||||
value="transcription"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("transcription")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="recordings"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("myRecordings")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="info" className="capitalize block truncate px-1">
|
||||
{t("mediaInfo")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsTrigger
|
||||
value="recordings"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("myRecordings")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="info" className="capitalize block truncate px-1">
|
||||
{t("mediaInfo")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 relative">
|
||||
<TabsContent forceMount={true} value="provider">
|
||||
|
||||
@@ -20,8 +20,10 @@ import {
|
||||
import { TimelineEntry } from "echogarden/dist/utilities/Timeline.d.js";
|
||||
import { milisecondsToTimestamp } from "@/utils";
|
||||
import { toast } from "@renderer/components/ui";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
|
||||
export const MediaProvider = () => {
|
||||
export const MediaProvider = (props: { className?: string }) => {
|
||||
const { className } = props;
|
||||
const { theme } = useContext(ThemeProviderContext);
|
||||
const { media, setMediaProvider, setDecodeError, transcription } = useContext(
|
||||
MediaShadowProviderContext
|
||||
@@ -63,7 +65,7 @@ export const MediaProvider = () => {
|
||||
if (!media?.src) return null;
|
||||
|
||||
return (
|
||||
<div className="px-2 py-4">
|
||||
<div className={cn("px-2 py-4", className)}>
|
||||
<VidstackMediaPlayer
|
||||
ref={player}
|
||||
className="my-auto"
|
||||
|
||||
@@ -50,6 +50,7 @@ const LoadingContent = () => {
|
||||
transcribingProgress,
|
||||
transcribingOutput,
|
||||
generateTranscription,
|
||||
onCancel,
|
||||
} = useContext(MediaShadowProviderContext);
|
||||
if (decoded) {
|
||||
// Decoded and transcription created but not ready
|
||||
@@ -109,7 +110,12 @@ const LoadingContent = () => {
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<Button variant="secondary" onClick={() => navigate(-1)}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
onCancel ? onCancel() : navigate(-1);
|
||||
}}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
@@ -132,7 +138,12 @@ const LoadingContent = () => {
|
||||
)}
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<Button variant="secondary" onClick={() => navigate(-1)}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
onCancel ? onCancel() : navigate(-1);
|
||||
}}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -173,6 +173,7 @@ export const MediaCaptionActions = (props: {
|
||||
className="rounded-full w-8 h-8 p-0"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("displayIpa")}
|
||||
data-tooltip-place="left"
|
||||
onClick={() => setDisplayIpa(!displayIpa)}
|
||||
>
|
||||
<SpeechIcon className="w-4 h-4" />
|
||||
@@ -184,6 +185,7 @@ export const MediaCaptionActions = (props: {
|
||||
className="rounded-full w-8 h-8 p-0"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("displayNotes")}
|
||||
data-tooltip-place="left"
|
||||
onClick={() => setDisplayNotes(!displayNotes)}
|
||||
>
|
||||
<NotebookPenIcon className="w-4 h-4" />
|
||||
@@ -195,6 +197,7 @@ export const MediaCaptionActions = (props: {
|
||||
<Button
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("sendToAIAssistant")}
|
||||
data-tooltip-place="left"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="p-0 w-8 h-8 rounded-full"
|
||||
@@ -210,6 +213,7 @@ export const MediaCaptionActions = (props: {
|
||||
className="rounded-full w-8 h-8 p-0"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("copyText")}
|
||||
data-tooltip-place="left"
|
||||
onClick={() => {
|
||||
if (displayIpa) {
|
||||
const text = caption.timeline
|
||||
@@ -245,6 +249,7 @@ export const MediaCaptionActions = (props: {
|
||||
<CopyIcon
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("copyText")}
|
||||
data-tooltip-place="left"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
)}
|
||||
@@ -256,6 +261,7 @@ export const MediaCaptionActions = (props: {
|
||||
className="rounded-full w-8 h-8 p-0"
|
||||
data-tooltip-id="media-shadow-tooltip"
|
||||
data-tooltip-content={t("downloadSegment")}
|
||||
data-tooltip-place="left"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useEffect, useState, useContext, useRef } from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
MediaShadowProviderContext,
|
||||
} from "@renderer/context";
|
||||
import { MediaShadowProviderContext } from "@renderer/context";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import {
|
||||
Button,
|
||||
ScrollArea,
|
||||
Tabs,
|
||||
TabsList,
|
||||
@@ -22,8 +20,14 @@ import {
|
||||
MediaCaptionNote,
|
||||
MediaCaptionTranslation,
|
||||
} from "@renderer/components";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
import { ArrowLeftRightIcon } from "lucide-react";
|
||||
|
||||
export const MediaRightPanel = () => {
|
||||
export const MediaRightPanel = (props: {
|
||||
className?: string;
|
||||
setDisplayPanel?: (displayPanel: "left" | "right" | null) => void;
|
||||
}) => {
|
||||
const { className, setDisplayPanel } = props;
|
||||
const {
|
||||
currentSegmentIndex,
|
||||
currentTime,
|
||||
@@ -34,6 +38,7 @@ export const MediaRightPanel = () => {
|
||||
editingRegion,
|
||||
setEditingRegion,
|
||||
setTranscriptionDraft,
|
||||
layout,
|
||||
} = useContext(MediaShadowProviderContext);
|
||||
const [activeIndex, setActiveIndex] = useState<number>(0);
|
||||
const [selectedIndices, setSelectedIndices] = useState<number[]>([]);
|
||||
@@ -257,33 +262,45 @@ export const MediaRightPanel = () => {
|
||||
if (!caption) return null;
|
||||
|
||||
return (
|
||||
<div className="h-full relative">
|
||||
<div className={cn("h-full relative", className)}>
|
||||
<div className="flex-1 font-serif h-full">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => setTab(value)}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
<TabsList className="grid grid-cols-3 gap-4 rounded-none w-full px-4">
|
||||
<TabsTrigger
|
||||
value="translation"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("captionTabs.translation")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="note"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("captionTabs.note")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="analysis"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("captionTabs.analysis")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center bg-muted px-4">
|
||||
{layout === "compact" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mr-2"
|
||||
onClick={() => setDisplayPanel?.("left")}
|
||||
>
|
||||
<ArrowLeftRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<TabsList className="grid grid-cols-3 gap-4 rounded-none w-full">
|
||||
<TabsTrigger
|
||||
value="translation"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("captionTabs.translation")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="note"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("captionTabs.note")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="analysis"
|
||||
className="capitalize block truncate px-1"
|
||||
>
|
||||
{t("captionTabs.analysis")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 relative">
|
||||
<MediaCaption
|
||||
caption={caption}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { MediaShadowProviderContext } from "@renderer/context";
|
||||
import {
|
||||
MediaLoadingModal,
|
||||
MediaRightPanel,
|
||||
MediaLeftPanel,
|
||||
MediaBottomPanel,
|
||||
MediaProvider,
|
||||
} from "@renderer/components";
|
||||
import {
|
||||
Button,
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@renderer/components/ui";
|
||||
import { useContext, useState } from "react";
|
||||
import { RefreshCcwDotIcon } from "lucide-react";
|
||||
|
||||
export const MediaShadowPlayer = () => {
|
||||
return (
|
||||
@@ -18,15 +23,7 @@ export const MediaShadowPlayer = () => {
|
||||
direction="vertical"
|
||||
>
|
||||
<ResizablePanel defaultSize={60} minSize={50}>
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel defaultSize={40} minSize={20}>
|
||||
<MediaLeftPanel />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel minSize={20}>
|
||||
<MediaRightPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<TopPanel />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
|
||||
@@ -38,3 +35,37 @@ export const MediaShadowPlayer = () => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TopPanel = () => {
|
||||
const { layout } = useContext(MediaShadowProviderContext);
|
||||
const [displayPanel, setDisplayPanel] = useState<"left" | "right" | null>(
|
||||
"right"
|
||||
);
|
||||
|
||||
if (layout === "normal") {
|
||||
return (
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel id="left-panel" order={0} defaultSize={40} minSize={20}>
|
||||
<MediaLeftPanel />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel id="right-panel" order={1} minSize={20}>
|
||||
<MediaRightPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<MediaLeftPanel
|
||||
className={displayPanel === "left" ? "flex-1" : "invisible fixed"}
|
||||
setDisplayPanel={setDisplayPanel}
|
||||
/>
|
||||
<MediaRightPanel
|
||||
className={displayPanel === "right" ? "flex-1" : "invisible fixed"}
|
||||
setDisplayPanel={setDisplayPanel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ import { t } from "i18next";
|
||||
import { useContext } from "react";
|
||||
import { AISettingsProviderContext } from "@renderer/context";
|
||||
|
||||
export const ChatGPTForm = (props: { form: ReturnType<typeof useForm> }) => {
|
||||
export const GPTForm = (props: { form: ReturnType<typeof useForm> }) => {
|
||||
const { form } = props;
|
||||
const { gptProviders } = useContext(AISettingsProviderContext);
|
||||
|
||||
@@ -12,3 +12,5 @@ export * from "./page-placeholder";
|
||||
export * from "./universal-player";
|
||||
export * from "./sidebar";
|
||||
export * from "./wavesurfer-player";
|
||||
export * from "./tts-form";
|
||||
export * from "./gpt-form";
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
import Markdown from "react-markdown";
|
||||
import Markdown, { defaultUrlTransform } from "react-markdown";
|
||||
import { visitParents } from "unist-util-visit-parents";
|
||||
import { Sentence } from "@renderer/components";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
memo,
|
||||
useContext,
|
||||
} from "react";
|
||||
import {
|
||||
LanguagesIcon,
|
||||
LoaderIcon,
|
||||
PlayIcon,
|
||||
RefreshCwIcon,
|
||||
} from "lucide-react";
|
||||
import { Button, toast } from "@renderer/components/ui";
|
||||
import { useIntersectionObserver } from "@uidotdev/usehooks";
|
||||
import { md5 } from "js-md5";
|
||||
import { AppSettingsProviderContext } from "@/renderer/context";
|
||||
import { useAiCommand } from "@/renderer/hooks";
|
||||
|
||||
function rehypeWrapText() {
|
||||
return function wrapTextTransform(tree: any) {
|
||||
visitParents(tree, "text", (node, ancestors) => {
|
||||
const parent = ancestors.at(-1);
|
||||
|
||||
if (parent.tagName !== "vocabulary") {
|
||||
if (parent.tagName !== "vocabulary" && parent.tagName !== "a") {
|
||||
node.type = "element";
|
||||
node.tagName = "vocabulary";
|
||||
node.properties = { text: node.value };
|
||||
@@ -18,35 +38,244 @@ function rehypeWrapText() {
|
||||
};
|
||||
}
|
||||
|
||||
export const MarkdownWrapper = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
children: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<Markdown
|
||||
className={cn("prose dark:prose-invert", className)}
|
||||
rehypePlugins={[rehypeWrapText]}
|
||||
components={{
|
||||
a({ node, children, ...props }) {
|
||||
// Memoize the Segment component
|
||||
const Segment = memo(
|
||||
({
|
||||
tag: Tag,
|
||||
index,
|
||||
children,
|
||||
onSegmentVisible,
|
||||
onSpeech,
|
||||
autoTranslate,
|
||||
translatable,
|
||||
section,
|
||||
...props
|
||||
}: {
|
||||
tag: "h1" | "h2" | "h3" | "h4" | "h5" | "p";
|
||||
index: number;
|
||||
children: any;
|
||||
onSegmentVisible?: (id: string) => void;
|
||||
onSpeech?: (id: string) => void;
|
||||
autoTranslate?: boolean;
|
||||
translatable?: boolean;
|
||||
section?: number;
|
||||
}) => {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { translate } = useAiCommand();
|
||||
const [translating, setTranslating] = useState(false);
|
||||
const [translation, setTranslation] = useState<string>("");
|
||||
|
||||
const [ref, entry] = useIntersectionObserver({
|
||||
threshold: 0,
|
||||
root: null,
|
||||
rootMargin: "0px",
|
||||
});
|
||||
|
||||
const toggleTranslation = () => {
|
||||
if (translation) {
|
||||
setTranslation("");
|
||||
} else {
|
||||
handleTranslate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTranslate = async (force = false) => {
|
||||
if (translating) return;
|
||||
|
||||
const content = entry.target
|
||||
?.querySelector(".segment-content")
|
||||
?.textContent?.trim();
|
||||
if (!content) return;
|
||||
|
||||
const md5Hash = md5(content);
|
||||
|
||||
const cacheKey = `translate-${md5Hash}`;
|
||||
const cached = await EnjoyApp.cacheObjects.get(cacheKey);
|
||||
if (cached && !force) {
|
||||
setTranslation(cached);
|
||||
} else {
|
||||
setTranslating(true);
|
||||
setTranslation("");
|
||||
translate(content, cacheKey)
|
||||
.then((result) => {
|
||||
setTranslation(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setTranslating(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!entry?.target) return "";
|
||||
return entry.target?.textContent?.trim();
|
||||
}, [entry]);
|
||||
|
||||
const id = `segment-${index}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!onSegmentVisible) return;
|
||||
if (entry?.isIntersecting) {
|
||||
onSegmentVisible(`segment-${index}`);
|
||||
if (autoTranslate) {
|
||||
handleTranslate();
|
||||
}
|
||||
}
|
||||
}, [entry?.isIntersecting, autoTranslate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tag
|
||||
id={`segment-${index}`}
|
||||
ref={ref}
|
||||
data-index={index}
|
||||
data-section={section}
|
||||
className="segment"
|
||||
>
|
||||
<span className="flex items-center gap-2 opacity-50 hover:opacity-100">
|
||||
{content && (onSpeech || translatable) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
#{index + 1}
|
||||
</span>
|
||||
)}
|
||||
{onSpeech && content && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
onSpeech(id);
|
||||
}}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
<PlayIcon className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
{translatable && content && (
|
||||
<Button
|
||||
onClick={toggleTranslation}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
{translating ? (
|
||||
<LoaderIcon className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<LanguagesIcon className="w-3 h-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<span className="segment-content">{children}</span>
|
||||
</Tag>
|
||||
{translation && (
|
||||
<Tag id={`translation-${index}`}>
|
||||
{translation}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-4 h-4 opacity-50 hover:opacity-100"
|
||||
onClick={() => handleTranslate(true)}
|
||||
>
|
||||
<RefreshCwIcon className="w-3 h-3" />
|
||||
</Button>
|
||||
</Tag>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
// Wrap MarkdownWrapper with React.memo and memoize callbacks
|
||||
export const MarkdownWrapper = memo(
|
||||
({
|
||||
children,
|
||||
className,
|
||||
onLinkClick,
|
||||
onSpeech,
|
||||
onSegmentVisible,
|
||||
translatable = false,
|
||||
autoTranslate,
|
||||
section,
|
||||
...props
|
||||
}: {
|
||||
children: string;
|
||||
className?: string;
|
||||
onLinkClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
onSpeech?: (id: string) => void;
|
||||
onSegmentVisible?: (id: string) => void;
|
||||
translatable?: boolean;
|
||||
autoTranslate?: boolean;
|
||||
section?: number;
|
||||
}) => {
|
||||
// Memoize component callbacks
|
||||
const handleLinkClick = useCallback(onLinkClick, [onLinkClick]);
|
||||
|
||||
const components = useMemo(() => {
|
||||
let segmentIndex = 0;
|
||||
const HEADER_COMPONENTS = ["h1", "h2", "h3", "h4", "h5", "p"] as const;
|
||||
|
||||
const headerComponents = Object.fromEntries(
|
||||
HEADER_COMPONENTS.map((tag) => [
|
||||
tag,
|
||||
({ node, children, ...props }: any) => (
|
||||
<Segment
|
||||
tag={tag}
|
||||
index={segmentIndex++}
|
||||
onSegmentVisible={onSegmentVisible}
|
||||
onSpeech={onSpeech}
|
||||
autoTranslate={autoTranslate}
|
||||
translatable={translatable}
|
||||
section={section}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Segment>
|
||||
),
|
||||
])
|
||||
);
|
||||
|
||||
return {
|
||||
a({ node, children, ...props }: any) {
|
||||
try {
|
||||
new URL(props.href ?? "");
|
||||
props.target = "_blank";
|
||||
props.rel = "noopener noreferrer";
|
||||
} catch (e) {}
|
||||
|
||||
return <a {...props}>{children}</a>;
|
||||
return (
|
||||
<a {...props} onClick={handleLinkClick}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
vocabulary({ node, children, ...props }) {
|
||||
vocabulary({ node, children, ...props }: any) {
|
||||
return <Sentence sentence={props.text} />;
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Markdown>
|
||||
);
|
||||
};
|
||||
...headerComponents,
|
||||
};
|
||||
}, [handleLinkClick, onSegmentVisible, onSpeech, autoTranslate, section]);
|
||||
|
||||
return (
|
||||
<Markdown
|
||||
className={cn("prose dark:prose-invert", className)}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeWrapText]}
|
||||
urlTransform={(url) => {
|
||||
if (url.startsWith("blob:") || url.startsWith("data:")) {
|
||||
return url;
|
||||
}
|
||||
return defaultUrlTransform(url);
|
||||
}}
|
||||
components={components}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Markdown>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MarkdownWrapper.displayName = "MarkdownWrapper";
|
||||
|
||||
@@ -155,10 +155,10 @@ export const Sidebar = () => {
|
||||
/>
|
||||
|
||||
<SidebarItem
|
||||
href="/stories"
|
||||
label={t("sidebar.stories")}
|
||||
tooltip={t("sidebar.stories")}
|
||||
active={activeTab.startsWith("/stories")}
|
||||
href="/documents"
|
||||
label={t("sidebar.documents")}
|
||||
tooltip={t("sidebar.documents")}
|
||||
active={activeTab.startsWith("/documents")}
|
||||
Icon={NewspaperIcon}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { t } from "i18next";
|
||||
import { useContext } from "react";
|
||||
import { AISettingsProviderContext } from "@renderer/context";
|
||||
|
||||
export const ChatTTSForm = (props: { form: ReturnType<typeof useForm> }) => {
|
||||
export const TTSForm = (props: { form: ReturnType<typeof useForm> }) => {
|
||||
const { form } = props;
|
||||
const { ttsProviders } = useContext(AISettingsProviderContext);
|
||||
|
||||
@@ -22,6 +22,7 @@ export const WavesurferPlayer = (props: {
|
||||
pitchContourOptions?: any;
|
||||
className?: string;
|
||||
autoplay?: boolean;
|
||||
onEnded?: () => void;
|
||||
}) => {
|
||||
const {
|
||||
id,
|
||||
@@ -33,6 +34,7 @@ export const WavesurferPlayer = (props: {
|
||||
pitchContourOptions,
|
||||
className = "",
|
||||
autoplay = false,
|
||||
onEnded,
|
||||
} = props;
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@@ -95,6 +97,9 @@ export const WavesurferPlayer = (props: {
|
||||
wavesurfer.on("pause", () => {
|
||||
setIsPlaying(false);
|
||||
}),
|
||||
wavesurfer.on("finish", () => {
|
||||
onEnded && onEnded();
|
||||
}),
|
||||
wavesurfer.on("timeupdate", (time: number) => {
|
||||
setCurrentTime(time);
|
||||
onSetCurrentTime && onSetCurrentTime(time);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ChevronLeftIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import { Button } from "@renderer/components/ui";
|
||||
import uniq from "lodash/uniq";
|
||||
import Mark from "mark.js";
|
||||
import { Vocabulary } from "@/renderer/components";
|
||||
import { Vocabulary } from "@renderer/components";
|
||||
|
||||
export const StoryViewer = (props: {
|
||||
story: Partial<StoryType> & Partial<CreateStoryParamsType>;
|
||||
|
||||
@@ -62,7 +62,7 @@ export const VideosSegment = (props: { limit?: number }) => {
|
||||
|
||||
{videos.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
|
||||
<MediaAddButton />
|
||||
<MediaAddButton type="Video" />
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea>
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
import { memo } from "react";
|
||||
import { Vocabulary } from "@renderer/components";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
|
||||
export const Sentence = ({
|
||||
sentence,
|
||||
className,
|
||||
}: {
|
||||
sentence: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
// split by space or punctuation
|
||||
// Sentence may be in other languages, so need to handle only English words
|
||||
let words = sentence.split(/(\s+|[a-zA-Z]+)/);
|
||||
export const Sentence = memo(
|
||||
({ sentence, className }: { sentence: string; className?: string }) => {
|
||||
// split by space or punctuation
|
||||
// Sentence may be in other languages, so need to handle only English words
|
||||
let words = sentence.split(/(\s+|[a-zA-Z]+)/);
|
||||
|
||||
return (
|
||||
<span className={cn("break-words align-middle", className)}>
|
||||
{words.map((word, index) => {
|
||||
return (
|
||||
<span key={index}>
|
||||
{word.match(/[a-zA-Z]+/) ? (
|
||||
<Vocabulary word={word} context={sentence} />
|
||||
) : (
|
||||
word
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<span className={cn("break-words align-middle", className)}>
|
||||
{words.map((word, index) => {
|
||||
return (
|
||||
<span key={index}>
|
||||
{word.match(/[a-zA-Z]+/) ? (
|
||||
<Vocabulary word={word} context={sentence} />
|
||||
) : (
|
||||
word
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Sentence.displayName = "Sentence";
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useContext, useState, memo } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
|
||||
export const Vocabulary = ({
|
||||
word,
|
||||
context,
|
||||
children,
|
||||
}: {
|
||||
word: string;
|
||||
context?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
let [timer, setTimer] = useState<ReturnType<typeof setTimeout>>();
|
||||
const { vocabularyConfig, EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
export const Vocabulary = memo(
|
||||
({
|
||||
word,
|
||||
context,
|
||||
children,
|
||||
}: {
|
||||
word: string;
|
||||
context?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
let [timer, setTimer] = useState<ReturnType<typeof setTimeout>>();
|
||||
const { vocabularyConfig, EnjoyApp } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
|
||||
const handleLookup = (e: any) => {
|
||||
if (!context) {
|
||||
context = e.target?.parentElement
|
||||
.closest(".sentence, h2, p, div")
|
||||
?.textContent?.trim();
|
||||
}
|
||||
|
||||
const { x, bottom: y } = e.target.getBoundingClientRect();
|
||||
const _word = word.replace(/[^\w\s]|_/g, "");
|
||||
|
||||
EnjoyApp.lookup(_word, context, { x, y });
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: any) => {
|
||||
let _timer = setTimeout(() => {
|
||||
const handleLookup = (e: any) => {
|
||||
if (!context) {
|
||||
context = e.target?.parentElement
|
||||
.closest(".sentence, h2, p, div")
|
||||
@@ -38,23 +27,40 @@ export const Vocabulary = ({
|
||||
const _word = word.replace(/[^\w\s]|_/g, "");
|
||||
|
||||
EnjoyApp.lookup(_word, context, { x, y });
|
||||
}, 800);
|
||||
};
|
||||
|
||||
setTimer(_timer);
|
||||
};
|
||||
const handleMouseEnter = (e: any) => {
|
||||
let _timer = setTimeout(() => {
|
||||
if (!context) {
|
||||
context = e.target?.parentElement
|
||||
.closest(".sentence, h2, p, div")
|
||||
?.textContent?.trim();
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
const { x, bottom: y } = e.target.getBoundingClientRect();
|
||||
const _word = word.replace(/[^\w\s]|_/g, "");
|
||||
|
||||
return vocabularyConfig.lookupOnMouseOver ? (
|
||||
<span
|
||||
className="cursor-pointer hover:bg-active-word"
|
||||
onClick={handleLookup}
|
||||
>
|
||||
{word || children}
|
||||
</span>
|
||||
) : (
|
||||
<span>{word || children}</span>
|
||||
);
|
||||
};
|
||||
EnjoyApp.lookup(_word, context, { x, y });
|
||||
}, 800);
|
||||
|
||||
setTimer(_timer);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
|
||||
return vocabularyConfig.lookupOnMouseOver ? (
|
||||
<span
|
||||
className="cursor-pointer hover:bg-active-word"
|
||||
onClick={handleLookup}
|
||||
>
|
||||
{word || children}
|
||||
</span>
|
||||
) : (
|
||||
<span>{word || children}</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Vocabulary.displayName = "Vocabulary";
|
||||
|
||||
264
enjoy/src/renderer/context/document-provider.tsx
Normal file
264
enjoy/src/renderer/context/document-provider.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
DbProviderContext,
|
||||
MediaShadowProvider,
|
||||
} from "@renderer/context";
|
||||
import { toast } from "@renderer/components/ui";
|
||||
import debounce from "lodash/debounce";
|
||||
import { useSpeech } from "@renderer/hooks";
|
||||
|
||||
type DocumentProviderProps = {
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
document: DocumentEType;
|
||||
playingSegmentId: string | null;
|
||||
playingSegment: {
|
||||
id: string;
|
||||
index: number;
|
||||
text: string;
|
||||
} | null;
|
||||
nextSegment: {
|
||||
id: string;
|
||||
index: number;
|
||||
text: string;
|
||||
} | null;
|
||||
togglePlayingSegment: (segment: string | null) => void;
|
||||
section: number;
|
||||
setSection: (section: number) => void;
|
||||
onSpeech: (segment: string) => void;
|
||||
onSegmentVisible: (id: string) => void;
|
||||
locateSegment: (id: string) => HTMLElement | null;
|
||||
content: string;
|
||||
setContent: (content: string) => void;
|
||||
};
|
||||
|
||||
export const DocumentProviderContext = createContext<DocumentProviderProps>({
|
||||
ref: null,
|
||||
document: null,
|
||||
playingSegmentId: null,
|
||||
playingSegment: null,
|
||||
nextSegment: null,
|
||||
togglePlayingSegment: () => {},
|
||||
section: 0,
|
||||
setSection: () => {},
|
||||
onSpeech: () => {},
|
||||
onSegmentVisible: () => {},
|
||||
locateSegment: () => null,
|
||||
content: "",
|
||||
setContent: () => {},
|
||||
});
|
||||
|
||||
export function DocumentProvider({
|
||||
documentId,
|
||||
children,
|
||||
}: {
|
||||
documentId: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
|
||||
const { tts } = useSpeech();
|
||||
|
||||
const [document, setDocument] = useState<DocumentEType>(null);
|
||||
const [section, setSection] = useState(0);
|
||||
const [playingSegmentId, setPlayingSegmentId] = useState<string | null>(null);
|
||||
const [playingSegment, setPlayingSegment] = useState<{
|
||||
id: string;
|
||||
index: number;
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const [nextSegment, setNextSegment] = useState<{
|
||||
id: string;
|
||||
index: number;
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const [content, setContent] = useState<string>();
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const locateSegment = (id: string) => {
|
||||
return ref.current?.querySelector(`#${id}`) as HTMLElement | null;
|
||||
};
|
||||
|
||||
const findNextSegment = async (index: number) => {
|
||||
if (!document.config.autoNextSpeech) return;
|
||||
|
||||
const next: HTMLElement | null = ref.current?.querySelector(
|
||||
`[data-index="${index}"]`
|
||||
);
|
||||
if (!next) return;
|
||||
|
||||
const text = next.querySelector(".segment-content")?.textContent?.trim();
|
||||
if (!text) {
|
||||
return findNextSegment(index + 1);
|
||||
}
|
||||
|
||||
const existingSpeech = await EnjoyApp.speeches.findOne({
|
||||
sourceId: document.id,
|
||||
sourceType: "Document",
|
||||
section,
|
||||
segment: index,
|
||||
});
|
||||
|
||||
if (!existingSpeech) {
|
||||
tts({
|
||||
sourceId: document.id,
|
||||
sourceType: "Document",
|
||||
section,
|
||||
segment: index,
|
||||
text,
|
||||
configuration: document.config.tts,
|
||||
});
|
||||
}
|
||||
setNextSegment({
|
||||
id: next.id,
|
||||
index,
|
||||
text,
|
||||
});
|
||||
};
|
||||
|
||||
const onSegmentVisible = useCallback(
|
||||
(id: string) => {
|
||||
updateDocumentPosition(id);
|
||||
},
|
||||
[document]
|
||||
);
|
||||
|
||||
const updateDocumentPosition = debounce((id: string) => {
|
||||
if (!id) return;
|
||||
|
||||
const segment = locateSegment(id);
|
||||
if (!segment) return;
|
||||
|
||||
const index = segment.dataset.index || "0";
|
||||
const sectionIndex = segment.dataset.section || "0";
|
||||
|
||||
EnjoyApp.documents.update(document.id, {
|
||||
lastReadPosition: {
|
||||
section: parseInt(sectionIndex),
|
||||
segment: parseInt(index),
|
||||
},
|
||||
lastReadAt: new Date(),
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
const togglePlayingSegment = useCallback((segment: string | null) => {
|
||||
setPlayingSegmentId((prev) => (prev === segment ? null : segment));
|
||||
}, []);
|
||||
|
||||
const onSpeech = useCallback(
|
||||
(segment: string) => {
|
||||
togglePlayingSegment(segment);
|
||||
},
|
||||
[togglePlayingSegment]
|
||||
);
|
||||
|
||||
const fetchDocument = async () => {
|
||||
if (!documentId) return;
|
||||
|
||||
EnjoyApp.documents
|
||||
.findOne({ id: documentId })
|
||||
.then((doc) => {
|
||||
setDocument(doc);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDocumentUpdate = (event: CustomEvent) => {
|
||||
const { action, record } = event.detail;
|
||||
if (action === "update" && record.id === documentId) {
|
||||
setDocument(record as DocumentEType);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
if (!playingSegmentId) return;
|
||||
|
||||
const element = locateSegment(playingSegmentId);
|
||||
if (!element) return;
|
||||
|
||||
const index = parseInt(element.dataset.index || "0");
|
||||
findNextSegment(index + 1);
|
||||
setPlayingSegment({
|
||||
id: element.id,
|
||||
index,
|
||||
text: element.querySelector(".segment-content")?.textContent?.trim(),
|
||||
});
|
||||
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
element.classList.add("playing-segment", "bg-yellow-100");
|
||||
|
||||
return () => {
|
||||
setPlayingSegment(null);
|
||||
element?.classList?.remove("playing-segment", "bg-yellow-100");
|
||||
};
|
||||
}, [ref, playingSegmentId]);
|
||||
|
||||
// auto scroll to the top when new section is rendered
|
||||
useEffect(() => {
|
||||
if (!content) return;
|
||||
if (!ref?.current) return;
|
||||
|
||||
if (document.lastReadPosition.section === section) {
|
||||
const element = locateSegment(
|
||||
`segment-${document.lastReadPosition.segment || 0}`
|
||||
);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
}, [section, content]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!document) return;
|
||||
setSection(document.lastReadPosition.section || 0);
|
||||
}, [document]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocument();
|
||||
addDblistener(handleDocumentUpdate);
|
||||
|
||||
return () => {
|
||||
removeDbListener(handleDocumentUpdate);
|
||||
setDocument(null);
|
||||
};
|
||||
}, [documentId]);
|
||||
|
||||
return (
|
||||
<DocumentProviderContext.Provider
|
||||
value={{
|
||||
document,
|
||||
ref,
|
||||
playingSegmentId,
|
||||
playingSegment,
|
||||
nextSegment,
|
||||
togglePlayingSegment,
|
||||
section,
|
||||
setSection,
|
||||
onSpeech,
|
||||
onSegmentVisible,
|
||||
locateSegment,
|
||||
content,
|
||||
setContent,
|
||||
}}
|
||||
>
|
||||
<MediaShadowProvider
|
||||
layout="compact"
|
||||
onCancel={() => togglePlayingSegment(null)}
|
||||
>
|
||||
<div ref={ref}>{children}</div>
|
||||
</MediaShadowProvider>
|
||||
</DocumentProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -8,3 +8,4 @@ export * from "./hotkeys-settings-provider";
|
||||
export * from "./media-shadow-provider";
|
||||
export * from "./theme-provider";
|
||||
export * from "./dict-provider";
|
||||
export * from "./document-provider";
|
||||
|
||||
@@ -23,6 +23,8 @@ const ONE_MINUTE = 60;
|
||||
const TEN_MINUTES = 10 * ONE_MINUTE;
|
||||
|
||||
type MediaShadowContextType = {
|
||||
layout: "compact" | "normal";
|
||||
onCancel?: () => void;
|
||||
media: AudioType | VideoType;
|
||||
setMedia: (media: AudioType | VideoType) => void;
|
||||
setMediaProvider: (mediaProvider: HTMLAudioElement | null) => void;
|
||||
@@ -103,8 +105,12 @@ export const MediaShadowProviderContext =
|
||||
|
||||
export const MediaShadowProvider = ({
|
||||
children,
|
||||
layout = "normal",
|
||||
onCancel,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
layout?: "compact" | "normal";
|
||||
onCancel?: () => void;
|
||||
}) => {
|
||||
const minPxPerSec = 150;
|
||||
const { EnjoyApp, learningLanguage, recorderConfig } = useContext(
|
||||
@@ -636,6 +642,8 @@ export const MediaShadowProvider = ({
|
||||
<>
|
||||
<MediaShadowProviderContext.Provider
|
||||
value={{
|
||||
layout,
|
||||
onCancel,
|
||||
media,
|
||||
setMedia,
|
||||
setMediaProvider,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
refineCommand,
|
||||
chatSuggestionCommand,
|
||||
} from "@commands";
|
||||
import { md5 as md5Hash } from "js-md5";
|
||||
|
||||
export const useAiCommand = () => {
|
||||
const { EnjoyApp, webApi, nativeLanguage, learningLanguage } = useContext(
|
||||
@@ -99,17 +100,48 @@ export const useAiCommand = () => {
|
||||
text: string,
|
||||
cacheKey?: string
|
||||
): Promise<string> => {
|
||||
return translateCommand(text, nativeLanguage, {
|
||||
key: currentGptEngine.key,
|
||||
modelName:
|
||||
currentGptEngine.models.translate || currentGptEngine.models.default,
|
||||
baseUrl: currentGptEngine.baseUrl,
|
||||
}).then((res) => {
|
||||
if (cacheKey) {
|
||||
EnjoyApp.cacheObjects.set(cacheKey, res);
|
||||
let translatedContent = "";
|
||||
const md5 = md5Hash(text.trim());
|
||||
const engine = currentGptEngine.key;
|
||||
const modelName =
|
||||
currentGptEngine.models.translate || currentGptEngine.models.default;
|
||||
|
||||
try {
|
||||
const res = await webApi.translations({
|
||||
md5,
|
||||
translatedLanguage: nativeLanguage,
|
||||
engine: modelName,
|
||||
});
|
||||
|
||||
if (res.translations.length > 0) {
|
||||
translatedContent = res.translations[0].translatedContent;
|
||||
}
|
||||
return res;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
if (!translatedContent) {
|
||||
translatedContent = await translateCommand(text, nativeLanguage, {
|
||||
key: engine,
|
||||
modelName,
|
||||
baseUrl: currentGptEngine.baseUrl,
|
||||
});
|
||||
|
||||
webApi.createTranslation({
|
||||
md5,
|
||||
content: text,
|
||||
translatedContent,
|
||||
language: learningLanguage,
|
||||
translatedLanguage: nativeLanguage,
|
||||
engine: modelName,
|
||||
});
|
||||
}
|
||||
|
||||
if (cacheKey) {
|
||||
EnjoyApp.cacheObjects.set(cacheKey, translatedContent);
|
||||
}
|
||||
|
||||
return translatedContent;
|
||||
};
|
||||
|
||||
const analyzeText = async (text: string, cacheKey?: string) => {
|
||||
|
||||
@@ -29,6 +29,8 @@ export const useSpeech = () => {
|
||||
text: params.text,
|
||||
sourceType: params.sourceType,
|
||||
sourceId: params.sourceId,
|
||||
section: params.section,
|
||||
segment: params.segment,
|
||||
configuration: {
|
||||
engine,
|
||||
model,
|
||||
|
||||
@@ -156,3 +156,12 @@ export function imgErrorToDefalut(
|
||||
target.onerror = null;
|
||||
target.src = "assets/default-img.jpg";
|
||||
}
|
||||
|
||||
export function blobToDataUrl(blob: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
78
enjoy/src/renderer/pages/document.tsx
Normal file
78
enjoy/src/renderer/pages/document.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
Button,
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
ScrollArea,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
DocumentHtmlRenderer,
|
||||
DocumentEpubRenderer,
|
||||
DocumentPlayer,
|
||||
LoaderSpin,
|
||||
DocumentTextRenderer,
|
||||
} from "@renderer/components";
|
||||
import { useContext } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { DocumentProvider, DocumentProviderContext } from "@renderer/context";
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
|
||||
export default () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
return (
|
||||
<DocumentProvider documentId={id}>
|
||||
<DocumentComponent />
|
||||
</DocumentProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentComponent = () => {
|
||||
const { document, playingSegment } = useContext(DocumentProviderContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!document) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col justify-center items-center relative">
|
||||
<LoaderSpin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col relative">
|
||||
<div className="flex space-x-1 items-center px-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<span className="text-sm line-clamp-1">{document.title}</span>
|
||||
</div>
|
||||
|
||||
<ResizablePanelGroup direction="horizontal" className="p-4">
|
||||
<ResizablePanel id="document" order={0}>
|
||||
<ScrollArea
|
||||
className={`h-full px-4 pb-6 border rounded-lg shadow-lg ${
|
||||
playingSegment ? "" : "max-w-screen-md mx-auto"
|
||||
}`}
|
||||
>
|
||||
{document.metadata.extension === "html" && <DocumentHtmlRenderer />}
|
||||
{document.metadata.extension === "epub" && <DocumentEpubRenderer />}
|
||||
{["txt", "md", "markdown"].includes(
|
||||
document.metadata.extension
|
||||
) && <DocumentTextRenderer />}
|
||||
</ScrollArea>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
className={playingSegment ? "invisible mx-2" : "invisible"}
|
||||
/>
|
||||
<ResizablePanel
|
||||
id="player"
|
||||
order={1}
|
||||
className={playingSegment ? "" : "invisible fixed"}
|
||||
>
|
||||
<DocumentPlayer />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
67
enjoy/src/renderer/pages/documents.tsx
Normal file
67
enjoy/src/renderer/pages/documents.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Button, Input } from "@renderer/components/ui";
|
||||
import {
|
||||
DocumentAddButton,
|
||||
DocumentCard,
|
||||
LoaderSpin,
|
||||
} from "@renderer/components";
|
||||
import { useState, useContext, useEffect } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useDebounce } from "@uidotdev/usehooks";
|
||||
|
||||
export default () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [documents, setDocuments] = useState<DocumentEType[]>([]);
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const debouncedQuery = useDebounce(query, 500);
|
||||
|
||||
const fetchDocuments = () => {
|
||||
setLoading(true);
|
||||
EnjoyApp.documents
|
||||
.findAll({ query: debouncedQuery })
|
||||
.then((documents) => {
|
||||
setDocuments(documents);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments();
|
||||
}, [debouncedQuery]);
|
||||
|
||||
return (
|
||||
<div className="h-full max-w-5xl mx-auto px-4 py-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.documents")}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4 mb-4">
|
||||
<Input
|
||||
className="max-w-48"
|
||||
placeholder={t("search")}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<DocumentAddButton />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<LoaderSpin />
|
||||
) : (
|
||||
<div className="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{documents.map((document) => (
|
||||
<DocumentCard key={document.id} document={document} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
AudiosSegment,
|
||||
AudibleBooksSegment,
|
||||
StoriesSegment,
|
||||
DocumentsSegment,
|
||||
VideosSegment,
|
||||
YoutubeVideosSegment,
|
||||
EnrollmentSegment,
|
||||
@@ -41,7 +41,7 @@ export default () => {
|
||||
<EnrollmentSegment />
|
||||
<AudiosSegment />
|
||||
<VideosSegment />
|
||||
<StoriesSegment />
|
||||
<DocumentsSegment />
|
||||
<AudibleBooksSegment />
|
||||
{channels.map((channel) => (
|
||||
<YoutubeVideosSegment key={channel} channel={channel} />
|
||||
|
||||
@@ -11,6 +11,8 @@ import Audios from "./pages/audios";
|
||||
import Videos from "./pages/videos";
|
||||
import Stories from "./pages/stories";
|
||||
import Story from "./pages/story";
|
||||
import Documents from "./pages/documents";
|
||||
import Document from "./pages/document";
|
||||
import Books from "./pages/books";
|
||||
import Profile from "./pages/profile";
|
||||
import User from "./pages/user";
|
||||
@@ -96,6 +98,14 @@ export default createHashRouter([
|
||||
path: "/videos/:id",
|
||||
element: <Video />,
|
||||
},
|
||||
{
|
||||
path: "/documents",
|
||||
element: <Documents />,
|
||||
},
|
||||
{
|
||||
path: "/documents/:id",
|
||||
element: <Document />,
|
||||
},
|
||||
{
|
||||
path: "/stories",
|
||||
element: <Stories />,
|
||||
|
||||
22
enjoy/src/types/document.d.ts
vendored
Normal file
22
enjoy/src/types/document.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
type DocumentEType = {
|
||||
id: string;
|
||||
language: string;
|
||||
md5: string;
|
||||
title: string;
|
||||
metadata: Record<string, any>;
|
||||
config: Record<string, any>;
|
||||
autoTranslate: boolean;
|
||||
autoNextSpeech: boolean;
|
||||
ttsConfig: Record<string, any>;
|
||||
lastReadPosition: Record<string, any>;
|
||||
lastReadAt: Date;
|
||||
syncedAt: Date;
|
||||
uploadedAt: Date;
|
||||
updatedAt: Date;
|
||||
createdAt: Date;
|
||||
src?: string;
|
||||
filePath?: string;
|
||||
isSynced?: boolean;
|
||||
isUploaded?: boolean;
|
||||
sync(): Promise<void>;
|
||||
};
|
||||
12
enjoy/src/types/enjoy-app.d.ts
vendored
12
enjoy/src/types/enjoy-app.d.ts
vendored
@@ -260,6 +260,8 @@ type EnjoyAppType = {
|
||||
sourceId: string;
|
||||
sourceType: string;
|
||||
text: string;
|
||||
section?: number;
|
||||
segment?: number;
|
||||
configuration: {
|
||||
engine: string;
|
||||
model: string;
|
||||
@@ -271,6 +273,7 @@ type EnjoyAppType = {
|
||||
arrayBuffer: ArrayBuffer;
|
||||
}
|
||||
) => Promise<SpeechType>;
|
||||
delete: (id: string) => Promise<void>;
|
||||
};
|
||||
echogarden: {
|
||||
recognize: (
|
||||
@@ -395,4 +398,13 @@ type EnjoyAppType = {
|
||||
update: (id: string, params: any) => Promise<ChatMessageType>;
|
||||
destroy: (id: string) => Promise<ChatMessageType>;
|
||||
};
|
||||
documents: {
|
||||
findAll: (params?: any) => Promise<DocumentEType[]>;
|
||||
findOne: (params: any) => Promise<DocumentEType>;
|
||||
create: (params: any) => Promise<DocumentEType>;
|
||||
update: (id: string, params: any) => Promise<DocumentEType>;
|
||||
destroy: (id: string) => Promise<void>;
|
||||
upload: (id: string) => Promise<void>;
|
||||
cleanUp: () => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
2
enjoy/src/types/index.d.ts
vendored
2
enjoy/src/types/index.d.ts
vendored
@@ -3,6 +3,8 @@
|
||||
// whether you're running in development or production).
|
||||
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
|
||||
declare const MAIN_WINDOW_VITE_NAME: string;
|
||||
declare module "foliate-js/view.js";
|
||||
declare module "foliate-js/epub.js";
|
||||
declare module "compromise-paragraphs";
|
||||
|
||||
type SupportedLlmProviderType = "enjoyai" | "openai";
|
||||
|
||||
4
enjoy/src/types/speech.d.ts
vendored
4
enjoy/src/types/speech.d.ts
vendored
@@ -4,13 +4,15 @@ type SpeechType = {
|
||||
sourceType: string;
|
||||
source?: MessageType;
|
||||
text: string;
|
||||
section: number;
|
||||
segment: number;
|
||||
engine: string;
|
||||
model: string;
|
||||
voice: string;
|
||||
md5: string;
|
||||
filename: string;
|
||||
filePath: string;
|
||||
configuration: {[key: string]: any};
|
||||
configuration: { [key: string]: any };
|
||||
src?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
9
enjoy/src/types/translation.d.ts
vendored
Normal file
9
enjoy/src/types/translation.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
type TranslationType = {
|
||||
id: string;
|
||||
md5: string;
|
||||
content: string;
|
||||
translatedContent: string;
|
||||
language: string;
|
||||
translatedLanguage: string;
|
||||
engine: string;
|
||||
};
|
||||
Reference in New Issue
Block a user