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:
an-lee
2024-06-25 20:05:09 +08:00
committed by GitHub
parent 0dc17de9f6
commit 0e4c1f4ba4
9 changed files with 412 additions and 245 deletions

View File

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

View File

@@ -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": "没有数据"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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