mirror of
https://github.com/Xevion/byte-me.git
synced 2025-12-14 02:11:16 -06:00
Compare commits
9 Commits
d7657b6e13
...
19a39a8c25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19a39a8c25 | ||
|
|
a6921918c1 | ||
|
|
2f43f81555 | ||
|
|
8069d5a061 | ||
|
|
ecc8380645 | ||
|
|
3414880705 | ||
|
|
f90f377277 | ||
|
|
b0cb176f17 | ||
| c172fe4e31 |
115
.github/workflows/ci.yml
vendored
115
.github/workflows/ci.yml
vendored
@@ -9,55 +9,46 @@ env:
|
|||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Frontend checks
|
build:
|
||||||
frontend-check:
|
|
||||||
name: Frontend Check
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install pnpm
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: 20
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install JS deps
|
||||||
run: pnpm install
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Check TypeScript
|
- name: Build frontend (tsc + vite)
|
||||||
run: pnpm run build
|
run: |
|
||||||
|
pnpm run build # implicitly runs generate-types
|
||||||
- name: Format check
|
|
||||||
run: pnpm exec prettier --check .
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
# Rust backend checks
|
|
||||||
rust-check:
|
|
||||||
name: Rust Check
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
components: rustfmt, clippy
|
|
||||||
|
|
||||||
- name: Rust Cache
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
workspaces: src-tauri
|
|
||||||
|
|
||||||
- name: Install Linux dependencies
|
- name: Install Linux dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
sudo apt-get install -y \
|
||||||
|
pkg-config \
|
||||||
|
build-essential \
|
||||||
|
libssl-dev \
|
||||||
|
libglib2.0-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf
|
||||||
|
|
||||||
- name: Format check
|
- name: Format check
|
||||||
run: cargo fmt --manifest-path src-tauri/Cargo.toml --all -- --check
|
run: cargo fmt --manifest-path src-tauri/Cargo.toml --all -- --check
|
||||||
@@ -67,59 +58,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cargo test --manifest-path src-tauri/Cargo.toml --all-features
|
run: cargo test --manifest-path src-tauri/Cargo.toml --all-features
|
||||||
|
|
||||||
# Security audit
|
|
||||||
security-audit:
|
|
||||||
name: Security Audit
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Install cargo-audit
|
|
||||||
uses: taiki-e/cache-cargo-install-action@v2
|
|
||||||
with:
|
|
||||||
tool: cargo-audit
|
|
||||||
|
|
||||||
- name: Run security audit
|
|
||||||
run: cargo audit --file src-tauri/Cargo.lock
|
|
||||||
|
|
||||||
# Check if Tauri app builds successfully
|
|
||||||
build-check:
|
|
||||||
name: Build Check
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [frontend-check, rust-check]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
cache: "pnpm"
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Rust Cache
|
|
||||||
uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
workspaces: src-tauri
|
|
||||||
|
|
||||||
- name: Install Linux dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
run: pnpm install
|
|
||||||
|
|
||||||
- name: Build Tauri app
|
|
||||||
run: pnpm tauri build --no-bundle
|
|
||||||
|
|||||||
142
.github/workflows/code-quality.yml
vendored
Normal file
142
.github/workflows/code-quality.yml
vendored
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
name: Code Quality
|
||||||
|
|
||||||
|
permissions: read-all
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch: # Allow manual triggering
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- "**/Cargo.toml"
|
||||||
|
- "**/Cargo.lock"
|
||||||
|
- "**/package.json"
|
||||||
|
- "**/pnpm-lock.yaml"
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- "**/Cargo.toml"
|
||||||
|
- "**/Cargo.lock"
|
||||||
|
- "**/package.json"
|
||||||
|
- "**/pnpm-lock.yaml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rust-quality:
|
||||||
|
name: Rust Code Quality
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@nightly
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- name: Rust Cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: src-tauri
|
||||||
|
|
||||||
|
- name: Install Linux dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
pkg-config \
|
||||||
|
build-essential \
|
||||||
|
libssl-dev \
|
||||||
|
libglib2.0-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf
|
||||||
|
|
||||||
|
- name: Install cargo-udeps
|
||||||
|
uses: taiki-e/cache-cargo-install-action@v2
|
||||||
|
with:
|
||||||
|
tool: cargo-udeps
|
||||||
|
|
||||||
|
- name: Check for unused dependencies
|
||||||
|
run: cargo +nightly udeps --manifest-path src-tauri/Cargo.toml --all-targets
|
||||||
|
|
||||||
|
- name: Install cargo-machete
|
||||||
|
uses: taiki-e/cache-cargo-install-action@v2
|
||||||
|
with:
|
||||||
|
tool: cargo-machete
|
||||||
|
|
||||||
|
- name: Check for unused Cargo.toml dependencies
|
||||||
|
run: cargo machete src-tauri/
|
||||||
|
|
||||||
|
- name: Install cargo-outdated
|
||||||
|
uses: taiki-e/cache-cargo-install-action@v2
|
||||||
|
with:
|
||||||
|
tool: cargo-outdated
|
||||||
|
|
||||||
|
- name: Check for outdated dependencies
|
||||||
|
run: cargo outdated --manifest-path src-tauri/Cargo.toml --exit-code 1
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
frontend-quality:
|
||||||
|
name: Frontend Code Quality
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Check for unused dependencies
|
||||||
|
run: pnpm exec depcheck --ignore-bin-package=false --skip-missing=true
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Check for outdated dependencies
|
||||||
|
run: pnpm outdated
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Bundle size analysis
|
||||||
|
run: pnpm run build && du -sh dist/
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
license-check:
|
||||||
|
name: License Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install cargo-license
|
||||||
|
uses: taiki-e/cache-cargo-install-action@v2
|
||||||
|
with:
|
||||||
|
tool: cargo-license
|
||||||
|
|
||||||
|
- name: Check Rust crate licenses
|
||||||
|
run: cargo license --manifest-path src-tauri/Cargo.toml --json > rust-licenses.json
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Check npm package licenses
|
||||||
|
run: pnpm exec license-checker --json > npm-licenses.json
|
||||||
|
continue-on-error: true
|
||||||
68
.github/workflows/release.yml
vendored
Normal file
68
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-tauri:
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: macos-latest
|
||||||
|
args: --target aarch64-apple-darwin
|
||||||
|
- platform: macos-latest
|
||||||
|
args: --target x86_64-apple-darwin
|
||||||
|
- platform: ubuntu-22.04
|
||||||
|
args: ""
|
||||||
|
- platform: windows-latest
|
||||||
|
args: ""
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||||
|
|
||||||
|
- name: Rust Cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: src-tauri
|
||||||
|
|
||||||
|
- name: Install dependencies (ubuntu only)
|
||||||
|
if: matrix.platform == 'ubuntu-22.04'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Build Tauri app
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
tagName: app-v__VERSION__
|
||||||
|
releaseName: "App v__VERSION__"
|
||||||
|
releaseBody: "See the assets to download this version and install."
|
||||||
|
releaseDraft: true
|
||||||
|
prerelease: false
|
||||||
|
args: ${{ matrix.args }}
|
||||||
4
.github/workflows/security-audit.yml
vendored
4
.github/workflows/security-audit.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
name: Rust Security Audit
|
name: Rust Security Audit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
name: NPM Security Audit
|
name: NPM Security Audit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
|
src/bindings/*.ts
|
||||||
|
src-tauri/bindings/*.ts
|
||||||
|
|
||||||
# Seed data
|
# Seed data
|
||||||
.data/*
|
.data/*
|
||||||
!.data/seed.ps1
|
!.data/seed.ps1
|
||||||
|
|||||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
src/bindings.ts
|
||||||
|
src-tauri/target/**
|
||||||
|
src-tauri/gen/**
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"ignore": ["src/bindings.ts"],
|
|
||||||
"useTabs": true,
|
"useTabs": true,
|
||||||
"tabWidth": 2
|
"tabWidth": 2
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "pnpm generate-types && tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"generate-types": "cargo test --manifest-path src-tauri/Cargo.toml -- --test export_bindings"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nivo/core": "^0.99.0",
|
"@nivo/core": "^0.99.0",
|
||||||
@@ -26,6 +27,8 @@
|
|||||||
"@types/react": "^18.3.1",
|
"@types/react": "^18.3.1",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
|
|||||||
72
pnpm-lock.yaml
generated
72
pnpm-lock.yaml
generated
@@ -16,7 +16,7 @@ importers:
|
|||||||
version: 0.99.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 0.99.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.1.11
|
specifier: ^4.1.11
|
||||||
version: 4.1.11(vite@6.3.5(jiti@2.4.2)(lightningcss@1.30.1))
|
version: 4.1.11(vite@6.3.5(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.4))
|
||||||
'@tauri-apps/api':
|
'@tauri-apps/api':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.6.0
|
version: 2.6.0
|
||||||
@@ -50,16 +50,22 @@ importers:
|
|||||||
version: 18.3.7(@types/react@18.3.23)
|
version: 18.3.7(@types/react@18.3.23)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^4.3.4
|
specifier: ^4.3.4
|
||||||
version: 4.6.0(vite@6.3.5(jiti@2.4.2)(lightningcss@1.30.1))
|
version: 4.6.0(vite@6.3.5(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.4))
|
||||||
|
prettier:
|
||||||
|
specifier: ^3.6.2
|
||||||
|
version: 3.6.2
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.19.2
|
||||||
|
version: 4.20.4
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ~5.6.2
|
specifier: ~5.6.2
|
||||||
version: 5.6.3
|
version: 5.6.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^6.0.3
|
specifier: ^6.0.3
|
||||||
version: 6.3.5(jiti@2.4.2)(lightningcss@1.30.1)
|
version: 6.3.5(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.4)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.2.4
|
specifier: ^3.2.4
|
||||||
version: 3.2.4(jiti@2.4.2)(lightningcss@1.30.1)
|
version: 3.2.4(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.4)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -924,6 +930,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
get-tsconfig@4.10.1:
|
||||||
|
resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==}
|
||||||
|
|
||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
@@ -1081,6 +1090,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
prettier@3.6.2:
|
||||||
|
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
react-dom@18.3.1:
|
react-dom@18.3.1:
|
||||||
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
|
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1100,6 +1114,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
resolve-pkg-maps@1.0.0:
|
||||||
|
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||||
|
|
||||||
robust-predicates@3.0.2:
|
robust-predicates@3.0.2:
|
||||||
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
|
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
|
||||||
|
|
||||||
@@ -1167,6 +1184,11 @@ packages:
|
|||||||
ts-pattern@5.7.1:
|
ts-pattern@5.7.1:
|
||||||
resolution: {integrity: sha512-EGs8PguQqAAUIcQfK4E9xdXxB6s2GK4sJfT/vcc9V1ELIvC4LH/zXu2t/5fajtv6oiRCxdv7BgtVK3vWgROxag==}
|
resolution: {integrity: sha512-EGs8PguQqAAUIcQfK4E9xdXxB6s2GK4sJfT/vcc9V1ELIvC4LH/zXu2t/5fajtv6oiRCxdv7BgtVK3vWgROxag==}
|
||||||
|
|
||||||
|
tsx@4.20.4:
|
||||||
|
resolution: {integrity: sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
typescript@5.6.3:
|
typescript@5.6.3:
|
||||||
resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==}
|
resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
@@ -1781,12 +1803,12 @@ snapshots:
|
|||||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.11
|
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.11
|
||||||
'@tailwindcss/oxide-win32-x64-msvc': 4.1.11
|
'@tailwindcss/oxide-win32-x64-msvc': 4.1.11
|
||||||
|
|
||||||
'@tailwindcss/vite@4.1.11(vite@6.3.5(jiti@2.4.2)(lightningcss@1.30.1))':
|
'@tailwindcss/vite@4.1.11(vite@6.3.5(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.4))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tailwindcss/node': 4.1.11
|
'@tailwindcss/node': 4.1.11
|
||||||
'@tailwindcss/oxide': 4.1.11
|
'@tailwindcss/oxide': 4.1.11
|
||||||
tailwindcss: 4.1.11
|
tailwindcss: 4.1.11
|
||||||
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.30.1)
|
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.4)
|
||||||
|
|
||||||
'@tauri-apps/api@2.6.0': {}
|
'@tauri-apps/api@2.6.0': {}
|
||||||
|
|
||||||
@@ -1911,7 +1933,7 @@ snapshots:
|
|||||||
'@types/prop-types': 15.7.15
|
'@types/prop-types': 15.7.15
|
||||||
csstype: 3.1.3
|
csstype: 3.1.3
|
||||||
|
|
||||||
'@vitejs/plugin-react@4.6.0(vite@6.3.5(jiti@2.4.2)(lightningcss@1.30.1))':
|
'@vitejs/plugin-react@4.6.0(vite@6.3.5(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.4))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.0
|
'@babel/core': 7.28.0
|
||||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0)
|
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0)
|
||||||
@@ -1919,7 +1941,7 @@ snapshots:
|
|||||||
'@rolldown/pluginutils': 1.0.0-beta.19
|
'@rolldown/pluginutils': 1.0.0-beta.19
|
||||||
'@types/babel__core': 7.20.5
|
'@types/babel__core': 7.20.5
|
||||||
react-refresh: 0.17.0
|
react-refresh: 0.17.0
|
||||||
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.30.1)
|
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.4)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -1931,13 +1953,13 @@ snapshots:
|
|||||||
chai: 5.2.1
|
chai: 5.2.1
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
'@vitest/mocker@3.2.4(vite@6.3.5(jiti@2.4.2)(lightningcss@1.30.1))':
|
'@vitest/mocker@3.2.4(vite@6.3.5(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.4))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 3.2.4
|
'@vitest/spy': 3.2.4
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.17
|
magic-string: 0.30.17
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.30.1)
|
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.4)
|
||||||
|
|
||||||
'@vitest/pretty-format@3.2.4':
|
'@vitest/pretty-format@3.2.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2114,6 +2136,10 @@ snapshots:
|
|||||||
|
|
||||||
gensync@1.0.0-beta.2: {}
|
gensync@1.0.0-beta.2: {}
|
||||||
|
|
||||||
|
get-tsconfig@4.10.1:
|
||||||
|
dependencies:
|
||||||
|
resolve-pkg-maps: 1.0.0
|
||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
internmap@1.0.1: {}
|
internmap@1.0.1: {}
|
||||||
@@ -2223,6 +2249,8 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
prettier@3.6.2: {}
|
||||||
|
|
||||||
react-dom@18.3.1(react@18.3.1):
|
react-dom@18.3.1(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
@@ -2240,6 +2268,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
|
|
||||||
|
resolve-pkg-maps@1.0.0: {}
|
||||||
|
|
||||||
robust-predicates@3.0.2: {}
|
robust-predicates@3.0.2: {}
|
||||||
|
|
||||||
rollup@4.45.0:
|
rollup@4.45.0:
|
||||||
@@ -2316,6 +2346,13 @@ snapshots:
|
|||||||
|
|
||||||
ts-pattern@5.7.1: {}
|
ts-pattern@5.7.1: {}
|
||||||
|
|
||||||
|
tsx@4.20.4:
|
||||||
|
dependencies:
|
||||||
|
esbuild: 0.25.6
|
||||||
|
get-tsconfig: 4.10.1
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.3
|
||||||
|
|
||||||
typescript@5.6.3: {}
|
typescript@5.6.3: {}
|
||||||
|
|
||||||
update-browserslist-db@1.1.3(browserslist@4.25.1):
|
update-browserslist-db@1.1.3(browserslist@4.25.1):
|
||||||
@@ -2328,13 +2365,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|
||||||
vite-node@3.2.4(jiti@2.4.2)(lightningcss@1.30.1):
|
vite-node@3.2.4(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
debug: 4.4.1
|
debug: 4.4.1
|
||||||
es-module-lexer: 1.7.0
|
es-module-lexer: 1.7.0
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.30.1)
|
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.4)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
- jiti
|
- jiti
|
||||||
@@ -2349,7 +2386,7 @@ snapshots:
|
|||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
vite@6.3.5(jiti@2.4.2)(lightningcss@1.30.1):
|
vite@6.3.5(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.25.6
|
esbuild: 0.25.6
|
||||||
fdir: 6.4.6(picomatch@4.0.2)
|
fdir: 6.4.6(picomatch@4.0.2)
|
||||||
@@ -2361,12 +2398,13 @@ snapshots:
|
|||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.4.2
|
jiti: 2.4.2
|
||||||
lightningcss: 1.30.1
|
lightningcss: 1.30.1
|
||||||
|
tsx: 4.20.4
|
||||||
|
|
||||||
vitest@3.2.4(jiti@2.4.2)(lightningcss@1.30.1):
|
vitest@3.2.4(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/chai': 5.2.2
|
'@types/chai': 5.2.2
|
||||||
'@vitest/expect': 3.2.4
|
'@vitest/expect': 3.2.4
|
||||||
'@vitest/mocker': 3.2.4(vite@6.3.5(jiti@2.4.2)(lightningcss@1.30.1))
|
'@vitest/mocker': 3.2.4(vite@6.3.5(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.4))
|
||||||
'@vitest/pretty-format': 3.2.4
|
'@vitest/pretty-format': 3.2.4
|
||||||
'@vitest/runner': 3.2.4
|
'@vitest/runner': 3.2.4
|
||||||
'@vitest/snapshot': 3.2.4
|
'@vitest/snapshot': 3.2.4
|
||||||
@@ -2384,8 +2422,8 @@ snapshots:
|
|||||||
tinyglobby: 0.2.14
|
tinyglobby: 0.2.14
|
||||||
tinypool: 1.1.1
|
tinypool: 1.1.1
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.30.1)
|
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.4)
|
||||||
vite-node: 3.2.4(jiti@2.4.2)(lightningcss@1.30.1)
|
vite-node: 3.2.4(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.4)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- jiti
|
- jiti
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- '@tailwindcss/oxide'
|
- "@tailwindcss/oxide"
|
||||||
- esbuild
|
- esbuild
|
||||||
|
|||||||
668
src-tauri/Cargo.lock
generated
668
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -15,15 +15,13 @@ name = "byte_me_lib"
|
|||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2.4.0", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2.0", features = [] }
|
tauri = { version = "2.8.2", features = [] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2.5.0"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1.0.143"
|
||||||
ffprobe = "0.4.0"
|
ffprobe = "0.4.0"
|
||||||
specta = "=2.0.0-rc.22"
|
ts-rs = { version = "11.0", features = ["format"] }
|
||||||
specta-typescript = "0.0.9"
|
infer = "0.19.0"
|
||||||
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,5 @@
|
|||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capability for the main window",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": ["core:default", "opener:default"]
|
||||||
"core:default",
|
|
||||||
"opener:default"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
47
src-tauri/src/ff.rs
Normal file
47
src-tauri/src/ff.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use crate::models::StreamDetail;
|
||||||
|
|
||||||
|
pub fn extract_streams(info: &ffprobe::FfProbe) -> Vec<StreamDetail> {
|
||||||
|
let mut streams = Vec::new();
|
||||||
|
|
||||||
|
for stream in &info.streams {
|
||||||
|
match stream.codec_type.as_deref() {
|
||||||
|
Some("video") => {
|
||||||
|
streams.push(StreamDetail::Video {
|
||||||
|
codec: stream
|
||||||
|
.codec_name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "unknown".to_string()),
|
||||||
|
width: stream.width.map(|w| w as u32),
|
||||||
|
height: stream.height.map(|h| h as u32),
|
||||||
|
bit_rate: stream.bit_rate.as_ref().map(|b| b.to_string()),
|
||||||
|
frame_rate: Some(stream.r_frame_rate.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some("audio") => {
|
||||||
|
streams.push(StreamDetail::Audio {
|
||||||
|
codec: stream
|
||||||
|
.codec_name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "unknown".to_string()),
|
||||||
|
sample_rate: stream.sample_rate.clone(),
|
||||||
|
channels: stream.channels.map(|c| c as u32),
|
||||||
|
bit_rate: stream.bit_rate.as_ref().map(|b| b.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some("subtitle") => {
|
||||||
|
streams.push(StreamDetail::Subtitle {
|
||||||
|
codec: stream
|
||||||
|
.codec_name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "unknown".to_string()),
|
||||||
|
language: stream.tags.as_ref().and_then(|tags| tags.language.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
streams
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,91 +1,111 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
mod ff;
|
||||||
use specta::Type;
|
mod media;
|
||||||
use specta_typescript::Typescript;
|
mod models;
|
||||||
|
|
||||||
|
use ff::extract_streams;
|
||||||
|
use media::{detect_media_type, is_media_file};
|
||||||
|
use models::{StreamResult, StreamResultError};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tauri_specta::{collect_commands, Builder};
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, Type)]
|
// detection, helpers moved to modules above
|
||||||
struct StreamResult {
|
|
||||||
path: String,
|
|
||||||
filename: String,
|
|
||||||
streams: Vec<StreamDetail>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, Type)]
|
|
||||||
enum StreamDetail {
|
|
||||||
Video { codec: String },
|
|
||||||
Audio { codec: String },
|
|
||||||
Subtitle { codec: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, Type)]
|
|
||||||
struct StreamResultError {
|
|
||||||
filename: Option<String>,
|
|
||||||
reason: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
|
||||||
fn has_streams(paths: Vec<String>) -> Result<Vec<StreamResult>, StreamResultError> {
|
fn has_streams(paths: Vec<String>) -> Result<Vec<StreamResult>, StreamResultError> {
|
||||||
paths
|
paths
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|path_str| {
|
.map(|path_str| {
|
||||||
let path = Path::new(&path_str);
|
let path = Path::new(&path_str);
|
||||||
let filename = path.file_name().unwrap().to_str().unwrap().to_string();
|
let filename = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Err(StreamResultError {
|
return Err(StreamResultError {
|
||||||
filename: Some(filename),
|
filename: Some(filename),
|
||||||
reason: "File does not exist".to_string(),
|
reason: "File does not exist".to_string(),
|
||||||
});
|
error_type: "not_found".to_string(),
|
||||||
}
|
|
||||||
if !path.is_file() {
|
|
||||||
return Err(StreamResultError {
|
|
||||||
filename: Some(filename),
|
|
||||||
reason: "Not a file".to_string(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if it's a file (not directory)
|
||||||
|
if !path.is_file() {
|
||||||
|
return Err(StreamResultError {
|
||||||
|
filename: Some(filename),
|
||||||
|
reason: "Not a file (directory or other)".to_string(),
|
||||||
|
error_type: "not_file".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file size
|
||||||
|
let size = std::fs::metadata(&path_str)
|
||||||
|
.map(|metadata| metadata.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Detect media type using magic numbers and fallback to extensions
|
||||||
|
let media_type = detect_media_type(path);
|
||||||
|
|
||||||
|
// Only try to analyze media files with ffprobe
|
||||||
|
if is_media_file(&media_type) {
|
||||||
|
// Analyze with ffprobe
|
||||||
match ffprobe::ffprobe(&path_str) {
|
match ffprobe::ffprobe(&path_str) {
|
||||||
Ok(info) => {
|
Ok(info) => {
|
||||||
dbg!(info);
|
let streams = extract_streams(&info);
|
||||||
|
let duration = info
|
||||||
|
.format
|
||||||
|
.duration
|
||||||
|
.and_then(|dur_str| dur_str.parse::<f64>().ok());
|
||||||
|
|
||||||
Ok(StreamResult {
|
Ok(StreamResult {
|
||||||
filename,
|
filename,
|
||||||
path: path_str,
|
path: path_str,
|
||||||
streams: vec![],
|
media_type,
|
||||||
|
duration,
|
||||||
|
size,
|
||||||
|
streams,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!("Could not analyze file with ffprobe: {:?}", err);
|
eprintln!("Could not analyze media file with ffprobe: {err:?}");
|
||||||
Err(StreamResultError {
|
Err(StreamResultError {
|
||||||
filename: Some(filename),
|
filename: Some(filename),
|
||||||
reason: "Could not analyze file with ffprobe".to_string(),
|
reason: format!("Could not analyze media file: {err}"),
|
||||||
|
error_type: "analysis_failed".to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// For non-media files, return an error indicating it's not a media file
|
||||||
|
Err(StreamResultError {
|
||||||
|
filename: Some(filename),
|
||||||
|
reason: format!("Not a media file (detected as {media_type:?})"),
|
||||||
|
error_type: "not_media".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>, _>>()
|
.collect::<Result<Vec<_>, _>>()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let builder = Builder::<tauri::Wry>::new()
|
|
||||||
// Then register them (separated by a comma)
|
|
||||||
.commands(collect_commands![has_streams,]);
|
|
||||||
|
|
||||||
#[cfg(debug_assertions)] // <- Only export on non-release builds
|
|
||||||
builder
|
|
||||||
.export(Typescript::default(), "../src/bindings.ts")
|
|
||||||
.expect("Failed to export typescript bindings");
|
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.invoke_handler(tauri::generate_handler![has_streams])
|
.invoke_handler(tauri::generate_handler![has_streams])
|
||||||
.setup(move |app| {
|
|
||||||
// Ensure you mount your events!
|
|
||||||
builder.mount_events(app);
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_bindings() {
|
||||||
|
// This will generate TypeScript bindings when you run `cargo test export_bindings`
|
||||||
|
use crate::models::*;
|
||||||
|
|
||||||
|
StreamDetail::export_all_to("../../src/bindings").expect("Failed to export bindings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
141
src-tauri/src/media.rs
Normal file
141
src-tauri/src/media.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
use crate::models::MediaType;
|
||||||
|
use std::{fs::File, io::Read, path::Path};
|
||||||
|
|
||||||
|
pub fn detect_media_type(path: &Path) -> MediaType {
|
||||||
|
// First try to detect using infer crate (magic number detection)
|
||||||
|
if let Ok(mut file) = File::open(path) {
|
||||||
|
let mut buffer = [0; 512];
|
||||||
|
if let Ok(bytes_read) = file.read(&mut buffer) {
|
||||||
|
if let Some(kind) = infer::get(&buffer[..bytes_read]) {
|
||||||
|
return match kind.mime_type() {
|
||||||
|
// Audio types
|
||||||
|
"audio/mpeg" | "audio/mp3" | "audio/m4a" | "audio/ogg" | "audio/x-flac"
|
||||||
|
| "audio/x-wav" | "audio/amr" | "audio/aac" | "audio/x-aiff"
|
||||||
|
| "audio/x-dsf" | "audio/x-ape" | "audio/midi" => MediaType::Audio,
|
||||||
|
|
||||||
|
// Video types
|
||||||
|
"video/mp4" | "video/x-m4v" | "video/x-matroska" | "video/webm"
|
||||||
|
| "video/quicktime" | "video/x-msvideo" | "video/x-ms-wmv" | "video/mpeg"
|
||||||
|
| "video/x-flv" => MediaType::Video,
|
||||||
|
|
||||||
|
// Image types
|
||||||
|
"image/jpeg"
|
||||||
|
| "image/png"
|
||||||
|
| "image/gif"
|
||||||
|
| "image/webp"
|
||||||
|
| "image/x-canon-cr2"
|
||||||
|
| "image/tiff"
|
||||||
|
| "image/bmp"
|
||||||
|
| "image/heif"
|
||||||
|
| "image/avif"
|
||||||
|
| "image/vnd.ms-photo"
|
||||||
|
| "image/vnd.adobe.photoshop"
|
||||||
|
| "image/vnd.microsoft.icon"
|
||||||
|
| "image/openraster"
|
||||||
|
| "image/vnd.djvu" => MediaType::Image,
|
||||||
|
|
||||||
|
// Document types
|
||||||
|
"application/pdf"
|
||||||
|
| "application/rtf"
|
||||||
|
| "application/msword"
|
||||||
|
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
| "application/vnd.ms-excel"
|
||||||
|
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
| "application/vnd.ms-powerpoint"
|
||||||
|
| "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||||
|
| "application/vnd.oasis.opendocument.text"
|
||||||
|
| "application/vnd.oasis.opendocument.spreadsheet"
|
||||||
|
| "application/vnd.oasis.opendocument.presentation" => MediaType::Document,
|
||||||
|
|
||||||
|
// Archive types
|
||||||
|
"application/zip"
|
||||||
|
| "application/x-tar"
|
||||||
|
| "application/vnd.rar"
|
||||||
|
| "application/gzip"
|
||||||
|
| "application/x-bzip2"
|
||||||
|
| "application/vnd.bzip3"
|
||||||
|
| "application/x-7z-compressed"
|
||||||
|
| "application/x-xz"
|
||||||
|
| "application/x-shockwave-flash"
|
||||||
|
| "application/octet-stream"
|
||||||
|
| "application/postscript"
|
||||||
|
| "application/vnd.sqlite3"
|
||||||
|
| "application/x-nintendo-nes-rom"
|
||||||
|
| "application/x-google-chrome-extension"
|
||||||
|
| "application/vnd.ms-cab-compressed"
|
||||||
|
| "application/vnd.debian.binary-package"
|
||||||
|
| "application/x-unix-archive"
|
||||||
|
| "application/x-compress"
|
||||||
|
| "application/x-lzip"
|
||||||
|
| "application/x-rpm"
|
||||||
|
| "application/dicom"
|
||||||
|
| "application/zstd"
|
||||||
|
| "application/x-lz4"
|
||||||
|
| "application/x-ole-storage"
|
||||||
|
| "application/x-cpio"
|
||||||
|
| "application/x-par2"
|
||||||
|
| "application/epub+zip"
|
||||||
|
| "application/x-mobipocket-ebook" => MediaType::Archive,
|
||||||
|
|
||||||
|
// Executable types
|
||||||
|
"application/vnd.microsoft.portable-executable"
|
||||||
|
| "application/x-executable"
|
||||||
|
| "application/llvm"
|
||||||
|
| "application/x-mach-binary"
|
||||||
|
| "application/java"
|
||||||
|
| "application/vnd.android.dex"
|
||||||
|
| "application/vnd.android.dey"
|
||||||
|
| "application/x-x509-ca-cert" => MediaType::Executable,
|
||||||
|
|
||||||
|
// Library types (covered by executable types above, but keeping for clarity)
|
||||||
|
_ => MediaType::Unknown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to extension-based detection
|
||||||
|
if let Some(extension) = path.extension() {
|
||||||
|
match extension.to_str().unwrap_or("").to_lowercase().as_str() {
|
||||||
|
// Audio extensions
|
||||||
|
"mp3" | "wav" | "flac" | "ogg" | "m4a" | "aac" | "wma" | "mid" | "amr" | "aiff"
|
||||||
|
| "dsf" | "ape" => MediaType::Audio,
|
||||||
|
|
||||||
|
// Video extensions
|
||||||
|
"mp4" | "mkv" | "webm" | "mov" | "avi" | "wmv" | "mpg" | "flv" | "m4v" => {
|
||||||
|
MediaType::Video
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image extensions
|
||||||
|
"gif" | "png" | "jpg" | "jpeg" | "bmp" | "tiff" | "webp" | "cr2" | "heif" | "avif"
|
||||||
|
| "jxr" | "psd" | "ico" | "ora" | "djvu" => MediaType::Image,
|
||||||
|
|
||||||
|
// Document extensions
|
||||||
|
"txt" | "md" | "pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" | "odt"
|
||||||
|
| "ods" | "odp" | "rtf" => MediaType::Document,
|
||||||
|
|
||||||
|
// Archive extensions
|
||||||
|
"zip" | "rar" | "7z" | "tar" | "gz" | "bz2" | "bz3" | "xz" | "swf" | "sqlite"
|
||||||
|
| "nes" | "crx" | "cab" | "deb" | "ar" | "Z" | "lz" | "rpm" | "dcm" | "zst" | "lz4"
|
||||||
|
| "cpio" | "par2" | "epub" | "mobi" => MediaType::Archive,
|
||||||
|
|
||||||
|
// Executable extensions
|
||||||
|
"exe" | "dll" | "msi" | "dmg" | "pkg" | "app" | "elf" | "bc" | "mach" | "class"
|
||||||
|
| "dex" | "dey" | "der" | "obj" => MediaType::Executable,
|
||||||
|
|
||||||
|
// Library extensions
|
||||||
|
"so" | "dylib" => MediaType::Library,
|
||||||
|
|
||||||
|
_ => MediaType::Unknown,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
MediaType::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_media_file(media_type: &MediaType) -> bool {
|
||||||
|
matches!(
|
||||||
|
media_type,
|
||||||
|
MediaType::Audio | MediaType::Video | MediaType::Image
|
||||||
|
)
|
||||||
|
}
|
||||||
56
src-tauri/src/models.rs
Normal file
56
src-tauri/src/models.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub enum MediaType {
|
||||||
|
Audio,
|
||||||
|
Video,
|
||||||
|
Image,
|
||||||
|
Document,
|
||||||
|
Executable,
|
||||||
|
Archive,
|
||||||
|
Library,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct StreamResult {
|
||||||
|
pub path: String,
|
||||||
|
pub filename: String,
|
||||||
|
pub media_type: MediaType,
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
pub size: u64,
|
||||||
|
pub streams: Vec<StreamDetail>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub enum StreamDetail {
|
||||||
|
Video {
|
||||||
|
codec: String,
|
||||||
|
width: Option<u32>,
|
||||||
|
height: Option<u32>,
|
||||||
|
bit_rate: Option<String>,
|
||||||
|
frame_rate: Option<String>,
|
||||||
|
},
|
||||||
|
Audio {
|
||||||
|
codec: String,
|
||||||
|
sample_rate: Option<String>,
|
||||||
|
channels: Option<u32>,
|
||||||
|
bit_rate: Option<String>,
|
||||||
|
},
|
||||||
|
Subtitle {
|
||||||
|
codec: String,
|
||||||
|
language: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
|
||||||
|
#[ts(export)]
|
||||||
|
pub struct StreamResultError {
|
||||||
|
pub filename: Option<String>,
|
||||||
|
pub reason: String,
|
||||||
|
pub error_type: String,
|
||||||
|
}
|
||||||
36
src/App.tsx
36
src/App.tsx
@@ -1,38 +1,12 @@
|
|||||||
type Frame = {
|
import { useDragDropPaths } from "./hooks/useDragDropPaths.js";
|
||||||
id: string;
|
import Graph from "./features/graph/graph.js";
|
||||||
data: { x: string | number; y: number }[];
|
import DropOverlay from "./features/drop/drop-overlay.js";
|
||||||
};
|
import type { Frame } from "./types/graph.js";
|
||||||
|
|
||||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import Graph from "./components/graph.js";
|
|
||||||
import DropOverlay from "./components/drop-overlay.js";
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const data: Frame[] = [];
|
const data: Frame[] = [];
|
||||||
|
|
||||||
const [paths, setPaths] = useState<string[]>([]);
|
const paths = useDragDropPaths();
|
||||||
useEffect(() => {
|
|
||||||
const unlistenPromise = getCurrentWebview().onDragDropEvent(
|
|
||||||
async ({ payload }) => {
|
|
||||||
if (payload.type === "enter") {
|
|
||||||
setPaths(payload.paths);
|
|
||||||
console.log("User hovering", payload);
|
|
||||||
} else if (payload.type === "leave" || payload.type === "drop") {
|
|
||||||
setPaths([]);
|
|
||||||
console.log("User left", payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// you need to call unlisten if your handler goes out of scope e.g. the component is unmounted
|
|
||||||
return () => {
|
|
||||||
unlistenPromise.then((unlisten) => {
|
|
||||||
unlisten();
|
|
||||||
console.log("Unlistened");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const graph = <Graph data={data} />;
|
const graph = <Graph data={data} />;
|
||||||
|
|
||||||
|
|||||||
101
src/bindings.ts
101
src/bindings.ts
@@ -1,90 +1,25 @@
|
|||||||
|
// Import generated TypeScript types from ts-rs
|
||||||
|
import type { StreamResult } from "./bindings/StreamResult";
|
||||||
|
import type { StreamDetail } from "./bindings/StreamDetail";
|
||||||
|
import type { StreamResultError } from "./bindings/StreamResultError";
|
||||||
|
import type { MediaType } from "./bindings/MediaType";
|
||||||
|
export type { StreamResult, StreamDetail, StreamResultError, MediaType };
|
||||||
|
|
||||||
// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually.
|
// Tauri invoke wrapper
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
/** user-defined commands **/
|
|
||||||
|
|
||||||
|
|
||||||
export const commands = {
|
|
||||||
async hasStreams(paths: string[]) : Promise<Result<StreamResult[], StreamResultError>> {
|
|
||||||
try {
|
|
||||||
return { status: "ok", data: await TAURI_INVOKE("has_streams", { paths }) };
|
|
||||||
} catch (e) {
|
|
||||||
if(e instanceof Error) throw e;
|
|
||||||
else return { status: "error", error: e as any };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** user-defined events **/
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/** user-defined constants **/
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/** user-defined types **/
|
|
||||||
|
|
||||||
export type StreamDetail = { Video: { codec: string } } | { Audio: { codec: string } } | { Subtitle: { codec: string } }
|
|
||||||
export type StreamResult = { path: string; filename: string; streams: StreamDetail[] }
|
|
||||||
export type StreamResultError = { filename: string | null; reason: string }
|
|
||||||
|
|
||||||
/** tauri-specta globals **/
|
|
||||||
|
|
||||||
import {
|
|
||||||
invoke as TAURI_INVOKE,
|
|
||||||
Channel as TAURI_CHANNEL,
|
|
||||||
} from "@tauri-apps/api/core";
|
|
||||||
import * as TAURI_API_EVENT from "@tauri-apps/api/event";
|
|
||||||
import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow";
|
|
||||||
|
|
||||||
type __EventObj__<T> = {
|
|
||||||
listen: (
|
|
||||||
cb: TAURI_API_EVENT.EventCallback<T>,
|
|
||||||
) => ReturnType<typeof TAURI_API_EVENT.listen<T>>;
|
|
||||||
once: (
|
|
||||||
cb: TAURI_API_EVENT.EventCallback<T>,
|
|
||||||
) => ReturnType<typeof TAURI_API_EVENT.once<T>>;
|
|
||||||
emit: null extends T
|
|
||||||
? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit>
|
|
||||||
: (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Result<T, E> =
|
export type Result<T, E> =
|
||||||
| { status: "ok"; data: T }
|
| { status: "ok"; data: T }
|
||||||
| { status: "error"; error: E };
|
| { status: "error"; error: E };
|
||||||
|
|
||||||
function __makeEvents__<T extends Record<string, any>>(
|
export const commands = {
|
||||||
mappings: Record<keyof T, string>,
|
async hasStreams(paths: string[]): Promise<Result<StreamResult[], StreamResultError>> {
|
||||||
) {
|
try {
|
||||||
return new Proxy(
|
const data = await invoke<StreamResult[]>("has_streams", { paths });
|
||||||
{} as unknown as {
|
return { status: "ok", data };
|
||||||
[K in keyof T]: __EventObj__<T[K]> & {
|
} catch (e) {
|
||||||
(handle: __WebviewWindow__): __EventObj__<T[K]>;
|
if (e instanceof Error) throw e;
|
||||||
|
else return { status: "error", error: e as any };
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
},
|
|
||||||
{
|
|
||||||
get: (_, event) => {
|
|
||||||
const name = mappings[event as keyof T];
|
|
||||||
|
|
||||||
return new Proxy((() => {}) as any, {
|
|
||||||
apply: (_, __, [window]: [__WebviewWindow__]) => ({
|
|
||||||
listen: (arg: any) => window.listen(name, arg),
|
|
||||||
once: (arg: any) => window.once(name, arg),
|
|
||||||
emit: (arg: any) => window.emit(name, arg),
|
|
||||||
}),
|
|
||||||
get: (_, command: keyof __EventObj__<any>) => {
|
|
||||||
switch (command) {
|
|
||||||
case "listen":
|
|
||||||
return (arg: any) => TAURI_API_EVENT.listen(name, arg);
|
|
||||||
case "once":
|
|
||||||
return (arg: any) => TAURI_API_EVENT.once(name, arg);
|
|
||||||
case "emit":
|
|
||||||
return (arg: any) => TAURI_API_EVENT.emit(name, arg);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
|
||||||
import { match, P } from "ts-pattern";
|
|
||||||
|
|
||||||
type DropOverlayProps = {
|
|
||||||
paths: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type State =
|
|
||||||
| { status: "hidden" }
|
|
||||||
| { status: "loading"; count: number }
|
|
||||||
| { status: "ready"; files: { name: string; key: string }[] }
|
|
||||||
| { status: "error"; reason: string; filename?: string };
|
|
||||||
|
|
||||||
import {
|
|
||||||
CircleQuestionMarkIcon,
|
|
||||||
File as FileIcon,
|
|
||||||
Film,
|
|
||||||
Image,
|
|
||||||
Music,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { commands } from "../bindings";
|
|
||||||
|
|
||||||
type FileItemProps = {
|
|
||||||
filename: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Item = ({ icon, text }: { icon: ReactNode; text: ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-neutral-800 rounded-md shadow-sm"
|
|
||||||
style={{
|
|
||||||
maxWidth: "100%",
|
|
||||||
marginBottom: "0.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
<span className="truncate text-neutral-100 max-w-md">{text}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const FileItem = ({ filename, error }: FileItemProps) => {
|
|
||||||
const ext = filename.split(".").pop()?.toLowerCase();
|
|
||||||
const icon =
|
|
||||||
error == null ? (
|
|
||||||
match(ext)
|
|
||||||
.with("mp3", "wav", "flac", "ogg", "m4a", "aac", () => (
|
|
||||||
<Music className="w-5 h-5 text-blue-400" />
|
|
||||||
))
|
|
||||||
.with("mp4", "mkv", "webm", "mov", "avi", () => (
|
|
||||||
<Film className="w-5 h-5 text-purple-400" />
|
|
||||||
))
|
|
||||||
.with("gif", () => <Image className="w-5 h-5 text-pink-400" />)
|
|
||||||
.otherwise(() => <FileIcon className="w-5 h-5 text-neutral-300" />)
|
|
||||||
) : (
|
|
||||||
<CircleQuestionMarkIcon className="w-5 h-5 text-neutral-300" />
|
|
||||||
);
|
|
||||||
|
|
||||||
return <Item icon={icon} text={filename} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DropOverlay = ({ paths }: DropOverlayProps) => {
|
|
||||||
const [state, setState] = useState<State>({ status: "hidden" });
|
|
||||||
const aborterRef = useRef<AbortController | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (paths.length === 0) {
|
|
||||||
setState({ status: "hidden" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState({ status: "loading", count: paths.length });
|
|
||||||
|
|
||||||
aborterRef.current = new AbortController();
|
|
||||||
|
|
||||||
commands.hasStreams(paths).then((result) => {
|
|
||||||
setState((_state) => {
|
|
||||||
return match(result)
|
|
||||||
.with({ status: "ok" }, (r) => ({
|
|
||||||
status: "ready" as const,
|
|
||||||
files: r.data.map((item) => ({
|
|
||||||
name: item.filename,
|
|
||||||
key: item.path,
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
.with({ status: "error" }, (r) => {
|
|
||||||
if (r.error.filename) {
|
|
||||||
return {
|
|
||||||
status: "error" as const,
|
|
||||||
reason: r.error.reason,
|
|
||||||
filename: r.error.filename,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status: "error" as const, reason: r.error.reason };
|
|
||||||
})
|
|
||||||
.exhaustive();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [paths]);
|
|
||||||
|
|
||||||
if (state.status === "hidden") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inner = match(state)
|
|
||||||
.with({ status: "loading" }, ({ count }) =>
|
|
||||||
Array.from({ length: count }).map((_, i) => (
|
|
||||||
<Item
|
|
||||||
key={i}
|
|
||||||
icon={
|
|
||||||
<CircleQuestionMarkIcon className="w-5 h-5 text-neutral-300/50" />
|
|
||||||
}
|
|
||||||
text={
|
|
||||||
<span className="inline-block w-32 h-5 bg-neutral-300/10 rounded animate-pulse" />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)
|
|
||||||
.with({ status: "ready" }, (r) => {
|
|
||||||
return r.files
|
|
||||||
.slice(0, 8)
|
|
||||||
.map((file) => <FileItem key={file.key} filename={file.name} />);
|
|
||||||
})
|
|
||||||
.with({ status: "error", filename: P.string }, (r) => {
|
|
||||||
return <FileItem filename={r.filename} error={r.reason} />;
|
|
||||||
})
|
|
||||||
.with({ status: "error" }, ({ reason }) => {
|
|
||||||
return (
|
|
||||||
<Item
|
|
||||||
icon={<CircleQuestionMarkIcon className="w-5 h-5 text-neutral-300" />}
|
|
||||||
text={reason}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.exhaustive();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute z-10 top-0 left-0 w-full h-full bg-black/40 backdrop-blur-sm transition-all duration-300 ease-in-out">
|
|
||||||
<div className="flex flex-col justify-center items-center h-full">
|
|
||||||
<span className="text-white text-2xl">{inner}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DropOverlay;
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { ResponsiveLine } from "@nivo/line";
|
|
||||||
import { formatBytes } from "../lib/format.js";
|
|
||||||
|
|
||||||
type Frame = {
|
|
||||||
id: string;
|
|
||||||
data: { x: string | number; y: number }[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type GraphProps = {
|
|
||||||
data: Frame[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const Graph = ({ data }: GraphProps) => (
|
|
||||||
<ResponsiveLine
|
|
||||||
data={data}
|
|
||||||
margin={{ top: 50, right: 110, bottom: 50, left: 60 }}
|
|
||||||
xScale={{ type: "linear" }}
|
|
||||||
yScale={{
|
|
||||||
type: "linear",
|
|
||||||
min: 0,
|
|
||||||
max: "auto",
|
|
||||||
stacked: false,
|
|
||||||
reverse: false,
|
|
||||||
}}
|
|
||||||
theme={{
|
|
||||||
tooltip: {
|
|
||||||
container: {
|
|
||||||
backgroundColor: "#2e2b45",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
line: {
|
|
||||||
stroke: "rgb(252, 191, 212)",
|
|
||||||
strokeWidth: 0.35,
|
|
||||||
strokeOpacity: 0.75,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
crosshair: {
|
|
||||||
line: {
|
|
||||||
stroke: "#fdd3e2",
|
|
||||||
strokeWidth: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
axis: {
|
|
||||||
legend: {},
|
|
||||||
domain: {
|
|
||||||
line: {
|
|
||||||
stroke: "rgb(252, 191, 212)",
|
|
||||||
strokeWidth: 0.5,
|
|
||||||
strokeOpacity: 0.5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
fill: "#6e6a86",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
axisBottom={{ legend: "transportation", legendOffset: 36 }}
|
|
||||||
axisLeft={{
|
|
||||||
legend: "count",
|
|
||||||
legendOffset: -40,
|
|
||||||
format: (v) => formatBytes(v * 1024 * 53),
|
|
||||||
}}
|
|
||||||
pointSize={10}
|
|
||||||
colors={["#3e8faf", "#c4a7e7", "#f5c276", "#EA9B96", "#EB7092", "#9CCFD8"]}
|
|
||||||
pointBorderWidth={0}
|
|
||||||
pointBorderColor={{ from: "seriesColor" }}
|
|
||||||
pointLabelYOffset={-12}
|
|
||||||
enableSlices={"x"}
|
|
||||||
enableTouchCrosshair={true}
|
|
||||||
useMesh={true}
|
|
||||||
legends={[
|
|
||||||
{
|
|
||||||
anchor: "bottom-right",
|
|
||||||
direction: "column",
|
|
||||||
translateX: 100,
|
|
||||||
itemWidth: 80,
|
|
||||||
itemHeight: 22,
|
|
||||||
symbolShape: "circle",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Graph;
|
|
||||||
381
src/features/drop/drop-overlay.tsx
Normal file
381
src/features/drop/drop-overlay.tsx
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||||
|
import { match, P } from "ts-pattern";
|
||||||
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
File as FileIcon,
|
||||||
|
FileText,
|
||||||
|
Film,
|
||||||
|
Image,
|
||||||
|
Loader2,
|
||||||
|
Music,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { commands } from "../../bindings";
|
||||||
|
import type { MediaType, StreamDetail } from "../../bindings";
|
||||||
|
|
||||||
|
type DropOverlayProps = {
|
||||||
|
paths: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type State =
|
||||||
|
| { status: "hidden" }
|
||||||
|
| { status: "loading"; count: number }
|
||||||
|
| {
|
||||||
|
status: "ready";
|
||||||
|
files: {
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
media_type: MediaType;
|
||||||
|
duration?: number | null;
|
||||||
|
size: number;
|
||||||
|
streams: StreamDetail[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
| { status: "error"; reason: string; filename?: string; error_type?: string };
|
||||||
|
|
||||||
|
type FileItemProps = {
|
||||||
|
filename: string;
|
||||||
|
media_type: MediaType;
|
||||||
|
duration?: number | null;
|
||||||
|
size: number;
|
||||||
|
streams: StreamDetail[];
|
||||||
|
error?: string;
|
||||||
|
error_type?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number): string => {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
return `${minutes}:${secs.toString().padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileIcon = (
|
||||||
|
mediaType: MediaType,
|
||||||
|
error?: string,
|
||||||
|
errorType?: string,
|
||||||
|
) => {
|
||||||
|
// For non-media files, show a neutral icon instead of error icon
|
||||||
|
if (errorType === "not_media") {
|
||||||
|
switch (mediaType) {
|
||||||
|
case "Executable":
|
||||||
|
return <FileIcon className="w-5 h-5 text-orange-400" />;
|
||||||
|
case "Archive":
|
||||||
|
return <FileIcon className="w-5 h-5 text-yellow-400" />;
|
||||||
|
case "Library":
|
||||||
|
return <FileIcon className="w-5 h-5 text-indigo-400" />;
|
||||||
|
case "Document":
|
||||||
|
return <FileText className="w-5 h-5 text-green-400" />;
|
||||||
|
default:
|
||||||
|
return <FileIcon className="w-5 h-5 text-neutral-300" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <XCircle className="w-5 h-5 text-red-400" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (mediaType) {
|
||||||
|
case "Audio":
|
||||||
|
return <Music className="w-5 h-5 text-blue-400" />;
|
||||||
|
case "Video":
|
||||||
|
return <Film className="w-5 h-5 text-purple-400" />;
|
||||||
|
case "Image":
|
||||||
|
return <Image className="w-5 h-5 text-pink-400" />;
|
||||||
|
case "Document":
|
||||||
|
return <FileText className="w-5 h-5 text-green-400" />;
|
||||||
|
case "Executable":
|
||||||
|
return <FileIcon className="w-5 h-5 text-orange-400" />;
|
||||||
|
case "Archive":
|
||||||
|
return <FileIcon className="w-5 h-5 text-yellow-400" />;
|
||||||
|
case "Library":
|
||||||
|
return <FileIcon className="w-5 h-5 text-indigo-400" />;
|
||||||
|
default:
|
||||||
|
return <FileIcon className="w-5 h-5 text-neutral-300" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStreamInfo = (
|
||||||
|
streams: StreamDetail[],
|
||||||
|
mediaType: MediaType,
|
||||||
|
): string => {
|
||||||
|
// For non-media files, return file type description
|
||||||
|
if (!["Audio", "Video", "Image"].includes(mediaType)) {
|
||||||
|
switch (mediaType) {
|
||||||
|
case "Executable":
|
||||||
|
return "Executable file";
|
||||||
|
case "Archive":
|
||||||
|
return "Archive file";
|
||||||
|
case "Library":
|
||||||
|
return "Library file";
|
||||||
|
case "Document":
|
||||||
|
return "Document file";
|
||||||
|
default:
|
||||||
|
return "Unknown file type";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For media files, analyze streams
|
||||||
|
const videoStreams = streams.filter((s: any) => "Video" in s);
|
||||||
|
const audioStreams = streams.filter((s: any) => "Audio" in s);
|
||||||
|
const subtitleStreams = streams.filter((s: any) => "Subtitle" in s);
|
||||||
|
|
||||||
|
const parts = [] as string[];
|
||||||
|
if (videoStreams.length > 0) {
|
||||||
|
const video: any = videoStreams[0] as any;
|
||||||
|
if ("Video" in video) {
|
||||||
|
const width = (video as any).Video.width;
|
||||||
|
const height = (video as any).Video.height;
|
||||||
|
const codec = (video as any).Video.codec;
|
||||||
|
if (width && height) {
|
||||||
|
parts.push(`${width}x${height} ${codec}`);
|
||||||
|
} else {
|
||||||
|
parts.push(codec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (audioStreams.length > 0) {
|
||||||
|
const audio: any = audioStreams[0] as any;
|
||||||
|
if ("Audio" in audio) {
|
||||||
|
parts.push(`${(audio as any).Audio.codec} audio`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (subtitleStreams.length > 0) {
|
||||||
|
parts.push(`${subtitleStreams.length} subtitle(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(", ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const Item = ({
|
||||||
|
icon,
|
||||||
|
text,
|
||||||
|
subtitle,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
text: ReactNode;
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
status?: "success" | "error" | "loading";
|
||||||
|
}) => {
|
||||||
|
const statusColor =
|
||||||
|
status === "success"
|
||||||
|
? "border-green-500"
|
||||||
|
: status === "error"
|
||||||
|
? "border-red-500"
|
||||||
|
: status === "loading"
|
||||||
|
? "border-blue-500"
|
||||||
|
: "border-neutral-600";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 bg-neutral-800 rounded-lg shadow-lg border-2 ${statusColor} transition-all duration-200`}
|
||||||
|
style={{
|
||||||
|
maxWidth: "100%",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="truncate text-neutral-100 font-medium">{text}</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div className="truncate text-neutral-400 text-sm mt-1">
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FileItem = ({
|
||||||
|
filename,
|
||||||
|
media_type,
|
||||||
|
duration,
|
||||||
|
size,
|
||||||
|
streams,
|
||||||
|
error,
|
||||||
|
error_type,
|
||||||
|
}: FileItemProps) => {
|
||||||
|
const icon = getFileIcon(media_type, error, error_type);
|
||||||
|
const fileSize = formatFileSize(size);
|
||||||
|
|
||||||
|
let subtitle: ReactNode;
|
||||||
|
let status: "success" | "error" | "loading" | undefined;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
subtitle = error;
|
||||||
|
// For non-media files, show as neutral instead of error
|
||||||
|
status = error_type === "not_media" ? undefined : "error";
|
||||||
|
} else {
|
||||||
|
const streamInfo = getStreamInfo(streams, media_type);
|
||||||
|
const durationStr = duration ? formatDuration(duration) : null;
|
||||||
|
const details = [streamInfo, durationStr, fileSize].filter(
|
||||||
|
Boolean,
|
||||||
|
) as string[];
|
||||||
|
subtitle = details.join(" • ");
|
||||||
|
status = "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item icon={icon} text={filename} subtitle={subtitle} status={status} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DropOverlay = ({ paths }: DropOverlayProps) => {
|
||||||
|
const [state, setState] = useState<State>({ status: "hidden" });
|
||||||
|
const aborterRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paths.length === 0) {
|
||||||
|
setState({ status: "hidden" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({ status: "loading", count: paths.length });
|
||||||
|
|
||||||
|
aborterRef.current = new AbortController();
|
||||||
|
|
||||||
|
commands.hasStreams(paths).then((result) => {
|
||||||
|
setState((_state) => {
|
||||||
|
return match(result)
|
||||||
|
.with({ status: "ok" }, (r) => ({
|
||||||
|
status: "ready" as const,
|
||||||
|
files: r.data.map((item) => ({
|
||||||
|
name: item.filename,
|
||||||
|
key: item.path,
|
||||||
|
media_type: item.media_type,
|
||||||
|
duration: item.duration,
|
||||||
|
size: Number(item.size),
|
||||||
|
streams: item.streams,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
.with({ status: "error" }, (r) => {
|
||||||
|
if (r.error.filename) {
|
||||||
|
return {
|
||||||
|
status: "error" as const,
|
||||||
|
reason: r.error.reason,
|
||||||
|
filename: r.error.filename,
|
||||||
|
error_type: r.error.error_type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "error" as const,
|
||||||
|
reason: r.error.reason,
|
||||||
|
error_type: r.error.error_type,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.exhaustive();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [paths]);
|
||||||
|
|
||||||
|
if (state.status === "hidden") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inner = match(state)
|
||||||
|
.with({ status: "loading" }, ({ count }) => (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<Loader2 className="w-8 h-8 text-blue-400 animate-spin" />
|
||||||
|
<div className="text-white text-lg font-medium">
|
||||||
|
Analyzing {count} file{count > 1 ? "s" : ""}...
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: Math.min(count, 3) }).map((_, i) => (
|
||||||
|
<Item
|
||||||
|
key={i}
|
||||||
|
icon={
|
||||||
|
<Loader2 className="w-5 h-5 text-neutral-300/50 animate-spin" />
|
||||||
|
}
|
||||||
|
text={
|
||||||
|
<span className="inline-block w-32 h-5 bg-neutral-300/10 rounded animate-pulse" />
|
||||||
|
}
|
||||||
|
status="loading"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
.with({ status: "ready" }, (r) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-green-400">
|
||||||
|
<CheckCircle className="w-6 h-6" />
|
||||||
|
<span className="text-lg font-medium">Files Ready</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{r.files.slice(0, 8).map((file) => (
|
||||||
|
<FileItem
|
||||||
|
key={file.key}
|
||||||
|
filename={file.name}
|
||||||
|
media_type={file.media_type}
|
||||||
|
duration={file.duration}
|
||||||
|
size={file.size}
|
||||||
|
streams={file.streams}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.with({ status: "error", filename: P.string }, (r) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-red-400">
|
||||||
|
<XCircle className="w-6 h-6" />
|
||||||
|
<span className="text-lg font-medium">Error</span>
|
||||||
|
</div>
|
||||||
|
<FileItem
|
||||||
|
filename={r.filename}
|
||||||
|
media_type="Unknown"
|
||||||
|
size={0}
|
||||||
|
streams={[]}
|
||||||
|
error={r.reason}
|
||||||
|
error_type={r.error_type}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.with({ status: "error" }, ({ reason }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-red-400">
|
||||||
|
<XCircle className="w-6 h-6" />
|
||||||
|
<span className="text-lg font-medium">Error</span>
|
||||||
|
</div>
|
||||||
|
<Item
|
||||||
|
icon={<XCircle className="w-5 h-5 text-red-400" />}
|
||||||
|
text={reason}
|
||||||
|
status="error"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute z-10 top-0 left-0 w-full h-full bg-black/60 backdrop-blur-sm transition-all duration-300 ease-in-out">
|
||||||
|
<div className="flex flex-col justify-center items-center h-full p-8">
|
||||||
|
<div className="bg-neutral-900 rounded-xl p-6 shadow-2xl max-w-2xl w-full">
|
||||||
|
{inner}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DropOverlay;
|
||||||
81
src/features/graph/graph.tsx
Normal file
81
src/features/graph/graph.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { ResponsiveLine } from "@nivo/line";
|
||||||
|
import { formatBytes } from "../../lib/format.js";
|
||||||
|
import type { Frame } from "../../types/graph.js";
|
||||||
|
|
||||||
|
type GraphProps = {
|
||||||
|
data: Frame[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const Graph = ({ data }: GraphProps) => (
|
||||||
|
<ResponsiveLine
|
||||||
|
data={data}
|
||||||
|
margin={{ top: 50, right: 110, bottom: 50, left: 60 }}
|
||||||
|
xScale={{ type: "linear" }}
|
||||||
|
yScale={{
|
||||||
|
type: "linear",
|
||||||
|
min: 0,
|
||||||
|
max: "auto",
|
||||||
|
stacked: false,
|
||||||
|
reverse: false,
|
||||||
|
}}
|
||||||
|
theme={{
|
||||||
|
tooltip: {
|
||||||
|
container: {
|
||||||
|
backgroundColor: "#2e2b45",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
line: {
|
||||||
|
stroke: "rgb(252, 191, 212)",
|
||||||
|
strokeWidth: 0.35,
|
||||||
|
strokeOpacity: 0.75,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
crosshair: {
|
||||||
|
line: {
|
||||||
|
stroke: "#fdd3e2",
|
||||||
|
strokeWidth: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axis: {
|
||||||
|
legend: {},
|
||||||
|
domain: {
|
||||||
|
line: {
|
||||||
|
stroke: "rgb(252, 191, 212)",
|
||||||
|
strokeWidth: 0.5,
|
||||||
|
strokeOpacity: 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fill: "#6e6a86",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
axisBottom={{ legend: "transportation", legendOffset: 36 }}
|
||||||
|
axisLeft={{
|
||||||
|
legend: "count",
|
||||||
|
legendOffset: -40,
|
||||||
|
format: (v) => formatBytes(v * 1024 * 53),
|
||||||
|
}}
|
||||||
|
pointSize={10}
|
||||||
|
colors={["#3e8faf", "#c4a7e7", "#f5c276", "#EA9B96", "#EB7092", "#9CCFD8"]}
|
||||||
|
pointBorderWidth={0}
|
||||||
|
pointBorderColor={{ from: "seriesColor" }}
|
||||||
|
pointLabelYOffset={-12}
|
||||||
|
enableSlices={"x"}
|
||||||
|
enableTouchCrosshair={true}
|
||||||
|
useMesh={true}
|
||||||
|
legends={[
|
||||||
|
{
|
||||||
|
anchor: "bottom-right",
|
||||||
|
direction: "column",
|
||||||
|
translateX: 100,
|
||||||
|
itemWidth: 80,
|
||||||
|
itemHeight: 22,
|
||||||
|
symbolShape: "circle",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Graph;
|
||||||
24
src/hooks/useDragDropPaths.ts
Normal file
24
src/hooks/useDragDropPaths.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||||
|
|
||||||
|
export function useDragDropPaths(): string[] {
|
||||||
|
const [paths, setPaths] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unlistenPromise = getCurrentWebview().onDragDropEvent(
|
||||||
|
async ({ payload }) => {
|
||||||
|
if (payload.type === "enter") {
|
||||||
|
setPaths(payload.paths);
|
||||||
|
} else if (payload.type === "leave" || payload.type === "drop") {
|
||||||
|
setPaths([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlistenPromise.then((unlisten) => unlisten());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
@@ -6,5 +6,5 @@ import "./global.css";
|
|||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
4
src/types/graph.ts
Normal file
4
src/types/graph.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export type Frame = {
|
||||||
|
id: string;
|
||||||
|
data: { x: string | number; y: number }[];
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user