Improve: UI & code (#1179)

* use frameless

* frame true

* fix UI

* disable whisper.cpp for darwin

* improve page UI

* clean code

* refactor shadowing cancel

* upgrade deps

* fix type

* update e2e

* downgrade echogarden to fix align error

* upgrade echogarden

* upgrade

* fix profile

* refactor login form

* may scan to login with Mixin

* refactor sidebar

* update sidebar ui

* update

* update UI

* update
This commit is contained in:
an-lee
2024-11-14 16:02:34 +08:00
committed by GitHub
parent ab813dabb0
commit 437c133647
55 changed files with 1071 additions and 752 deletions

View File

@@ -106,6 +106,10 @@ export class Client {
return this.api.post("/api/sessions", decamelizeKeys(params));
}
oauthState(state: string): Promise<UserType> {
return this.api.post("/api/sessions/oauth_state", { state });
}
config(key: string): Promise<any> {
return this.api.get(`/api/config/${key}`);
}

View File

@@ -275,7 +275,9 @@
"phoneNumber": "Phone number",
"mixinId": "Mixin ID",
"inputMixinId": "Input your Mixin ID",
"dontHaveMixinAccount": "don't have Mixin account?",
"scanMixinQRCodeDescription": "Scan Mixin QR code in the popup window",
"createMixinAccount": "Create Mixin account",
"scanToLogin": "Scan to login",
"youCanAlsoLoginWith": "You can also login with",
"downloadTranscript": "Download transcript",
"downloadTranscriptFromCloud": "Download transcript from cloud",

View File

@@ -275,7 +275,9 @@
"phoneNumber": "手机号",
"mixinId": "Mixin 号",
"inputMixinId": "请输入您的 Mixin ID",
"dontHaveMixinAccount": "没有 Mixin 账号?",
"scanMixinQRCodeDescription": "请在弹出窗口中用 Mixin 扫码",
"createMixinAccount": "创建 Mixin 账号",
"scanToLogin": "扫码登录",
"youCanAlsoLoginWith": "您也可以使用以下方式登录",
"downloadTranscript": "下载字幕",
"downloadTranscriptFromCloud": "从云端下载字幕",

View File

@@ -139,7 +139,11 @@ app.on("ready", async () => {
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on("window-all-closed", () => {
app.quit();
// Respect the OSX convention of having the application in memory even
// after all windows have been closed
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("activate", () => {

View File

@@ -24,9 +24,6 @@ import settings from "@main/settings";
import fs from "fs-extra";
import ffmpegPath from "ffmpeg-static";
import { enjoyUrlToPath, pathToEnjoyUrl } from "./utils";
import { UserSetting } from "./db/models";
import { UserSettingKeyEnum } from "@/types/enums";
import { WHISPER_MODELS } from "@/constants";
Echogarden.setGlobalOption(
"ffmpegPath",
@@ -107,13 +104,18 @@ class EchogardenWrapper {
try {
logger.info("check:", options);
const result = await this.recognize(sampleFile, options);
logger.info(result?.transcript);
logger.info("transcript:", result?.transcript);
fs.writeJsonSync(
path.join(settings.cachePath(), "echogarden-check.json"),
result,
{ spaces: 2 }
);
const timeline = await this.align(sampleFile, result.transcript, {
language: "en",
});
logger.info("timeline:", !!timeline);
return { success: true, log: "" };
} catch (e) {
logger.error(e);

View File

@@ -509,7 +509,8 @@ ${log}
// Create the browser window.
const mainWindow = new BrowserWindow({
icon: "./assets/icon.png",
icon:
process.platform === "win32" ? "./assets/icon.ico" : "./assets/icon.png",
width: 1280,
height: 720,
minWidth: 800,

View File

@@ -7,8 +7,9 @@ export const AudioPlayer = (props: {
id?: string;
md5?: string;
segmentIndex?: number;
onLoad?: (audio: AudioType) => void;
}) => {
const { id, md5, segmentIndex } = props;
const { id, md5, segmentIndex, onLoad } = props;
const { media, setMedia, setCurrentSegmentIndex, getCachedSegmentIndex } =
useContext(MediaShadowProviderContext);
@@ -21,6 +22,7 @@ export const AudioPlayer = (props: {
useEffect(() => {
setMedia(audio);
onLoad?.(audio);
}, [audio]);
useEffect(() => {

View File

@@ -42,9 +42,9 @@ export const ChatHeader = (props: {
onClick={toggleSidePanel}
>
{sidePanelCollapsed ? (
<ChevronsRightIcon className="w-5 h-5" />
<ChevronsRightIcon className="size-4" />
) : (
<ChevronsLeftIcon className="w-5 h-5" />
<ChevronsLeftIcon className="size-4" />
)}
</Button>
{chat.type === ChatTypeEnum.CONVERSATION && (
@@ -59,7 +59,7 @@ export const ChatHeader = (props: {
<Dialog open={displayChatForm} onOpenChange={setDisplayChatForm}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="absolute right-4">
<SettingsIcon className="w-5 h-5" />
<SettingsIcon className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-screen-sm max-h-[70%] overflow-y-auto">

View File

@@ -19,3 +19,4 @@ export * from "./transcriptions";
export * from "./users";
export * from "./videos";
export * from "./widgets";
export * from "./login";

View File

@@ -8,12 +8,16 @@ import {
SheetContent,
SheetHeader,
SheetTitle,
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@renderer/components/ui";
import { useContext, useEffect, useRef, useState } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { t } from "i18next";
import intlTelInput from "intl-tel-input/intlTelInputWithUtils";
import "intl-tel-input/build/css/intlTelInput.css";
import { REGEXP_ONLY_DIGITS } from "input-otp";
export const BanduLoginButton = () => {
const [open, setOpen] = useState(false);
@@ -117,30 +121,38 @@ export const BanduLoginForm = () => {
value={phoneNumber}
onInput={validatePhone}
onBlur={validatePhone}
disabled={countdown > 0}
className="border text-lg py-2 px-4 rounded w-80 dark:bg-background dark:text-foreground"
ref={ref}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="verrificationCode">{t("verificationCode")}</Label>
<Input
id="verrificationCode"
className="border py-2 h-10 px-4 rounded"
type="text"
minLength={5}
maxLength={5}
placeholder={t("verificationCode")}
value={code}
onChange={(e) => setCode(e.target.value)}
/>
</div>
{codeSent && (
<div className="grid gap-2">
<Label htmlFor="verrificationCode">{t("verificationCode")}</Label>
<InputOTP
id="verrificationCode"
maxLength={5}
value={code}
pattern={REGEXP_ONLY_DIGITS}
onChange={(value) => setCode(value)}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
</InputOTPGroup>
</InputOTP>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<Button
variant="secondary"
size="lg"
className="w-full"
className="w-full px-2"
disabled={!phoneNumber || countdown > 0}
onClick={() => {
webApi

View File

@@ -1,7 +1,16 @@
import { Button, toast, Input, Label } from "@renderer/components/ui";
import {
Button,
toast,
Input,
Label,
InputOTP,
InputOTPSlot,
InputOTPGroup,
} from "@renderer/components/ui";
import { useContext, useEffect, useState } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { t } from "i18next";
import { REGEXP_ONLY_DIGITS } from "input-otp";
export const EmailLoginForm = () => {
const [email, setEmail] = useState<string>("");
@@ -41,27 +50,33 @@ export const EmailLoginForm = () => {
/>
</div>
<div className="grid gap-2">
<Label htmlFor="code">{t("verificationCode")}</Label>
<Input
id="code"
className="h-10"
type="text"
required
minLength={5}
maxLength={5}
placeholder={t("verificationCode")}
value={code}
onChange={(e) => setCode(e.target.value)}
/>
</div>
{codeSent && (
<div className="grid gap-2">
<Label htmlFor="code">{t("verificationCode")}</Label>
<InputOTP
id="code"
maxLength={5}
value={code}
pattern={REGEXP_ONLY_DIGITS}
onChange={(value) => setCode(value)}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
</InputOTPGroup>
</InputOTP>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<Button
variant="secondary"
size="lg"
className="w-full"
className="w-full px-2"
disabled={!email || countdown > 0}
onClick={() => {
webApi
@@ -83,7 +98,7 @@ export const EmailLoginForm = () => {
<Button
variant="default"
size="lg"
className="w-full"
className="w-full px-2"
disabled={!code || code.length < 5 || !email}
onClick={() => {
webApi

View File

@@ -0,0 +1,5 @@
export * from "./mixin-login-form";
export * from "./login-form";
export * from "./email-login-form";
export * from "./bandu-login-form";
export * from "./github-login-form";

View File

@@ -68,9 +68,9 @@ export const LoginForm = () => {
{rememberedUsers.map((rememberedUser) => (
<div
key={rememberedUser.id}
className="px-4 py-2 border-b last:border-b-0 w-full max-w-md"
className="px-4 border-b last:border-b-0 w-full max-w-md"
>
<div className="flex items-center justify-between py-4">
<div className="flex items-center justify-between py-2">
<div className="">
<div className="flex items-center space-x-2">
<Avatar>

View File

@@ -8,11 +8,17 @@ import {
SheetContent,
SheetHeader,
SheetTitle,
InputOTP,
InputOTPGroup,
InputOTPSlot,
Separator,
} from "@renderer/components/ui";
import { useContext, useEffect, useState } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { t } from "i18next";
import { LoaderIcon } from "lucide-react";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import { v4 as uuidv4 } from "uuid";
export const MixinLoginButton = () => {
const [open, setOpen] = useState(false);
@@ -57,7 +63,11 @@ export const MixinLoginForm = () => {
const [loading, setLoading] = useState<boolean>(false);
const [codeSent, setCodeSent] = useState<boolean>(false);
const [countdown, setCountdown] = useState<number>(0);
const { login, webApi, EnjoyApp } = useContext(AppSettingsProviderContext);
const { login, webApi, EnjoyApp, apiUrl } = useContext(
AppSettingsProviderContext
);
const [state, setState] = useState<string>("");
const [scanning, setScanning] = useState<boolean>(false);
const validateMixinId = (id: string) => {
setInput(id);
@@ -69,6 +79,39 @@ export const MixinLoginForm = () => {
}
};
const handleScanToLogin = () => {
const uuid = uuidv4();
setState(uuid);
EnjoyApp.shell.openExternal(
`${apiUrl}/sessions/new?provider=mixin&state=${uuid}`
);
setScanning(true);
};
const pollingOAuthState = () => {
if (!state) return;
webApi.oauthState(state).then((user) => {
if (user?.id && user?.accessToken) {
login(user);
}
});
};
useEffect(() => {
let interval: NodeJS.Timeout;
if (!scanning) return;
interval = setInterval(() => {
pollingOAuthState();
}, 1500);
return () => {
if (interval) clearInterval(interval);
};
}, [scanning]);
useEffect(() => {
let timeout: NodeJS.Timeout;
@@ -83,10 +126,41 @@ export const MixinLoginForm = () => {
};
}, [countdown]);
if (scanning) {
return (
<div className="w-80">
<div className="flex items-center justify-center mb-4">
<img src="assets/mixin-logo.png" className="w-20 h-20" alt="mixin" />
</div>
<div className="flex items-center justify-center mb-4">
<LoaderIcon className="w-5 h-5 animate-spin" />
</div>
<div className="text-center text-sm text-muted-foreground mb-6">
{t("scanMixinQRCodeDescription")}
</div>
<div className="flex justify-center items-center space-x-4">
<Button
variant="default"
onClick={() =>
EnjoyApp.shell.openExternal(
`${apiUrl}/sessions/new?provider=mixin&state=${state}`
)
}
>
{t("open")}
</Button>
<Button variant="secondary" onClick={() => setScanning(false)}>
{t("cancel")}
</Button>
</div>
</div>
);
}
return (
<div className="w-80">
<div className="flex items-center justify-center mb-4">
<img src="assets/mixin-logo.png" className="w-20 h-20" alt="bandu" />
<img src="assets/mixin-logo.png" className="w-20 h-20" alt="mixin" />
</div>
<div className="grid gap-6">
@@ -96,33 +170,50 @@ export const MixinLoginForm = () => {
<input
id="mixinId"
value={input}
disabled={countdown > 0}
placeholder={t("inputMixinId")}
onInput={(event) => validateMixinId(event.currentTarget.value)}
onBlur={(event) => validateMixinId(event.currentTarget.value)}
className="border py-2 px-4 rounded dark:bg-background dark:text-foreground"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="verificationCode">{t("verificationCode")}</Label>
<Input
id="verificationCode"
className="border py-2 h-10 px-4 rounded"
type="text"
minLength={5}
maxLength={5}
placeholder={t("verificationCode")}
value={code}
onChange={(e) => setCode(e.target.value)}
/>
</div>
{codeSent && (
<div className="grid gap-2">
<Label htmlFor="verificationCode">{t("verificationCode")}</Label>
<InputOTP
id="verificationCode"
maxLength={5}
value={code}
pattern={REGEXP_ONLY_DIGITS}
onChange={(value) => setCode(value)}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
</InputOTPGroup>
</InputOTP>
</div>
)}
<div
onClick={() =>
EnjoyApp.shell.openExternal("https://mixin.one/messenger")
}
className="text-xs text-muted-foreground cursor-pointer"
>
{t("dontHaveMixinAccount")}
<div className="flex items-center space-x-2">
<div
onClick={() =>
EnjoyApp.shell.openExternal("https://mixin.one/messenger")
}
className="text-xs text-muted-foreground cursor-pointer"
>
{t("createMixinAccount")}
</div>
<Separator orientation="vertical" />
<div
onClick={handleScanToLogin}
className="text-xs text-muted-foreground cursor-pointer"
>
{t("scanToLogin")}
</div>
</div>
</div>
@@ -160,7 +251,7 @@ export const MixinLoginForm = () => {
<Button
variant="default"
size="lg"
className="w-full"
className="w-full px-2"
disabled={!code || code.length < 5 || !mixinId}
onClick={() => {
webApi

View File

@@ -40,7 +40,6 @@ export const MediaLoadingModal = () => {
};
const LoadingContent = () => {
const navigate = useNavigate();
const {
media,
decoded,
@@ -74,7 +73,7 @@ const LoadingContent = () => {
isolate: data.isolate,
});
}}
onCancel={() => navigate(-1)}
onCancel={onCancel}
transcribing={transcribing}
transcribingProgress={transcribingProgress}
transcribingOutput={transcribingOutput}
@@ -109,12 +108,7 @@ const LoadingContent = () => {
</div>
</div>
<AlertDialogFooter>
<Button
variant="secondary"
onClick={() => {
onCancel ? onCancel() : navigate(-1);
}}
>
<Button variant="secondary" onClick={onCancel}>
{t("cancel")}
</Button>
</AlertDialogFooter>
@@ -137,12 +131,7 @@ const LoadingContent = () => {
)}
</div>
<AlertDialogFooter>
<Button
variant="secondary"
onClick={() => {
onCancel ? onCancel() : navigate(-1);
}}
>
<Button variant="secondary" onClick={onCancel}>
{t("cancel")}
</Button>
</AlertDialogFooter>

View File

@@ -1,12 +1,7 @@
export * from "./bandu-login-form";
export * from "./db-state";
export * from "./email-login-form";
export * from "./layout";
export * from "./loader-spin";
export * from "./login-form";
export * from "./github-login-form";
export * from "./markdown-wrapper";
export * from "./mixin-login-form";
export * from "./no-records-found";
export * from "./page-placeholder";
export * from "./universal-player";

View File

@@ -15,6 +15,9 @@ import {
DropdownMenuItem,
Separator,
DialogTitle,
Avatar,
AvatarImage,
DropdownMenuSeparator,
} from "@renderer/components/ui";
import {
SettingsIcon,
@@ -35,14 +38,14 @@ import {
MessagesSquareIcon,
PanelLeftOpenIcon,
PanelLeftCloseIcon,
ChevronsUpDownIcon,
LogOutIcon,
ChevronRightIcon,
} from "lucide-react";
import { useLocation, Link } from "react-router-dom";
import { useLocation, Link, useNavigate } from "react-router-dom";
import { t } from "i18next";
import { Preferences } from "@renderer/components";
import {
AppSettingsProviderContext,
CopilotProviderContext,
} from "@renderer/context";
import { AppSettingsProviderContext } from "@renderer/context";
import { useContext, useEffect } from "react";
import { NoticiationsChannel } from "@renderer/cables";
import { useState } from "react";
@@ -51,7 +54,6 @@ export const Sidebar = () => {
const location = useLocation();
const activeTab = location.pathname;
const { EnjoyApp, cable } = useContext(AppSettingsProviderContext);
const { active, setActive } = useContext(CopilotProviderContext);
const [isOpen, setIsOpen] = useState(true);
useEffect(() => {
@@ -82,30 +84,17 @@ export const Sidebar = () => {
return (
<div
className={`h-[100vh] transition-all relative ${
isOpen ? "w-36" : "w-14"
isOpen ? "w-48" : "w-12"
}`}
data-testid="sidebar"
>
<div
className={`fixed top-0 left-0 h-full bg-muted ${
isOpen ? "w-36" : "w-14"
isOpen ? "w-48" : "w-12"
}`}
>
<ScrollArea className="w-full h-full pb-12">
<div className="py-4 mb-4 flex items-center space-x-1 justify-center">
<img
src="./assets/logo-light.svg"
className="w-8 h-8 cursor-pointer hover:animate-spin"
onClick={() => setActive(!active)}
/>
<span
className={`text-xl font-semibold text-[#4797F5] ${
isOpen ? "" : "hidden"
}`}
>
ENJOY
</span>
</div>
<SidebarHeader isOpen={isOpen} />
<div className="grid gap-2 mb-4">
<SidebarItem
href="/"
@@ -205,26 +194,6 @@ export const Sidebar = () => {
<Separator />
<SidebarItem
href="/community"
label={t("sidebar.community")}
tooltip={t("sidebar.community")}
active={activeTab === "/community"}
Icon={UsersRoundIcon}
isOpen={isOpen}
/>
<SidebarItem
href="/profile"
label={t("sidebar.profile")}
tooltip={t("sidebar.profile")}
active={activeTab.startsWith("/profile")}
Icon={UserIcon}
isOpen={isOpen}
/>
<Separator />
<Dialog>
<DialogTrigger asChild>
<div className="px-1">
@@ -239,7 +208,7 @@ export const Sidebar = () => {
data-tooltip-content={t("sidebar.preferences")}
data-tooltip-place="right"
>
<SettingsIcon className="h-5 w-5" />
<SettingsIcon className="size-4" />
{isOpen && (
<span className="ml-2"> {t("sidebar.preferences")} </span>
)}
@@ -268,24 +237,33 @@ export const Sidebar = () => {
isOpen ? "justify-start" : "justify-center"
}`}
>
<HelpCircleIcon className="h-5 w-5" />
<HelpCircleIcon className="size-4" />
{isOpen && (
<span className="ml-2"> {t("sidebar.help")} </span>
<>
<span className="ml-2"> {t("sidebar.help")} </span>
<ChevronRightIcon className="w-4 h-4 ml-auto" />
</>
)}
</Button>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="px-6">
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width]"
align="start"
side="top"
>
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() =>
EnjoyApp.shell.openExternal("https://998h.org/enjoy-app/")
EnjoyApp.shell.openExternal(
"https://1000h.org/enjoy-app/"
)
}
className="flex justify-between space-x-4"
>
<span>{t("userGuide")}</span>
<ExternalLinkIcon className="h-6 w-4" />
<span className="min-w-fit">{t("userGuide")}</span>
<ExternalLinkIcon className="size-4" />
</DropdownMenuItem>
</DropdownMenuGroup>
@@ -305,7 +283,7 @@ export const Sidebar = () => {
className="flex justify-between space-x-4"
>
<span>Mixin</span>
<ExternalLinkIcon className="h-6 w-4" />
<ExternalLinkIcon className="size-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
@@ -316,7 +294,7 @@ export const Sidebar = () => {
className="flex justify-between space-x-4"
>
<span>Github</span>
<ExternalLinkIcon className="h-6 w-4" />
<ExternalLinkIcon className="size-4" />
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
@@ -335,9 +313,9 @@ export const Sidebar = () => {
onClick={() => setIsOpen(!isOpen)}
>
{isOpen ? (
<PanelLeftCloseIcon className="h-5 w-5" />
<PanelLeftCloseIcon className="size-4" />
) : (
<PanelLeftOpenIcon className="h-5 w-5" />
<PanelLeftOpenIcon className="size-4" />
)}
{isOpen && <span className="ml-2"> {t("sidebar.collapse")} </span>}
</Button>
@@ -372,9 +350,72 @@ const SidebarItem = (props: {
variant={active ? "default" : "ghost"}
className={`w-full ${isOpen ? "justify-start" : "justify-center"}`}
>
<Icon className="h-5 w-5" />
<Icon className="size-4" />
{isOpen && <span className="ml-2">{label}</span>}
</Button>
</Link>
);
};
const SidebarHeader = (props: { isOpen: boolean }) => {
const { isOpen } = props;
const { user, logout } = useContext(AppSettingsProviderContext);
const navigate = useNavigate();
return (
<div className="py-3 px-1 sticky top-0 bg-muted z-10">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={`w-full h-12 hover:bg-background ${
isOpen ? "justify-start" : "justify-center px-1"
}`}
>
<Avatar className="size-8">
<AvatarImage src={user.avatarUrl} />
</Avatar>
{isOpen && (
<>
<div className="ml-2 flex flex-col leading-none">
<span className="text-left text-sm font-medium line-clamp-1">
{user.name}
</span>
<span className="text-left text-xs text-muted-foreground line-clamp-1">
{user.id}
</span>
</div>
<ChevronsUpDownIcon className="size-4 ml-auto" />
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width]"
align="start"
side="bottom"
>
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => navigate("/profile")}
>
<span>{t("sidebar.profile")}</span>
<UserIcon className="size-4 ml-auto" />
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => navigate("/community")}
>
<span>{t("sidebar.community")}</span>
<UsersRoundIcon className="size-4 ml-auto" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={logout} className="cursor-pointer">
<span>{t("logout")}</span>
<LogOutIcon className="size-4 ml-auto" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};

View File

@@ -131,7 +131,9 @@ export const BalanceSettings = () => {
{[2, 10, 50, 100].map((amount) => (
<div
className={`text-xl w-full h-20 border rounded-md flex items-center justify-center cursor-pointer shadow hover:bg-gray-100 hover:dark:text-primary-foreground transition-colors duration-200 ease-in-out ${
amount == depositAmount ? "bg-gray-100 dark:text-primary-foreground" : ""
amount == depositAmount
? "bg-gray-100 dark:text-primary-foreground"
: ""
}`}
key={`deposit-amount-${amount}`}
onClick={() => setDepositAmount(amount)}
@@ -269,7 +271,7 @@ export const BalanceSettings = () => {
};
const UsageChart = () => {
const { webApi, EnjoyApp } = useContext(AppSettingsProviderContext);
const { webApi } = useContext(AppSettingsProviderContext);
const [usages, setUsages] = useState<{ label: string; data: number[] }[]>([]);
const chartRef = useRef<HTMLCanvasElement>(null);

View File

@@ -115,7 +115,12 @@ export const EchogardenSttSettings = (props: {
</SelectTrigger>
<SelectContent>
<SelectItem value="whisper">Whisper</SelectItem>
<SelectItem value="whisper.cpp">Whisper.cpp</SelectItem>
<SelectItem
disabled={platformInfo?.platform === "darwin"}
value="whisper.cpp"
>
Whisper.cpp
</SelectItem>
</SelectContent>
</Select>
</FormControl>

View File

@@ -1,14 +1,5 @@
import { t } from "i18next";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Avatar,
AvatarImage,
AvatarFallback,
@@ -103,38 +94,14 @@ export const UserSettings = () => {
</DialogContent>
</Dialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="secondary"
className="text-destructive"
size="sm"
>
{t("logout")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("logout")}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
{t("logoutConfirmation")}
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive hover:bg-destructive-hover"
onClick={() => {
logout();
redirect("/landing");
}}
>
{t("logout")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button
variant="secondary"
className="text-destructive"
size="sm"
onClick={logout}
>
{t("logout")}
</Button>
</div>
</div>
</>

View File

@@ -45,7 +45,7 @@ export const RecordingCalendar = (props: {
value: currentYear,
},
];
const startYear = dayjs(user.createdAt).year();
const startYear = dayjs(user?.createdAt).year();
for (let i = currentYear - 1; i >= startYear; i--) {
_tabs.push({
@@ -116,7 +116,7 @@ export const RecordingCalendar = (props: {
}
return (
<div className="mx-auto p-4 rounded-lg border flex justify-between gap-4">
<div className="mx-auto p-4 rounded-lg border flex justify-between gap-2">
<Calendar
data={stats}
labels={{
@@ -162,11 +162,12 @@ export const RecordingCalendar = (props: {
},
}}
/>
<ScrollArea className="w-32 h-36 px-2">
<ScrollArea className="max-w-fit h-36">
{tabs().map((_tab) => (
<Button
key={_tab.value}
className="w-full justify-start py-1 min-w-max text-xs xl:text-sm 2xl:text-md"
size="sm"
className="w-full justify-center py-1 px-1 min-w-max text-xs xl:text-sm 2xl:text-md mb-2"
variant={_tab.value === tab ? "secondary" : "ghost"}
onClick={() => handleTabChange(_tab.value)}
>

View File

@@ -38,3 +38,4 @@ export * from "./tabs";
export * from "./textarea";
export * from "./toggle";
export * from "./tooltip";
export * from "./input-otp";

View File

@@ -0,0 +1,70 @@
"use client";
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { Minus } from "lucide-react";
import { cn } from "@renderer/lib/utils";
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
));
InputOTP.displayName = "InputOTP";
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
));
InputOTPGroup.displayName = "InputOTPGroup";
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-1 ring-ring",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = "InputOTPSlot";
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Minus />
</div>
));
InputOTPSeparator.displayName = "InputOTPSeparator";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -8,8 +8,9 @@ export const VideoPlayer = (props: {
id?: string;
md5?: string;
segmentIndex?: number;
onLoad?: (video: VideoType) => void;
}) => {
const { id, md5, segmentIndex } = props;
const { id, md5, segmentIndex, onLoad } = props;
const { media, setMedia, setCurrentSegmentIndex, getCachedSegmentIndex } =
useContext(MediaShadowProviderContext);
const { video } = useVideo({ id, md5 });
@@ -21,6 +22,7 @@ export const VideoPlayer = (props: {
useEffect(() => {
setMedia(video);
onLoad?.(video);
}, [video]);
useEffect(() => {

View File

@@ -6,6 +6,20 @@ import ahoy from "ahoy.js";
import { type Consumer, createConsumer } from "@rails/actioncable";
import { DbProviderContext } from "@renderer/context";
import { UserSettingKeyEnum } from "@/types/enums";
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
Button,
} from "@renderer/components/ui";
import { t } from "i18next";
import { redirect } from "react-router-dom";
type AppSettingsProviderState = {
webApi: Client;
@@ -72,6 +86,8 @@ export const AppSettingsProvider = ({
const [ipaMappings, setIpaMappings] = useState<{ [key: string]: string }>(
IPA_MAPPINGS
);
const [loggingOut, setLoggingOut] = useState<boolean>(false);
const db = useContext(DbProviderContext);
const fetchLanguages = async () => {
@@ -321,7 +337,7 @@ export const AppSettingsProvider = ({
setApiUrl: setApiUrlHandler,
user,
login,
logout,
logout: () => setLoggingOut(true),
libraryPath,
setLibraryPath: setLibraryPathHandler,
proxy,
@@ -337,6 +353,29 @@ export const AppSettingsProvider = ({
}}
>
{children}
<AlertDialog open={loggingOut} onOpenChange={setLoggingOut}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("logout")}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
{t("logoutConfirmation")}
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive hover:bg-destructive-hover"
onClick={() => {
logout();
redirect("/landing");
}}
>
{t("logout")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AppSettingsProviderContext.Provider>
);
};

View File

@@ -447,7 +447,7 @@ export const ChatSessionProvider = ({
updateMessage,
}}
>
<MediaShadowProvider>
<MediaShadowProvider onCancel={() => setShadowing(null)}>
{chat && children}
<AlertDialog
@@ -472,6 +472,7 @@ export const ChatSessionProvider = ({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Sheet
modal={false}
open={Boolean(shadowing)}

View File

@@ -61,7 +61,7 @@ export const CourseProvider = ({
setShadowing,
}}
>
<MediaShadowProvider>
<MediaShadowProvider onCancel={() => setShadowing(null)}>
{children}
<Sheet
modal={false}

View File

@@ -18,13 +18,14 @@ import { Tooltip } from "react-tooltip";
import { useAudioRecorder } from "react-audio-voice-recorder";
import { t } from "i18next";
import { SttEngineOptionEnum } from "@/types/enums";
import { useNavigate } from "react-router-dom";
const ONE_MINUTE = 60;
const TEN_MINUTES = 10 * ONE_MINUTE;
type MediaShadowContextType = {
layout: "compact" | "normal";
onCancel?: () => void;
onCancel: () => void;
media: AudioType | VideoType;
setMedia: (media: AudioType | VideoType) => void;
setMediaProvider: (mediaProvider: HTMLAudioElement | null) => void;
@@ -116,6 +117,7 @@ export const MediaShadowProvider = ({
const { EnjoyApp, learningLanguage, recorderConfig } = useContext(
AppSettingsProviderContext
);
const navigate = useNavigate();
const [media, setMedia] = useState<AudioType | VideoType>(null);
const [mediaProvider, setMediaProvider] = useState<HTMLAudioElement | null>(
@@ -643,7 +645,7 @@ export const MediaShadowProvider = ({
<MediaShadowProviderContext.Provider
value={{
layout,
onCancel,
onCancel: onCancel || (() => navigate(-1)),
media,
setMedia,
setMediaProvider,

View File

@@ -188,7 +188,9 @@ export const useTranscribe = () => {
return {
engine: "upload",
model: "-",
transcript: segmentTimeline.map((entry) => entry.text).join(" "),
transcript: segmentTimeline
.map((entry: TimelineEntry) => entry.text)
.join(" "),
segmentTimeline,
};
} else {

View File

@@ -12,9 +12,7 @@ import { SttEngineOptionEnum } from "@/types/enums";
import { t } from "i18next";
export const useTranscriptions = (media: AudioType | VideoType) => {
const { sttEngine, echogardenSttConfig } = useContext(
AISettingsProviderContext
);
const { sttEngine } = useContext(AISettingsProviderContext);
const { EnjoyApp, learningLanguage, webApi } = useContext(
AppSettingsProviderContext
);

View File

@@ -1,32 +1,56 @@
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
import {
useParams,
useSearchParams,
Link,
useNavigate,
} from "react-router-dom";
import { AudioPlayer } from "@renderer/components";
import { Button } from "@renderer/components/ui";
import { ChevronLeftIcon } from "lucide-react";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@renderer/components/ui";
import { t } from "i18next";
import { MediaShadowProvider } from "@renderer/context";
import { useState } from "react";
export default () => {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const segmentIndex = searchParams.get("segmentIndex") || "0";
const [audio, setAudio] = useState<AudioType | null>(null);
const navigate = useNavigate();
return (
<>
<div className="h-screen flex flex-col relative">
<div className="flex space-x-1 items-center h-12 px-4">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ChevronLeftIcon className="w-5 h-5" />
</Button>
<span className="text-sm">{t("shadowingAudio")}</span>
</div>
<div className="h-screen flex flex-col relative">
<Breadcrumb className="px-4 pt-3 pb-2">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={`/audios`}>{t("sidebar.audios")}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>
{audio?.name || t("shadowingAudio")}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="flex-1">
<MediaShadowProvider>
<AudioPlayer id={id} segmentIndex={parseInt(segmentIndex)} />
</MediaShadowProvider>
</div>
<div className="flex-1">
<MediaShadowProvider onCancel={() => navigate("/audios")}>
<AudioPlayer
id={id}
segmentIndex={parseInt(segmentIndex)}
onLoad={setAudio}
/>
</MediaShadowProvider>
</div>
</>
</div>
);
};

View File

@@ -1,20 +1,8 @@
import { AudiosComponent } from "@renderer/components";
import { Button } from "@renderer/components/ui";
import { ChevronLeftIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { t } from "i18next";
export default () => {
const navigate = useNavigate();
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.audios")}</span>
</div>
<div className="min-h-full max-w-5xl mx-auto px-4 py-6">
<AudiosComponent />
</div>
);

View File

@@ -1,19 +0,0 @@
import { Button } from "@renderer/components/ui";
import { ChevronLeftIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { t } from "i18next";
export default () => {
const navigate = useNavigate();
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.books")}</span>
</div>
</div>
);
};

View File

@@ -14,30 +14,21 @@ export default () => {
const navigate = useNavigate();
return (
<div className="h-full px-4 lg:px-8 py-6">
<div className="max-w-screen-md mx-auto mb-6">
<div className="flex space-x-1 items-center mb-4">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ChevronLeftIcon className="w-5 h-5" />
</Button>
<span>{t("sidebar.community")}</span>
</div>
<div className="min-h-full px-4 lg:px-8 py-6 max-w-screen-md mx-auto">
<Tabs defaultValue="square">
<TabsList className="mb-4">
<TabsTrigger value="square">{t("square")}</TabsTrigger>
<TabsTrigger value="rankings">{t("rankings")}</TabsTrigger>
</TabsList>
<Tabs defaultValue="square">
<TabsList className="mb-4">
<TabsTrigger value="square">{t("square")}</TabsTrigger>
<TabsTrigger value="rankings">{t("rankings")}</TabsTrigger>
</TabsList>
<TabsContent value="square">
<Posts />
</TabsContent>
<TabsContent value="square">
<Posts />
</TabsContent>
<TabsContent value="rankings">
<UsersRankings />
</TabsContent>
</Tabs>
</div>
<TabsContent value="rankings">
<UsersRankings />
</TabsContent>
</Tabs>
</div>
);
};

View File

@@ -213,129 +213,120 @@ export default () => {
}, [currentGptEngine]);
return (
<div className="h-full px-4 py-6 lg:px-8 flex flex-col">
<div className="w-full max-w-screen-md mx-auto flex-1">
<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.aiAssistant")}</span>
</div>
<div className="min-h-full px-4 py-6 lg:px-8 max-w-5xl mx-auto">
<div className="mb-6 flex justify-center">
<Dialog>
<DialogTrigger asChild>
<Button
data-testid="conversation-new-button"
className="h-12 rounded-lg w-96"
>
{t("newConversation")}
</Button>
</DialogTrigger>
<div className="my-6 flex justify-center">
<Dialog>
<DialogTrigger asChild>
<Button
data-testid="conversation-new-button"
className="h-12 rounded-lg w-96"
>
{t("newConversation")}
</Button>
</DialogTrigger>
<DialogContent aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>{t("selectAiRole")}</DialogTitle>
</DialogHeader>
<DialogContent aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>{t("selectAiRole")}</DialogTitle>
</DialogHeader>
<div data-testid="conversation-presets" className="">
<div className="text-sm text-foreground/70 mb-2">
{t("chooseFromPresetGpts")}
</div>
<ScrollArea className="h-64 pr-4">
{config.gptPresets.map((preset: any) => (
<DialogTrigger
key={preset.key}
data-testid={`conversation-preset-${preset.key}`}
asChild
onClick={() => {
setPreset(preset);
setCreating(true);
}}
>
<div className="w-full p-2 cursor-pointer rounded hover:bg-muted">
<div className="capitalize truncate">{preset.name}</div>
{preset.configuration.roleDefinition && (
<div className="line-clamp-1 text-xs text-foreground/70">
{preset.configuration.roleDefinition}
</div>
)}
</div>
</DialogTrigger>
))}
</ScrollArea>
<div data-testid="conversation-presets" className="">
<div className="text-sm text-foreground/70 mb-2">
{t("chooseFromPresetGpts")}
</div>
<ScrollArea className="h-64 pr-4">
{config.gptPresets.map((preset: any) => (
<DialogTrigger
key={preset.key}
data-testid={`conversation-preset-${preset.key}`}
asChild
onClick={() => {
setPreset(preset);
setCreating(true);
}}
>
<div className="w-full p-2 cursor-pointer rounded hover:bg-muted">
<div className="capitalize truncate">{preset.name}</div>
{preset.configuration.roleDefinition && (
<div className="line-clamp-1 text-xs text-foreground/70">
{preset.configuration.roleDefinition}
</div>
)}
</div>
</DialogTrigger>
))}
</ScrollArea>
</div>
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="grid grid-cols-2 gap-4 mb-6">
<DialogTrigger asChild>
<Button
data-testid={`conversation-preset-${config.customPreset.key}`}
onClick={() => {
setPreset(config.customPreset);
setCreating(true);
}}
variant="secondary"
className="w-full"
>
{t("custom")} GPT
</Button>
</DialogTrigger>
{config.ttsPreset.key && (
<DialogTrigger asChild>
<Button
data-testid={`conversation-preset-${config.customPreset.key}`}
data-testid={`conversation-preset-${config.ttsPreset.key}`}
onClick={() => {
setPreset(config.customPreset);
setPreset(config.ttsPreset);
setCreating(true);
}}
variant="secondary"
className="w-full"
>
{t("custom")} GPT
TTS
</Button>
</DialogTrigger>
{config.ttsPreset.key && (
<DialogTrigger asChild>
<Button
data-testid={`conversation-preset-${config.ttsPreset.key}`}
onClick={() => {
setPreset(config.ttsPreset);
setCreating(true);
}}
variant="secondary"
className="w-full"
>
TTS
</Button>
</DialogTrigger>
)}
</div>
</DialogContent>
</Dialog>
)}
</div>
</DialogContent>
</Dialog>
<Sheet open={creating} onOpenChange={(value) => setCreating(value)}>
<SheetContent className="p-0" aria-describedby={undefined}>
<SheetHeader>
<SheetTitle className="sr-only">
{t("startConversation")}
</SheetTitle>
</SheetHeader>
<div className="h-screen relative">
<ConversationForm
conversation={preset}
onFinish={() => setCreating(false)}
/>
</div>
</SheetContent>
</Sheet>
</div>
{conversations.map((conversation) => (
<Link key={conversation.id} to={`/conversations/${conversation.id}`}>
<ConversationCard conversation={conversation} />
</Link>
))}
{hasMore && (
<div className="flex justify-center">
<Button
variant="ghost"
onClick={() => fetchConversations()}
disabled={loading || !hasMore}
className="px-4 py-2"
>
{t("loadMore")}
{loading && <LoaderIcon className="w-4 h-4 animate-spin ml-2" />}
</Button>
</div>
)}
<Sheet open={creating} onOpenChange={(value) => setCreating(value)}>
<SheetContent className="p-0" aria-describedby={undefined}>
<SheetHeader>
<SheetTitle className="sr-only">
{t("startConversation")}
</SheetTitle>
</SheetHeader>
<div className="h-screen relative">
<ConversationForm
conversation={preset}
onFinish={() => setCreating(false)}
/>
</div>
</SheetContent>
</Sheet>
</div>
{conversations.map((conversation) => (
<Link key={conversation.id} to={`/conversations/${conversation.id}`}>
<ConversationCard conversation={conversation} />
</Link>
))}
{hasMore && (
<div className="flex justify-center">
<Button
variant="ghost"
onClick={() => fetchConversations()}
disabled={loading || !hasMore}
className="px-4 py-2"
>
{t("loadMore")}
{loading && <LoaderIcon className="w-4 h-4 animate-spin ml-2" />}
</Button>
</div>
)}
</div>
);
};

View File

@@ -43,7 +43,7 @@ export default () => {
return (
<CourseProvider id={id}>
<div className="flex flex-col h-screen px-4 xl:px-6 py-6">
<Breadcrumb className="mb-6">
<Breadcrumb className="mb-4">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
@@ -51,9 +51,9 @@ export default () => {
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbPage>
<BreadcrumbLink asChild>
<Link to={`/courses/${id}`}>{chapter?.course?.title || id}</Link>
</BreadcrumbPage>
</BreadcrumbLink>
<BreadcrumbSeparator />
<BreadcrumbPage>{chapter?.title || sequence}</BreadcrumbPage>
</BreadcrumbList>

View File

@@ -1,25 +1,13 @@
import { useContext, useEffect, useState } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { Button, toast } from "@renderer/components/ui";
import { ChevronLeftIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "@renderer/components/ui";
import { t } from "i18next";
import { CourseCard } from "@renderer/components";
import { CourseCard, LoaderSpin } from "@renderer/components";
export default () => {
const navigate = useNavigate();
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.courses")}</span>
</div>
<div className="">
<CoursesList />
</div>
<div className="min-h-full max-w-5xl mx-auto px-4 py-6">
<CoursesList />
</div>
);
};
@@ -34,6 +22,7 @@ const CoursesList = () => {
if (loading) return;
if (!webApi) return;
setLoading(true);
webApi
.courses({ page: nextPage, language: learningLanguage })
.then(({ courses = [], next }) => {
@@ -48,6 +37,10 @@ const CoursesList = () => {
fetchCourses();
}, []);
if (loading) {
return <LoaderSpin />;
}
if (!courses.length) {
return (
<div className="flex items-center justify-center py-4">

View File

@@ -34,8 +34,8 @@ export default () => {
}, [id]);
return (
<div className="h-full max-w-5xl mx-auto px-4 py-6">
<Breadcrumb className="mb-6">
<div className="min-h-full max-w-5xl mx-auto px-4 py-6">
<Breadcrumb className="mb-4">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
@@ -113,7 +113,9 @@ const CourseDetail = (props: { course: CourseType; onUpdate: () => void }) => {
{course.enrollmentsCount > 0 && (
<div className="flex items-center space-x-1">
<UsersIcon className="w-4 h-4" />
<span className="text-sm text-muted-foreground">{course.enrollmentsCount}</span>
<span className="text-sm text-muted-foreground">
{course.enrollmentsCount}
</span>
</div>
)}
</div>

View File

@@ -1,5 +1,9 @@
import {
Button,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
@@ -13,9 +17,9 @@ import {
DocumentTextRenderer,
} from "@renderer/components";
import { useContext } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Link, useParams } from "react-router-dom";
import { DocumentProvider, DocumentProviderContext } from "@renderer/context";
import { ChevronLeftIcon } from "lucide-react";
import { t } from "i18next";
export default () => {
const { id } = useParams<{ id: string }>();
@@ -29,7 +33,6 @@ export default () => {
const DocumentComponent = () => {
const { document, playingSegment } = useContext(DocumentProviderContext);
const navigate = useNavigate();
if (!document) {
return (
@@ -41,12 +44,19 @@ const DocumentComponent = () => {
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>
<Breadcrumb className="px-4 pt-3 pb-2">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={`/documents`}>{t("sidebar.documents")}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<span className="text-sm line-clamp-1">{document.title}</span>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<ResizablePanelGroup direction="horizontal" className="p-4">
<ResizablePanel id="document" order={0}>

View File

@@ -1,4 +1,4 @@
import { Button, Input } from "@renderer/components/ui";
import { Input } from "@renderer/components/ui";
import {
DocumentAddButton,
DocumentCard,
@@ -7,13 +7,10 @@ import {
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);
@@ -37,13 +34,7 @@ export default () => {
}, [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="min-h-full max-w-5xl mx-auto px-4 py-6">
<div className="flex flex-wrap items-center gap-4 mb-4">
<Input
className="max-w-48"

View File

@@ -48,41 +48,34 @@ export default function Notes() {
return (
<div className="min-h-[100vh] w-full max-w-5xl mx-auto px-4 py-6 lg:px-8">
<div className="w-full max-w-screen-md mx-auto">
<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.notes")}</span>
</div>
{groups.length === 0 && (
<div className="flex justify-center">
<div className="my-4 text-muted-foreground text-sm">{t("noNotesYet")}</div>
{groups.length === 0 && (
<div className="flex justify-center">
<div className="my-4 text-muted-foreground text-sm">
{t("noNotesYet")}
</div>
)}
<div className="flex flex-col space-y-4">
{groups.map((group) => (
<NoteSegmentGroup
key={group.targetId}
count={group.count}
segment={group.segment}
/>
))}
</div>
)}
{hasMore && (
<div className="flex justify-center mt-4">
<Button
variant="secondary"
onClick={() => findNotesGroup({ offset: groups.length })}
>
{t("loadMore")}
</Button>
</div>
)}
<div className="flex flex-col space-y-4">
{groups.map((group) => (
<NoteSegmentGroup
key={group.targetId}
count={group.count}
segment={group.segment}
/>
))}
</div>
{hasMore && (
<div className="flex justify-center mt-4">
<Button
variant="secondary"
onClick={() => findNotesGroup({ offset: groups.length })}
>
{t("loadMore")}
</Button>
</div>
)}
</div>
);
}

View File

@@ -25,31 +25,20 @@ export default () => {
}, []);
return (
<div className="h-full px-4 py-6 lg:px-8">
<div className="max-w-5xl mx-auto">
<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.profile")}</span>
</div>
<div className="mb-8">
<RecordingStats />
</div>
<div className="mb-8">
<RecordingCalendar
onSelectRange={(from, to) => {
setRange([from, to]);
}}
/>
</div>
<div className="">
<RecordingActivities from={range[0]} to={range[1]} />
</div>
<div className="min-h-full px-4 py-6 lg:px-8 max-w-5xl mx-auto">
<div className="mb-8">
<RecordingStats />
</div>
<div className="mb-8">
<RecordingCalendar
onSelectRange={(from, to) => {
setRange([from, to]);
}}
/>
</div>
<RecordingActivities from={range[0]} to={range[1]} />
</div>
);
};

View File

@@ -138,15 +138,8 @@ export default () => {
}, [orderBy]);
return (
<div className="h-full px-4 py-6 lg:px-8">
<div className="max-w-5xl mx-auto">
<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.pronunciationAssessment")}</span>
</div>
<>
<div className="min-h-full px-4 py-6 lg:px-8 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-4">
<div className="">
<div className="flex items-center gap-2">
@@ -269,6 +262,6 @@ export default () => {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</>
);
};

View File

@@ -1,29 +1,34 @@
import { ChevronLeftIcon } from "lucide-react";
import { t } from "i18next";
import { Link, useNavigate } from "react-router-dom";
import { Button } from "@renderer/components/ui";
import { Link } from "react-router-dom";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@renderer/components/ui";
import { PronunciationAssessmentForm } from "@renderer/components";
export default () => {
const navigate = useNavigate();
return (
<div className="h-full px-4 py-6 lg:px-8">
<div className="max-w-5xl mx-auto">
<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>
<Link to="/pronunciation_assessments">
{t("sidebar.pronunciationAssessment")}
</Link>
<span>/</span>
<span>{t("newAssessment")}</span>
</div>
<div className="">
<PronunciationAssessmentForm />
</div>
</div>
<div className="min-h-full px-4 py-6 lg:px-8 max-w-5xl mx-auto">
<Breadcrumb className="mb-4">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={`/pronunciation_assessments`}>
{t("sidebar.pronunciationAssessment")}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{t("newAssessment")}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<PronunciationAssessmentForm />
</div>
);
};

View File

@@ -2,9 +2,23 @@ import { useContext, useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { AppSettingsProviderContext } from "../context";
import { LoaderSpin, Posts } from "@renderer/components";
import { ChevronLeftIcon } from "lucide-react";
import { t } from "i18next";
import { Avatar, AvatarFallback, AvatarImage, Button, Tabs, TabsContent, TabsList, TabsTrigger } from "@renderer/components/ui";
import {
Avatar,
AvatarFallback,
AvatarImage,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
Button,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@renderer/components/ui";
export default () => {
const { id } = useParams<{ id: string }>();
@@ -18,19 +32,19 @@ export default () => {
webApi.user(id).then((user) => {
setUser(user);
});
}
};
const follow = () => {
webApi.follow(id).then(() => {
setUser({ ...user, following: true });
});
}
};
const unfollow = () => {
webApi.unfollow(id).then(() => {
setUser({ ...user, following: false });
});
}
};
useEffect(() => {
fetchUser();
@@ -41,12 +55,17 @@ export default () => {
return (
<div className="h-full px-4 py-6 lg:px-8">
<div className="max-w-5xl mx-auto">
<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>{user.name}</span>
</div>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="/community">{t("sidebar.community")}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbPage>{user.name}</BreadcrumbPage>
</BreadcrumbList>
</Breadcrumb>
<div className="mb-6">
<div className="flex justify-center mb-2">
@@ -58,34 +77,55 @@ export default () => {
</Avatar>
</div>
{
currentUser.id != user.id && <div className="flex justify-center">{
user.following ? <Button variant="link" className="text-destructive" size="sm" onClick={unfollow}>{t('unfollow')}</Button> : <Button size="sm" onClick={follow}>{t('follow')}</Button>
}</div>
}
{currentUser.id != user.id && (
<div className="flex justify-center">
{user.following ? (
<Button
variant="link"
className="text-destructive"
size="sm"
onClick={unfollow}
>
{t("unfollow")}
</Button>
) : (
<Button size="sm" onClick={follow}>
{t("follow")}
</Button>
)}
</div>
)}
</div>
<div className="max-w-screen-sm mx-auto">
<Tabs defaultValue="activities">
<div className="w-full flex justify-center">
<TabsList>
<TabsTrigger value="activities">{t('activities')}</TabsTrigger>
<TabsTrigger value="followers"><span className="capitalize">{t('followers')}</span></TabsTrigger>
<TabsTrigger value="following"><span className="capitalize">{t('following')}</span></TabsTrigger>
<TabsTrigger value="activities">{t("activities")}</TabsTrigger>
<TabsTrigger value="followers">
<span className="capitalize">{t("followers")}</span>
</TabsTrigger>
<TabsTrigger value="following">
<span className="capitalize">{t("following")}</span>
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="activities">
<Posts userId={user.id} />
</TabsContent>
<TabsContent value="followers"><UserFollowers id={user.id} /></TabsContent>
<TabsContent value="following"><UserFollowing id={user.id} /></TabsContent>
<TabsContent value="followers">
<UserFollowers id={user.id} />
</TabsContent>
<TabsContent value="following">
<UserFollowing id={user.id} />
</TabsContent>
</Tabs>
</div>
</div>
</div>
)
}
);
};
const UserFollowers = (props: { id: string }) => {
const { id } = props;
@@ -100,17 +140,22 @@ const UserFollowers = (props: { id: string }) => {
setUsers(res.users);
setPage(res.next);
});
}
};
useEffect(() => {
fetchFollowers();
return () => {
setUsers([]);
setPage(1);
}
};
}, [id]);
if (users.length === 0) return <div className="w-full px-4 py-6 text-center text-sm text-muted-foreground">{t("noFollowersYet")}</div>;
if (users.length === 0)
return (
<div className="w-full px-4 py-6 text-center text-sm text-muted-foreground">
{t("noFollowersYet")}
</div>
);
return (
<>
@@ -119,14 +164,14 @@ const UserFollowers = (props: { id: string }) => {
<UserCard key={user.id} user={user} />
))}
</div>
{
page && (<div className="flex justify-center py-4">
{page && (
<div className="flex justify-center py-4">
<Button onClick={() => fetchFollowers()}>{t("loadMore")}</Button>
</div>)
}
</div>
)}
</>
)
}
);
};
const UserFollowing = (props: { id: string }) => {
const { id } = props;
@@ -141,17 +186,22 @@ const UserFollowing = (props: { id: string }) => {
setUsers(res.users);
setPage(res.next);
});
}
};
useEffect(() => {
fetchFollowing();
return () => {
setUsers([]);
setPage(1);
}
};
}, [id]);
if (users.length === 0) return <div className="w-full px-4 py-6 text-center text-sm text-muted-foreground">{t("notFollowingAnyoneYet")}</div>;
if (users.length === 0)
return (
<div className="w-full px-4 py-6 text-center text-sm text-muted-foreground">
{t("notFollowingAnyoneYet")}
</div>
);
return (
<>
@@ -160,14 +210,14 @@ const UserFollowing = (props: { id: string }) => {
<UserCard key={user.id} user={user} />
))}
</div>
{
page && (<div className="flex justify-center py-4">
{page && (
<div className="flex justify-center py-4">
<Button onClick={() => fetchFollowing()}>{t("loadMore")}</Button>
</div>)
}
</div>
)}
</>
)
}
);
};
const UserCard = ({ user }: { user: UserType }) => {
const { webApi, user: currentUser } = useContext(AppSettingsProviderContext);
@@ -203,16 +253,16 @@ const UserCard = ({ user }: { user: UserType }) => {
</div>
<div className="">
{
currentUser.id != user.id && <Button
{currentUser.id != user.id && (
<Button
variant={following ? "secondary" : "default"}
size="sm"
onClick={handleFollow}
>
{following ? t("unfollow") : t("follow")}
</Button>
}
)}
</div>
</div>
);
}
};

View File

@@ -1,30 +1,56 @@
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
import {
useParams,
useSearchParams,
Link,
useNavigate,
} from "react-router-dom";
import { VideoPlayer } from "@renderer/components";
import { Button } from "@renderer/components/ui";
import { ChevronLeftIcon } from "lucide-react";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@renderer/components/ui";
import { t } from "i18next";
import { MediaShadowProvider } from "@renderer/context";
import { useState } from "react";
export default () => {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const segmentIndex = searchParams.get("segmentIndex") || "0";
const [video, setVideo] = useState<VideoType | null>(null);
const navigate = useNavigate();
return (
<>
<div className="h-screen flex flex-col relative">
<div className="flex space-x-1 items-center h-12 px-4">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ChevronLeftIcon className="w-5 h-5" />
</Button>
<span>{t("shadowingVideo")}</span>
</div>
<div className="h-screen flex flex-col relative">
<Breadcrumb className="px-4 pt-3 pb-2">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={`/videos`}>{t("sidebar.videos")}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>
{video?.name || t("shadowingVideo")}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<MediaShadowProvider>
<VideoPlayer id={id} segmentIndex={parseInt(segmentIndex)} />
<div className="flex-1">
<MediaShadowProvider onCancel={() => navigate("/videos")}>
<VideoPlayer
id={id}
segmentIndex={parseInt(segmentIndex)}
onLoad={setVideo}
/>
</MediaShadowProvider>
</div>
</>
</div>
);
};

View File

@@ -1,20 +1,8 @@
import { VideosComponent } from "@renderer/components";
import { Button } from "@renderer/components/ui";
import { ChevronLeftIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { t } from "i18next";
export default () => {
const navigate = useNavigate();
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.videos")}</span>
</div>
<div className="min-h-full max-w-5xl mx-auto px-4 py-6">
<VideosComponent />
</div>
);

View File

@@ -1,18 +1,14 @@
import { Button } from "@renderer/components/ui";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useState, useContext, useEffect } from "react";
import {
AppSettingsProviderContext,
HotKeysSettingsProviderContext,
} from "@renderer/context";
import { LoaderSpin, MeaningMemorizingCard } from "@renderer/components";
import { t } from "i18next";
import { useHotkeys } from "react-hotkeys-hook";
export default () => {
const navigate = useNavigate();
const [loading, setLoading] = useState<boolean>(false);
const [meanings, setMeanings] = useState<MeaningType[]>([]);
const { webApi } = useContext(AppSettingsProviderContext);
@@ -24,7 +20,9 @@ export default () => {
const fetchMeanings = async (page: number = nextPage) => {
if (!page) return;
if (loading) return;
setLoading(true);
webApi
.mineMeanings({ page, items: 10 })
.then((response) => {
@@ -67,13 +65,6 @@ export default () => {
return (
<div className="h-[100vh]">
<div className="max-w-screen-md mx-auto p-4">
<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.vocabulary")}</span>
</div>
{meanings.length === 0 ? (
<div className=""></div>
) : (
@@ -89,7 +80,7 @@ export default () => {
}
}}
>
<ChevronLeftIcon className="w-5 h-5" />
<ChevronLeftIcon className="size-5" />
</Button>
<div className="bg-background flex-1 h-5/6 border p-6 rounded-xl shadow-xl">
<MeaningMemorizingCard meaning={meanings[currentIndex]} />
@@ -108,7 +99,7 @@ export default () => {
}
}}
>
<ChevronRightIcon className="w-5 h-5" />
<ChevronRightIcon className="size-5" />
</Button>
</div>
)}

View File

@@ -13,7 +13,6 @@ 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";
import Home from "./pages/home";
@@ -114,10 +113,6 @@ export default createHashRouter([
path: "/stories/:id",
element: <Story />,
},
{
path: "/books",
element: <Books />,
},
{
path: "/stories/preview/:uri",
element: <StoryPreview />,