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

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
target/
dist/

1801
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

2
Cargo.toml Normal file
View File

@@ -0,0 +1,2 @@
[workspace]
members = ["server"]

101
electron/.gitignore vendored Normal file
View File

@@ -0,0 +1,101 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
.DS_Store
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Webpack
.webpack/
# Vite
.vite/
dist/
# Electron-Forge
out/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

5
electron/.prettierignore Normal file
View File

@@ -0,0 +1,5 @@
# Add files here to ignore them from prettier formatting
/dist
/coverage
/.vite
README.md

8
electron/.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"singleQuote": false,
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"endOfLine": "auto",
"plugins": ["prettier-plugin-tailwindcss"]
}

21
electron/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Luan Roger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

146
electron/README.md Normal file
View File

@@ -0,0 +1,146 @@
# electron-shadcn
Electron in all its glory. Everything you will need to develop your beautiful desktop application.
![Demo GIF](https://github.com/LuanRoger/electron-shadcn/blob/main/images/demo.gif)
## Libs and tools
To develop a Electron app, you probably will need some UI, test, formatter, style or other kind of library or framework, so let me install and configure some of them to you.
### Core 🏍️
- [Electron 38](https://www.electronjs.org)
- [Vite 7](https://vitejs.dev)
### DX 🛠️
- [TypeScript 5.9](https://www.typescriptlang.org)
- [Prettier](https://prettier.io)
- [ESLint 9](https://eslint.org)
- [Zod 4](https://zod.dev)
- [React Query (TanStack)](https://react-query.tanstack.com)
### UI 🎨
- [React 19](https://reactjs.org)
- [Tailwind 4](https://tailwindcss.com)
- [Shadcn UI](https://ui.shadcn.com)
- [Geist](https://vercel.com/font) as default font
- [i18next](https://www.i18next.com)
- [TanStack Router](https://tanstack.com/router)
- [Lucide](https://lucide.dev)
### Test 🧪
- [Vitest](https://vitest.dev)
- [Playwright](https://playwright.dev)
- [React Testing Library](https://testing-library.com/docs/react-testing-library/intro)
### Packing and distribution 📦
- [Electron Forge](https://www.electronforge.io)
### CI/CD 🚀
- Pre-configured [GitHub Actions workflow](https://github.com/LuanRoger/electron-shadcn/blob/main/.github/workflows/playwright.yml), for test with Playwright
### Project preferences 🎯
- Use Context isolation
- [React Compiler](https://react.dev/learn/react-compiler) is enabled by default.
- `titleBarStyle`: hidden (Using custom title bar)
- Geist as default font
- Some default styles was applied, check the [`styles`](https://github.com/LuanRoger/electron-shadcn/tree/main/src/styles) directory
- React DevTools are installed by default
## Directory structure
```plaintext
.
└── ./src/
├── ./src/assets/
│ └── ./src/assets/fonts/
├── ./src/components/
│ ├── ./src/components/template
│ └── ./src/components/ui/
├── ./src/helpers/
│ └── ./src/helpers/ipc/
├── ./src/layout/
├── ./src/lib/
├── ./src/pages/
├── ./src/style/
└── ./src/tests/
```
- `src/`: Main directory
- `assets/`: Store assets like images, fonts, etc.
- `components/`: Store UI components
- `template/`: Store the all not important components used by the template. It doesn't include the `WindowRegion` or the theme toggler, if you want to start an empty project, you can safely delete this directory.
- `ui/`: Store Shadcn UI components (this is the default direcotry used by Shadcn UI)
- `helpers/`: Store IPC related functions to be called in the renderer process
- `ipc/`: Directory to store IPC context and listener functions
- Some implementations are already done, like `theme` and `window` for the custom title bar
- `layout/`: Directory to store layout components
- `lib/`: Store libraries and other utilities
- `pages/`: Store app's pages
- `style/`: Store global styles
- `tests/`: Store tests (from Vitest and Playwright)
## NPM script
To run any of those scripts:
```bash
npm run <script>
```
- `start`: Start the app in development mode
- `package`: Package your application into a platform-specific executable bundle and put the result in a folder.
- `make`: Generate platform-specific distributables (e.g. .exe, .dmg, etc) of your application for distribution.
- `publish`: Electron Forge's way of taking the artifacts generated by the `make` command and sending them to a service somewhere for you to distribute or use as updates.
- `lint`: Run ESLint to lint the code
- `format`: Run Prettier to check the code (it doesn't change the code)
- `format:write`: Run Prettier to format the code
- `test`: Run the default unit-test script (Vitest)
- `test:watch`: Run the default unit-test script in watch mode (Vitest)
- `test:unit`: Run the Vitest tests
- `test:e2e`: Run the Playwright tests
- `test:all`: Run all tests (Vitest and Playwright)
> The test scripts involving Playwright require the app be builded before running the tests. So, before run the tests, run the `package`, `make` or `publish` script.
## How to use
1. Clone this repository
```bash
git clone https://github.com/LuanRoger/electron-shadcn.git
```
Or use it as a template on GitHub
2. Install dependencies
```bash
npm install
```
3. Run the app
```bash
npm run start
```
## Used by
- [yaste](https://github.com/LuanRoger/yaste) - yaste (Yet another super ₛᵢₘₚₗₑ text editor) is a text editor, that can be used as an alternative to the native text editor of your SO, maybe.
- [eletric-drizzle](https://github.com/LuanRoger/electric-drizzle) - shadcn-ui and Drizzle ORM with Electron.
- [Wordle Game](https://github.com/masonyekta/wordle-game) - A Wordle game which features interactive gameplay, cross-platform compatibility, and integration with a custom Wordle API for word validation and letter correctness.
- [Mehr 🌟](https://github.com/xmannii/MehrLocalChat) - A modern, elegant local AI chatbot application using Electron, React, shadcn/ui, and Ollama.
> Does you've used this template in your project? Add it here and open a PR.
## License
This project is licensed under the MIT License - see the [LICENSE](https://github.com/LuanRoger/electron-shadcn/blob/main/LICENSE) file for details.

20
electron/components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/global.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/utils/tailwind",
"ui": "@/components/ui",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -0,0 +1,32 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
import pluginReact from "eslint-plugin-react";
import eslintPluginPrettierRecommended from "eslint-config-prettier";
import reactCompiler from "eslint-plugin-react-compiler";
import path from "node:path";
import { includeIgnoreFile } from "@eslint/compat";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const prettierIgnorePath = path.resolve(__dirname, ".prettierignore");
/** @type {import('eslint').Linter.Config[]} */
export default [
includeIgnoreFile(prettierIgnorePath),
{
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
plugins: {
"react-compiler": reactCompiler,
},
rules: {
"react-compiler/react-compiler": "error",
},
},
{ languageOptions: { globals: globals.browser } },
pluginJs.configs.recommended,
pluginReact.configs.flat.recommended,
eslintPluginPrettierRecommended,
...tseslint.configs.recommended,
];

55
electron/forge.config.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { ForgeConfig } from "@electron-forge/shared-types";
import { MakerSquirrel } from "@electron-forge/maker-squirrel";
import { MakerZIP } from "@electron-forge/maker-zip";
import { MakerDeb } from "@electron-forge/maker-deb";
import { MakerRpm } from "@electron-forge/maker-rpm";
import { VitePlugin } from "@electron-forge/plugin-vite";
import { FusesPlugin } from "@electron-forge/plugin-fuses";
import { FuseV1Options, FuseVersion } from "@electron/fuses";
const config: ForgeConfig = {
packagerConfig: {
asar: true,
},
rebuildConfig: {},
makers: [
new MakerSquirrel({}),
new MakerZIP({}, ["darwin"]),
new MakerRpm({}),
new MakerDeb({}),
],
plugins: [
new VitePlugin({
build: [
{
entry: "src/main.ts",
config: "vite.main.config.mts",
target: "main",
},
{
entry: "src/preload.ts",
config: "vite.preload.config.mts",
target: "preload",
},
],
renderer: [
{
name: "main_window",
config: "vite.renderer.config.mts",
},
],
}),
new FusesPlugin({
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
],
};
export default config;

1
electron/forge.env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="@electron-forge/plugin-vite/forge-vite-env" />

BIN
electron/images/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 KiB

13
electron/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>electron-shadcn Template</title>
<meta http-equiv="Content-Security-Policy" content="script-src 'self';" />
<link rel="stylesheet" href="/src/styles/global.css" />
</head>
<body>
<div id="app" />
<script type="module" src="/src/renderer.ts"></script>
</body>
</html>

13495
electron/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

89
electron/package.json Normal file
View File

@@ -0,0 +1,89 @@
{
"name": "electron-shadcn",
"productName": "electron-shadcn Template",
"version": "1.0.0",
"description": "Electron Forge with shadcn-ui (Vite + Typescript)",
"main": ".vite/build/main.js",
"private": true,
"scripts": {
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
"publish": "electron-forge publish",
"lint": "eslint .",
"format": "prettier --check .",
"format:write": "prettier --write .",
"test": "vitest run",
"test:watch": "vitest watch",
"test:unit": "vitest",
"test:e2e": "playwright test",
"test:all": "vitest run && playwright test"
},
"author": "ROG <luan.roger.2003@gmail.com>",
"license": "MIT",
"devDependencies": {
"@electron-forge/cli": "^7.8.3",
"@electron-forge/maker-deb": "^7.8.3",
"@electron-forge/maker-rpm": "^7.8.3",
"@electron-forge/maker-squirrel": "^7.8.3",
"@electron-forge/maker-zip": "^7.8.3",
"@electron-forge/plugin-auto-unpack-natives": "^7.8.3",
"@electron-forge/plugin-fuses": "^7.8.3",
"@electron-forge/plugin-vite": "^7.8.3",
"@electron-forge/shared-types": "^7.8.1",
"@electron/fuses": "~1.8.0",
"@eslint/compat": "^1.3.2",
"@eslint/js": "^9.28.0",
"@playwright/test": "^1.55.0",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/electron-squirrel-startup": "^1.0.2",
"@types/eslint-config-prettier": "^6.11.3",
"@types/node": "^22.18.1",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.2",
"babel-plugin-react-compiler": "^19.1.0-rc.3",
"electron": "^38.0.0",
"electron-devtools-installer": "^4.0.0",
"electron-playwright-helpers": "^1.8.2",
"eslint": "^9.35.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"globals": "^16.3.0",
"jsdom": "^26.1.0",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.11",
"ts-node": "^10.9.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.42.0",
"vite": "^7.1.4",
"vitest": "^3.2.4"
},
"dependencies": {
"@icons-pack/react-simple-icons": "^13.7.0",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.11",
"@tailwindcss/vite": "^4.1.13",
"@tanstack/react-query": "^5.87.1",
"@tanstack/react-router": "^1.131.35",
"@tanstack/router-devtools": "^1.131.35",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"electron-squirrel-startup": "^1.0.1",
"i18next": "^25.5.2",
"lucide-react": "^0.542.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-i18next": "^15.7.3",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.1.5"
}
}

View File

@@ -0,0 +1,23 @@
import { defineConfig, devices } from "@playwright/test";
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./src/tests/e2e",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});

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

32
electron/tsconfig.json Normal file
View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"target": "ESNext",
"module": "ESNext",
"lib": ["dom", "ESNext"],
"experimentalDecorators": true,
"composite": true,
"declaration": true,
"forceConsistentCasingInFileNames": true,
"allowJs": false,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"noImplicitAny": true,
"sourceMap": true,
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"outDir": "dist",
"moduleResolution": "bundler",
"resolveJsonModule": true
},
"include": [
"src/**/*",
"./package.json",
"./forge.config.ts",
"*.mts",
"vite.renderer.config.mts"
]
}

View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import path from "path";
// https://vitejs.dev/config
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});

View File

@@ -0,0 +1,4 @@
import { defineConfig } from "vite";
// https://vitejs.dev/config
export default defineConfig({});

View File

@@ -0,0 +1,21 @@
import * as path from "path";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
tailwindcss(),
react({
babel: {
plugins: [["babel-plugin-react-compiler"]],
},
}),
],
resolve: {
preserveSymlinks: true,
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});

26
electron/vitest.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import * as path from "path";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
test: {
dir: "./src/tests/unit",
globals: true,
environment: "jsdom",
setupFiles: "./src/tests/unit/setup.ts",
css: true,
reporters: ["verbose"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*"],
exclude: [],
},
},
});

1
server/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

30
server/Cargo.toml Normal file
View File

@@ -0,0 +1,30 @@
[package]
name = "server"
version = "0.1.0"
edition = "2024"
[dependencies]
# web
tower-livereload = "0.9.6"
socketioxide-core = "0.16.0"
socketioxide = "0.16.0"
tower-http = { version = "0.6.2", features = ["cors", "trace", "fs"] }
axum = { version = "0.8.1", features = ["macros"] }
# utility
uom = "0.36.0"
chrono = "0.4.39"
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
serde = "1.0.217"
anyhow = "1.0.95"
serde_json = "1.0.137"
signal-hook = "0.3.17"
log = "0.4.25"
env_logger = "0.11.6"
tokio = { version = "1.43.0", features = ["rt-multi-thread"] }
include_dir = "0.7.4"
mime_guess = "2.0.5"
open = "5.3.2"
bitvec = "1.0.1"
lazy_static = "1.5.0"

View File

@@ -0,0 +1,10 @@
[toolchain]
channel = "beta"
components = [
"rustfmt",
"clippy",
"rust-src",
"rustc-dev",
"llvm-tools-preview",
"rust-analyzer",
]

1
server/src/app_state.rs Normal file
View File

@@ -0,0 +1 @@
pub static APP_STATE: LazyLock<Arc<AppState>> = LazyLock::new(|| Arc::new(AppState::new()));

15
server/src/main.rs Normal file
View File

@@ -0,0 +1,15 @@
use anyhow::{Error, Result};
//use app_state::APP_STATE;
use env_logger::Env;
use rest::init::init_api;
//use socketio::init::init_socketio;
pub mod rest;
#[tokio::main]
async fn main() -> Result<(), Error> {
env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
init_api().await?;
Ok(())
}

View File

@@ -0,0 +1,32 @@
use axum::body::Body;
use serde::Serialize;
pub mod my_test;
#[derive(Debug, Serialize, Clone)]
pub struct MutationResponse {
pub success: bool,
pub error: Option<String>,
}
impl MutationResponse {
pub fn success() -> Self {
Self {
success: true,
error: None,
}
}
pub fn error(error: String) -> Self {
Self {
success: false,
error: Some(error),
}
}
}
impl From<MutationResponse> for Body {
fn from(mutation_response: MutationResponse) -> Self {
let body = serde_json::to_string(&mutation_response).unwrap();
Body::from(body)
}
}

View File

@@ -0,0 +1,10 @@
use super::MutationResponse;
use crate::rest::util::ResponseUtil;
use axum::{body::Body, extract::State, http::Response, Json};
use std::sync::Arc;
#[axum::debug_handler]
pub async fn post_my_test() -> Response<Body> {
ResponseUtil::ok(MutationResponse::success())
}

24
server/src/rest/init.rs Normal file
View File

@@ -0,0 +1,24 @@
use super::handlers::my_test::post_my_test;
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use std::io::Error;
use axum::routing::post;
pub async fn init_api(
) -> Result<(), Error> {
let cors = CorsLayer::permissive();
let app = axum::Router::new()
.route(
"/api/v1/test",
post(post_my_test)
)
.layer(cors)
.layer(TraceLayer::new_for_http());
let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await?;
axum::serve(listener, app).await?;
open::that("http://localhost:3001")?;
Ok(())
}

3
server/src/rest/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod handlers;
pub mod init;
pub mod util;

51
server/src/rest/util.rs Normal file
View File

@@ -0,0 +1,51 @@
use axum::{
body::Body,
http::{Response, StatusCode},
};
use serde_json::json;
pub struct ResponseUtil {}
impl ResponseUtil {
pub fn error(message: &str) -> Response<Body> {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/json")
.body(Body::from(
serde_json::to_string(&json!({ "error": message })).unwrap(),
))
.unwrap()
}
pub fn ok<T: serde::Serialize>(data: T) -> Response<Body> {
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&data).unwrap()))
.unwrap()
}
pub fn not_found(message: &str) -> Response<Body> {
Response::builder()
.status(StatusCode::NOT_FOUND)
.header("Content-Type", "application/json")
.body(Body::from(
serde_json::to_string(&json!({ "error": message })).unwrap(),
))
.unwrap()
}
}
pub enum ResponseUtilError {
Error(anyhow::Error),
NotFound(anyhow::Error),
}
impl From<ResponseUtilError> for Response<Body> {
fn from(error: ResponseUtilError) -> Self {
match error {
ResponseUtilError::Error(e) => ResponseUtil::error(&e.to_string()),
ResponseUtilError::NotFound(e) => ResponseUtil::not_found(&e.to_string()),
}
}
}