Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
991727d1c5 | ||
|
|
8e1e0e33e3 | ||
|
|
e02ef0d2c0 | ||
|
|
fc7c1d28e5 | ||
|
|
2553442a7d | ||
|
|
b3247a6a97 | ||
|
|
124f2da992 | ||
|
|
9343453365 | ||
|
|
1802725581 | ||
|
|
885d66c4a4 | ||
|
|
555b199e03 | ||
|
|
371f79a35f | ||
|
|
64bc261c20 | ||
|
|
21e9f38be6 | ||
|
|
a4fa260727 | ||
|
|
546bc7352c | ||
|
|
9540a8c0a9 | ||
|
|
60d3dfaef5 | ||
|
|
cbfc242308 | ||
|
|
f0779c96a3 | ||
|
|
9eb362012b | ||
|
|
843c130834 | ||
|
|
f384338765 | ||
|
|
0e082fff9c | ||
|
|
6ca24c3411 | ||
|
|
31bc733415 | ||
|
|
451bb203b7 | ||
|
|
57fdad0646 | ||
|
|
71bb09c82e | ||
|
|
4ab8f3d1f1 | ||
|
|
4bac15cb44 | ||
|
|
d76f38fb35 | ||
|
|
236ca4da29 | ||
|
|
cf8d211eb2 | ||
|
|
4ecd18086c | ||
|
|
a2b9eea90a | ||
|
|
4b3afcf535 | ||
|
|
185969a9d1 | ||
|
|
5f20820735 | ||
|
|
397a943426 | ||
|
|
83d3e7b6b8 | ||
|
|
5573c9f427 | ||
|
|
92d2a41296 | ||
|
|
9bc2c78b4d | ||
|
|
87735c2716 | ||
|
|
91c9de2561 | ||
|
|
9df9264d25 | ||
|
|
c6d33aa82a | ||
|
|
85f2388041 | ||
|
|
44cf97c7a1 | ||
|
|
bd50b193a1 | ||
|
|
491db0ab2e | ||
|
|
518fe4ca15 | ||
|
|
647705fb58 | ||
|
|
59d786bfda | ||
|
|
19476da5cc | ||
|
|
cdff1a9b5d | ||
|
|
fac4af40c7 | ||
|
|
d9177b4a44 | ||
|
|
4d7e2a2d85 | ||
|
|
8e94dcbc2c | ||
|
|
05f4e74de6 | ||
|
|
a89198ccdc | ||
|
|
170dd2efd2 | ||
|
|
d4704d554d | ||
|
|
485d2fd098 | ||
|
|
d5dbcb3a7a | ||
|
|
e1760020ac | ||
|
|
c8a4becaf0 | ||
|
|
e6db74f183 | ||
|
|
271a3ee443 | ||
|
|
cef987cd33 | ||
|
|
95b4df0ae4 | ||
|
|
0d27f4fc36 | ||
|
|
23aced6007 | ||
|
|
db9cf960f8 | ||
|
|
afbd0740a3 | ||
|
|
f30e2d654e | ||
|
|
f2c6d8ff0f | ||
|
|
9821e926d9 | ||
|
|
a2ca0799d4 | ||
|
|
6d2e1edb5b | ||
|
|
08f58b3539 | ||
|
|
1586dbe65e | ||
|
|
3a63617c55 | ||
|
|
e8d2c19b7d | ||
|
|
23ede0fcfa | ||
|
|
78cfb64f98 | ||
|
|
3124342309 | ||
|
|
a9bd2ac820 | ||
|
|
e190915c48 | ||
|
|
171a02aef4 | ||
|
|
085ebad38f | ||
|
|
f3e12629c2 | ||
|
|
f00d381f94 | ||
|
|
fb92b0b6d9 | ||
|
|
8ca2b8362a | ||
|
|
134f392553 | ||
|
|
c7e81c6b7f | ||
|
|
f58b8b2897 | ||
|
|
6e6ecba172 | ||
|
|
175bb36eda | ||
|
|
2ca99136ba | ||
|
|
b7485865f3 | ||
|
|
7db2fa4e01 | ||
|
|
81b59cad7c | ||
|
|
7e0ce53df0 | ||
|
|
d57140b031 | ||
|
|
5dd85abaee | ||
|
|
78fbd30b15 | ||
|
|
ebb1d29375 | ||
|
|
250fc5d221 | ||
|
|
58b5ea0f9b | ||
|
|
4bc8a1e970 | ||
|
|
16752a7ae8 | ||
|
|
5f4d20b26d | ||
|
|
8cbdcf2d7a | ||
|
|
f1f507e6e9 | ||
|
|
a7fb7670a7 | ||
|
|
eccccf583b | ||
|
|
d6d1a3eca6 | ||
|
|
c5aa622898 | ||
|
|
1345c8109c | ||
|
|
d91ed78fc2 | ||
|
|
5d7b817586 | ||
|
|
fecb9a9b22 | ||
|
|
cbdef41667 | ||
|
|
f34bd19183 | ||
|
|
7a7db0b277 | ||
|
|
3a4ec9c470 | ||
|
|
d2a62b137d | ||
|
|
c9e9d1d1bd | ||
|
|
cce88b3dab | ||
|
|
4018741648 | ||
|
|
ed3cdab64e | ||
|
|
4ffa9c6ecb | ||
|
|
899e55d257 | ||
|
|
977be1e3b1 | ||
|
|
262745a97f | ||
|
|
6ac712eaac | ||
|
|
79e40cef68 | ||
|
|
c847953a52 | ||
|
|
391938049b | ||
|
|
71ba4e4cea | ||
|
|
ec3b9b46a1 | ||
|
|
2ad5899417 | ||
|
|
1f08d3ca26 | ||
|
|
bae17c0d1b | ||
|
|
0c89e3e01a | ||
|
|
d2ee511466 | ||
|
|
4c725dfceb | ||
|
|
71e2b51f5b | ||
|
|
c9321240d8 | ||
|
|
159f770da8 | ||
|
|
443e4b0581 | ||
|
|
65bc21f153 | ||
|
|
59807662d8 | ||
|
|
8f31bde518 | ||
|
|
6fb9a24834 | ||
|
|
6a8b99c7bd | ||
|
|
1241de6e1a | ||
|
|
3998af5398 | ||
|
|
2b5b15f3e8 | ||
|
|
e549850b75 | ||
|
|
b6d6fe6a70 | ||
|
|
e6cb86fafc | ||
|
|
1dbbf8721c | ||
|
|
c63f5eddeb | ||
|
|
6475814541 | ||
|
|
d85e6e1254 | ||
|
|
6baeebec96 | ||
|
|
ddf30ed60e | ||
|
|
98d6acaa6a | ||
|
|
060a7bab92 | ||
|
|
48253cc31d | ||
|
|
f887d09865 | ||
|
|
6634d0eb53 | ||
|
|
6617fd39f6 | ||
|
|
8ec426d200 | ||
|
|
188ba94aad | ||
|
|
7129d55f86 | ||
|
|
483ace8e46 | ||
|
|
bf63154801 | ||
|
|
437eded23a | ||
|
|
472d4053f4 | ||
|
|
864902b660 | ||
|
|
dac826a5bc | ||
|
|
dd17551c18 | ||
|
|
dae7dc5212 | ||
|
|
210977faf4 | ||
|
|
9c6ac891e4 | ||
|
|
1306c6e4ea | ||
|
|
b181546ad3 | ||
|
|
0d5c4529d1 | ||
|
|
4a78bb999b | ||
|
|
f034c9fe01 | ||
|
|
325c239a3a | ||
|
|
9fedc5c167 | ||
|
|
0e0f5003ff | ||
|
|
55a373c7ef | ||
|
|
e14ecbff56 | ||
|
|
996d0d8c5b | ||
|
|
ba72eaf83c | ||
|
|
7f5b010c00 |
@@ -0,0 +1,14 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = tab
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{json,yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -1,18 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
name: Bug Report
|
||||
description: Create a report to help us improve
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Search existing issues
|
||||
description: Please search to see if an issue already exists for the bug you encountered.
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: bug-description
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: e.g., When I click submit, nothing happens...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
placeholder: e.g., The form should submit and show a success message
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: To Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem.
|
||||
placeholder: Drag and drop images here or paste them
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: os-type
|
||||
attributes:
|
||||
label: OS
|
||||
description: Operating system
|
||||
options:
|
||||
- Windows
|
||||
- macOS
|
||||
- Linux
|
||||
- iOS
|
||||
- Android
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: os-version
|
||||
attributes:
|
||||
label: OS Version
|
||||
description: Please specify your OS version
|
||||
placeholder: e.g., Windows 11, macOS Sonoma, Ubuntu 22.04
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: os-other
|
||||
attributes:
|
||||
label: Other OS
|
||||
description: If you selected "Other" for OS, please specify your operating system
|
||||
placeholder: e.g., FreeBSD, Solaris
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser
|
||||
description: What browser are you using?
|
||||
options:
|
||||
- Chrome
|
||||
- Firefox
|
||||
- Safari
|
||||
- Edge
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: browser-version
|
||||
attributes:
|
||||
label: Browser Version
|
||||
description: Please specify your browser version
|
||||
placeholder: e.g., 120.0, 121.0.1
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: browser-other
|
||||
attributes:
|
||||
label: Other Browser
|
||||
description: If you selected "Other" for Browser, please specify your browser
|
||||
placeholder: e.g., Brave, Vivaldi, Opera
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: device-type
|
||||
attributes:
|
||||
label: Device Type
|
||||
description: Device category
|
||||
options:
|
||||
- Desktop
|
||||
- Laptop
|
||||
- Tablet
|
||||
- Mobile
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: device-other
|
||||
attributes:
|
||||
label: Other Device
|
||||
description: If you selected "Other" for Device Type, please specify your device
|
||||
placeholder: e.g., Smart TV, IoT device
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the problem here.
|
||||
placeholder: Links, references, or any additional information
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Feature Request
|
||||
description: Suggest an idea for this project
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "feature-request"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Search existing issues
|
||||
description: Please search to see if an issue already exists for this feature request.
|
||||
options:
|
||||
- label: I have searched the existing issues
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
attributes:
|
||||
label: Is your feature request related to a problem?
|
||||
description: A clear and concise description of what the problem is.
|
||||
placeholder: e.g., I'm always frustrated when I have to...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution-description
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
placeholder: Describe the feature or change you're proposing
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
placeholder: Have you considered any workarounds or alternative approaches?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
placeholder: Links, mockups, or any additional information
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,43 @@
|
||||
# Pull Request Template
|
||||
|
||||
## Description
|
||||
<!-- Briefly describe the purpose of this PR. -->
|
||||
|
||||
## Motivation
|
||||
<!-- Explain why this change is needed. What problem does it solve? -->
|
||||
|
||||
## Type of Change
|
||||
- [ ] New Feature
|
||||
- [ ] Bug Fix
|
||||
- [ ] Refactor / Code Cleanup
|
||||
- [ ] Documentation Update
|
||||
- [ ] Other (please specify)
|
||||
|
||||
## Related Issue(s)
|
||||
<!-- Link to any related issue(s) (e.g., #123) -->
|
||||
|
||||
## Screenshots / Video
|
||||
<!-- Include screenshots or a short video demonstrating the change. If the change adds a new UI feature, attach an image. If it adds functionality best shown via video, embed a video. -->
|
||||
|
||||
**Screenshot** (if applicable):
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
**Video** (if applicable):
|
||||
|
||||
```html
|
||||
<video src="path/to/video.mp4" controls width="600"></video>
|
||||
```
|
||||
|
||||
## Testing
|
||||
<!-- Describe how reviewers can test the changes. Include steps, commands, or environment setup. -->
|
||||
|
||||
## Checklist
|
||||
- [ ] I have performed a self-review of my code.
|
||||
- [ ] I have added any necessary screenshots or videos.
|
||||
- [ ] I have linked related issue(s) and updated the changelog if applicable.
|
||||
|
||||
---
|
||||
*Thank you for contributing!*
|
||||
@@ -1,15 +1,12 @@
|
||||
|
||||
name: Build Electron App
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
@@ -18,11 +15,6 @@ jobs:
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
@@ -30,14 +22,78 @@ jobs:
|
||||
- name: Install app dependencies
|
||||
run: npx electron-builder install-app-deps
|
||||
|
||||
- name: Build Windows app
|
||||
run: npm run build:win
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload Windows build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: release/**/*.exe
|
||||
retention-days: 30
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install app dependencies
|
||||
run: npx electron-builder install-app-deps
|
||||
|
||||
- name: Build macOS app
|
||||
run: npm run build:mac
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
- name: Upload macOS build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-installer
|
||||
path: release/**/*.dmg
|
||||
retention-days: 30
|
||||
retention-days: 30
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install app dependencies
|
||||
run: npx electron-builder install-app-deps
|
||||
|
||||
- name: Build Linux app
|
||||
run: npm run build:linux
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload Linux build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-installer
|
||||
path: release/**/*.AppImage
|
||||
retention-days: 30
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
|
||||
typecheck:
|
||||
name: Type Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npx tsc --noEmit
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npx vite build
|
||||
@@ -9,6 +9,7 @@ lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-electron
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
@@ -23,5 +24,5 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
release/**
|
||||
|
||||
*.kiro/
|
||||
# npx electron-builder --mac --win
|
||||
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
@@ -1,70 +1,99 @@
|
||||
<p align="center">
|
||||
<img src="openscreen.png" alt="OpenScreen Logo" width="64" />
|
||||
</p>
|
||||
|
||||
|
||||
# <p align="center">OpenScreen</p>
|
||||
|
||||
<p align="center"><strong>OpenScreen is your free, open-source alternative to Screen Studio (sort of).</strong></p>
|
||||
|
||||
|
||||
If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need, making beautiful product demos and walkthroughs, here's a free-to-use app for you. OpenScreen does not offer all Screen Studio features, but covers the basics well!
|
||||
|
||||
Screen Studio is an awesome product and this is definitely not a 1:1 clone. OpenScreen is a much simpler take, just the basics for folks who want control and don't want to pay. If you need all the fancy features, your best bet is to support Screen Studio (they really do a great job, haha). But if you just want something free (no gotchas) and open, this project does the job!
|
||||
|
||||
OpenScreen is 100% free for personal and commercial use. Use it, modify it, distribute it. (Just be cool 😁 and give a shoutout if you feel like it !)
|
||||
|
||||
|
||||
|
||||
**⚠️ DISCLAIMER: This is very much in beta and might be buggy here and there (but hope you have a good experience!).**
|
||||
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="preview.png" alt="OpenScreen App Preview" style="height: 320px; margin-right: 12px;" />
|
||||
<img src="preview2.png" alt="OpenScreen App Preview 2" style="height: 320px; margin-right: 12px;" />
|
||||
<img src="preview3.png" alt="OpenScreen App Preview 3" style="height: 320px; margin-right: 12px;" />
|
||||
<img src="preview4.png" alt="OpenScreen App Preview 4" style="height: 320px; margin-right: 12px;" />
|
||||
|
||||
</p>
|
||||
</p>
|
||||
|
||||
## Core Features
|
||||
- Record your whole screen or specific apps
|
||||
- Add manual zooms (customizable depth levels)
|
||||
- Customize the duration and position of zooms however you please
|
||||
- Crop video recordings to hide parts
|
||||
- Choose between wallpapers, solid colors, or gradients for your background
|
||||
- Motion blur and exponential easing for smoother pan and zoom effects
|
||||
|
||||
**Note:**
|
||||
- After you install the app, you'll need to grant it accessibility and screen recording permissions for it to work properly.
|
||||
|
||||
|
||||
## macOS Installation Instructions
|
||||
|
||||
Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page.
|
||||
|
||||
If you encounter issues with macOS Gatekeeper blocking the app (since it does not come with a developer certificate), you can bypass this by running the following command in your terminal after installation:
|
||||
|
||||
```bash
|
||||
xattr -rd com.apple.quarantine /Applications/Openscreen.app
|
||||
```
|
||||
|
||||
After running this command, proceed to **System Preferences > Security & Privacy** to grant the necessary permissions for "screen recording" and "accessibility". Once permissions are granted, you can launch the app.
|
||||
|
||||
## Built with
|
||||
- Electron
|
||||
- React
|
||||
- TypeScript
|
||||
- Vite
|
||||
- PixiJS
|
||||
- dnd-timeline
|
||||
|
||||
---
|
||||
|
||||
|
||||
_I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue 🙏_
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](./LICENSE). By using this software, you agree that the authors are not liable for any issues, damages, or claims arising from its use.
|
||||
> [!WARNING]
|
||||
> This is very much in beta and might be buggy here and there (but hope you have a good experience!).
|
||||
|
||||
<p align="center">
|
||||
<img src="public/openscreen.png" alt="OpenScreen Logo" width="64" />
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://deepwiki.com/siddharthvaddem/openscreen">
|
||||
<img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# <p align="center">OpenScreen</p>
|
||||
|
||||
<p align="center"><strong>OpenScreen is your free, open-source alternative to Screen Studio (sort of).</strong></p>
|
||||
|
||||
If you don't want to pay $29/month for Screen Studio but want a much simpler version that does what most people seem to need, making beautiful product demos and walkthroughs, here's a free-to-use app for you. OpenScreen does not offer all Screen Studio features, but covers the basics well!
|
||||
|
||||
Screen Studio is an awesome product and this is definitely not a 1:1 clone. OpenScreen is a much simpler take, just the basics for folks who want control and don't want to pay. If you need all the fancy features, your best bet is to support Screen Studio (they really do a great job, haha). But if you just want something free (no gotchas) and open, this project does the job!
|
||||
|
||||
OpenScreen is 100% free for personal and commercial use. Use it, modify it, distribute it. (Just be cool 😁 and give a shoutout if you feel like it !)
|
||||
|
||||
<p align="center">
|
||||
<img src="public/preview3.png" alt="OpenScreen App Preview 3" style="height: 320px; margin-right: 12px;" />
|
||||
<img src="public/preview4.png" alt="OpenScreen App Preview 4" style="height: 320px; margin-right: 12px;" />
|
||||
</p>
|
||||
|
||||
## Core Features
|
||||
- Record your whole screen or specific windows.
|
||||
- Add Automatic zooms or manual zooms (customizable depth levels).
|
||||
- Record microphone audio and system audio capture.
|
||||
- Customize the duration and position of zooms however you please.
|
||||
- Crop video recordings to hide parts.
|
||||
- Choose between wallpapers, solid colors, gradients or a custom background.
|
||||
- Motion blur for smoother pan and zoom effects.
|
||||
- Add annotations (text, arrows, images).
|
||||
- Trim sections of the clip.
|
||||
- Customize speed at different segments.
|
||||
- Export in different aspect ratios and resolutions.
|
||||
|
||||
## Installation
|
||||
|
||||
Download the latest installer for your platform from the [GitHub Releases](https://github.com/siddharthvaddem/openscreen/releases) page.
|
||||
|
||||
### macOS
|
||||
|
||||
If you encounter issues with macOS Gatekeeper blocking the app (since it does not come with a developer certificate), you can bypass this by running the following command in your terminal after installation:
|
||||
|
||||
```bash
|
||||
xattr -rd com.apple.quarantine /Applications/Openscreen.app
|
||||
```
|
||||
|
||||
Note: Give your terminal Full Disk Access in **System Settings > Privacy & Security** to grant you access and then run the above command.
|
||||
|
||||
After running this command, proceed to **System Preferences > Security & Privacy** to grant the necessary permissions for "screen recording" and "accessibility". Once permissions are granted, you can launch the app.
|
||||
|
||||
### Linux
|
||||
|
||||
Download the `.AppImage` file from the releases page. Make it executable and run:
|
||||
|
||||
```bash
|
||||
chmod +x Openscreen-Linux-*.AppImage
|
||||
./Openscreen-Linux-*.AppImage
|
||||
```
|
||||
|
||||
You may need to grant screen recording permissions depending on your desktop environment.
|
||||
|
||||
**Note:** If the app fails to launch due to a "sandbox" error, run it with --no-sandbox:
|
||||
```bash
|
||||
./Openscreen-Linux-*.AppImage --no-sandbox
|
||||
```
|
||||
|
||||
### Limitations
|
||||
|
||||
System audio capture relies on Electron's [desktopCapturer](https://www.electronjs.org/docs/latest/api/desktop-capturer) and has some platform-specific quirks:
|
||||
|
||||
- **macOS**: Requires macOS 13+. On macOS 14.2+ you'll be prompted to grant audio capture permission. macOS 12 and below does not support system audio (mic still work).
|
||||
- **Windows**: Works out of the box.
|
||||
- **Linux**: Needs PipeWire (default on Ubuntu 22.04+, Fedora 34+). Older PulseAudio-only setups may not support system audio (mic should still works).
|
||||
|
||||
## Built with
|
||||
- Electron
|
||||
- React
|
||||
- TypeScript
|
||||
- Vite
|
||||
- PixiJS
|
||||
- dnd-timeline
|
||||
|
||||
---
|
||||
|
||||
_I'm new to open source, idk what I'm doing lol. If something is wrong please raise an issue 🙏_
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! If you’d like to help out or see what’s currently being worked on, take a look at the open issues and the [project roadmap](https://github.com/users/siddharthvaddem/projects/3) to understand the current direction of the project and find ways to contribute.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](./LICENSE). By using this software, you agree that the authors are not liable for any issues, damages, or claims arising from its use.
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
|
||||
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
||||
"files": { "ignoreUnknown": false, "includes": ["**", "!**/*.css"] },
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"formatWithErrors": true,
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 100,
|
||||
"attributePosition": "auto"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": false,
|
||||
"complexity": {
|
||||
"noAdjacentSpacesInRegex": "error",
|
||||
"noBannedTypes": "error",
|
||||
"noExtraBooleanCast": "error",
|
||||
"noUselessCatch": "error",
|
||||
"noUselessEscapeInRegex": "error",
|
||||
"noUselessThisAlias": "error",
|
||||
"noUselessTypeConstraint": "error"
|
||||
},
|
||||
"correctness": {
|
||||
"noConstAssign": "error",
|
||||
"noConstantCondition": "error",
|
||||
"noEmptyCharacterClassInRegex": "error",
|
||||
"noEmptyPattern": "error",
|
||||
"noGlobalObjectCalls": "error",
|
||||
"noInnerDeclarations": "error",
|
||||
"noInvalidConstructorSuper": "error",
|
||||
"noNonoctalDecimalEscape": "error",
|
||||
"noPrecisionLoss": "error",
|
||||
"noSelfAssign": "error",
|
||||
"noSetterReturn": "error",
|
||||
"noSwitchDeclarations": "error",
|
||||
"noUndeclaredVariables": "error",
|
||||
"noUnreachable": "error",
|
||||
"noUnreachableSuper": "error",
|
||||
"noUnsafeFinally": "error",
|
||||
"noUnsafeOptionalChaining": "error",
|
||||
"noUnusedLabels": "error",
|
||||
"noUnusedVariables": "error",
|
||||
"useExhaustiveDependencies": "warn",
|
||||
"useHookAtTopLevel": "error",
|
||||
"useIsNan": "error",
|
||||
"useValidForDirection": "error",
|
||||
"useValidTypeof": "error",
|
||||
"useYield": "error"
|
||||
},
|
||||
"style": {
|
||||
"noNamespace": "off",
|
||||
"useArrayLiterals": "error",
|
||||
"useAsConstAssertion": "error",
|
||||
"useComponentExportOnlyModules": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noAssignInExpressions": "error",
|
||||
"noAsyncPromiseExecutor": "error",
|
||||
"noCatchAssign": "error",
|
||||
"noClassAssign": "error",
|
||||
"noCompareNegZero": "error",
|
||||
"noControlCharactersInRegex": "error",
|
||||
"noDebugger": "error",
|
||||
"noDuplicateCase": "error",
|
||||
"noDuplicateClassMembers": "error",
|
||||
"noDuplicateElseIf": "error",
|
||||
"noDuplicateObjectKeys": "error",
|
||||
"noDuplicateParameters": "error",
|
||||
"noEmptyBlockStatements": "warn",
|
||||
"noExplicitAny": "warn",
|
||||
"noExtraNonNullAssertion": "error",
|
||||
"noFallthroughSwitchClause": "error",
|
||||
"noFunctionAssign": "error",
|
||||
"noGlobalAssign": "error",
|
||||
"noImportAssign": "error",
|
||||
"noIrregularWhitespace": "error",
|
||||
"noMisleadingCharacterClass": "error",
|
||||
"noMisleadingInstantiator": "error",
|
||||
"noNonNullAssertedOptionalChain": "error",
|
||||
"noPrototypeBuiltins": "error",
|
||||
"noRedeclare": "error",
|
||||
"noShadowRestrictedNames": "error",
|
||||
"noSparseArray": "error",
|
||||
"noTsIgnore": "error",
|
||||
"noUnsafeDeclarationMerging": "error",
|
||||
"noUnsafeNegation": "error",
|
||||
"noUselessRegexBackrefs": "error",
|
||||
"noWith": "error",
|
||||
"useGetterReturn": "error"
|
||||
}
|
||||
},
|
||||
"includes": ["**", "**/dist", "**/.eslintrc.cjs", "!**/*.css"]
|
||||
},
|
||||
"javascript": { "formatter": { "quoteStyle": "double" } },
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["*.ts", "*.tsx", "*.mts", "*.cts"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"complexity": { "noArguments": "error" },
|
||||
"correctness": {
|
||||
"noConstAssign": "off",
|
||||
"noGlobalObjectCalls": "off",
|
||||
"noInvalidBuiltinInstantiation": "off",
|
||||
"noInvalidConstructorSuper": "off",
|
||||
"noSetterReturn": "off",
|
||||
"noUndeclaredVariables": "off",
|
||||
"noUnreachable": "off",
|
||||
"noUnreachableSuper": "off"
|
||||
},
|
||||
"style": { "useConst": "error" },
|
||||
"suspicious": {
|
||||
"noDuplicateClassMembers": "off",
|
||||
"noDuplicateObjectKeys": "off",
|
||||
"noDuplicateParameters": "off",
|
||||
"noFunctionAssign": "off",
|
||||
"noImportAssign": "off",
|
||||
"noRedeclare": "off",
|
||||
"noUnsafeNegation": "off",
|
||||
"noVar": "error",
|
||||
"useGetterReturn": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": { "source": { "organizeImports": "on" } }
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.cjs",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "stone",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.cjs",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "stone",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
|
||||
@@ -1,483 +0,0 @@
|
||||
import { BrowserWindow, screen, ipcMain, desktopCapturer, shell, app, nativeImage, Tray, Menu } from "electron";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import { uIOhook } from "uiohook-napi";
|
||||
const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
|
||||
const APP_ROOT = path.join(__dirname$1, "..");
|
||||
const VITE_DEV_SERVER_URL$1 = process.env["VITE_DEV_SERVER_URL"];
|
||||
const RENDERER_DIST$1 = path.join(APP_ROOT, "dist");
|
||||
function createHudOverlayWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 250,
|
||||
height: 80,
|
||||
minWidth: 250,
|
||||
maxWidth: 250,
|
||||
minHeight: 80,
|
||||
maxHeight: 80,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname$1, "preload.mjs"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
backgroundThrottling: false
|
||||
}
|
||||
});
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
win == null ? void 0 : win.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
|
||||
});
|
||||
if (VITE_DEV_SERVER_URL$1) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL$1 + "?windowType=hud-overlay");
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST$1, "index.html"), {
|
||||
query: { windowType: "hud-overlay" }
|
||||
});
|
||||
}
|
||||
return win;
|
||||
}
|
||||
function createEditorWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
transparent: false,
|
||||
resizable: true,
|
||||
alwaysOnTop: false,
|
||||
skipTaskbar: false,
|
||||
title: "OpenScreen",
|
||||
backgroundColor: "#000000",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname$1, "preload.mjs"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: false
|
||||
}
|
||||
});
|
||||
win.maximize();
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
win == null ? void 0 : win.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
|
||||
});
|
||||
if (VITE_DEV_SERVER_URL$1) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL$1 + "?windowType=editor");
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST$1, "index.html"), {
|
||||
query: { windowType: "editor" }
|
||||
});
|
||||
}
|
||||
return win;
|
||||
}
|
||||
function createSourceSelectorWindow() {
|
||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
||||
const win = new BrowserWindow({
|
||||
width: 620,
|
||||
height: 420,
|
||||
minHeight: 350,
|
||||
maxHeight: 500,
|
||||
x: Math.round((width - 620) / 2),
|
||||
y: Math.round((height - 420) / 2),
|
||||
frame: false,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
transparent: true,
|
||||
backgroundColor: "#00000000",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname$1, "preload.mjs"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true
|
||||
}
|
||||
});
|
||||
if (VITE_DEV_SERVER_URL$1) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL$1 + "?windowType=source-selector");
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST$1, "index.html"), {
|
||||
query: { windowType: "source-selector" }
|
||||
});
|
||||
}
|
||||
return win;
|
||||
}
|
||||
let isMouseTrackingActive = false;
|
||||
let isHookStarted = false;
|
||||
let recordingStartTime = 0;
|
||||
let mouseEventData = [];
|
||||
function startMouseTracking() {
|
||||
if (isMouseTrackingActive) {
|
||||
return { success: false, message: "Already tracking" };
|
||||
}
|
||||
isMouseTrackingActive = true;
|
||||
recordingStartTime = performance.now();
|
||||
mouseEventData = [];
|
||||
if (!isHookStarted) {
|
||||
setupMouseEventListeners();
|
||||
try {
|
||||
uIOhook.start();
|
||||
isHookStarted = true;
|
||||
return { success: true, message: "Mouse tracking started", startTime: recordingStartTime };
|
||||
} catch (error) {
|
||||
console.error("Failed to start mouse tracking:", error);
|
||||
isMouseTrackingActive = false;
|
||||
return { success: false, message: "Failed to start hook", error };
|
||||
}
|
||||
} else {
|
||||
return { success: true, message: "Mouse tracking resumed", startTime: recordingStartTime };
|
||||
}
|
||||
}
|
||||
function stopMouseTracking() {
|
||||
if (!isMouseTrackingActive) {
|
||||
return { success: false, message: "Not currently tracking" };
|
||||
}
|
||||
isMouseTrackingActive = false;
|
||||
const duration = performance.now() - recordingStartTime;
|
||||
const session = {
|
||||
startTime: recordingStartTime,
|
||||
events: mouseEventData,
|
||||
duration
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
message: "Mouse tracking stopped",
|
||||
data: session
|
||||
};
|
||||
}
|
||||
function setupMouseEventListeners() {
|
||||
uIOhook.on("mousemove", (e) => {
|
||||
if (isMouseTrackingActive) {
|
||||
const timestamp = performance.now() - recordingStartTime;
|
||||
const event = {
|
||||
type: "move",
|
||||
timestamp,
|
||||
x: e.x,
|
||||
y: e.y
|
||||
};
|
||||
mouseEventData.push(event);
|
||||
}
|
||||
});
|
||||
uIOhook.on("mousedown", (e) => {
|
||||
if (isMouseTrackingActive) {
|
||||
const timestamp = performance.now() - recordingStartTime;
|
||||
const event = {
|
||||
type: "down",
|
||||
timestamp,
|
||||
x: e.x,
|
||||
y: e.y,
|
||||
button: e.button,
|
||||
clicks: e.clicks
|
||||
};
|
||||
mouseEventData.push(event);
|
||||
}
|
||||
});
|
||||
uIOhook.on("mouseup", (e) => {
|
||||
if (isMouseTrackingActive) {
|
||||
const timestamp = performance.now() - recordingStartTime;
|
||||
const event = {
|
||||
type: "up",
|
||||
timestamp,
|
||||
x: e.x,
|
||||
y: e.y,
|
||||
button: e.button
|
||||
};
|
||||
mouseEventData.push(event);
|
||||
}
|
||||
});
|
||||
uIOhook.on("click", (e) => {
|
||||
if (isMouseTrackingActive) {
|
||||
const timestamp = performance.now() - recordingStartTime;
|
||||
const event = {
|
||||
type: "click",
|
||||
timestamp,
|
||||
x: e.x,
|
||||
y: e.y,
|
||||
button: e.button,
|
||||
clicks: e.clicks
|
||||
};
|
||||
mouseEventData.push(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
function getTrackingData() {
|
||||
return [...mouseEventData];
|
||||
}
|
||||
function cleanupMouseTracking() {
|
||||
if (isHookStarted) {
|
||||
try {
|
||||
uIOhook.stop();
|
||||
isHookStarted = false;
|
||||
isMouseTrackingActive = false;
|
||||
mouseEventData = [];
|
||||
} catch (error) {
|
||||
console.error("Error cleaning up mouse tracking:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
let selectedSource = null;
|
||||
function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, getMainWindow, getSourceSelectorWindow, onRecordingStateChange) {
|
||||
ipcMain.handle("get-sources", async (_, opts) => {
|
||||
const sources = await desktopCapturer.getSources(opts);
|
||||
return sources.map((source) => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
display_id: source.display_id,
|
||||
thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
|
||||
appIcon: source.appIcon ? source.appIcon.toDataURL() : null
|
||||
}));
|
||||
});
|
||||
ipcMain.handle("select-source", (_, source) => {
|
||||
selectedSource = source;
|
||||
const sourceSelectorWin = getSourceSelectorWindow();
|
||||
if (sourceSelectorWin) {
|
||||
sourceSelectorWin.close();
|
||||
}
|
||||
return selectedSource;
|
||||
});
|
||||
ipcMain.handle("get-selected-source", () => {
|
||||
return selectedSource;
|
||||
});
|
||||
ipcMain.handle("open-source-selector", () => {
|
||||
const sourceSelectorWin = getSourceSelectorWindow();
|
||||
if (sourceSelectorWin) {
|
||||
sourceSelectorWin.focus();
|
||||
return;
|
||||
}
|
||||
createSourceSelectorWindow2();
|
||||
});
|
||||
ipcMain.handle("switch-to-editor", () => {
|
||||
const mainWin = getMainWindow();
|
||||
if (mainWin) {
|
||||
mainWin.close();
|
||||
}
|
||||
createEditorWindow2();
|
||||
});
|
||||
ipcMain.handle("start-mouse-tracking", () => {
|
||||
return startMouseTracking();
|
||||
});
|
||||
ipcMain.handle("stop-mouse-tracking", () => {
|
||||
return stopMouseTracking();
|
||||
});
|
||||
ipcMain.handle("store-recorded-video", async (_, videoData, fileName) => {
|
||||
try {
|
||||
const videoPath = path.join(RECORDINGS_DIR, fileName);
|
||||
await fs.writeFile(videoPath, Buffer.from(videoData));
|
||||
return {
|
||||
success: true,
|
||||
path: videoPath,
|
||||
message: "Video stored successfully"
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to store video:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to store video",
|
||||
error: String(error)
|
||||
};
|
||||
}
|
||||
});
|
||||
ipcMain.handle("store-mouse-tracking-data", async (_, fileName) => {
|
||||
try {
|
||||
const data = getTrackingData();
|
||||
if (data.length === 0) {
|
||||
return { success: false, message: "No tracking data to save" };
|
||||
}
|
||||
const trackingPath = path.join(RECORDINGS_DIR, fileName);
|
||||
await fs.writeFile(trackingPath, JSON.stringify(data, null, 2), "utf-8");
|
||||
return {
|
||||
success: true,
|
||||
path: trackingPath,
|
||||
eventCount: data.length,
|
||||
message: "Mouse tracking data stored successfully"
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to store mouse tracking data:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to store mouse tracking data",
|
||||
error: String(error)
|
||||
};
|
||||
}
|
||||
});
|
||||
ipcMain.handle("get-recorded-video-path", async () => {
|
||||
try {
|
||||
const files = await fs.readdir(RECORDINGS_DIR);
|
||||
const videoFiles = files.filter((file) => file.endsWith(".webm"));
|
||||
if (videoFiles.length === 0) {
|
||||
return { success: false, message: "No recorded video found" };
|
||||
}
|
||||
const latestVideo = videoFiles.sort().reverse()[0];
|
||||
const videoPath = path.join(RECORDINGS_DIR, latestVideo);
|
||||
return { success: true, path: videoPath };
|
||||
} catch (error) {
|
||||
console.error("Failed to get video path:", error);
|
||||
return { success: false, message: "Failed to get video path", error: String(error) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle("set-recording-state", (_, recording) => {
|
||||
const source = selectedSource || { name: "Screen" };
|
||||
if (onRecordingStateChange) {
|
||||
onRecordingStateChange(recording, source.name);
|
||||
}
|
||||
});
|
||||
ipcMain.handle("open-external-url", async (_, url) => {
|
||||
try {
|
||||
await shell.openExternal(url);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to open URL:", error);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle("get-asset-base-path", () => {
|
||||
try {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, "assets");
|
||||
}
|
||||
return path.join(app.getAppPath(), "public", "assets");
|
||||
} catch (err) {
|
||||
console.error("Failed to resolve asset base path:", err);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
ipcMain.handle("save-exported-video", async (_, videoData, fileName) => {
|
||||
try {
|
||||
const downloadsPath = app.getPath("downloads");
|
||||
const videoPath = path.join(downloadsPath, fileName);
|
||||
await fs.writeFile(videoPath, Buffer.from(videoData));
|
||||
return {
|
||||
success: true,
|
||||
path: videoPath,
|
||||
message: "Video exported successfully"
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to save exported video:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to save exported video",
|
||||
error: String(error)
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
|
||||
async function cleanupOldRecordings() {
|
||||
try {
|
||||
const files = await fs.readdir(RECORDINGS_DIR);
|
||||
const now = Date.now();
|
||||
const maxAge = 1 * 24 * 60 * 60 * 1e3;
|
||||
for (const file of files) {
|
||||
const filePath = path.join(RECORDINGS_DIR, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
if (now - stats.mtimeMs > maxAge) {
|
||||
await fs.unlink(filePath);
|
||||
console.log(`Deleted old recording: ${file}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to cleanup old recordings:", error);
|
||||
}
|
||||
}
|
||||
async function ensureRecordingsDir() {
|
||||
try {
|
||||
await fs.mkdir(RECORDINGS_DIR, { recursive: true });
|
||||
console.log("Recordings directory ready:", RECORDINGS_DIR);
|
||||
} catch (error) {
|
||||
console.error("Failed to create recordings directory:", error);
|
||||
}
|
||||
}
|
||||
process.env.APP_ROOT = path.join(__dirname, "..");
|
||||
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
|
||||
const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron");
|
||||
const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
|
||||
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, "public") : RENDERER_DIST;
|
||||
let mainWindow = null;
|
||||
let sourceSelectorWindow = null;
|
||||
let tray = null;
|
||||
let selectedSourceName = "";
|
||||
function createWindow() {
|
||||
mainWindow = createHudOverlayWindow();
|
||||
}
|
||||
function createTray() {
|
||||
const iconPath = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, "rec-button.png");
|
||||
let icon = nativeImage.createFromPath(iconPath);
|
||||
icon = icon.resize({ width: 24, height: 24, quality: "best" });
|
||||
tray = new Tray(icon);
|
||||
updateTrayMenu();
|
||||
}
|
||||
function updateTrayMenu() {
|
||||
if (!tray) return;
|
||||
const menuTemplate = [
|
||||
{
|
||||
label: "Stop Recording",
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("stop-recording-from-tray");
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
const contextMenu = Menu.buildFromTemplate(menuTemplate);
|
||||
tray.setContextMenu(contextMenu);
|
||||
tray.setToolTip(`Recording: ${selectedSourceName}`);
|
||||
}
|
||||
function createEditorWindowWrapper() {
|
||||
if (mainWindow) {
|
||||
mainWindow.close();
|
||||
mainWindow = null;
|
||||
}
|
||||
mainWindow = createEditorWindow();
|
||||
}
|
||||
function createSourceSelectorWindowWrapper() {
|
||||
sourceSelectorWindow = createSourceSelectorWindow();
|
||||
sourceSelectorWindow.on("closed", () => {
|
||||
sourceSelectorWindow = null;
|
||||
});
|
||||
return sourceSelectorWindow;
|
||||
}
|
||||
app.on("window-all-closed", () => {
|
||||
});
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
app.on("before-quit", async (event) => {
|
||||
event.preventDefault();
|
||||
cleanupMouseTracking();
|
||||
await cleanupOldRecordings();
|
||||
app.exit(0);
|
||||
});
|
||||
app.whenReady().then(async () => {
|
||||
await ensureRecordingsDir();
|
||||
registerIpcHandlers(
|
||||
createEditorWindowWrapper,
|
||||
createSourceSelectorWindowWrapper,
|
||||
() => mainWindow,
|
||||
() => sourceSelectorWindow,
|
||||
(recording, sourceName) => {
|
||||
selectedSourceName = sourceName;
|
||||
if (recording) {
|
||||
if (!tray) createTray();
|
||||
updateTrayMenu();
|
||||
if (mainWindow) mainWindow.minimize();
|
||||
} else {
|
||||
if (tray) {
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
}
|
||||
if (mainWindow) mainWindow.restore();
|
||||
}
|
||||
}
|
||||
);
|
||||
createWindow();
|
||||
});
|
||||
export {
|
||||
MAIN_DIST,
|
||||
RECORDINGS_DIR,
|
||||
RENDERER_DIST,
|
||||
VITE_DEV_SERVER_URL
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
"use strict";
|
||||
const electron = require("electron");
|
||||
electron.contextBridge.exposeInMainWorld("electronAPI", {
|
||||
getAssetBasePath: async () => {
|
||||
return await electron.ipcRenderer.invoke("get-asset-base-path");
|
||||
},
|
||||
getSources: async (opts) => {
|
||||
return await electron.ipcRenderer.invoke("get-sources", opts);
|
||||
},
|
||||
switchToEditor: () => {
|
||||
return electron.ipcRenderer.invoke("switch-to-editor");
|
||||
},
|
||||
openSourceSelector: () => {
|
||||
return electron.ipcRenderer.invoke("open-source-selector");
|
||||
},
|
||||
selectSource: (source) => {
|
||||
return electron.ipcRenderer.invoke("select-source", source);
|
||||
},
|
||||
getSelectedSource: () => {
|
||||
return electron.ipcRenderer.invoke("get-selected-source");
|
||||
},
|
||||
startMouseTracking: () => {
|
||||
return electron.ipcRenderer.invoke("start-mouse-tracking");
|
||||
},
|
||||
stopMouseTracking: () => {
|
||||
return electron.ipcRenderer.invoke("stop-mouse-tracking");
|
||||
},
|
||||
storeRecordedVideo: (videoData, fileName) => {
|
||||
return electron.ipcRenderer.invoke("store-recorded-video", videoData, fileName);
|
||||
},
|
||||
storeMouseTrackingData: (fileName) => {
|
||||
return electron.ipcRenderer.invoke("store-mouse-tracking-data", fileName);
|
||||
},
|
||||
getRecordedVideoPath: () => {
|
||||
return electron.ipcRenderer.invoke("get-recorded-video-path");
|
||||
},
|
||||
setRecordingState: (recording) => {
|
||||
return electron.ipcRenderer.invoke("set-recording-state", recording);
|
||||
},
|
||||
onStopRecordingFromTray: (callback) => {
|
||||
const listener = () => callback();
|
||||
electron.ipcRenderer.on("stop-recording-from-tray", listener);
|
||||
return () => electron.ipcRenderer.removeListener("stop-recording-from-tray", listener);
|
||||
},
|
||||
openExternalUrl: (url) => {
|
||||
return electron.ipcRenderer.invoke("open-external-url", url);
|
||||
},
|
||||
saveExportedVideo: (videoData, fileName) => {
|
||||
return electron.ipcRenderer.invoke("save-exported-video", videoData, fileName);
|
||||
}
|
||||
});
|
||||
@@ -6,12 +6,19 @@
|
||||
"productName": "Openscreen",
|
||||
"npmRebuild": true,
|
||||
"buildDependenciesFromSource": true,
|
||||
"compression": "normal",
|
||||
"directories": {
|
||||
"output": "release/${version}"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"dist-electron"
|
||||
"dist-electron",
|
||||
"!*.png",
|
||||
"!preview*.png",
|
||||
"!*.md",
|
||||
"!README.md",
|
||||
"!CONTRIBUTING.md",
|
||||
"!LICENSE"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
@@ -19,17 +26,33 @@
|
||||
"to": "assets/wallpapers"
|
||||
}
|
||||
],
|
||||
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg"
|
||||
],
|
||||
"icon": "icons/icons/mac/icon.icns",
|
||||
"artifactName": "${productName}-Mac-${version}-Installer.${ext}"
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": ["x64", "arm64"]
|
||||
}
|
||||
],
|
||||
"icon": "icons/icons/mac/icon.icns",
|
||||
"artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}",
|
||||
"extendInfo": {
|
||||
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
|
||||
"NSCameraUseContinuityCameraDeviceType": true
|
||||
}
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage"
|
||||
],
|
||||
"artifactName": "${productName}-Linux-${version}.${ext}"
|
||||
"icon": "icons/icons/png",
|
||||
"artifactName": "${productName}-Linux-${version}.${ext}",
|
||||
"category": "AudioVideo"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "icons/icons/win/icon.ico"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +1,110 @@
|
||||
/// <reference types="vite-plugin-electron/electron-env" />
|
||||
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
/**
|
||||
* The built directory structure
|
||||
*
|
||||
* ```tree
|
||||
* ├─┬─┬ dist
|
||||
* │ │ └── index.html
|
||||
* │ │
|
||||
* │ ├─┬ dist-electron
|
||||
* │ │ ├── main.js
|
||||
* │ │ └── preload.js
|
||||
* │
|
||||
* ```
|
||||
*/
|
||||
APP_ROOT: string
|
||||
/** /dist/ or /public/ */
|
||||
VITE_PUBLIC: string
|
||||
}
|
||||
}
|
||||
|
||||
// Used in Renderer process, expose in `preload.ts`
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>
|
||||
switchToEditor: () => Promise<void>
|
||||
openSourceSelector: () => Promise<void>
|
||||
selectSource: (source: any) => Promise<any>
|
||||
getSelectedSource: () => Promise<any>
|
||||
startMouseTracking: () => Promise<void>
|
||||
stopMouseTracking: () => Promise<void>
|
||||
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }>
|
||||
storeMouseTrackingData: (fileName: string) => Promise<{ success: boolean; path?: string; eventCount?: number; message?: string }>
|
||||
getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>
|
||||
setRecordingState: (recording: boolean) => Promise<void>
|
||||
onStopRecordingFromTray: (callback: () => void) => () => void
|
||||
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>
|
||||
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }>
|
||||
}
|
||||
}
|
||||
|
||||
interface ProcessedDesktopSource {
|
||||
id: string
|
||||
name: string
|
||||
display_id: string
|
||||
thumbnail: string | null
|
||||
appIcon: string | null
|
||||
}
|
||||
/// <reference types="vite-plugin-electron/electron-env" />
|
||||
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
/**
|
||||
* The built directory structure
|
||||
*
|
||||
* ```tree
|
||||
* ├─┬─┬ dist
|
||||
* │ │ └── index.html
|
||||
* │ │
|
||||
* │ ├─┬ dist-electron
|
||||
* │ │ ├── main.js
|
||||
* │ │ └── preload.js
|
||||
* │
|
||||
* ```
|
||||
*/
|
||||
APP_ROOT: string;
|
||||
/** /dist/ or /public/ */
|
||||
VITE_PUBLIC: string;
|
||||
}
|
||||
}
|
||||
|
||||
// Used in Renderer process, expose in `preload.ts`
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
|
||||
switchToEditor: () => Promise<void>;
|
||||
openSourceSelector: () => Promise<void>;
|
||||
selectSource: (source: any) => Promise<any>;
|
||||
getSelectedSource: () => Promise<any>;
|
||||
storeRecordedVideo: (
|
||||
videoData: ArrayBuffer,
|
||||
fileName: string,
|
||||
) => Promise<{ success: boolean; path?: string; message?: string }>;
|
||||
getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>;
|
||||
setRecordingState: (recording: boolean) => Promise<void>;
|
||||
getCursorTelemetry: (videoPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
samples: CursorTelemetryPoint[];
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
onStopRecordingFromTray: (callback: () => void) => () => void;
|
||||
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
saveExportedVideo: (
|
||||
videoData: ArrayBuffer,
|
||||
fileName: string,
|
||||
) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
|
||||
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
|
||||
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
|
||||
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>;
|
||||
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
|
||||
saveProjectFile: (
|
||||
projectData: unknown,
|
||||
suggestedName?: string,
|
||||
existingProjectPath?: string,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
message?: string;
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
loadProjectFile: () => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
project?: unknown;
|
||||
message?: string;
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
loadCurrentProjectFile: () => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
project?: unknown;
|
||||
message?: string;
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
onMenuLoadProject: (callback: () => void) => () => void;
|
||||
onMenuSaveProject: (callback: () => void) => () => void;
|
||||
onMenuSaveProjectAs: (callback: () => void) => () => void;
|
||||
getPlatform: () => Promise<string>;
|
||||
revealInFolder: (
|
||||
filePath: string,
|
||||
) => Promise<{ success: boolean; error?: string; message?: string }>;
|
||||
getShortcuts: () => Promise<Record<string, unknown> | null>;
|
||||
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
|
||||
hudOverlayHide: () => void;
|
||||
hudOverlayClose: () => void;
|
||||
setMicrophoneExpanded: (expanded: boolean) => void;
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||
onRequestSaveBeforeClose: (callback: () => Promise<void>) => () => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProcessedDesktopSource {
|
||||
id: string;
|
||||
name: string;
|
||||
display_id: string;
|
||||
thumbnail: string | null;
|
||||
appIcon: string | null;
|
||||
}
|
||||
|
||||
interface CursorTelemetryPoint {
|
||||
timeMs: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
}
|
||||
|
||||
@@ -1,181 +1,539 @@
|
||||
import { ipcMain, desktopCapturer, BrowserWindow, shell, app } from 'electron'
|
||||
import { startMouseTracking, stopMouseTracking, getTrackingData } from './mouseTracking'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { RECORDINGS_DIR } from '../main'
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, screen, shell } from "electron";
|
||||
import { RECORDINGS_DIR } from "../main";
|
||||
|
||||
let selectedSource: any = null
|
||||
const PROJECT_FILE_EXTENSION = "openscreen";
|
||||
const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json");
|
||||
|
||||
type SelectedSource = {
|
||||
name: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
let selectedSource: SelectedSource | null = null;
|
||||
let currentProjectPath: string | null = null;
|
||||
|
||||
function normalizePath(filePath: string) {
|
||||
return path.resolve(filePath);
|
||||
}
|
||||
|
||||
function isTrustedProjectPath(filePath?: string | null) {
|
||||
if (!filePath || !currentProjectPath) {
|
||||
return false;
|
||||
}
|
||||
return normalizePath(filePath) === normalizePath(currentProjectPath);
|
||||
}
|
||||
|
||||
const CURSOR_TELEMETRY_VERSION = 1;
|
||||
const CURSOR_SAMPLE_INTERVAL_MS = 100;
|
||||
const MAX_CURSOR_SAMPLES = 60 * 60 * 10; // 1 hour @ 10Hz
|
||||
|
||||
interface CursorTelemetryPoint {
|
||||
timeMs: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
}
|
||||
|
||||
let cursorCaptureInterval: NodeJS.Timeout | null = null;
|
||||
let cursorCaptureStartTimeMs = 0;
|
||||
let activeCursorSamples: CursorTelemetryPoint[] = [];
|
||||
let pendingCursorSamples: CursorTelemetryPoint[] = [];
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function stopCursorCapture() {
|
||||
if (cursorCaptureInterval) {
|
||||
clearInterval(cursorCaptureInterval);
|
||||
cursorCaptureInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function sampleCursorPoint() {
|
||||
const cursor = screen.getCursorScreenPoint();
|
||||
const sourceDisplayId = Number(selectedSource?.display_id);
|
||||
const sourceDisplay = Number.isFinite(sourceDisplayId)
|
||||
? (screen.getAllDisplays().find((display) => display.id === sourceDisplayId) ?? null)
|
||||
: null;
|
||||
const display = sourceDisplay ?? screen.getDisplayNearestPoint(cursor);
|
||||
const bounds = display.bounds;
|
||||
const width = Math.max(1, bounds.width);
|
||||
const height = Math.max(1, bounds.height);
|
||||
|
||||
const cx = clamp((cursor.x - bounds.x) / width, 0, 1);
|
||||
const cy = clamp((cursor.y - bounds.y) / height, 0, 1);
|
||||
|
||||
activeCursorSamples.push({
|
||||
timeMs: Math.max(0, Date.now() - cursorCaptureStartTimeMs),
|
||||
cx,
|
||||
cy,
|
||||
});
|
||||
|
||||
if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) {
|
||||
activeCursorSamples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(
|
||||
createEditorWindow: () => void,
|
||||
createSourceSelectorWindow: () => BrowserWindow,
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
getSourceSelectorWindow: () => BrowserWindow | null,
|
||||
onRecordingStateChange?: (recording: boolean, sourceName: string) => void
|
||||
createEditorWindow: () => void,
|
||||
createSourceSelectorWindow: () => BrowserWindow,
|
||||
getMainWindow: () => BrowserWindow | null,
|
||||
getSourceSelectorWindow: () => BrowserWindow | null,
|
||||
onRecordingStateChange?: (recording: boolean, sourceName: string) => void,
|
||||
) {
|
||||
ipcMain.handle('get-sources', async (_, opts) => {
|
||||
const sources = await desktopCapturer.getSources(opts)
|
||||
return sources.map(source => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
display_id: source.display_id,
|
||||
thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
|
||||
appIcon: source.appIcon ? source.appIcon.toDataURL() : null
|
||||
}))
|
||||
})
|
||||
ipcMain.handle("get-sources", async (_, opts) => {
|
||||
const sources = await desktopCapturer.getSources(opts);
|
||||
return sources.map((source) => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
display_id: source.display_id,
|
||||
thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null,
|
||||
appIcon: source.appIcon ? source.appIcon.toDataURL() : null,
|
||||
}));
|
||||
});
|
||||
|
||||
ipcMain.handle('select-source', (_, source) => {
|
||||
selectedSource = source
|
||||
const sourceSelectorWin = getSourceSelectorWindow()
|
||||
if (sourceSelectorWin) {
|
||||
sourceSelectorWin.close()
|
||||
}
|
||||
return selectedSource
|
||||
})
|
||||
ipcMain.handle("select-source", (_, source: SelectedSource) => {
|
||||
selectedSource = source;
|
||||
const sourceSelectorWin = getSourceSelectorWindow();
|
||||
if (sourceSelectorWin) {
|
||||
sourceSelectorWin.close();
|
||||
}
|
||||
return selectedSource;
|
||||
});
|
||||
|
||||
ipcMain.handle('get-selected-source', () => {
|
||||
return selectedSource
|
||||
})
|
||||
ipcMain.handle("get-selected-source", () => {
|
||||
return selectedSource;
|
||||
});
|
||||
|
||||
ipcMain.handle('open-source-selector', () => {
|
||||
const sourceSelectorWin = getSourceSelectorWindow()
|
||||
if (sourceSelectorWin) {
|
||||
sourceSelectorWin.focus()
|
||||
return
|
||||
}
|
||||
createSourceSelectorWindow()
|
||||
})
|
||||
ipcMain.handle("open-source-selector", () => {
|
||||
const sourceSelectorWin = getSourceSelectorWindow();
|
||||
if (sourceSelectorWin) {
|
||||
sourceSelectorWin.focus();
|
||||
return;
|
||||
}
|
||||
createSourceSelectorWindow();
|
||||
});
|
||||
|
||||
ipcMain.handle('switch-to-editor', () => {
|
||||
const mainWin = getMainWindow()
|
||||
if (mainWin) {
|
||||
mainWin.close()
|
||||
}
|
||||
createEditorWindow()
|
||||
})
|
||||
ipcMain.handle("switch-to-editor", () => {
|
||||
const mainWin = getMainWindow();
|
||||
if (mainWin) {
|
||||
mainWin.close();
|
||||
}
|
||||
createEditorWindow();
|
||||
});
|
||||
|
||||
ipcMain.handle('start-mouse-tracking', () => {
|
||||
return startMouseTracking()
|
||||
})
|
||||
ipcMain.handle("store-recorded-video", async (_, videoData: ArrayBuffer, fileName: string) => {
|
||||
try {
|
||||
const videoPath = path.join(RECORDINGS_DIR, fileName);
|
||||
await fs.writeFile(videoPath, Buffer.from(videoData));
|
||||
currentProjectPath = null;
|
||||
|
||||
ipcMain.handle('stop-mouse-tracking', () => {
|
||||
return stopMouseTracking()
|
||||
})
|
||||
const telemetryPath = `${videoPath}.cursor.json`;
|
||||
if (pendingCursorSamples.length > 0) {
|
||||
await fs.writeFile(
|
||||
telemetryPath,
|
||||
JSON.stringify(
|
||||
{ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
pendingCursorSamples = [];
|
||||
|
||||
ipcMain.handle('store-recorded-video', async (_, videoData: ArrayBuffer, fileName: string) => {
|
||||
try {
|
||||
const videoPath = path.join(RECORDINGS_DIR, fileName)
|
||||
await fs.writeFile(videoPath, Buffer.from(videoData))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: videoPath,
|
||||
message: 'Video stored successfully'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to store video:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to store video',
|
||||
error: String(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
path: videoPath,
|
||||
message: "Video stored successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to store video:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to store video",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('store-mouse-tracking-data', async (_, fileName: string) => {
|
||||
try {
|
||||
const data = getTrackingData()
|
||||
|
||||
if (data.length === 0) {
|
||||
return { success: false, message: 'No tracking data to save' }
|
||||
}
|
||||
ipcMain.handle("get-recorded-video-path", async () => {
|
||||
try {
|
||||
const files = await fs.readdir(RECORDINGS_DIR);
|
||||
const videoFiles = files.filter((file) => file.endsWith(".webm"));
|
||||
|
||||
const trackingPath = path.join(RECORDINGS_DIR, fileName)
|
||||
await fs.writeFile(trackingPath, JSON.stringify(data, null, 2), 'utf-8')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: trackingPath,
|
||||
eventCount: data.length,
|
||||
message: 'Mouse tracking data stored successfully'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to store mouse tracking data:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to store mouse tracking data',
|
||||
error: String(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
if (videoFiles.length === 0) {
|
||||
return { success: false, message: "No recorded video found" };
|
||||
}
|
||||
|
||||
ipcMain.handle('get-recorded-video-path', async () => {
|
||||
try {
|
||||
const files = await fs.readdir(RECORDINGS_DIR)
|
||||
const videoFiles = files.filter(file => file.endsWith('.webm'))
|
||||
|
||||
if (videoFiles.length === 0) {
|
||||
return { success: false, message: 'No recorded video found' }
|
||||
}
|
||||
|
||||
const latestVideo = videoFiles.sort().reverse()[0]
|
||||
const videoPath = path.join(RECORDINGS_DIR, latestVideo)
|
||||
|
||||
return { success: true, path: videoPath }
|
||||
} catch (error) {
|
||||
console.error('Failed to get video path:', error)
|
||||
return { success: false, message: 'Failed to get video path', error: String(error) }
|
||||
}
|
||||
})
|
||||
const latestVideo = videoFiles.sort().reverse()[0];
|
||||
const videoPath = path.join(RECORDINGS_DIR, latestVideo);
|
||||
|
||||
ipcMain.handle('set-recording-state', (_, recording: boolean) => {
|
||||
const source = selectedSource || { name: 'Screen' }
|
||||
if (onRecordingStateChange) {
|
||||
onRecordingStateChange(recording, source.name)
|
||||
}
|
||||
})
|
||||
return { success: true, path: videoPath };
|
||||
} catch (error) {
|
||||
console.error("Failed to get video path:", error);
|
||||
return { success: false, message: "Failed to get video path", error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('open-external-url', async (_, url: string) => {
|
||||
try {
|
||||
await shell.openExternal(url)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Failed to open URL:', error)
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
})
|
||||
ipcMain.handle("set-recording-state", (_, recording: boolean) => {
|
||||
if (recording) {
|
||||
stopCursorCapture();
|
||||
activeCursorSamples = [];
|
||||
pendingCursorSamples = [];
|
||||
cursorCaptureStartTimeMs = Date.now();
|
||||
sampleCursorPoint();
|
||||
cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS);
|
||||
} else {
|
||||
stopCursorCapture();
|
||||
pendingCursorSamples = [...activeCursorSamples];
|
||||
activeCursorSamples = [];
|
||||
}
|
||||
|
||||
// Return base path for assets so renderer can resolve file:// paths in production
|
||||
ipcMain.handle('get-asset-base-path', () => {
|
||||
try {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, 'assets')
|
||||
}
|
||||
return path.join(app.getAppPath(), 'public', 'assets')
|
||||
} catch (err) {
|
||||
console.error('Failed to resolve asset base path:', err)
|
||||
return null
|
||||
}
|
||||
})
|
||||
const source = selectedSource || { name: "Screen" };
|
||||
if (onRecordingStateChange) {
|
||||
onRecordingStateChange(recording, source.name);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('save-exported-video', async (_, videoData: ArrayBuffer, fileName: string) => {
|
||||
try {
|
||||
const downloadsPath = app.getPath('downloads')
|
||||
const videoPath = path.join(downloadsPath, fileName)
|
||||
await fs.writeFile(videoPath, Buffer.from(videoData))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: videoPath,
|
||||
message: 'Video exported successfully'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save exported video:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to save exported video',
|
||||
error: String(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
ipcMain.handle("get-cursor-telemetry", async (_, videoPath?: string) => {
|
||||
const targetVideoPath = videoPath ?? currentVideoPath;
|
||||
if (!targetVideoPath) {
|
||||
return { success: true, samples: [] };
|
||||
}
|
||||
|
||||
const telemetryPath = `${targetVideoPath}.cursor.json`;
|
||||
try {
|
||||
const content = await fs.readFile(telemetryPath, "utf-8");
|
||||
const parsed = JSON.parse(content);
|
||||
const rawSamples = Array.isArray(parsed)
|
||||
? parsed
|
||||
: Array.isArray(parsed?.samples)
|
||||
? parsed.samples
|
||||
: [];
|
||||
|
||||
const samples: CursorTelemetryPoint[] = rawSamples
|
||||
.filter((sample: unknown) => Boolean(sample && typeof sample === "object"))
|
||||
.map((sample: unknown) => {
|
||||
const point = sample as Partial<CursorTelemetryPoint>;
|
||||
return {
|
||||
timeMs:
|
||||
typeof point.timeMs === "number" && Number.isFinite(point.timeMs)
|
||||
? Math.max(0, point.timeMs)
|
||||
: 0,
|
||||
cx:
|
||||
typeof point.cx === "number" && Number.isFinite(point.cx)
|
||||
? clamp(point.cx, 0, 1)
|
||||
: 0.5,
|
||||
cy:
|
||||
typeof point.cy === "number" && Number.isFinite(point.cy)
|
||||
? clamp(point.cy, 0, 1)
|
||||
: 0.5,
|
||||
};
|
||||
})
|
||||
.sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs);
|
||||
|
||||
return { success: true, samples };
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code === "ENOENT") {
|
||||
return { success: true, samples: [] };
|
||||
}
|
||||
console.error("Failed to load cursor telemetry:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to load cursor telemetry",
|
||||
error: String(error),
|
||||
samples: [],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("open-external-url", async (_, url: string) => {
|
||||
try {
|
||||
await shell.openExternal(url);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to open URL:", error);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Return base path for assets so renderer can resolve file:// paths in production
|
||||
ipcMain.handle("get-asset-base-path", () => {
|
||||
try {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, "assets");
|
||||
}
|
||||
return path.join(app.getAppPath(), "public", "assets");
|
||||
} catch (err) {
|
||||
console.error("Failed to resolve asset base path:", err);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("save-exported-video", async (_, videoData: ArrayBuffer, fileName: string) => {
|
||||
try {
|
||||
// Determine file type from extension
|
||||
const isGif = fileName.toLowerCase().endsWith(".gif");
|
||||
const filters = isGif
|
||||
? [{ name: "GIF Image", extensions: ["gif"] }]
|
||||
: [{ name: "MP4 Video", extensions: ["mp4"] }];
|
||||
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: isGif ? "Save Exported GIF" : "Save Exported Video",
|
||||
defaultPath: path.join(app.getPath("downloads"), fileName),
|
||||
filters,
|
||||
properties: ["createDirectory", "showOverwriteConfirmation"],
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return {
|
||||
success: false,
|
||||
canceled: true,
|
||||
message: "Export canceled",
|
||||
};
|
||||
}
|
||||
|
||||
await fs.writeFile(result.filePath, Buffer.from(videoData));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: result.filePath,
|
||||
message: "Video exported successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to save exported video:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to save exported video",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("open-video-file-picker", async () => {
|
||||
try {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: "Select Video File",
|
||||
defaultPath: RECORDINGS_DIR,
|
||||
filters: [
|
||||
{ name: "Video Files", extensions: ["webm", "mp4", "mov", "avi", "mkv"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
properties: ["openFile"],
|
||||
});
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { success: false, canceled: true };
|
||||
}
|
||||
|
||||
currentProjectPath = null;
|
||||
return {
|
||||
success: true,
|
||||
path: result.filePaths[0],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to open file picker:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to open file picker",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("reveal-in-folder", async (_, filePath: string) => {
|
||||
try {
|
||||
// shell.showItemInFolder doesn't return a value, it throws on error
|
||||
shell.showItemInFolder(filePath);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(`Error revealing item in folder: ${filePath}`, error);
|
||||
// Fallback to open the directory if revealing the item fails
|
||||
// This might happen if the file was moved or deleted after export,
|
||||
// or if the path is somehow invalid for showItemInFolder
|
||||
try {
|
||||
const openPathResult = await shell.openPath(path.dirname(filePath));
|
||||
if (openPathResult) {
|
||||
// openPath returned an error message
|
||||
return { success: false, error: openPathResult };
|
||||
}
|
||||
return { success: true, message: "Could not reveal item, but opened directory." };
|
||||
} catch (openError) {
|
||||
console.error(`Error opening directory: ${path.dirname(filePath)}`, openError);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let currentVideoPath: string | null = null;
|
||||
ipcMain.handle(
|
||||
"save-project-file",
|
||||
async (_, projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
|
||||
try {
|
||||
const trustedExistingProjectPath = isTrustedProjectPath(existingProjectPath)
|
||||
? existingProjectPath
|
||||
: null;
|
||||
|
||||
if (trustedExistingProjectPath) {
|
||||
await fs.writeFile(
|
||||
trustedExistingProjectPath,
|
||||
JSON.stringify(projectData, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
currentProjectPath = trustedExistingProjectPath;
|
||||
return {
|
||||
success: true,
|
||||
path: trustedExistingProjectPath,
|
||||
message: "Project saved successfully",
|
||||
};
|
||||
}
|
||||
|
||||
const safeName = (suggestedName || `project-${Date.now()}`).replace(/[^a-zA-Z0-9-_]/g, "_");
|
||||
const defaultName = safeName.endsWith(`.${PROJECT_FILE_EXTENSION}`)
|
||||
? safeName
|
||||
: `${safeName}.${PROJECT_FILE_EXTENSION}`;
|
||||
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: "Save OpenScreen Project",
|
||||
defaultPath: path.join(RECORDINGS_DIR, defaultName),
|
||||
filters: [
|
||||
{ name: "OpenScreen Project", extensions: [PROJECT_FILE_EXTENSION] },
|
||||
{ name: "JSON", extensions: ["json"] },
|
||||
],
|
||||
properties: ["createDirectory", "showOverwriteConfirmation"],
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return {
|
||||
success: false,
|
||||
canceled: true,
|
||||
message: "Save project canceled",
|
||||
};
|
||||
}
|
||||
|
||||
await fs.writeFile(result.filePath, JSON.stringify(projectData, null, 2), "utf-8");
|
||||
currentProjectPath = result.filePath;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: result.filePath,
|
||||
message: "Project saved successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to save project file:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to save project file",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle("load-project-file", async () => {
|
||||
try {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: "Open OpenScreen Project",
|
||||
defaultPath: RECORDINGS_DIR,
|
||||
filters: [
|
||||
{ name: "OpenScreen Project", extensions: [PROJECT_FILE_EXTENSION] },
|
||||
{ name: "JSON", extensions: ["json"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
properties: ["openFile"],
|
||||
});
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return { success: false, canceled: true, message: "Open project canceled" };
|
||||
}
|
||||
|
||||
const filePath = result.filePaths[0];
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
const project = JSON.parse(content);
|
||||
currentProjectPath = filePath;
|
||||
if (project && typeof project === "object" && typeof project.videoPath === "string") {
|
||||
currentVideoPath = project.videoPath;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: filePath,
|
||||
project,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to load project file:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to load project file",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("load-current-project-file", async () => {
|
||||
try {
|
||||
if (!currentProjectPath) {
|
||||
return { success: false, message: "No active project" };
|
||||
}
|
||||
|
||||
const content = await fs.readFile(currentProjectPath, "utf-8");
|
||||
const project = JSON.parse(content);
|
||||
if (project && typeof project === "object" && typeof project.videoPath === "string") {
|
||||
currentVideoPath = project.videoPath;
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
path: currentProjectPath,
|
||||
project,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to load current project file:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to load current project file",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
ipcMain.handle("set-current-video-path", (_, path: string) => {
|
||||
currentVideoPath = path;
|
||||
currentProjectPath = null;
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("get-current-video-path", () => {
|
||||
return currentVideoPath ? { success: true, path: currentVideoPath } : { success: false };
|
||||
});
|
||||
|
||||
ipcMain.handle("clear-current-video-path", () => {
|
||||
currentVideoPath = null;
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("get-platform", () => {
|
||||
return process.platform;
|
||||
});
|
||||
|
||||
ipcMain.handle("get-shortcuts", async () => {
|
||||
try {
|
||||
const data = await fs.readFile(SHORTCUTS_FILE, "utf-8");
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("save-shortcuts", async (_, shortcuts: unknown) => {
|
||||
try {
|
||||
await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), "utf-8");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to save shortcuts:", error);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
// uiohook-napi [WIP: scoping out mouse tracking with cross platform support for potential auto zoom/ post processing cursor effects]
|
||||
// not currently being used.
|
||||
|
||||
import { uIOhook } from 'uiohook-napi'
|
||||
|
||||
let isMouseTrackingActive = false
|
||||
let isHookStarted = false
|
||||
let recordingStartTime: number = 0
|
||||
let mouseEventData: MouseEvent[] = []
|
||||
|
||||
export interface MouseEvent {
|
||||
type: 'move' | 'down' | 'up' | 'click'
|
||||
timestamp: number // milliseconds since recording started
|
||||
x: number
|
||||
y: number
|
||||
button?: unknown
|
||||
clicks?: number
|
||||
}
|
||||
|
||||
export interface MouseTrackingSession {
|
||||
startTime: number
|
||||
events: MouseEvent[]
|
||||
duration: number
|
||||
}
|
||||
|
||||
export function startMouseTracking() {
|
||||
if (isMouseTrackingActive) {
|
||||
return { success: false, message: 'Already tracking' }
|
||||
}
|
||||
|
||||
isMouseTrackingActive = true
|
||||
|
||||
// Reset data for new recording session
|
||||
recordingStartTime = performance.now()
|
||||
mouseEventData = []
|
||||
|
||||
// Only start the hook once
|
||||
if (!isHookStarted) {
|
||||
setupMouseEventListeners()
|
||||
|
||||
try {
|
||||
uIOhook.start()
|
||||
isHookStarted = true
|
||||
return { success: true, message: 'Mouse tracking started', startTime: recordingStartTime }
|
||||
} catch (error) {
|
||||
console.error('Failed to start mouse tracking:', error)
|
||||
isMouseTrackingActive = false
|
||||
return { success: false, message: 'Failed to start hook', error }
|
||||
}
|
||||
} else {
|
||||
return { success: true, message: 'Mouse tracking resumed', startTime: recordingStartTime }
|
||||
}
|
||||
}
|
||||
|
||||
export function stopMouseTracking(): { success: boolean; message: string; data?: MouseTrackingSession } {
|
||||
if (!isMouseTrackingActive) {
|
||||
return { success: false, message: 'Not currently tracking' }
|
||||
}
|
||||
|
||||
isMouseTrackingActive = false
|
||||
|
||||
const duration = performance.now() - recordingStartTime
|
||||
|
||||
const session: MouseTrackingSession = {
|
||||
startTime: recordingStartTime,
|
||||
events: mouseEventData,
|
||||
duration: duration
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Mouse tracking stopped',
|
||||
data: session
|
||||
}
|
||||
}
|
||||
|
||||
function setupMouseEventListeners() {
|
||||
// Track mouse movement
|
||||
uIOhook.on('mousemove', (e) => {
|
||||
if (isMouseTrackingActive) {
|
||||
const timestamp = performance.now() - recordingStartTime
|
||||
const event: MouseEvent = {
|
||||
type: 'move',
|
||||
timestamp,
|
||||
x: e.x,
|
||||
y: e.y
|
||||
}
|
||||
mouseEventData.push(event)
|
||||
}
|
||||
})
|
||||
|
||||
// Track mouse button press
|
||||
uIOhook.on('mousedown', (e) => {
|
||||
if (isMouseTrackingActive) {
|
||||
const timestamp = performance.now() - recordingStartTime
|
||||
const event: MouseEvent = {
|
||||
type: 'down',
|
||||
timestamp,
|
||||
x: e.x,
|
||||
y: e.y,
|
||||
button: e.button,
|
||||
clicks: e.clicks
|
||||
}
|
||||
mouseEventData.push(event)
|
||||
}
|
||||
})
|
||||
|
||||
// Track mouse button release
|
||||
uIOhook.on('mouseup', (e) => {
|
||||
if (isMouseTrackingActive) {
|
||||
const timestamp = performance.now() - recordingStartTime
|
||||
const event: MouseEvent = {
|
||||
type: 'up',
|
||||
timestamp,
|
||||
x: e.x,
|
||||
y: e.y,
|
||||
button: e.button
|
||||
}
|
||||
mouseEventData.push(event)
|
||||
}
|
||||
})
|
||||
|
||||
// Track complete click events
|
||||
uIOhook.on('click', (e) => {
|
||||
if (isMouseTrackingActive) {
|
||||
const timestamp = performance.now() - recordingStartTime
|
||||
const event: MouseEvent = {
|
||||
type: 'click',
|
||||
timestamp,
|
||||
x: e.x,
|
||||
y: e.y,
|
||||
button: e.button,
|
||||
clicks: e.clicks
|
||||
}
|
||||
mouseEventData.push(event)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getTrackingData(): MouseEvent[] {
|
||||
return [...mouseEventData]
|
||||
}
|
||||
|
||||
export function cleanupMouseTracking() {
|
||||
if (isHookStarted) {
|
||||
try {
|
||||
uIOhook.stop()
|
||||
isHookStarted = false
|
||||
isMouseTrackingActive = false
|
||||
mouseEventData = []
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up mouse tracking:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,29 @@
|
||||
import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs/promises'
|
||||
import { createHudOverlayWindow, createEditorWindow, createSourceSelectorWindow } from './windows'
|
||||
import { registerIpcHandlers } from './ipc/handlers'
|
||||
import { cleanupMouseTracking } from './ipc/mouseTracking'
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, Tray } from "electron";
|
||||
import { registerIpcHandlers } from "./ipc/handlers";
|
||||
import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const RECORDINGS_DIR = path.join(app.getPath('userData'), 'recordings')
|
||||
|
||||
// Cleanup old recordings (older than 1 day)
|
||||
async function cleanupOldRecordings() {
|
||||
try {
|
||||
const files = await fs.readdir(RECORDINGS_DIR)
|
||||
const now = Date.now()
|
||||
const maxAge = 1 * 24 * 60 * 60 * 1000
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(RECORDINGS_DIR, file)
|
||||
const stats = await fs.stat(filePath)
|
||||
|
||||
if (now - stats.mtimeMs > maxAge) {
|
||||
await fs.unlink(filePath)
|
||||
console.log(`Deleted old recording: ${file}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup old recordings:', error)
|
||||
}
|
||||
// Use Screen & System Audio Recording permissions instead of CoreAudio Tap API on macOS.
|
||||
// CoreAudio Tap requires NSAudioCaptureUsageDescription in the parent app's Info.plist,
|
||||
// which doesn't work when running from a terminal/IDE during development, makes my life easier
|
||||
if (process.platform === "darwin") {
|
||||
app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare");
|
||||
}
|
||||
|
||||
export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings");
|
||||
|
||||
async function ensureRecordingsDir() {
|
||||
try {
|
||||
await fs.mkdir(RECORDINGS_DIR, { recursive: true })
|
||||
console.log('Recordings directory ready:', RECORDINGS_DIR)
|
||||
} catch (error) {
|
||||
console.error('Failed to create recordings directory:', error)
|
||||
}
|
||||
try {
|
||||
await fs.mkdir(RECORDINGS_DIR, { recursive: true });
|
||||
console.log("RECORDINGS_DIR:", RECORDINGS_DIR);
|
||||
console.log("User Data Path:", app.getPath("userData"));
|
||||
} catch (error) {
|
||||
console.error("Failed to create recordings directory:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// The built directory structure
|
||||
@@ -49,112 +35,285 @@ async function ensureRecordingsDir() {
|
||||
// │ │ ├── main.js
|
||||
// │ │ └── preload.mjs
|
||||
// │
|
||||
process.env.APP_ROOT = path.join(__dirname, '..')
|
||||
process.env.APP_ROOT = path.join(__dirname, "..");
|
||||
|
||||
// Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x
|
||||
export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
|
||||
export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron')
|
||||
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist')
|
||||
export const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
|
||||
export const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron");
|
||||
export const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
|
||||
|
||||
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST
|
||||
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
|
||||
? path.join(process.env.APP_ROOT, "public")
|
||||
: RENDERER_DIST;
|
||||
|
||||
// Window references
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let sourceSelectorWindow: BrowserWindow | null = null
|
||||
let tray: Tray | null = null
|
||||
let selectedSourceName = ''
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let sourceSelectorWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let selectedSourceName = "";
|
||||
|
||||
// Tray Icons
|
||||
const defaultTrayIcon = getTrayIcon("openscreen.png");
|
||||
const recordingTrayIcon = getTrayIcon("rec-button.png");
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = createHudOverlayWindow()
|
||||
mainWindow = createHudOverlayWindow();
|
||||
}
|
||||
|
||||
function isEditorWindow(window: BrowserWindow) {
|
||||
return window.webContents.getURL().includes("windowType=editor");
|
||||
}
|
||||
|
||||
function sendEditorMenuAction(
|
||||
channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as",
|
||||
) {
|
||||
let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow;
|
||||
|
||||
if (!targetWindow || targetWindow.isDestroyed() || !isEditorWindow(targetWindow)) {
|
||||
createEditorWindowWrapper();
|
||||
targetWindow = mainWindow;
|
||||
if (!targetWindow || targetWindow.isDestroyed()) return;
|
||||
|
||||
targetWindow.webContents.once("did-finish-load", () => {
|
||||
if (!targetWindow || targetWindow.isDestroyed()) return;
|
||||
targetWindow.webContents.send(channel);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
targetWindow.webContents.send(channel);
|
||||
}
|
||||
|
||||
function setupApplicationMenu() {
|
||||
const isMac = process.platform === "darwin";
|
||||
const template: Electron.MenuItemConstructorOptions[] = [];
|
||||
|
||||
if (isMac) {
|
||||
template.push({
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: "about" },
|
||||
{ type: "separator" },
|
||||
{ role: "services" },
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "unhide" },
|
||||
{ type: "separator" },
|
||||
{ role: "quit" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
template.push(
|
||||
{
|
||||
label: "File",
|
||||
submenu: [
|
||||
{
|
||||
label: "Load Project…",
|
||||
accelerator: "CmdOrCtrl+O",
|
||||
click: () => sendEditorMenuAction("menu-load-project"),
|
||||
},
|
||||
{
|
||||
label: "Save Project…",
|
||||
accelerator: "CmdOrCtrl+S",
|
||||
click: () => sendEditorMenuAction("menu-save-project"),
|
||||
},
|
||||
{
|
||||
label: "Save Project As…",
|
||||
accelerator: "CmdOrCtrl+Shift+S",
|
||||
click: () => sendEditorMenuAction("menu-save-project-as"),
|
||||
},
|
||||
...(isMac ? [] : [{ type: "separator" as const }, { role: "quit" as const }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
{ type: "separator" },
|
||||
{ role: "cut" },
|
||||
{ role: "copy" },
|
||||
{ role: "paste" },
|
||||
{ role: "selectAll" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{ role: "reload" },
|
||||
{ role: "forceReload" },
|
||||
{ role: "toggleDevTools" },
|
||||
{ type: "separator" },
|
||||
{ role: "resetZoom" },
|
||||
{ role: "zoomIn" },
|
||||
{ role: "zoomOut" },
|
||||
{ type: "separator" },
|
||||
{ role: "togglefullscreen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Window",
|
||||
submenu: isMac
|
||||
? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }]
|
||||
: [{ role: "minimize" }, { role: "close" }],
|
||||
},
|
||||
);
|
||||
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
Menu.setApplicationMenu(menu);
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
const iconPath = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, 'rec-button.png');
|
||||
let icon = nativeImage.createFromPath(iconPath);
|
||||
icon = icon.resize({ width: 24, height: 24, quality: 'best' });
|
||||
tray = new Tray(icon);
|
||||
updateTrayMenu();
|
||||
tray = new Tray(defaultTrayIcon);
|
||||
}
|
||||
|
||||
function updateTrayMenu() {
|
||||
if (!tray) return;
|
||||
const menuTemplate = [
|
||||
{
|
||||
label: 'Stop Recording',
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('stop-recording-from-tray');
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
const contextMenu = Menu.buildFromTemplate(menuTemplate);
|
||||
tray.setContextMenu(contextMenu);
|
||||
tray.setToolTip(`Recording: ${selectedSourceName}`);
|
||||
function getTrayIcon(filename: string) {
|
||||
return nativeImage
|
||||
.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename))
|
||||
.resize({
|
||||
width: 24,
|
||||
height: 24,
|
||||
quality: "best",
|
||||
});
|
||||
}
|
||||
|
||||
function updateTrayMenu(recording: boolean = false) {
|
||||
if (!tray) return;
|
||||
const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon;
|
||||
const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen";
|
||||
const menuTemplate = recording
|
||||
? [
|
||||
{
|
||||
label: "Stop Recording",
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("stop-recording-from-tray");
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: "Open",
|
||||
click: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.isMinimized() && mainWindow.restore();
|
||||
} else {
|
||||
createWindow();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Quit",
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
];
|
||||
tray.setImage(trayIcon);
|
||||
tray.setToolTip(trayToolTip);
|
||||
tray.setContextMenu(Menu.buildFromTemplate(menuTemplate));
|
||||
}
|
||||
|
||||
let editorHasUnsavedChanges = false;
|
||||
let isForceClosing = false;
|
||||
|
||||
ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => {
|
||||
editorHasUnsavedChanges = hasChanges;
|
||||
});
|
||||
|
||||
function createEditorWindowWrapper() {
|
||||
if (mainWindow) {
|
||||
mainWindow.close()
|
||||
mainWindow = null
|
||||
}
|
||||
mainWindow = createEditorWindow()
|
||||
if (mainWindow) {
|
||||
isForceClosing = true;
|
||||
mainWindow.close();
|
||||
isForceClosing = false;
|
||||
mainWindow = null;
|
||||
}
|
||||
mainWindow = createEditorWindow();
|
||||
editorHasUnsavedChanges = false;
|
||||
|
||||
mainWindow.on("close", (event) => {
|
||||
if (isForceClosing || !editorHasUnsavedChanges) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const choice = dialog.showMessageBoxSync(mainWindow!, {
|
||||
type: "warning",
|
||||
buttons: ["Save & Close", "Discard & Close", "Cancel"],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
title: "Unsaved Changes",
|
||||
message: "You have unsaved changes.",
|
||||
detail: "Do you want to save your project before closing?",
|
||||
});
|
||||
|
||||
if (choice === 0) {
|
||||
// Save & Close — tell renderer to save, then close
|
||||
mainWindow!.webContents.send("request-save-before-close");
|
||||
ipcMain.once("save-before-close-done", () => {
|
||||
isForceClosing = true;
|
||||
mainWindow?.close();
|
||||
isForceClosing = false;
|
||||
});
|
||||
} else if (choice === 1) {
|
||||
// Discard & Close
|
||||
isForceClosing = true;
|
||||
mainWindow?.close();
|
||||
isForceClosing = false;
|
||||
}
|
||||
// choice === 2: Cancel — do nothing, window stays open
|
||||
});
|
||||
}
|
||||
|
||||
function createSourceSelectorWindowWrapper() {
|
||||
sourceSelectorWindow = createSourceSelectorWindow()
|
||||
sourceSelectorWindow.on('closed', () => {
|
||||
sourceSelectorWindow = null
|
||||
})
|
||||
return sourceSelectorWindow
|
||||
sourceSelectorWindow = createSourceSelectorWindow();
|
||||
sourceSelectorWindow.on("closed", () => {
|
||||
sourceSelectorWindow = null;
|
||||
});
|
||||
return sourceSelectorWindow;
|
||||
}
|
||||
|
||||
// On macOS, applications and their menu bar stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on('window-all-closed', () => {
|
||||
// Keep app running (macOS behavior)
|
||||
})
|
||||
app.on("window-all-closed", () => {
|
||||
// Keep app running (macOS behavior)
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
// On OS X it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup old recordings on quit (both macOS and other platforms)
|
||||
app.on('before-quit', async (event) => {
|
||||
event.preventDefault()
|
||||
cleanupMouseTracking()
|
||||
await cleanupOldRecordings()
|
||||
app.exit(0)
|
||||
})
|
||||
app.on("activate", () => {
|
||||
// On OS X it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
// Register all IPC handlers when app is ready
|
||||
app.whenReady().then(async () => {
|
||||
// Ensure recordings directory exists
|
||||
await ensureRecordingsDir()
|
||||
|
||||
registerIpcHandlers(
|
||||
createEditorWindowWrapper,
|
||||
createSourceSelectorWindowWrapper,
|
||||
() => mainWindow,
|
||||
() => sourceSelectorWindow,
|
||||
(recording: boolean, sourceName: string) => {
|
||||
selectedSourceName = sourceName
|
||||
if (recording) {
|
||||
if (!tray) createTray();
|
||||
updateTrayMenu();
|
||||
if (mainWindow) mainWindow.minimize();
|
||||
} else {
|
||||
if (tray) {
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
}
|
||||
if (mainWindow) mainWindow.restore();
|
||||
}
|
||||
}
|
||||
)
|
||||
createWindow()
|
||||
})
|
||||
// Listen for HUD overlay quit event (macOS only)
|
||||
ipcMain.on("hud-overlay-close", () => {
|
||||
app.quit();
|
||||
});
|
||||
createTray();
|
||||
updateTrayMenu();
|
||||
setupApplicationMenu();
|
||||
// Ensure recordings directory exists
|
||||
await ensureRecordingsDir();
|
||||
|
||||
registerIpcHandlers(
|
||||
createEditorWindowWrapper,
|
||||
createSourceSelectorWindowWrapper,
|
||||
() => mainWindow,
|
||||
() => sourceSelectorWindow,
|
||||
(recording: boolean, sourceName: string) => {
|
||||
selectedSourceName = sourceName;
|
||||
if (!tray) createTray();
|
||||
updateTrayMenu(recording);
|
||||
if (!recording) {
|
||||
if (mainWindow) mainWindow.restore();
|
||||
}
|
||||
},
|
||||
);
|
||||
createWindow();
|
||||
});
|
||||
|
||||
@@ -1,52 +1,116 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getAssetBasePath: async () => {
|
||||
// ask main process for the correct base path (production vs dev)
|
||||
return await ipcRenderer.invoke('get-asset-base-path')
|
||||
},
|
||||
getSources: async (opts: Electron.SourcesOptions) => {
|
||||
return await ipcRenderer.invoke('get-sources', opts)
|
||||
},
|
||||
switchToEditor: () => {
|
||||
return ipcRenderer.invoke('switch-to-editor')
|
||||
},
|
||||
openSourceSelector: () => {
|
||||
return ipcRenderer.invoke('open-source-selector')
|
||||
},
|
||||
selectSource: (source: any) => {
|
||||
return ipcRenderer.invoke('select-source', source)
|
||||
},
|
||||
getSelectedSource: () => {
|
||||
return ipcRenderer.invoke('get-selected-source')
|
||||
},
|
||||
startMouseTracking: () => {
|
||||
return ipcRenderer.invoke('start-mouse-tracking')
|
||||
},
|
||||
stopMouseTracking: () => {
|
||||
return ipcRenderer.invoke('stop-mouse-tracking')
|
||||
},
|
||||
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke('store-recorded-video', videoData, fileName)
|
||||
},
|
||||
storeMouseTrackingData: (fileName: string) => {
|
||||
return ipcRenderer.invoke('store-mouse-tracking-data', fileName)
|
||||
},
|
||||
getRecordedVideoPath: () => {
|
||||
return ipcRenderer.invoke('get-recorded-video-path')
|
||||
},
|
||||
setRecordingState: (recording: boolean) => {
|
||||
return ipcRenderer.invoke('set-recording-state', recording)
|
||||
},
|
||||
onStopRecordingFromTray: (callback: () => void) => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('stop-recording-from-tray', listener)
|
||||
return () => ipcRenderer.removeListener('stop-recording-from-tray', listener)
|
||||
},
|
||||
openExternalUrl: (url: string) => {
|
||||
return ipcRenderer.invoke('open-external-url', url)
|
||||
},
|
||||
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke('save-exported-video', videoData, fileName)
|
||||
},
|
||||
})
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
hudOverlayHide: () => {
|
||||
ipcRenderer.send("hud-overlay-hide");
|
||||
},
|
||||
hudOverlayClose: () => {
|
||||
ipcRenderer.send("hud-overlay-close");
|
||||
},
|
||||
getAssetBasePath: async () => {
|
||||
// ask main process for the correct base path (production vs dev)
|
||||
return await ipcRenderer.invoke("get-asset-base-path");
|
||||
},
|
||||
getSources: async (opts: Electron.SourcesOptions) => {
|
||||
return await ipcRenderer.invoke("get-sources", opts);
|
||||
},
|
||||
switchToEditor: () => {
|
||||
return ipcRenderer.invoke("switch-to-editor");
|
||||
},
|
||||
openSourceSelector: () => {
|
||||
return ipcRenderer.invoke("open-source-selector");
|
||||
},
|
||||
selectSource: (source: any) => {
|
||||
return ipcRenderer.invoke("select-source", source);
|
||||
},
|
||||
getSelectedSource: () => {
|
||||
return ipcRenderer.invoke("get-selected-source");
|
||||
},
|
||||
|
||||
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
|
||||
},
|
||||
|
||||
getRecordedVideoPath: () => {
|
||||
return ipcRenderer.invoke("get-recorded-video-path");
|
||||
},
|
||||
setRecordingState: (recording: boolean) => {
|
||||
return ipcRenderer.invoke("set-recording-state", recording);
|
||||
},
|
||||
getCursorTelemetry: (videoPath?: string) => {
|
||||
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
|
||||
},
|
||||
onStopRecordingFromTray: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("stop-recording-from-tray", listener);
|
||||
return () => ipcRenderer.removeListener("stop-recording-from-tray", listener);
|
||||
},
|
||||
openExternalUrl: (url: string) => {
|
||||
return ipcRenderer.invoke("open-external-url", url);
|
||||
},
|
||||
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke("save-exported-video", videoData, fileName);
|
||||
},
|
||||
openVideoFilePicker: () => {
|
||||
return ipcRenderer.invoke("open-video-file-picker");
|
||||
},
|
||||
setCurrentVideoPath: (path: string) => {
|
||||
return ipcRenderer.invoke("set-current-video-path", path);
|
||||
},
|
||||
getCurrentVideoPath: () => {
|
||||
return ipcRenderer.invoke("get-current-video-path");
|
||||
},
|
||||
clearCurrentVideoPath: () => {
|
||||
return ipcRenderer.invoke("clear-current-video-path");
|
||||
},
|
||||
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
|
||||
return ipcRenderer.invoke("save-project-file", projectData, suggestedName, existingProjectPath);
|
||||
},
|
||||
loadProjectFile: () => {
|
||||
return ipcRenderer.invoke("load-project-file");
|
||||
},
|
||||
loadCurrentProjectFile: () => {
|
||||
return ipcRenderer.invoke("load-current-project-file");
|
||||
},
|
||||
onMenuLoadProject: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("menu-load-project", listener);
|
||||
return () => ipcRenderer.removeListener("menu-load-project", listener);
|
||||
},
|
||||
onMenuSaveProject: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("menu-save-project", listener);
|
||||
return () => ipcRenderer.removeListener("menu-save-project", listener);
|
||||
},
|
||||
onMenuSaveProjectAs: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("menu-save-project-as", listener);
|
||||
return () => ipcRenderer.removeListener("menu-save-project-as", listener);
|
||||
},
|
||||
getPlatform: () => {
|
||||
return ipcRenderer.invoke("get-platform");
|
||||
},
|
||||
revealInFolder: (filePath: string) => {
|
||||
return ipcRenderer.invoke("reveal-in-folder", filePath);
|
||||
},
|
||||
getShortcuts: () => {
|
||||
return ipcRenderer.invoke("get-shortcuts");
|
||||
},
|
||||
saveShortcuts: (shortcuts: unknown) => {
|
||||
return ipcRenderer.invoke("save-shortcuts", shortcuts);
|
||||
},
|
||||
setMicrophoneExpanded: (expanded: boolean) => {
|
||||
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
|
||||
},
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => {
|
||||
ipcRenderer.send("set-has-unsaved-changes", hasChanges);
|
||||
},
|
||||
onRequestSaveBeforeClose: (callback: () => Promise<void>) => {
|
||||
const listener = async () => {
|
||||
await callback();
|
||||
ipcRenderer.send("save-before-close-done");
|
||||
};
|
||||
ipcRenderer.on("request-save-before-close", listener);
|
||||
return () => ipcRenderer.removeListener("request-save-before-close", listener);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,120 +1,151 @@
|
||||
import { BrowserWindow, screen } from 'electron'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BrowserWindow, ipcMain, screen } from "electron";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const APP_ROOT = path.join(__dirname, '..')
|
||||
const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
|
||||
const RENDERER_DIST = path.join(APP_ROOT, 'dist')
|
||||
const APP_ROOT = path.join(__dirname, "..");
|
||||
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
|
||||
const RENDERER_DIST = path.join(APP_ROOT, "dist");
|
||||
|
||||
let hudOverlayWindow: BrowserWindow | null = null;
|
||||
|
||||
ipcMain.on("hud-overlay-hide", () => {
|
||||
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
|
||||
hudOverlayWindow.minimize();
|
||||
}
|
||||
});
|
||||
|
||||
export function createHudOverlayWindow(): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
width: 250,
|
||||
height: 80,
|
||||
minWidth: 250,
|
||||
maxWidth: 250,
|
||||
minHeight: 80,
|
||||
maxHeight: 80,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.mjs'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
})
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
const { workArea } = primaryDisplay;
|
||||
|
||||
const windowWidth = 500;
|
||||
const windowHeight = 155;
|
||||
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
win?.webContents.send('main-process-message', (new Date).toLocaleString())
|
||||
})
|
||||
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
|
||||
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + '?windowType=hud-overlay')
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, 'index.html'), {
|
||||
query: { windowType: 'hud-overlay' }
|
||||
})
|
||||
}
|
||||
const win = new BrowserWindow({
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
minWidth: 500,
|
||||
maxWidth: 500,
|
||||
minHeight: 155,
|
||||
maxHeight: 155,
|
||||
x: x,
|
||||
y: y,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
});
|
||||
|
||||
return win
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
win?.webContents.send("main-process-message", new Date().toLocaleString());
|
||||
});
|
||||
|
||||
hudOverlayWindow = win;
|
||||
|
||||
win.on("closed", () => {
|
||||
if (hudOverlayWindow === win) {
|
||||
hudOverlayWindow = null;
|
||||
}
|
||||
});
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=hud-overlay");
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, "index.html"), {
|
||||
query: { windowType: "hud-overlay" },
|
||||
});
|
||||
}
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
export function createEditorWindow(): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
titleBarStyle: 'hiddenInset',
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
transparent: false,
|
||||
resizable: true,
|
||||
alwaysOnTop: false,
|
||||
skipTaskbar: false,
|
||||
title: 'OpenScreen',
|
||||
backgroundColor: '#000000',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.mjs'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: false,
|
||||
},
|
||||
})
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
// Maximize the window by default
|
||||
win.maximize();
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
...(isMac && {
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
}),
|
||||
transparent: false,
|
||||
resizable: true,
|
||||
alwaysOnTop: false,
|
||||
skipTaskbar: false,
|
||||
title: "OpenScreen",
|
||||
backgroundColor: "#000000",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: false,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
});
|
||||
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
win?.webContents.send('main-process-message', (new Date).toLocaleString())
|
||||
})
|
||||
// Maximize the window by default
|
||||
win.maximize();
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + '?windowType=editor')
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, 'index.html'), {
|
||||
query: { windowType: 'editor' }
|
||||
})
|
||||
}
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
win?.webContents.send("main-process-message", new Date().toLocaleString());
|
||||
});
|
||||
|
||||
return win
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=editor");
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, "index.html"), {
|
||||
query: { windowType: "editor" },
|
||||
});
|
||||
}
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
export function createSourceSelectorWindow(): BrowserWindow {
|
||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 620,
|
||||
height: 420,
|
||||
minHeight: 350,
|
||||
maxHeight: 500,
|
||||
x: Math.round((width - 620) / 2),
|
||||
y: Math.round((height - 420) / 2),
|
||||
frame: false,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
transparent: true,
|
||||
backgroundColor: '#00000000',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.mjs'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
},
|
||||
})
|
||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + '?windowType=source-selector')
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, 'index.html'), {
|
||||
query: { windowType: 'source-selector' }
|
||||
})
|
||||
}
|
||||
const win = new BrowserWindow({
|
||||
width: 620,
|
||||
height: 420,
|
||||
minHeight: 350,
|
||||
maxHeight: 500,
|
||||
x: Math.round((width - 620) / 2),
|
||||
y: Math.round((height - 420) / 2),
|
||||
frame: false,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
transparent: true,
|
||||
backgroundColor: "#00000000",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
},
|
||||
});
|
||||
|
||||
return win
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=source-selector");
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, "index.html"), {
|
||||
query: { windowType: "source-selector" },
|
||||
});
|
||||
}
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 798 KiB After Width: | Height: | Size: 813 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 660 B After Width: | Height: | Size: 630 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 713 KiB |
@@ -1,59 +1,88 @@
|
||||
{
|
||||
"name": "openscreen",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build && electron-builder",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"build:mac": "tsc && vite build && electron-builder --mac"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fix-webm-duration/fix": "^1.0.1",
|
||||
"@pixi/filter-drop-shadow": "^5.2.0",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@uiw/react-color-colorful": "^2.9.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dnd-timeline": "^2.2.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"mp4-muxer": "^5.2.2",
|
||||
"mp4box": "^2.2.0",
|
||||
"pixi.js": "^8.14.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uiohook-napi": "^1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||
"@typescript-eslint/parser": "^7.1.1",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"electron": "^30.0.1",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.6",
|
||||
"vite-plugin-electron": "^0.28.6",
|
||||
"vite-plugin-electron-renderer": "^0.14.5"
|
||||
},
|
||||
"main": "dist-electron/main.js"
|
||||
}
|
||||
"name": "openscreen",
|
||||
"private": true,
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build && electron-builder",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"format": "biome format --write .",
|
||||
"preview": "vite preview",
|
||||
"build:mac": "tsc && vite build && electron-builder --mac",
|
||||
"build:win": "tsc && vite build && electron-builder --win",
|
||||
"build:linux": "tsc && vite build && electron-builder --linux",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fix-webm-duration/fix": "^1.0.1",
|
||||
"@pixi/filter-drop-shadow": "^5.2.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@types/gif.js": "^0.2.5",
|
||||
"@uiw/color-convert": "^2.9.2",
|
||||
"@uiw/react-color-block": "^2.9.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dnd-timeline": "^2.2.0",
|
||||
"emoji-picker-react": "^4.16.1",
|
||||
"fix-webm-duration": "^1.0.6",
|
||||
"gif.js": "^0.2.0",
|
||||
"gsap": "^3.13.0",
|
||||
"lucide-react": "^0.545.0",
|
||||
"mediabunny": "^1.25.1",
|
||||
"motion": "^12.23.24",
|
||||
"mp4box": "^2.2.0",
|
||||
"pixi.js": "^8.14.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-rnd": "^10.5.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^13.0.0",
|
||||
"web-demuxer": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.13",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.7.0",
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"fast-check": "^4.5.2",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.3.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"terser": "^5.44.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.6",
|
||||
"vite-plugin-electron": "^0.28.6",
|
||||
"vite-plugin-electron-renderer": "^0.14.5",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"main": "dist-electron/main.js",
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,jsx,mts,cts,json}": "biome check --no-errors-on-unmatched"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
Before Width: | Height: | Size: 530 KiB |
|
Before Width: | Height: | Size: 305 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 399 KiB |
@@ -1,34 +0,0 @@
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M63.9202 127.84C99.2223 127.84 127.84 99.2223 127.84 63.9202C127.84 28.6181 99.2223 0 63.9202 0C28.6181 0 0 28.6181 0 63.9202C0 99.2223 28.6181 127.84 63.9202 127.84Z" fill="url(#paint0_linear_103_2)"/>
|
||||
<g id="not-lightning" clip-path="url(#clip0_103_2)">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
attributeType="XML"
|
||||
type="rotate"
|
||||
from="0 64 64"
|
||||
to="360 64 64"
|
||||
dur="20s"
|
||||
repeatCount="indefinite"/>
|
||||
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
|
||||
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
|
||||
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path d="M63.9835 27.6986C66.4621 27.6986 68.4714 25.6861 68.4714 23.2036C68.4714 20.7211 66.4621 18.7086 63.9835 18.7086C61.5049 18.7086 59.4956 20.7211 59.4956 23.2036C59.4956 25.6861 61.5049 27.6986 63.9835 27.6986Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
</g>
|
||||
<path d="M70.7175 48.0096L56.3133 50.676C56.0766 50.7199 55.9013 50.9094 55.887 51.1369L55.001 65.2742C54.9801 65.6072 55.3038 65.8656 55.6478 65.7907L59.6582 64.9163C60.0334 64.8346 60.3724 65.1468 60.2953 65.5033L59.1038 71.0151C59.0237 71.386 59.3923 71.7032 59.7758 71.5932L62.2528 70.8822C62.6368 70.7721 63.0057 71.0902 62.9245 71.4615L61.031 80.1193C60.9126 80.6608 61.6751 80.9561 61.9931 80.4918L62.2055 80.1817L73.9428 58.053C74.1393 57.6825 73.8004 57.26 73.3696 57.3385L69.2417 58.0912C68.8538 58.1618 68.5237 57.8206 68.6332 57.462L71.3274 48.6385C71.437 48.2794 71.1058 47.9378 70.7175 48.0096Z" fill="url(#paint1_linear_103_2)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_103_2" x1="1.43824" y1="7.91009" x2="56.3296" y2="82.4569" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#41D1FF"/>
|
||||
<stop offset="1" stop-color="#BD34FE"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_103_2" x1="60.3173" y1="48.7336" x2="64.237" y2="77.1962" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFEA83"/>
|
||||
<stop offset="0.0833333" stop-color="#FFDD35"/>
|
||||
<stop offset="1" stop-color="#FFA800"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_103_2">
|
||||
<rect width="128" height="128" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.9 KiB |
@@ -1,26 +0,0 @@
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_103_2)">
|
||||
<path d="M63.9202 127.84C99.2223 127.84 127.84 99.2223 127.84 63.9202C127.84 28.6181 99.2223 0 63.9202 0C28.6181 0 0 28.6181 0 63.9202C0 99.2223 28.6181 127.84 63.9202 127.84Z" fill="url(#paint0_linear_103_2)"/>
|
||||
<path d="M51.3954 39.5028C52.3733 39.6812 53.3108 39.033 53.4892 38.055C53.6676 37.0771 53.0194 36.1396 52.0414 35.9612L51.3954 39.5028ZM28.9393 60.9358C29.4332 61.7985 30.5329 62.0976 31.3957 61.6037C32.2585 61.1098 32.5575 60.0101 32.0636 59.1473L28.9393 60.9358ZM37.6935 66.7457C37.025 66.01 35.8866 65.9554 35.1508 66.6239C34.415 67.2924 34.3605 68.4308 35.029 69.1666L37.6935 66.7457ZM96.9206 89.515C97.7416 88.9544 97.9526 87.8344 97.3919 87.0135C96.8313 86.1925 95.7113 85.9815 94.8904 86.5422L96.9206 89.515ZM52.0414 35.9612C46.4712 34.9451 41.2848 34.8966 36.9738 35.9376C32.6548 36.9806 29.0841 39.1576 27.0559 42.6762L30.1748 44.4741C31.5693 42.0549 34.1448 40.3243 37.8188 39.4371C41.5009 38.5479 46.1547 38.5468 51.3954 39.5028L52.0414 35.9612ZM27.0559 42.6762C24.043 47.9029 25.2781 54.5399 28.9393 60.9358L32.0636 59.1473C28.6579 53.1977 28.1088 48.0581 30.1748 44.4741L27.0559 42.6762ZM35.029 69.1666C39.6385 74.24 45.7158 79.1355 52.8478 83.2597L54.6499 80.1432C47.8081 76.1868 42.0298 71.5185 37.6935 66.7457L35.029 69.1666ZM52.8478 83.2597C61.344 88.1726 70.0465 91.2445 77.7351 92.3608C85.359 93.4677 92.2744 92.6881 96.9206 89.515L94.8904 86.5422C91.3255 88.9767 85.4902 89.849 78.2524 88.7982C71.0793 87.7567 62.809 84.8612 54.6499 80.1432L52.8478 83.2597ZM105.359 84.9077C105.359 81.4337 102.546 78.6127 99.071 78.6127V82.2127C100.553 82.2127 101.759 83.4166 101.759 84.9077H105.359ZM99.071 78.6127C95.5956 78.6127 92.7831 81.4337 92.7831 84.9077H96.3831C96.3831 83.4166 97.5892 82.2127 99.071 82.2127V78.6127ZM92.7831 84.9077C92.7831 88.3817 95.5956 91.2027 99.071 91.2027V87.6027C97.5892 87.6027 96.3831 86.3988 96.3831 84.9077H92.7831ZM99.071 91.2027C102.546 91.2027 105.359 88.3817 105.359 84.9077H101.759C101.759 86.3988 100.553 87.6027 99.071 87.6027V91.2027Z" fill="#A2ECFB"/>
|
||||
<path d="M91.4873 65.382C90.8456 66.1412 90.9409 67.2769 91.7002 67.9186C92.4594 68.5603 93.5951 68.465 94.2368 67.7058L91.4873 65.382ZM84.507 35.2412C83.513 35.2282 82.6967 36.0236 82.6838 37.0176C82.6708 38.0116 83.4661 38.8279 84.4602 38.8409L84.507 35.2412ZM74.9407 39.8801C75.9127 39.6716 76.5315 38.7145 76.323 37.7425C76.1144 36.7706 75.1573 36.1517 74.1854 36.3603L74.9407 39.8801ZM25.5491 80.9047C25.6932 81.8883 26.6074 82.5688 27.5911 82.4247C28.5747 82.2806 29.2552 81.3664 29.1111 80.3828L25.5491 80.9047ZM94.2368 67.7058C97.8838 63.3907 100.505 58.927 101.752 54.678C103.001 50.4213 102.9 46.2472 100.876 42.7365L97.7574 44.5344C99.1494 46.9491 99.3603 50.0419 98.2974 53.6644C97.2323 57.2945 94.9184 61.3223 91.4873 65.382L94.2368 67.7058ZM100.876 42.7365C97.9119 37.5938 91.7082 35.335 84.507 35.2412L84.4602 38.8409C91.1328 38.9278 95.7262 41.0106 97.7574 44.5344L100.876 42.7365ZM74.1854 36.3603C67.4362 37.8086 60.0878 40.648 52.8826 44.8146L54.6847 47.931C61.5972 43.9338 68.5948 41.2419 74.9407 39.8801L74.1854 36.3603ZM52.8826 44.8146C44.1366 49.872 36.9669 56.0954 32.1491 62.3927C27.3774 68.63 24.7148 75.2115 25.5491 80.9047L29.1111 80.3828C28.4839 76.1026 30.4747 70.5062 35.0084 64.5802C39.496 58.7143 46.2839 52.7889 54.6847 47.931L52.8826 44.8146Z" fill="#A2ECFB"/>
|
||||
<path d="M49.0825 87.2295C48.7478 86.2934 47.7176 85.8059 46.7816 86.1406C45.8455 86.4753 45.358 87.5055 45.6927 88.4416L49.0825 87.2295ZM78.5635 96.4256C79.075 95.5732 78.7988 94.4675 77.9464 93.9559C77.0941 93.4443 75.9884 93.7205 75.4768 94.5729L78.5635 96.4256ZM79.5703 85.1795C79.2738 86.1284 79.8027 87.1379 80.7516 87.4344C81.7004 87.7308 82.71 87.2019 83.0064 86.2531L79.5703 85.1795ZM69.156 22.5301C68.2477 22.1261 67.1838 22.535 66.7799 23.4433C66.3759 24.3517 66.7848 25.4155 67.6931 25.8194L69.156 22.5301ZM45.6927 88.4416C47.5994 93.7741 50.1496 98.2905 53.2032 101.505C56.2623 104.724 59.9279 106.731 63.9835 106.731V103.131C61.1984 103.131 58.4165 101.765 55.8131 99.0249C53.2042 96.279 50.8768 92.2477 49.0825 87.2295L45.6927 88.4416ZM63.9835 106.731C69.8694 106.731 74.8921 102.542 78.5635 96.4256L75.4768 94.5729C72.0781 100.235 68.0122 103.131 63.9835 103.131V106.731ZM83.0064 86.2531C85.0269 79.7864 86.1832 72.1831 86.1832 64.0673H82.5832C82.5832 71.8536 81.4723 79.0919 79.5703 85.1795L83.0064 86.2531ZM86.1832 64.0673C86.1832 54.1144 84.4439 44.922 81.4961 37.6502C78.5748 30.4436 74.3436 24.8371 69.156 22.5301L67.6931 25.8194C71.6364 27.5731 75.3846 32.1564 78.1598 39.0026C80.9086 45.7836 82.5832 54.507 82.5832 64.0673H86.1832Z" fill="#A2ECFB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M103.559 84.9077C103.559 82.4252 101.55 80.4127 99.071 80.4127C96.5924 80.4127 94.5831 82.4252 94.5831 84.9077C94.5831 87.3902 96.5924 89.4027 99.071 89.4027C101.55 89.4027 103.559 87.3902 103.559 84.9077Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.8143 89.4027C31.2929 89.4027 33.3023 87.3902 33.3023 84.9077C33.3023 82.4252 31.2929 80.4127 28.8143 80.4127C26.3357 80.4127 24.3264 82.4252 24.3264 84.9077C24.3264 87.3902 26.3357 89.4027 28.8143 89.4027Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path d="M63.9835 27.6986C66.4621 27.6986 68.4714 25.6861 68.4714 23.2036C68.4714 20.7211 66.4621 18.7086 63.9835 18.7086C61.5049 18.7086 59.4956 20.7211 59.4956 23.2036C59.4956 25.6861 61.5049 27.6986 63.9835 27.6986Z" stroke="#A2ECFB" stroke-width="3.6" stroke-linecap="round"/>
|
||||
<path d="M70.7175 48.0096L56.3133 50.676C56.0766 50.7199 55.9013 50.9094 55.887 51.1369L55.001 65.2742C54.9801 65.6072 55.3038 65.8656 55.6478 65.7907L59.6582 64.9163C60.0334 64.8346 60.3724 65.1468 60.2953 65.5033L59.1038 71.0151C59.0237 71.386 59.3923 71.7032 59.7758 71.5932L62.2528 70.8822C62.6368 70.7721 63.0057 71.0902 62.9245 71.4615L61.031 80.1193C60.9126 80.6608 61.6751 80.9561 61.9931 80.4918L62.2055 80.1817L73.9428 58.053C74.1393 57.6825 73.8004 57.26 73.3696 57.3385L69.2417 58.0912C68.8538 58.1618 68.5237 57.8206 68.6332 57.462L71.3274 48.6385C71.437 48.2794 71.1058 47.9378 70.7175 48.0096Z" fill="url(#paint1_linear_103_2)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_103_2" x1="1.43824" y1="7.91009" x2="56.3296" y2="82.4569" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#41D1FF"/>
|
||||
<stop offset="1" stop-color="#BD34FE"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_103_2" x1="60.3173" y1="48.7336" x2="64.237" y2="77.1962" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFEA83"/>
|
||||
<stop offset="0.0833333" stop-color="#FFDD35"/>
|
||||
<stop offset="1" stop-color="#FFA800"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_103_2">
|
||||
<rect width="128" height="128" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 853 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 20 MiB |
|
Before Width: | Height: | Size: 11 MiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 20 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 439 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 890 KiB |
|
Before Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 12 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 15 MiB After Width: | Height: | Size: 827 KiB |
|
Before Width: | Height: | Size: 18 MiB After Width: | Height: | Size: 524 KiB |
|
Before Width: | Height: | Size: 20 MiB After Width: | Height: | Size: 538 KiB |
|
Before Width: | Height: | Size: 8.5 MiB After Width: | Height: | Size: 897 KiB |
@@ -1,42 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
@@ -1,34 +1,47 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { LaunchWindow } from "./components/launch/LaunchWindow";
|
||||
import { SourceSelector } from "./components/launch/SourceSelector";
|
||||
import VideoEditor from "./components/video-editor/VideoEditor";
|
||||
|
||||
export default function App() {
|
||||
const [windowType, setWindowType] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const type = params.get('windowType') || '';
|
||||
setWindowType(type);
|
||||
if (type === 'hud-overlay' || type === 'source-selector') {
|
||||
document.body.style.background = 'transparent';
|
||||
document.documentElement.style.background = 'transparent';
|
||||
document.getElementById('root')?.style.setProperty('background', 'transparent');
|
||||
}
|
||||
}, []);
|
||||
|
||||
switch (windowType) {
|
||||
case 'hud-overlay':
|
||||
return <LaunchWindow />;
|
||||
case 'source-selector':
|
||||
return <SourceSelector />;
|
||||
case 'editor':
|
||||
return <VideoEditor />;
|
||||
default:
|
||||
return (
|
||||
<div className="w-full h-full bg-background text-foreground">
|
||||
<h1>Openscreen</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
import { useEffect, useState } from "react";
|
||||
import { LaunchWindow } from "./components/launch/LaunchWindow";
|
||||
import { SourceSelector } from "./components/launch/SourceSelector";
|
||||
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
|
||||
import VideoEditor from "./components/video-editor/VideoEditor";
|
||||
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
|
||||
import { loadAllCustomFonts } from "./lib/customFonts";
|
||||
|
||||
export default function App() {
|
||||
const [windowType, setWindowType] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const type = params.get("windowType") || "";
|
||||
setWindowType(type);
|
||||
if (type === "hud-overlay" || type === "source-selector") {
|
||||
document.body.style.background = "transparent";
|
||||
document.documentElement.style.background = "transparent";
|
||||
document.getElementById("root")?.style.setProperty("background", "transparent");
|
||||
}
|
||||
|
||||
// Load custom fonts on app initialization
|
||||
loadAllCustomFonts().catch((error) => {
|
||||
console.error("Failed to load custom fonts:", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
switch (windowType) {
|
||||
case "hud-overlay":
|
||||
return <LaunchWindow />;
|
||||
case "source-selector":
|
||||
return <SourceSelector />;
|
||||
case "editor":
|
||||
return (
|
||||
<ShortcutsProvider>
|
||||
<VideoEditor />
|
||||
<ShortcutsConfigDialog />
|
||||
</ShortcutsProvider>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="w-full h-full bg-background text-foreground">
|
||||
<h1>Openscreen</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,133 @@
|
||||
.electronDrag {
|
||||
-webkit-app-region: drag;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.electronNoDrag {
|
||||
-webkit-app-region: no-drag;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.hudBar {
|
||||
isolation: isolate;
|
||||
box-shadow:
|
||||
0 2px 16px rgba(0, 0, 0, 0.25),
|
||||
0 0 40px rgba(100, 80, 200, 0.08);
|
||||
}
|
||||
|
||||
/* Sub-pill group container */
|
||||
.hudGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 9999px;
|
||||
padding: 4px 8px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.hudGroup:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Icon button within groups */
|
||||
.hudIconBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border-radius: 9999px;
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hudIconBtn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.hudIconBtn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Active icon glow (green) for enabled audio toggles */
|
||||
.hudIconActive {
|
||||
filter: drop-shadow(0 0 4px rgba(74, 222, 128, 0.4));
|
||||
}
|
||||
|
||||
/* Recording pulse animation on the record group */
|
||||
@keyframes recordPulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 16px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.recordingPulse {
|
||||
animation: recordPulse 1.5s ease-in-out infinite;
|
||||
background: rgba(239, 68, 68, 0.1) !important;
|
||||
}
|
||||
|
||||
/* Mic panel above the bar */
|
||||
.micPanel {
|
||||
background: linear-gradient(135deg, rgba(28, 28, 36, 0.97) 0%, rgba(18, 18, 26, 0.96) 100%);
|
||||
backdrop-filter: blur(16px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(140%);
|
||||
border: 1px solid rgba(80, 80, 120, 0.25);
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 2px 12px rgba(0, 0, 0, 0.2),
|
||||
0 0 30px rgba(100, 80, 200, 0.06);
|
||||
animation: micPanelIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes micPanelIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Window control buttons */
|
||||
.windowBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3px;
|
||||
border-radius: 9999px;
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.windowBtn:hover {
|
||||
opacity: 0.9;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Folder button */
|
||||
.folderButton {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.folderText {
|
||||
color: #cbd5e1;
|
||||
transition: text-decoration 0.15s;
|
||||
}
|
||||
|
||||
.folderButton:hover .folderText {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -1,93 +1,272 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import styles from "./LaunchWindow.module.css";
|
||||
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
||||
import { Button } from "../ui/button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BsRecordCircle } from "react-icons/bs";
|
||||
import { FaRegStopCircle } from "react-icons/fa";
|
||||
import { MdMonitor } from "react-icons/md";
|
||||
import { FaFolderOpen } from "react-icons/fa6";
|
||||
import { FiMinus, FiX } from "react-icons/fi";
|
||||
import { MdMic, MdMicOff, MdMonitor, MdVideoFile, MdVolumeOff, MdVolumeUp } from "react-icons/md";
|
||||
import { RxDragHandleDots2 } from "react-icons/rx";
|
||||
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
||||
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
||||
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
||||
import { AudioLevelMeter } from "../ui/audio-level-meter";
|
||||
import styles from "./LaunchWindow.module.css";
|
||||
|
||||
export function LaunchWindow() {
|
||||
const { recording, toggleRecording } = useScreenRecorder();
|
||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||
const {
|
||||
recording,
|
||||
toggleRecording,
|
||||
microphoneEnabled,
|
||||
setMicrophoneEnabled,
|
||||
microphoneDeviceId,
|
||||
setMicrophoneDeviceId,
|
||||
systemAudioEnabled,
|
||||
setSystemAudioEnabled,
|
||||
} = useScreenRecorder();
|
||||
const [recordingStart, setRecordingStart] = useState<number | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSelectedSource = async () => {
|
||||
if (window.electronAPI) {
|
||||
const source = await window.electronAPI.getSelectedSource();
|
||||
if (source) {
|
||||
setSelectedSource(source.name);
|
||||
setHasSelectedSource(true);
|
||||
} else {
|
||||
setSelectedSource("Screen");
|
||||
setHasSelectedSource(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
const showMicControls = microphoneEnabled && !recording;
|
||||
const { devices, selectedDeviceId, setSelectedDeviceId } =
|
||||
useMicrophoneDevices(microphoneEnabled);
|
||||
const { level } = useAudioLevelMeter({
|
||||
enabled: showMicControls,
|
||||
deviceId: microphoneDeviceId,
|
||||
});
|
||||
|
||||
checkSelectedSource();
|
||||
|
||||
const interval = setInterval(checkSelectedSource, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (selectedDeviceId && selectedDeviceId !== "default") {
|
||||
setMicrophoneDeviceId(selectedDeviceId);
|
||||
}
|
||||
}, [selectedDeviceId, setMicrophoneDeviceId]);
|
||||
|
||||
const truncateText = (text: string, maxLength: number = 6) => {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + "...";
|
||||
};
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
if (recording) {
|
||||
if (!recordingStart) setRecordingStart(Date.now());
|
||||
timer = setInterval(() => {
|
||||
if (recordingStart) {
|
||||
setElapsed(Math.floor((Date.now() - recordingStart) / 1000));
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
setRecordingStart(null);
|
||||
setElapsed(0);
|
||||
if (timer) clearInterval(timer);
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [recording, recordingStart]);
|
||||
|
||||
const openSourceSelector = () => {
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.openSourceSelector();
|
||||
}
|
||||
};
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
const s = (seconds % 60).toString().padStart(2, "0");
|
||||
return `${m}:${s}`;
|
||||
};
|
||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center bg-transparent">
|
||||
<div
|
||||
className={`w-full max-w-2xl mx-auto flex items-center justify-between px-3 py-1.5 ${styles.electronDrag}`}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
background: 'linear-gradient(135deg, rgba(30,30,40,0.85) 0%, rgba(20,20,30,0.75) 100%)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
boxShadow: '0 4px 16px 0 rgba(0,0,0,0.24), 0 1px 3px 0 rgba(0,0,0,0.12) inset',
|
||||
border: '1px solid rgba(80,80,120,0.18)',
|
||||
minHeight: 36,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 flex-1 text-left text-xs ${styles.electronNoDrag}`}
|
||||
onClick={openSourceSelector}
|
||||
>
|
||||
<MdMonitor size={13} className="text-white" />
|
||||
{truncateText(selectedSource)}
|
||||
</Button>
|
||||
useEffect(() => {
|
||||
const checkSelectedSource = async () => {
|
||||
if (window.electronAPI) {
|
||||
const source = await window.electronAPI.getSelectedSource();
|
||||
if (source) {
|
||||
setSelectedSource(source.name);
|
||||
setHasSelectedSource(true);
|
||||
} else {
|
||||
setSelectedSource("Screen");
|
||||
setHasSelectedSource(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
<div className="w-px h-5 bg-white/30" />
|
||||
checkSelectedSource();
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
|
||||
disabled={!hasSelectedSource && !recording}
|
||||
className={`gap-1 bg-transparent hover:bg-transparent px-0 flex-1 text-right text-xs ${styles.electronNoDrag}`}
|
||||
>
|
||||
{recording ? (
|
||||
<>
|
||||
<FaRegStopCircle size={13} className="text-red-400" />
|
||||
<span className="text-red-400">Stop</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BsRecordCircle size={13} className={hasSelectedSource ? "text-white" : "text-white/50"} />
|
||||
<span className={hasSelectedSource ? "text-white" : "text-white/50"}>Record</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const interval = setInterval(checkSelectedSource, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const openSourceSelector = () => {
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.openSourceSelector();
|
||||
}
|
||||
};
|
||||
|
||||
const openVideoFile = async () => {
|
||||
const result = await window.electronAPI.openVideoFilePicker();
|
||||
|
||||
if (result.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success && result.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(result.path);
|
||||
await window.electronAPI.switchToEditor();
|
||||
}
|
||||
};
|
||||
|
||||
const openProjectFile = async () => {
|
||||
const result = await window.electronAPI.loadProjectFile();
|
||||
if (result.canceled || !result.success) return;
|
||||
await window.electronAPI.switchToEditor();
|
||||
};
|
||||
|
||||
const sendHudOverlayHide = () => {
|
||||
if (window.electronAPI && window.electronAPI.hudOverlayHide) {
|
||||
window.electronAPI.hudOverlayHide();
|
||||
}
|
||||
};
|
||||
const sendHudOverlayClose = () => {
|
||||
if (window.electronAPI && window.electronAPI.hudOverlayClose) {
|
||||
window.electronAPI.hudOverlayClose();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMicrophone = () => {
|
||||
if (!recording) {
|
||||
setMicrophoneEnabled(!microphoneEnabled);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-end justify-center bg-transparent">
|
||||
<div className={`flex flex-col items-center gap-2 mx-auto ${styles.electronDrag}`}>
|
||||
{/* Mic controls panel */}
|
||||
{showMicControls && (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-4 py-2 ${styles.micPanel} ${styles.electronNoDrag}`}
|
||||
>
|
||||
<select
|
||||
value={microphoneDeviceId || selectedDeviceId}
|
||||
onChange={(e) => {
|
||||
setSelectedDeviceId(e.target.value);
|
||||
setMicrophoneDeviceId(e.target.value);
|
||||
}}
|
||||
className="flex-1 bg-white/10 text-white text-xs rounded-full px-3 py-1 border border-white/20 outline-none truncate"
|
||||
style={{ maxWidth: "70%" }}
|
||||
>
|
||||
{devices.map((device) => (
|
||||
<option key={device.deviceId} value={device.deviceId}>
|
||||
{device.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<AudioLevelMeter level={level} className="w-24 h-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main pill bar */}
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-2 py-1.5 ${styles.hudBar}`}
|
||||
style={{
|
||||
borderRadius: 9999,
|
||||
background: "linear-gradient(135deg, rgba(28,28,36,0.97) 0%, rgba(18,18,26,0.96) 100%)",
|
||||
backdropFilter: "blur(16px) saturate(140%)",
|
||||
WebkitBackdropFilter: "blur(16px) saturate(140%)",
|
||||
border: "1px solid rgba(80,80,120,0.25)",
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
|
||||
<RxDragHandleDots2 size={16} className="text-white/30" />
|
||||
</div>
|
||||
|
||||
{/* Source selector */}
|
||||
<button
|
||||
className={`${styles.hudGroup} ${styles.electronNoDrag}`}
|
||||
onClick={openSourceSelector}
|
||||
disabled={recording}
|
||||
title={selectedSource}
|
||||
>
|
||||
<MdMonitor size={14} className="text-white/80" />
|
||||
<span className="text-white/70 text-[11px] max-w-[72px] truncate">
|
||||
{selectedSource}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Audio controls group */}
|
||||
<div className={`${styles.hudGroup} ${styles.electronNoDrag}`}>
|
||||
<button
|
||||
className={`${styles.hudIconBtn} ${systemAudioEnabled ? styles.hudIconActive : ""}`}
|
||||
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
|
||||
disabled={recording}
|
||||
title={systemAudioEnabled ? "Disable system audio" : "Enable system audio"}
|
||||
>
|
||||
{systemAudioEnabled ? (
|
||||
<MdVolumeUp size={15} className="text-green-400" />
|
||||
) : (
|
||||
<MdVolumeOff size={15} className="text-white/40" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.hudIconBtn} ${microphoneEnabled ? styles.hudIconActive : ""}`}
|
||||
onClick={toggleMicrophone}
|
||||
disabled={recording}
|
||||
title={microphoneEnabled ? "Disable microphone" : "Enable microphone"}
|
||||
>
|
||||
{microphoneEnabled ? (
|
||||
<MdMic size={15} className="text-green-400" />
|
||||
) : (
|
||||
<MdMicOff size={15} className="text-white/40" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Record/Stop group */}
|
||||
<button
|
||||
className={`${styles.hudGroup} ${styles.electronNoDrag} ${recording ? styles.recordingPulse : ""}`}
|
||||
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
|
||||
disabled={!hasSelectedSource && !recording}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
>
|
||||
{recording ? (
|
||||
<>
|
||||
<FaRegStopCircle size={13} className="text-red-400" />
|
||||
<span className="text-red-400 text-xs font-semibold tabular-nums">
|
||||
{formatTime(elapsed)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<BsRecordCircle
|
||||
size={14}
|
||||
className={hasSelectedSource ? "text-white/80" : "text-white/30"}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Open video file */}
|
||||
<button
|
||||
className={`${styles.hudIconBtn} ${styles.electronNoDrag}`}
|
||||
onClick={openVideoFile}
|
||||
disabled={recording}
|
||||
title="Open video file"
|
||||
>
|
||||
<MdVideoFile size={14} className="text-white/60" />
|
||||
</button>
|
||||
|
||||
{/* Open project */}
|
||||
<button
|
||||
className={`${styles.hudIconBtn} ${styles.electronNoDrag}`}
|
||||
onClick={openProjectFile}
|
||||
disabled={recording}
|
||||
title="Open project"
|
||||
>
|
||||
<FaFolderOpen size={14} className="text-white/60" />
|
||||
</button>
|
||||
|
||||
{/* Window controls */}
|
||||
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
|
||||
<button className={styles.windowBtn} title="Hide HUD" onClick={sendHudOverlayHide}>
|
||||
<FiMinus size={14} className="text-white" />
|
||||
</button>
|
||||
<button className={styles.windowBtn} title="Close App" onClick={sendHudOverlayClose}>
|
||||
<FiX size={14} className="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,99 @@
|
||||
|
||||
.glassContainer {
|
||||
background: linear-gradient(135deg, rgba(28,28,34,0.92) 0%, rgba(18,18,22,0.88) 100%);
|
||||
backdrop-filter: blur(20px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(160%);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 4px 16px 0 rgba(0,0,0,0.32), 0 1px 3px 0 rgba(0,0,0,0.18) inset;
|
||||
border: 1px solid rgba(60,60,80,0.18);
|
||||
background: linear-gradient(135deg, rgba(28, 28, 34, 0.92) 0%, rgba(18, 18, 22, 0.88) 100%);
|
||||
backdrop-filter: blur(20px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(160%);
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
0 4px 16px 0 rgba(0, 0, 0, 0.32),
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.18) inset;
|
||||
border: 1px solid rgba(60, 60, 80, 0.18);
|
||||
}
|
||||
|
||||
.sourceCard {
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(120deg, rgba(38,38,48,0.98) 0%, rgba(24,24,32,0.96) 100%);
|
||||
border: 1px solid rgba(60,60,80,0.22);
|
||||
box-shadow: 0 2px 8px 0 rgba(0,0,0,0.18);
|
||||
transition: box-shadow 0.2s, border 0.2s, background 0.2s;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(120deg, rgba(38, 38, 48, 0.98) 0%, rgba(24, 24, 32, 0.96) 100%);
|
||||
border: 1px solid rgba(60, 60, 80, 0.22);
|
||||
box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.18);
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sourceCard:hover {
|
||||
border-color: rgba(120, 120, 160, 0.35);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.selected {
|
||||
border: 2px solid #34B27B;
|
||||
background: linear-gradient(120deg, rgba(91,33,182,0.18) 0%, rgba(38,38,48,0.98) 100%);
|
||||
box-shadow: 0 0 0 2px #34B27B33;
|
||||
border: 2px solid #34b27b;
|
||||
background: linear-gradient(120deg, rgba(52, 178, 123, 0.08) 0%, rgba(38, 38, 48, 0.98) 100%);
|
||||
box-shadow:
|
||||
0 0 12px rgba(52, 178, 123, 0.15),
|
||||
0 0 4px rgba(52, 178, 123, 0.1);
|
||||
}
|
||||
|
||||
.selected:hover {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
color: #c7d2fe;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
color: #c7d2fe;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 0.85rem;
|
||||
color: #f3f4f6;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
font-size: 0.8rem;
|
||||
color: #e4e4e7;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.cardText {
|
||||
color: #a1a1aa;
|
||||
font-size: 0.75rem;
|
||||
color: #a1a1aa;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Checkmark badge */
|
||||
.checkBadge {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #34b27b;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 8px rgba(52, 178, 123, 0.4);
|
||||
}
|
||||
|
||||
/* scrollbar */
|
||||
.sourceGridScroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(52, 178, 123, 0.5) rgba(40, 40, 50, 0.6);
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar-track {
|
||||
background: rgba(30, 30, 38, 0.5);
|
||||
border-radius: 4px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(80, 80, 100, 0.6);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(52, 178, 123, 0.6);
|
||||
}
|
||||
|
||||
.sourceGridScroll::-webkit-scrollbar-thumb:active {
|
||||
background: rgba(52, 178, 123, 0.8);
|
||||
}
|
||||
|
||||
@@ -1,159 +1,158 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { MdCheck } from "react-icons/md";
|
||||
import { Button } from "../ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||
import { Card } from "../ui/card";
|
||||
import styles from "./SourceSelector.module.css";
|
||||
|
||||
interface DesktopSource {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnail: string | null;
|
||||
display_id: string;
|
||||
appIcon: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnail: string | null;
|
||||
display_id: string;
|
||||
appIcon: string | null;
|
||||
}
|
||||
|
||||
export function SourceSelector() {
|
||||
const [sources, setSources] = useState<DesktopSource[]>([]);
|
||||
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sources, setSources] = useState<DesktopSource[]>([]);
|
||||
const [selectedSource, setSelectedSource] = useState<DesktopSource | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchSources() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const rawSources = await window.electronAPI.getSources({
|
||||
types: ['screen', 'window'],
|
||||
thumbnailSize: { width: 320, height: 180 },
|
||||
fetchWindowIcons: true
|
||||
});
|
||||
setSources(
|
||||
rawSources.map(source => ({
|
||||
id: source.id,
|
||||
name:
|
||||
source.id.startsWith('window:') && source.name.includes(' — ')
|
||||
? source.name.split(' — ')[1] || source.name
|
||||
: source.name,
|
||||
thumbnail: source.thumbnail,
|
||||
display_id: source.display_id,
|
||||
appIcon: source.appIcon
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error loading sources:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchSources();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
async function fetchSources() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const rawSources = await window.electronAPI.getSources({
|
||||
types: ["screen", "window"],
|
||||
thumbnailSize: { width: 320, height: 180 },
|
||||
fetchWindowIcons: true,
|
||||
});
|
||||
setSources(
|
||||
rawSources.map((source) => ({
|
||||
id: source.id,
|
||||
name:
|
||||
source.id.startsWith("window:") && source.name.includes(" — ")
|
||||
? source.name.split(" — ")[1] || source.name
|
||||
: source.name,
|
||||
thumbnail: source.thumbnail,
|
||||
display_id: source.display_id,
|
||||
appIcon: source.appIcon,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error loading sources:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchSources();
|
||||
}, []);
|
||||
|
||||
const screenSources = sources.filter(s => s.id.startsWith('screen:'));
|
||||
const windowSources = sources.filter(s => s.id.startsWith('window:'));
|
||||
const screenSources = sources.filter((s) => s.id.startsWith("screen:"));
|
||||
const windowSources = sources.filter((s) => s.id.startsWith("window:"));
|
||||
|
||||
const handleSourceSelect = (source: DesktopSource) => setSelectedSource(source);
|
||||
const handleShare = async () => {
|
||||
if (selectedSource) await window.electronAPI.selectSource(selectedSource);
|
||||
};
|
||||
const handleSourceSelect = (source: DesktopSource) => setSelectedSource(source);
|
||||
const handleShare = async () => {
|
||||
if (selectedSource) await window.electronAPI.selectSource(selectedSource);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`h-full flex items-center justify-center ${styles.glassContainer}`} style={{ minHeight: '100vh' }}>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-zinc-600 mx-auto mb-2" />
|
||||
<p className="text-xs text-zinc-300">Loading sources...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className={`h-full flex items-center justify-center ${styles.glassContainer}`}
|
||||
style={{ minHeight: "100vh" }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#34B27B] mx-auto mb-2" />
|
||||
<p className="text-xs text-zinc-400">Loading sources...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col items-center justify-center ${styles.glassContainer}`}>
|
||||
<div className="flex-1 flex flex-col w-full max-w-xl" style={{ padding: 0 }}>
|
||||
<Tabs defaultValue="screens">
|
||||
<TabsList className="grid grid-cols-2 mb-3 bg-zinc-900/40 rounded-full">
|
||||
<TabsTrigger value="screens" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-zinc-200 rounded-full text-xs py-1">Screens</TabsTrigger>
|
||||
<TabsTrigger value="windows" className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-zinc-200 rounded-full text-xs py-1">Windows</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="h-60 flex flex-col justify-stretch">
|
||||
<TabsContent value="screens" className="h-full">
|
||||
<div className="grid grid-cols-2 gap-2 h-full overflow-y-auto pr-1 relative">
|
||||
{screenSources.map(source => (
|
||||
<Card
|
||||
key={source.id}
|
||||
className={`${styles.sourceCard} ${selectedSource?.id === source.id ? styles.selected : ''} cursor-pointer h-fit p-2 scale-95`}
|
||||
style={{ margin: 8, width: '90%', maxWidth: 220 }}
|
||||
onClick={() => handleSourceSelect(source)}
|
||||
>
|
||||
<div className="p-1">
|
||||
<div className="relative mb-1">
|
||||
<img
|
||||
src={source.thumbnail || ''}
|
||||
alt={source.name}
|
||||
className="w-full aspect-video object-cover rounded border border-zinc-800"
|
||||
/>
|
||||
{selectedSource?.id === source.id && (
|
||||
<div className="absolute -top-1 -right-1">
|
||||
<div className="w-4 h-4 bg-[#34B27B] rounded-full flex items-center justify-center shadow-md">
|
||||
<MdCheck className={styles.icon} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.name + " truncate"}>{source.name}</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="windows" className="h-full">
|
||||
<div className="grid grid-cols-2 gap-2 h-full overflow-y-auto pr-1 relative">
|
||||
{windowSources.map(source => (
|
||||
<Card
|
||||
key={source.id}
|
||||
className={`${styles.sourceCard} ${selectedSource?.id === source.id ? styles.selected : ''} cursor-pointer h-fit p-2 scale-95`}
|
||||
style={{ margin: 8, width: '90%', maxWidth: 220 }}
|
||||
onClick={() => handleSourceSelect(source)}
|
||||
>
|
||||
<div className="p-1">
|
||||
<div className="relative mb-1">
|
||||
<img
|
||||
src={source.thumbnail || ''}
|
||||
alt={source.name}
|
||||
className="w-full aspect-video object-cover rounded border border-gray-700"
|
||||
/>
|
||||
{selectedSource?.id === source.id && (
|
||||
<div className="absolute -top-1 -right-1">
|
||||
<div className="w-4 h-4 bg-blue-600 rounded-full flex items-center justify-center shadow-md">
|
||||
<MdCheck className={styles.icon} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{source.appIcon && (
|
||||
<img
|
||||
src={source.appIcon}
|
||||
alt="App icon"
|
||||
className={styles.icon + " flex-shrink-0"}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.name + " truncate"}>{source.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="border-t border-zinc-800 p-2 w-full max-w-xl">
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button variant="outline" onClick={() => window.close()} className="px-4 py-1 text-xs bg-zinc-800 border-zinc-700 text-zinc-200 hover:bg-zinc-700">Cancel</Button>
|
||||
<Button onClick={handleShare} disabled={!selectedSource} className="px-4 py-1 text-xs bg-[#34B27B] text-white hover:bg-[#34B27B]/80 disabled:opacity-50 disabled:bg-zinc-700">Share</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const renderSourceCard = (source: DesktopSource) => {
|
||||
const isSelected = selectedSource?.id === source.id;
|
||||
return (
|
||||
<div
|
||||
key={source.id}
|
||||
className={`${styles.sourceCard} ${isSelected ? styles.selected : ""} p-2`}
|
||||
onClick={() => handleSourceSelect(source)}
|
||||
>
|
||||
<div className="relative mb-1.5">
|
||||
<img
|
||||
src={source.thumbnail || ""}
|
||||
alt={source.name}
|
||||
className="w-full aspect-video object-cover rounded-lg"
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className="absolute -top-1.5 -right-1.5">
|
||||
<div className={styles.checkBadge}>
|
||||
<MdCheck size={12} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{source.appIcon && (
|
||||
<img src={source.appIcon} alt="" className={`${styles.icon} flex-shrink-0`} />
|
||||
)}
|
||||
<div className={`${styles.name} truncate`}>{source.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col ${styles.glassContainer}`}>
|
||||
<div className="flex-1 flex flex-col w-full px-4 pt-4">
|
||||
<Tabs defaultValue="screens" className="flex-1 flex flex-col">
|
||||
<TabsList className="grid grid-cols-2 mb-3 bg-white/5 rounded-full">
|
||||
<TabsTrigger
|
||||
value="screens"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
|
||||
>
|
||||
Screens
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="windows"
|
||||
className="data-[state=active]:bg-white/15 data-[state=active]:text-white text-zinc-400 rounded-full text-xs py-1 transition-all"
|
||||
>
|
||||
Windows
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex-1 min-h-0">
|
||||
<TabsContent value="screens" className="h-full mt-0">
|
||||
<div
|
||||
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}
|
||||
>
|
||||
{screenSources.map(renderSourceCard)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="windows" className="h-full mt-0">
|
||||
<div
|
||||
className={`grid grid-cols-2 gap-3 h-[280px] overflow-y-auto pr-1 auto-rows-min ${styles.sourceGridScroll}`}
|
||||
>
|
||||
{windowSources.map(renderSourceCard)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="p-3 flex justify-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => window.close()}
|
||||
className="px-5 py-1 text-xs text-zinc-400 hover:text-white hover:bg-white/5 rounded-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleShare}
|
||||
disabled={!selectedSource}
|
||||
className="px-5 py-1 text-xs bg-[#34B27B] text-white hover:bg-[#34B27B]/80 disabled:opacity-30 disabled:bg-zinc-700 rounded-full"
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b border-white/5", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AccordionItem.displayName = "AccordionItem";
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-3 text-sm font-medium text-slate-200 transition-all hover:text-white [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-slate-400 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -0,0 +1,37 @@
|
||||
interface AudioLevelMeterProps {
|
||||
level: number; // 0-100
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const bars = [
|
||||
{ threshold: 10, height: "30%" },
|
||||
{ threshold: 25, height: "45%" },
|
||||
{ threshold: 45, height: "60%" },
|
||||
{ threshold: 65, height: "75%" },
|
||||
{ threshold: 85, height: "90%" },
|
||||
];
|
||||
|
||||
function getBarColor(level: number, threshold: number) {
|
||||
if (!level || level < threshold) return "bg-slate-700";
|
||||
if (threshold > 80) return "bg-red-500";
|
||||
if (threshold > 60) return "bg-yellow-500";
|
||||
if (threshold > 40) return "bg-green-500";
|
||||
return "bg-emerald-500";
|
||||
}
|
||||
|
||||
export function AudioLevelMeter({ level, className = "" }: AudioLevelMeterProps) {
|
||||
return (
|
||||
<div className={`flex items-end justify-between gap-1.5 h-6 ${className}`}>
|
||||
{bars.map((bar, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex-1 rounded-sm transition-all duration-100 ease-out ${getBarColor(level, bar.threshold)}`}
|
||||
style={{
|
||||
height: level >= bar.threshold ? bar.height : "15%",
|
||||
opacity: level >= bar.threshold ? 1 : 0.4,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +1,50 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,76 +1,55 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Popover, PopoverArrow, PopoverContent, PopoverTrigger } from "./popover";
|
||||
|
||||
interface ContentClampProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
truncateLength?: number;
|
||||
}
|
||||
|
||||
function ContentClamp({ children, className, truncateLength = 50, ...props }: ContentClampProps) {
|
||||
const text = typeof children === "string" ? children : String(children ?? "");
|
||||
const isTruncated = text.length > truncateLength;
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setOpen(false);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isTruncated) {
|
||||
return (
|
||||
<div className={cn("inline", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const truncatedText = text.slice(0, truncateLength) + "...";
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<span
|
||||
className={className}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
{...props}
|
||||
>
|
||||
{truncatedText}
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto max-w-sm rounded-lg border border-white bg-popover p-3 text-sm text-popover-foreground"
|
||||
sideOffset={8}
|
||||
animated={false}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PopoverArrow className="fill-white" />
|
||||
{children}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export { ContentClamp };
|
||||
@@ -1,120 +1,102 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-[9999] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-[10000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
|
||||
@@ -1,199 +1,186 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -7,11 +7,12 @@ interface ItemContentProps extends PropsWithChildren {
|
||||
function ItemContent({ children, classes }: ItemContentProps) {
|
||||
return (
|
||||
<div
|
||||
className={`border-2 rounded-sm shadow-md w-full overflow-hidden flex flex-row pl-3 h-items-center ${classes}`}
|
||||
className={`bg-white/5 border border-white/10 rounded-md shadow-sm w-full flex flex-row items-center px-3 py-1 gap-2 transition-all duration-150 hover:bg-[#34B27B]/10 hover:shadow-lg ${classes}`}
|
||||
style={{ minHeight: 40 }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemContent;
|
||||
export default ItemContent;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
||||
|
||||
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Label.displayName = "Label";
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
animated = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
|
||||
animated?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-hidden",
|
||||
animated &&
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-popover-content-transform-origin)",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverArrow({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Arrow>) {
|
||||
return (
|
||||
<PopoverPrimitive.Arrow
|
||||
data-slot="popover-arrow"
|
||||
className={cn("fill-popover", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverArrow };
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -1,26 +1,23 @@
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-white/10">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-[#34B27B]" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border-2 border-[#34B27B] bg-[#34B27B] shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B]/50 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider }
|
||||
export { Slider };
|
||||
|
||||
@@ -3,25 +3,23 @@ import { Toaster as Sonner } from "sonner";
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
theme="light"
|
||||
className="toaster group"
|
||||
duration={3000}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Sonner
|
||||
theme="light"
|
||||
className="toaster group"
|
||||
duration={3000}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"data-[state=checked]:bg-[#34B27B] data-[state=unchecked]:bg-[#23232a]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform",
|
||||
"bg-[#f5f5f7] dark:bg-[#23232a]",
|
||||
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"data-[state=checked]:bg-[#34B27B] data-[state=unchecked]:bg-[#23232a]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform",
|
||||
"bg-[#f5f5f7] dark:bg-[#23232a]",
|
||||
"data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch }
|
||||
export { Switch };
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { toggleVariants } from "@/components/ui/toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center gap-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
));
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
|
||||
|
||||
const ToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-3",
|
||||
sm: "h-9 px-2.5",
|
||||
lg: "h-11 px-5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName;
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
@@ -0,0 +1,181 @@
|
||||
import { Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
addCustomFont,
|
||||
type CustomFont,
|
||||
generateFontId,
|
||||
isValidGoogleFontsUrl,
|
||||
parseFontFamilyFromImport,
|
||||
} from "@/lib/customFonts";
|
||||
|
||||
interface AddCustomFontDialogProps {
|
||||
onFontAdded?: (font: CustomFont) => void;
|
||||
}
|
||||
|
||||
export function AddCustomFontDialog({ onFontAdded }: AddCustomFontDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [importUrl, setImportUrl] = useState("");
|
||||
const [fontName, setFontName] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleImportUrlChange = (url: string) => {
|
||||
setImportUrl(url);
|
||||
|
||||
// Auto-extract font name if valid Google Fonts URL
|
||||
if (isValidGoogleFontsUrl(url)) {
|
||||
const extracted = parseFontFamilyFromImport(url);
|
||||
if (extracted && !fontName) {
|
||||
setFontName(extracted);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
// Validate inputs
|
||||
if (!importUrl.trim()) {
|
||||
toast.error("Please enter a Google Fonts import URL");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidGoogleFontsUrl(importUrl)) {
|
||||
toast.error("Please enter a valid Google Fonts URL");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fontName.trim()) {
|
||||
toast.error("Please enter a font name");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Extract font family from URL
|
||||
const fontFamily = parseFontFamilyFromImport(importUrl);
|
||||
if (!fontFamily) {
|
||||
toast.error("Could not extract font family from URL");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create custom font object
|
||||
const newFont: CustomFont = {
|
||||
id: generateFontId(fontName),
|
||||
name: fontName.trim(),
|
||||
fontFamily: fontFamily,
|
||||
importUrl: importUrl.trim(),
|
||||
};
|
||||
|
||||
// Add font (this will load and verify it) - throws if it fails
|
||||
await addCustomFont(newFont);
|
||||
|
||||
// Notify parent
|
||||
if (onFontAdded) {
|
||||
onFontAdded(newFont);
|
||||
}
|
||||
|
||||
toast.success(`Font "${fontName}" added successfully`);
|
||||
|
||||
// Reset and close
|
||||
setImportUrl("");
|
||||
setFontName("");
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to add custom font:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to load font";
|
||||
toast.error("Failed to add font", {
|
||||
description: errorMessage.includes("timeout")
|
||||
? "Font took too long to load. Please check the URL and try again."
|
||||
: "The font could not be loaded. Please verify the Google Fonts URL is correct.",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10 h-9 text-xs"
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
Add Google Font
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Google Font</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Add a custom font from Google Fonts to use in your annotations.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="import-url" className="text-slate-200">
|
||||
Google Fonts Import URL
|
||||
</Label>
|
||||
<Input
|
||||
id="import-url"
|
||||
placeholder="https://fonts.googleapis.com/css2?family=Roboto&display=swap"
|
||||
value={importUrl}
|
||||
onChange={(e) => handleImportUrlChange(e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-slate-200"
|
||||
/>
|
||||
<p className="text-xs text-slate-400">
|
||||
Get this from Google Fonts: Select a font → Click "Get font" → Copy the @import URL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="font-name" className="text-slate-200">
|
||||
Display Name
|
||||
</Label>
|
||||
<Input
|
||||
id="font-name"
|
||||
placeholder="My Custom Font"
|
||||
value={fontName}
|
||||
onChange={(e) => setFontName(e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-slate-200"
|
||||
/>
|
||||
<p className="text-xs text-slate-400">
|
||||
This is how the font will appear in the font selector
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
className="bg-white/5 border-white/10 text-slate-200 hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{loading ? "Adding..." : "Add Font"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useRef } from "react";
|
||||
import { Rnd } from "react-rnd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getArrowComponent } from "./ArrowSvgs";
|
||||
import type { AnnotationRegion } from "./types";
|
||||
|
||||
interface AnnotationOverlayProps {
|
||||
annotation: AnnotationRegion;
|
||||
isSelected: boolean;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
onPositionChange: (id: string, position: { x: number; y: number }) => void;
|
||||
onSizeChange: (id: string, size: { width: number; height: number }) => void;
|
||||
onClick: (id: string) => void;
|
||||
zIndex: number;
|
||||
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
|
||||
}
|
||||
|
||||
export function AnnotationOverlay({
|
||||
annotation,
|
||||
isSelected,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
onPositionChange,
|
||||
onSizeChange,
|
||||
onClick,
|
||||
zIndex,
|
||||
isSelectedBoost,
|
||||
}: AnnotationOverlayProps) {
|
||||
const x = (annotation.position.x / 100) * containerWidth;
|
||||
const y = (annotation.position.y / 100) * containerHeight;
|
||||
const width = (annotation.size.width / 100) * containerWidth;
|
||||
const height = (annotation.size.height / 100) * containerHeight;
|
||||
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
const renderArrow = () => {
|
||||
const direction = annotation.figureData?.arrowDirection || "right";
|
||||
const color = annotation.figureData?.color || "#34B27B";
|
||||
const strokeWidth = annotation.figureData?.strokeWidth || 4;
|
||||
|
||||
const ArrowComponent = getArrowComponent(direction);
|
||||
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (annotation.type) {
|
||||
case "text":
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full flex items-center p-2 overflow-hidden"
|
||||
style={{
|
||||
justifyContent:
|
||||
annotation.style.textAlign === "left"
|
||||
? "flex-start"
|
||||
: annotation.style.textAlign === "right"
|
||||
? "flex-end"
|
||||
: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: annotation.style.color,
|
||||
backgroundColor: annotation.style.backgroundColor,
|
||||
fontSize: `${annotation.style.fontSize}px`,
|
||||
fontFamily: annotation.style.fontFamily,
|
||||
fontWeight: annotation.style.fontWeight,
|
||||
fontStyle: annotation.style.fontStyle,
|
||||
textDecoration: annotation.style.textDecoration,
|
||||
textAlign: annotation.style.textAlign,
|
||||
wordBreak: "break-word",
|
||||
whiteSpace: "pre-wrap",
|
||||
boxDecorationBreak: "clone",
|
||||
WebkitBoxDecorationBreak: "clone",
|
||||
padding: "0.1em 0.2em",
|
||||
borderRadius: "4px",
|
||||
lineHeight: "1.4",
|
||||
}}
|
||||
>
|
||||
{annotation.content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "image":
|
||||
if (annotation.content && annotation.content.startsWith("data:image")) {
|
||||
return (
|
||||
<img
|
||||
src={annotation.content}
|
||||
alt="Annotation"
|
||||
className="w-full h-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-400 text-sm">
|
||||
No image
|
||||
</div>
|
||||
);
|
||||
|
||||
case "figure":
|
||||
if (!annotation.figureData) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-400 text-sm">
|
||||
No arrow data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center p-2">{renderArrow()}</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Rnd
|
||||
position={{ x, y }}
|
||||
size={{ width, height }}
|
||||
onDragStart={() => {
|
||||
isDraggingRef.current = true;
|
||||
}}
|
||||
onDragStop={(_e, d) => {
|
||||
const xPercent = (d.x / containerWidth) * 100;
|
||||
const yPercent = (d.y / containerHeight) * 100;
|
||||
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
|
||||
|
||||
// Reset dragging flag after a short delay to prevent click event
|
||||
setTimeout(() => {
|
||||
isDraggingRef.current = false;
|
||||
}, 100);
|
||||
}}
|
||||
onResizeStop={(_e, _direction, ref, _delta, position) => {
|
||||
const xPercent = (position.x / containerWidth) * 100;
|
||||
const yPercent = (position.y / containerHeight) * 100;
|
||||
const widthPercent = (ref.offsetWidth / containerWidth) * 100;
|
||||
const heightPercent = (ref.offsetHeight / containerHeight) * 100;
|
||||
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
|
||||
onSizeChange(annotation.id, { width: widthPercent, height: heightPercent });
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isDraggingRef.current) return;
|
||||
onClick(annotation.id);
|
||||
}}
|
||||
bounds="parent"
|
||||
className={cn(
|
||||
"cursor-move transition-all",
|
||||
isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
|
||||
)}
|
||||
style={{
|
||||
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
|
||||
pointerEvents: isSelected ? "auto" : "none",
|
||||
border: isSelected ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
|
||||
backgroundColor: isSelected ? "rgba(52, 178, 123, 0.1)" : "transparent",
|
||||
boxShadow: isSelected ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
|
||||
}}
|
||||
enableResizing={isSelected}
|
||||
disableDragging={!isSelected}
|
||||
resizeHandleStyles={{
|
||||
topLeft: {
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
backgroundColor: isSelected ? "white" : "transparent",
|
||||
border: isSelected ? "2px solid #34B27B" : "none",
|
||||
borderRadius: "50%",
|
||||
left: "-6px",
|
||||
top: "-6px",
|
||||
cursor: "nwse-resize",
|
||||
},
|
||||
topRight: {
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
backgroundColor: isSelected ? "white" : "transparent",
|
||||
border: isSelected ? "2px solid #34B27B" : "none",
|
||||
borderRadius: "50%",
|
||||
right: "-6px",
|
||||
top: "-6px",
|
||||
cursor: "nesw-resize",
|
||||
},
|
||||
bottomLeft: {
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
backgroundColor: isSelected ? "white" : "transparent",
|
||||
border: isSelected ? "2px solid #34B27B" : "none",
|
||||
borderRadius: "50%",
|
||||
left: "-6px",
|
||||
bottom: "-6px",
|
||||
cursor: "nesw-resize",
|
||||
},
|
||||
bottomRight: {
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
backgroundColor: isSelected ? "white" : "transparent",
|
||||
border: isSelected ? "2px solid #34B27B" : "none",
|
||||
borderRadius: "50%",
|
||||
right: "-6px",
|
||||
bottom: "-6px",
|
||||
cursor: "nwse-resize",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-full rounded-lg",
|
||||
annotation.type === "text" && "bg-transparent",
|
||||
annotation.type === "image" && "bg-transparent",
|
||||
annotation.type === "figure" && "bg-transparent",
|
||||
isSelected && "shadow-lg",
|
||||
)}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</Rnd>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
import Block from "@uiw/react-color-block";
|
||||
import {
|
||||
AlignCenter,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
Bold,
|
||||
ChevronDown,
|
||||
Image as ImageIcon,
|
||||
Info,
|
||||
Italic,
|
||||
Trash2,
|
||||
Type,
|
||||
Underline,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AddCustomFontDialog } from "./AddCustomFontDialog";
|
||||
import { getArrowComponent } from "./ArrowSvgs";
|
||||
import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
|
||||
|
||||
interface AnnotationSettingsPanelProps {
|
||||
annotation: AnnotationRegion;
|
||||
onContentChange: (content: string) => void;
|
||||
onTypeChange: (type: AnnotationType) => void;
|
||||
onStyleChange: (style: Partial<AnnotationRegion["style"]>) => void;
|
||||
onFigureDataChange?: (figureData: FigureData) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const FONT_FAMILIES = [
|
||||
{ value: "system-ui, -apple-system, sans-serif", label: "Classic" },
|
||||
{ value: "Georgia, serif", label: "Editor" },
|
||||
{ value: "Impact, Arial Black, sans-serif", label: "Strong" },
|
||||
{ value: "Courier New, monospace", label: "Typewriter" },
|
||||
{ value: "Brush Script MT, cursive", label: "Deco" },
|
||||
{ value: "Arial, sans-serif", label: "Simple" },
|
||||
{ value: "Verdana, sans-serif", label: "Modern" },
|
||||
{ value: "Trebuchet MS, sans-serif", label: "Clean" },
|
||||
];
|
||||
|
||||
const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128];
|
||||
|
||||
export function AnnotationSettingsPanel({
|
||||
annotation,
|
||||
onContentChange,
|
||||
onTypeChange,
|
||||
onStyleChange,
|
||||
onFigureDataChange,
|
||||
onDelete,
|
||||
}: AnnotationSettingsPanelProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [customFonts, setCustomFonts] = useState<CustomFont[]>([]);
|
||||
|
||||
// Load custom fonts on mount
|
||||
useEffect(() => {
|
||||
setCustomFonts(getCustomFonts());
|
||||
}, []);
|
||||
|
||||
const colorPalette = [
|
||||
"#FF0000", // Red
|
||||
"#FFD700", // Yellow/Gold
|
||||
"#00FF00", // Green
|
||||
"#FFFFFF", // White
|
||||
"#0000FF", // Blue
|
||||
"#FF6B00", // Orange
|
||||
"#9B59B6", // Purple
|
||||
"#E91E63", // Pink
|
||||
"#00BCD4", // Cyan
|
||||
"#FF5722", // Deep Orange
|
||||
"#8BC34A", // Light Green
|
||||
"#FFC107", // Amber
|
||||
"#34B27B", // Brand Green
|
||||
"#000000", // Black
|
||||
"#607D8B", // Blue Grey
|
||||
"#795548", // Brown
|
||||
];
|
||||
|
||||
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
toast.error("Invalid file type", {
|
||||
description: "Please upload a JPG, PNG, GIF, or WebP image file.",
|
||||
});
|
||||
event.target.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const dataUrl = e.target?.result as string;
|
||||
if (dataUrl) {
|
||||
onContentChange(dataUrl);
|
||||
toast.success("Image uploaded successfully!");
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
toast.error("Failed to upload image", {
|
||||
description: "There was an error reading the file.",
|
||||
});
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-slate-200">Annotation Settings</span>
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Type Selector */}
|
||||
<Tabs
|
||||
value={annotation.type}
|
||||
onValueChange={(value) => onTypeChange(value as AnnotationType)}
|
||||
className="mb-6"
|
||||
>
|
||||
<TabsList className="mb-4 bg-white/5 border border-white/5 p-1 w-full grid grid-cols-3 h-auto rounded-xl">
|
||||
<TabsTrigger
|
||||
value="text"
|
||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
|
||||
>
|
||||
<Type className="w-4 h-4" />
|
||||
Text
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="image"
|
||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
Image
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="figure"
|
||||
className="data-[state=active]:bg-[#34B27B] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M4 12h16m0 0l-6-6m6 6l-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
Arrow
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Text Content */}
|
||||
<TabsContent value="text" className="mt-0 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">Text Content</label>
|
||||
<textarea
|
||||
value={annotation.textContent || annotation.content}
|
||||
onChange={(e) => onContentChange(e.target.value)}
|
||||
placeholder="Enter your text..."
|
||||
rows={5}
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-slate-200 text-sm placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-[#34B27B] focus:border-transparent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Styling Controls */}
|
||||
<div className="space-y-4">
|
||||
{/* Font Family & Size */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||
Font Style
|
||||
</label>
|
||||
<Select
|
||||
value={annotation.style.fontFamily}
|
||||
onValueChange={(value) => onStyleChange({ fontFamily: value })}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
|
||||
<SelectValue placeholder="Select style" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[300px]">
|
||||
{FONT_FAMILIES.map((font) => (
|
||||
<SelectItem
|
||||
key={font.value}
|
||||
value={font.value}
|
||||
style={{ fontFamily: font.value }}
|
||||
>
|
||||
{font.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{customFonts.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-[10px] font-medium text-slate-400 uppercase tracking-wider">
|
||||
Custom Fonts
|
||||
</div>
|
||||
{customFonts.map((font) => (
|
||||
<SelectItem
|
||||
key={font.id}
|
||||
value={font.fontFamily}
|
||||
style={{ fontFamily: font.fontFamily }}
|
||||
>
|
||||
{font.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">Size</label>
|
||||
<Select
|
||||
value={annotation.style.fontSize.toString()}
|
||||
onValueChange={(value) => onStyleChange({ fontSize: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
|
||||
<SelectValue placeholder="Size" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200 max-h-[200px]">
|
||||
{FONT_SIZES.map((size) => (
|
||||
<SelectItem key={size} value={size.toString()}>
|
||||
{size}px
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Custom Font Button */}
|
||||
<div>
|
||||
<AddCustomFontDialog
|
||||
onFontAdded={(font) => {
|
||||
setCustomFonts(getCustomFonts());
|
||||
onStyleChange({ fontFamily: font.fontFamily });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Formatting Toggles */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<ToggleGroup
|
||||
type="multiple"
|
||||
className="justify-start bg-white/5 p-1 rounded-lg border border-white/5"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="bold"
|
||||
aria-label="Toggle bold"
|
||||
data-state={annotation.style.fontWeight === "bold" ? "on" : "off"}
|
||||
onClick={() =>
|
||||
onStyleChange({
|
||||
fontWeight: annotation.style.fontWeight === "bold" ? "normal" : "bold",
|
||||
})
|
||||
}
|
||||
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="italic"
|
||||
aria-label="Toggle italic"
|
||||
data-state={annotation.style.fontStyle === "italic" ? "on" : "off"}
|
||||
onClick={() =>
|
||||
onStyleChange({
|
||||
fontStyle: annotation.style.fontStyle === "italic" ? "normal" : "italic",
|
||||
})
|
||||
}
|
||||
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="underline"
|
||||
aria-label="Toggle underline"
|
||||
data-state={annotation.style.textDecoration === "underline" ? "on" : "off"}
|
||||
onClick={() =>
|
||||
onStyleChange({
|
||||
textDecoration:
|
||||
annotation.style.textDecoration === "underline" ? "none" : "underline",
|
||||
})
|
||||
}
|
||||
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
|
||||
>
|
||||
<Underline className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={annotation.style.textAlign}
|
||||
className="justify-start bg-white/5 p-1 rounded-lg border border-white/5"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="left"
|
||||
aria-label="Align left"
|
||||
onClick={() => onStyleChange({ textAlign: "left" })}
|
||||
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
|
||||
>
|
||||
<AlignLeft className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="center"
|
||||
aria-label="Align center"
|
||||
onClick={() => onStyleChange({ textAlign: "center" })}
|
||||
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
|
||||
>
|
||||
<AlignCenter className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="right"
|
||||
aria-label="Align right"
|
||||
onClick={() => onStyleChange({ textAlign: "right" })}
|
||||
className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
|
||||
>
|
||||
<AlignRight className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{/* Colors */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||
Text Color
|
||||
</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-9 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
|
||||
>
|
||||
<div
|
||||
className="w-4 h-4 rounded-full border border-white/20"
|
||||
style={{ backgroundColor: annotation.style.color }}
|
||||
/>
|
||||
<span className="text-xs text-slate-300 truncate flex-1 text-left">
|
||||
{annotation.style.color}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
|
||||
<Block
|
||||
color={annotation.style.color}
|
||||
colors={colorPalette}
|
||||
onChange={(color) => {
|
||||
onStyleChange({ color: color.hex });
|
||||
}}
|
||||
style={{
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||
Background
|
||||
</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-9 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full border border-white/20 relative overflow-hidden">
|
||||
<div className="absolute inset-0 checkerboard-bg opacity-50" />
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ backgroundColor: annotation.style.backgroundColor }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-300 truncate flex-1 text-left">
|
||||
{annotation.style.backgroundColor === "transparent" ? "None" : "Color"}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
|
||||
<Block
|
||||
color={
|
||||
annotation.style.backgroundColor === "transparent"
|
||||
? "#000000"
|
||||
: annotation.style.backgroundColor
|
||||
}
|
||||
colors={colorPalette}
|
||||
onChange={(color) => {
|
||||
onStyleChange({ backgroundColor: color.hex });
|
||||
}}
|
||||
style={{
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full mt-2 text-xs h-7 hover:bg-white/5 text-slate-400"
|
||||
onClick={() => {
|
||||
onStyleChange({ backgroundColor: "transparent" });
|
||||
}}
|
||||
>
|
||||
Clear Background
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Image Upload */}
|
||||
<TabsContent value="image" className="mt-0 space-y-4">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleImageUpload}
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="outline"
|
||||
className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all py-8"
|
||||
>
|
||||
<Upload className="w-5 h-5" />
|
||||
Upload Image
|
||||
</Button>
|
||||
|
||||
{annotation.content && annotation.content.startsWith("data:image") && (
|
||||
<div className="rounded-lg border border-white/10 overflow-hidden bg-white/5 p-2">
|
||||
<img
|
||||
src={annotation.content}
|
||||
alt="Uploaded annotation"
|
||||
className="w-full h-auto rounded-md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-slate-500 text-center leading-relaxed">
|
||||
Supported formats: JPG, PNG, GIF, WebP
|
||||
</p>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="figure" className="mt-0 space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-3 block">
|
||||
Arrow Direction
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(
|
||||
[
|
||||
"up",
|
||||
"down",
|
||||
"left",
|
||||
"right",
|
||||
"up-right",
|
||||
"up-left",
|
||||
"down-right",
|
||||
"down-left",
|
||||
] as ArrowDirection[]
|
||||
).map((direction) => {
|
||||
const ArrowComponent = getArrowComponent(direction);
|
||||
return (
|
||||
<button
|
||||
key={direction}
|
||||
onClick={() => {
|
||||
const newFigureData: FigureData = {
|
||||
...annotation.figureData!,
|
||||
arrowDirection: direction,
|
||||
};
|
||||
onFigureDataChange?.(newFigureData);
|
||||
}}
|
||||
className={cn(
|
||||
"h-16 rounded-lg border flex items-center justify-center transition-all p-2",
|
||||
annotation.figureData?.arrowDirection === direction
|
||||
? "bg-[#34B27B] border-[#34B27B]"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
|
||||
)}
|
||||
>
|
||||
<ArrowComponent
|
||||
color={
|
||||
annotation.figureData?.arrowDirection === direction
|
||||
? "#ffffff"
|
||||
: "#94a3b8"
|
||||
}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">
|
||||
Stroke Width: {annotation.figureData?.strokeWidth || 4}px
|
||||
</label>
|
||||
<Slider
|
||||
value={[annotation.figureData?.strokeWidth || 4]}
|
||||
onValueChange={([value]) => {
|
||||
const newFigureData: FigureData = {
|
||||
...annotation.figureData!,
|
||||
strokeWidth: value,
|
||||
};
|
||||
onFigureDataChange?.(newFigureData);
|
||||
}}
|
||||
min={1}
|
||||
max={6}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-200 mb-2 block">Arrow Color</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-10 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10"
|
||||
>
|
||||
<div
|
||||
className="w-5 h-5 rounded-full border border-white/20"
|
||||
style={{ backgroundColor: annotation.figureData?.color || "#34B27B" }}
|
||||
/>
|
||||
<span className="text-xs text-slate-300 truncate flex-1 text-left">
|
||||
{annotation.figureData?.color || "#34B27B"}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
|
||||
<Block
|
||||
color={annotation.figureData?.color || "#34B27B"}
|
||||
colors={colorPalette}
|
||||
onChange={(color) => {
|
||||
const newFigureData: FigureData = {
|
||||
...annotation.figureData!,
|
||||
color: color.hex,
|
||||
};
|
||||
onFigureDataChange?.(newFigureData);
|
||||
}}
|
||||
style={{
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Button
|
||||
onClick={onDelete}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete Annotation
|
||||
</Button>
|
||||
|
||||
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
|
||||
<div className="flex items-center gap-2 mb-2 text-slate-300">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<span className="text-xs font-medium">Shortcuts & Tips</span>
|
||||
</div>
|
||||
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
|
||||
<li>Move playhead to overlapping annotation section and select an item.</li>
|
||||
<li>
|
||||
Use{" "}
|
||||
<kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">Tab</kbd> to
|
||||
cycle through overlapping items.
|
||||
</li>
|
||||
<li>
|
||||
Use{" "}
|
||||
<kbd className="px-1 py-0.5 bg-white/10 rounded text-slate-300 font-mono">
|
||||
Shift+Tab
|
||||
</kbd>{" "}
|
||||
to cycle backwards.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { ArrowDirection } from "./types";
|
||||
|
||||
interface ArrowSvgProps {
|
||||
color: string;
|
||||
strokeWidth: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline SVG arrow components for 8 directions.
|
||||
* These match the visual style of the previous icon-based arrows but use
|
||||
* pure SVG paths for easy replication in export.
|
||||
*/
|
||||
|
||||
export function ArrowUp({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 50 20 L 50 80 M 50 20 L 35 35 M 50 20 L 65 35"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowDown({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 50 20 L 50 80 M 50 80 L 35 65 M 50 80 L 65 65"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowLeft({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 80 50 L 20 50 M 20 50 L 35 35 M 20 50 L 35 65"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowRight({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 20 50 L 80 50 M 80 50 L 65 35 M 80 50 L 65 65"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowUpRight({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 25 75 L 75 25 M 75 25 L 60 30 M 75 25 L 70 40"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowUpLeft({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 75 75 L 25 25 M 25 25 L 40 30 M 25 25 L 30 40"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowDownRight({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 25 25 L 75 75 M 75 75 L 70 60 M 75 75 L 60 70"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArrowDownLeft({ color, strokeWidth, className }: ArrowSvgProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} style={{ width: "100%", height: "100%" }}>
|
||||
<defs>
|
||||
<filter id="arrow-shadow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M 75 25 L 25 75 M 25 75 L 30 60 M 25 75 L 40 70"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
filter="url(#arrow-shadow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function getArrowComponent(direction: ArrowDirection) {
|
||||
switch (direction) {
|
||||
case "up":
|
||||
return ArrowUp;
|
||||
case "down":
|
||||
return ArrowDown;
|
||||
case "left":
|
||||
return ArrowLeft;
|
||||
case "right":
|
||||
return ArrowRight;
|
||||
case "up-right":
|
||||
return ArrowUpRight;
|
||||
case "up-left":
|
||||
return ArrowUpLeft;
|
||||
case "down-right":
|
||||
return ArrowDownRight;
|
||||
case "down-left":
|
||||
return ArrowDownLeft;
|
||||
}
|
||||
}
|
||||
@@ -1,221 +1,233 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
|
||||
interface CropRegion {
|
||||
x: number; // 0-1 normalized
|
||||
y: number; // 0-1 normalized
|
||||
width: number; // 0-1 normalized
|
||||
height: number; // 0-1 normalized
|
||||
x: number; // 0-1 normalized
|
||||
y: number; // 0-1 normalized
|
||||
width: number; // 0-1 normalized
|
||||
height: number; // 0-1 normalized
|
||||
}
|
||||
|
||||
interface CropControlProps {
|
||||
videoElement: HTMLVideoElement | null;
|
||||
cropRegion: CropRegion;
|
||||
onCropChange: (region: CropRegion) => void;
|
||||
videoElement: HTMLVideoElement | null;
|
||||
cropRegion: CropRegion;
|
||||
onCropChange: (region: CropRegion) => void;
|
||||
aspectRatio: AspectRatio;
|
||||
}
|
||||
|
||||
type DragHandle = 'top' | 'right' | 'bottom' | 'left' | null;
|
||||
type DragHandle = "top" | "right" | "bottom" | "left" | null;
|
||||
|
||||
export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState<DragHandle>(null);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [initialCrop, setInitialCrop] = useState<CropRegion>(cropRegion);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState<DragHandle>(null);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [initialCrop, setInitialCrop] = useState<CropRegion>(cropRegion);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoElement || !canvasRef.current) return;
|
||||
useEffect(() => {
|
||||
if (!videoElement || !canvasRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
if (!ctx) return;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext("2d", { alpha: false });
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = videoElement.videoWidth || 1920;
|
||||
canvas.height = videoElement.videoHeight || 1080;
|
||||
canvas.width = videoElement.videoWidth || 1920;
|
||||
canvas.height = videoElement.videoHeight || 1080;
|
||||
|
||||
const draw = () => {
|
||||
if (videoElement.readyState >= 2) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
requestAnimationFrame(draw);
|
||||
};
|
||||
const draw = () => {
|
||||
if (videoElement.readyState >= 2) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
const rafId = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [videoElement]);
|
||||
const rafId = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [videoElement]);
|
||||
|
||||
const getContainerRect = () => {
|
||||
return containerRef.current?.getBoundingClientRect() || { width: 0, height: 0, left: 0, top: 0 };
|
||||
};
|
||||
const getContainerRect = () => {
|
||||
return (
|
||||
containerRef.current?.getBoundingClientRect() || { width: 0, height: 0, left: 0, top: 0 }
|
||||
);
|
||||
};
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent, handle: DragHandle) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setIsDragging(handle);
|
||||
const rect = getContainerRect();
|
||||
setDragStart({
|
||||
x: (e.clientX - rect.left) / rect.width,
|
||||
y: (e.clientY - rect.top) / rect.height,
|
||||
});
|
||||
setInitialCrop(cropRegion);
|
||||
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
};
|
||||
const handlePointerDown = (e: React.PointerEvent, handle: DragHandle) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setIsDragging(handle);
|
||||
const rect = getContainerRect();
|
||||
setDragStart({
|
||||
x: (e.clientX - rect.left) / rect.width,
|
||||
y: (e.clientY - rect.top) / rect.height,
|
||||
});
|
||||
setInitialCrop(cropRegion);
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent) => {
|
||||
if (!isDragging) return;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const rect = getContainerRect();
|
||||
const currentX = (e.clientX - rect.left) / rect.width;
|
||||
const currentY = (e.clientY - rect.top) / rect.height;
|
||||
const deltaX = currentX - dragStart.x;
|
||||
const deltaY = currentY - dragStart.y;
|
||||
const handlePointerMove = (e: React.PointerEvent) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
let newCrop = { ...initialCrop };
|
||||
const rect = getContainerRect();
|
||||
const currentX = (e.clientX - rect.left) / rect.width;
|
||||
const currentY = (e.clientY - rect.top) / rect.height;
|
||||
const deltaX = currentX - dragStart.x;
|
||||
const deltaY = currentY - dragStart.y;
|
||||
|
||||
switch (isDragging) {
|
||||
case 'top': {
|
||||
const newY = Math.max(0, initialCrop.y + deltaY);
|
||||
const bottom = initialCrop.y + initialCrop.height;
|
||||
newCrop.y = Math.min(newY, bottom - 0.1);
|
||||
newCrop.height = bottom - newCrop.y;
|
||||
break;
|
||||
}
|
||||
case 'bottom':
|
||||
newCrop.height = Math.max(0.1, Math.min(initialCrop.height + deltaY, 1 - initialCrop.y));
|
||||
break;
|
||||
case 'left': {
|
||||
const newX = Math.max(0, initialCrop.x + deltaX);
|
||||
const right = initialCrop.x + initialCrop.width;
|
||||
newCrop.x = Math.min(newX, right - 0.1);
|
||||
newCrop.width = right - newCrop.x;
|
||||
break;
|
||||
}
|
||||
case 'right':
|
||||
newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x));
|
||||
break;
|
||||
}
|
||||
let newCrop = { ...initialCrop };
|
||||
|
||||
onCropChange(newCrop);
|
||||
};
|
||||
switch (isDragging) {
|
||||
case "top": {
|
||||
const newY = Math.max(0, initialCrop.y + deltaY);
|
||||
const bottom = initialCrop.y + initialCrop.height;
|
||||
newCrop.y = Math.min(newY, bottom - 0.1);
|
||||
newCrop.height = bottom - newCrop.y;
|
||||
break;
|
||||
}
|
||||
case "bottom":
|
||||
newCrop.height = Math.max(0.1, Math.min(initialCrop.height + deltaY, 1 - initialCrop.y));
|
||||
break;
|
||||
case "left": {
|
||||
const newX = Math.max(0, initialCrop.x + deltaX);
|
||||
const right = initialCrop.x + initialCrop.width;
|
||||
newCrop.x = Math.min(newX, right - 0.1);
|
||||
newCrop.width = right - newCrop.x;
|
||||
break;
|
||||
}
|
||||
case "right":
|
||||
newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x));
|
||||
break;
|
||||
}
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent) => {
|
||||
if (isDragging) {
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
setIsDragging(null);
|
||||
};
|
||||
onCropChange(newCrop);
|
||||
};
|
||||
|
||||
const cropPixelX = cropRegion.x * 100;
|
||||
const cropPixelY = cropRegion.y * 100;
|
||||
const cropPixelWidth = cropRegion.width * 100;
|
||||
const cropPixelHeight = cropRegion.height * 100;
|
||||
const handlePointerUp = (e: React.PointerEvent) => {
|
||||
if (isDragging) {
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} catch {}
|
||||
}
|
||||
setIsDragging(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full p-8">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full aspect-video bg-black rounded-lg overflow-visible cursor-default select-none shadow-2xl"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full rounded-lg"
|
||||
style={{ imageRendering: 'auto' }}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 pointer-events-none" style={{ transition: 'none' }}>
|
||||
<svg width="100%" height="100%" className="absolute inset-0" style={{ transition: 'none' }}>
|
||||
<defs>
|
||||
<mask id="cropMask">
|
||||
<rect width="100%" height="100%" fill="white" />
|
||||
<rect
|
||||
x={`${cropPixelX}%`}
|
||||
y={`${cropPixelY}%`}
|
||||
width={`${cropPixelWidth}%`}
|
||||
height={`${cropPixelHeight}%`}
|
||||
fill="black"
|
||||
style={{ transition: 'none' }}
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="black"
|
||||
fillOpacity="0.6"
|
||||
mask="url(#cropMask)"
|
||||
style={{ transition: 'none' }}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
const cropPixelX = cropRegion.x * 100;
|
||||
const cropPixelY = cropRegion.y * 100;
|
||||
const cropPixelWidth = cropRegion.width * 100;
|
||||
const cropPixelHeight = cropRegion.height * 100;
|
||||
const videoAspectRatio = videoElement
|
||||
? videoElement.videoWidth / videoElement.videoHeight
|
||||
: 16 / 9;
|
||||
const isVideoPortrait = videoAspectRatio < 1;
|
||||
const maxContainerWidth = isVideoPortrait ? "40vw" : "75vw";
|
||||
const maxContainerHeight = "75vh";
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]"
|
||||
)}
|
||||
style={{
|
||||
left: `${cropPixelX}%`,
|
||||
top: `${cropPixelY}%`,
|
||||
width: `${cropPixelWidth}%`,
|
||||
transform: 'translateY(-50%)',
|
||||
willChange: 'transform',
|
||||
transition: 'none',
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, 'top')}
|
||||
/>
|
||||
return (
|
||||
<div className="w-full p-8">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full bg-black rounded-lg overflow-visible cursor-default select-none shadow-2xl"
|
||||
style={{
|
||||
aspectRatio: videoAspectRatio,
|
||||
maxWidth: maxContainerWidth,
|
||||
maxHeight: maxContainerHeight,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full rounded-lg"
|
||||
style={{ imageRendering: "auto" }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]"
|
||||
)}
|
||||
style={{
|
||||
left: `${cropPixelX}%`,
|
||||
top: `${cropPixelY + cropPixelHeight}%`,
|
||||
width: `${cropPixelWidth}%`,
|
||||
transform: 'translateY(-50%)',
|
||||
willChange: 'transform',
|
||||
transition: 'none',
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, 'bottom')}
|
||||
/>
|
||||
<div className="absolute inset-0 pointer-events-none" style={{ transition: "none" }}>
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="absolute inset-0"
|
||||
style={{ transition: "none" }}
|
||||
>
|
||||
<defs>
|
||||
<mask id="cropMask">
|
||||
<rect width="100%" height="100%" fill="white" />
|
||||
<rect
|
||||
x={`${cropPixelX}%`}
|
||||
y={`${cropPixelY}%`}
|
||||
width={`${cropPixelWidth}%`}
|
||||
height={`${cropPixelHeight}%`}
|
||||
fill="black"
|
||||
style={{ transition: "none" }}
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="black"
|
||||
fillOpacity="0.6"
|
||||
mask="url(#cropMask)"
|
||||
style={{ transition: "none" }}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-[#34B27B]"
|
||||
)}
|
||||
style={{
|
||||
left: `${cropPixelX}%`,
|
||||
top: `${cropPixelY}%`,
|
||||
height: `${cropPixelHeight}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
willChange: 'transform',
|
||||
transition: 'none',
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, 'left')}
|
||||
/>
|
||||
<div
|
||||
className={cn("absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]")}
|
||||
style={{
|
||||
left: `${cropPixelX}%`,
|
||||
top: `${cropPixelY}%`,
|
||||
width: `${cropPixelWidth}%`,
|
||||
transform: "translateY(-50%)",
|
||||
willChange: "transform",
|
||||
transition: "none",
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, "top")}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-[#34B27B]"
|
||||
)}
|
||||
style={{
|
||||
left: `${cropPixelX + cropPixelWidth}%`,
|
||||
top: `${cropPixelY}%`,
|
||||
height: `${cropPixelHeight}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
willChange: 'transform',
|
||||
transition: 'none',
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, 'right')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div
|
||||
className={cn("absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]")}
|
||||
style={{
|
||||
left: `${cropPixelX}%`,
|
||||
top: `${cropPixelY + cropPixelHeight}%`,
|
||||
width: `${cropPixelWidth}%`,
|
||||
transform: "translateY(-50%)",
|
||||
willChange: "transform",
|
||||
transition: "none",
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, "bottom")}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn("absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-[#34B27B]")}
|
||||
style={{
|
||||
left: `${cropPixelX}%`,
|
||||
top: `${cropPixelY}%`,
|
||||
height: `${cropPixelHeight}%`,
|
||||
transform: "translateX(-50%)",
|
||||
willChange: "transform",
|
||||
transition: "none",
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, "left")}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn("absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-[#34B27B]")}
|
||||
style={{
|
||||
left: `${cropPixelX + cropPixelWidth}%`,
|
||||
top: `${cropPixelY}%`,
|
||||
height: `${cropPixelHeight}%`,
|
||||
transform: "translateX(-50%)",
|
||||
willChange: "transform",
|
||||
transition: "none",
|
||||
}}
|
||||
onPointerDown={(e) => handlePointerDown(e, "right")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,155 +1,271 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { X, Download, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { ExportProgress } from '@/lib/exporter';
|
||||
import { Download, Loader2, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner"; // Add this import
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ExportProgress } from "@/lib/exporter";
|
||||
|
||||
interface ExportDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
progress: ExportProgress | null;
|
||||
isExporting: boolean;
|
||||
error: string | null;
|
||||
onCancel?: () => void;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
progress: ExportProgress | null;
|
||||
isExporting: boolean;
|
||||
error: string | null;
|
||||
onCancel?: () => void;
|
||||
exportFormat?: "mp4" | "gif";
|
||||
exportedFilePath?: string;
|
||||
}
|
||||
|
||||
export function ExportDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
progress,
|
||||
isExporting,
|
||||
error,
|
||||
onCancel,
|
||||
isOpen,
|
||||
onClose,
|
||||
progress,
|
||||
isExporting,
|
||||
error,
|
||||
onCancel,
|
||||
exportFormat = "mp4",
|
||||
exportedFilePath, // Add this line
|
||||
}: ExportDialogProps) {
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExporting && progress && progress.percentage >= 100 && !error) {
|
||||
setShowSuccess(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShowSuccess(false);
|
||||
onClose();
|
||||
}, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isExporting, progress, error, onClose]);
|
||||
// Reset showSuccess when a new export starts or dialog reopens
|
||||
useEffect(() => {
|
||||
if (isExporting) {
|
||||
setShowSuccess(false);
|
||||
}
|
||||
}, [isExporting]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
// Reset showSuccess when dialog opens fresh
|
||||
useEffect(() => {
|
||||
if (isOpen && !isExporting && !progress) {
|
||||
setShowSuccess(false);
|
||||
}
|
||||
}, [isOpen, isExporting, progress]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-md z-50 animate-in fade-in duration-200"
|
||||
onClick={isExporting ? undefined : onClose}
|
||||
/>
|
||||
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-md animate-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
{showSuccess ? (
|
||||
<>
|
||||
<div className="w-12 h-12 rounded-full bg-[#34B27B]/20 flex items-center justify-center ring-1 ring-[#34B27B]/50">
|
||||
<Download className="w-6 h-6 text-[#34B27B]" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xl font-bold text-slate-200 block">Export Complete</span>
|
||||
<span className="text-sm text-slate-400">Your video is ready</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isExporting ? (
|
||||
<div className="w-12 h-12 rounded-full bg-[#34B27B]/10 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 text-[#34B27B] animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center border border-white/10">
|
||||
<Download className="w-6 h-6 text-slate-200" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-xl font-bold text-slate-200 block">
|
||||
{error ? 'Export Failed' : isExporting ? 'Exporting Video' : 'Export Video'}
|
||||
</span>
|
||||
<span className="text-sm text-slate-400">
|
||||
{error ? 'Please try again' : isExporting ? 'This may take a moment...' : 'Ready to start'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isExporting && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="hover:bg-white/10 text-slate-400 hover:text-white rounded-full"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
useEffect(() => {
|
||||
if (!isExporting && progress && progress.percentage >= 100 && !error) {
|
||||
setShowSuccess(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShowSuccess(false);
|
||||
onClose();
|
||||
}, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isExporting, progress, error, onClose]);
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 animate-in slide-in-from-top-2">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-xl p-4 flex items-start gap-3">
|
||||
<div className="p-1 bg-red-500/20 rounded-full">
|
||||
<X className="w-3 h-3 text-red-400" />
|
||||
</div>
|
||||
<p className="text-sm text-red-400 leading-relaxed">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
if (!isOpen) return null;
|
||||
|
||||
{isExporting && progress && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
<span>Progress</span>
|
||||
<span className="font-mono text-slate-200">{progress.percentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-white/5 rounded-full overflow-hidden border border-white/5">
|
||||
<div
|
||||
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.min(progress.percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
const formatLabel = exportFormat === "gif" ? "GIF" : "Video";
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">Current Frame</div>
|
||||
<div className="text-slate-200 font-mono text-lg font-medium">
|
||||
{progress.currentFrame} <span className="text-slate-500 text-sm">/ {progress.totalFrames}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">Status</div>
|
||||
<div className="text-slate-200 font-medium text-sm flex items-center gap-2 h-[28px]">
|
||||
<span className="w-2 h-2 rounded-full bg-[#34B27B] animate-pulse" />
|
||||
Processing
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
// Determine if we're in the compiling phase (frames done but still exporting)
|
||||
const isCompiling =
|
||||
isExporting && progress && progress.percentage >= 100 && exportFormat === "gif";
|
||||
const isFinalizing = progress?.phase === "finalizing";
|
||||
const renderProgress = progress?.renderProgress;
|
||||
|
||||
{onCancel && (
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="destructive"
|
||||
className="w-full py-6 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all rounded-xl"
|
||||
>
|
||||
Cancel Export
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
// Get status message based on phase
|
||||
const getStatusMessage = () => {
|
||||
if (error) return "Please try again";
|
||||
if (isCompiling || isFinalizing) {
|
||||
if (renderProgress !== undefined && renderProgress > 0) {
|
||||
return `Compiling GIF... ${renderProgress}%`;
|
||||
}
|
||||
return "Compiling GIF... This may take a while";
|
||||
}
|
||||
return "This may take a moment...";
|
||||
};
|
||||
|
||||
{showSuccess && (
|
||||
<div className="text-center py-4 animate-in zoom-in-95">
|
||||
<p className="text-lg text-slate-200 font-medium">Video saved successfully!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
// Get title based on phase
|
||||
const getTitle = () => {
|
||||
if (error) return "Export Failed";
|
||||
if (isCompiling || isFinalizing) return "Compiling GIF";
|
||||
return `Exporting ${formatLabel}`;
|
||||
};
|
||||
|
||||
const handleClickShowInFolder = async () => {
|
||||
if (exportedFilePath) {
|
||||
try {
|
||||
const result = await window.electronAPI.revealInFolder(exportedFilePath);
|
||||
if (!result.success) {
|
||||
const errorMessage = result.error || result.message || "Failed to reveal item in folder.";
|
||||
console.error("Failed to reveal in folder:", errorMessage);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = String(err);
|
||||
console.error("Error calling revealInFolder IPC:", errorMessage);
|
||||
toast.error(`Error revealing in folder: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-md z-50 animate-in fade-in duration-200"
|
||||
onClick={isExporting ? undefined : onClose}
|
||||
/>
|
||||
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-md animate-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{showSuccess ? (
|
||||
<>
|
||||
<div className="w-12 h-12 rounded-full bg-[#34B27B]/20 flex items-center justify-center ring-1 ring-[#34B27B]/50">
|
||||
<Download className="w-6 h-6 text-[#34B27B]" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold text-slate-200 block">Export Complete</span>
|
||||
<span className="text-sm text-slate-400">
|
||||
Your {formatLabel.toLowerCase()} is ready
|
||||
</span>
|
||||
{exportedFilePath && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleClickShowInFolder}
|
||||
className="mt-2 w-fit px-3 py-1 text-sm rounded-md bg-white/10 hover:bg-white/20 text-slate-200"
|
||||
>
|
||||
Show in Folder
|
||||
</Button>
|
||||
)}
|
||||
{exportedFilePath && (
|
||||
<span className="text-xs text-slate-500 break-all max-w-xs mt-1">
|
||||
{exportedFilePath.split("/").pop()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isExporting ? (
|
||||
<div className="w-12 h-12 rounded-full bg-[#34B27B]/10 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 text-[#34B27B] animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center border border-white/10">
|
||||
<Download className="w-6 h-6 text-slate-200" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-xl font-bold text-slate-200 block">{getTitle()}</span>
|
||||
<span className="text-sm text-slate-400">{getStatusMessage()}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isExporting && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="hover:bg-white/10 text-slate-400 hover:text-white rounded-full"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 animate-in slide-in-from-top-2">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-xl p-4 flex items-start gap-3">
|
||||
<div className="p-1 bg-red-500/20 rounded-full">
|
||||
<X className="w-3 h-3 text-red-400" />
|
||||
</div>
|
||||
<p className="text-sm text-red-400 leading-relaxed">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExporting && progress && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
<span>{isCompiling || isFinalizing ? "Compiling" : "Rendering Frames"}</span>
|
||||
<span className="font-mono text-slate-200">
|
||||
{isCompiling || isFinalizing ? (
|
||||
renderProgress !== undefined && renderProgress > 0 ? (
|
||||
`${renderProgress}%`
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Processing...
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
`${progress.percentage.toFixed(0)}%`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-white/5 rounded-full overflow-hidden border border-white/5">
|
||||
{isCompiling || isFinalizing ? (
|
||||
// Show render progress if available, otherwise animated indeterminate bar
|
||||
renderProgress !== undefined && renderProgress > 0 ? (
|
||||
<div
|
||||
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
|
||||
style={{ width: `${renderProgress}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full relative overflow-hidden">
|
||||
<div
|
||||
className="absolute h-full w-1/3 bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)]"
|
||||
style={{
|
||||
animation: "indeterminate 1.5s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
<style>{`
|
||||
@keyframes indeterminate {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(400%); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
className="h-full bg-[#34B27B] shadow-[0_0_10px_rgba(52,178,123,0.3)] transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.min(progress.percentage, 100)}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
|
||||
{isCompiling || isFinalizing ? "Status" : "Format"}
|
||||
</div>
|
||||
<div className="text-slate-200 font-medium text-sm">
|
||||
{isCompiling || isFinalizing ? "Compiling..." : formatLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl p-3 border border-white/5">
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1">
|
||||
Frames
|
||||
</div>
|
||||
<div className="text-slate-200 font-medium text-sm">
|
||||
{progress.currentFrame} / {progress.totalFrames}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onCancel && (
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="destructive"
|
||||
className="w-full py-6 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all rounded-xl"
|
||||
>
|
||||
Cancel Export
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSuccess && (
|
||||
<div className="text-center py-4 animate-in zoom-in-95">
|
||||
<p className="text-lg text-slate-200 font-medium">{formatLabel} saved successfully!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Film, Image } from "lucide-react";
|
||||
import type { ExportFormat } from "@/lib/exporter/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FormatSelectorProps {
|
||||
selectedFormat: ExportFormat;
|
||||
onFormatChange: (format: ExportFormat) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface FormatOption {
|
||||
value: ExportFormat;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const formatOptions: FormatOption[] = [
|
||||
{
|
||||
value: "mp4",
|
||||
label: "MP4 Video",
|
||||
description: "High quality video file",
|
||||
icon: <Film className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
value: "gif",
|
||||
label: "GIF Animation",
|
||||
description: "Animated image for sharing",
|
||||
icon: <Image className="w-5 h-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
export function FormatSelector({
|
||||
selectedFormat,
|
||||
onFormatChange,
|
||||
disabled = false,
|
||||
}: FormatSelectorProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{formatOptions.map((option) => {
|
||||
const isSelected = selectedFormat === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onFormatChange(option.value)}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 p-4 rounded-xl border transition-all duration-200",
|
||||
"focus:outline-none focus:ring-2 focus:ring-[#34B27B]/50 focus:ring-offset-2 focus:ring-offset-[#09090b]",
|
||||
isSelected
|
||||
? "bg-[#34B27B]/10 border-[#34B27B]/50 text-white"
|
||||
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:border-white/20 hover:text-slate-200",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center transition-colors",
|
||||
isSelected ? "bg-[#34B27B]/20 text-[#34B27B]" : "bg-white/5",
|
||||
)}
|
||||
>
|
||||
{option.icon}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-sm">{option.label}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{option.description}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 w-2 h-2 rounded-full bg-[#34B27B]" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
GIF_FRAME_RATES,
|
||||
GIF_SIZE_PRESETS,
|
||||
type GifFrameRate,
|
||||
type GifSizePreset,
|
||||
} from "@/lib/exporter/types";
|
||||
|
||||
interface GifOptionsPanelProps {
|
||||
frameRate: GifFrameRate;
|
||||
onFrameRateChange: (rate: GifFrameRate) => void;
|
||||
loop: boolean;
|
||||
onLoopChange: (loop: boolean) => void;
|
||||
sizePreset: GifSizePreset;
|
||||
onSizePresetChange: (preset: GifSizePreset) => void;
|
||||
outputDimensions: { width: number; height: number };
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function GifOptionsPanel({
|
||||
frameRate,
|
||||
onFrameRateChange,
|
||||
loop,
|
||||
onLoopChange,
|
||||
sizePreset,
|
||||
onSizePresetChange,
|
||||
outputDimensions,
|
||||
disabled = false,
|
||||
}: GifOptionsPanelProps) {
|
||||
const sizePresetOptions = Object.entries(GIF_SIZE_PRESETS).map(([key, value]) => ({
|
||||
value: key as GifSizePreset,
|
||||
label: value.label,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-in slide-in-from-bottom-2 duration-200">
|
||||
{/* Frame Rate */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Frame Rate
|
||||
</label>
|
||||
<Select
|
||||
value={String(frameRate)}
|
||||
onValueChange={(value) => onFrameRateChange(Number(value) as GifFrameRate)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
|
||||
{GIF_FRAME_RATES.map((rate) => (
|
||||
<SelectItem
|
||||
key={rate.value}
|
||||
value={String(rate.value)}
|
||||
className="text-slate-200 focus:bg-white/10 focus:text-white"
|
||||
>
|
||||
{rate.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Size Preset */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Output Size
|
||||
</label>
|
||||
<Select
|
||||
value={sizePreset}
|
||||
onValueChange={(value) => onSizePresetChange(value as GifSizePreset)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 hover:bg-white/10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1a1a1f] border-white/10 z-[100]">
|
||||
{sizePresetOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="text-slate-200 focus:bg-white/10 focus:text-white"
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-xs text-slate-500">
|
||||
Output: {outputDimensions.width} × {outputDimensions.height}px
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loop Toggle */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-200">Loop Animation</label>
|
||||
<p className="text-xs text-slate-500">GIF will play continuously</p>
|
||||
</div>
|
||||
<Switch checked={loop} onCheckedChange={onLoopChange} disabled={disabled} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { HelpCircle, Settings2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { formatBinding, SHORTCUT_ACTIONS, SHORTCUT_LABELS } from "@/lib/shortcuts";
|
||||
import { formatShortcut } from "@/utils/platformUtils";
|
||||
|
||||
export function KeyboardShortcutsHelp() {
|
||||
const { shortcuts, isMac, openConfig } = useShortcuts();
|
||||
|
||||
const [scrollLabels, setScrollLabels] = useState({
|
||||
pan: "Shift + Ctrl + Scroll",
|
||||
zoom: "Ctrl + Scroll",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
formatShortcut(["shift", "mod", "Scroll"]),
|
||||
formatShortcut(["mod", "Scroll"]),
|
||||
]).then(([pan, zoom]) => setScrollLabels({ pan, zoom }));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<HelpCircle className="w-4 h-4 text-slate-500 hover:text-[#34B27B] transition-colors cursor-help" />
|
||||
|
||||
<div className="absolute right-0 top-full mt-2 w-64 bg-[#09090b] border border-white/10 rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 shadow-xl z-50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-slate-200">Keyboard Shortcuts</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openConfig}
|
||||
title="Customize shortcuts"
|
||||
className="flex items-center gap-1 text-[10px] text-slate-500 hover:text-[#34B27B] transition-colors"
|
||||
>
|
||||
<Settings2 className="w-3 h-3" />
|
||||
Customize
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-[10px]">
|
||||
{SHORTCUT_ACTIONS.map((action) => (
|
||||
<div key={action} className="flex items-center justify-between">
|
||||
<span className="text-slate-400">{SHORTCUT_LABELS[action]}</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
{formatBinding(shortcuts[action], isMac)}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="pt-1 border-t border-white/5 mt-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Pan Timeline</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
{scrollLabels.pan}
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-slate-400">Zoom Timeline</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
{scrollLabels.zoom}
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-slate-400">Cycle Annotations</span>
|
||||
<kbd className="px-1 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-mono">
|
||||
Tab
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +1,89 @@
|
||||
import { Button } from "../ui/button";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
import { Pause, Play } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface PlaybackControlsProps {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onTogglePlayPause: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onTogglePlayPause: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
}
|
||||
|
||||
export default function PlaybackControls({
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
onTogglePlayPause,
|
||||
onSeek,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
onTogglePlayPause,
|
||||
onSeek,
|
||||
}: PlaybackControlsProps) {
|
||||
function formatTime(seconds: number) {
|
||||
if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return '0:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
function formatTime(seconds: number) {
|
||||
if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return "0:00";
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function handleSeekChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
onSeek(parseFloat(e.target.value));
|
||||
}
|
||||
function handleSeekChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
onSeek(parseFloat(e.target.value));
|
||||
}
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2 rounded-full bg-black/60 backdrop-blur-md border border-white/10 shadow-xl transition-all duration-300 hover:bg-black/70 hover:border-white/20">
|
||||
<Button
|
||||
onClick={onTogglePlayPause}
|
||||
size="icon"
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full transition-all duration-200 border border-white/10",
|
||||
isPlaying
|
||||
? "bg-white/10 text-white hover:bg-white/20"
|
||||
: "bg-white text-black hover:bg-white/90 hover:scale-105 shadow-[0_0_15px_rgba(255,255,255,0.3)]"
|
||||
)}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-3.5 h-3.5 fill-current" />
|
||||
) : (
|
||||
<Play className="w-3.5 h-3.5 fill-current ml-0.5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<span className="text-[10px] font-medium text-slate-300 tabular-nums w-[35px] text-right">
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 relative h-6 flex items-center group">
|
||||
{/* Custom Track Background */}
|
||||
<div className="absolute left-0 right-0 h-0.5 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#34B27B] rounded-full"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Interactive Input */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 100}
|
||||
value={currentTime}
|
||||
onChange={handleSeekChange}
|
||||
step="0.01"
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
||||
/>
|
||||
|
||||
{/* Custom Thumb (visual only, follows progress) */}
|
||||
<div
|
||||
className="absolute w-2.5 h-2.5 bg-white rounded-full shadow-lg pointer-events-none group-hover:scale-125 transition-transform duration-100"
|
||||
style={{
|
||||
left: `${progress}%`,
|
||||
transform: 'translateX(-50%)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] font-medium text-slate-500 tabular-nums w-[35px]">
|
||||
{formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 py-0.5 rounded-full bg-black/60 backdrop-blur-md border border-white/10 shadow-xl transition-all duration-300 hover:bg-black/70 hover:border-white/20">
|
||||
<Button
|
||||
onClick={onTogglePlayPause}
|
||||
size="icon"
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full transition-all duration-200 border border-white/10",
|
||||
isPlaying
|
||||
? "bg-white/10 text-white hover:bg-white/20"
|
||||
: "bg-white text-black hover:bg-white/90 hover:scale-105 shadow-[0_0_15px_rgba(255,255,255,0.3)]",
|
||||
)}
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-3.5 h-3.5 fill-current" />
|
||||
) : (
|
||||
<Play className="w-3.5 h-3.5 fill-current ml-0.5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<span className="text-[9px] font-medium text-slate-300 tabular-nums w-[30px] text-right">
|
||||
{formatTime(currentTime)}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 relative h-6 flex items-center group">
|
||||
{/* Custom Track Background */}
|
||||
<div className="absolute left-0 right-0 h-0.5 bg-white/10 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-[#34B27B] rounded-full" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
|
||||
{/* Interactive Input */}
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 100}
|
||||
value={currentTime}
|
||||
onChange={handleSeekChange}
|
||||
step="0.01"
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
||||
/>
|
||||
|
||||
{/* Custom Thumb (visual only, follows progress) */}
|
||||
<div
|
||||
className="absolute w-2.5 h-2.5 bg-white rounded-full shadow-lg pointer-events-none group-hover:scale-125 transition-transform duration-100"
|
||||
style={{
|
||||
left: `${progress}%`,
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-[9px] font-medium text-slate-500 tabular-nums w-[30px]">
|
||||
{formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { Keyboard, RotateCcw } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import {
|
||||
DEFAULT_SHORTCUTS,
|
||||
FIXED_SHORTCUTS,
|
||||
findConflict,
|
||||
formatBinding,
|
||||
SHORTCUT_ACTIONS,
|
||||
SHORTCUT_LABELS,
|
||||
type ShortcutAction,
|
||||
type ShortcutBinding,
|
||||
type ShortcutConflict,
|
||||
type ShortcutsConfig,
|
||||
} from "@/lib/shortcuts";
|
||||
|
||||
const MODIFIER_KEYS = new Set(["Control", "Shift", "Alt", "Meta"]);
|
||||
|
||||
export function ShortcutsConfigDialog() {
|
||||
const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } =
|
||||
useShortcuts();
|
||||
|
||||
const [draft, setDraft] = useState<ShortcutsConfig>(shortcuts);
|
||||
const [captureFor, setCaptureFor] = useState<ShortcutAction | null>(null);
|
||||
const [conflict, setConflict] = useState<{
|
||||
forAction: ShortcutAction;
|
||||
pending: ShortcutBinding;
|
||||
conflictWith: ShortcutConflict;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConfigOpen) {
|
||||
setDraft(shortcuts);
|
||||
setCaptureFor(null);
|
||||
setConflict(null);
|
||||
}
|
||||
}, [isConfigOpen, shortcuts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!captureFor) return;
|
||||
|
||||
const handleCapture = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === "Escape") {
|
||||
setCaptureFor(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (MODIFIER_KEYS.has(e.key)) return;
|
||||
|
||||
const binding: ShortcutBinding = {
|
||||
key: e.key.toLowerCase(),
|
||||
...(e.ctrlKey || e.metaKey ? { ctrl: true } : {}),
|
||||
...(e.shiftKey ? { shift: true } : {}),
|
||||
...(e.altKey ? { alt: true } : {}),
|
||||
};
|
||||
|
||||
const found = findConflict(binding, captureFor, draft);
|
||||
setCaptureFor(null);
|
||||
|
||||
if (found?.type === "fixed") {
|
||||
toast.error(`This shortcut is reserved for "${found.label}" and cannot be reassigned.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (found?.type === "configurable") {
|
||||
setConflict({ forAction: captureFor, pending: binding, conflictWith: found });
|
||||
return;
|
||||
}
|
||||
|
||||
setDraft((prev: ShortcutsConfig) => ({ ...prev, [captureFor]: binding }));
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleCapture, { capture: true });
|
||||
return () => window.removeEventListener("keydown", handleCapture, { capture: true });
|
||||
}, [captureFor]);
|
||||
|
||||
const handleSwap = useCallback(() => {
|
||||
if (!conflict || conflict.conflictWith.type !== "configurable") return;
|
||||
const { forAction, pending, conflictWith } = conflict;
|
||||
setDraft((prev: ShortcutsConfig) => ({
|
||||
...prev,
|
||||
[forAction]: pending,
|
||||
[conflictWith.action]: prev[forAction],
|
||||
}));
|
||||
setConflict(null);
|
||||
}, [conflict]);
|
||||
|
||||
const handleCancelConflict = useCallback(() => setConflict(null), []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setShortcuts(draft);
|
||||
await persistShortcuts(draft);
|
||||
toast.success("Keyboard shortcuts saved");
|
||||
closeConfig();
|
||||
}, [draft, setShortcuts, persistShortcuts, closeConfig]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setDraft({ ...DEFAULT_SHORTCUTS });
|
||||
toast.info("Reset to default shortcuts — click Save to apply");
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setCaptureFor(null);
|
||||
setConflict(null);
|
||||
closeConfig();
|
||||
}, [closeConfig]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isConfigOpen}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) handleClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="bg-[#09090b] border-white/10 text-white max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||
<Keyboard className="w-4 h-4 text-[#34B27B]" />
|
||||
Keyboard Shortcuts
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
||||
Configurable
|
||||
</p>
|
||||
{SHORTCUT_ACTIONS.map((action) => {
|
||||
const isCapturing = captureFor === action;
|
||||
const hasConflict = conflict?.forAction === action;
|
||||
return (
|
||||
<div key={action}>
|
||||
<div className="flex items-center justify-between py-1.5 px-1 border-b border-white/5">
|
||||
<span className="text-sm text-slate-300">{SHORTCUT_LABELS[action]}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConflict(null);
|
||||
setCaptureFor(isCapturing ? null : action);
|
||||
}}
|
||||
title={isCapturing ? "Press Esc to cancel" : "Click to change"}
|
||||
className={[
|
||||
"px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none",
|
||||
isCapturing
|
||||
? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B] animate-pulse"
|
||||
: hasConflict
|
||||
? "bg-amber-500/10 border-amber-500/50 text-amber-400"
|
||||
: "bg-white/5 border-white/10 text-slate-200 hover:border-[#34B27B]/50 hover:text-[#34B27B] cursor-pointer",
|
||||
].join(" ")}
|
||||
>
|
||||
{isCapturing ? "Press a key…" : formatBinding(draft[action], isMac)}
|
||||
</button>
|
||||
</div>
|
||||
{hasConflict && conflict?.conflictWith.type === "configurable" && (
|
||||
<div className="flex items-center justify-between px-1 py-1.5 mb-0.5 bg-amber-500/10 border border-amber-500/20 rounded text-xs">
|
||||
<span className="text-amber-400">
|
||||
⚠ Already used by{" "}
|
||||
<strong>{SHORTCUT_LABELS[conflict.conflictWith.action]}</strong>
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSwap}
|
||||
className="px-2 py-0.5 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500/40 rounded text-amber-300 font-medium transition-colors"
|
||||
>
|
||||
Swap
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelConflict}
|
||||
className="px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded text-slate-400 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5 mt-2">
|
||||
<p className="text-[10px] text-slate-500 mb-2 uppercase tracking-wide font-semibold">
|
||||
Fixed
|
||||
</p>
|
||||
{FIXED_SHORTCUTS.map(({ label, display }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex items-center justify-between py-1.5 px-1 border-b border-white/5 last:border-0"
|
||||
>
|
||||
<span className="text-sm text-slate-400">{label}</span>
|
||||
<kbd className="px-2 py-1 bg-white/5 border border-white/10 rounded text-xs font-mono text-slate-400 min-w-[90px] text-center">
|
||||
{display}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
Click a shortcut then press the new key combination. Press{" "}
|
||||
<span className="font-mono border border-white/10 rounded px-1">Esc</span> to cancel.
|
||||
</p>
|
||||
|
||||
<DialogFooter className="flex gap-2 sm:justify-between mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-400 hover:text-white gap-1.5"
|
||||
onClick={handleReset}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Reset to defaults
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#34B27B] hover:bg-[#2d9e6c] text-white"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { ArrowRight, HelpCircle, Scissors } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function TutorialHelp() {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-slate-400 hover:text-slate-200 hover:bg-white/10 transition-all gap-1.5"
|
||||
>
|
||||
<HelpCircle className="w-3.5 h-3.5" />
|
||||
<span className="font-medium">How trimming works</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl bg-[#09090b] border-white/10 [&>button]:text-slate-400 [&>button:hover]:text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold text-slate-200 flex items-center gap-2">
|
||||
<Scissors className="w-5 h-5 text-[#ef4444]" /> How Trimming Works
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Understanding how to cut out unwanted parts of your video.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-8">
|
||||
{/* Explanation */}
|
||||
<div className="bg-white/5 rounded-lg p-4 border border-white/5">
|
||||
<p className="text-slate-300 leading-relaxed">
|
||||
The Trim tool works by defining the segments you want to
|
||||
<span className="text-[#ef4444] font-bold"> remove</span>. Any part of the timeline
|
||||
that is
|
||||
<span className="text-[#ef4444] font-bold"> covered</span> by a red trim segment will
|
||||
be cut out when you export.
|
||||
</p>
|
||||
</div>
|
||||
{/* Visual Illustration */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">
|
||||
Visual Example
|
||||
</h3>
|
||||
<div className="relative h-24 bg-[#000] rounded-lg border border-white/10 flex items-center px-4 overflow-hidden select-none">
|
||||
{/* Background track (Kept parts) */}
|
||||
<div className="absolute inset-x-4 h-2 bg-slate-600 rounded-full overflow-hidden">
|
||||
{/* Solid line representing video */}
|
||||
</div>
|
||||
{/* Removed Segment 1 */}
|
||||
<div
|
||||
className="absolute left-[20%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
|
||||
style={{ width: "20%" }}
|
||||
>
|
||||
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
||||
REMOVED
|
||||
</span>
|
||||
</div>
|
||||
{/* Removed Segment 2 */}
|
||||
<div
|
||||
className="absolute left-[65%] h-8 bg-[#ef4444]/20 border border-[#ef4444] rounded flex flex-col items-center justify-center z-10"
|
||||
style={{ width: "15%" }}
|
||||
>
|
||||
<span className="text-[10px] font-bold text-[#ef4444] bg-black/50 px-1 rounded">
|
||||
REMOVED
|
||||
</span>
|
||||
</div>
|
||||
{/* Labels for kept parts */}
|
||||
<div className="absolute left-[5%] text-[10px] text-slate-400 font-medium">Kept</div>
|
||||
<div className="absolute left-[50%] text-[10px] text-slate-400 font-medium">Kept</div>
|
||||
<div className="absolute left-[90%] text-[10px] text-slate-400 font-medium">Kept</div>
|
||||
</div>
|
||||
<div className="flex justify-center mt-2">
|
||||
<ArrowRight className="w-4 h-4 text-slate-600 rotate-90" />
|
||||
</div>
|
||||
{/* Result */}
|
||||
<div className="relative h-12 bg-[#000] rounded-lg border border-white/10 flex items-center justify-center gap-1 px-4 select-none">
|
||||
<div
|
||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||
style={{ width: "30%" }}
|
||||
>
|
||||
<span className="text-[10px] text-white font-medium">Part 1</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||
style={{ width: "30%" }}
|
||||
>
|
||||
<span className="text-[10px] text-white font-medium">Part 2</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-8 bg-slate-700 rounded flex items-center justify-center opacity-80"
|
||||
style={{ width: "30%" }}
|
||||
>
|
||||
<span className="text-[10px] text-white font-medium">Part 3</span>
|
||||
</div>
|
||||
<span className="absolute right-4 text-xs text-slate-400">Final Video</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Steps */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-3 rounded bg-white/5 border border-white/5">
|
||||
<div className="text-[#ef4444] font-bold mb-1">1. Add Trim</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
Press
|
||||
<kbd className="bg-white/10 px-1 rounded text-slate-300">T</kbd>
|
||||
or click the scissors icon to mark a section for removal.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded bg-white/5 border border-white/5">
|
||||
<div className="text-[#ef4444] font-bold mb-1">2. Adjust</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
Drag the edges of the red region to cover exactly what you want to cut out.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||