├── .github
└── workflows
│ └── release.yml
├── .gitignore
├── LICENSE
├── README.md
├── components.json
├── index.html
├── package.json
├── pnpm-lock.yaml
├── postcss.config.js
├── public
├── docs.svg
└── icon.png
├── src-tauri
├── .gitignore
├── Cargo.lock
├── Cargo.toml
├── assets
│ ├── pencil.svg
│ └── trash.svg
├── build.rs
├── icons
│ ├── 128x128.png
│ ├── 128x128@2x.png
│ ├── 32x32.png
│ ├── Square107x107Logo.png
│ ├── Square142x142Logo.png
│ ├── Square150x150Logo.png
│ ├── Square284x284Logo.png
│ ├── Square30x30Logo.png
│ ├── Square310x310Logo.png
│ ├── Square44x44Logo.png
│ ├── Square71x71Logo.png
│ ├── Square89x89Logo.png
│ ├── StoreLogo.png
│ ├── icon.icns
│ ├── icon.ico
│ └── icon.png
├── src
│ ├── main.rs
│ └── menu.rs
├── tauri.conf.json
└── tauri.macos.conf.json
├── src
├── App.tsx
├── Project.tsx
├── ThemeProvider.tsx
├── components
│ ├── Editor
│ │ ├── Editor.tsx
│ │ ├── Menu.tsx
│ │ ├── NodeViews
│ │ │ ├── CodeBlockView.tsx
│ │ │ ├── Image
│ │ │ │ ├── Image.tsx
│ │ │ │ └── Options.tsx
│ │ │ ├── TableView.tsx
│ │ │ └── types.ts
│ │ ├── Popover
│ │ │ └── Link.tsx
│ │ ├── Publish.tsx
│ │ ├── TableOfContents.tsx
│ │ ├── Titles.tsx
│ │ ├── extensions
│ │ │ ├── CodeBlockLowlight
│ │ │ │ ├── index.ts
│ │ │ │ └── lowlightPlugin.ts
│ │ │ ├── link-text.ts
│ │ │ ├── metadata.ts
│ │ │ └── table-of-contents.ts
│ │ └── hasLink.ts
│ ├── Main
│ │ ├── AddProject.tsx
│ │ ├── Apps.tsx
│ │ ├── EmptyProject.tsx
│ │ └── Projects.tsx
│ ├── Option.tsx
│ ├── Project
│ │ ├── App.tsx
│ │ ├── FileTree
│ │ │ ├── File.tsx
│ │ │ ├── Root.tsx
│ │ │ ├── SortItem.tsx
│ │ │ └── Tree.tsx
│ │ ├── Selector.tsx
│ │ └── createFile.tsx
│ ├── Settings
│ │ ├── AppSettings.tsx
│ │ └── CommandMenu.tsx
│ └── ui
│ │ ├── alert-dialog.tsx
│ │ ├── button.tsx
│ │ ├── command.tsx
│ │ ├── dialog.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── popover.tsx
│ │ ├── select.tsx
│ │ ├── slider.tsx
│ │ ├── switch.tsx
│ │ ├── toast.tsx
│ │ ├── toaster.tsx
│ │ └── use-toast.ts
├── hooks
│ └── useEditor.ts
├── lib
│ ├── tableShortcut.ts
│ └── utils.ts
├── main.tsx
├── store
│ ├── appStore.ts
│ └── restoreState.ts
├── styles
│ └── globals.css
├── utils
│ ├── appStore.ts
│ ├── getFileMeta.ts
│ ├── getImgUrl.ts
│ ├── htmlToMarkdown
│ │ ├── index.ts
│ │ └── turndown
│ │ │ ├── index.ts
│ │ │ ├── listItem.ts
│ │ │ ├── paragraph.ts
│ │ │ └── table
│ │ │ ├── table.ts
│ │ │ ├── tableCell.ts
│ │ │ └── tableRow.ts
│ ├── markdown.ts
│ ├── markdownToHtml
│ │ ├── helpers.ts
│ │ ├── index.ts
│ │ └── marked
│ │ │ └── index.ts
│ ├── parseMd.ts
│ ├── removePath.ts
│ └── types.ts
└── vite-env.d.ts
├── tailwind.config.js
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 | on:
3 | push:
4 | tags:
5 | - "master"
6 | workflow_dispatch:
7 |
8 | jobs:
9 | release:
10 | permissions:
11 | contents: write
12 | strategy:
13 | fail-fast: false
14 | matrix:
15 | platform: [macos-12, macos-latest, ubuntu-20.04, windows-latest]
16 | runs-on: ${{ matrix.platform }}
17 |
18 | env:
19 | TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
20 | TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
21 |
22 | steps:
23 | - name: Checkout repository
24 | uses: actions/checkout@v4
25 |
26 | - name: Install dependencies (ubuntu only)
27 | if: matrix.platform == 'ubuntu-20.04'
28 | # You can remove libayatana-appindicator3-dev if you don't use the system tray feature.
29 | run: |
30 | sudo apt-get update
31 | sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev librsvg2-dev
32 |
33 | - name: Rust setup
34 | uses: dtolnay/rust-toolchain@stable
35 |
36 | - name: Rust cache
37 | uses: swatinem/rust-cache@v2
38 | with:
39 | workspaces: "./src-tauri -> target"
40 |
41 | - name: Sync node version and setup cache
42 | uses: pnpm/action-setup@v3
43 | with:
44 | version: 8
45 | - name: Use Node.js ${{ matrix.node-version }}
46 | uses: actions/setup-node@v4
47 | with:
48 | node-version: "lts/*"
49 | cache: "pnpm"
50 | - name: Install dependencies
51 | run: pnpm install
52 |
53 | - name: Install frontend dependencies
54 | run: pnpm install
55 |
56 | - name: Build the app
57 | uses: tauri-apps/tauri-action@v0
58 |
59 | env:
60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
61 | with:
62 | tagName: ${{ github.ref_name }} # This only works if your workflow triggers on new tags.
63 | releaseName: "Marker v__VERSION__" # tauri-action replaces \_\_VERSION\_\_ with the app version.
64 | releaseBody: "Includes new features / bug fixes"
65 | releaseDraft: true
66 | prerelease: false
67 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # build output
2 | dist/
3 |
4 | # generated types
5 | .astro/
6 |
7 | # dependencies
8 | node_modules/
9 |
10 | # logs
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 | pnpm-debug.log*
15 |
16 | # environment variables
17 | .env
18 | .env.production
19 |
20 | # macOS-specific files
21 | .DS_Store
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 TK
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
Marker
4 |
An open-source, user-friendly UI for viewing and editing markdown files
5 |
6 |
7 | ## Download
8 |
9 | Navigate to the [release page](https://github.com/tk04/Marker/releases) and select the installer that matches your platform.
10 |
11 | #### Using Hombrew
12 | ```bash
13 | $ brew install --cask tk04/tap/marker
14 | ```
15 |
16 | #### [AUR](https://aur.archlinux.org/packages/marker-md) for Arch Linux
17 | ##### Using `paru`
18 | ```bash
19 | $ paru -S marker-md
20 | ```
21 |
22 | ##### Using `yay`
23 | ```bash
24 | $ yay -S marker-md
25 | ```
26 |
27 | ## Building Locally
28 |
29 | To build Marker locally, clone this repo and run the following commands (make sure to have Rust already installed on your system):
30 |
31 | ```sh
32 | $ pnpm install && npx tauri build
33 | ```
34 |
35 | ## Contributing
36 |
37 | If you feel that Marker is missing something, feel free to open a PR. Contributions are welcome and highly appreciated.
38 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.mjs",
8 | "css": "./src/styles/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Vite + React + TS
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Marker",
3 | "type": "module",
4 | "version": "1.4.1",
5 | "scripts": {
6 | "dev": "vite --port 3000",
7 | "build": "tsc && vite build",
8 | "tauri": "tauri"
9 | },
10 | "dependencies": {
11 | "@radix-ui/react-alert-dialog": "^1.0.5",
12 | "@radix-ui/react-dialog": "^1.0.5",
13 | "@radix-ui/react-dropdown-menu": "^2.0.6",
14 | "@radix-ui/react-label": "^2.0.2",
15 | "@radix-ui/react-popover": "^1.0.7",
16 | "@radix-ui/react-select": "^2.0.0",
17 | "@radix-ui/react-slider": "^1.1.2",
18 | "@radix-ui/react-slot": "^1.0.2",
19 | "@radix-ui/react-switch": "^1.0.3",
20 | "@radix-ui/react-toast": "^1.1.5",
21 | "@tauri-apps/api": "^1.5.3",
22 | "@tiptap/core": "^2.4.0",
23 | "@tiptap/extension-bubble-menu": "^2.4.0",
24 | "@tiptap/extension-bullet-list": "^2.4.0",
25 | "@tiptap/extension-character-count": "^2.4.0",
26 | "@tiptap/extension-code": "^2.4.0",
27 | "@tiptap/extension-code-block": "^2.4.0",
28 | "@tiptap/extension-document": "^2.4.0",
29 | "@tiptap/extension-heading": "^2.4.0",
30 | "@tiptap/extension-image": "^2.4.0",
31 | "@tiptap/extension-link": "^2.4.0",
32 | "@tiptap/extension-list-item": "^2.4.0",
33 | "@tiptap/extension-ordered-list": "^2.4.0",
34 | "@tiptap/extension-paragraph": "^2.4.0",
35 | "@tiptap/extension-placeholder": "^2.4.0",
36 | "@tiptap/extension-table": "^2.4.0",
37 | "@tiptap/extension-table-cell": "^2.4.0",
38 | "@tiptap/extension-table-header": "^2.4.0",
39 | "@tiptap/extension-table-row": "^2.4.0",
40 | "@tiptap/extension-task-item": "^2.4.0",
41 | "@tiptap/extension-task-list": "^2.4.0",
42 | "@tiptap/pm": "^2.4.0",
43 | "@tiptap/react": "^2.4.0",
44 | "@tiptap/starter-kit": "^2.4.0",
45 | "@types/react": "^18.2.59",
46 | "@types/react-dom": "^18.2.19",
47 | "antd": "^5.14.2",
48 | "class-variance-authority": "^0.7.0",
49 | "clsx": "^2.1.0",
50 | "cmdk": "^1.0.0",
51 | "hast-util-to-html": "^9.0.0",
52 | "he": "^1.2.0",
53 | "highlight.js": "^11.9.0",
54 | "lowlight": "^3.1.0",
55 | "lucide-react": "^0.341.0",
56 | "marked": "^12.0.0",
57 | "react": "^18.2.0",
58 | "react-color": "^2.19.3",
59 | "react-dom": "^18.2.0",
60 | "react-icons": "^5.0.1",
61 | "react-router-dom": "^6.22.2",
62 | "tailwind-merge": "^2.2.1",
63 | "tailwindcss-animate": "^1.0.7",
64 | "tauri-plugin-context-menu": "^0.7.0",
65 | "tauri-plugin-store-api": "github:tauri-apps/tauri-plugin-store#v1",
66 | "turndown": "^7.1.2",
67 | "typescript": "^5.3.3",
68 | "uuid": "^9.0.1",
69 | "yaml": "^2.4.0",
70 | "zustand": "^4.5.2"
71 | },
72 | "devDependencies": {
73 | "@tauri-apps/cli": "^1.5.10",
74 | "@types/he": "^1.2.3",
75 | "@types/node": "^20.11.24",
76 | "@types/react-color": "^3.0.12",
77 | "@types/turndown": "^5.0.4",
78 | "@types/uuid": "^9.0.8",
79 | "@vitejs/plugin-react": "^4.2.1",
80 | "autoprefixer": "^10.4.18",
81 | "postcss": "^8.4.35",
82 | "tailwindcss": "^3.4.1",
83 | "vite": "^5.1.5"
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | "tailwindcss/nesting": {},
4 | tailwindcss: {},
5 | autoprefixer: {},
6 | },
7 | };
8 |
--------------------------------------------------------------------------------
/public/docs.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tk04/Marker/b878afcb2c8895702cacce2613f9081d3682ddc7/public/icon.png
--------------------------------------------------------------------------------
/src-tauri/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated by Cargo
2 | # will have compiled files and executables
3 | /target/
4 |
--------------------------------------------------------------------------------
/src-tauri/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "marker"
3 | version = "1.4.1"
4 | description = "A Secure Visual Markdown Editor"
5 | authors = ["tk"]
6 | license = ""
7 | repository = ""
8 | default-run = "marker"
9 | edition = "2021"
10 | rust-version = "1.60"
11 |
12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
13 |
14 | [build-dependencies]
15 | tauri-build = { version = "1.5.1", features = [] }
16 |
17 | [dependencies]
18 | serde_json = "1.0"
19 | serde = { version = "1.0", features = ["derive"] }
20 | tauri = { version = "1.6.0", features = [ "window-create", "native-tls-vendored", "updater", "window-unmaximize", "window-maximize", "window-start-dragging", "shell-open", "protocol-asset", "shell-execute", "dialog-all", "fs-all", "path-all"] }
21 | tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
22 | tauri-plugin-context-menu = "0.7.1"
23 |
24 | [target.'cfg(target_os = "macos")'.dependencies]
25 | objc = "0.2.7"
26 | cocoa = "0.25.0"
27 |
28 | [features]
29 | # this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.
30 | # If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes.
31 | # DO NOT REMOVE!!
32 | custom-protocol = [ "tauri/custom-protocol" ]
33 |
--------------------------------------------------------------------------------
/src-tauri/assets/pencil.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src-tauri/assets/trash.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src-tauri/build.rs:
--------------------------------------------------------------------------------
1 | fn main() {
2 | tauri_build::build()
3 | }
4 |
--------------------------------------------------------------------------------
/src-tauri/icons/128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tk04/Marker/b878afcb2c8895702cacce2613f9081d3682ddc7/src-tauri/icons/128x128.png
--------------------------------------------------------------------------------
/src-tauri/icons/128x128@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tk04/Marker/b878afcb2c8895702cacce2613f9081d3682ddc7/src-tauri/icons/128x128@2x.png
--------------------------------------------------------------------------------
/src-tauri/icons/32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tk04/Marker/b878afcb2c8895702cacce2613f9081d3682ddc7/src-tauri/icons/32x32.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square107x107Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tk04/Marker/b878afcb2c8895702cacce2613f9081d3682ddc7/src-tauri/icons/Square107x107Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square142x142Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tk04/Marker/b878afcb2c8895702cacce2613f9081d3682ddc7/src-tauri/icons/Square142x142Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square150x150Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tk04/Marker/b878afcb2c8895702cacce2613f9081d3682ddc7/src-tauri/icons/Square150x150Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square284x284Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tk04/Marker/b878afcb2c8895702cacce2613f9081d3682ddc7/src-tauri/icons/Square284x284Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square30x30Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tk04/Marker/b878afcb2c8895702cacce2613f9081d3682ddc7/src-tauri/icons/Square30x30Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square310x310Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tk04/Marker/b878afcb2c8895702cacce2613f9081d3682ddc7/src-tauri/icons/Square310x310Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square44x44Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tk04/Marker/b878afcb2c8895702cacce2613f9081d3682ddc7/src-tauri/icons/Square44x44Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square71x71Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tk04/Marker/b878afcb2c8895702cacce2613f9081d3682ddc7/src-tauri/icons/Square71x71Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square89x89Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tk04/Marker/b878afcb2c8895702cacce2613f9081d3682ddc7/src-tauri/icons/Square89x89Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/StoreLogo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tk04/Marker/b878afcb2c8895702cacce2613f9081d3682ddc7/src-tauri/icons/StoreLogo.png
--------------------------------------------------------------------------------
/src-tauri/icons/icon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tk04/Marker/b878afcb2c8895702cacce2613f9081d3682ddc7/src-tauri/icons/icon.icns
--------------------------------------------------------------------------------
/src-tauri/icons/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tk04/Marker/b878afcb2c8895702cacce2613f9081d3682ddc7/src-tauri/icons/icon.ico
--------------------------------------------------------------------------------
/src-tauri/icons/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tk04/Marker/b878afcb2c8895702cacce2613f9081d3682ddc7/src-tauri/icons/icon.png
--------------------------------------------------------------------------------
/src-tauri/src/main.rs:
--------------------------------------------------------------------------------
1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!!
2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
3 | use serde::{Deserialize, Serialize};
4 | use std::fs::metadata;
5 | use std::time::SystemTime;
6 | use tauri::{Manager, Window};
7 | use tauri_plugin_context_menu;
8 | mod menu;
9 |
10 | #[derive(Serialize, Deserialize, Debug)]
11 | pub struct FileMeta {
12 | pub created_at: Option,
13 | pub updated_at: Option,
14 | }
15 |
16 | pub enum ToolbarThickness {
17 | Thick,
18 | Medium,
19 | Thin,
20 | }
21 | pub trait WindowExt {
22 | #[cfg(target_os = "macos")]
23 | fn set_transparent_titlebar(&self);
24 | }
25 | #[cfg(target_os = "macos")]
26 | impl WindowExt for Window {
27 | fn set_transparent_titlebar(&self) {
28 | use cocoa::appkit::{NSWindow, NSWindowTitleVisibility};
29 | use objc::{class, msg_send, sel, sel_impl};
30 |
31 | unsafe {
32 | let id = self.ns_window().unwrap() as cocoa::base::id;
33 |
34 | id.setTitlebarAppearsTransparent_(cocoa::base::YES);
35 | let thickness = ToolbarThickness::Medium;
36 | match thickness {
37 | ToolbarThickness::Thick => {
38 | self.set_title("").ok();
39 | id.setToolbar_(msg_send![class!(NSToolbar), new]);
40 | }
41 | ToolbarThickness::Medium => {
42 | id.setTitleVisibility_(NSWindowTitleVisibility::NSWindowTitleHidden);
43 | id.setToolbar_(msg_send![class!(NSToolbar), new]);
44 | }
45 | ToolbarThickness::Thin => {
46 | id.setTitleVisibility_(NSWindowTitleVisibility::NSWindowTitleHidden);
47 | }
48 | }
49 | }
50 | }
51 | }
52 | #[tauri::command]
53 | fn get_file_metadata(filepath: String) -> FileMeta {
54 | if let Ok(meta) = metadata(filepath) {
55 | return FileMeta {
56 | updated_at: meta.modified().ok(),
57 | created_at: meta.created().ok(),
58 | };
59 | }
60 | FileMeta {
61 | created_at: None,
62 | updated_at: None,
63 | }
64 | }
65 |
66 | fn main() {
67 | let context = tauri::generate_context!();
68 | tauri::Builder::default()
69 | .setup(|app| {
70 | #[cfg(target_os = "macos")]
71 | {
72 | let win = app.get_window("main").unwrap();
73 | win.set_transparent_titlebar();
74 | }
75 | Ok(())
76 | })
77 | .plugin(tauri_plugin_store::Builder::default().build())
78 | .plugin(tauri_plugin_context_menu::init())
79 | .invoke_handler(tauri::generate_handler![get_file_metadata])
80 | .menu(menu::os_default(&context.package_info().name))
81 | .run(context)
82 | .expect("error while running tauri application");
83 | }
84 |
--------------------------------------------------------------------------------
/src-tauri/src/menu.rs:
--------------------------------------------------------------------------------
1 | use tauri::{AboutMetadata, CustomMenuItem, Menu, MenuItem, Submenu};
2 | pub fn os_default(#[allow(unused)] app_name: &str) -> Menu {
3 | let mut menu = Menu::new();
4 | let settings = CustomMenuItem::new("settings", "Settings").accelerator("CmdOrCtrl+,");
5 | #[cfg(target_os = "macos")]
6 | {
7 | menu = menu.add_submenu(Submenu::new(
8 | app_name,
9 | Menu::new()
10 | .add_native_item(MenuItem::About(
11 | app_name.to_string(),
12 | AboutMetadata::default(),
13 | ))
14 | .add_native_item(MenuItem::Separator)
15 | .add_item(settings)
16 | .add_native_item(MenuItem::Separator)
17 | .add_native_item(MenuItem::Services)
18 | .add_native_item(MenuItem::Separator)
19 | .add_native_item(MenuItem::Hide)
20 | .add_native_item(MenuItem::HideOthers)
21 | .add_native_item(MenuItem::ShowAll)
22 | .add_native_item(MenuItem::Separator)
23 | .add_native_item(MenuItem::Quit),
24 | ));
25 | }
26 |
27 | let mut file_menu = Menu::new();
28 | file_menu = file_menu.add_native_item(MenuItem::CloseWindow);
29 | #[cfg(not(target_os = "macos"))]
30 | {
31 | file_menu = file_menu.add_item(settings).add_native_item(MenuItem::Quit);
32 | }
33 | menu = menu.add_submenu(Submenu::new("File", file_menu));
34 |
35 | #[cfg(not(target_os = "linux"))]
36 | let mut edit_menu = Menu::new();
37 | #[cfg(target_os = "macos")]
38 | {
39 | edit_menu = edit_menu.add_native_item(MenuItem::Undo);
40 | edit_menu = edit_menu.add_native_item(MenuItem::Redo);
41 | edit_menu = edit_menu.add_native_item(MenuItem::Separator);
42 | }
43 | #[cfg(not(target_os = "linux"))]
44 | {
45 | edit_menu = edit_menu.add_native_item(MenuItem::Cut);
46 | edit_menu = edit_menu.add_native_item(MenuItem::Copy);
47 | edit_menu = edit_menu.add_native_item(MenuItem::Paste);
48 | }
49 | #[cfg(target_os = "macos")]
50 | {
51 | edit_menu = edit_menu.add_native_item(MenuItem::SelectAll);
52 | }
53 | #[cfg(not(target_os = "linux"))]
54 | {
55 | menu = menu.add_submenu(Submenu::new("Edit", edit_menu));
56 | }
57 | #[cfg(target_os = "macos")]
58 | {
59 | menu = menu.add_submenu(Submenu::new(
60 | "View",
61 | Menu::new().add_native_item(MenuItem::EnterFullScreen),
62 | ));
63 | }
64 |
65 | let mut window_menu = Menu::new();
66 | window_menu = window_menu.add_native_item(MenuItem::Minimize);
67 | #[cfg(target_os = "macos")]
68 | {
69 | window_menu = window_menu.add_native_item(MenuItem::Zoom);
70 | window_menu = window_menu.add_native_item(MenuItem::Separator);
71 | }
72 | window_menu = window_menu.add_native_item(MenuItem::CloseWindow);
73 | menu = menu.add_submenu(Submenu::new("Window", window_menu));
74 |
75 | menu
76 | }
77 |
--------------------------------------------------------------------------------
/src-tauri/tauri.conf.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "../node_modules/@tauri-apps/cli/schema.json",
3 | "build": {
4 | "beforeBuildCommand": "pnpm build",
5 | "beforeDevCommand": "pnpm dev",
6 | "devPath": "http://localhost:3000",
7 | "distDir": "../dist"
8 | },
9 | "package": {
10 | "productName": "Marker",
11 | "version": "1.4.1"
12 | },
13 | "tauri": {
14 | "allowlist": {
15 | "all": false,
16 | "shell": {
17 | "open": "^(?:mailto:|https://|tel:).*\\?open=true$",
18 | "execute": true,
19 | "scope": [
20 | {
21 | "name": "git",
22 | "cmd": "git",
23 | "args": true
24 | }
25 | ]
26 | },
27 | "protocol": {
28 | "asset": true,
29 | "assetScope": ["$HOME/**/*"]
30 | },
31 | "dialog": {
32 | "all": true
33 | },
34 | "path": {
35 | "all": true
36 | },
37 | "fs": {
38 | "all": true,
39 | "scope": ["$HOME/**/*", "$APPDATA/**/*"]
40 | },
41 | "window": {
42 | "all": false,
43 | "maximize": true,
44 | "unmaximize": true,
45 | "startDragging": true,
46 | "create": true
47 | }
48 | },
49 | "bundle": {
50 | "active": true,
51 | "category": "DeveloperTool",
52 | "copyright": "",
53 | "deb": {
54 | "depends": []
55 | },
56 | "externalBin": [],
57 | "icon": [
58 | "icons/32x32.png",
59 | "icons/128x128.png",
60 | "icons/128x128@2x.png",
61 | "icons/icon.icns",
62 | "icons/icon.ico"
63 | ],
64 | "identifier": "com.marker.app",
65 | "longDescription": "",
66 | "macOS": {
67 | "entitlements": null,
68 | "exceptionDomain": "",
69 | "frameworks": [],
70 | "providerShortName": null,
71 | "signingIdentity": null
72 | },
73 | "resources": ["assets/"],
74 | "shortDescription": "",
75 | "targets": "all",
76 | "windows": {
77 | "certificateThumbprint": null,
78 | "digestAlgorithm": "sha256",
79 | "timestampUrl": ""
80 | }
81 | },
82 | "security": {
83 | "csp": null
84 | },
85 | "updater": {
86 | "active": true,
87 | "endpoints": [
88 | "https://github.com/tk04/Marker/releases/download/master/latest.json"
89 | ],
90 | "dialog": true,
91 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDc3MTU3NzVGQ0U5NzQyMDEKUldRQlFwZk9YM2NWZHg1TUZLVlI1Z3VMOStTY1lSeENJb2R1cnpnQjgzZk5TS0c3SFFha2VPWVoK"
92 | },
93 | "windows": [
94 | {
95 | "fullscreen": false,
96 | "height": 600,
97 | "resizable": true,
98 | "center": true,
99 | "title": "Marker",
100 | "hiddenTitle": false,
101 | "titleBarStyle": "Transparent",
102 | "width": 800,
103 | "minWidth": 800,
104 | "minHeight": 400,
105 | "fileDropEnabled": false
106 | }
107 | ]
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/src-tauri/tauri.macos.conf.json:
--------------------------------------------------------------------------------
1 | {
2 | "tauri": {
3 | "windows": [
4 | {
5 | "fullscreen": false,
6 | "height": 600,
7 | "resizable": true,
8 | "center": true,
9 | "title": "Marker",
10 | "hiddenTitle": false,
11 | "titleBarStyle": "Overlay",
12 | "width": 800,
13 | "minWidth": 800,
14 | "minHeight": 400,
15 | "fileDropEnabled": false
16 | }
17 | ]
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import Apps from "./components/Main/Apps";
2 |
3 | function App() {
4 | return (
5 |
11 | );
12 | }
13 |
14 | export default App;
15 |
--------------------------------------------------------------------------------
/src/Project.tsx:
--------------------------------------------------------------------------------
1 | import { useLoaderData } from "react-router-dom";
2 | import { Dir } from "./utils/types";
3 | import App from "@/components/Project/App";
4 |
5 | function Project() {
6 | const { project }: { project: Dir } = useLoaderData() as any;
7 | return ;
8 | }
9 |
10 | export default Project;
11 |
--------------------------------------------------------------------------------
/src/ThemeProvider.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useEffect, useState } from "react";
2 |
3 | export type Theme = "dark" | "light" | "system";
4 |
5 | type ThemeProviderProps = {
6 | children: React.ReactNode;
7 | defaultTheme?: Theme;
8 | storageKey?: string;
9 | };
10 |
11 | type ThemeProviderState = {
12 | theme: Theme;
13 | setTheme: (theme: Theme) => void;
14 | };
15 |
16 | const initialState: ThemeProviderState = {
17 | theme: "system",
18 | setTheme: () => null,
19 | };
20 |
21 | const ThemeProviderContext = createContext(initialState);
22 |
23 | export function ThemeProvider({
24 | children,
25 | defaultTheme = "system",
26 | storageKey = "ui-theme",
27 | ...props
28 | }: ThemeProviderProps) {
29 | const [theme, setTheme] = useState(
30 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
31 | );
32 | function changeTheme() {
33 | const root = window.document.documentElement;
34 |
35 | root.classList.remove("light", "dark");
36 |
37 | if (theme === "system") {
38 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
39 | .matches
40 | ? "dark"
41 | : "light";
42 |
43 | root.classList.add(systemTheme);
44 | return;
45 | }
46 |
47 | root.classList.add(theme);
48 | }
49 |
50 | useEffect(() => {
51 | changeTheme();
52 |
53 | window
54 | .matchMedia("(prefers-color-scheme: dark)")
55 | .addEventListener("change", changeTheme);
56 |
57 | return () => {
58 | window
59 | .matchMedia("(prefers-color-scheme: dark)")
60 | .removeEventListener("change", changeTheme);
61 | };
62 | }, [theme]);
63 |
64 | const value = {
65 | theme,
66 | setTheme: (theme: Theme) => {
67 | localStorage.setItem(storageKey, theme);
68 | setTheme(theme);
69 | },
70 | };
71 |
72 | return (
73 |
74 | {children}
75 |
76 | );
77 | }
78 |
79 | export const useTheme = () => {
80 | const context = useContext(ThemeProviderContext);
81 |
82 | if (context === undefined)
83 | throw new Error("useTheme must be used within a ThemeProvider");
84 |
85 | return context;
86 | };
87 |
--------------------------------------------------------------------------------
/src/components/Editor/Editor.tsx:
--------------------------------------------------------------------------------
1 | import yaml from "yaml";
2 |
3 | import { writeTextFile, type FileEntry } from "@tauri-apps/api/fs";
4 | import { EditorContent, isMacOS } from "@tiptap/react";
5 | import Titles from "./Titles";
6 | import { useEffect, useRef, useState } from "react";
7 | import Menu from "./Menu";
8 | import LinkPopover from "./Popover/Link";
9 | import useTextEditor from "@/hooks/useEditor.ts";
10 | import Publish from "./Publish";
11 | import { htmlToMarkdown, readMarkdownFile } from "@/utils/markdown";
12 | import { Node } from "@tiptap/pm/model";
13 | import TableOfContents from "./TableOfContents";
14 | import useStore from "@/store/appStore";
15 | import type { Editor as EditorType } from "@tiptap/core";
16 |
17 | export type TOC = { node: Node; level: number }[];
18 | interface props {
19 | file: FileEntry;
20 | projectPath: string;
21 | collapse: boolean;
22 | }
23 | const Editor: React.FC = ({ projectPath, file, collapse }) => {
24 | const settings = useStore((s) => s.settings);
25 | const [metadata, setMetadata] = useState<{ [key: string]: any } | null>(null);
26 | const editor = useTextEditor({
27 | content: "",
28 | onUpdate,
29 | filePath: file.path,
30 | projectDir: projectPath,
31 | loadFile: loadFile,
32 | });
33 |
34 | const saveFileTimeoutRef = useRef(null);
35 | function clearSaveFileTimeout() {
36 | if (saveFileTimeoutRef.current != null) {
37 | clearTimeout(saveFileTimeoutRef.current);
38 | }
39 | }
40 |
41 | function onUpdate() {
42 | clearSaveFileTimeout();
43 | saveFileTimeoutRef.current = setTimeout(saveFile, 200);
44 | }
45 |
46 | async function saveFile() {
47 | try {
48 | let mdContent = "---\n" + yaml.stringify(metadata) + "---\n";
49 | mdContent += htmlToMarkdown(editor?.getHTML() || "");
50 | await writeTextFile(file.path, mdContent);
51 | } catch {
52 | alert(
53 | "An error occurred when trying to save this file. Let us know by opening an issue at https://github.com/tk04/marker",
54 | );
55 | }
56 | }
57 | useEffect(() => {
58 | if (metadata != null) {
59 | onUpdate();
60 | }
61 | }, [metadata]);
62 |
63 | async function loadFile(editor: EditorType | null) {
64 | if (!editor) return;
65 | const { metadata, html } = await readMarkdownFile(file.path);
66 |
67 | editor.commands.updateMetadata({
68 | filePath: file.path,
69 | });
70 | editor.commands.setContent(html);
71 | setMetadata(metadata);
72 |
73 | editor.commands.focus("start");
74 | document.querySelector(".editor")?.scroll({ top: 0 });
75 |
76 | // reset history (see https://github.com/ueberdosis/tiptap/issues/491#issuecomment-1261056162)
77 | // @ts-ignore
78 | if (editor.state.history$) {
79 | // @ts-ignore
80 | editor.state.history$.prevRanges = null;
81 | // @ts-ignore
82 | editor.state.history$.done.eventCount = 0;
83 | }
84 | }
85 | useEffect(() => {
86 | clearSaveFileTimeout();
87 | let timeout = setTimeout(loadFile.bind(null, editor), 0);
88 | return () => {
89 | clearTimeout(timeout);
90 | };
91 | }, [file.path]);
92 |
93 | if (!editor) return;
94 |
95 | return (
96 |
97 |
98 |
99 |
100 |
101 | {editor.storage.characterCount.words()} words
102 |
103 |
107 |
108 |
109 |
{file.path.replace(projectPath + "/", "")}
110 |
111 |
112 |
113 |
114 |
loadFile(editor)}
118 | />
119 |
120 |
121 | {settings.showTOC && (
122 |
125 | )}
126 |
130 |
131 |
132 |
133 |
134 |
138 |
139 |
140 |
141 |
142 | );
143 | };
144 |
145 | export default Editor;
146 |
--------------------------------------------------------------------------------
/src/components/Editor/Menu.tsx:
--------------------------------------------------------------------------------
1 | import type { Editor } from "@tiptap/react";
2 | import { BubbleMenu } from "@tiptap/react";
3 |
4 | import type { ReactElement } from "react";
5 | import {
6 | AiOutlineBold,
7 | AiOutlineItalic,
8 | AiOutlineStrikethrough,
9 | } from "react-icons/ai";
10 | import { HiListBullet } from "react-icons/hi2";
11 | import { BsListOl } from "react-icons/bs";
12 | import { HiOutlineCode } from "react-icons/hi";
13 | import { TbBlockquote } from "react-icons/tb";
14 | import { RxDividerHorizontal } from "react-icons/rx";
15 |
16 | const MenuItem = ({
17 | icon,
18 | onClick,
19 | isActive = false,
20 | }: {
21 | icon: ReactElement;
22 | isActive?: boolean;
23 | onClick?: () => void;
24 | }) => {
25 | return (
26 |
34 | );
35 | };
36 | interface props {
37 | editor: Editor;
38 | }
39 | const Menu: React.FC = ({ editor }) => {
40 | let menu = editor.chain();
41 | return (
42 |
48 | }
50 | isActive={editor?.isActive("bold")}
51 | onClick={() => menu?.focus().toggleBold().run()}
52 | />
53 |
54 | }
56 | isActive={editor?.isActive("italic")}
57 | onClick={() => menu?.focus().toggleItalic().run()}
58 | />
59 |
60 | }
62 | isActive={editor?.isActive("strike")}
63 | onClick={() => menu?.focus().toggleStrike().run()}
64 | />
65 |
66 | }
68 | isActive={editor?.isActive("codeBlock")}
69 | onClick={() => menu?.focus().toggleCodeBlock().run()}
70 | />
71 |
72 | }
74 | isActive={editor?.isActive("bulletList")}
75 | onClick={() => menu?.focus().toggleBulletList().run()}
76 | />
77 |
78 | }
80 | isActive={editor?.isActive("orderedList")}
81 | onClick={() => menu?.focus().toggleOrderedList().run()}
82 | />
83 |
84 | }
86 | isActive={editor?.isActive("blockquote")}
87 | onClick={() => menu?.focus().toggleBlockquote().run()}
88 | />
89 |
90 | }
92 | isActive={editor?.isActive("horizontalRule")}
93 | onClick={() => menu?.focus().setHorizontalRule().run()}
94 | />
95 |
96 | );
97 | };
98 | export default Menu;
99 |
--------------------------------------------------------------------------------
/src/components/Editor/NodeViews/CodeBlockView.tsx:
--------------------------------------------------------------------------------
1 | import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";
2 | import { FaSort } from "react-icons/fa";
3 | import props from "./types";
4 |
5 | import { common } from "lowlight";
6 |
7 | import {
8 | Command,
9 | CommandEmpty,
10 | CommandInput,
11 | CommandItem,
12 | } from "@/components/ui/command";
13 |
14 | import {
15 | Popover,
16 | PopoverContent,
17 | PopoverTrigger,
18 | } from "@/components/ui/popover";
19 | import { useState } from "react";
20 | import { CommandList } from "cmdk";
21 |
22 | const langs = Object.keys(common);
23 | const CodeBlockView: React.FC = ({ node, updateAttributes }) => {
24 | const [open, setOpen] = useState(false);
25 | return (
26 |
27 |
28 |
setOpen(val)}>
29 | setOpen(true)}
33 | >
34 |
42 |
43 |
44 |
45 |
49 | No language found.
50 |
51 | {langs.map((lang) => (
52 | {
56 | updateAttributes({ language: currentValue });
57 | setOpen(false);
58 | }}
59 | >
60 | {lang}
61 |
62 | ))}
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | );
73 | };
74 | export default CodeBlockView;
75 |
--------------------------------------------------------------------------------
/src/components/Editor/NodeViews/Image/Image.tsx:
--------------------------------------------------------------------------------
1 | import { NodeViewWrapper } from "@tiptap/react";
2 | import { BiDotsVerticalRounded } from "react-icons/bi";
3 | import { useEffect, useRef, useState } from "react";
4 | import {
5 | Popover,
6 | PopoverContent,
7 | PopoverTrigger,
8 | } from "@/components/ui/popover";
9 | import Options from "./Options";
10 | import type props from "../types";
11 | import getImgUrl from "@/utils/getImgUrl";
12 |
13 | var imageExtensions = [
14 | "svg",
15 | "jpg",
16 | "jpeg",
17 | "png",
18 | "gif",
19 | "bmp",
20 | "webp",
21 | "heif",
22 | ];
23 | const ImageView: React.FC = ({
24 | editor,
25 | node,
26 | selected,
27 | updateAttributes,
28 | }) => {
29 | const ref = useRef(null);
30 | const [src, setSrc] = useState(node.attrs?.src);
31 | const [open, setOpen] = useState(false);
32 | const [isImg, setIsImage] = useState(true);
33 | async function updateAssetSrc() {
34 | const src = await getImgUrl(
35 | editor.storage.metadata.filePath,
36 | node.attrs.src,
37 | );
38 | setSrc(src);
39 | }
40 | useEffect(() => {
41 | if (
42 | !node?.attrs?.src?.startsWith("asset://") &&
43 | !node?.attrs?.src?.startsWith("http")
44 | ) {
45 | updateAssetSrc();
46 | }
47 |
48 | const ext: string = node.attrs.src.split("?")[0].split(".").pop();
49 | setIsImage(imageExtensions.includes(ext.toLowerCase()));
50 | }, [node.attrs.src]);
51 |
52 | const updateAlt = (alt: string) => {
53 | updateAttributes({
54 | alt,
55 | });
56 | };
57 | return (
58 |
59 |
67 | {isImg ? (
68 |

69 | ) : (
70 |
73 | )}
74 | {selected && isImg && (
75 |
setOpen(val)}>
76 |
77 |
78 |
79 |
80 | setOpen(false)}
84 | updateAttributes={updateAttributes}
85 | />
86 |
87 |
88 | )}
89 |
90 |
91 | );
92 | };
93 | export default ImageView;
94 |
--------------------------------------------------------------------------------
/src/components/Editor/NodeViews/Image/Options.tsx:
--------------------------------------------------------------------------------
1 | import { useRef } from "react";
2 |
3 | interface props {
4 | alt: string;
5 | updateAlt: (alt: string) => void;
6 | closeModal: () => void;
7 | updateAttributes: (attrs: object) => void;
8 | }
9 | const Options: React.FC = ({ alt, updateAlt, closeModal }) => {
10 | let inputRef = useRef(null);
11 | return (
12 |
38 | );
39 | };
40 | export default Options;
41 |
--------------------------------------------------------------------------------
/src/components/Editor/NodeViews/TableView.tsx:
--------------------------------------------------------------------------------
1 | import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";
2 |
3 | import { HiPlus } from "react-icons/hi2";
4 | import props from "./types";
5 |
6 | const TableView: React.FC = ({ editor }) => {
7 | return (
8 |
9 |
10 |
11 |
12 |
20 |
21 |
22 |
30 |
31 |
32 | );
33 | };
34 |
35 | export default TableView;
36 |
--------------------------------------------------------------------------------
/src/components/Editor/NodeViews/types.ts:
--------------------------------------------------------------------------------
1 | import { Node } from "@tiptap/pm/model";
2 | import { Editor } from "@tiptap/react";
3 | export default interface props {
4 | node: Node;
5 | selected: boolean;
6 | updateAttributes: (attrs: object) => void;
7 | editor: Editor;
8 | getPos: () => number;
9 | deleteNode: () => void;
10 | }
11 |
--------------------------------------------------------------------------------
/src/components/Editor/Popover/Link.tsx:
--------------------------------------------------------------------------------
1 | import { Editor } from "@tiptap/react";
2 |
3 | import { TbTrash } from "react-icons/tb";
4 | import { FiEdit } from "react-icons/fi";
5 | import { getLinkText } from "../hasLink";
6 | import {
7 | Popover,
8 | PopoverContent,
9 | PopoverTrigger,
10 | } from "@/components/ui/popover";
11 | import { useEffect, useRef, useState } from "react";
12 |
13 | interface props {
14 | editor: Editor | null;
15 | }
16 | const LinkPopover: React.FC = ({ editor }) => {
17 | const ref = useRef(null);
18 | const [open, setOpen] = useState(false);
19 | const [link, setLink] = useState("");
20 | const [text, setText] = useState("");
21 |
22 | if (ref.current) {
23 | if (editor?.isActive("link")) {
24 | let { from } = editor.state.selection;
25 | let startPos = editor.view.coordsAtPos(from);
26 | ref.current!.style.display = "block";
27 | ref.current!.style.top = `${startPos.bottom + 10 + window.scrollY}px`;
28 | ref.current!.style.left = `${startPos.left}px`;
29 | } else {
30 | ref.current!.style.display = "none";
31 | }
32 | }
33 | const scrollHandler = () => {
34 | if (editor?.isActive("link")) {
35 | //@ts-ignore
36 | let { from } = editor.state.selection;
37 | let startPos = editor.view.coordsAtPos(from);
38 | ref.current!.style.display = "block";
39 | ref.current!.style.top = `${startPos.bottom + 10 + window.scrollY}px`;
40 | }
41 | };
42 | useEffect(() => {
43 | if (!editor) return;
44 | let textEditor = document.querySelector(".editor");
45 | textEditor?.addEventListener("scroll", scrollHandler);
46 |
47 | return () => {
48 | window.removeEventListener("scroll", scrollHandler);
49 | };
50 | }, [editor?.isActive("link")]);
51 | const clickHandler = () => {
52 | if (!editor) return;
53 | const previousUrl = editor.getAttributes("link").href;
54 | const { view, state } = editor;
55 | const { from, to } = view.state.selection;
56 | //@ts-ignore
57 | let nodeBefore = view.state.selection.$cursor?.nodeBefore;
58 | //@ts-ignore
59 | let nodeAfter = view.state.selection.$cursor?.nodeAfter;
60 |
61 | if (previousUrl && (nodeBefore || nodeAfter)) {
62 | let linkText = getLinkText(nodeBefore, nodeAfter);
63 | setText(linkText);
64 | } else {
65 | setText(state.doc.textBetween(from, to, ""));
66 | }
67 | setLink(editor.getAttributes("link").href);
68 | };
69 | if (!editor) return;
70 |
71 | return (
72 |
77 | {editor.isActive("link") && (
78 |
79 |
84 | {editor.getAttributes("link").href}
85 |
86 |
-
87 |
setOpen(val)}>
88 |
89 |
90 |
91 |
92 |
147 |
148 |
149 |
161 |
162 | )}
163 |
164 | );
165 | };
166 | export default LinkPopover;
167 |
--------------------------------------------------------------------------------
/src/components/Editor/Publish.tsx:
--------------------------------------------------------------------------------
1 | import { Command } from "@tauri-apps/api/shell";
2 |
3 | import { readTextFile, writeTextFile } from "@tauri-apps/api/fs";
4 | import markdown from "highlight.js/lib/languages/markdown";
5 | import "highlight.js/styles/nord.css";
6 | import hljs from "highlight.js/lib/core";
7 | hljs.registerLanguage("md", markdown);
8 | import {
9 | Dialog,
10 | DialogContent,
11 | DialogDescription,
12 | DialogFooter,
13 | DialogHeader,
14 | DialogTitle,
15 | DialogTrigger,
16 | } from "@/components/ui/dialog";
17 | import { Button } from "../ui/button";
18 | import { useEffect, useRef, useState } from "react";
19 | import { join } from "@tauri-apps/api/path";
20 | import { Input } from "../ui/input";
21 | import { Label } from "../ui/label";
22 | import { useToast } from "../ui/use-toast";
23 | import { IoCheckmarkSharp } from "react-icons/io5";
24 |
25 | interface props {
26 | projectPath: string;
27 | filePath: string;
28 | reRender: () => void;
29 | }
30 | const Publish: React.FC = ({ filePath, projectPath, reRender }) => {
31 | const { toast } = useToast();
32 | const mdRef = useRef(null);
33 | const commitMsgRef = useRef(null);
34 | const [error, setError] = useState();
35 | const [content, setContent] = useState();
36 | const [open, setOpen] = useState(false);
37 | async function publishHandler() {
38 | const txtContent = mdRef.current!.textContent;
39 | if (!txtContent) {
40 | setError(
41 | `An error happened while trying to get this file's text content.`,
42 | );
43 | return;
44 | }
45 | await writeTextFile(filePath, txtContent);
46 | const dir = await join(filePath, "../");
47 | const addCmd = await new Command(
48 | "git",
49 | ["add", filePath.replace(dir + "/", "")],
50 | {
51 | cwd: dir,
52 | },
53 | ).execute();
54 | if (addCmd.code != 0) {
55 | setError(addCmd.stderr || addCmd.stdout);
56 | return;
57 | }
58 |
59 | const commitCmd = await new Command(
60 | "git",
61 | ["commit", "-m", commitMsgRef.current!.value],
62 | {
63 | cwd: dir,
64 | },
65 | ).execute();
66 | if (commitCmd.code != 0) {
67 | setError(commitCmd.stderr || commitCmd.stdout);
68 | return;
69 | }
70 |
71 | const pushCmd = await new Command("git", ["push"], {
72 | cwd: dir,
73 | }).execute();
74 | if (pushCmd.code != 0) {
75 | setError(pushCmd.stderr || pushCmd.stdout);
76 | return;
77 | }
78 | //re-render to show changes in updated markdown content
79 | reRender();
80 |
81 | toast({
82 | title: "Pushed changes successfully",
83 | variant: "successfull",
84 | action: ,
85 | });
86 | }
87 | async function getContent() {
88 | let data = await readTextFile(filePath);
89 | setContent(data);
90 | }
91 | useEffect(() => {
92 | if (open) {
93 | getContent();
94 | }
95 | let timeout: ReturnType;
96 | if (open && content) {
97 | timeout = setTimeout(() => {
98 | if (!mdRef.current) return;
99 | mdRef.current.removeAttribute("data-highlighted");
100 | hljs.highlightElement(mdRef.current);
101 | }, 0);
102 | }
103 | return () => {
104 | if (timeout) {
105 | clearTimeout(timeout);
106 | }
107 | };
108 | }, [open, content]);
109 | function highlightMarkdown() {
110 | if (!mdRef.current) return;
111 | mdRef.current.removeAttribute("data-highlighted");
112 | hljs.highlightElement(mdRef.current);
113 | }
114 |
115 | return (
116 |
117 |
159 |
160 | );
161 | };
162 | export default Publish;
163 |
--------------------------------------------------------------------------------
/src/components/Editor/TableOfContents.tsx:
--------------------------------------------------------------------------------
1 | import { TOC } from "./Editor";
2 | import { memo } from "react";
3 |
4 | interface props {
5 | toc: TOC;
6 | }
7 | const TableOfContents: React.FC = ({ toc }) => {
8 | return (
9 |
10 |
Table of Contents
11 |
12 | {toc.map((element) => (
13 |
31 | ))}
32 |
33 | );
34 | };
35 | export default memo(TableOfContents);
36 |
--------------------------------------------------------------------------------
/src/components/Editor/Titles.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | useEffect,
3 | useRef,
4 | type Dispatch,
5 | type SetStateAction,
6 | } from "react";
7 |
8 | type Metadata = { [key: string]: any };
9 | interface props {
10 | metadata?: Metadata;
11 | setMetadata: Dispatch>;
12 | }
13 | const Titles: React.FC = ({ metadata, setMetadata }) => {
14 | const titleRef = useRef(null);
15 | const subTitleRef = useRef(null);
16 | function resizeInput() {
17 | if (titleRef.current && subTitleRef.current) {
18 | titleRef.current!.style.height = "auto";
19 | titleRef.current!.style.height =
20 | titleRef.current!.scrollHeight + 4 + "px";
21 |
22 | subTitleRef.current!.style.height = "auto";
23 | subTitleRef.current!.style.height =
24 | subTitleRef.current!.scrollHeight + 4 + "px";
25 | }
26 | }
27 | useEffect(() => {
28 | resizeInput();
29 | }, [metadata]);
30 |
31 | useEffect(() => {
32 | window.addEventListener("resize", resizeInput);
33 | return () => {
34 | window.removeEventListener("resize", resizeInput);
35 | };
36 | }, []);
37 | return (
38 |
39 |
60 | );
61 | };
62 | export default React.memo(Titles);
63 |
--------------------------------------------------------------------------------
/src/components/Editor/extensions/CodeBlockLowlight/index.ts:
--------------------------------------------------------------------------------
1 | import CodeBlock, { type CodeBlockOptions } from "@tiptap/extension-code-block";
2 | import { common, createLowlight } from "lowlight";
3 |
4 | import { LowlightPlugin } from "./lowlightPlugin";
5 | import { ReactNodeViewRenderer } from "@tiptap/react";
6 | import CodeBlockView from "../../NodeViews/CodeBlockView";
7 |
8 | export interface CodeBlockLowlightOptions extends CodeBlockOptions {
9 | lowlight: any;
10 | defaultLanguage: string | null | undefined;
11 | }
12 |
13 | export const lowlight = createLowlight(common);
14 |
15 | lowlight.registerAlias({
16 | javascript: ["js"],
17 | typescript: ["ts"],
18 | });
19 | const CodeBlockLowlight = CodeBlock.extend({
20 | addNodeView() {
21 | return ReactNodeViewRenderer(CodeBlockView);
22 | },
23 | addOptions() {
24 | return {
25 | ...this.parent?.(),
26 | defaultLanguage: null,
27 | };
28 | },
29 | addProseMirrorPlugins() {
30 | return [
31 | ...(this.parent?.() || []),
32 | LowlightPlugin({
33 | name: this.name,
34 | lowlight,
35 | defaultLanguage: this.options.defaultLanguage,
36 | }),
37 | ];
38 | },
39 | });
40 | export default CodeBlockLowlight;
41 |
--------------------------------------------------------------------------------
/src/components/Editor/extensions/CodeBlockLowlight/lowlightPlugin.ts:
--------------------------------------------------------------------------------
1 | import { findChildren } from "@tiptap/core";
2 | import { Node as ProsemirrorNode } from "@tiptap/pm/model";
3 | import { Plugin, PluginKey } from "@tiptap/pm/state";
4 | import { Decoration, DecorationSet } from "@tiptap/pm/view";
5 | function parseNodes(
6 | nodes: any[],
7 | className: string[] = [],
8 | ): { text: string; classes: string[] }[] {
9 | return nodes
10 | .map((node) => {
11 | const classes = [
12 | ...className,
13 | ...(node.properties ? node.properties.className : []),
14 | ];
15 |
16 | if (node.children) {
17 | return parseNodes(node.children, classes);
18 | }
19 |
20 | return {
21 | text: node.value,
22 | classes,
23 | };
24 | })
25 | .flat();
26 | }
27 |
28 | function getHighlightNodes(result: any) {
29 | // `.value` for lowlight v1, `.children` for lowlight v2
30 | return result.value || result.children || [];
31 | }
32 | function getDecorations({
33 | doc,
34 | name,
35 | lowlight,
36 | defaultLanguage,
37 | }: {
38 | doc: ProsemirrorNode;
39 | name: string;
40 | lowlight: any;
41 | defaultLanguage: string | null | undefined;
42 | }) {
43 | const decorations: Decoration[] = [];
44 |
45 | findChildren(doc, (node) => node.type.name === name).forEach((block) => {
46 | let from = block.pos + 1;
47 | const language = block.node.attrs.language || defaultLanguage;
48 | const languages = lowlight.listLanguages();
49 |
50 | const nodes =
51 | language &&
52 | (languages.includes(language) || lowlight.registered(language))
53 | ? getHighlightNodes(
54 | lowlight.highlight(language, block.node.textContent),
55 | )
56 | : getHighlightNodes(lowlight.highlightAuto(block.node.textContent));
57 |
58 | parseNodes(nodes).forEach((node) => {
59 | const to = from + node.text.length;
60 |
61 | if (node.classes.length) {
62 | const decoration = Decoration.inline(from, to, {
63 | class: node.classes.join(" "),
64 | });
65 |
66 | decorations.push(decoration);
67 | }
68 |
69 | from = to;
70 | });
71 | });
72 |
73 | return DecorationSet.create(doc, decorations);
74 | }
75 |
76 | function isFunction(param: Function) {
77 | return typeof param === "function";
78 | }
79 |
80 | export function LowlightPlugin({
81 | name,
82 | lowlight,
83 | defaultLanguage,
84 | }: {
85 | name: string;
86 | lowlight: any;
87 | defaultLanguage: string | null | undefined;
88 | }) {
89 | if (
90 | !["highlight", "highlightAuto", "listLanguages"].every((api) =>
91 | isFunction(lowlight[api]),
92 | )
93 | ) {
94 | throw Error(
95 | "You should provide an instance of lowlight to use the code-block-lowlight extension",
96 | );
97 | }
98 |
99 | const lowlightPlugin: Plugin = new Plugin({
100 | key: new PluginKey("lowlight"),
101 |
102 | state: {
103 | init: (_, { doc }) =>
104 | getDecorations({
105 | doc,
106 | name,
107 | lowlight,
108 | defaultLanguage,
109 | }),
110 | apply: (transaction, decorationSet, oldState, newState) => {
111 | const oldNodeName = oldState.selection.$head.parent.type.name;
112 | const newNodeName = newState.selection.$head.parent.type.name;
113 | const oldNodes = findChildren(
114 | oldState.doc,
115 | (node) => node.type.name === name,
116 | );
117 | const newNodes = findChildren(
118 | newState.doc,
119 | (node) => node.type.name === name,
120 | );
121 |
122 | if (
123 | transaction.docChanged &&
124 | // Apply decorations if:
125 | // selection includes named node,
126 | ([oldNodeName, newNodeName].includes(name) ||
127 | // OR transaction adds/removes named node,
128 | newNodes.length !== oldNodes.length ||
129 | // OR transaction has changes that completely encapsulte a node
130 | // (for example, a transaction that affects the entire document).
131 | // Such transactions can happen during collab syncing via y-prosemirror, for example.
132 | transaction.steps.some((step) => {
133 | // @ts-ignore
134 | return (
135 | // @ts-ignore
136 | step.from !== undefined &&
137 | // @ts-ignore
138 | step.to !== undefined &&
139 | oldNodes.some((node) => {
140 | // @ts-ignore
141 | return (
142 | // @ts-ignore
143 | node.pos >= step.from &&
144 | // @ts-ignore
145 | node.pos + node.node.nodeSize <= step.to
146 | );
147 | })
148 | );
149 | }))
150 | ) {
151 | return getDecorations({
152 | doc: transaction.doc,
153 | name,
154 | lowlight,
155 | defaultLanguage,
156 | });
157 | }
158 |
159 | return decorationSet.map(transaction.mapping, transaction.doc);
160 | },
161 | },
162 |
163 | props: {
164 | decorations(state) {
165 | return lowlightPlugin.getState(state);
166 | },
167 | },
168 | });
169 |
170 | return lowlightPlugin;
171 | }
172 |
--------------------------------------------------------------------------------
/src/components/Editor/extensions/link-text.ts:
--------------------------------------------------------------------------------
1 | import {
2 | InputRule,
3 | markInputRule,
4 | markPasteRule,
5 | PasteRule,
6 | } from "@tiptap/core";
7 | import { Link } from "@tiptap/extension-link";
8 |
9 | import type { LinkOptions } from "@tiptap/extension-link";
10 |
11 | /**
12 | * The input regex for Markdown links with title support, and multiple quotation marks (required
13 | * in case the `Typography` extension is being included).
14 | */
15 | const inputRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)$/i;
16 |
17 | /**
18 | * The paste regex for Markdown links with title support, and multiple quotation marks (required
19 | * in case the `Typography` extension is being included).
20 | */
21 | const pasteRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)/gi;
22 |
23 | /**
24 | * Input rule built specifically for the `Link` extension, which ignores the auto-linked URL in
25 | * parentheses (e.g., `(https://doist.dev)`).
26 | *
27 | * @see https://github.com/ueberdosis/tiptap/discussions/1865
28 | */
29 | function linkInputRule(config: Parameters[0]) {
30 | const defaultMarkInputRule = markInputRule(config);
31 |
32 | return new InputRule({
33 | find: config.find,
34 | handler(props) {
35 | const { tr } = props.state;
36 |
37 | defaultMarkInputRule.handler(props);
38 | tr.setMeta("preventAutolink", true);
39 | },
40 | });
41 | }
42 |
43 | /**
44 | * Paste rule built specifically for the `Link` extension, which ignores the auto-linked URL in
45 | * parentheses (e.g., `(https://doist.dev)`). This extension was inspired from the multiple
46 | * implementations found in a Tiptap discussion at GitHub.
47 | *
48 | * @see https://github.com/ueberdosis/tiptap/discussions/1865
49 | */
50 | function linkPasteRule(config: Parameters[0]) {
51 | const defaultMarkPasteRule = markPasteRule(config);
52 |
53 | return new PasteRule({
54 | find: config.find,
55 | handler(props) {
56 | const { tr } = props.state;
57 |
58 | defaultMarkPasteRule.handler(props);
59 | tr.setMeta("preventAutolink", true);
60 | },
61 | });
62 | }
63 |
64 | /**
65 | * Custom extension that extends the built-in `Link` extension to add additional input/paste rules
66 | * for converting the Markdown link syntax (i.e. `[Doist](https://doist.com)`) into links, and also
67 | * adds support for the `title` attribute.
68 | */
69 | const RichTextLink = Link.extend({
70 | addAttributes() {
71 | return {
72 | ...this.parent?.(),
73 | title: {
74 | default: null,
75 | parseHTML: (element) => element.innerText,
76 | },
77 | };
78 | },
79 | addInputRules() {
80 | return [
81 | linkInputRule({
82 | find: inputRegex,
83 | type: this.type,
84 |
85 | // We need to use `pop()` to remove the last capture groups from the match to
86 | // satisfy Tiptap's `markPasteRule` expectation of having the content as the last
87 | // capture group in the match (this makes the attribute order important)
88 | getAttributes(match) {
89 | return {
90 | title: match.pop()?.trim(),
91 | href: match.pop()?.trim(),
92 | };
93 | },
94 | }),
95 | ];
96 | },
97 | addPasteRules() {
98 | return [
99 | linkPasteRule({
100 | find: pasteRegex,
101 | type: this.type,
102 |
103 | // We need to use `pop()` to remove the last capture groups from the match to
104 | // satisfy Tiptap's `markInputRule` expectation of having the content as the last
105 | // capture group in the match (this makes the attribute order important)
106 | getAttributes(match) {
107 | return {
108 | title: match.pop()?.trim(),
109 | href: match.pop()?.trim(),
110 | };
111 | },
112 | }),
113 | ];
114 | },
115 | });
116 |
117 | export { RichTextLink };
118 |
119 | export type { LinkOptions as RichTextLinkOptions };
120 |
--------------------------------------------------------------------------------
/src/components/Editor/extensions/metadata.ts:
--------------------------------------------------------------------------------
1 | import { Extension } from "@tiptap/core";
2 | export interface MetadataType {
3 | projectDir: string;
4 | assetsFolder: string;
5 | filePath: string;
6 | }
7 | declare module "@tiptap/core" {
8 | interface Storage {
9 | metadata: MetadataType;
10 | }
11 | interface Commands {
12 | metadata: {
13 | /**
14 | * update file metadata
15 | */
16 | updateMetadata: (data: Partial) => ReturnType;
17 | };
18 | }
19 | }
20 |
21 | const Metadata = Extension.create({
22 | name: "metadata",
23 | addStorage() {
24 | return this.options;
25 | },
26 |
27 | addCommands() {
28 | return {
29 | updateMetadata: (data) => () => {
30 | for (let prop of Object.keys(data)) {
31 | //@ts-ignore
32 | this.storage[prop] = data[prop];
33 | }
34 | return true;
35 | },
36 | };
37 | },
38 | });
39 |
40 | export default Metadata;
41 |
--------------------------------------------------------------------------------
/src/components/Editor/extensions/table-of-contents.ts:
--------------------------------------------------------------------------------
1 | import { Extension } from "@tiptap/core";
2 | import { Node } from "@tiptap/pm/model";
3 | import { Transaction } from "@tiptap/pm/state";
4 | import { ReplaceAroundStep, ReplaceStep } from "@tiptap/pm/transform";
5 |
6 | import { v4 as uuidv4 } from "uuid";
7 |
8 | export type TOC = { node: Node; level: number }[];
9 |
10 | declare module "@tiptap/core" {
11 | interface Commands {
12 | tableOfContents: {
13 | /**
14 | * updates the table of contents
15 | */
16 | updateTOC: () => ReturnType;
17 | };
18 | }
19 | }
20 |
21 | const TableOfContents = Extension.create({
22 | name: "tableOfContents",
23 | addGlobalAttributes() {
24 | return [
25 | {
26 | types: ["heading"],
27 | attributes: {
28 | id: {
29 | isRequired: true,
30 | renderHTML(attributes) {
31 | return { id: attributes.id };
32 | },
33 | parseHTML: (element) => {
34 | element.getAttribute("id") || uuidv4();
35 | },
36 | },
37 | },
38 | },
39 | ];
40 | },
41 |
42 | addStorage() {
43 | return {
44 | toc: [],
45 | };
46 | },
47 |
48 | onTransaction({ transaction: tr }: { transaction: Transaction }) {
49 | if (!tr.docChanged || tr.getMeta("tocUpdated")) return;
50 |
51 | let runUpdate = false;
52 | if (!runUpdate) {
53 | for (let step of tr.steps) {
54 | if (!(step instanceof ReplaceStep || step instanceof ReplaceAroundStep))
55 | continue;
56 | const { from, to } = step;
57 |
58 | //@ts-ignore
59 | // check for new inserted headings
60 | for (let node of step.slice.content.content as Node[]) {
61 | if (node.type.name == "heading") {
62 | runUpdate = true;
63 | break;
64 | }
65 | }
66 |
67 | if (!runUpdate) {
68 | // check for updated/deleted headings
69 | tr.before.nodesBetween(from, to, (node) => {
70 | if (runUpdate) return false;
71 | if (node.type.name == "heading") runUpdate = true;
72 | });
73 | }
74 | }
75 | }
76 |
77 | if (runUpdate) {
78 | this.editor.commands.updateTOC();
79 | }
80 | },
81 |
82 | onCreate() {
83 | this.editor.commands.updateTOC();
84 | },
85 |
86 | addCommands() {
87 | return {
88 | updateTOC:
89 | () =>
90 | ({ tr }) => {
91 | const toc: TOC = [];
92 | let prevLevel: number | null = null;
93 |
94 | tr.setMeta("tocUpdated", true);
95 | tr.setMeta("addToHistory", false);
96 |
97 | tr.doc.descendants((node, pos) => {
98 | if (node.type.name != "heading") return;
99 | let nodeId = node.attrs.id;
100 |
101 | if (!nodeId) {
102 | nodeId = uuidv4();
103 | tr.setNodeAttribute(pos, "id", nodeId);
104 | }
105 |
106 | let currLvl;
107 | if (prevLevel != null) {
108 | let lastVal = toc[toc.length - 1].level;
109 | currLvl =
110 | node.attrs.level < prevLevel
111 | ? node.attrs.level
112 | : node.attrs.level == prevLevel
113 | ? lastVal
114 | : lastVal + 1;
115 | } else {
116 | currLvl = 1;
117 | }
118 | prevLevel = node.attrs.level;
119 |
120 | let headingNode =
121 | node.attrs.id != nodeId
122 | ? node.type.create(
123 | { ...node.attrs, id: nodeId },
124 | node.content,
125 | node.marks,
126 | )
127 | : node;
128 |
129 | toc.push({
130 | level: currLvl,
131 | node: headingNode,
132 | });
133 | });
134 |
135 | this.storage.toc = toc;
136 | return true;
137 | },
138 | };
139 | },
140 | });
141 |
142 | export default TableOfContents;
143 |
--------------------------------------------------------------------------------
/src/components/Editor/hasLink.ts:
--------------------------------------------------------------------------------
1 | import { Mark, Node } from "@tiptap/core";
2 |
3 | export function isLink(marks: Mark[]) {
4 | for (let i = 0; i < marks.length; i++) {
5 | //@ts-ignore
6 | if (marks[i].type?.name == "link") {
7 | return true;
8 | }
9 | }
10 | return false;
11 | }
12 |
13 | export function getLinkText(
14 | nodeBefore: Node | undefined,
15 | nodeAfter: Node | undefined
16 | ) {
17 | let text = "";
18 | if (nodeBefore) {
19 | //@ts-ignore
20 | if (isLink(nodeBefore.marks)) {
21 | //@ts-ignore
22 | text += nodeBefore.text;
23 | }
24 | }
25 |
26 | if (nodeAfter) {
27 | //@ts-ignore
28 | if (isLink(nodeAfter.marks)) {
29 | //@ts-ignore
30 | text += nodeAfter.text;
31 | }
32 | }
33 | return text;
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/Main/AddProject.tsx:
--------------------------------------------------------------------------------
1 | import { Input } from "../ui/input";
2 | import { Label } from "../ui/label";
3 | import { Button } from "../ui/button";
4 | import { createProject } from "@/utils/appStore";
5 | import { open } from "@tauri-apps/api/dialog";
6 | import { ReactNode, useRef, useState } from "react";
7 | import {
8 | Dialog,
9 | DialogContent,
10 | DialogDescription,
11 | DialogHeader,
12 | DialogTitle,
13 | DialogTrigger,
14 | } from "@/components/ui/dialog";
15 | import useStore from "@/store/appStore";
16 |
17 | const defaultClass =
18 | "p-2 w-full rounded-md bg-primary text-secondary font-medium";
19 | interface props {
20 | children: ReactNode;
21 | className?: string;
22 | }
23 | const AddProject: React.FC = ({
24 | children,
25 | className = defaultClass,
26 | }) => {
27 | const setProjects = useStore((s) => s.setProjects);
28 | const [error, setError] = useState();
29 | const nameRef = useRef(null);
30 | const [dir, setDir] = useState(null);
31 |
32 | async function searchForDirectory() {
33 | try {
34 | const result = await open({
35 | directory: true,
36 | multiple: false,
37 | });
38 | if (result) {
39 | setDir(result as string);
40 | } else {
41 | console.log("No directory selected.");
42 | return null;
43 | }
44 | } catch (error) {
45 | console.error("Error while selecting directory:", error);
46 | return null;
47 | }
48 | }
49 | const submitHandler = async () => {
50 | const name = nameRef.current?.value;
51 | if (!dir || !name) {
52 | setError("Please make sure to enter both name and directory values");
53 | return;
54 | }
55 |
56 | const { projects, newProjectId } = await createProject({ dir, name });
57 | setProjects(projects);
58 |
59 | window.location.assign(`/project/${newProjectId}`);
60 | };
61 | return (
62 |
106 | );
107 | };
108 | export default AddProject;
109 |
--------------------------------------------------------------------------------
/src/components/Main/Apps.tsx:
--------------------------------------------------------------------------------
1 | import { deleteProject } from "@/utils/appStore";
2 |
3 | import { useEffect, useState } from "react";
4 | import Projects from "./Projects";
5 | import EmptyProject from "./EmptyProject";
6 | import AddProject from "./AddProject";
7 | import useStore from "@/store/appStore";
8 |
9 | const Apps = () => {
10 | const { projects, setProjects } = useStore((s) => ({
11 | projects: s.projects,
12 | setProjects: s.setProjects,
13 | }));
14 | const [empty, setEmpty] = useState(false);
15 | useEffect(() => {
16 | if (projects && Object.keys(projects).length == 0) {
17 | setEmpty(true);
18 | } else {
19 | setEmpty(false);
20 | }
21 | }, [projects]);
22 |
23 | async function deleteHandler(id: string) {
24 | const res = await deleteProject(id);
25 | setProjects(res);
26 | }
27 | if (!projects) return;
28 | return (
29 |
30 | {empty ? (
31 |
32 | ) : (
33 |
34 | )}
35 |
Add Project
36 |
37 | );
38 | };
39 | export default Apps;
40 |
--------------------------------------------------------------------------------
/src/components/Main/EmptyProject.tsx:
--------------------------------------------------------------------------------
1 | import docSrc from "/docs.svg";
2 |
3 | const EmptyProject = () => {
4 | return (
5 |
16 | );
17 | };
18 | export default EmptyProject;
19 |
--------------------------------------------------------------------------------
/src/components/Main/Projects.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Popover,
3 | PopoverContent,
4 | PopoverTrigger,
5 | } from "@/components/ui/popover";
6 | import type { Projects } from "@/utils/types";
7 | import { TbTrash } from "react-icons/tb";
8 | import { Link } from "react-router-dom";
9 | interface props {
10 | projects: Projects | [];
11 | deleteHandler: (id: string) => void;
12 | }
13 | const Projects: React.FC = ({ projects, deleteHandler }) => {
14 | return (
15 |
16 | {Object.entries(projects).map((p) => (
17 |
21 |
22 |
{p[1].name}
23 |
{p[1].dir}
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | Are you sure?
32 |
38 |
39 |
40 |
41 |
42 | ))}
43 |
44 | );
45 | };
46 | export default Projects;
47 |
--------------------------------------------------------------------------------
/src/components/Option.tsx:
--------------------------------------------------------------------------------
1 | import { IoIosArrowForward } from "react-icons/io";
2 | interface props {
3 | children: string;
4 | onClick: () => void;
5 | }
6 | const Option: React.FC = ({ children, onClick }) => {
7 | return (
8 |
14 | );
15 | };
16 | export default Option;
17 |
--------------------------------------------------------------------------------
/src/components/Project/App.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | readDir,
3 | type FileEntry,
4 | writeTextFile,
5 | exists,
6 | createDir,
7 | } from "@tauri-apps/api/fs";
8 |
9 | import { join } from "@tauri-apps/api/path";
10 | import {
11 | setCurrProject as storeVisitedProject,
12 | getProjects,
13 | } from "@/utils/appStore";
14 |
15 | import { useEffect, useState } from "react";
16 | import Editor from "../Editor/Editor";
17 | import { MdKeyboardDoubleArrowLeft } from "react-icons/md";
18 | import { Dir } from "@/utils/types";
19 | import Selector from "./Selector";
20 | import { BsHouse } from "react-icons/bs";
21 | import { isMacOS } from "@tiptap/core";
22 | import CommandMenu from "../Settings/CommandMenu";
23 | import useStore from "@/store/appStore";
24 | import { FileInfo, getFileMeta } from "@/utils/getFileMeta";
25 | import Root from "./FileTree/Root";
26 | import { Link } from "react-router-dom";
27 |
28 | interface props {
29 | project: Dir;
30 | }
31 | const App: React.FC = ({ project }) => {
32 | const {
33 | files,
34 | setFiles,
35 | currFile,
36 | setCurrFile,
37 | setCurrProject,
38 | setProjects,
39 | } = useStore((s) => ({
40 | files: s.files,
41 | setFiles: s.setFiles,
42 | currFile: s.currFile,
43 | setCurrFile: s.setCurrFile,
44 | setCurrProject: s.setCurrProject,
45 | setProjects: s.setProjects,
46 | }));
47 |
48 | const [collapse, setCollapse] = useState(false);
49 | async function getFiles(path: string) {
50 | const entries = await readDir(path, {
51 | recursive: true,
52 | });
53 |
54 | async function processEntries(entries: FileEntry[], arr: FileInfo[]) {
55 | for (const entry of entries) {
56 | if (entry.name?.startsWith(".")) {
57 | continue;
58 | }
59 | if (entry.children) {
60 | let subArr: any[] = [];
61 | processEntries(entry.children, subArr);
62 | arr.push({
63 | ...entry,
64 | children: subArr,
65 | meta: await getFileMeta(entry),
66 | });
67 | } else {
68 | if (!entry.name?.endsWith(".md")) {
69 | continue;
70 | }
71 | arr.push({ ...entry, meta: await getFileMeta(entry) });
72 | }
73 | }
74 | }
75 | const files: FileInfo[] = [];
76 | await processEntries(entries, files);
77 | setFiles(files);
78 | }
79 | async function getProject() {
80 | await getFiles(project.dir);
81 | setProjects(await getProjects());
82 | }
83 | useEffect(() => {
84 | setCurrFile(undefined);
85 | storeVisitedProject(project);
86 | setCurrProject(project);
87 | getProject();
88 | }, [project]);
89 |
90 | async function addFileHandler(path: string, filename: string) {
91 | if (!filename.endsWith(".md")) filename += ".md";
92 | const newfilePath = await join(path, filename);
93 | const folder = await join(newfilePath, "../");
94 | if (!(await exists(folder))) {
95 | await createDir(folder, { recursive: true });
96 | }
97 | if (newfilePath.endsWith(".md")) {
98 | await writeTextFile(newfilePath, "");
99 | }
100 | await getFiles(project!.dir);
101 | }
102 | if (!project) return;
103 | return (
104 |
105 |
106 |
107 |
112 |
116 |
120 |
121 |
122 |
setCollapse((p) => !p)}
126 | >
127 |
128 |
129 |
130 |
131 |
135 |
136 |
140 |
141 |
142 |
143 |
144 |
145 | {currFile && (
146 |
151 | )}
152 |
153 |
154 | );
155 | };
156 |
157 | export default App;
158 |
--------------------------------------------------------------------------------
/src/components/Project/FileTree/File.tsx:
--------------------------------------------------------------------------------
1 | import useStore from "@/store/appStore";
2 |
3 | import { showMenu } from "tauri-plugin-context-menu";
4 | import { removeFile, renameFile, type FileEntry } from "@tauri-apps/api/fs";
5 | import { confirm } from "@tauri-apps/api/dialog";
6 | import { useRef, useState } from "react";
7 | import { join, resolveResource } from "@tauri-apps/api/path";
8 | import removePath from "@/utils/removePath";
9 |
10 | interface props {
11 | file: FileEntry;
12 | }
13 | const File: React.FC = ({ file }) => {
14 | const nameRef = useRef(null);
15 | const [showInput, setShowInput] = useState(false);
16 | const { currFile, fetchDir, setCurrFile, files, setFiles } = useStore(
17 | (s) => ({
18 | currFile: s.currFile,
19 | setCurrFile: s.setCurrFile,
20 | setFiles: s.setFiles,
21 | files: s.files,
22 | fetchDir: s.fetchDir,
23 | }),
24 | );
25 |
26 | async function rename() {
27 | let name = nameRef.current?.value;
28 | if (!name) return;
29 |
30 | if (!name.endsWith(".md")) name += ".md";
31 | const newPath = await join(file.path, "../", name);
32 | await renameFile(file.path!, newPath);
33 | await fetchDir();
34 | setShowInput(false);
35 | if (currFile?.path == file.path) {
36 | setCurrFile({ path: newPath, name, children: [] });
37 | }
38 | }
39 | async function deleteFile() {
40 | const confirmed = await confirm("Are you sure?", `Delete ${file.name}`);
41 | if (!confirmed) return;
42 | await removeFile(file.path);
43 | setFiles(removePath(file.path, files));
44 | }
45 | return (
46 | {
48 | e.preventDefault();
49 | const pencil = await resolveResource("assets/pencil.svg");
50 | const trash = await resolveResource("assets/trash.svg");
51 | showMenu({
52 | pos: { x: e.clientX, y: e.clientY },
53 | items: [
54 | {
55 | label: "Rename",
56 | event: () => setShowInput(true),
57 | icon: {
58 | path: pencil,
59 | width: 12,
60 | height: 12,
61 | },
62 | },
63 | {
64 | label: "Delete",
65 | event: deleteFile,
66 | icon: {
67 | path: trash,
68 | width: 12,
69 | height: 12,
70 | },
71 | },
72 | ],
73 | });
74 | }}
75 | className={` flex group items-center justify-between -mx-5 px-5 py-2 ${currFile?.path == file.path && "bg-accent"} cursor-pointer has-[.dots:hover]:bg-opacity-0 hover:bg-accent `}
76 | onClick={() => setCurrFile(file)}
77 | key={file.path}
78 | >
79 | {showInput ? (
80 |
94 | ) : (
95 |
96 | {file.name}
97 |
98 | )}
99 |
100 | );
101 | };
102 | export default File;
103 |
--------------------------------------------------------------------------------
/src/components/Project/FileTree/Root.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useEffect } from "react";
2 | import { TiSortAlphabetically } from "react-icons/ti";
3 | import File from "./File";
4 | import CreateFile from "../createFile";
5 | import { FileInfo } from "@/utils/getFileMeta";
6 | import Tree from "./Tree";
7 | import { MdFilterList, MdOutlineEditCalendar } from "react-icons/md";
8 | import {
9 | Popover,
10 | PopoverContent,
11 | PopoverTrigger,
12 | } from "@/components/ui/popover";
13 | import {
14 | FaRegCalendarPlus,
15 | FaSortAmountDownAlt,
16 | FaSortAmountUp,
17 | } from "react-icons/fa";
18 | import SortItem from "./SortItem";
19 | import { SortBy, SortType } from "@/utils/types";
20 | import useStore from "@/store/appStore";
21 |
22 | interface props {
23 | file: FileInfo;
24 | addFile: (path: string, filename: string) => Promise;
25 | }
26 | const Root: React.FC = ({ file, addFile }) => {
27 | const { sortBy, sortType, setSortInfo } = useStore((s) => ({
28 | ...s.sortInfo,
29 | setSortInfo: s.setSortInfo,
30 | }));
31 | const filenameRef = useRef(null);
32 | const [create, setCreate] = useState(false);
33 | const [sortedFiles, setSortedFiles] = useState();
34 |
35 | function createHandler() {
36 | setCreate((p) => !p);
37 | }
38 | function compare(res: boolean) {
39 | if (sortType == SortType.Asc) {
40 | return res ? -1 : 1;
41 | }
42 | return res ? 1 : -1;
43 | }
44 | function sortFn(a: FileInfo, b: FileInfo) {
45 | let res = 0;
46 | switch (sortBy) {
47 | case SortBy.Name: {
48 | if (!a.name || !b.name) break;
49 | res = compare(a.name < b.name);
50 | break;
51 | }
52 | case SortBy.UpdatedAt: {
53 | if (!a.meta?.updated_at || !b.meta?.updated_at) break;
54 | res = compare(
55 | a.meta.updated_at.secs_since_epoch >
56 | b.meta.updated_at.secs_since_epoch,
57 | );
58 | break;
59 | }
60 |
61 | case SortBy.CreatedAt: {
62 | if (!a.meta?.created_at || !b.meta?.created_at) break;
63 | res = compare(
64 | a.meta.created_at.secs_since_epoch >
65 | b.meta.created_at.secs_since_epoch,
66 | );
67 | break;
68 | }
69 | }
70 | a.children?.sort(sortFn);
71 | b.children?.sort(sortFn);
72 | return res;
73 | }
74 | useEffect(() => {
75 | if (!file.children) return;
76 | file.children?.sort(sortFn);
77 | setSortedFiles([...file.children]);
78 | }, [sortBy, sortType, file.children]);
79 | return (
80 |
81 |
82 |
83 |
Files
84 |
85 |
86 |
87 |
88 |
89 |
90 |
95 | Sort by:
96 |
97 |
99 | setSortInfo({ sortBy: SortBy.Name, sortType })
100 | }
101 | active={sortBy == SortBy.Name}
102 | >
103 |
104 | Name
105 |
106 |
107 |
109 | setSortInfo({ sortBy: SortBy.CreatedAt, sortType })
110 | }
111 | active={sortBy == SortBy.CreatedAt}
112 | >
113 |
114 | Created At
115 |
116 |
117 |
119 | setSortInfo({ sortBy: SortBy.UpdatedAt, sortType })
120 | }
121 | active={sortBy == SortBy.UpdatedAt}
122 | >
123 |
124 | Updated At
125 |
126 |
127 |
128 |
129 |
130 | Sort type:
131 |
132 |
133 |
135 | setSortInfo({ sortBy, sortType: SortType.Asc })
136 | }
137 | active={sortType == SortType.Asc}
138 | >
139 |
140 | Ascending
141 |
142 |
143 |
145 | setSortInfo({ sortBy, sortType: SortType.Desc })
146 | }
147 | active={sortType == SortType.Desc}
148 | >
149 |
150 | Descending
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 | {sortedFiles?.map((file) =>
162 | file.children ? (
163 |
164 | ) : (
165 |
166 | ),
167 | )}
168 |
169 |
170 | {create && (
171 |
191 | )}
192 |
193 |
194 | );
195 | };
196 | export default Root;
197 |
--------------------------------------------------------------------------------
/src/components/Project/FileTree/SortItem.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react";
2 | import { IoMdCheckmark } from "react-icons/io";
3 |
4 | interface props {
5 | active: boolean;
6 | children: ReactNode;
7 | onClick: () => void;
8 | }
9 | const SortItem: React.FC = ({ active, children, onClick }) => {
10 | return (
11 |
21 | );
22 | };
23 |
24 | export default SortItem;
25 |
--------------------------------------------------------------------------------
/src/components/Project/FileTree/Tree.tsx:
--------------------------------------------------------------------------------
1 | import useStore from "@/store/appStore";
2 | import { useState, useRef } from "react";
3 | import { removeDir } from "@tauri-apps/api/fs";
4 | import { confirm } from "@tauri-apps/api/dialog";
5 | import { showMenu } from "tauri-plugin-context-menu";
6 | import File from "./File";
7 | import { IoIosArrowForward } from "react-icons/io";
8 | import CreateFile from "../createFile";
9 | import { FileInfo } from "@/utils/getFileMeta";
10 | import removePath from "@/utils/removePath";
11 | import { resolveResource } from "@tauri-apps/api/path";
12 |
13 | interface props {
14 | file: FileInfo;
15 | addFile: (path: string, filename: string) => Promise;
16 | }
17 | const Tree: React.FC = ({ file, addFile }) => {
18 | const { setFiles, files } = useStore((s) => ({
19 | setFiles: s.setFiles,
20 | files: s.files,
21 | }));
22 | const [toggle, setToggle] = useState(false);
23 | const filenameRef = useRef(null);
24 | const [create, setCreate] = useState(false);
25 | function createHandler() {
26 | setToggle(true);
27 | setCreate((p) => !p);
28 | }
29 |
30 | async function deleteFile() {
31 | const confirmed = await confirm("Are you sure?", `Delete ${file.name}`);
32 | if (!confirmed) return;
33 | await removeDir(file.path, { recursive: true });
34 | setFiles(removePath(file.path, files));
35 | }
36 | return (
37 |
38 |
{
40 | e.preventDefault();
41 | const trash = await resolveResource("assets/trash.svg");
42 | showMenu({
43 | pos: { x: e.clientX, y: e.clientY },
44 | items: [
45 | {
46 | label: "Delete",
47 | event: deleteFile,
48 | icon: {
49 | path: trash,
50 | width: 12,
51 | height: 12,
52 | },
53 | },
54 | ],
55 | });
56 | }}
57 | className="flex justify-between items-center gap-2 cursor-pointer -mx-5 group has-[:not(.addFile:hover)]:hover:bg-accent has-[.addFile:hover]:hover:bg-opacity-0 pr-3"
58 | key={file.path}
59 | >
60 |
setToggle((p) => !p)}
63 | >
64 |
69 |
{file.name}
70 |
71 |
72 |
73 |
74 | {toggle && (
75 |
76 |
77 | {file.children?.map((file) =>
78 | file.children ? (
79 |
80 | ) : (
81 |
82 | ),
83 | )}
84 |
85 |
86 | {create && (
87 |
107 | )}
108 |
109 | )}
110 |
111 | );
112 | };
113 | export default Tree;
114 |
--------------------------------------------------------------------------------
/src/components/Project/Selector.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Popover,
3 | PopoverContent,
4 | PopoverTrigger,
5 | } from "@/components/ui/popover";
6 | import { useState } from "react";
7 | import { BsFolder2Open } from "react-icons/bs";
8 | import { Link } from "react-router-dom";
9 | import CreateProject from "../Main/AddProject";
10 | import { MdAdd } from "react-icons/md";
11 | import useStore from "@/store/appStore";
12 |
13 | const Selector = () => {
14 | const { projects, currProject } = useStore((s) => ({
15 | projects: s.projects,
16 | currProject: s.currProject,
17 | }));
18 | const [open, setOpen] = useState(false);
19 |
20 | if (!currProject) return;
21 | return (
22 | setOpen(e)}>
23 |
27 |
28 |
29 |
{currProject.name}
30 |
31 |
32 | {currProject.dir}
33 |
34 |
35 |
36 |
37 | {Object.entries(projects).map((p) => (
38 |
setOpen(false)}
42 | className={`block border-b border-gray-300 py-4 px-5 last:border-b-0 hover:bg-neutral-200 hover:cursor-pointer ${p[1].dir == currProject.dir && "bg-neutral-200"
43 | }`}
44 | >
45 |
46 |
47 |
{p[1].name}
48 |
49 |
50 | {p[1].dir}
51 |
52 |
53 | ))}
54 |
55 |
56 |
57 |
58 | Add Project
59 |
60 |
61 |
62 |
63 | );
64 | };
65 | export default Selector;
66 |
--------------------------------------------------------------------------------
/src/components/Project/createFile.tsx:
--------------------------------------------------------------------------------
1 | import { HiPlus } from "react-icons/hi2";
2 |
3 | interface props {
4 | onClick: () => void;
5 | root?: boolean;
6 | }
7 | const CreateFile: React.FC = ({ onClick, root }) => {
8 | return (
9 |
15 |
16 |
17 | );
18 | };
19 | export default CreateFile;
20 |
--------------------------------------------------------------------------------
/src/components/Settings/AppSettings.tsx:
--------------------------------------------------------------------------------
1 | import { appWindow } from "@tauri-apps/api/window";
2 | import { useEffect, useState } from "react";
3 | import {
4 | Dialog,
5 | DialogContent,
6 | DialogDescription,
7 | DialogHeader,
8 | DialogTitle,
9 | } from "@/components/ui/dialog";
10 | import {
11 | Select,
12 | SelectContent,
13 | SelectGroup,
14 | SelectItem,
15 | SelectTrigger,
16 | SelectValue,
17 | } from "@/components/ui/select";
18 | import { useTheme, Theme } from "@/ThemeProvider";
19 | import { Switch } from "../ui/switch";
20 | import useStore from "@/store/appStore";
21 |
22 | const AppSettings = () => {
23 | const { setTheme } = useTheme();
24 | const { settings, setSettings } = useStore((s) => ({
25 | settings: s.settings,
26 | setSettings: s.setSettings,
27 | }));
28 |
29 | const [open, setOpen] = useState(false);
30 | useEffect(() => {
31 | const unlisten = appWindow.onMenuClicked(({ payload: menuId }) => {
32 | if (menuId == "settings") {
33 | setOpen(true);
34 | }
35 | });
36 | return () => {
37 | unlisten.then((f) => f());
38 | };
39 | }, []);
40 | return (
41 |
96 | );
97 | };
98 | export default AppSettings;
99 |
--------------------------------------------------------------------------------
/src/components/Settings/CommandMenu.tsx:
--------------------------------------------------------------------------------
1 | import { CiFolderOn } from "react-icons/ci";
2 | import { PiFileTextThin } from "react-icons/pi";
3 | import {
4 | CommandDialog,
5 | CommandEmpty,
6 | CommandGroup,
7 | CommandInput,
8 | CommandItem,
9 | CommandList,
10 | } from "@/components/ui/command";
11 | import useStore from "@/store/appStore";
12 | import { FileEntry } from "@tauri-apps/api/fs";
13 | import { useState, useEffect, memo, useRef } from "react";
14 | import { useNavigate } from "react-router-dom";
15 | import { useTheme } from "@/ThemeProvider";
16 | import { Moon, Sun, SunMoon } from "lucide-react";
17 |
18 | const Files = ({
19 | files,
20 | close,
21 | }: {
22 | files?: FileEntry[];
23 | close: () => void;
24 | }) => {
25 | const { setCurrFile, projectDir } = useStore((s) => ({
26 | setCurrFile: s.setCurrFile,
27 | projectDir: s.currProject!.dir,
28 | }));
29 | return (
30 | <>
31 | {files?.map((file) =>
32 | file.name?.endsWith(".md") ? (
33 | {
36 | setCurrFile(file);
37 | close();
38 | }}
39 | className="space-x-1"
40 | >
41 |
42 | {file.path.replace(projectDir, "")}
43 |
44 | ) : (
45 |
46 | ),
47 | )}
48 | >
49 | );
50 | };
51 |
52 | const CommandMenu: React.FC = () => {
53 | const { setTheme } = useTheme();
54 | const navigate = useNavigate();
55 | const elementRef = useRef(null);
56 | const [open, setOpen] = useState(false);
57 | const { files, projects } = useStore((s) => ({
58 | files: s.files,
59 | projects: s.projects,
60 | }));
61 | useEffect(() => {
62 | const down = (e: KeyboardEvent) => {
63 | if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
64 | e.preventDefault();
65 | setOpen((open) => !open);
66 | }
67 | };
68 | document.addEventListener("keydown", down);
69 | return () => document.removeEventListener("keydown", down);
70 | }, []);
71 |
72 | function close() {
73 | setOpen(false);
74 | }
75 | return (
76 |
77 | {
80 | if (e.key === "Tab") {
81 | e.preventDefault();
82 | const arrowDownEvent = new KeyboardEvent("keydown", {
83 | bubbles: true,
84 | cancelable: true,
85 | key: "ArrowDown",
86 | code: "ArrowDown",
87 | });
88 | elementRef.current?.dispatchEvent(arrowDownEvent);
89 | }
90 | }}
91 | />
92 |
93 | No results found.
94 |
95 |
96 |
97 |
98 | {Object.entries(projects).map((p) => (
99 | {
103 | navigate(`/project/${p[0]}`);
104 | close();
105 | }}
106 | >
107 |
108 | {p[1].name}
109 |
110 | ))}
111 |
112 |
113 | {
116 | setTheme("light");
117 | close();
118 | }}
119 | >
120 |
121 | Light Theme
122 |
123 | {
126 | setTheme("dark");
127 | close();
128 | }}
129 | >
130 |
131 | Dark Theme
132 |
133 |
134 | {
137 | setTheme("system");
138 | close();
139 | }}
140 | >
141 |
142 | System Theme
143 |
144 |
145 |
146 |
147 | );
148 | };
149 |
150 | export default memo(CommandMenu);
151 |
--------------------------------------------------------------------------------
/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
3 |
4 | import { cn } from "@/lib/utils"
5 | import { buttonVariants } from "@/components/ui/button"
6 |
7 | const AlertDialog = AlertDialogPrimitive.Root
8 |
9 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger
10 |
11 | const AlertDialogPortal = AlertDialogPrimitive.Portal
12 |
13 | const AlertDialogOverlay = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef
16 | >(({ className, ...props }, ref) => (
17 |
25 | ))
26 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
27 |
28 | const AlertDialogContent = React.forwardRef<
29 | React.ElementRef,
30 | React.ComponentPropsWithoutRef
31 | >(({ className, ...props }, ref) => (
32 |
33 |
34 |
42 |
43 | ))
44 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
45 |
46 | const AlertDialogHeader = ({
47 | className,
48 | ...props
49 | }: React.HTMLAttributes) => (
50 |
57 | )
58 | AlertDialogHeader.displayName = "AlertDialogHeader"
59 |
60 | const AlertDialogFooter = ({
61 | className,
62 | ...props
63 | }: React.HTMLAttributes) => (
64 |
71 | )
72 | AlertDialogFooter.displayName = "AlertDialogFooter"
73 |
74 | const AlertDialogTitle = React.forwardRef<
75 | React.ElementRef,
76 | React.ComponentPropsWithoutRef
77 | >(({ className, ...props }, ref) => (
78 |
83 | ))
84 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
85 |
86 | const AlertDialogDescription = React.forwardRef<
87 | React.ElementRef,
88 | React.ComponentPropsWithoutRef
89 | >(({ className, ...props }, ref) => (
90 |
95 | ))
96 | AlertDialogDescription.displayName =
97 | AlertDialogPrimitive.Description.displayName
98 |
99 | const AlertDialogAction = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
110 |
111 | const AlertDialogCancel = React.forwardRef<
112 | React.ElementRef,
113 | React.ComponentPropsWithoutRef
114 | >(({ className, ...props }, ref) => (
115 |
124 | ))
125 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
126 |
127 | export {
128 | AlertDialog,
129 | AlertDialogPortal,
130 | AlertDialogOverlay,
131 | AlertDialogTrigger,
132 | AlertDialogContent,
133 | AlertDialogHeader,
134 | AlertDialogFooter,
135 | AlertDialogTitle,
136 | AlertDialogDescription,
137 | AlertDialogAction,
138 | AlertDialogCancel,
139 | }
140 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent text-primary",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | },
34 | );
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean;
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button";
45 | return (
46 |
51 | );
52 | },
53 | );
54 | Button.displayName = "Button";
55 |
56 | export { Button, buttonVariants };
57 |
--------------------------------------------------------------------------------
/src/components/ui/command.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { type DialogProps } from "@radix-ui/react-dialog";
3 | import { Command as CommandPrimitive } from "cmdk";
4 | import { Search } from "lucide-react";
5 |
6 | import { cn } from "@/lib/utils";
7 | import { Dialog, DialogContent } from "@/components/ui/dialog";
8 |
9 | const Command = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 | ));
22 | Command.displayName = CommandPrimitive.displayName;
23 |
24 | interface CommandDialogProps extends DialogProps { }
25 |
26 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
27 | return (
28 |
35 | );
36 | };
37 |
38 | const CommandInput = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
43 |
44 |
52 |
53 | ));
54 |
55 | CommandInput.displayName = CommandPrimitive.Input.displayName;
56 |
57 | const CommandList = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, ...props }, ref) => (
61 |
66 | ));
67 |
68 | CommandList.displayName = CommandPrimitive.List.displayName;
69 |
70 | const CommandEmpty = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >((props, ref) => (
74 |
79 | ));
80 |
81 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
82 |
83 | const CommandGroup = React.forwardRef<
84 | React.ElementRef,
85 | React.ComponentPropsWithoutRef
86 | >(({ className, ...props }, ref) => (
87 |
95 | ));
96 |
97 | CommandGroup.displayName = CommandPrimitive.Group.displayName;
98 |
99 | const CommandSeparator = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ));
109 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
110 |
111 | const CommandItem = React.forwardRef<
112 | React.ElementRef,
113 | React.ComponentPropsWithoutRef
114 | >(({ className, ...props }, ref) => (
115 |
123 | ));
124 |
125 | CommandItem.displayName = CommandPrimitive.Item.displayName;
126 |
127 | const CommandShortcut = ({
128 | className,
129 | ...props
130 | }: React.HTMLAttributes) => {
131 | return (
132 |
139 | );
140 | };
141 | CommandShortcut.displayName = "CommandShortcut";
142 |
143 | export {
144 | Command,
145 | CommandDialog,
146 | CommandInput,
147 | CommandList,
148 | CommandEmpty,
149 | CommandGroup,
150 | CommandItem,
151 | CommandShortcut,
152 | CommandSeparator,
153 | };
154 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as DialogPrimitive from "@radix-ui/react-dialog";
3 | import { X } from "lucide-react";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const Dialog = DialogPrimitive.Root;
8 |
9 | const DialogTrigger = DialogPrimitive.Trigger;
10 |
11 | const DialogPortal = DialogPrimitive.Portal;
12 |
13 | const DialogClose = DialogPrimitive.Close;
14 |
15 | const DialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ));
28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
29 |
30 | const DialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, children, ...props }, ref) => (
34 |
35 |
36 |
44 | {children}
45 |
46 |
47 | Close
48 |
49 |
50 |
51 | ));
52 | DialogContent.displayName = DialogPrimitive.Content.displayName;
53 |
54 | const DialogHeader = ({
55 | className,
56 | ...props
57 | }: React.HTMLAttributes) => (
58 |
65 | );
66 | DialogHeader.displayName = "DialogHeader";
67 |
68 | const DialogFooter = ({
69 | className,
70 | ...props
71 | }: React.HTMLAttributes) => (
72 |
79 | );
80 | DialogFooter.displayName = "DialogFooter";
81 |
82 | const DialogTitle = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef
85 | >(({ className, ...props }, ref) => (
86 |
94 | ));
95 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
96 |
97 | const DialogDescription = React.forwardRef<
98 | React.ElementRef,
99 | React.ComponentPropsWithoutRef
100 | >(({ className, ...props }, ref) => (
101 |
106 | ));
107 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
108 |
109 | export {
110 | Dialog,
111 | DialogPortal,
112 | DialogOverlay,
113 | DialogClose,
114 | DialogTrigger,
115 | DialogContent,
116 | DialogHeader,
117 | DialogFooter,
118 | DialogTitle,
119 | DialogDescription,
120 | };
121 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
3 | import { Check, ChevronRight, Circle } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const DropdownMenu = DropdownMenuPrimitive.Root
8 |
9 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
10 |
11 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
12 |
13 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
14 |
15 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
16 |
17 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
18 |
19 | const DropdownMenuSubTrigger = React.forwardRef<
20 | React.ElementRef,
21 | React.ComponentPropsWithoutRef & {
22 | inset?: boolean
23 | }
24 | >(({ className, inset, children, ...props }, ref) => (
25 |
34 | {children}
35 |
36 |
37 | ))
38 | DropdownMenuSubTrigger.displayName =
39 | DropdownMenuPrimitive.SubTrigger.displayName
40 |
41 | const DropdownMenuSubContent = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef
44 | >(({ className, ...props }, ref) => (
45 |
53 | ))
54 | DropdownMenuSubContent.displayName =
55 | DropdownMenuPrimitive.SubContent.displayName
56 |
57 | const DropdownMenuContent = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, sideOffset = 4, ...props }, ref) => (
61 |
62 |
71 |
72 | ))
73 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
74 |
75 | const DropdownMenuItem = React.forwardRef<
76 | React.ElementRef,
77 | React.ComponentPropsWithoutRef & {
78 | inset?: boolean
79 | }
80 | >(({ className, inset, ...props }, ref) => (
81 |
90 | ))
91 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
92 |
93 | const DropdownMenuCheckboxItem = React.forwardRef<
94 | React.ElementRef,
95 | React.ComponentPropsWithoutRef
96 | >(({ className, children, checked, ...props }, ref) => (
97 |
106 |
107 |
108 |
109 |
110 |
111 | {children}
112 |
113 | ))
114 | DropdownMenuCheckboxItem.displayName =
115 | DropdownMenuPrimitive.CheckboxItem.displayName
116 |
117 | const DropdownMenuRadioItem = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, children, ...props }, ref) => (
121 |
129 |
130 |
131 |
132 |
133 |
134 | {children}
135 |
136 | ))
137 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
138 |
139 | const DropdownMenuLabel = React.forwardRef<
140 | React.ElementRef,
141 | React.ComponentPropsWithoutRef & {
142 | inset?: boolean
143 | }
144 | >(({ className, inset, ...props }, ref) => (
145 |
154 | ))
155 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
156 |
157 | const DropdownMenuSeparator = React.forwardRef<
158 | React.ElementRef,
159 | React.ComponentPropsWithoutRef
160 | >(({ className, ...props }, ref) => (
161 |
166 | ))
167 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
168 |
169 | const DropdownMenuShortcut = ({
170 | className,
171 | ...props
172 | }: React.HTMLAttributes) => {
173 | return (
174 |
178 | )
179 | }
180 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
181 |
182 | export {
183 | DropdownMenu,
184 | DropdownMenuTrigger,
185 | DropdownMenuContent,
186 | DropdownMenuItem,
187 | DropdownMenuCheckboxItem,
188 | DropdownMenuRadioItem,
189 | DropdownMenuLabel,
190 | DropdownMenuSeparator,
191 | DropdownMenuShortcut,
192 | DropdownMenuGroup,
193 | DropdownMenuPortal,
194 | DropdownMenuSub,
195 | DropdownMenuSubContent,
196 | DropdownMenuSubTrigger,
197 | DropdownMenuRadioGroup,
198 | }
199 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes { }
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | );
21 | },
22 | );
23 | Input.displayName = "Input";
24 |
25 | export { Input };
26 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const labelVariants = cva(
8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
9 | )
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ))
22 | Label.displayName = LabelPrimitive.Root.displayName
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as PopoverPrimitive from "@radix-ui/react-popover";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const Popover = PopoverPrimitive.Root;
7 |
8 | const PopoverTrigger = PopoverPrimitive.Trigger;
9 |
10 | const PopoverContent = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
14 |
15 |
25 |
26 | ));
27 | PopoverContent.displayName = PopoverPrimitive.Content.displayName;
28 |
29 | export { Popover, PopoverTrigger, PopoverContent };
30 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SelectPrimitive from "@radix-ui/react-select"
3 | import { Check, ChevronDown, ChevronUp } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const Select = SelectPrimitive.Root
8 |
9 | const SelectGroup = SelectPrimitive.Group
10 |
11 | const SelectValue = SelectPrimitive.Value
12 |
13 | const SelectTrigger = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef
16 | >(({ className, children, ...props }, ref) => (
17 | span]:line-clamp-1",
21 | className
22 | )}
23 | {...props}
24 | >
25 | {children}
26 |
27 |
28 |
29 |
30 | ))
31 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
32 |
33 | const SelectScrollUpButton = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, ...props }, ref) => (
37 |
45 |
46 |
47 | ))
48 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
49 |
50 | const SelectScrollDownButton = React.forwardRef<
51 | React.ElementRef,
52 | React.ComponentPropsWithoutRef
53 | >(({ className, ...props }, ref) => (
54 |
62 |
63 |
64 | ))
65 | SelectScrollDownButton.displayName =
66 | SelectPrimitive.ScrollDownButton.displayName
67 |
68 | const SelectContent = React.forwardRef<
69 | React.ElementRef,
70 | React.ComponentPropsWithoutRef
71 | >(({ className, children, position = "popper", ...props }, ref) => (
72 |
73 |
84 |
85 |
92 | {children}
93 |
94 |
95 |
96 |
97 | ))
98 | SelectContent.displayName = SelectPrimitive.Content.displayName
99 |
100 | const SelectLabel = React.forwardRef<
101 | React.ElementRef,
102 | React.ComponentPropsWithoutRef
103 | >(({ className, ...props }, ref) => (
104 |
109 | ))
110 | SelectLabel.displayName = SelectPrimitive.Label.displayName
111 |
112 | const SelectItem = React.forwardRef<
113 | React.ElementRef,
114 | React.ComponentPropsWithoutRef
115 | >(({ className, children, ...props }, ref) => (
116 |
124 |
125 |
126 |
127 |
128 |
129 |
130 | {children}
131 |
132 | ))
133 | SelectItem.displayName = SelectPrimitive.Item.displayName
134 |
135 | const SelectSeparator = React.forwardRef<
136 | React.ElementRef,
137 | React.ComponentPropsWithoutRef
138 | >(({ className, ...props }, ref) => (
139 |
144 | ))
145 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
146 |
147 | export {
148 | Select,
149 | SelectGroup,
150 | SelectValue,
151 | SelectTrigger,
152 | SelectContent,
153 | SelectLabel,
154 | SelectItem,
155 | SelectSeparator,
156 | SelectScrollUpButton,
157 | SelectScrollDownButton,
158 | }
159 |
--------------------------------------------------------------------------------
/src/components/ui/slider.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SliderPrimitive from "@radix-ui/react-slider"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Slider = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
19 |
20 |
21 |
22 |
23 | ))
24 | Slider.displayName = SliderPrimitive.Root.displayName
25 |
26 | export { Slider }
27 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SwitchPrimitives from "@radix-ui/react-switch"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Switch = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
23 |
24 | ))
25 | Switch.displayName = SwitchPrimitives.Root.displayName
26 |
27 | export { Switch }
28 |
--------------------------------------------------------------------------------
/src/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ToastPrimitives from "@radix-ui/react-toast";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 | import { X } from "lucide-react";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const ToastProvider = ToastPrimitives.Provider;
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ));
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
24 |
25 | const toastVariants = cva(
26 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
27 | {
28 | variants: {
29 | variant: {
30 | default: "border bg-background text-foreground",
31 | successfull:
32 | "border p-4 text-white bg-green-500 border-none xtext-foreground",
33 | destructive:
34 | "destructive group border-destructive bg-destructive text-destructive-foreground",
35 | },
36 | },
37 | defaultVariants: {
38 | variant: "default",
39 | },
40 | },
41 | );
42 |
43 | const Toast = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef &
46 | VariantProps
47 | >(({ className, variant, ...props }, ref) => {
48 | return (
49 |
54 | );
55 | });
56 | Toast.displayName = ToastPrimitives.Root.displayName;
57 |
58 | const ToastAction = React.forwardRef<
59 | React.ElementRef,
60 | React.ComponentPropsWithoutRef
61 | >(({ className, ...props }, ref) => (
62 |
70 | ));
71 | ToastAction.displayName = ToastPrimitives.Action.displayName;
72 |
73 | const ToastClose = React.forwardRef<
74 | React.ElementRef,
75 | React.ComponentPropsWithoutRef
76 | >(({ className, ...props }, ref) => (
77 |
86 |
87 |
88 | ));
89 | ToastClose.displayName = ToastPrimitives.Close.displayName;
90 |
91 | const ToastTitle = React.forwardRef<
92 | React.ElementRef,
93 | React.ComponentPropsWithoutRef
94 | >(({ className, ...props }, ref) => (
95 |
100 | ));
101 | ToastTitle.displayName = ToastPrimitives.Title.displayName;
102 |
103 | const ToastDescription = React.forwardRef<
104 | React.ElementRef,
105 | React.ComponentPropsWithoutRef
106 | >(({ className, ...props }, ref) => (
107 |
112 | ));
113 | ToastDescription.displayName = ToastPrimitives.Description.displayName;
114 |
115 | type ToastProps = React.ComponentPropsWithoutRef;
116 |
117 | type ToastActionElement = React.ReactElement;
118 |
119 | export {
120 | type ToastProps,
121 | type ToastActionElement,
122 | ToastProvider,
123 | ToastViewport,
124 | Toast,
125 | ToastTitle,
126 | ToastDescription,
127 | ToastClose,
128 | ToastAction,
129 | };
130 |
--------------------------------------------------------------------------------
/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Toast,
3 | ToastClose,
4 | ToastDescription,
5 | ToastProvider,
6 | ToastTitle,
7 | ToastViewport,
8 | } from "@/components/ui/toast"
9 | import { useToast } from "@/components/ui/use-toast"
10 |
11 | export function Toaster() {
12 | const { toasts } = useToast()
13 |
14 | return (
15 |
16 | {toasts.map(function ({ id, title, description, action, ...props }) {
17 | return (
18 |
19 |
20 | {title && {title}}
21 | {description && (
22 | {description}
23 | )}
24 |
25 | {action}
26 |
27 |
28 | )
29 | })}
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from "react"
3 |
4 | import type {
5 | ToastActionElement,
6 | ToastProps,
7 | } from "@/components/ui/toast"
8 |
9 | const TOAST_LIMIT = 1
10 | const TOAST_REMOVE_DELAY = 1000000
11 |
12 | type ToasterToast = ToastProps & {
13 | id: string
14 | title?: React.ReactNode
15 | description?: React.ReactNode
16 | action?: ToastActionElement
17 | }
18 |
19 | const actionTypes = {
20 | ADD_TOAST: "ADD_TOAST",
21 | UPDATE_TOAST: "UPDATE_TOAST",
22 | DISMISS_TOAST: "DISMISS_TOAST",
23 | REMOVE_TOAST: "REMOVE_TOAST",
24 | } as const
25 |
26 | let count = 0
27 |
28 | function genId() {
29 | count = (count + 1) % Number.MAX_SAFE_INTEGER
30 | return count.toString()
31 | }
32 |
33 | type ActionType = typeof actionTypes
34 |
35 | type Action =
36 | | {
37 | type: ActionType["ADD_TOAST"]
38 | toast: ToasterToast
39 | }
40 | | {
41 | type: ActionType["UPDATE_TOAST"]
42 | toast: Partial
43 | }
44 | | {
45 | type: ActionType["DISMISS_TOAST"]
46 | toastId?: ToasterToast["id"]
47 | }
48 | | {
49 | type: ActionType["REMOVE_TOAST"]
50 | toastId?: ToasterToast["id"]
51 | }
52 |
53 | interface State {
54 | toasts: ToasterToast[]
55 | }
56 |
57 | const toastTimeouts = new Map>()
58 |
59 | const addToRemoveQueue = (toastId: string) => {
60 | if (toastTimeouts.has(toastId)) {
61 | return
62 | }
63 |
64 | const timeout = setTimeout(() => {
65 | toastTimeouts.delete(toastId)
66 | dispatch({
67 | type: "REMOVE_TOAST",
68 | toastId: toastId,
69 | })
70 | }, TOAST_REMOVE_DELAY)
71 |
72 | toastTimeouts.set(toastId, timeout)
73 | }
74 |
75 | export const reducer = (state: State, action: Action): State => {
76 | switch (action.type) {
77 | case "ADD_TOAST":
78 | return {
79 | ...state,
80 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
81 | }
82 |
83 | case "UPDATE_TOAST":
84 | return {
85 | ...state,
86 | toasts: state.toasts.map((t) =>
87 | t.id === action.toast.id ? { ...t, ...action.toast } : t
88 | ),
89 | }
90 |
91 | case "DISMISS_TOAST": {
92 | const { toastId } = action
93 |
94 | // ! Side effects ! - This could be extracted into a dismissToast() action,
95 | // but I'll keep it here for simplicity
96 | if (toastId) {
97 | addToRemoveQueue(toastId)
98 | } else {
99 | state.toasts.forEach((toast) => {
100 | addToRemoveQueue(toast.id)
101 | })
102 | }
103 |
104 | return {
105 | ...state,
106 | toasts: state.toasts.map((t) =>
107 | t.id === toastId || toastId === undefined
108 | ? {
109 | ...t,
110 | open: false,
111 | }
112 | : t
113 | ),
114 | }
115 | }
116 | case "REMOVE_TOAST":
117 | if (action.toastId === undefined) {
118 | return {
119 | ...state,
120 | toasts: [],
121 | }
122 | }
123 | return {
124 | ...state,
125 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
126 | }
127 | }
128 | }
129 |
130 | const listeners: Array<(state: State) => void> = []
131 |
132 | let memoryState: State = { toasts: [] }
133 |
134 | function dispatch(action: Action) {
135 | memoryState = reducer(memoryState, action)
136 | listeners.forEach((listener) => {
137 | listener(memoryState)
138 | })
139 | }
140 |
141 | type Toast = Omit
142 |
143 | function toast({ ...props }: Toast) {
144 | const id = genId()
145 |
146 | const update = (props: ToasterToast) =>
147 | dispatch({
148 | type: "UPDATE_TOAST",
149 | toast: { ...props, id },
150 | })
151 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
152 |
153 | dispatch({
154 | type: "ADD_TOAST",
155 | toast: {
156 | ...props,
157 | id,
158 | open: true,
159 | onOpenChange: (open) => {
160 | if (!open) dismiss()
161 | },
162 | },
163 | })
164 |
165 | return {
166 | id: id,
167 | dismiss,
168 | update,
169 | }
170 | }
171 |
172 | function useToast() {
173 | const [state, setState] = React.useState(memoryState)
174 |
175 | React.useEffect(() => {
176 | listeners.push(setState)
177 | return () => {
178 | const index = listeners.indexOf(setState)
179 | if (index > -1) {
180 | listeners.splice(index, 1)
181 | }
182 | }
183 | }, [state])
184 |
185 | return {
186 | ...state,
187 | toast,
188 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
189 | }
190 | }
191 |
192 | export { useToast, toast }
193 |
--------------------------------------------------------------------------------
/src/hooks/useEditor.ts:
--------------------------------------------------------------------------------
1 | import CharacterCount from "@tiptap/extension-character-count";
2 | import BubbleMenu from "@tiptap/extension-bubble-menu";
3 | import { useEditor, ReactNodeViewRenderer } from "@tiptap/react";
4 | import Image from "@tiptap/extension-image";
5 | import Heading from "@tiptap/extension-heading";
6 | import BulletList from "@tiptap/extension-bullet-list";
7 | import ListItem from "@tiptap/extension-list-item";
8 | import OrderedList from "@tiptap/extension-ordered-list";
9 | import StarterKit from "@tiptap/starter-kit";
10 | import Code from "@tiptap/extension-code";
11 | import Placeholder from "@tiptap/extension-placeholder";
12 |
13 | import Table from "@tiptap/extension-table";
14 | import TableCell from "@tiptap/extension-table-cell";
15 | import TableHeader from "@tiptap/extension-table-header";
16 | import TableRow from "@tiptap/extension-table-row";
17 |
18 | import ImageView from "@/components/Editor/NodeViews/Image/Image";
19 | import CodeBlockLowlight from "@/components/Editor/extensions/CodeBlockLowlight";
20 | import { RichTextLink } from "@/components/Editor/extensions/link-text";
21 | import TableView from "@/components/Editor/NodeViews/TableView";
22 | import { DeleteCells } from "@/lib/tableShortcut";
23 | import TableOfContents from "@/components/Editor/extensions/table-of-contents";
24 | import Metadata from "@/components/Editor/extensions/metadata";
25 |
26 | import TaskItem from "@tiptap/extension-task-item";
27 | import TaskList from "@tiptap/extension-task-list";
28 | import { Editor } from "@tiptap/core";
29 |
30 | interface props {
31 | content: string;
32 | onUpdate: () => void;
33 | loadFile: (editor: Editor) => void;
34 | filePath: string;
35 | projectDir: string;
36 | assetsDir?: string;
37 | }
38 | const useTextEditor = ({
39 | content,
40 | onUpdate,
41 | loadFile,
42 | filePath,
43 | projectDir,
44 | }: props) => {
45 | const editor = useEditor({
46 | editorProps: {
47 | attributes: {
48 | class: `prose h-full`,
49 | },
50 | },
51 | extensions: [
52 | Metadata.configure({
53 | filePath: filePath,
54 | assetsFolder: "assets",
55 | projectDir,
56 | }),
57 | TaskList,
58 | TaskItem.configure({ nested: true }),
59 | Table.extend({
60 | addNodeView() {
61 | return ReactNodeViewRenderer(TableView, {
62 | contentDOMElementTag: "table",
63 | });
64 | },
65 | addInputRules() {
66 | return [
67 | {
68 | find: /table(\r\n|\r|\n)/,
69 | type: this.type,
70 | handler({ state, range, match, commands }) {
71 | const { tr } = state;
72 | const { $from } = state.selection;
73 | const start = range.from;
74 | let end = range.to;
75 |
76 | const isEmptyLine =
77 | $from.parent.textContent.trim() === match[0].slice(0, -1);
78 | if (isEmptyLine) {
79 | tr.delete(tr.mapping.map(start), tr.mapping.map(end));
80 | commands.insertTable({
81 | rows: 2,
82 | cols: 2,
83 | withHeaderRow: true,
84 | });
85 | }
86 | },
87 | },
88 | ];
89 | },
90 | addKeyboardShortcuts() {
91 | return {
92 | ...this.parent?.(),
93 | Backspace: DeleteCells,
94 | "Mod-Backspace": DeleteCells,
95 | Delete: DeleteCells,
96 | "Mod-Delete": DeleteCells,
97 | };
98 | },
99 | }),
100 | TableRow,
101 | TableHeader,
102 | TableCell,
103 | BubbleMenu.configure({
104 | element: document.querySelector(".menu") as HTMLElement,
105 | }),
106 | Image.extend({
107 | addNodeView() {
108 | return ReactNodeViewRenderer(ImageView);
109 | },
110 | addInputRules() {
111 | return [
112 | {
113 | find: /(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/,
114 | type: this.type,
115 | handler({ state, range, match }) {
116 | const { tr } = state;
117 | const { $from } = state.selection;
118 | const start = range.from;
119 | let end = range.to;
120 |
121 | const isEmptyLine =
122 | $from.parent.textContent.trim() === match[0].slice(0, -1);
123 | if (isEmptyLine) {
124 | //@ts-ignore
125 | const node = this.type.create({
126 | src: match[3],
127 | alt: match[2],
128 | title: match[4],
129 | });
130 | tr.insert(start - 1, node).delete(
131 | tr.mapping.map(start),
132 | tr.mapping.map(end),
133 | );
134 | }
135 | },
136 | },
137 | ];
138 | },
139 | }),
140 | CharacterCount,
141 | OrderedList,
142 | BulletList,
143 | ListItem,
144 | Code.configure({
145 | HTMLAttributes: {
146 | class: "code",
147 | },
148 | }),
149 | CodeBlockLowlight,
150 | TableOfContents,
151 | Heading,
152 | StarterKit.configure({
153 | orderedList: false,
154 | bulletList: false,
155 | listItem: false,
156 | codeBlock: false,
157 | code: false,
158 | heading: false,
159 | }),
160 |
161 | RichTextLink.configure({
162 | openOnClick: false,
163 | HTMLAttributes: {
164 | class: "link",
165 | },
166 | }),
167 | Placeholder.configure({
168 | placeholder: "Start writing here...",
169 | }),
170 | ],
171 | content,
172 | onUpdate,
173 | onCreate: ({ editor }) => {
174 | loadFile(editor);
175 | },
176 | });
177 |
178 | return editor;
179 | };
180 | export default useTextEditor;
181 |
--------------------------------------------------------------------------------
/src/lib/tableShortcut.ts:
--------------------------------------------------------------------------------
1 | import {
2 | findParentNodeClosestToPos,
3 | KeyboardShortcutCommand,
4 | } from "@tiptap/core";
5 |
6 | import { CellSelection } from "@tiptap/pm/tables";
7 |
8 | export function isCellSelection(value: unknown): value is CellSelection {
9 | return value instanceof CellSelection;
10 | }
11 |
12 | export const DeleteCells: KeyboardShortcutCommand = ({ editor }) => {
13 | const { selection } = editor.state;
14 |
15 | if (!isCellSelection(selection)) {
16 | return false;
17 | }
18 |
19 | let cellCount = 0;
20 | const table = findParentNodeClosestToPos(
21 | selection.ranges[0].$from,
22 | (node) => {
23 | return node.type.name === "table";
24 | },
25 | );
26 | let numRows = 0;
27 | let numCols = 0;
28 |
29 | table?.node.descendants((node) => {
30 | if (node.type.name === "table") {
31 | return false;
32 | }
33 | if (node.type.name == "tableRow") {
34 | //@ts-ignore
35 | numCols = node.content.content.length;
36 | numRows += 1;
37 | }
38 | if (["tableCell", "tableHeader"].includes(node.type.name)) {
39 | cellCount += 1;
40 | }
41 | });
42 |
43 | const allCellsSelected = cellCount === selection.ranges.length;
44 | if (allCellsSelected) {
45 | editor.commands.deleteTable();
46 | return true;
47 | } else if (selection.ranges.length == numRows) {
48 | // delete column
49 | editor.commands.deleteColumn();
50 | return true;
51 | } else if (selection.ranges.length == numCols) {
52 | // delete row
53 | editor.commands.deleteRow();
54 | return true;
55 | }
56 | return false;
57 | };
58 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import App from "./App.tsx";
4 | import "@/styles/globals.css";
5 | import {
6 | createBrowserRouter,
7 | redirect,
8 | RouterProvider,
9 | } from "react-router-dom";
10 | import { getProject, getCurrProject } from "@/utils/appStore";
11 | import { lazy } from "react";
12 | import { Toaster } from "./components/ui/toaster.tsx";
13 | import restoreState from "./store/restoreState.ts";
14 | import { ThemeProvider } from "./ThemeProvider.tsx";
15 | import AppSettings from "./components/Settings/AppSettings.tsx";
16 | const Project = lazy(() => import("./Project.tsx"));
17 |
18 | restoreState();
19 | const router = createBrowserRouter([
20 | {
21 | path: "/",
22 | element: ,
23 | loader: async (p) => {
24 | const home = new URLSearchParams(p.request.url.split("?")[1]).get("home");
25 | if (home) return null;
26 | const currProj = await getCurrProject();
27 | if (currProj) {
28 | return redirect(`/project/${currProj.id}`);
29 | }
30 | return null;
31 | },
32 | },
33 |
34 | {
35 | path: "/project/:id",
36 | loader: async ({ params }) => {
37 | const id = params.id as string;
38 | const project = await getProject(id);
39 | if (!project) return redirect("/?home=true");
40 | return { project };
41 | },
42 | element: ,
43 | },
44 | ]);
45 | ReactDOM.createRoot(document.getElementById("root")!).render(
46 |
47 |
48 |
49 |
50 |
51 |
52 | ,
53 | );
54 |
--------------------------------------------------------------------------------
/src/store/appStore.ts:
--------------------------------------------------------------------------------
1 | import { setCurrProject, setSortInfo } from "@/utils/appStore";
2 | import { FileInfo, getFileMeta } from "@/utils/getFileMeta";
3 | import { Dir, Projects, Settings, SortInfo } from "@/utils/types";
4 | import { FileEntry, readDir } from "@tauri-apps/api/fs";
5 | import { create } from "zustand";
6 | interface AppState {
7 | currProject?: Dir;
8 | projects: Projects;
9 | files: FileInfo[];
10 | currFile?: FileInfo;
11 | sortInfo?: SortInfo;
12 | settings: Settings;
13 |
14 | setCurrFile: (name?: FileInfo) => void;
15 | setProjects: (projects: Projects) => void;
16 | setCurrProject: (project: Dir) => void;
17 | setFiles: (files: FileInfo[]) => void;
18 | fetchDir: () => Promise;
19 | setSortInfo: (sortInfo: SortInfo) => Promise;
20 | setSettings: (settings: Settings) => void;
21 | }
22 | const useStore = create()((set, get) => ({
23 | currProject: undefined,
24 | projects: {},
25 | files: [],
26 | currFile: undefined,
27 | settings: localStorage.getItem("settings")
28 | ? JSON.parse(localStorage.getItem("settings")!)
29 | : { showTOC: true },
30 |
31 | setCurrFile: (currFile) => set(() => ({ currFile })),
32 | setCurrProject: async (project) => {
33 | set(() => ({ currProject: project }));
34 | await setCurrProject(project);
35 | },
36 |
37 | setProjects: (projects) => set(() => ({ projects })),
38 | setFiles: (files) => set(() => ({ files })),
39 |
40 | setSortInfo: async (sortInfo) => {
41 | set(() => ({
42 | sortInfo,
43 | }));
44 | await setSortInfo(sortInfo);
45 | },
46 | fetchDir: async () => {
47 | const currProject = get().currProject?.dir;
48 | if (!currProject) return;
49 | const entries = await readDir(currProject, {
50 | recursive: true,
51 | });
52 |
53 | async function processEntries(entries: FileEntry[], arr: FileInfo[]) {
54 | for (const entry of entries) {
55 | if (entry.name?.startsWith(".")) {
56 | continue;
57 | }
58 | if (entry.children) {
59 | let subArr: any[] = [];
60 | processEntries(entry.children, subArr);
61 | arr.push({
62 | ...entry,
63 | children: subArr,
64 | meta: await getFileMeta(entry),
65 | });
66 | } else {
67 | if (!entry.name?.endsWith(".md")) {
68 | continue;
69 | }
70 | arr.push({ ...entry, meta: await getFileMeta(entry) });
71 | }
72 | }
73 | }
74 | const files: FileInfo[] = [];
75 | await processEntries(entries, files);
76 | set(() => ({ files }));
77 | },
78 | setSettings: (settings) => {
79 | localStorage.setItem("settings", JSON.stringify(settings));
80 | set(() => ({ settings }));
81 | },
82 | }));
83 |
84 | export default useStore;
85 |
--------------------------------------------------------------------------------
/src/store/restoreState.ts:
--------------------------------------------------------------------------------
1 | import { getCurrProject, getProjects, getSortInfo } from "@/utils/appStore";
2 | import useStore from "./appStore";
3 |
4 | async function restoreState() {
5 | useStore.setState({
6 | projects: await getProjects(),
7 | sortInfo: (await getSortInfo()) ?? undefined,
8 | currProject: (await getCurrProject()) ?? undefined,
9 | });
10 | }
11 |
12 | export default restoreState;
13 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @import url("https://fonts.googleapis.com/css2?family=Poppins&display=swap");
2 | @import url("https://fonts.googleapis.com/css2?family=Inter&display=swap");
3 | @import url("https://fonts.googleapis.com/css2?family=Inter:wght@700&display=swap");
4 | @import url("https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500&display=swap");
5 |
6 | @tailwind base;
7 | @tailwind components;
8 | @tailwind utilities;
9 | .poppins {
10 | font-family: "Poppins", sans-serif;
11 | }
12 | @layer base {
13 | :root {
14 | --background: theme(colors.white);
15 | --foreground: theme(colors.black);
16 |
17 | --muted: 210 40% 96.1%;
18 | --muted-foreground: theme(colors.neutral.500);
19 |
20 | --popover: theme(colors.white);
21 | --popover-foreground: 222.2 84% 4.9%;
22 |
23 | --card: 0 0% 100%;
24 | --card-foreground: 222.2 84% 4.9%;
25 |
26 | --border: 214.3 31.8% 91.4%;
27 |
28 | --border: theme(colors.gray.200);
29 | --input: 214.3 31.8% 91.4%;
30 |
31 | --primary: theme(colors.neutral.700);
32 | --primary-foreground: theme(colors.white);
33 |
34 | --secondary: theme(colors.neutral.100);
35 | --secondary-foreground: 222.2 47.4% 11.2%;
36 |
37 | --accent: theme(colors.neutral.200);
38 | --accent-foreground: ;
39 |
40 | --destructive: 0 84.2% 60.2%;
41 | --destructive-foreground: 210 40% 98%;
42 |
43 | --ring: 215 20.2% 65.1%;
44 |
45 | --radius: 0.5rem;
46 |
47 | .code {
48 | background-color: #6161611a;
49 | color: #616161;
50 | }
51 |
52 | input {
53 | @apply text-black;
54 | @apply bg-white;
55 | }
56 | }
57 |
58 | .dark {
59 | --background: theme(colors.neutral.800);
60 | --foreground: theme(colors.white);
61 |
62 | --muted: 217.2 32.6% 17.5%;
63 | --muted-foreground: theme(colors.neutral.500);
64 |
65 | --popover: theme(colors.zinc.900);
66 | --popover-foreground: theme(colors.neutral.100);
67 |
68 | --card: 222.2 84% 4.9%;
69 | --card-foreground: 210 40% 98%;
70 |
71 | --border: theme(colors.neutral.700);
72 | --input: theme(colors.neutral.500);
73 |
74 | --primary: theme(colors.neutral.300);
75 | --primary-foreground: theme(colors.neutral.800);
76 |
77 | --secondary: theme(colors.neutral.800);
78 | --secondary-foreground: 210 40% 98%;
79 |
80 | --accent: theme(colors.neutral.700);
81 | /* --accent-foreground: ; */
82 |
83 | --destructive: 0 62.8% 30.6%;
84 | --destructive-foreground: 0 85.7% 97.3%;
85 |
86 | --ring: theme(colors.white);
87 |
88 | .code {
89 | background-color: #94929229;
90 | color: #949292;
91 | }
92 |
93 | input {
94 | @apply text-white;
95 | @apply bg-neutral-700;
96 | }
97 | }
98 | }
99 |
100 | @layer base {
101 | * {
102 | @apply border-border;
103 | }
104 | }
105 |
106 | body {
107 | max-width: 100vw;
108 | max-height: 100vh;
109 | @apply bg-background;
110 | @apply text-primary;
111 | }
112 |
113 | .ant-modal-content {
114 | border-radius: 0px !important;
115 | }
116 | .code {
117 | border-radius: 0.25em;
118 | font-size: 0.9rem;
119 | padding: 0.25em;
120 | font-weight: 400;
121 | }
122 |
123 | * {
124 | -webkit-font-smoothing: antialiased;
125 | }
126 | a,
127 | div {
128 | outline: none;
129 | }
130 | ul {
131 | list-style-type: disc;
132 | padding-left: 20px;
133 | }
134 | li > p {
135 | margin: 10px 0px !important;
136 | }
137 | ol {
138 | list-style-type: decimal;
139 | padding-left: 20px;
140 | }
141 | li > ul {
142 | list-style-type: circle !important;
143 | padding-left: 30px;
144 | }
145 | ul[data-type="taskList"] {
146 | list-style: none;
147 | padding: 0;
148 |
149 | p {
150 | margin: 0;
151 | }
152 |
153 | li {
154 | display: flex;
155 |
156 | > label {
157 | flex: 0 0 auto;
158 | margin-right: 0.5rem;
159 | user-select: none;
160 | }
161 |
162 | > div {
163 | flex: 1 1 auto;
164 | }
165 |
166 | ul li,
167 | ol li {
168 | display: list-item;
169 | }
170 |
171 | ul[data-type="taskList"] > li {
172 | display: flex;
173 | }
174 | }
175 | }
176 | .edit-menu > div {
177 | @apply space-x-3;
178 | @apply px-2;
179 | }
180 |
181 | .edit-menu > div:is(:first-child) {
182 | @apply border-r;
183 | }
184 | .edit-menu > div:not(:last-child):not(:first-child) {
185 | @apply border-r;
186 | }
187 |
188 | p,
189 | .inter {
190 | font-family:
191 | "__Inter_0ec1f4",
192 | "__Inter_Fallback_0ec1f4",
193 | ui-sans-serif,
194 | system-ui,
195 | -apple-system,
196 | BlinkMacSystemFont,
197 | "Segoe UI",
198 | Roboto,
199 | "Helvetica Neue",
200 | Arial,
201 | "Noto Sans",
202 | sans-serif,
203 | "Apple Color Emoji",
204 | "Segoe UI Emoji",
205 | "Segoe UI Symbol",
206 | "Noto Color Emoji";
207 | }
208 |
209 | .blogArticle > *:not(img) {
210 | max-width: 736px;
211 | margin-left: auto;
212 | margin-right: auto;
213 | }
214 | .blogArticle img {
215 | width: 100%;
216 | margin: auto;
217 | }
218 | .ant-btn-primary:not([disabled]) {
219 | background: #1677ff !important;
220 | }
221 |
222 | .button {
223 | display: block;
224 | display: flex;
225 | justify-items: center;
226 | width: 100%;
227 | cursor: auto;
228 | }
229 | .button > a {
230 | color: white;
231 | padding: 7px 20px;
232 | text-decoration: none;
233 | font-weight: 500;
234 | font-size: 17px;
235 | font-family: "Inter", sans-serif;
236 | }
237 |
238 | .ProseMirror button.ProseMirror-selectednode {
239 | @apply border-2;
240 | padding: 5px 0px;
241 | @apply border-neutral-200;
242 | }
243 | .ProseMirror pre {
244 | background: #0d0d0d;
245 | color: #fff;
246 | font-family: "JetBrainsMono", monospace;
247 | padding: 0.75rem 1rem;
248 | border-radius: 0.5rem;
249 | }
250 | .ProseMirror pre code {
251 | color: inherit;
252 | padding: 0;
253 | background: none;
254 | font-size: 0.8rem;
255 | }
256 |
257 | .ProseMirror p.is-editor-empty:first-child::before {
258 | color: #adb5bd;
259 | content: attr(data-placeholder);
260 | float: left;
261 | height: 0;
262 | pointer-events: none;
263 | }
264 | .ProseMirror > * {
265 | margin-bottom: 1rem;
266 | }
267 | .ProseMirror p {
268 | font-size: 18px;
269 | line-height: 1.8rem;
270 | @apply text-primary;
271 | }
272 | .ProseMirror blockquote {
273 | @apply border-l-[3px];
274 | @apply border-muted-foreground;
275 | padding-left: 1rem;
276 | }
277 | .ProseMirror strong {
278 | @apply text-primary;
279 | }
280 |
281 | .ProseMirror h1,
282 | .ProseMirror h2,
283 | .ProseMirror h3,
284 | .ProseMirror h4,
285 | .ProseMirror h5,
286 | .ProseMirror h6 {
287 | font-weight: 500;
288 | margin-bottom: 0.5rem !important;
289 | }
290 | .ProseMirror h1 {
291 | font-size: 32px;
292 | }
293 | .ProseMirror h2 {
294 | font-size: 28px;
295 | }
296 | .ProseMirror h3 {
297 | font-size: 24px;
298 | }
299 |
300 | .ProseMirror h4 {
301 | font-size: 22px;
302 | }
303 | .ProseMirror h5 {
304 | font-size: 20px;
305 | }
306 | .ProseMirror .link {
307 | text-decoration: underline !important;
308 | }
309 |
310 | .hljs-comment,
311 | .hljs-quote {
312 | color: #616161;
313 | }
314 |
315 | .hljs-variable,
316 | .hljs-template-variable,
317 | .hljs-attribute,
318 | .hljs-tag,
319 | .hljs-name,
320 | .hljs-regexp,
321 | .hljs-link,
322 | .hljs-name,
323 | .hljs-selector-id,
324 | .hljs-selector-class {
325 | color: #f98181;
326 | }
327 |
328 | .hljs-number,
329 | .hljs-meta,
330 | .hljs-built_in,
331 | .hljs-builtin-name,
332 | .hljs-literal,
333 | .hljs-type,
334 | .hljs-params {
335 | color: #fbbc88;
336 | }
337 |
338 | .hljs-string,
339 | .hljs-symbol,
340 | .hljs-bullet {
341 | color: #b9f18d;
342 | }
343 |
344 | .hljs-title,
345 | .hljs-section {
346 | color: #faf594;
347 | }
348 |
349 | .hljs-keyword,
350 | .hljs-selector-tag {
351 | color: #70cff8;
352 | }
353 |
354 | .hljs-emphasis {
355 | font-style: italic;
356 | }
357 |
358 | .hljs-strong {
359 | font-weight: 700;
360 | }
361 |
362 | .cm-editor {
363 | min-height: 100%;
364 | height: 100%;
365 | }
366 | .editor-wrapper,
367 | .editor-layout {
368 | height: 100% !important;
369 | }
370 |
371 | @keyframes slider {
372 | from {
373 | transform: scale(0.95);
374 | }
375 | to {
376 | transform: translateY(0px);
377 | transform: scale(1);
378 | }
379 | }
380 | .slide-in {
381 | animation: slider 150ms ease-in;
382 | }
383 | ::-webkit-scrollbar {
384 | height: 5px;
385 | width: 5px;
386 | @apply bg-accent;
387 | }
388 |
389 | ::-webkit-scrollbar-thumb {
390 | @apply bg-primary;
391 | opacity: 0.6;
392 |
393 | -webkit-border-radius: 1ex;
394 | }
395 |
396 | ::-webkit-scrollbar-corner {
397 | @apply bg-neutral-200;
398 | }
399 |
400 | .tiptap {
401 | table {
402 | border-collapse: collapse;
403 | border-radius: 2px;
404 | margin: 0;
405 | overflow: hidden;
406 | table-layout: fixed;
407 | width: 100%;
408 |
409 | td,
410 | th {
411 | border: 1.5px solid;
412 | @apply border-gray-300;
413 | box-sizing: border-box;
414 | min-width: 60px;
415 | padding: 3px 5px;
416 | position: relative;
417 | vertical-align: top;
418 |
419 | > * {
420 | margin-bottom: 0;
421 | }
422 | }
423 |
424 | th {
425 | @apply bg-accent;
426 | font-weight: bold;
427 | text-align: left;
428 | }
429 |
430 | .selectedCell:after {
431 | background: rgba(200, 200, 255, 0.4);
432 | content: "";
433 | left: 0;
434 | right: 0;
435 | top: 0;
436 | bottom: 0;
437 | pointer-events: none;
438 | position: absolute;
439 | z-index: 2;
440 | }
441 |
442 | .column-resize-handle {
443 | background-color: #adf;
444 | bottom: -2px;
445 | position: absolute;
446 | right: -2px;
447 | pointer-events: none;
448 | top: 0;
449 | width: 4px;
450 | }
451 |
452 | p {
453 | margin: 0;
454 | }
455 | }
456 | }
457 |
458 | textarea {
459 | background: transparent;
460 | @apply text-primary;
461 | }
462 | textarea::placeholder,
463 | .tiptap p.is-editor-empty:first-child::before {
464 | @apply text-neutral-500 !important;
465 | opacity: 0.5;
466 | }
467 | li::marker {
468 | @apply text-primary;
469 | }
470 |
--------------------------------------------------------------------------------
/src/utils/appStore.ts:
--------------------------------------------------------------------------------
1 | import { Store } from "tauri-plugin-store-api";
2 | import type { Projects, Dir, SortInfo } from "./types";
3 |
4 | const store = new Store(".apps.dat");
5 |
6 | async function getProjects(): Promise {
7 | return (await store.get("projects")) || ({} as any);
8 | }
9 |
10 | async function getProject(id: string): Promise {
11 | const projects = await getProjects();
12 | return projects[id];
13 | }
14 | async function createProject(
15 | project: Omit,
16 | ): Promise<{ projects: Projects; newProjectId: string }> {
17 | const projects = await getProjects();
18 | const currId: string = (await store.get("id")) || "0";
19 | projects[currId] = { ...project, id: currId };
20 |
21 | await store.set("projects", projects);
22 | await store.set("id", currId + 1);
23 |
24 | return { projects, newProjectId: currId };
25 | }
26 |
27 | async function deleteProject(id: string) {
28 | const projects = await getProjects();
29 | delete projects[id];
30 |
31 | await store.set("projects", projects);
32 | return projects;
33 | }
34 |
35 | async function getCurrProject(): Promise {
36 | return store.get("currProject");
37 | }
38 | async function setCurrProject(project: Dir) {
39 | await store.set("currProject", project);
40 | }
41 |
42 | async function setSortInfo(sortInfo: SortInfo) {
43 | await store.set("sortInfo", sortInfo);
44 | }
45 |
46 | async function getSortInfo(): Promise {
47 | return store.get("sortInfo");
48 | }
49 | export {
50 | getProjects,
51 | getProject,
52 | createProject,
53 | deleteProject,
54 | getCurrProject,
55 | setCurrProject,
56 | getSortInfo,
57 | setSortInfo,
58 | };
59 |
--------------------------------------------------------------------------------
/src/utils/getFileMeta.ts:
--------------------------------------------------------------------------------
1 | import { FileEntry } from "@tauri-apps/api/fs";
2 | import { invoke } from "@tauri-apps/api/tauri";
3 |
4 | interface SystemTime {
5 | secs_since_epoch: number;
6 | }
7 | interface FileMeta {
8 | updated_at?: SystemTime;
9 | created_at?: SystemTime;
10 | }
11 |
12 | interface FileInfo extends FileEntry {
13 | meta?: FileMeta;
14 | }
15 | async function getFileMeta(file: FileEntry) {
16 | return (await invoke("get_file_metadata", {
17 | filepath: file.path,
18 | })) as FileMeta;
19 | }
20 |
21 | export { getFileMeta, type FileInfo };
22 |
--------------------------------------------------------------------------------
/src/utils/getImgUrl.ts:
--------------------------------------------------------------------------------
1 | import { join } from "@tauri-apps/api/path";
2 | import { convertFileSrc } from "@tauri-apps/api/tauri";
3 | async function getImgUrl(filePath: string, imgPath: string) {
4 | const dir = await join(filePath, "../", imgPath);
5 | return convertFileSrc(dir);
6 | }
7 | export default getImgUrl;
8 |
--------------------------------------------------------------------------------
/src/utils/htmlToMarkdown/index.ts:
--------------------------------------------------------------------------------
1 | import service from "./turndown";
2 |
3 | function htmlToMarkdown(html: string) {
4 | return service.turndown(html);
5 | }
6 |
7 | export default htmlToMarkdown;
8 |
--------------------------------------------------------------------------------
/src/utils/htmlToMarkdown/turndown/index.ts:
--------------------------------------------------------------------------------
1 | import TurndownService from "turndown";
2 | import listItem from "./listItem";
3 | import paragraph from "./paragraph";
4 | import tableRow from "./table/tableRow";
5 | import table from "./table/table";
6 | import tableCell from "./table/tableCell";
7 |
8 | const service = new TurndownService({
9 | headingStyle: "atx",
10 | hr: "---",
11 | codeBlockStyle: "fenced",
12 | bulletListMarker: "-",
13 | });
14 |
15 | service.addRule("tableCell", tableCell);
16 | service.addRule("table", table);
17 | service.addRule("tableRow", tableRow);
18 | service.addRule("paragraph", paragraph);
19 | service.addRule("listItem", listItem);
20 | export default service;
21 |
--------------------------------------------------------------------------------
/src/utils/htmlToMarkdown/turndown/listItem.ts:
--------------------------------------------------------------------------------
1 | import { Rule } from "turndown";
2 |
3 | export default {
4 | filter: "li",
5 |
6 | replacement: function (content, node, options) {
7 | content = content
8 | .replace(/^\n+/, "") // remove leading newlines
9 | .replace(/\n+$/, "\n") // replace trailing newlines with just a single one
10 | .replace(/\n/gm, "\n ") // indent
11 | .replace(/\n\s{2,}\n/, "\n"); // remove 2 new lines seperate by 2 spaces (occurs in the task list item nodes)
12 | let prefix = options.bulletListMarker + " ";
13 | let parent = node.parentNode as HTMLElement;
14 | if (parent?.nodeName === "OL") {
15 | //@ts-ignore
16 | var start = parent?.getAttribute("start");
17 | var index = Array.prototype.indexOf.call(parent.children, node);
18 | prefix = (start ? Number(start) + index : index + 1) + ". ";
19 | } else if (parent?.getAttribute("data-type") == "taskList") {
20 | const checked =
21 | (node).getAttribute("data-checked") == "true";
22 | prefix += (checked ? "[x]" : "[ ]") + " ";
23 | content = content.trim();
24 | }
25 | return (
26 | prefix + content + (node.nextSibling && !/\n$/.test(content) ? "\n" : "")
27 | );
28 | },
29 | } as Rule;
30 |
--------------------------------------------------------------------------------
/src/utils/htmlToMarkdown/turndown/paragraph.ts:
--------------------------------------------------------------------------------
1 | import { Rule } from "turndown";
2 |
3 | export default {
4 | filter: "p",
5 |
6 | replacement: function (content, node) {
7 | if (node.parentNode?.nodeName == "LI") {
8 | return content;
9 | }
10 | return "\n\n" + content + "\n\n";
11 | },
12 | } as Rule;
13 |
--------------------------------------------------------------------------------
/src/utils/htmlToMarkdown/turndown/table/table.ts:
--------------------------------------------------------------------------------
1 | import { Rule } from "turndown";
2 |
3 | export default {
4 | filter: "table",
5 |
6 | replacement: function (content) {
7 | try {
8 | const table = content
9 | .split("\n")
10 | .filter(Boolean)
11 | .map((i) => i.split("|").filter(Boolean));
12 | let cols_max_width = [];
13 | for (let i = 0; i < table[0].length; i++) {
14 | let max_width = 0;
15 | for (let j = 0; j < table.length; j++) {
16 | max_width = Math.max(max_width, table[j][i].length);
17 | }
18 | cols_max_width.push(max_width);
19 | }
20 | for (let r = 0; r < table.length; r++) {
21 | for (let c = 0; c < table[0].length; c++) {
22 | table[r][c] = table[r][c].padEnd(
23 | cols_max_width[c],
24 | r == 1 ? "-" : " ",
25 | );
26 | }
27 | }
28 | return (
29 | "\n\n" + table.map((i) => "|" + i.join("|") + "|").join("\n") + "\n\n"
30 | );
31 | } catch {
32 | return "\n\n" + content + "\n\n";
33 | }
34 | },
35 | } as Rule;
36 |
--------------------------------------------------------------------------------
/src/utils/htmlToMarkdown/turndown/table/tableCell.ts:
--------------------------------------------------------------------------------
1 | import { Rule } from "turndown";
2 |
3 | export default {
4 | filter: ["th", "td"],
5 | replacement: function (content) {
6 | return "| " + content.trim() + " ";
7 | },
8 | } as Rule;
9 |
--------------------------------------------------------------------------------
/src/utils/htmlToMarkdown/turndown/table/tableRow.ts:
--------------------------------------------------------------------------------
1 | import { Rule } from "turndown";
2 |
3 | export default {
4 | filter: "tr",
5 |
6 | replacement: function (content, node) {
7 | let body = content;
8 | let sep = "";
9 | const isHeader = node.children[0].nodeName == "TH";
10 | if (isHeader) {
11 | sep = content.replace(/[^|]/g, "-");
12 | }
13 | body += " |";
14 | if (sep != "") {
15 | body += "\n" + sep + "|";
16 | }
17 | return "\n" + body + "\n";
18 | },
19 | } as Rule;
20 |
--------------------------------------------------------------------------------
/src/utils/markdown.ts:
--------------------------------------------------------------------------------
1 | import markdownToHtml from "./markdownToHtml";
2 | import htmlToMarkdown from "./htmlToMarkdown";
3 | import { readTextFile } from "@tauri-apps/api/fs";
4 |
5 | import yaml from "yaml";
6 |
7 | async function readMarkdownFile(filePath: string) {
8 | let content = await readTextFile(filePath);
9 | const linesIdx = content.indexOf("---", 2);
10 | let metadata = {};
11 | // parse YAML header
12 | if (content.startsWith("---") && linesIdx != -1) {
13 | const metadataText = content.slice(3, linesIdx);
14 | metadata = yaml.parse(metadataText);
15 | content = content.slice(content.indexOf("---", 2) + 4);
16 | }
17 | const parsedHTML = await markdownToHtml(content);
18 |
19 | return {
20 | metadata,
21 | html: parsedHTML,
22 | };
23 | }
24 |
25 | export { markdownToHtml, htmlToMarkdown, readMarkdownFile };
26 |
--------------------------------------------------------------------------------
/src/utils/markdownToHtml/helpers.ts:
--------------------------------------------------------------------------------
1 | const escapeTest = /[&<>"']/;
2 | const escapeReplace = new RegExp(escapeTest.source, "g");
3 | const escapeReplacements: { [index: string]: string } = {
4 | "&": "&",
5 | "<": "<",
6 | ">": ">",
7 | '"': """,
8 | "'": "'",
9 | };
10 | const getEscapeReplacement = (ch: string) => escapeReplacements[ch];
11 |
12 | export function escape(code: string) {
13 | if (escapeTest.test(code)) {
14 | return code.replace(escapeReplace, getEscapeReplacement);
15 | }
16 |
17 | return code;
18 | }
19 |
--------------------------------------------------------------------------------
/src/utils/markdownToHtml/index.ts:
--------------------------------------------------------------------------------
1 | import marked from "./marked";
2 |
3 | async function markdownToHtml(markdown: string) {
4 | return marked.parse(markdown, { breaks: true });
5 | }
6 |
7 | export default markdownToHtml;
8 |
--------------------------------------------------------------------------------
/src/utils/markdownToHtml/marked/index.ts:
--------------------------------------------------------------------------------
1 | import { Renderer, marked } from "marked";
2 | import { escape } from "../helpers";
3 |
4 | const renderer: Partial = {
5 | paragraph(text) {
6 | // don't wrap images in p tags
7 | if (text.startsWith("
" + text + "";
11 | },
12 |
13 | // same as the marked default code renderer
14 | // here we just adjust it to not add a trailing new line at the end of code blocks
15 | code(code, infostring, escaped) {
16 | const lang = (infostring || "").match(/^\S*/)?.[0];
17 |
18 | code = code.replace(/\n$/, "");
19 |
20 | if (!lang) {
21 | return (
22 | "" + (escaped ? code : escape(code)) + "
\n"
23 | );
24 | }
25 |
26 | return (
27 | '' +
30 | (escaped ? code : escape(code)) +
31 | "
\n"
32 | );
33 | },
34 | };
35 | export default marked.use({ renderer });
36 |
--------------------------------------------------------------------------------
/src/utils/parseMd.ts:
--------------------------------------------------------------------------------
1 | //parse md yaml metadata
2 | import yaml from "yaml";
3 | function parseMd(metadata: string | null) {
4 | if (!metadata) return {};
5 | return yaml.parse(metadata);
6 |
7 | // const data = {};
8 | // metadata?.split("\n").forEach((s) => {
9 | // let colonIdx = s.indexOf(":");
10 | // if (colonIdx == -1) return;
11 | // //@ts-ignore
12 | // data[s.slice(0, colonIdx)] = s.slice(colonIdx + 1).trim();
13 | // });
14 | // return data;
15 | }
16 | export default parseMd;
17 |
--------------------------------------------------------------------------------
/src/utils/removePath.ts:
--------------------------------------------------------------------------------
1 | import { FileInfo } from "./getFileMeta";
2 |
3 | function removePath(path: string, files: FileInfo[]) {
4 | return files.filter((f) => {
5 | if (f.path == path) return false;
6 | if (f.children) {
7 | f.children = removePath(path, f.children);
8 | }
9 | return true;
10 | });
11 | }
12 |
13 | export default removePath;
14 |
--------------------------------------------------------------------------------
/src/utils/types.ts:
--------------------------------------------------------------------------------
1 | export type Dir = { name: string; dir: string; id: string };
2 | export type Projects = { [key: string]: Dir };
3 | export type SortInfo = { sortBy?: SortBy; sortType?: SortType };
4 |
5 | export enum SortBy {
6 | Name,
7 | CreatedAt,
8 | UpdatedAt,
9 | }
10 | export enum SortType {
11 | Asc,
12 | Desc,
13 | }
14 |
15 | export type Settings = {
16 | showTOC?: boolean;
17 | };
18 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
5 | prefix: "",
6 | theme: {
7 | container: {
8 | center: true,
9 | padding: "2rem",
10 | screens: {
11 | "2xl": "1400px",
12 | },
13 | },
14 | extend: {
15 | colors: ({ colors }) => ({
16 | border: "var(--border)",
17 | input: "var(--input)",
18 | ring: "var(--ring)",
19 | background: "var(--background)",
20 | foreground: "var(--foreground)",
21 | primary: {
22 | DEFAULT: "var(--primary)",
23 | foreground: "var(--primary-foreground)",
24 | },
25 | secondary: {
26 | DEFAULT: "var(--secondary)",
27 | foreground: "hsl(var(--secondary-foreground))",
28 | },
29 | destructive: {
30 | DEFAULT: "hsl(var(--destructive))",
31 | foreground: "hsl(var(--destructive-foreground))",
32 | },
33 | muted: {
34 | DEFAULT: "hsl(var(--muted))",
35 | foreground: "var(--muted-foreground)",
36 | },
37 | accent: {
38 | DEFAULT: "var(--accent)",
39 | foreground: "hsl(var(--accent-foreground))",
40 | },
41 | popover: {
42 | DEFAULT: "var(--popover)",
43 | foreground: "hsl(var(--popover-foreground))",
44 | },
45 | card: {
46 | DEFAULT: "hsl(var(--card))",
47 | foreground: "hsl(var(--card-foreground))",
48 | },
49 | }),
50 | borderRadius: {
51 | lg: "var(--radius)",
52 | md: "calc(var(--radius) - 2px)",
53 | sm: "calc(var(--radius) - 4px)",
54 | },
55 | keyframes: {
56 | "accordion-down": {
57 | from: { height: "0" },
58 | to: { height: "var(--radix-accordion-content-height)" },
59 | },
60 | "accordion-up": {
61 | from: { height: "var(--radix-accordion-content-height)" },
62 | to: { height: "0" },
63 | },
64 | },
65 | animation: {
66 | "accordion-down": "accordion-down 0.2s ease-out",
67 | "accordion-up": "accordion-up 0.2s ease-out",
68 | },
69 | },
70 | },
71 | plugins: [require("tailwindcss-animate")],
72 | };
73 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "paths": {
23 | "@/*": ["./src/*"]
24 | }
25 | },
26 | "include": ["src"],
27 | "references": [{ "path": "./tsconfig.node.json" }]
28 | }
29 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true,
8 | "strict": true
9 | },
10 | "include": ["vite.config.ts"]
11 | }
12 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import path from "path";
4 |
5 | export default defineConfig({
6 | // prevent vite from obscuring rust errors
7 | clearScreen: false,
8 | // Tauri expects a fixed port, fail if that port is not available
9 | server: {
10 | strictPort: true,
11 | },
12 | // to access the Tauri environment variables set by the CLI with information about the current target
13 | envPrefix: [
14 | "VITE_",
15 | "TAURI_PLATFORM",
16 | "TAURI_ARCH",
17 | "TAURI_FAMILY",
18 | "TAURI_PLATFORM_VERSION",
19 | "TAURI_PLATFORM_TYPE",
20 | "TAURI_DEBUG",
21 | ],
22 | build: {
23 | // Tauri uses Chromium on Windows and WebKit on macOS and Linux
24 | target: process.env.TAURI_PLATFORM == "windows" ? "chrome105" : "safari13",
25 | // don't minify for debug builds
26 | minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
27 | // produce sourcemaps for debug builds
28 | sourcemap: !!process.env.TAURI_DEBUG,
29 | },
30 |
31 | plugins: [react()],
32 | resolve: {
33 | alias: {
34 | "@": path.resolve(__dirname, "./src"),
35 | },
36 | },
37 | });
38 |
--------------------------------------------------------------------------------