Refactor current recording component (#691)

* upgrade deps

* update buttons order

* auto assess when modal open

* display assess score in recording list

* display assess mark on transcription list

* auto scroll to current segment

* toast may close

* update ui components
This commit is contained in:
an-lee
2024-06-20 13:25:56 +08:00
committed by GitHub
parent d3e93ec39b
commit 814be8369d
16 changed files with 1112 additions and 738 deletions

View File

@@ -8,7 +8,7 @@
"markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0",
"mermaid": "^10.9.1",
"sass": "^1.77.5",
"sass": "^1.77.6",
"vitepress": "^1.2.3",
"vitepress-plugin-mermaid": "^2.0.16",
"vue": "^3.4.29"

View File

@@ -52,17 +52,17 @@
"@types/intl-tel-input": "^18.1.4",
"@types/lodash": "^4.17.5",
"@types/mark.js": "^8.11.12",
"@types/node": "^20.14.2",
"@types/node": "^20.14.6",
"@types/rails__actioncable": "^6.1.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/validator": "^13.12.0",
"@types/wavesurfer.js": "^6.0.12",
"@typescript-eslint/eslint-plugin": "^7.13.0",
"@typescript-eslint/parser": "^7.13.0",
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"electron": "^31.0.1",
"electron": "^31.0.2",
"electron-playwright-helpers": "^1.7.1",
"eslint": "^9.5.0",
"eslint-import-resolver-typescript": "^3.6.1",
@@ -79,41 +79,41 @@
"typescript": "^5.4.5",
"vite": "^5.3.1",
"vite-plugin-static-copy": "^1.0.5",
"zx": "^8.1.2"
"zx": "^8.1.3"
},
"dependencies": {
"@andrkrn/ffprobe-static": "^5.2.0",
"@electron-forge/publisher-s3": "^7.4.0",
"@hookform/resolvers": "^3.6.0",
"@langchain/community": "^0.2.11",
"@langchain/google-genai": "^0.0.17",
"@langchain/community": "^0.2.12",
"@langchain/google-genai": "^0.0.20",
"@mozilla/readability": "^0.5.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.0",
"@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.0",
"@radix-ui/react-hover-card": "^1.1.0",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-menubar": "^1.0.4",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7",
"@rails/actioncable": "7.1.3",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-menubar": "^1.1.0",
"@radix-ui/react-popover": "^1.1.0",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.0",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.0",
"@rails/actioncable": "7.1.3-4",
"@sentry/electron": "^5.1.0",
"@uidotdev/usehooks": "^2.4.1",
"@vidstack/react": "^1.11.22",
"@vidstack/react": "^1.11.23",
"ahoy.js": "^0.4.4",
"autosize": "^6.0.1",
"axios": "^1.7.2",
@@ -142,7 +142,7 @@
"html-to-text": "^9.0.5",
"https-proxy-agent": "^7.0.4",
"i18next": "^23.11.5",
"intl-tel-input": "^23.0.12",
"intl-tel-input": "^23.1.0",
"js-md5": "^0.8.3",
"langchain": "^0.2.5",
"lodash": "^4.17.21",
@@ -150,7 +150,7 @@
"mark.js": "^8.11.1",
"microsoft-cognitiveservices-speech-sdk": "^1.38.0",
"next-themes": "^0.3.0",
"openai": "^4.51.0",
"openai": "^4.52.0",
"pitchfinder": "^2.3.2",
"postcss": "^8.4.38",
"proxy-agent": "^6.4.0",
@@ -174,6 +174,6 @@
"update-electron-app": "^3.0.0",
"wavesurfer.js": "^7.7.15",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.23.0"
"zod-to-json-schema": "^3.23.1"
}
}

View File

@@ -174,7 +174,7 @@ class RecordingsHandler {
throw new Error(t("models.recording.notFound"));
}
const assessment = await recording.assess(language)
const assessment = await recording.assess(language);
return assessment.toJSON();
}
@@ -307,6 +307,20 @@ class RecordingsHandler {
targetId,
targetType,
},
include: [
{
model: PronunciationAssessment,
attributes: [
[
Sequelize.fn(
"MAX",
Sequelize.col("pronunciation_score")
),
"pronunciationScore",
],
],
},
],
attributes: [
"targetId",
"targetType",

View File

@@ -39,7 +39,7 @@ function App() {
<AISettingsProvider>
<DbProvider>
<RouterProvider router={router} />
<Toaster richColors position="top-center" />
<Toaster richColors closeButton position="top-center" />
<Tooltip id="global-tooltip" />
<TranslateWidget />
<LookupWidget />

View File

@@ -28,6 +28,7 @@ import {
SheetContent,
SheetHeader,
SheetClose,
SheetTitle,
} from "@renderer/components/ui";
import {
GitCompareIcon,
@@ -500,6 +501,34 @@ export const MediaCurrentRecording = () => {
setIsRecording={setIsRecording}
/>
<Button
variant={detailIsOpen ? "secondary" : "outline"}
size="icon"
id="media-pronunciation-assessment-button"
data-tooltip-id="media-player-tooltip"
data-tooltip-content={t("pronunciationAssessment")}
className={
layout?.name === "sm" ? "hidden" : "rounded-full w-8 h-8 p-0"
}
onClick={() => setDetailIsOpen(true)}
>
<GaugeCircleIcon
className={`w-4 h-4
${
currentRecording.pronunciationAssessment
? currentRecording.pronunciationAssessment
.pronunciationScore >= 80
? "text-green-500"
: currentRecording.pronunciationAssessment
.pronunciationScore >= 60
? "text-yellow-600"
: "text-red-500"
: ""
}
`}
/>
</Button>
<Button
variant={isComparing ? "secondary" : "outline"}
size="icon"
@@ -514,19 +543,6 @@ export const MediaCurrentRecording = () => {
<GitCompareIcon className="w-4 h-4" />
</Button>
<Button
variant={isSelectingRegion ? "secondary" : "outline"}
size="icon"
data-tooltip-id="media-player-tooltip"
data-tooltip-content={t("selectRegion")}
className={
layout?.name === "sm" ? "hidden" : "rounded-full w-8 h-8 p-0"
}
onClick={() => setIsSelectingRegion(!isSelectingRegion)}
>
<TextCursorInputIcon className="w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -545,27 +561,10 @@ export const MediaCurrentRecording = () => {
<>
<DropdownMenuItem
className="cursor-pointer"
onClick={toggleCompare}
onClick={() => setDetailIsOpen(true)}
>
<GitCompareIcon className="w-4 h-4 mr-4" />
<span>{t("compare")}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => setIsSelectingRegion(!isSelectingRegion)}
>
<TextCursorInputIcon className="w-4 h-4 mr-4" />
<span>{t("selectRegion")}</span>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem
className="cursor-pointer"
onClick={() => setDetailIsOpen(true)}
>
<GaugeCircleIcon
className={`w-4 h-4 mr-4
<GaugeCircleIcon
className={`w-4 h-4 mr-4
${
currentRecording.pronunciationAssessment
? currentRecording.pronunciationAssessment
@@ -578,10 +577,27 @@ export const MediaCurrentRecording = () => {
: ""
}
`}
/>
<span>{t("pronunciationAssessment")}</span>
</DropdownMenuItem>
/>
<span>{t("pronunciationAssessment")}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={toggleCompare}
>
<GitCompareIcon className="w-4 h-4 mr-4" />
<span>{t("compare")}</span>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem
className="cursor-pointer"
data-tooltip-content={t("selectRegion")}
onClick={() => setIsSelectingRegion(!isSelectingRegion)}
>
<TextCursorInputIcon className="w-4 h-4 mr-4" />
<span>{t("selectRegion")}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => setIsSharing(true)}
@@ -625,6 +641,9 @@ export const MediaCurrentRecording = () => {
displayClose={false}
>
<SheetHeader className="flex items-center justify-center -mt-4 mb-2">
<SheetTitle className="hidden">
{t("pronunciationAssessment")}
</SheetTitle>
<SheetClose>
<ChevronDownIcon />
</SheetClose>

View File

@@ -22,6 +22,7 @@ import {
MediaPlayerProviderContext,
} from "@renderer/context";
import {
GaugeCircleIcon,
LoaderIcon,
MicIcon,
MoreHorizontalIcon,
@@ -90,6 +91,28 @@ export const MediaRecordings = () => {
<span>{formatDuration(recording.duration, "ms")}</span>
</div>
<div className="flex items-center space-x-2">
{recording.pronunciationAssessment?.result && (
<div
className={`flex items-center space-x-1
${
recording.pronunciationAssessment
? recording.pronunciationAssessment
.pronunciationScore >= 80
? "text-green-500"
: recording.pronunciationAssessment
.pronunciationScore >= 60
? "text-yellow-600"
: "text-red-500"
: ""
}
`}
>
<GaugeCircleIcon className="w-4 h-4" />
<span className="text-xs font-mono">
{recording.pronunciationAssessment.pronunciationScore}
</span>
</div>
)}
<span className="text-sm text-muted-foreground">
{formatDateTime(recording.createdAt)}
</span>

View File

@@ -24,13 +24,15 @@ export const MediaTabs = () => {
return (
<ScrollArea className="h-full">
<div
className={`p-1 bg-muted rounded-t-lg mb-2 text-sm sticky top-0 z-[1] grid gap-4 ${media?.mediaType === "Video" ? "grid-cols-4" : "grid-cols-3"
}`}
className={`p-1 bg-muted rounded-t-lg mb-2 text-sm sticky top-0 z-[1] grid gap-4 ${
media?.mediaType === "Video" ? "grid-cols-4" : "grid-cols-3"
}`}
>
{media.mediaType === "Video" && (
<div
className={`rounded cursor-pointer px-2 py-1 text-sm text-center capitalize truncate ${tab === "provider" ? "bg-background" : ""
}`}
className={`rounded cursor-pointer px-2 py-1 text-sm text-center capitalize truncate ${
tab === "provider" ? "bg-background" : ""
}`}
onClick={() => setTab("provider")}
>
{t("player")}
@@ -38,22 +40,25 @@ export const MediaTabs = () => {
)}
<div
className={`rounded cursor-pointer px-2 py-1 text-sm text-center capitalize truncate ${tab === "transcription" ? "bg-background" : ""
}`}
className={`rounded cursor-pointer px-2 py-1 text-sm text-center capitalize truncate ${
tab === "transcription" ? "bg-background" : ""
}`}
onClick={() => setTab("transcription")}
>
{t("transcription")}
</div>
<div
className={`rounded cursor-pointer px-2 py-1 text-sm text-center capitalize truncate ${tab === "recordings" ? "bg-background" : ""
}`}
className={`rounded cursor-pointer px-2 py-1 text-sm text-center capitalize truncate ${
tab === "recordings" ? "bg-background" : ""
}`}
onClick={() => setTab("recordings")}
>
{t("myRecordings")}
</div>
<div
className={`rounded cursor-pointer px-2 py-1 text-sm text-center capitalize truncate ${tab === "info" ? "bg-background" : ""
}`}
className={`rounded cursor-pointer px-2 py-1 text-sm text-center capitalize truncate ${
tab === "info" ? "bg-background" : ""
}`}
onClick={() => setTab("info")}
>
{t("mediaInfo")}
@@ -67,7 +72,7 @@ export const MediaTabs = () => {
<MediaRecordings />
</div>
<div className={tab === "transcription" ? "" : "hidden"}>
<MediaTranscription />
<MediaTranscription display={tab === "transcription"} />
</div>
<div className={tab === "info" ? "" : "hidden"}>
<MediaInfoPanel />

View File

@@ -15,6 +15,7 @@ import {
Button,
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
DropdownMenu,
DropdownMenuContent,
@@ -121,6 +122,7 @@ export const MediaTranscriptionReadButton = (props: {
onPointerDownOutside={(event) => event.preventDefault()}
className="max-w-screen-md xl:max-w-screen-lg h-5/6 flex flex-col p-0"
>
<DialogTitle className="hidden">{t("readThrough")}</DialogTitle>
<ScrollArea className="flex-1 px-6 pt-4">
<div className="select-text mx-auto w-full max-w-prose">
<h3 className="font-bold text-xl my-4">{media.name}</h3>

View File

@@ -19,6 +19,7 @@ import {
MicIcon,
PencilLineIcon,
SquareMenuIcon,
GaugeCircleIcon,
} from "lucide-react";
import { AlignmentResult } from "echogarden/dist/api/API.d.js";
import { formatDuration } from "@renderer/lib/utils";
@@ -28,7 +29,8 @@ import {
MediaTranscriptionGenerateButton,
} from "@renderer/components";
export const MediaTranscription = () => {
export const MediaTranscription = (props: { display?: boolean }) => {
const { display } = props;
const containerRef = useRef<HTMLDivElement>();
const {
decoded,
@@ -69,6 +71,21 @@ export const MediaTranscription = () => {
});
};
const scrollToCurrentSegment = () => {
if (!containerRef?.current) return;
if (!decoded) return;
if (!display) return;
setTimeout(() => {
containerRef.current
?.querySelector(`#segment-${currentSegmentIndex}`)
?.scrollIntoView({
block: "center",
inline: "center",
} as ScrollIntoViewOptions);
}, 300);
};
useEffect(() => {
if (!transcription?.result) return;
@@ -81,18 +98,8 @@ export const MediaTranscription = () => {
}, [transcription?.result]);
useEffect(() => {
if (!containerRef?.current) return;
if (!decoded) return;
setTimeout(() => {
containerRef.current
?.querySelector(`#segment-${currentSegmentIndex}`)
?.scrollIntoView({
block: "center",
inline: "center",
} as ScrollIntoViewOptions);
}, 300);
}, [decoded, currentSegmentIndex, transcription, containerRef]);
scrollToCurrentSegment();
}, [display, decoded, currentSegmentIndex, transcription, containerRef]);
if (!transcription?.result?.timeline) {
return null;
@@ -182,9 +189,10 @@ export const MediaTranscription = () => {
<div className="flex items-center justify-between">
<span className="text-xs opacity-50">#{index + 1}</span>
<div className="flex items-center space-x-2">
{(recordingStats || []).findIndex(
(s) => s.referenceId === index
) !== -1 && <MicIcon className="w-3 h-3 text-sky-500" />}
<RecordingStatsRemark
stats={recordingStats}
referenceId={index}
/>
{(notesStats || []).findIndex(
(s) => s.segment?.segmentIndex === index
) !== -1 && <PencilLineIcon className="w-3 h-3 text-sky-500" />}
@@ -200,3 +208,34 @@ export const MediaTranscription = () => {
</div>
);
};
const RecordingStatsRemark = (props: {
stats: SegementRecordingStatsType;
referenceId: number;
}) => {
const { stats = [], referenceId } = props;
const stat = stats.find((s) => s.referenceId === referenceId);
if (!stat) return null;
return (
<>
{stat.pronunciationAssessment?.pronunciationScore && (
<GaugeCircleIcon
className={`w-3 h-3
${
stat.pronunciationAssessment
? stat.pronunciationAssessment.pronunciationScore >= 80
? "text-green-500"
: stat.pronunciationAssessment.pronunciationScore >=
60
? "text-yellow-600"
: "text-red-500"
: ""
}
`}
/>
)}
<MicIcon className="w-3 h-3 text-sky-500" />
</>
);
};

View File

@@ -14,6 +14,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuItem,
Separator,
DialogTitle,
} from "@renderer/components/ui";
import {
SettingsIcon,
@@ -172,6 +173,9 @@ export const Sidebar = () => {
</DialogTrigger>
<DialogContent className="max-w-screen-md xl:max-w-screen-lg h-5/6 p-0">
<DialogTitle className="hidden">
{t("sidebar.preferences")}
</DialogTitle>
<Preferences />
</DialogContent>
</Dialog>

View File

@@ -4,7 +4,7 @@ import {
PronunciationAssessmentScoreResult,
} from "@renderer/components";
import { Separator, ScrollArea } from "@renderer/components/ui";
import { useState, useContext } from "react";
import { useState, useContext, useEffect } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { Tooltip } from "react-tooltip";
@@ -29,12 +29,19 @@ export const RecordingDetail = (props: {
const [assessing, setAssessing] = useState(false);
const assess = () => {
if (assessing) return;
if (result) return;
setAssessing(true);
EnjoyApp.recordings.assess(recording.id, learningLanguage).finally(() => {
setAssessing(false);
});
};
useEffect(() => {
assess();
}, [recording]);
return (
<div className="">
<div className="mb-6 px-4">

View File

@@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}

View File

@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -38,13 +38,13 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>

View File

@@ -23,4 +23,7 @@ type SegementRecordingStatsType = {
referenceText?: string;
count: number;
duration: number;
pronunciationAssessment?: {
pronunciationScore: number;
};
}[];

View File

@@ -7,6 +7,7 @@
"scripts": {
"start:enjoy": "echo 'Please use `yarn enjoy:start` instead'",
"enjoy:add": "yarn workspace enjoy add",
"enjoy:remove": "yarn workspace enjoy remove",
"enjoy:dev": "yarn workspace enjoy dev",
"enjoy:start": "yarn workspace enjoy start",
"enjoy:test": "yarn workspace enjoy test",

1523
yarn.lock

File diff suppressed because it is too large Load Diff