Initial Commit

This commit is contained in:
Damian Wessels
2025-09-08 11:01:51 +02:00
commit fd33b5ceba
81 changed files with 17432 additions and 0 deletions

26
electron/src/App.tsx Normal file
View File

@@ -0,0 +1,26 @@
import React, { useEffect } from "react";
import { createRoot } from "react-dom/client";
import { syncThemeWithLocal } from "./helpers/theme_helpers";
import { useTranslation } from "react-i18next";
import "./localization/i18n";
import { updateAppLanguage } from "./helpers/language_helpers";
import { router } from "./routes/router";
import { RouterProvider } from "@tanstack/react-router";
export default function App() {
const { i18n } = useTranslation();
useEffect(() => {
syncThemeWithLocal();
updateAppLanguage(i18n);
}, [i18n]);
return <RouterProvider router={router} />;
}
const root = createRoot(document.getElementById("app")!);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,97 @@
import {
closeWindow,
maximizeWindow,
minimizeWindow,
} from "@/helpers/window_helpers";
import { isMacOS } from "@/utils/platform";
import React, { type ReactNode } from "react";
interface DragWindowRegionProps {
title?: ReactNode;
}
export default function DragWindowRegion({ title }: DragWindowRegionProps) {
return (
<div className="flex w-screen items-stretch justify-between">
<div className="draglayer w-full">
{title && !isMacOS() && (
<div className="flex flex-1 p-2 text-xs whitespace-nowrap text-gray-400 select-none">
{title}
</div>
)}
{isMacOS() && (
<div className="flex flex-1 p-2">
{/* Maintain the same height but do not display content */}
</div>
)}
</div>
{!isMacOS() && <WindowButtons />}
</div>
);
}
function WindowButtons() {
return (
<div className="flex">
<button
title="Minimize"
type="button"
className="p-2 hover:bg-slate-300"
onClick={minimizeWindow}
>
<svg
aria-hidden="true"
role="img"
width="12"
height="12"
viewBox="0 0 12 12"
>
<rect fill="currentColor" width="10" height="1" x="1" y="6"></rect>
</svg>
</button>
<button
title="Maximize"
type="button"
className="p-2 hover:bg-slate-300"
onClick={maximizeWindow}
>
<svg
aria-hidden="true"
role="img"
width="12"
height="12"
viewBox="0 0 12 12"
>
<rect
width="9"
height="9"
x="1.5"
y="1.5"
fill="none"
stroke="currentColor"
></rect>
</svg>
</button>
<button
type="button"
title="Close"
className="p-2 hover:bg-red-300"
onClick={closeWindow}
>
<svg
aria-hidden="true"
role="img"
width="12"
height="12"
viewBox="0 0 12 12"
>
<polygon
fill="currentColor"
fillRule="evenodd"
points="11 1.576 6.583 6 11 10.424 10.424 11 6 6.583 1.576 11 1 10.424 5.417 6 1 1.576 1.576 1 6 5.417 10.424 1"
></polygon>
</svg>
</button>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import React from "react";
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
import langs from "@/localization/langs";
import { useTranslation } from "react-i18next";
import { setAppLanguage } from "@/helpers/language_helpers";
export default function LangToggle() {
const { i18n } = useTranslation();
const currentLang = i18n.language;
function onValueChange(value: string) {
setAppLanguage(value, i18n);
}
return (
<ToggleGroup
type="single"
onValueChange={onValueChange}
value={currentLang}
>
{langs.map((lang) => (
<ToggleGroupItem
key={lang.key}
value={lang.key}
variant="outline"
size="lg"
>
{`${lang.prefix}`}
</ToggleGroupItem>
))}
</ToggleGroup>
);
}

View File

@@ -0,0 +1,12 @@
import { Moon } from "lucide-react";
import React from "react";
import { Button } from "@/components/ui/button";
import { toggleTheme } from "@/helpers/theme_helpers";
export default function ToggleTheme() {
return (
<Button onClick={toggleTheme} size="icon">
<Moon size={16} />
</Button>
);
}

View File

@@ -0,0 +1,10 @@
import React from "react";
export default function Footer() {
return (
<footer className="font-tomorrow text-muted-foreground inline-flex justify-between text-[0.7rem] uppercase">
<p>Made by LuanRoger - Based in Brazil 🇧🇷</p>
<p>Powered by Electron</p>
</footer>
);
}

View File

@@ -0,0 +1,14 @@
import React from "react";
import { SiElectron, SiReact, SiVite } from "@icons-pack/react-simple-icons";
export default function InitalIcons() {
const iconSize = 48;
return (
<div className="inline-flex gap-2">
<SiReact size={iconSize} />
<SiVite size={iconSize} />
<SiElectron size={iconSize} />
</div>
);
}

View File

@@ -0,0 +1,31 @@
import React from "react";
import { Link } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import {
NavigationMenu as NavigationMenuBase,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
navigationMenuTriggerStyle,
} from "../ui/navigation-menu";
export default function NavigationMenu() {
const { t } = useTranslation();
return (
<NavigationMenuBase className="text-muted-foreground px-2">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="/">{t("titleHomePage")}</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="/second-page">{t("titleSecondPage")}</Link>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenuBase>
);
}

View File

@@ -0,0 +1,59 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/tailwind";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,168 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/utils/tailwind";
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className,
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className,
)}
{...props}
/>
);
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
);
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
);
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
);
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className,
)}
{...props}
/>
);
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center",
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
{...props}
/>
</div>
);
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className,
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
);
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
};

View File

@@ -0,0 +1,73 @@
"use client";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/tailwind";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
});
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export { ToggleGroup, ToggleGroupItem };

View File

@@ -0,0 +1,45 @@
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/tailwind";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };

View File

@@ -0,0 +1,7 @@
import { exposeThemeContext } from "./theme/theme-context";
import { exposeWindowContext } from "./window/window-context";
export default function exposeContexts() {
exposeWindowContext();
exposeThemeContext();
}

View File

@@ -0,0 +1,8 @@
import { BrowserWindow } from "electron";
import { addThemeEventListeners } from "./theme/theme-listeners";
import { addWindowEventListeners } from "./window/window-listeners";
export default function registerListeners(mainWindow: BrowserWindow) {
addWindowEventListeners(mainWindow);
addThemeEventListeners();
}

View File

@@ -0,0 +1,5 @@
export const THEME_MODE_CURRENT_CHANNEL = "theme-mode:current";
export const THEME_MODE_TOGGLE_CHANNEL = "theme-mode:toggle";
export const THEME_MODE_DARK_CHANNEL = "theme-mode:dark";
export const THEME_MODE_LIGHT_CHANNEL = "theme-mode:light";
export const THEME_MODE_SYSTEM_CHANNEL = "theme-mode:system";

View File

@@ -0,0 +1,18 @@
import {
THEME_MODE_CURRENT_CHANNEL,
THEME_MODE_DARK_CHANNEL,
THEME_MODE_LIGHT_CHANNEL,
THEME_MODE_SYSTEM_CHANNEL,
THEME_MODE_TOGGLE_CHANNEL,
} from "./theme-channels";
export function exposeThemeContext() {
const { contextBridge, ipcRenderer } = window.require("electron");
contextBridge.exposeInMainWorld("themeMode", {
current: () => ipcRenderer.invoke(THEME_MODE_CURRENT_CHANNEL),
toggle: () => ipcRenderer.invoke(THEME_MODE_TOGGLE_CHANNEL),
dark: () => ipcRenderer.invoke(THEME_MODE_DARK_CHANNEL),
light: () => ipcRenderer.invoke(THEME_MODE_LIGHT_CHANNEL),
system: () => ipcRenderer.invoke(THEME_MODE_SYSTEM_CHANNEL),
});
}

View File

@@ -0,0 +1,33 @@
import { nativeTheme } from "electron";
import { ipcMain } from "electron";
import {
THEME_MODE_CURRENT_CHANNEL,
THEME_MODE_DARK_CHANNEL,
THEME_MODE_LIGHT_CHANNEL,
THEME_MODE_SYSTEM_CHANNEL,
THEME_MODE_TOGGLE_CHANNEL,
} from "./theme-channels";
export function addThemeEventListeners() {
ipcMain.handle(THEME_MODE_CURRENT_CHANNEL, () => nativeTheme.themeSource);
ipcMain.handle(THEME_MODE_TOGGLE_CHANNEL, () => {
if (nativeTheme.shouldUseDarkColors) {
nativeTheme.themeSource = "light";
} else {
nativeTheme.themeSource = "dark";
}
return nativeTheme.shouldUseDarkColors;
});
ipcMain.handle(
THEME_MODE_DARK_CHANNEL,
() => (nativeTheme.themeSource = "dark"),
);
ipcMain.handle(
THEME_MODE_LIGHT_CHANNEL,
() => (nativeTheme.themeSource = "light"),
);
ipcMain.handle(THEME_MODE_SYSTEM_CHANNEL, () => {
nativeTheme.themeSource = "system";
return nativeTheme.shouldUseDarkColors;
});
}

View File

@@ -0,0 +1,3 @@
export const WIN_MINIMIZE_CHANNEL = "window:minimize";
export const WIN_MAXIMIZE_CHANNEL = "window:maximize";
export const WIN_CLOSE_CHANNEL = "window:close";

View File

@@ -0,0 +1,14 @@
import {
WIN_MINIMIZE_CHANNEL,
WIN_MAXIMIZE_CHANNEL,
WIN_CLOSE_CHANNEL,
} from "./window-channels";
export function exposeWindowContext() {
const { contextBridge, ipcRenderer } = window.require("electron");
contextBridge.exposeInMainWorld("electronWindow", {
minimize: () => ipcRenderer.invoke(WIN_MINIMIZE_CHANNEL),
maximize: () => ipcRenderer.invoke(WIN_MAXIMIZE_CHANNEL),
close: () => ipcRenderer.invoke(WIN_CLOSE_CHANNEL),
});
}

View File

@@ -0,0 +1,22 @@
import { BrowserWindow, ipcMain } from "electron";
import {
WIN_CLOSE_CHANNEL,
WIN_MAXIMIZE_CHANNEL,
WIN_MINIMIZE_CHANNEL,
} from "./window-channels";
export function addWindowEventListeners(mainWindow: BrowserWindow) {
ipcMain.handle(WIN_MINIMIZE_CHANNEL, () => {
mainWindow.minimize();
});
ipcMain.handle(WIN_MAXIMIZE_CHANNEL, () => {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
});
ipcMain.handle(WIN_CLOSE_CHANNEL, () => {
mainWindow.close();
});
}

View File

@@ -0,0 +1,19 @@
import type { i18n } from "i18next";
const languageLocalStorageKey = "lang";
export function setAppLanguage(lang: string, i18n: i18n) {
localStorage.setItem(languageLocalStorageKey, lang);
i18n.changeLanguage(lang);
document.documentElement.lang = lang;
}
export function updateAppLanguage(i18n: i18n) {
const localLang = localStorage.getItem(languageLocalStorageKey);
if (!localLang) {
return;
}
i18n.changeLanguage(localLang);
document.documentElement.lang = localLang;
}

View File

@@ -0,0 +1,64 @@
import { ThemeMode } from "@/types/theme-mode";
const THEME_KEY = "theme";
export interface ThemePreferences {
system: ThemeMode;
local: ThemeMode | null;
}
export async function getCurrentTheme(): Promise<ThemePreferences> {
const currentTheme = await window.themeMode.current();
const localTheme = localStorage.getItem(THEME_KEY) as ThemeMode | null;
return {
system: currentTheme,
local: localTheme,
};
}
export async function setTheme(newTheme: ThemeMode) {
switch (newTheme) {
case "dark":
await window.themeMode.dark();
updateDocumentTheme(true);
break;
case "light":
await window.themeMode.light();
updateDocumentTheme(false);
break;
case "system": {
const isDarkMode = await window.themeMode.system();
updateDocumentTheme(isDarkMode);
break;
}
}
localStorage.setItem(THEME_KEY, newTheme);
}
export async function toggleTheme() {
const isDarkMode = await window.themeMode.toggle();
const newTheme = isDarkMode ? "dark" : "light";
updateDocumentTheme(isDarkMode);
localStorage.setItem(THEME_KEY, newTheme);
}
export async function syncThemeWithLocal() {
const { local } = await getCurrentTheme();
if (!local) {
setTheme("system");
return;
}
await setTheme(local);
}
function updateDocumentTheme(isDarkMode: boolean) {
if (!isDarkMode) {
document.documentElement.classList.remove("dark");
} else {
document.documentElement.classList.add("dark");
}
}

View File

@@ -0,0 +1,9 @@
export async function minimizeWindow() {
await window.electronWindow.minimize();
}
export async function maximizeWindow() {
await window.electronWindow.maximize();
}
export async function closeWindow() {
await window.electronWindow.close();
}

View File

@@ -0,0 +1,17 @@
import React from "react";
import DragWindowRegion from "@/components/DragWindowRegion";
import NavigationMenu from "@/components/template/NavigationMenu";
export default function BaseLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<DragWindowRegion title="electron-shadcn" />
<NavigationMenu />
<main className="h-screen p-2 pb-20">{children}</main>
</>
);
}

View File

@@ -0,0 +1,22 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
i18n.use(initReactI18next).init({
fallbackLng: "en",
resources: {
en: {
translation: {
appName: "electron-shadcn",
titleHomePage: "Home Page",
titleSecondPage: "Second Page",
},
},
"pt-BR": {
translation: {
appName: "electron-shadcn",
titleHomePage: "Página Inicial",
titleSecondPage: "Segunda Página",
},
},
},
});

View File

@@ -0,0 +1,14 @@
import { Language } from "./language";
export default [
{
key: "en",
nativeName: "English",
prefix: "EN-US",
},
{
key: "pt-BR",
nativeName: "Português (Brasil)",
prefix: "PT-BR",
},
] satisfies Language[];

View File

@@ -0,0 +1,5 @@
export interface Language {
key: string;
nativeName: string;
prefix: string;
}

64
electron/src/main.ts Normal file
View File

@@ -0,0 +1,64 @@
import { app, BrowserWindow } from "electron";
import registerListeners from "./helpers/ipc/listeners-register";
// "electron-squirrel-startup" seems broken when packaging with vite
//import started from "electron-squirrel-startup";
import path from "path";
import {
installExtension,
REACT_DEVELOPER_TOOLS,
} from "electron-devtools-installer";
const inDevelopment = process.env.NODE_ENV === "development";
function createWindow() {
const preload = path.join(__dirname, "preload.js");
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
devTools: inDevelopment,
contextIsolation: true,
nodeIntegration: true,
nodeIntegrationInSubFrames: false,
preload: preload,
},
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "hidden",
trafficLightPosition:
process.platform === "darwin" ? { x: 5, y: 5 } : undefined,
});
registerListeners(mainWindow);
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
} else {
mainWindow.loadFile(
path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`),
);
}
}
async function installExtensions() {
try {
const result = await installExtension(REACT_DEVELOPER_TOOLS);
console.log(`Extensions installed successfully: ${result.name}`);
} catch {
console.error("Failed to install extensions");
}
}
app.whenReady().then(createWindow).then(installExtensions);
//osX only
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
//osX only ends

View File

@@ -0,0 +1,30 @@
import React from "react";
import ToggleTheme from "@/components/ToggleTheme";
import { useTranslation } from "react-i18next";
import LangToggle from "@/components/LangToggle";
import Footer from "@/components/template/Footer";
import InitialIcons from "@/components/template/InitialIcons";
export default function HomePage() {
const { t } = useTranslation();
return (
<div className="flex h-full flex-col">
<div className="flex flex-1 flex-col items-center justify-center gap-2">
<InitialIcons />
<span>
<h1 className="font-mono text-4xl font-bold">{t("appName")}</h1>
<p
className="text-muted-foreground text-end text-sm uppercase"
data-testid="pageTitle"
>
{t("titleHomePage")}
</p>
</span>
<LangToggle />
<ToggleTheme />
</div>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,16 @@
import React from "react";
import Footer from "@/components/template/Footer";
import { useTranslation } from "react-i18next";
export default function SecondPage() {
const { t } = useTranslation();
return (
<div className="flex h-full flex-col">
<div className="flex flex-1 flex-col items-center justify-center gap-2">
<h1 className="text-4xl font-bold">{t("titleSecondPage")}</h1>
</div>
<Footer />
</div>
);
}

3
electron/src/preload.ts Normal file
View File

@@ -0,0 +1,3 @@
import exposeContexts from "./helpers/ipc/context-exposer";
exposeContexts();

1
electron/src/renderer.ts Normal file
View File

@@ -0,0 +1 @@
import "@/App";

View File

@@ -0,0 +1,15 @@
import React from "react";
import BaseLayout from "@/layouts/BaseLayout";
import { Outlet, createRootRoute } from "@tanstack/react-router";
export const RootRoute = createRootRoute({
component: Root,
});
function Root() {
return (
<BaseLayout>
<Outlet />
</BaseLayout>
);
}

View File

@@ -0,0 +1,13 @@
import { createMemoryHistory, createRouter } from "@tanstack/react-router";
import { rootTree } from "./routes";
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
const history = createMemoryHistory({
initialEntries: ["/"],
});
export const router = createRouter({ routeTree: rootTree, history: history });

View File

@@ -0,0 +1,37 @@
import { createRoute } from "@tanstack/react-router";
import { RootRoute } from "./__root";
import HomePage from "../pages/HomePage";
import SecondPage from "@/pages/SecondPage";
// TODO: Steps to add a new route:
// 1. Create a new page component in the '../pages/' directory (e.g., NewPage.tsx)
// 2. Import the new page component at the top of this file
// 3. Define a new route for the page using createRoute()
// 4. Add the new route to the routeTree in RootRoute.addChildren([...])
// 5. Add a new Link in the navigation section of RootRoute if needed
// Example of adding a new route:
// 1. Create '../pages/NewPage.tsx'
// 2. Import: import NewPage from '../pages/NewPage';
// 3. Define route:
// const NewRoute = createRoute({
// getParentRoute: () => RootRoute,
// path: '/new',
// component: NewPage,
// });
// 4. Add to routeTree: RootRoute.addChildren([HomeRoute, NewRoute, ...])
// 5. Add Link: <Link to="/new">New Page</Link>
export const HomeRoute = createRoute({
getParentRoute: () => RootRoute,
path: "/",
component: HomePage,
});
export const SecondPageRoute = createRoute({
getParentRoute: () => RootRoute,
path: "/second-page",
component: SecondPage,
});
export const rootTree = RootRoute.addChildren([HomeRoute, SecondPageRoute]);

View File

@@ -0,0 +1,203 @@
@import "tailwindcss";
@plugin 'tailwindcss-animate';
@custom-variant dark (&:is(.dark *));
@theme {
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--font-sans: Geist, sans-serif;
--font-mono: Geist Mono, monospace;
--font-tomorrow: Tomorrow, sans-serif;
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
}
@utility container {
margin-inline: auto;
padding-inline: 2rem;
@media (width >= --theme(--breakpoint-sm)) {
max-width: none;
}
@media (width >= 1400px) {
max-width: 1400px;
}
}
@layer base {
@font-face {
font-family: "Geist";
src: url("../assets/fonts/geist/geist.ttf") format("truetype");
}
@font-face {
font-family: "Geist Mono";
font-display: swap;
src: url("../assets/fonts/geist-mono/geist-mono.ttf") format("truetype");
}
@font-face {
font-family: "Tomorrow";
font-weight: 400;
font-style: normal;
src: url("../assets/fonts/tomorrow/tomorrow-regular.ttf") format("truetype");
}
@font-face {
font-family: "Tomorrow";
font-weight: 400;
font-style: italic;
src: url("../assets/fonts/tomorrow/tomorrow-italic.ttf") format("truetype");
}
@font-face {
font-family: "Tomorrow";
font-weight: 700;
font-style: normal;
src: url("../assets/fonts/tomorrow/tomorrow-bold.ttf") format("truetype");
}
@font-face {
font-family: "Tomorrow";
font-weight: 700;
font-style: italic;
src: url("../assets/fonts/tomorrow/tomorrow-bold-italic.ttf")
format("truetype");
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
body {
@apply overflow-hidden;
}
.draglayer {
@apply bg-background;
-webkit-app-region: drag;
}
button {
@apply cursor-pointer;
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,51 @@
import {
test,
expect,
_electron as electron,
ElectronApplication,
Page,
} from "@playwright/test";
import { findLatestBuild, parseElectronApp } from "electron-playwright-helpers";
/*
* Using Playwright with Electron:
* https://www.electronjs.org/pt/docs/latest/tutorial/automated-testing#using-playwright
*/
let electronApp: ElectronApplication;
test.beforeAll(async () => {
const latestBuild = findLatestBuild();
const appInfo = parseElectronApp(latestBuild);
process.env.CI = "e2e";
electronApp = await electron.launch({
args: [appInfo.main],
});
electronApp.on("window", async (page) => {
const filename = page.url()?.split("/").pop();
console.log(`Window opened: ${filename}`);
page.on("pageerror", (error) => {
console.error(error);
});
page.on("console", (msg) => {
console.log(msg.text());
});
});
});
test("renders the first page", async () => {
const page: Page = await electronApp.firstWindow();
const title = await page.waitForSelector("h1");
const text = await title.textContent();
expect(text).toBe("electron-shadcn");
});
test("renders page name", async () => {
const page: Page = await electronApp.firstWindow();
await page.waitForSelector("h1");
const pageName = await page.getByTestId("pageTitle");
const text = await pageName.textContent();
expect(text).toBe("Home Page");
});

View File

@@ -0,0 +1,27 @@
import { render } from "@testing-library/react";
import { test, expect } from "vitest";
import ToggleTheme from "@/components/ToggleTheme";
import React from "react";
test("renders ToggleTheme", () => {
const { getByRole } = render(<ToggleTheme />);
const isButton = getByRole("button");
expect(isButton).toBeInTheDocument();
});
test("has icon", () => {
const { getByRole } = render(<ToggleTheme />);
const button = getByRole("button");
const icon = button.querySelector("svg");
expect(icon).toBeInTheDocument();
});
test("is moon icon", () => {
const svgIconClassName: string = "lucide-moon";
const { getByRole } = render(<ToggleTheme />);
const svg = getByRole("button").querySelector("svg");
expect(svg?.classList).toContain(svgIconClassName);
});

View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom";

View File

@@ -0,0 +1,14 @@
import { expect, test } from "vitest";
function sum(a: number, b: number): number {
return a + b;
}
test("sum", () => {
const param1: number = 2;
const param2: number = 2;
const result: number = sum(param1, param2);
expect(result).toBe(4);
});

24
electron/src/types.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Vite
// plugin that tells the Electron app where to look for the Vite-bundled app code (depending on
// whether you're running in development or production).
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
declare const MAIN_WINDOW_VITE_NAME: string;
// Preload types
interface ThemeModeContext {
toggle: () => Promise<boolean>;
dark: () => Promise<void>;
light: () => Promise<void>;
system: () => Promise<boolean>;
current: () => Promise<"dark" | "light" | "system">;
}
interface ElectronWindow {
minimize: () => Promise<void>;
maximize: () => Promise<void>;
close: () => Promise<void>;
}
declare interface Window {
themeMode: ThemeModeContext;
electronWindow: ElectronWindow;
}

View File

@@ -0,0 +1 @@
export type ThemeMode = "dark" | "light" | "system";

View File

@@ -0,0 +1,13 @@
/**
* Platform detection utilities
*/
/**
* Check if the current platform is macOS
* @returns true if running on macOS, false otherwise
*/
export const isMacOS = (): boolean => {
return (
typeof window !== "undefined" && window.navigator.platform.includes("Mac")
);
};

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}