Customize title bar (#1184)

* add basic title bar

* add title bar actions

* fix layout

* update title bar

* update layout

* fix title bar for macOS

* UI

* setup menu for macOS

* fix title bar logo
This commit is contained in:
an-lee
2024-11-17 16:02:17 +08:00
committed by GitHub
parent b8167a99d8
commit 8bebf2072c
27 changed files with 771 additions and 277 deletions

View File

@@ -343,7 +343,9 @@
"logoutAndRemoveAllPersonalSettings": "Logout and remove all personal settings",
"hotkeys": "Hotkeys",
"player": "Player",
"quit": "Quit",
"quitApp": "Quit APP",
"quitAppDescription": "Are you sure to quit Enjoy app?",
"openPreferences": "Open preferences",
"openCopilot": "Open copilot",
"playOrPause": "Play or pause",
@@ -929,5 +931,7 @@
"compressMediaBeforeAdding": "Compress media before adding",
"keepOriginalMedia": "Keep original media",
"myPronunciation": "My pronunciation",
"originalPronunciation": "Original pronunciation"
"originalPronunciation": "Original pronunciation",
"reloadApp": "reload app",
"exit": "Exit"
}

View File

@@ -344,7 +344,9 @@
"hotkeys": "快捷键",
"system": "系统",
"player": "播放器",
"quit": "退出",
"quitApp": "退出应用",
"quitAppDescription": "确定要退出 Enjoy 应用吗?",
"openPreferences": "打开设置",
"openCopilot": "打开 Copilot",
"playOrPause": "播放/暂停",
@@ -929,5 +931,7 @@
"compressMediaBeforeAdding": "添加前压缩媒体",
"keepOriginalMedia": "保存原始媒体",
"myPronunciation": "我的发音",
"originalPronunciation": "原始发音"
"originalPronunciation": "原始发音",
"reloadApp": "重载应用",
"exit": "退出"
}

View File

@@ -94,6 +94,12 @@
.scroll {
@apply scrollbar-thin scrollbar-thumb-primary scrollbar-track-secondary;
}
.draggable-region {
-webkit-app-region: drag;
}
.non-draggable-region {
-webkit-app-region: no-drag;
}
}
body {

View File

@@ -25,6 +25,8 @@ import dict from "./dict";
import mdict from "./mdict";
import decompresser from "./decompresser";
import { UserSetting } from "@main/db/models";
import { platform } from "os";
import { t } from "i18next";
const __filename = url.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -520,6 +522,14 @@ ${log}
preload: path.join(__dirname, "preload.js"),
spellcheck: false,
},
frame: false,
titleBarStyle: "hidden",
titleBarOverlay: process.platform === "darwin",
trafficLightPosition: {
x: 10,
y: 8,
},
useContentSize: true,
});
mainWindow.on("ready-to-show", () => {
@@ -527,7 +537,62 @@ ${log}
});
mainWindow.on("resize", () => {
mainWindow.webContents.send("window-on-resize", mainWindow.getBounds());
mainWindow.webContents.send("window-on-change", {
event: "resize",
state: mainWindow.getBounds(),
});
});
mainWindow.on("enter-full-screen", () => {
mainWindow.webContents.send("window-on-change", { event: "enter-full-screen" });
});
mainWindow.on("leave-full-screen", () => {
mainWindow.webContents.send("window-on-change", { event: "leave-full-screen" });
});
mainWindow.on("maximize", () => {
mainWindow.webContents.send("window-on-change", { event: "maximize" });
});
mainWindow.on("unmaximize", () => {
mainWindow.webContents.send("window-on-change", { event: "unmaximize" });
});
ipcMain.handle("window-is-maximized", () => {
return mainWindow.isMaximized();
});
ipcMain.handle("window-toggle-maximized", () => {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
});
ipcMain.handle("window-maximize", () => {
mainWindow.maximize();
});
ipcMain.handle("window-unmaximize", () => {
mainWindow.unmaximize();
});
ipcMain.handle("window-fullscreen", () => {
mainWindow.setFullScreen(true);
});
ipcMain.handle("window-unfullscreen", () => {
mainWindow.setFullScreen(false);
});
ipcMain.handle("window-minimize", () => {
mainWindow.minimize();
});
ipcMain.handle("window-close", () => {
app.quit();
});
mainWindow.webContents.setWindowOpenHandler(() => {
@@ -571,7 +636,42 @@ ${log}
// mainWindow.webContents.openDevTools();
}
Menu.setApplicationMenu(null);
if (platform() === "darwin") {
const menu = Menu.buildFromTemplate([
{
label: app.name,
submenu: [
{ role: "about" },
{ type: "separator" },
{ role: "hide" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" },
],
},
{
label: "&Help",
submenu: [
{
label: "Check for Updates...",
click: () => {
shell.openExternal("https://1000h.org/enjoy-app/install.html");
},
},
{
label: "Report Issue...",
click: () => {
shell.openExternal(`${REPO_URL}/issues/new`)
}
}
]
}
])
Menu.setApplicationMenu(menu);
} else {
Menu.setApplicationMenu(null);
}
main.win = mainWindow;
};

View File

@@ -54,14 +54,49 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
version,
},
window: {
onResize: (
isFullScreen: () => {
return ipcRenderer.invoke("window-is-full-screen");
},
toggleFullscreen: () => {
return ipcRenderer.invoke("window-fullscreen");
},
isMaximized: () => {
return ipcRenderer.invoke("window-is-maximized");
},
toggleMaximized: () => {
return ipcRenderer.invoke("window-toggle-maximized");
},
maximize: () => {
return ipcRenderer.invoke("window-maximize");
},
unmaximize: () => {
return ipcRenderer.invoke("window-unmaximize");
},
fullscreen: () => {
return ipcRenderer.invoke("window-fullscreen");
},
unfullscreen: () => {
return ipcRenderer.invoke("window-unfullscreen");
},
minimize: () => {
return ipcRenderer.invoke("window-minimize");
},
close: () => {
return ipcRenderer.invoke("window-close");
},
onChange: (
callback: (
event: IpcRendererEvent,
bounds: { x: number; y: number; width: number; height: number }
state: { event: string; state: any }
) => void
) => ipcRenderer.on("window-on-resize", callback),
removeListeners: () => {
ipcRenderer.removeAllListeners("window-on-resize");
) => ipcRenderer.on("window-on-change", callback),
removeListener: (
listener: (event: IpcRendererEvent, ...args: any[]) => void
) => {
ipcRenderer.removeListener("window-on-change", listener);
},
removeAllListeners: () => {
ipcRenderer.removeAllListeners("window-on-change");
},
},
system: {

View File

@@ -12,14 +12,14 @@ export const ChatSession = (props: {
if (!chatId) {
return (
<div className="flex items-center justify-center h-screen">
<div className="flex items-center justify-center h-[calc(100vh-2rem)]">
<span className="text-muted-foreground">{t("noChatSelected")}</span>
</div>
);
}
return (
<ScrollArea className="h-screen relative">
<ScrollArea className="h-[calc(100vh-2rem)] relative">
<ChatSessionProvider chatId={chatId}>
<ChatHeader
sidePanelCollapsed={sidePanelCollapsed}

View File

@@ -8,7 +8,7 @@ export const CopilotSession = () => {
const { currentChat } = useContext(CopilotProviderContext);
return (
<ScrollArea className="h-screen relative">
<ScrollArea className="h-[calc(100vh-2rem)] relative">
{currentChat?.id ? (
<ChatSessionProvider chatId={currentChat.id}>
<CopilotHeader />

View File

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

View File

@@ -0,0 +1,2 @@
export * from "./layout";
export * from "./title-bar";

View File

@@ -0,0 +1,66 @@
import { Outlet } from "react-router-dom";
import {
AppSettingsProviderContext,
CopilotProviderContext,
} from "@renderer/context";
import { useContext } from "react";
import { CopilotSession, TitleBar, Sidebar } from "@renderer/components";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@renderer/components/ui";
export const Layout = () => {
const { initialized } = useContext(AppSettingsProviderContext);
const { active, setActive } = useContext(CopilotProviderContext);
if (initialized) {
return (
<div className="h-screen flex flex-col">
<TitleBar />
<ResizablePanelGroup
direction="horizontal"
className="flex-1 h-full w-full"
data-testid="layout-home"
>
<ResizablePanel id="main-panel" order={1} minSize={50}>
<div className="flex flex-start">
<Sidebar />
<div className="flex-1 border-l overflow-x-hidden overflow-y-auto h-[calc(100vh-2rem)]">
<Outlet />
</div>
</div>
</ResizablePanel>
{active && (
<>
<ResizableHandle />
<ResizablePanel
id="copilot-panel"
order={2}
collapsible={true}
defaultSize={30}
maxSize={50}
minSize={15}
onCollapse={() => setActive(false)}
>
<div className="h-[calc(100vh-2rem)]">
<CopilotSession />
</div>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</div>
);
} else {
return (
<div className="h-screen flex flex-col w-full">
<TitleBar />
<div className="flex-1 h-[calc(100vh-2rem)] overflow-y-auto">
<Outlet />
</div>
</div>
);
}
};

View File

@@ -0,0 +1,275 @@
import {
AppSettingsProviderContext,
CopilotProviderContext,
} from "@/renderer/context";
import { ChevronRightIcon } from "@radix-ui/react-icons";
import {
AlertDialog,
AlertDialogHeader,
AlertDialogContent,
AlertDialogTrigger,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
Button,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuPortal,
DropdownMenuSubContent,
DropdownMenuSeparator,
} from "@renderer/components/ui";
import { IpcRendererEvent } from "electron/renderer";
import { t } from "i18next";
import {
ExternalLinkIcon,
HelpCircleIcon,
LightbulbIcon,
LightbulbOffIcon,
MaximizeIcon,
MenuIcon,
MinimizeIcon,
MinusIcon,
SettingsIcon,
XIcon,
} from "lucide-react";
import { useContext, useEffect, useState } from "react";
export const TitleBar = () => {
const [isMaximized, setIsMaximized] = useState(false);
const [isFullScreen, setIsFullScreen] = useState(false);
const [platform, setPlatform] = useState<"darwin" | "win32" | "linux">();
const { EnjoyApp, setDisplayPreferences, initialized } = useContext(
AppSettingsProviderContext
);
const { active, setActive } = useContext(CopilotProviderContext);
const onWindowChange = (
_event: IpcRendererEvent,
state: { event: string }
) => {
if (state.event === "maximize") {
setIsMaximized(true);
} else if (state.event === "unmaximize") {
setIsMaximized(false);
} else if (state.event === "enter-full-screen") {
setIsFullScreen(true);
} else if (state.event === "leave-full-screen") {
setIsFullScreen(false);
}
};
useEffect(() => {
EnjoyApp.window.onChange(onWindowChange);
EnjoyApp.app.getPlatformInfo().then((info) => {
setPlatform(info.platform as "darwin" | "win32" | "linux");
});
return () => {
EnjoyApp.window.removeListener(onWindowChange);
};
}, []);
return (
<div className="z-50 h-8 w-full bg-muted draggable-region flex items-center justify-between border-b">
<div className="flex items-center px-2">
{platform === "darwin" && !isFullScreen && <div className="w-16"></div>}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8 rounded-none non-draggable-region hover:bg-primary/10"
>
<img src="./assets/icon.png" alt="Enjoy" className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="bottom">
<DropdownMenuItem
onClick={() => EnjoyApp.app.reload()}
className="cursor-pointer"
>
<span className="capitalize">{t("reloadApp")}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => EnjoyApp.window.close()}
className="cursor-pointer"
>
<span className="capitalize">{t("exit")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{initialized && (
<Button
variant="ghost"
size="icon"
className="size-8 rounded-none non-draggable-region hover:bg-primary/10"
onClick={() => setDisplayPreferences(true)}
>
<SettingsIcon className="size-4" />
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8 rounded-none non-draggable-region hover:bg-primary/10"
>
<HelpCircleIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width]"
align="start"
side="top"
>
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() =>
EnjoyApp.shell.openExternal("https://1000h.org/enjoy-app/")
}
className="flex justify-between space-x-4"
>
<span className="min-w-fit capitalize">{t("userGuide")}</span>
<ExternalLinkIcon className="size-4" />
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuGroup>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="non-draggable-region">
<span className="capitalize">
{t("feedback")}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={() =>
EnjoyApp.shell.openExternal(
"https://mixin.one/codes/f6ff96b8-54fb-4ad8-a6d4-5a5bdb1df13e"
)
}
className="flex justify-between space-x-4"
>
<span>Mixin</span>
<ExternalLinkIcon className="size-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
EnjoyApp.shell.openExternal(
"https://github.com/zuodaotech/everyone-can-use-english/discussions"
)
}
className="flex justify-between space-x-4"
>
<span>Github</span>
<ExternalLinkIcon className="size-4" />
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
EnjoyApp.shell.openExternal(
"https://1000h.org/enjoy-app/install.html"
)
}
className="cursor-pointer"
>
<span className="capitalize">{t("checkUpdate")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
{initialized && (
<Button
variant="ghost"
size="icon"
className={`size-8 rounded-none non-draggable-region hover:bg-primary/10 ${
active ? "bg-primary/10" : ""
}`}
onClick={() => setActive(!active)}
>
{active ? (
<LightbulbIcon className="size-4" />
) : (
<LightbulbOffIcon className="size-4" />
)}
</Button>
)}
</div>
{platform !== "darwin" && (
<div className="flex items-center">
<Button
variant="ghost"
size="icon"
className="size-8 rounded-none non-draggable-region hover:bg-primary/10"
onClick={() => EnjoyApp.window.minimize()}
>
<MinusIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-8 rounded-none non-draggable-region hover:bg-primary/10"
onClick={() => EnjoyApp.window.toggleMaximized()}
>
{isMaximized ? (
<MinimizeIcon className="size-4" />
) : (
<MaximizeIcon className="size-4" />
)}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8 rounded-none non-draggable-region hover:bg-destructive"
>
<XIcon className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("quitApp")}</AlertDialogTitle>
<AlertDialogDescription>
{t("quitAppDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => EnjoyApp.window.close()}
>
{t("quit")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</div>
</div>
);
};

View File

@@ -38,7 +38,7 @@ export const LoginForm = () => {
if (user) {
return (
<div className="px-4 py-2 border rounded-lg w-full max-w-sm">
<div className="px-4 py-2 border rounded-lg w-full max-w-sm m-auto">
<UserSettings />
<Separator />
<LanguageSettings />

View File

@@ -448,10 +448,8 @@ export const MediaCurrentRecording = () => {
debouncedCalContainerSize();
});
observer.observe(ref.current);
EnjoyApp.window.onResize(debouncedCalContainerSize);
return () => {
EnjoyApp.window.removeListeners();
observer.disconnect();
};
}, [ref, player]);

View File

@@ -144,10 +144,7 @@ export const MediaWaveform = () => {
});
observer.observe(ref.current);
EnjoyApp.window.onResize(debouncedCalContainerSize);
return () => {
EnjoyApp.window.removeListeners();
observer.disconnect();
};
}, [ref, wavesurfer]);

View File

@@ -1,5 +1,4 @@
export * from "./db-state";
export * from "./layout";
export * from "./loader-spin";
export * from "./markdown-wrapper";
export * from "./no-records-found";

View File

@@ -1,87 +0,0 @@
import { Sidebar } from "./sidebar";
import { Outlet, Link } from "react-router-dom";
import {
AppSettingsProviderContext,
CopilotProviderContext,
DbProviderContext,
} from "@renderer/context";
import { useContext } from "react";
import { Button } from "@renderer/components/ui/button";
import { DbState, CopilotSession } from "@renderer/components";
import { t } from "i18next";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@renderer/components/ui";
export const Layout = () => {
const { initialized } = useContext(AppSettingsProviderContext);
const db = useContext(DbProviderContext);
const { active, setActive } = useContext(CopilotProviderContext);
if (!initialized) {
return (
<div
className="h-screen flex justify-center items-center"
date-testid="layout-onboarding"
>
<div className="text-center">
<div className="text-lg mb-6">
{t("welcomeTo")} <span className="font-semibold">Enjoy App</span>
</div>
<div className="">
<Link data-testid="landing-button" to="/landing" replace>
<Button size="lg">{t("startToUse")}</Button>
</Link>
</div>
</div>
</div>
);
} else if (db.state === "connected") {
return (
<ResizablePanelGroup
direction="horizontal"
className="h-screen w-full"
data-testid="layout-home"
>
<ResizablePanel id="main-panel" order={1} minSize={50}>
<div className="flex flex-start">
<Sidebar />
<div className="flex-1 border-l overflow-x-hidden overflow-y-auto h-screen">
<Outlet />
</div>
</div>
</ResizablePanel>
{active && (
<>
<ResizableHandle />
<ResizablePanel
id="copilot-panel"
order={2}
collapsible={true}
defaultSize={30}
maxSize={50}
minSize={15}
onCollapse={() => setActive(false)}
>
<div className="h-screen">
<CopilotSession />
</div>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
);
} else {
return (
<div
className="h-screen w-screen flex justify-center items-center"
data-testid="layout-db-error"
>
<DbState />
</div>
);
}
};

View File

@@ -1,17 +1,11 @@
import {
Button,
Dialog,
DialogTrigger,
DialogContent,
ScrollArea,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuSub,
DropdownMenuPortal,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuItem,
Separator,
DialogTitle,
@@ -30,8 +24,6 @@ import {
BotIcon,
UsersRoundIcon,
LucideIcon,
HelpCircleIcon,
ExternalLinkIcon,
NotebookPenIcon,
SpeechIcon,
GraduationCapIcon,
@@ -40,7 +32,6 @@ import {
PanelLeftCloseIcon,
ChevronsUpDownIcon,
LogOutIcon,
ChevronRightIcon,
} from "lucide-react";
import { useLocation, Link, useNavigate } from "react-router-dom";
import { t } from "i18next";
@@ -53,7 +44,8 @@ import { useState } from "react";
export const Sidebar = () => {
const location = useLocation();
const activeTab = location.pathname;
const { EnjoyApp, cable } = useContext(AppSettingsProviderContext);
const { EnjoyApp, cable, displayPreferences, setDisplayPreferences } =
useContext(AppSettingsProviderContext);
const [isOpen, setIsOpen] = useState(true);
useEffect(() => {
@@ -83,7 +75,7 @@ export const Sidebar = () => {
return (
<div
className={`h-[100vh] transition-all relative ${
className={`h-[calc(100vh-2rem)] pt-8 transition-all relative draggable-region ${
isOpen ? "w-48" : "w-12"
}`}
data-testid="sidebar"
@@ -93,7 +85,7 @@ export const Sidebar = () => {
isOpen ? "w-48" : "w-12"
}`}
>
<ScrollArea className="w-full h-full pb-12">
<ScrollArea className="w-full h-full pb-12 pt-8">
<SidebarHeader isOpen={isOpen} />
<div className="grid gap-2 mb-4">
<SidebarItem
@@ -194,28 +186,30 @@ export const Sidebar = () => {
<Separator />
<Dialog>
<DialogTrigger asChild>
<div className="px-1">
<Button
size="sm"
variant="ghost"
id="preferences-button"
className={`w-full ${
isOpen ? "justify-start" : "justify-center"
}`}
data-tooltip-id="global-tooltip"
data-tooltip-content={t("sidebar.preferences")}
data-tooltip-place="right"
>
<SettingsIcon className="size-4" />
{isOpen && (
<span className="ml-2"> {t("sidebar.preferences")} </span>
)}
</Button>
</div>
</DialogTrigger>
<div className="px-1 non-draggable-region">
<Button
size="sm"
variant={displayPreferences ? "default" : "ghost"}
id="preferences-button"
className={`w-full ${
isOpen ? "justify-start" : "justify-center"
}`}
data-tooltip-id="global-tooltip"
data-tooltip-content={t("sidebar.preferences")}
data-tooltip-place="right"
onClick={() => setDisplayPreferences(true)}
>
<SettingsIcon className="size-4" />
{isOpen && (
<span className="ml-2"> {t("sidebar.preferences")} </span>
)}
</Button>
</div>
<Dialog
open={displayPreferences}
onOpenChange={setDisplayPreferences}
>
<DialogContent
aria-describedby={undefined}
className="max-w-screen-md xl:max-w-screen-lg h-5/6 p-0"
@@ -226,82 +220,6 @@ export const Sidebar = () => {
<Preferences />
</DialogContent>
</Dialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="px-1">
<Button
size="sm"
variant="ghost"
className={`w-full ${
isOpen ? "justify-start" : "justify-center"
}`}
>
<HelpCircleIcon className="size-4" />
{isOpen && (
<>
<span className="ml-2"> {t("sidebar.help")} </span>
<ChevronRightIcon className="w-4 h-4 ml-auto" />
</>
)}
</Button>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width]"
align="start"
side="top"
>
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() =>
EnjoyApp.shell.openExternal(
"https://1000h.org/enjoy-app/"
)
}
className="flex justify-between space-x-4"
>
<span className="min-w-fit">{t("userGuide")}</span>
<ExternalLinkIcon className="size-4" />
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuGroup>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
{t("feedback")}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={() =>
EnjoyApp.shell.openExternal(
"https://mixin.one/codes/f6ff96b8-54fb-4ad8-a6d4-5a5bdb1df13e"
)
}
className="flex justify-between space-x-4"
>
<span>Mixin</span>
<ExternalLinkIcon className="size-4" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
EnjoyApp.shell.openExternal(
"https://github.com/zuodaotech/everyone-can-use-english/discussions"
)
}
className="flex justify-between space-x-4"
>
<span>Github</span>
<ExternalLinkIcon className="size-4" />
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</ScrollArea>
@@ -309,7 +227,9 @@ export const Sidebar = () => {
<Button
size="sm"
variant="ghost"
className={`w-full ${isOpen ? "justify-start" : "justify-center"}`}
className={`w-full non-draggable-region ${
isOpen ? "justify-start" : "justify-center"
}`}
onClick={() => setIsOpen(!isOpen)}
>
{isOpen ? (
@@ -343,7 +263,7 @@ const SidebarItem = (props: {
data-tooltip-content={tooltip}
data-tooltip-place="right"
data-testid={testid}
className="block px-1"
className="block px-1 non-draggable-region"
>
<Button
size="sm"
@@ -362,8 +282,12 @@ const SidebarHeader = (props: { isOpen: boolean }) => {
const { user, logout } = useContext(AppSettingsProviderContext);
const navigate = useNavigate();
if (!user) {
return null;
}
return (
<div className="py-3 px-1 sticky top-0 bg-muted z-10">
<div className="py-3 px-1 sticky top-0 bg-muted z-10 non-draggable-region">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button

View File

@@ -8,7 +8,6 @@ import { DbProviderContext } from "@renderer/context";
import { UserSettingKeyEnum } from "@/types/enums";
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
@@ -16,10 +15,10 @@ import {
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
Button,
} from "@renderer/components/ui";
import { t } from "i18next";
import { redirect } from "react-router-dom";
import { Preferences } from "@renderer/components";
type AppSettingsProviderState = {
webApi: Client;
@@ -50,6 +49,8 @@ type AppSettingsProviderState = {
setRecorderConfig?: (config: RecorderConfigType) => Promise<void>;
// remote config
ipaMappings?: { [key: string]: string };
displayPreferences?: boolean;
setDisplayPreferences?: (display: boolean) => void;
};
const EnjoyApp = window.__ENJOY_APP__;
@@ -87,6 +88,7 @@ export const AppSettingsProvider = ({
IPA_MAPPINGS
);
const [loggingOut, setLoggingOut] = useState<boolean>(false);
const [displayPreferences, setDisplayPreferences] = useState<boolean>(false);
const db = useContext(DbProviderContext);
@@ -344,12 +346,14 @@ export const AppSettingsProvider = ({
setProxy: setProxyConfigHandler,
vocabularyConfig,
setVocabularyConfig: setVocabularyConfigHandler,
initialized: Boolean(db.state === "connected" && libraryPath),
initialized: Boolean(user && db.state === "connected" && libraryPath),
ahoy,
cable,
recorderConfig,
setRecorderConfig: setRecorderConfigHandler,
ipaMappings,
displayPreferences,
setDisplayPreferences,
}}
>
{children}

View File

@@ -25,7 +25,7 @@ export default () => {
const navigate = useNavigate();
return (
<div className="h-screen flex flex-col relative">
<div className="h-[calc(100vh-2rem)] flex flex-col relative">
<Breadcrumb className="px-4 pt-3 pb-2">
<BreadcrumbList>
<BreadcrumbItem>

View File

@@ -231,9 +231,9 @@ export default () => {
return (
<div
data-testid="conversation-page"
className="h-screen px-4 py-6 lg:px-8 flex flex-col"
className="h-[calc(100vh-2rem)] px-4 py-4 lg:px-8 flex flex-col"
>
<div className="h-[calc(100vh-3rem)] relative w-full max-w-screen-md mx-auto flex flex-col">
<div className="h-[calc(100vh-5rem)] relative w-full max-w-screen-md mx-auto flex flex-col">
<div className="flex items-center justify-center py-2 relative">
<div className="cursor-pointer h-6 opacity-50 hover:opacity-100">
<Link className="flex items-center" to="/conversations">

View File

@@ -43,7 +43,7 @@ const DocumentComponent = () => {
}
return (
<div className="h-screen flex flex-col relative">
<div className="h-[calc(100vh-2rem)] flex flex-col relative">
<Breadcrumb className="px-4 pt-3 pb-2">
<BreadcrumbList>
<BreadcrumbItem>

View File

@@ -1,35 +1,65 @@
import { t } from "i18next";
import { useContext } from "react";
import { useContext, useState } from "react";
import { Button } from "@renderer/components/ui";
import { Link } from "react-router-dom";
import { LoginForm } from "@renderer/components";
import { AppSettingsProviderContext } from "@renderer/context";
import { Link, Navigate } from "react-router-dom";
import { DbState, LoginForm } from "@renderer/components";
import {
AppSettingsProviderContext,
DbProviderContext,
} from "@renderer/context";
export default () => {
const { initialized, EnjoyApp } = useContext(AppSettingsProviderContext);
const { initialized, EnjoyApp, user } = useContext(
AppSettingsProviderContext
);
const [started, setStarted] = useState(false);
const db = useContext(DbProviderContext);
if (initialized) {
return <Navigate to="/" replace />;
}
if (user && db.state === "error") {
return (
<div
className="flex justify-center items-center h-full"
date-testid="layout-db-error"
>
<DbState />
</div>
);
}
if (!started) {
return (
<div
className="flex justify-center items-center h-full"
date-testid="layout-onboarding"
>
<div className="text-center">
<div className="text-lg mb-6">
{t("welcomeTo")} <span className="font-semibold">Enjoy App</span>
</div>
<div className="">
<Button size="lg" onClick={() => setStarted(true)}>
{t("startToUse")}
</Button>
</div>
</div>
</div>
);
}
return (
<div className="h-screen w-full px-4 py-6 lg:px-8 flex flex-col">
<div className="w-full h-full px-4 py-6 lg:px-8 flex flex-col gap-8">
<div className="text-center">
<div className="text-lg font-mono py-6">{t("login")}</div>
<div className="text-lg font-mono py-4">{t("login")}</div>
<div className="text-sm opacity-70">{t("loginBeforeYouStart")}</div>
</div>
<div className="flex-1 flex justify-center items-center">
<div className="flex-1 flex justify-center">
<LoginForm />
</div>
<div className="mt-auto">
<div className="flex mb-4 justify-end space-x-4">
{initialized ? (
<Link data-testid="start-to-use-button" to="/" replace>
<Button className="w-24">{t("startToUse")}</Button>
</Link>
) : (
<Button className="w-24" onClick={() => EnjoyApp.app.reload()}>
{t("reload")}
</Button>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,19 @@
import { Navigate } from "react-router-dom";
import { AppSettingsProviderContext } from "../context";
import { useContext } from "react";
export const ProtectedPage = ({
children,
redirectPath = "/landing",
}: {
children: React.ReactNode;
redirectPath?: string;
}) => {
const { initialized } = useContext(AppSettingsProviderContext);
if (!initialized) {
return <Navigate to={redirectPath} replace />;
}
return children;
};

View File

@@ -14,6 +14,7 @@ import { t } from "i18next";
import nlp from "compromise";
import paragraphs from "compromise-paragraphs";
import { useDebounce } from "@uidotdev/usehooks";
import { type IpcRendererEvent } from "electron/renderer";
nlp.plugin(paragraphs);
export default () => {
@@ -154,6 +155,15 @@ export default () => {
setLoading(false);
};
const onWindowChange = (
event: IpcRendererEvent,
state: { event: string }
) => {
if (state.event === "resize") {
setWebviewRect(containerRef.current.getBoundingClientRect());
}
};
useEffect(() => {
if (!containerRef?.current) return;
if (!url) return;
@@ -172,12 +182,10 @@ export default () => {
if (!containerRef?.current) return;
setWebviewRect(containerRef.current.getBoundingClientRect());
EnjoyApp.window.onResize(() => {
setWebviewRect(containerRef.current.getBoundingClientRect());
});
EnjoyApp.window.onChange((_event, state) => onWindowChange(_event, state));
return () => {
EnjoyApp.window.removeListeners();
EnjoyApp.window.removeListener(onWindowChange);
};
}, [containerRef?.current]);

View File

@@ -25,7 +25,7 @@ export default () => {
const navigate = useNavigate();
return (
<div className="h-screen flex flex-col relative">
<div className="h-[calc(100vh-2rem)] flex flex-col relative">
<Breadcrumb className="px-4 pt-3 pb-2">
<BreadcrumbList>
<BreadcrumbItem>

View File

@@ -25,6 +25,7 @@ import Courses from "./pages/courses/index";
import Course from "./pages/courses/show";
import Chapter from "./pages/courses/chapter";
import Chats from "./pages/chats";
import { ProtectedPage } from "./pages/protected-page";
export default createHashRouter([
{
@@ -32,96 +33,191 @@ export default createHashRouter([
element: <Layout />,
errorElement: <ErrorPage />,
children: [
{ index: true, element: <Home /> },
{ path: "/landing", element: <Landing /> },
{
index: true,
element: (
<ProtectedPage>
<Home />
</ProtectedPage>
),
},
{
path: "/chats",
element: <Chats />,
element: (
<ProtectedPage>
<Chats />
</ProtectedPage>
),
},
{
path: "/courses",
element: <Courses />,
element: (
<ProtectedPage>
<Courses />
</ProtectedPage>
),
},
{
path: "/courses/:id",
element: <Course />,
element: (
<ProtectedPage>
<Course />
</ProtectedPage>
),
},
{
path: "/courses/:id/chapters/:sequence",
element: <Chapter />,
element: (
<ProtectedPage>
<Chapter />
</ProtectedPage>
),
},
{
path: "/community",
element: <Community />,
element: (
<ProtectedPage>
<Community />
</ProtectedPage>
),
},
{
path: "/users/:id",
element: <User />,
element: (
<ProtectedPage>
<User />
</ProtectedPage>
),
},
{
path: "/profile",
element: <Profile />,
element: (
<ProtectedPage>
<Profile />
</ProtectedPage>
),
},
{
path: "/conversations",
element: <Conversations />,
element: (
<ProtectedPage>
<Conversations />
</ProtectedPage>
),
},
{
path: "/conversations/:id",
element: <Conversation />,
element: (
<ProtectedPage>
<Conversation />
</ProtectedPage>
),
},
{
path: "/pronunciation_assessments",
element: <PronunciationAssessmentsIndex />,
element: (
<ProtectedPage>
<PronunciationAssessmentsIndex />
</ProtectedPage>
),
},
{
path: "/pronunciation_assessments/new",
element: <PronunciationAssessmentsNew />,
element: (
<ProtectedPage>
<PronunciationAssessmentsNew />
</ProtectedPage>
),
},
{
path: "/vocabulary",
element: <Vocabulary />,
element: (
<ProtectedPage>
<Vocabulary />
</ProtectedPage>
),
},
{
path: "/audios",
element: <Audios />,
element: (
<ProtectedPage>
<Audios />
</ProtectedPage>
),
},
{
path: "/audios/:id",
element: <Audio />,
element: (
<ProtectedPage>
<Audio />
</ProtectedPage>
),
},
{
path: "/videos",
element: <Videos />,
element: (
<ProtectedPage>
<Videos />
</ProtectedPage>
),
},
{
path: "/videos/:id",
element: <Video />,
element: (
<ProtectedPage>
<Video />
</ProtectedPage>
),
},
{
path: "/documents",
element: <Documents />,
element: (
<ProtectedPage>
<Documents />
</ProtectedPage>
),
},
{
path: "/documents/:id",
element: <Document />,
element: (
<ProtectedPage>
<Document />
</ProtectedPage>
),
},
{
path: "/stories",
element: <Stories />,
element: (
<ProtectedPage>
<Stories />
</ProtectedPage>
),
},
{
path: "/stories/:id",
element: <Story />,
element: (
<ProtectedPage>
<Story />
</ProtectedPage>
),
},
{
path: "/stories/preview/:uri",
element: <StoryPreview />,
element: (
<ProtectedPage>
<StoryPreview />
</ProtectedPage>
),
},
{
path: "/notes",
element: <Notes />,
element: (
<ProtectedPage>
<Notes />
</ProtectedPage>
),
},
],
},
{ path: "/landing", element: <Landing /> },
]);

View File

@@ -17,8 +17,21 @@ type EnjoyAppType = {
version: string;
};
window: {
onResize: (callback: (event, bounds: any) => void) => void;
removeListeners: () => void;
onChange: (
callback: (event, state: { event: string; state: any }) => void
) => void;
toggleMaximized: () => Promise<void>;
isMaximized: () => Promise<boolean>;
maximize: () => Promise<void>;
unmaximize: () => Promise<void>;
fullscreen: () => Promise<void>;
unfullscreen: () => Promise<void>;
minimize: () => Promise<void>;
close: () => Promise<void>;
removeListener: (
listener: (event: IpcRendererEvent, ...args: any[]) => void
) => void;
removeAllListeners: () => void;
};
system: {
preferences: {