Feat: import audio/video in batch (#715)
* refactor audios table * import audio/video in batch * add search & filter for audios/videos * fix locale
This commit is contained in:
@@ -295,6 +295,8 @@
|
||||
"text": "text",
|
||||
"addResource": "add resource",
|
||||
"addResourceFromUrlOrLocal": "add resource from url or local",
|
||||
"resourceAdded": "Resource added",
|
||||
"resourcesAdded": "{{fulfilled}} resources added, {{rejected}} failed to add.",
|
||||
"editResource": "edit resource",
|
||||
"deleteResource": "delete resource",
|
||||
"deleteResourceConfirmation": "Are you sure to delete {{name}}?",
|
||||
@@ -611,6 +613,14 @@
|
||||
"sortBy": "Sort by",
|
||||
"createdAtDesc": "Created at desc",
|
||||
"createdAtAsc": "Created at asc",
|
||||
"updatedAtDesc": "Updated at desc",
|
||||
"updatedAtAsc": "Updated at asc",
|
||||
"scoreDesc": "Score desc",
|
||||
"scoreAsc": "Score asc"
|
||||
"scoreAsc": "Score asc",
|
||||
"recordingsDurationDesc": "Recordings duration",
|
||||
"recordingsCountDesc": "Recordings duration",
|
||||
"all": "All",
|
||||
"allLanguages": "All languages",
|
||||
"search": "Search",
|
||||
"noData": "No data"
|
||||
}
|
||||
|
||||
@@ -295,6 +295,8 @@
|
||||
"text": "文本",
|
||||
"addResource": "添加资源",
|
||||
"addResourceFromUrlOrLocal": "添加资源, 可以是 URL 或本地文件",
|
||||
"resourceAdded": "资源添加成功",
|
||||
"resourcesAdded": "成功添加 {{fulfilled}} 个资源, {{rejected}} 个资源添加失败",
|
||||
"editResource": "编辑资源",
|
||||
"deleteResource": "删除资源",
|
||||
"deleteResourceConfirmation": "您确定要删除资源 {{name}} 吗?",
|
||||
@@ -611,6 +613,14 @@
|
||||
"sortBy": "排序",
|
||||
"createdAtDesc": "创建时间降序",
|
||||
"createdAtAsc": "创建时间升序",
|
||||
"updatedAtDesc": "更新时间降序",
|
||||
"updatedAtAsc": "更新时间升序",
|
||||
"scoreDesc": "得分从高到低",
|
||||
"scoreAsc": "得分从低到高"
|
||||
"scoreAsc": "得分从低到高",
|
||||
"recordingsDurationDesc": "录音时长",
|
||||
"recordingsCountDesc": "录音次数",
|
||||
"all": "全部",
|
||||
"allLanguages": "全部语言",
|
||||
"search": "搜索",
|
||||
"noData": "没有数据"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ipcMain, IpcMainEvent } from "electron";
|
||||
import { Audio, Transcription } from "@main/db/models";
|
||||
import { FindOptions, WhereOptions, Attributes } from "sequelize";
|
||||
import { FindOptions, WhereOptions, Attributes, Op } from "sequelize";
|
||||
import downloader from "@main/downloader";
|
||||
import log from "@main/logger";
|
||||
import { t } from "i18next";
|
||||
@@ -12,8 +12,17 @@ const logger = log.scope("db/handlers/audios-handler");
|
||||
class AudiosHandler {
|
||||
private async findAll(
|
||||
_event: IpcMainEvent,
|
||||
options: FindOptions<Attributes<Audio>>
|
||||
options: FindOptions<Attributes<Audio>> & { query?: string }
|
||||
) {
|
||||
const { query, where = {} } = options || {};
|
||||
delete options.query;
|
||||
delete options.where;
|
||||
|
||||
if (query) {
|
||||
(where as any).name = {
|
||||
[Op.like]: `%${query}%`,
|
||||
};
|
||||
}
|
||||
const audios = await Audio.findAll({
|
||||
order: [["updatedAt", "DESC"]],
|
||||
include: [
|
||||
@@ -24,6 +33,7 @@ class AudiosHandler {
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
where,
|
||||
...options,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ipcMain, IpcMainEvent } from "electron";
|
||||
import { Video, Transcription } from "@main/db/models";
|
||||
import { FindOptions, WhereOptions, Attributes } from "sequelize";
|
||||
import { FindOptions, WhereOptions, Attributes, Op } from "sequelize";
|
||||
import downloader from "@main/downloader";
|
||||
import log from "@main/logger";
|
||||
import { t } from "i18next";
|
||||
@@ -12,8 +12,17 @@ const logger = log.scope("db/handlers/videos-handler");
|
||||
class VideosHandler {
|
||||
private async findAll(
|
||||
_event: IpcMainEvent,
|
||||
options: FindOptions<Attributes<Video>>
|
||||
options: FindOptions<Attributes<Video>> & { query?: string }
|
||||
) {
|
||||
const { query, where = {} } = options || {};
|
||||
delete options.query;
|
||||
delete options.where;
|
||||
|
||||
if (query) {
|
||||
(where as any).name = {
|
||||
[Op.like]: `%${query}%`,
|
||||
};
|
||||
}
|
||||
const videos = await Video.findAll({
|
||||
order: [["updatedAt", "DESC"]],
|
||||
include: [
|
||||
@@ -24,6 +33,7 @@ class VideosHandler {
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
where,
|
||||
...options,
|
||||
});
|
||||
if (!videos) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
AddMediaButton,
|
||||
AudiosTable,
|
||||
AudioEditForm,
|
||||
LoaderSpin,
|
||||
} from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
@@ -26,6 +25,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
toast,
|
||||
Input,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
DbProviderContext,
|
||||
@@ -33,59 +39,82 @@ import {
|
||||
} from "@renderer/context";
|
||||
import { LayoutGridIcon, LayoutListIcon } from "lucide-react";
|
||||
import { audiosReducer } from "@renderer/reducers";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranscribe } from "@renderer/hooks";
|
||||
import { useDebounce } from "@uidotdev/usehooks";
|
||||
import { LANGUAGES } from "@/constants";
|
||||
|
||||
export const AudiosComponent = () => {
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const [audios, dispatchAudios] = useReducer(audiosReducer, []);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [language, setLanguage] = useState<string | null>("all");
|
||||
const [orderBy, setOrderBy] = useState<string | null>("updatedAtDesc");
|
||||
const debouncedQuery = useDebounce(query, 500);
|
||||
|
||||
const [editing, setEditing] = useState<Partial<AudioType> | null>(null);
|
||||
const [deleting, setDeleting] = useState<Partial<AudioType> | null>(null);
|
||||
const [transcribing, setTranscribing] = useState<Partial<AudioType> | null>(
|
||||
null
|
||||
);
|
||||
const { transcribe } = useTranscribe();
|
||||
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [offset, setOffest] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
addDblistener(onAudiosUpdate);
|
||||
fetchAudios();
|
||||
|
||||
return () => {
|
||||
removeDbListener(onAudiosUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchAudios = async () => {
|
||||
const fetchAudios = async (options?: { offset: number }) => {
|
||||
if (loading) return;
|
||||
if (offset === -1) return;
|
||||
const { offset = audios.length } = options || {};
|
||||
|
||||
setLoading(true);
|
||||
const limit = 10;
|
||||
const limit = 20;
|
||||
|
||||
let order = [];
|
||||
switch (orderBy) {
|
||||
case "updatedAtDesc":
|
||||
order = [["updatedAt", "DESC"]];
|
||||
break;
|
||||
case "createdAtDesc":
|
||||
order = [["createdAt", "DESC"]];
|
||||
break;
|
||||
case "createdAtAsc":
|
||||
order = [["createdAt", "ASC"]];
|
||||
break;
|
||||
case "recordingsDurationDesc":
|
||||
order = [["recordingsDuration", "DESC"]];
|
||||
break;
|
||||
case "recordingsCountDesc":
|
||||
order = [["recordingsCount", "DESC"]];
|
||||
break;
|
||||
default:
|
||||
order = [["updatedAt", "DESC"]];
|
||||
}
|
||||
let where = {};
|
||||
if (language != "all") {
|
||||
where = { language };
|
||||
}
|
||||
|
||||
EnjoyApp.audios
|
||||
.findAll({
|
||||
offset,
|
||||
limit,
|
||||
order,
|
||||
where,
|
||||
query: debouncedQuery,
|
||||
|
||||
})
|
||||
.then((_audios) => {
|
||||
if (_audios.length === 0) {
|
||||
setOffest(-1);
|
||||
return;
|
||||
}
|
||||
setHasMore(_audios.length >= limit);
|
||||
|
||||
if (_audios.length < limit) {
|
||||
setOffest(-1);
|
||||
if (offset === 0) {
|
||||
dispatchAudios({ type: "set", records: _audios });
|
||||
} else {
|
||||
setOffest(offset + _audios.length);
|
||||
dispatchAudios({ type: "append", records: _audios });
|
||||
}
|
||||
|
||||
dispatchAudios({ type: "append", records: _audios });
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
@@ -100,14 +129,13 @@ export const AudiosComponent = () => {
|
||||
if (!record) return;
|
||||
|
||||
if (model === "Audio") {
|
||||
if (action === "create") {
|
||||
dispatchAudios({ type: "create", record });
|
||||
navigate(`/audios/${record.id}`);
|
||||
} else if (action === "destroy") {
|
||||
if (action === "destroy") {
|
||||
dispatchAudios({ type: "destroy", record });
|
||||
} else if (action === "create") {
|
||||
dispatchAudios({ type: "create", record });
|
||||
} else if (action === "update") {
|
||||
dispatchAudios({ type: "update", record });
|
||||
}
|
||||
} else if (model === "Video" && action === "create") {
|
||||
navigate(`/videos/${record.id}`);
|
||||
} else if (model === "Transcription" && action === "update") {
|
||||
dispatchAudios({
|
||||
type: "update",
|
||||
@@ -120,54 +148,103 @@ export const AudiosComponent = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (audios.length === 0) {
|
||||
if (loading) return <LoaderSpin />;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
|
||||
<AddMediaButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
useEffect(() => {
|
||||
fetchAudios({ offset: 0 });
|
||||
}, [debouncedQuery, language, orderBy]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="">
|
||||
<Tabs defaultValue="grid">
|
||||
<div className="flex justify-between mb-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="grid">
|
||||
<LayoutGridIcon className="h-4 w-4" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="list">
|
||||
<LayoutListIcon className="h-4 w-4" />
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<AddMediaButton />
|
||||
</div>
|
||||
<TabsContent value="grid">
|
||||
<div className="grid gap-4 grid-cols-5">
|
||||
{audios.map((audio) => (
|
||||
<AudioCard audio={audio} key={audio.id} />
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="list">
|
||||
<AudiosTable
|
||||
audios={audios}
|
||||
onEdit={(audio) => setEditing(audio)}
|
||||
onDelete={(audio) => setDeleting(audio)}
|
||||
onTranscribe={(audio) => setTranscribing(audio)}
|
||||
<div className="flex flex-wrap items-center gap-4 mb-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="grid">
|
||||
<LayoutGridIcon className="h-4 w-4" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="list">
|
||||
<LayoutListIcon className="h-4 w-4" />
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Select value={orderBy} onValueChange={setOrderBy}>
|
||||
<SelectTrigger className="max-w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="updatedAtDesc">
|
||||
{t("updatedAtDesc")}
|
||||
</SelectItem>
|
||||
<SelectItem value="createdAtDesc">
|
||||
{t("createdAtDesc")}
|
||||
</SelectItem>
|
||||
<SelectItem value="createdAtAsc">
|
||||
{t("createdAtAsc")}
|
||||
</SelectItem>
|
||||
<SelectItem value="recordingsDurationDesc">
|
||||
{t("recordingsDurationDesc")}
|
||||
</SelectItem>
|
||||
<SelectItem value="recordingsCountDesc">
|
||||
{t("recordingsCountDesc")}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={language} onValueChange={setLanguage}>
|
||||
<SelectTrigger className="max-w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">{t("allLanguages")}</SelectItem>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
className="max-w-48"
|
||||
placeholder={t("search")}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<AddMediaButton type="Audio" />
|
||||
</div>
|
||||
|
||||
{audios.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
|
||||
{t("noData")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TabsContent value="grid">
|
||||
<div className="grid gap-4 grid-cols-5">
|
||||
{audios.map((audio) => (
|
||||
<AudioCard audio={audio} key={audio.id} />
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="list">
|
||||
<AudiosTable
|
||||
audios={audios}
|
||||
onEdit={(audio) => setEditing(audio)}
|
||||
onDelete={(audio) => setDeleting(audio)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{offset > -1 && (
|
||||
{hasMore && (
|
||||
<div className="flex items-center justify-center my-4">
|
||||
<Button variant="link" onClick={fetchAudios}>
|
||||
<Button variant="link" onClick={() => fetchAudios()}>
|
||||
{t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -226,45 +303,6 @@ export const AudiosComponent = () => {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog
|
||||
open={!!transcribing}
|
||||
onOpenChange={(value) => {
|
||||
if (value) return;
|
||||
setTranscribing(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("transcribe")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<p className="break-all">
|
||||
{t("transcribeAudioConfirmation", {
|
||||
name: transcribing?.name || "",
|
||||
})}
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive"
|
||||
onClick={async () => {
|
||||
if (!transcribing) return;
|
||||
|
||||
transcribe(transcribing.src, {
|
||||
targetId: transcribing.id,
|
||||
targetType: "Audio",
|
||||
}).finally(() => {
|
||||
setTranscribing(null);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("transcribe")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,13 +12,9 @@ import {
|
||||
TooltipTrigger,
|
||||
Button,
|
||||
PingPoint,
|
||||
Badge,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
EditIcon,
|
||||
TrashIcon,
|
||||
CheckCircleIcon,
|
||||
AudioWaveformIcon,
|
||||
} from "lucide-react";
|
||||
import { EditIcon, TrashIcon, CheckCircleIcon } from "lucide-react";
|
||||
import dayjs from "@renderer/lib/dayjs";
|
||||
import { secondsToTimestamp } from "@renderer/lib/utils";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -27,15 +23,15 @@ export const AudiosTable = (props: {
|
||||
audios: Partial<AudioType>[];
|
||||
onEdit: (audio: Partial<AudioType>) => void;
|
||||
onDelete: (audio: Partial<AudioType>) => void;
|
||||
onTranscribe: (audio: Partial<AudioType>) => void;
|
||||
}) => {
|
||||
const { audios, onEdit, onDelete, onTranscribe } = props;
|
||||
const { audios, onEdit, onDelete } = props;
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="capitalize">{t("models.audio.name")}</TableHead>
|
||||
<TableHead className="capitalize">{t("language")}</TableHead>
|
||||
<TableHead className="capitalize">
|
||||
{t("models.audio.duration")}
|
||||
</TableHead>
|
||||
@@ -75,6 +71,7 @@ export const AudiosTable = (props: {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell>{audio.language ? audio.language : "-"}</TableCell>
|
||||
<TableCell>
|
||||
{audio.duration ? secondsToTimestamp(audio.duration) : "-"}
|
||||
</TableCell>
|
||||
@@ -96,13 +93,6 @@ export const AudiosTable = (props: {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
title={t("transcribe")}
|
||||
variant="ghost"
|
||||
onClick={() => onTranscribe(Object.assign({}, audio))}
|
||||
>
|
||||
<AudioWaveformIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
title={t("edit")}
|
||||
variant="ghost"
|
||||
|
||||
@@ -8,18 +8,27 @@ import {
|
||||
DialogFooter,
|
||||
Input,
|
||||
Button,
|
||||
Progress,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { PlusCircleIcon, LoaderIcon } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { useState, useContext } from "react";
|
||||
import { useState, useContext, useEffect } from "react";
|
||||
import { AudioFormats, VideoFormats } from "@/constants";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import {
|
||||
AppSettingsProviderContext,
|
||||
DbProviderContext,
|
||||
} from "@renderer/context";
|
||||
|
||||
export const AddMediaButton = () => {
|
||||
export const AddMediaButton = (props: { type?: "Audio" | "Video" }) => {
|
||||
const { type = "Audio" } = props;
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
const [uri, setUri] = useState("");
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [createdCount, setCreatedCount] = useState(0);
|
||||
|
||||
const handleOpen = (value: boolean) => {
|
||||
if (submitting) {
|
||||
@@ -33,12 +42,64 @@ export const AddMediaButton = () => {
|
||||
if (!uri) return;
|
||||
setSubmitting(true);
|
||||
|
||||
EnjoyApp.audios.create(uri).finally(() => {
|
||||
setTimeout(() => {
|
||||
setSubmitting(false);
|
||||
setOpen(false);
|
||||
});
|
||||
}, 3000);
|
||||
|
||||
if (files.length > 0) {
|
||||
Promise.allSettled(files.map((f) => EnjoyApp.audios.create(f)))
|
||||
.then((results) => {
|
||||
const fulfilled = results.filter(
|
||||
(r) => r.status === "fulfilled"
|
||||
).length;
|
||||
const rejected = results.filter(
|
||||
(r) => r.status === "rejected"
|
||||
).length;
|
||||
|
||||
toast.success(t("resourcesAdded", { fulfilled, rejected }));
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setSubmitting(false);
|
||||
setOpen(false);
|
||||
});
|
||||
} else {
|
||||
EnjoyApp.audios
|
||||
.create(uri)
|
||||
.then(() => {
|
||||
toast.success(t("resourceAdded"));
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setSubmitting(false);
|
||||
setOpen(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onMediaCreate = (event: CustomEvent) => {
|
||||
const { record, action, model } = event.detail || {};
|
||||
if (!record) return;
|
||||
if (action !== "create") return;
|
||||
if (model !== type) return;
|
||||
|
||||
setCreatedCount((count) => count + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (submitting) {
|
||||
addDblistener(onMediaCreate);
|
||||
}
|
||||
|
||||
return () => {
|
||||
removeDbListener(onMediaCreate);
|
||||
};
|
||||
}, [submitting]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -55,29 +116,33 @@ export const AddMediaButton = () => {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex space-x-2 mb-6">
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
placeholder="https://"
|
||||
value={uri}
|
||||
disabled={submitting}
|
||||
onChange={(element) => {
|
||||
setUri(element.target.value);
|
||||
setFiles([]);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="capitalize min-w-max"
|
||||
disabled={submitting}
|
||||
onClick={async () => {
|
||||
const files = await EnjoyApp.dialog.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
const selected = await EnjoyApp.dialog.showOpenDialog({
|
||||
properties: ["openFile", "multiSelections"],
|
||||
filters: [
|
||||
{
|
||||
name: "audio,video",
|
||||
extensions: [...AudioFormats, ...VideoFormats],
|
||||
extensions: type === "Audio" ? AudioFormats : VideoFormats,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (files) {
|
||||
setUri(files[0]);
|
||||
if (selected) {
|
||||
setFiles(selected);
|
||||
setUri(selected[0]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -85,11 +150,26 @@ export const AddMediaButton = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="">
|
||||
{t("selectedFiles")}: {files.length}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length > 0 && submitting && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={(createdCount * 100.0) / files.length} max={100} />
|
||||
<span>
|
||||
{createdCount}/{files.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setSubmitting(false);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
@@ -97,7 +177,7 @@ export const AddMediaButton = () => {
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!uri || submitting}
|
||||
disabled={(!uri && files.length === 0) || submitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting && <LoaderIcon className="animate-spin w-4 mr-2" />}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
VideosTable,
|
||||
VideoEditForm,
|
||||
AddMediaButton,
|
||||
LoaderSpin,
|
||||
} from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
@@ -25,7 +24,14 @@ import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
toast,
|
||||
Input,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
DbProviderContext,
|
||||
@@ -33,59 +39,81 @@ import {
|
||||
} from "@renderer/context";
|
||||
import { LayoutGridIcon, LayoutListIcon } from "lucide-react";
|
||||
import { videosReducer } from "@renderer/reducers";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranscribe } from "@renderer/hooks";
|
||||
import { useDebounce } from "@uidotdev/usehooks";
|
||||
import { LANGUAGES } from "@/constants";
|
||||
|
||||
export const VideosComponent = () => {
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const [videos, dispatchVideos] = useReducer(videosReducer, []);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [language, setLanguage] = useState<string | null>("all");
|
||||
const [orderBy, setOrderBy] = useState<string | null>("updatedAtDesc");
|
||||
const debouncedQuery = useDebounce(query, 500);
|
||||
|
||||
const [editing, setEditing] = useState<Partial<VideoType> | null>(null);
|
||||
const [deleting, setDeleting] = useState<Partial<VideoType> | null>(null);
|
||||
const [transcribing, setTranscribing] = useState<Partial<VideoType> | null>(
|
||||
null
|
||||
);
|
||||
const { transcribe } = useTranscribe();
|
||||
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [offset, setOffest] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
addDblistener(onVideosUpdate);
|
||||
fetchVideos();
|
||||
|
||||
return () => {
|
||||
removeDbListener(onVideosUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchVideos = async () => {
|
||||
const fetchVideos = async (options?: { offset: number }) => {
|
||||
if (loading) return;
|
||||
if (offset === -1) return;
|
||||
const { offset = videos.length } = options || {};
|
||||
|
||||
setLoading(true);
|
||||
const limit = 10;
|
||||
const limit = 20;
|
||||
|
||||
let order = [];
|
||||
switch (orderBy) {
|
||||
case "updatedAtDesc":
|
||||
order = [["updatedAt", "DESC"]];
|
||||
break;
|
||||
case "createdAtDesc":
|
||||
order = [["createdAt", "DESC"]];
|
||||
break;
|
||||
case "createdAtAsc":
|
||||
order = [["createdAt", "ASC"]];
|
||||
break;
|
||||
case "recordingsDurationDesc":
|
||||
order = [["recordingsDuration", "DESC"]];
|
||||
break;
|
||||
case "recordingsCountDesc":
|
||||
order = [["recordingsCount", "DESC"]];
|
||||
break;
|
||||
default:
|
||||
order = [["updatedAt", "DESC"]];
|
||||
}
|
||||
let where = {};
|
||||
if (language != "all") {
|
||||
where = { language };
|
||||
}
|
||||
EnjoyApp.videos
|
||||
.findAll({
|
||||
offset,
|
||||
limit,
|
||||
order,
|
||||
where,
|
||||
query: debouncedQuery,
|
||||
})
|
||||
.then((_videos) => {
|
||||
if (_videos.length === 0) {
|
||||
setOffest(-1);
|
||||
return;
|
||||
}
|
||||
setHasMore(_videos.length >= limit);
|
||||
|
||||
if (_videos.length < limit) {
|
||||
setOffest(-1);
|
||||
if (offset === 0) {
|
||||
dispatchVideos({ type: "set", records: _videos });
|
||||
} else {
|
||||
setOffest(offset + _videos.length);
|
||||
dispatchVideos({ type: "append", records: _videos });
|
||||
}
|
||||
|
||||
dispatchVideos({ type: "append", records: _videos });
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
@@ -102,12 +130,11 @@ export const VideosComponent = () => {
|
||||
if (model === "Video") {
|
||||
if (action === "create") {
|
||||
dispatchVideos({ type: "create", record });
|
||||
navigate(`/videos/${record.id}`);
|
||||
} else if (action === "update") {
|
||||
dispatchVideos({ type: "update", record });
|
||||
} else if (action === "destroy") {
|
||||
dispatchVideos({ type: "destroy", record });
|
||||
}
|
||||
} else if (model === "Audio" && action === "create") {
|
||||
navigate(`/audios/${record.id}`);
|
||||
} else if (model === "Transcription" && action === "update") {
|
||||
dispatchVideos({
|
||||
type: "update",
|
||||
@@ -120,32 +147,70 @@ export const VideosComponent = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (videos.length === 0) {
|
||||
if (loading) return <LoaderSpin />;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
|
||||
<AddMediaButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
useEffect(() => {
|
||||
fetchVideos({ offset: 0 });
|
||||
}, [debouncedQuery, language, orderBy]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="">
|
||||
<Tabs defaultValue="grid">
|
||||
<div className="flex justify-between mb-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="grid">
|
||||
<LayoutGridIcon className="h-4 w-4" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="list">
|
||||
<LayoutListIcon className="h-4 w-4" />
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<AddMediaButton />
|
||||
<div className="flex flex-wrap items-center gap-4 mb-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="grid">
|
||||
<LayoutGridIcon className="h-4 w-4" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="list">
|
||||
<LayoutListIcon className="h-4 w-4" />
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Select value={orderBy} onValueChange={setOrderBy}>
|
||||
<SelectTrigger className="max-w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="updatedAtDesc">
|
||||
{t("updatedAtDesc")}
|
||||
</SelectItem>
|
||||
<SelectItem value="createdAtDesc">
|
||||
{t("createdAtDesc")}
|
||||
</SelectItem>
|
||||
<SelectItem value="createdAtAsc">
|
||||
{t("createdAtAsc")}
|
||||
</SelectItem>
|
||||
<SelectItem value="recordingsDurationDesc">
|
||||
{t("recordingsDurationDesc")}
|
||||
</SelectItem>
|
||||
<SelectItem value="recordingsCountDesc">
|
||||
{t("recordingsCountDesc")}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={language} onValueChange={setLanguage}>
|
||||
<SelectTrigger className="max-w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">{t("allLanguages")}</SelectItem>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
className="max-w-48"
|
||||
placeholder={t("search")}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<AddMediaButton type="Video" />
|
||||
</div>
|
||||
<TabsContent value="grid">
|
||||
<div className="grid gap-4 grid-cols-4">
|
||||
@@ -159,15 +224,14 @@ export const VideosComponent = () => {
|
||||
videos={videos}
|
||||
onEdit={(video) => setEditing(video)}
|
||||
onDelete={(video) => setDeleting(video)}
|
||||
onTranscribe={(video) => setTranscribing(video)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{offset > -1 && (
|
||||
{hasMore && (
|
||||
<div className="flex items-center justify-center my-4">
|
||||
<Button variant="link" onClick={fetchVideos}>
|
||||
<Button variant="link" onClick={() => fetchVideos()}>
|
||||
{t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -226,45 +290,6 @@ export const VideosComponent = () => {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog
|
||||
open={!!transcribing}
|
||||
onOpenChange={(value) => {
|
||||
if (value) return;
|
||||
setTranscribing(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("transcribe")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<p className="break-all">
|
||||
{t("transcribeMediaConfirmation", {
|
||||
name: transcribing?.name || "",
|
||||
})}
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive"
|
||||
onClick={async () => {
|
||||
if (!transcribing) return;
|
||||
|
||||
transcribe(transcribing.src, {
|
||||
targetId: transcribing.id,
|
||||
targetType: "Video",
|
||||
}).finally(() => {
|
||||
setTranscribing(null);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("transcribe")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,15 +27,15 @@ export const VideosTable = (props: {
|
||||
videos: Partial<VideoType>[];
|
||||
onEdit: (video: Partial<VideoType>) => void;
|
||||
onDelete: (video: Partial<VideoType>) => void;
|
||||
onTranscribe: (video: Partial<VideoType>) => void;
|
||||
}) => {
|
||||
const { videos, onEdit, onDelete, onTranscribe } = props;
|
||||
const { videos, onEdit, onDelete } = props;
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="capitalize">{t("models.video.name")}</TableHead>
|
||||
<TableHead className="capitalize">{t("language")}</TableHead>
|
||||
<TableHead className="capitalize">
|
||||
{t("models.video.duration")}
|
||||
</TableHead>
|
||||
@@ -75,6 +75,7 @@ export const VideosTable = (props: {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
<TableCell>{video.language ? video.language : "-"}</TableCell>
|
||||
<TableCell>
|
||||
{video.duration ? secondsToTimestamp(video.duration) : "-"}
|
||||
</TableCell>
|
||||
@@ -96,13 +97,6 @@ export const VideosTable = (props: {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
title={t("transcribe")}
|
||||
variant="ghost"
|
||||
onClick={() => onTranscribe(Object.assign({}, video))}
|
||||
>
|
||||
<AudioWaveformIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
title={t("edit")}
|
||||
variant="ghost"
|
||||
|
||||
Reference in New Issue
Block a user