87 | >(({ className, ...props }, ref) => (
88 | | [role=checkbox]]:translate-y-[2px]',
92 | className,
93 | )}
94 | {...props}
95 | />
96 | ));
97 | TableCell.displayName = 'TableCell';
98 |
99 | const TableCaption = React.forwardRef<
100 | HTMLTableCaptionElement,
101 | React.HTMLAttributes
102 | >(({ className, ...props }, ref) => (
103 |
108 | ));
109 | TableCaption.displayName = 'TableCaption';
110 |
111 | export {
112 | Table,
113 | TableHeader,
114 | TableBody,
115 | TableFooter,
116 | TableHead,
117 | TableRow,
118 | TableCell,
119 | TableCaption,
120 | };
121 |
--------------------------------------------------------------------------------
/docs/src/content/docs/managing-projects.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Managing projects
3 | ---
4 | Each project in the library has a set of properties that can be used to organize and categorize your projects.
5 |
6 | When using the search bar, by default, Legato will search for projects based on the project title. You can also use the search bar to search for projects based on other properties applying [filters](/legato/search-filters).
7 |
8 | ## Project properties
9 |
10 | ### Title
11 |
12 | The title of the project. This is the name that will be displayed.
13 |
14 | **Default:** Same as the project file name.
15 |
16 | ### BPM
17 |
18 | Project tempo in beats per minute.
19 |
20 | **Default:** Same as the project tempo.
21 |
22 | **Filter:** `bpm:` [*number*](/legato/search-filters#number)
23 |
24 | ### Scale
25 |
26 | The musical scale of the project.
27 |
28 | **Filter:** `scale:` [*text*](/legato/search-filters#text)
29 |
30 | ### Genre
31 |
32 | The genre of the project.
33 |
34 | **Filter:** `genre:` [*text*](/legato/search-filters#text)
35 |
36 | ### Tags
37 |
38 | A list of tags that can be used to categorize the project.
39 |
40 | **Filter:** `tags:` [*text*](/legato/search-filters#text)
41 |
42 | ### Progress
43 |
44 | If you are working on a project, you can use this property to track your progress.
45 |
46 | **Values:** `to-do`, `in-progress`, `recording`, `mixing`, `mastering`, `needs-revision`, `final-touches`, `finished`, `on-hold`, `abandoned`
47 |
48 | **Default:** `to-do`
49 |
50 | **Filter:** `progress:` [*text*](/legato/search-filters#text)
51 |
52 | ### Favorite
53 |
54 | If you like a project, you can mark it as a favorite.
55 |
56 | **Values:** `true`, `false`
57 |
58 | **Default:** `false`
59 |
60 | **Filter:** `favorite:` [*boolean*](/legato/search-filters#boolean)
61 |
62 |
63 | ### Hidden
64 |
65 | If you don't want a project to be displayed in the library, you can hide it.
66 |
67 | **Values:** `true`, `false`
68 |
69 | **Default:** `false`
70 |
71 | **Filter:** `hidden:` [*boolean*](/legato/search-filters#boolean)
72 |
73 | ### Notes
74 |
75 | You can add notes to a project to keep track of ideas and thoughts.
76 |
77 | ### Audio File
78 |
79 | The path to the audio file that is used in the project.
80 |
81 | **Default:** Any audio file (.wav, .mp3 or .flac) in the project folder or subfolders with the same name as the project file. If there is no audio file with the same name as the project file, will look for the most recent audio file in the project root folder.
82 |
83 | **Filter:** `audioFile:` [*boolean*](/legato/search-filters#boolean)
84 |
85 | ### Path
86 |
87 | The path to the project file.
88 |
89 | **Filter:** `path:` [*text*](/legato/search-filters#text)
90 |
91 | ### DAW
92 |
93 | The digital audio workstation used to create the project.
94 |
95 | **Default**: The DAW used to create the project.
96 |
97 | **Filter:** `daw:` [*text*](/legato/search-filters#text)
98 |
99 | ### Tracks
100 |
101 | A list of tracks in the project.
102 |
103 | #### Audio Tracks
104 |
105 | A list of audio tracks in the project.
106 |
107 | #### MIDI Tracks
108 |
109 | A list of MIDI tracks in the project.
110 |
111 | #### Return Tracks
112 |
113 | A list of return tracks in the project.
114 |
115 | #### Plugins
116 |
117 | A list of plugins used in the project.
118 |
119 | ### Last modified
120 |
121 | The date and time when the project was last modified.
122 |
123 | ### Added
124 |
125 | The date and time when the project was added to Legato.
126 |
--------------------------------------------------------------------------------
/docs/src/pages/index.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import Layout from '../layouts/Layout.astro';
3 | import { Hero } from '../components/Hero';
4 | import { Features, Feature } from '../components/Features';
5 | ---
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | Why Choose Legato?
14 |
15 |
16 | Open and manage your Ableton sets directly through Legato's
17 | intuitive interface
18 |
19 |
20 | Search, categorize, tag, and take notes on your projects with
21 | powerful filtering
22 |
23 |
24 | Play your exported audio directly through Legato's built-in player
25 |
26 |
27 | Completely free with no hidden costs or limitations
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | How It Works
36 |
37 |
38 | 1
39 | Import Your Projects
40 |
41 | Connect Legato to your Ableton projects folder and let it index
42 | your work
43 |
44 |
45 |
46 | 2
47 | Organize & Tag
48 |
49 | Add tags, notes, and categories to keep your projects organized
50 |
51 |
52 |
53 | 3
54 | Find & Open
55 |
56 | Quickly find and open your projects with powerful search and
57 | filtering
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | Frequently Asked Questions
68 |
69 |
70 |
71 | Is Legato really free?
72 |
73 | Yes! Legato is completely free to use with no hidden costs or
74 | limitations.
75 |
76 |
77 |
78 |
79 | Does it work with all versions of Ableton?
80 |
81 |
82 | Yes! Legato works with all Ableton versions, supporting
83 | Lite/Intro, Standard and Suite versions.
84 |
85 |
86 |
87 | How do I get started?
88 |
89 | Simply download Legato, point it to your Ableton projects folder,
90 | and start organizing!
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
--------------------------------------------------------------------------------
/docs/src/content/docs/scanning-projects.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Scanning projects
3 | ---
4 | import { FileTree } from '@astrojs/starlight/components';
5 | import { Steps } from '@astrojs/starlight/components';
6 |
7 | In summary, [**Fast scan**](#fast-scan) is quicker but less thorough, while [**Full scan**](#full-scan) is slower but more comprehensive. The choice between the two would depend on whether speed or accuracy is more important in your specific use case.
8 |
9 | ## Fast Scan
10 |
11 | This function scans the project directory and processes only the projects that are not already saved in the database. If a project is already saved, it simply skips it. This makes the scan faster, but it won't pick up any changes to existing projects.
12 |
13 |
14 | 1. Scans the directory for Ableton projects.
15 | 2. If a project is found in the directory but not in the database, it is processed and added to the database.
16 | 3. If a project is already in the database, it is skipped. This means that any changes to the project since the last scan will not be detected.
17 |
18 |
19 | This scan is faster because it only processes new projects. However, it may not accurately reflect the current state of all projects if existing projects have been modified.
20 |
21 |
22 | ## Full scan
23 |
24 | This function also scans the project directory, but it processes all projects, whether they are already saved in the database or not. If a project is already saved, it updates the existing database entry. If a project is not found in the scan but exists in the database, it is removed from the database. This scan is more thorough and will pick up any changes to existing projects, but it is also slower.
25 |
26 |
27 | 1. Scans the directory for Ableton projects.
28 | 2. If a project is found in the directory and in the database, it is updated in the database. This means that any changes to the project since the last scan will be detected.
29 | 3. If a project is found in the directory but not in the database, it is processed and added to the database.
30 | 4. If a project is in the database but not found in the directory, it is removed from the database. This means that if a project has been deleted since the last scan, it will be removed from the database.
31 |
32 |
33 | This scan is slower because it processes all projects, whether they are new, existing, or deleted. However, it accurately reflects the current state of all projects.
34 |
35 | ## Automatic Background Scanning
36 |
37 | Legato can automatically scan for new projects in the background on a schedule, allowing you to keep your project library up-to-date without manual intervention.
38 |
39 | Background scanning runs a [Fast Scan](#fast-scan) in the background, which means it will only process new projects without updating existing ones or removing deleted projects.
40 |
41 | The background scan schedule is configurable using a cron string in the settings. You can use a cron string generator like [crontab.guru](https://crontab.guru/) to create a cron string that suits your needs.
42 |
43 | ## Example file structure
44 |
45 | Legato only loads projects from subdirectories of the root directory. It does not load projects from subdirectories of subdirectories. For example, it will not load automatic backup projects because those are in a subdirectory of the project directory.
46 |
47 |
48 | - my-projects (root directory)
49 | - project1_Project
50 | - Ableton_Project_Info/
51 | - Backup
52 | - project1_[2024-03-01_195216].als not added to Legato
53 | - project1_[2024-03-01_195647].als not added to Legato
54 | - ...
55 | - Samples/
56 | - Icon
57 | - **project1.als** added to Legato
58 | - project2_Project
59 | - Ableton_Project_Info/
60 | - Backup
61 | - project2_[2024-03-01_195216].als not added to Legato
62 | - project2_[2024-03-01_195647].als not added to Legato
63 | - ...
64 | - Samples/
65 | - Icon
66 | - **project2.als** added to Legato
67 | - **project2 Copy.als** added to Legato
68 |
69 |
--------------------------------------------------------------------------------
/src/renderer/components/datatable/data-table-pagination.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ChevronLeftIcon,
3 | ChevronRightIcon,
4 | ChevronsLeftIcon,
5 | ChevronsRightIcon,
6 | } from 'lucide-react';
7 | import { Table } from '@tanstack/react-table';
8 |
9 | import { Button } from '@/components/ui/button';
10 | import {
11 | Select,
12 | SelectContent,
13 | SelectItem,
14 | SelectTrigger,
15 | SelectValue,
16 | } from '@/components/ui/select';
17 |
18 | import { useDispatch } from 'react-redux';
19 | import { updateSettings } from '@/store/Slices/settingsSlice';
20 |
21 | interface DataTablePaginationProps {
22 | table: Table;
23 | }
24 |
25 | // eslint-disable-next-line import/prefer-default-export
26 | export function DataTablePagination({
27 | table,
28 | }: DataTablePaginationProps) {
29 | const dispatch = useDispatch();
30 | return (
31 |
32 |
33 | {table.getFilteredSelectedRowModel().rows.length} of{' '}
34 | {table.getFilteredRowModel().rows.length} row(s) selected.
35 |
36 |
37 |
38 | Rows per page
39 |
57 |
58 |
59 | Page {table.getState().pagination.pageIndex + 1} of{' '}
60 | {table.getPageCount() || 1}
61 |
62 |
63 |
72 |
81 |
90 |
99 |
100 |
101 |
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/.erb/configs/webpack.config.renderer.prod.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Build config for electron renderer process
3 | */
4 |
5 | import path from 'path';
6 | import webpack from 'webpack';
7 | import HtmlWebpackPlugin from 'html-webpack-plugin';
8 | import MiniCssExtractPlugin from 'mini-css-extract-plugin';
9 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
10 | import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
11 | import { merge } from 'webpack-merge';
12 | import TerserPlugin from 'terser-webpack-plugin';
13 | import baseConfig from './webpack.config.base';
14 | import webpackPaths from './webpack.paths';
15 | import checkNodeEnv from '../scripts/check-node-env';
16 | import deleteSourceMaps from '../scripts/delete-source-maps';
17 |
18 | checkNodeEnv('production');
19 | deleteSourceMaps();
20 |
21 | const configuration: webpack.Configuration = {
22 | devtool: 'source-map',
23 |
24 | mode: 'production',
25 |
26 | target: ['web', 'electron-renderer'],
27 |
28 | entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')],
29 |
30 | output: {
31 | path: webpackPaths.distRendererPath,
32 | publicPath: './',
33 | filename: 'renderer.js',
34 | library: {
35 | type: 'umd',
36 | },
37 | },
38 |
39 | module: {
40 | rules: [
41 | {
42 | test: /\.s?(a|c)ss$/,
43 | use: [
44 | MiniCssExtractPlugin.loader,
45 | {
46 | loader: 'css-loader',
47 | options: {
48 | modules: true,
49 | sourceMap: true,
50 | importLoaders: 1,
51 | },
52 | },
53 | ],
54 | include: /\.module\.s?(c|a)ss$/,
55 | },
56 | {
57 | test: /\.s?(a|c)ss$/,
58 | use: [
59 | MiniCssExtractPlugin.loader,
60 | 'css-loader',
61 | {
62 | loader: 'postcss-loader',
63 | options: {
64 | postcssOptions: {
65 | plugins: [require('tailwindcss'), require('autoprefixer')],
66 | },
67 | },
68 | },
69 | ],
70 | exclude: /\.module\.s?(c|a)ss$/,
71 | },
72 | // Fonts
73 | {
74 | test: /\.(woff|woff2|eot|ttf|otf)$/i,
75 | type: 'asset/resource',
76 | },
77 | // Images
78 | {
79 | test: /\.(png|jpg|jpeg|gif)$/i,
80 | type: 'asset/resource',
81 | },
82 | // SVG
83 | {
84 | test: /\.svg$/,
85 | use: [
86 | {
87 | loader: '@svgr/webpack',
88 | options: {
89 | prettier: false,
90 | svgo: false,
91 | svgoConfig: {
92 | plugins: [{ removeViewBox: false }],
93 | },
94 | titleProp: true,
95 | ref: true,
96 | },
97 | },
98 | 'file-loader',
99 | ],
100 | },
101 | ],
102 | },
103 |
104 | optimization: {
105 | minimize: true,
106 | minimizer: [new TerserPlugin(), new CssMinimizerPlugin()],
107 | },
108 |
109 | plugins: [
110 | /**
111 | * Create global constants which can be configured at compile time.
112 | *
113 | * Useful for allowing different behaviour between development builds and
114 | * release builds
115 | *
116 | * NODE_ENV should be production so that modules do not perform certain
117 | * development checks
118 | */
119 | new webpack.EnvironmentPlugin({
120 | NODE_ENV: 'production',
121 | DEBUG_PROD: false,
122 | }),
123 |
124 | new MiniCssExtractPlugin({
125 | filename: 'style.css',
126 | }),
127 |
128 | new BundleAnalyzerPlugin({
129 | analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
130 | analyzerPort: 8889,
131 | }),
132 |
133 | new HtmlWebpackPlugin({
134 | filename: 'index.html',
135 | template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
136 | minify: {
137 | collapseWhitespace: true,
138 | removeAttributeQuotes: true,
139 | removeComments: true,
140 | },
141 | isBrowser: false,
142 | isDevelopment: false,
143 | }),
144 |
145 | new webpack.DefinePlugin({
146 | 'process.type': '"renderer"',
147 | }),
148 | ],
149 | };
150 |
151 | export default merge(baseConfig, configuration);
152 |
--------------------------------------------------------------------------------
/src/renderer/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as DialogPrimitive from '@radix-ui/react-dialog';
3 | import { XIcon } from 'lucide-react';
4 |
5 | import { cn } from '@/utils';
6 |
7 | const Dialog = DialogPrimitive.Root;
8 |
9 | const DialogTrigger = DialogPrimitive.Trigger;
10 |
11 | const DialogPortal = DialogPrimitive.Portal;
12 |
13 | const DialogClose = DialogPrimitive.Close;
14 |
15 | const DialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ));
28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
29 |
30 | const DialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, children, ...props }, ref) => (
34 |
35 |
36 |
44 | {children}
45 |
46 |
47 | Close
48 |
49 |
50 |
51 | ));
52 | DialogContent.displayName = DialogPrimitive.Content.displayName;
53 |
54 | function DialogHeader({
55 | className,
56 | ...props
57 | }: React.HTMLAttributes) {
58 | return (
59 |
66 | );
67 | }
68 | DialogHeader.displayName = 'DialogHeader';
69 |
70 | function DialogFooter({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) {
74 | return (
75 |
82 | );
83 | }
84 | DialogFooter.displayName = 'DialogFooter';
85 |
86 | const DialogTitle = React.forwardRef<
87 | React.ElementRef,
88 | React.ComponentPropsWithoutRef
89 | >(({ className, ...props }, ref) => (
90 |
98 | ));
99 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
100 |
101 | const DialogDescription = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ));
111 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
112 |
113 | export {
114 | Dialog,
115 | DialogPortal,
116 | DialogOverlay,
117 | DialogTrigger,
118 | DialogClose,
119 | DialogContent,
120 | DialogHeader,
121 | DialogFooter,
122 | DialogTitle,
123 | DialogDescription,
124 | };
125 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
23 |
24 |
25 |
26 |
27 |
28 | Table of Contents
29 |
30 | -
31 | About The Project
32 |
33 | -
34 | Getting Started
35 |
39 |
40 | - Contributing
41 | - License
42 |
43 |
44 |
45 |
46 |
47 |
48 | ## About The Project
49 | 
50 |
51 | Welcome to Legato, your ultimate manager for Ableton projects!
52 |
53 | Legato simplifies project organization and enhances your workflow, allowing you to focus more on your music creation process and less on file management.
54 |
55 | (back to top)
56 |
57 |
58 |
59 | ## Getting Started
60 |
61 | ### Installation
62 |
63 | Before you can start using Legato, you'll need to install it on your system. Follow these steps:
64 |
65 | 1. **Download Legato:** Visit the [GitHub releases page](https://github.com/pruizlezcano/legato/releases/latest) and download the appropriate version of Legato for your operating system.
66 |
67 | 2. **Install Legato:** Once the download is complete, follow the installation instructions provided in the installation wizard.
68 |
69 | 3. **Launch Legato:** After installation, launch Legato from your applications folder or desktop shortcut.
70 |
71 | > [!NOTE]
72 | > During the installation you may found some security issues. This ocurs because the installer is not signed by a certificate.
73 | > When you download a file from the internet, **macOS** automatically adds a `quarantine` attribute to it. This attribute is used to trigger certain security measures, such as warning you when you try to open the file, or preventing the file from being opened at all.
74 | > You can remove the `quarantine` attribute by running this command in the Terminal app.
75 | > ```shell
76 | > xattr -d com.apple.quarantine /Applications/Legato.app
77 | >```
78 | >In **Windows** you can ignore the alert and open Legato normaly.
79 |
80 | (back to top)
81 |
82 |
83 |
84 | ### Adding Ableton Projects
85 |
86 | Legato seamlessly integrates with your Ableton projects, providing a centralized platform for managing them. In order to connect Legato to your Ableton projects, follow these steps:
87 |
88 | 1. In Legato, open the settings page.
89 | 2. Modify your projects path.
90 | 3. Click on the scan button to start adding your Ableton projects to Legato.
91 |
92 | Learn more about the scanning modes [here](https://pruizlezcano.github.io/legato/scanning-projects).
93 |
94 |
95 |
96 | ## Contributing
97 |
98 | Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
99 |
100 | If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement".
101 | Don't forget to give the project a star! Thanks again!
102 |
103 | 1. Fork the Project
104 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
105 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
106 | 4. Push to the Branch (`git push origin feature/AmazingFeature`)
107 | 5. Open a Pull Request
108 |
109 | (back to top)
110 |
111 |
112 |
113 |
114 | ## License
115 |
116 | Distributed under the GPL-3.0 License. See `LICENSE` for more information.
117 |
118 | (back to top)
119 |
--------------------------------------------------------------------------------
/src/renderer/components/ui/audio-player.tsx:
--------------------------------------------------------------------------------
1 | import { useGlobalAudioPlayer } from 'react-use-audio-player';
2 | import { useEffect, useRef, useState } from 'react';
3 | import { Card, CardContent } from '@/components/ui/card';
4 | import {
5 | Tooltip,
6 | TooltipContent,
7 | TooltipProvider,
8 | TooltipTrigger,
9 | } from '@/components/ui/tooltip';
10 | import { PlayCircleIcon, PauseCircleIcon, XIcon } from 'lucide-react';
11 | import { useSelector, useDispatch } from 'react-redux';
12 | import {
13 | selectAppState,
14 | setShowAudioPlayer,
15 | } from '@/store/Slices/appStateSlice';
16 | import { Slider } from './slider';
17 |
18 | function useAudioTime() {
19 | const frameRef = useRef();
20 | const [pos, setPos] = useState(0);
21 | const { getPosition } = useGlobalAudioPlayer();
22 |
23 | useEffect(() => {
24 | const animate = () => {
25 | setPos(getPosition());
26 | frameRef.current = requestAnimationFrame(animate);
27 | };
28 |
29 | frameRef.current = window.requestAnimationFrame(animate);
30 |
31 | return () => {
32 | if (frameRef.current) {
33 | cancelAnimationFrame(frameRef.current);
34 | }
35 | };
36 | }, [getPosition]);
37 |
38 | return pos;
39 | }
40 |
41 | function posToString(pos: number) {
42 | const minutes = Math.floor(pos / 60);
43 | const seconds = Math.floor(pos % 60);
44 | return `${minutes}:${seconds.toString().padStart(2, '0')}`;
45 | }
46 |
47 | // eslint-disable-next-line import/prefer-default-export
48 | export function AudioPlayer() {
49 | const { playing, togglePlayPause, duration, seek, stop } =
50 | useGlobalAudioPlayer();
51 | const pos = useAudioTime();
52 | const [sliderPos, setSliderPos] = useState(pos);
53 | const [showTitle, setShowTitle] = useState(false);
54 | const dispatch = useDispatch();
55 | const appState = useSelector(selectAppState);
56 |
57 | const handleSliderChange = ([newValue]: number[]) => {
58 | seek(newValue);
59 | setSliderPos(newValue);
60 | };
61 |
62 | useEffect(() => {
63 | setSliderPos(pos);
64 | }, [pos]);
65 |
66 | return (
67 | <>
68 |
72 |
73 | setShowTitle(true)}
76 | onMouseLeave={() => setShowTitle(false)}
77 | >
78 |
83 | now playing:
84 | {appState.nowPlaying || 'No track playing'}
85 |
86 |
87 |
94 | {posToString(pos)}
95 |
102 | {posToString(duration)}
103 |
104 |
105 |
106 |
116 |
117 |
118 | Close
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 | >
128 | );
129 | }
130 |
--------------------------------------------------------------------------------
/src/renderer/components/TableSkeleton.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-unstable-nested-components */
2 | import {
3 | Table,
4 | TableBody,
5 | TableCell,
6 | TableHead,
7 | TableHeader,
8 | TableRow,
9 | } from '@/components/ui/table';
10 | import {
11 | useReactTable,
12 | getCoreRowModel,
13 | flexRender,
14 | createColumnHelper,
15 | } from '@tanstack/react-table';
16 | import { Skeleton } from '@/components/ui/skeleton';
17 |
18 | function SkeletonCell({ width }: { width: string }) {
19 | return (
20 |
21 |
22 |
23 | );
24 | }
25 |
26 | function PaginationSkeleton() {
27 | return (
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | );
37 | }
38 |
39 | export default function TableSkeleton() {
40 | const columnHelper = createColumnHelper<{}>();
41 |
42 | const data = Array(10).fill({});
43 | const columns = [
44 | columnHelper.display({
45 | id: '1',
46 | header: () => ,
47 | cell: () => ,
48 | }),
49 | columnHelper.display({
50 | id: '2',
51 | header: () => ,
52 | cell: () => ,
53 | }),
54 | columnHelper.display({
55 | id: '3',
56 | header: () => ,
57 | cell: () => ,
58 | }),
59 | columnHelper.display({
60 | id: '4',
61 | header: () => ,
62 | cell: () => ,
63 | }),
64 | columnHelper.display({
65 | id: '5',
66 | header: () => ,
67 | cell: () => ,
68 | }),
69 | columnHelper.display({
70 | id: '6',
71 | header: () => ,
72 | cell: () => ,
73 | }),
74 | ];
75 | const table = useReactTable({
76 | columns,
77 | data,
78 | getCoreRowModel: getCoreRowModel(),
79 | // Need to provide filterFns to avoid error
80 | filterFns: {
81 | textFilter: () => true,
82 | numberFilter: () => true,
83 | arrayFilter: () => true,
84 | booleanFilter: () => true,
85 | notNullFilter: () => true,
86 | },
87 | });
88 | return (
89 | <>
90 |
91 |
92 |
93 | {table.getHeaderGroups().map((headerGroup) => (
94 |
95 | {headerGroup.headers.map((header) => (
96 |
100 | {header.isPlaceholder ? null : (
101 |
102 | {flexRender(
103 | header.column.columnDef.header,
104 | header.getContext(),
105 | )}
106 |
107 | )}
108 |
109 | ))}
110 |
111 | ))}
112 |
113 |
114 | {table.getRowModel().rows?.length ? (
115 | table.getRowModel().rows.map((row) => (
116 |
117 | {row.getVisibleCells().map((cell) => (
118 |
119 | {flexRender(
120 | cell.column.columnDef.cell,
121 | cell.getContext(),
122 | )}
123 |
124 | ))}
125 |
126 | ))
127 | ) : (
128 |
129 |
133 | No results.
134 |
135 |
136 | )}
137 |
138 |
139 |
140 |
143 | >
144 | );
145 | }
146 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 |
2 | ## [0.4.0](https://github.com/pruizlezcano/legato/compare/v0.3.0...v0.4.0) (2025-04-23)
3 |
4 | Legato now seamlessly integrates with your workflow through background operation capabilities. You can minimize the app to your system tray while scheduled scans run automatically based on customizable cron expressions, and optionally have Legato start automatically at login.
5 |
6 | The interface has been refreshed with a reorganized settings panel, updated icons, and smoother visuals throughout the application.
7 |
8 | ### Features
9 |
10 | * Added auto-start functionality for application launch at login ([1b6c5f9](https://github.com/pruizlezcano/legato/commit/1b6c5f9756295c4ed2a56a514a7a92e472c54097))
11 | * Added background scan scheduling and UI integration ([0c1564e](https://github.com/pruizlezcano/legato/commit/0c1564e683479022ac84681473073a4607d8b18f))
12 | * Added minimize to tray functionality ([9109aac](https://github.com/pruizlezcano/legato/commit/9109aac0c30c2caa363aaac61b6a6fce13ade1b4))
13 | * Added scan cancellation and quit confirmation logic ([f04249d](https://github.com/pruizlezcano/legato/commit/f04249da6c6748dda69e47e6e5c0175f56279858))
14 | * Added start minimized to tray functionality ([d375b45](https://github.com/pruizlezcano/legato/commit/d375b45f85dfe8ab4563d25a41bb473db3e3f55f))
15 | * **projectScanner:** Notify main window on scan completion or error ([85b925a](https://github.com/pruizlezcano/legato/commit/85b925aea5cf83df8acc9c04ed255e6eb586ad01))
16 | * **settings:** Improved settings UI layout and organization for better user experience ([adfbcc9](https://github.com/pruizlezcano/legato/commit/adfbcc911b782b891aa07b9676ce168ad53b59f3))
17 | * **tray:** Added notification for ongoing project scans when restoring window ([2627351](https://github.com/pruizlezcano/legato/commit/2627351ec7458389e9f83e14c49abc71c654f6b3))
18 |
19 |
20 | ### Bug Fixes
21 |
22 | * Correct typo in TO_DO progress color class ([6457940](https://github.com/pruizlezcano/legato/commit/64579407aff679ce17dba832986464c079d181a1))
23 | * Prevent UI flashing during initial load ([29a10fb](https://github.com/pruizlezcano/legato/commit/29a10fb85bfb36f5f10e25f19623fab155fed142))
24 | * **settings:** `autoStart` not being initialized ([3e339af](https://github.com/pruizlezcano/legato/commit/3e339aff1ab23da679b648c3a6eefe57abf56702))
25 |
26 | ## [0.3.0](https://github.com/pruizlezcano/legato/compare/v0.2.0...v0.3.0) (2024-12-17)
27 |
28 |
29 | ### Features
30 |
31 | * Added column visibility toggle ([1a0e74f](https://github.com/pruizlezcano/legato/commit/1a0e74fce6d418bb33c817d800823625bd5b66dc))
32 | * Added table state persistence for sorting, page size, and displayed columns ([5794023](https://github.com/pruizlezcano/legato/commit/5794023f3af124d912467c2aad21c2ddb3d99b68))
33 | * **project:** Added new project progress states and color coding ([a7b1906](https://github.com/pruizlezcano/legato/commit/a7b19066a460bf950c84ed0d7076a57a7a907976))
34 | * Return to first page when changing filter ([03cd354](https://github.com/pruizlezcano/legato/commit/03cd3547bdc225fb4397d2b6596146fb6350b8ea))
35 | * **ui:** Added skeleton during table load ([7886d37](https://github.com/pruizlezcano/legato/commit/7886d373e83555639ff55922ed6a327040bdbfe2))
36 |
37 |
38 | ### Bug Fixes
39 |
40 | * **tsc:** Resolve Typescript errors ([d80a8bb](https://github.com/pruizlezcano/legato/commit/d80a8bb8f728cdd416cadf89202563f59916f6b3))
41 | * **ui:** Adjust margin for header badges ([c3c6ead](https://github.com/pruizlezcano/legato/commit/c3c6ead9d108f32895606b9a67c371630ad3c298))
42 | * **ui:** Update project saving logic to use accessorKey for column updates ([8d3d6fb](https://github.com/pruizlezcano/legato/commit/8d3d6fb927758665a292282651285dfa1e5a39e6))
43 |
44 | ## [0.2.0](https://github.com/pruizlezcano/legato/compare/v0.1.0...v0.2.0) (2024-03-19)
45 |
46 |
47 | ### Features
48 |
49 | * Add DAW field to project details ([#44](https://github.com/pruizlezcano/legato/issues/44)) ([fa3ffe8](https://github.com/pruizlezcano/legato/commit/fa3ffe8f6d81c4761c12a78cb56310f4ed397ea8))
50 | * Add functionality to automatically detect and set audio file in project processing. ([#46](https://github.com/pruizlezcano/legato/issues/46)) ([56e970c](https://github.com/pruizlezcano/legato/commit/56e970c280b36e8c4099336fc935f3c6f675a150))
51 | * Add warning toast for IPC warning event ([ff31dc6](https://github.com/pruizlezcano/legato/commit/ff31dc693db85fe663b5744053ccc031282a57c5))
52 | * **ProjectsTable:** Added better text filtering functionality ([#45](https://github.com/pruizlezcano/legato/issues/45)) ([b7f0faa](https://github.com/pruizlezcano/legato/commit/b7f0faa818ff86238762076f28cb2a7bc1b589cc))
53 |
54 |
55 | ### Bug Fixes
56 |
57 | * Ensure saving project when values have change ([ea7668e](https://github.com/pruizlezcano/legato/commit/ea7668e3f3c7c5c38936a3fab3b4bf915e81bfdc))
58 | * Prevent the title input to be selected when opening Project Details ([9d9f86e](https://github.com/pruizlezcano/legato/commit/9d9f86e9302e913c9c464f300b92331e6b3fdeb5))
59 |
--------------------------------------------------------------------------------
/src/renderer/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as SheetPrimitive from '@radix-ui/react-dialog';
3 | import { XIcon } from 'lucide-react';
4 | import { cva, type VariantProps } from 'class-variance-authority';
5 |
6 | import { cn } from '@/utils';
7 |
8 | const Sheet = SheetPrimitive.Root;
9 |
10 | const SheetTrigger = SheetPrimitive.Trigger;
11 |
12 | const SheetClose = SheetPrimitive.Close;
13 |
14 | const SheetPortal = SheetPrimitive.Portal;
15 |
16 | const SheetOverlay = React.forwardRef<
17 | React.ElementRef,
18 | React.ComponentPropsWithoutRef
19 | >(({ className, ...props }, ref) => (
20 |
28 | ));
29 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
30 |
31 | const sheetVariants = cva(
32 | 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
33 | {
34 | variants: {
35 | side: {
36 | top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
37 | bottom:
38 | 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
39 | left: 'overflow-y-auto inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
40 | right:
41 | 'overflow-y-auto inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
42 | },
43 | },
44 | defaultVariants: {
45 | side: 'right',
46 | },
47 | },
48 | );
49 |
50 | interface SheetContentProps
51 | extends React.ComponentPropsWithoutRef,
52 | VariantProps {}
53 |
54 | const SheetContent = React.forwardRef<
55 | React.ElementRef,
56 | SheetContentProps
57 | >(({ side = 'right', className, children, ...props }, ref) => (
58 |
59 |
60 |
65 | {children}
66 |
67 |
68 | Close
69 |
70 |
71 |
72 | ));
73 | SheetContent.displayName = SheetPrimitive.Content.displayName;
74 |
75 | function SheetHeader({
76 | className,
77 | ...props
78 | }: React.HTMLAttributes) {
79 | return (
80 |
87 | );
88 | }
89 | SheetHeader.displayName = 'SheetHeader';
90 |
91 | function SheetFooter({
92 | className,
93 | ...props
94 | }: React.HTMLAttributes) {
95 | return (
96 |
103 | );
104 | }
105 | SheetFooter.displayName = 'SheetFooter';
106 |
107 | const SheetTitle = React.forwardRef<
108 | React.ElementRef,
109 | React.ComponentPropsWithoutRef
110 | >(({ className, ...props }, ref) => (
111 |
116 | ));
117 | SheetTitle.displayName = SheetPrimitive.Title.displayName;
118 |
119 | const SheetDescription = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, ...props }, ref) => (
123 |
128 | ));
129 | SheetDescription.displayName = SheetPrimitive.Description.displayName;
130 |
131 | export {
132 | Sheet,
133 | SheetPortal,
134 | SheetOverlay,
135 | SheetTrigger,
136 | SheetClose,
137 | SheetContent,
138 | SheetHeader,
139 | SheetFooter,
140 | SheetTitle,
141 | SheetDescription,
142 | };
143 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
6 |
7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
8 |
9 | ## Our Standards
10 |
11 | Examples of behavior that contributes to a positive environment for our community include:
12 |
13 | * Demonstrating empathy and kindness toward other people
14 | * Being respectful of differing opinions, viewpoints, and experiences
15 | * Giving and gracefully accepting constructive feedback
16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
17 | * Focusing on what is best not just for us as individuals, but for the overall community
18 |
19 |
20 | Examples of unacceptable behavior include:
21 |
22 | * The use of sexualized language or imagery, and sexual attention or advances of any kind
23 | * Trolling, insulting or derogatory comments, and personal or political attacks
24 | * Public or private harassment
25 | * Publishing others' private information, such as a physical or email address, without their explicit permission
26 | * Other conduct which could reasonably be considered inappropriate in a professional setting
27 |
28 |
29 | ## Enforcement Responsibilities
30 |
31 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
32 |
33 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
34 |
35 | ## Scope
36 |
37 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
38 |
39 | ## Enforcement
40 |
41 |
42 |
43 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement. All complaints will be reviewed and investigated promptly and fairly.
44 |
45 | All community leaders are obligated to respect the privacy and security of the reporter of any incident.
46 |
47 | ## Enforcement Guidelines
48 |
49 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
50 |
51 | ### 1. Correction
52 |
53 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
54 |
55 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
56 |
57 | ### 2. Warning
58 |
59 | **Community Impact**: A violation through a single incident or series of actions.
60 |
61 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
62 |
63 | ### 3. Temporary Ban
64 |
65 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
66 |
67 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
68 |
69 | ### 4. Permanent Ban
70 |
71 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
72 |
73 | **Consequence**: A permanent ban from any sort of public interaction within the community.
74 |
75 | ## Attribution
76 |
77 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.0,
78 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
79 |
80 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
81 |
82 | For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
83 |
--------------------------------------------------------------------------------
/src/main/lib/abletonParser.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-restricted-syntax */
2 | import pako from 'pako';
3 | import { XMLParser } from 'fast-xml-parser';
4 | import fs from 'fs';
5 | import { Track } from '../../types/Track';
6 |
7 | // eslint-disable-next-line import/prefer-default-export
8 | export class AbletonParser {
9 | private filePath: string;
10 |
11 | constructor(filePath: string) {
12 | this.filePath = filePath;
13 | }
14 |
15 | public parse(this: AbletonParser): {
16 | daw: string;
17 | bpm: number;
18 | audioTracks: Track[];
19 | midiTracks: Track[];
20 | returnTracks: Track[];
21 | } {
22 | const zippedContent = fs.readFileSync(this.filePath);
23 | const content = pako.ungzip(zippedContent, { to: 'string' });
24 |
25 | const parser = new XMLParser({
26 | ignoreAttributes: false,
27 | attributeNamePrefix: '',
28 | });
29 | const jObj = parser.parse(content);
30 | const version = jObj?.Ableton?.Creator;
31 | let bpm;
32 | if (version.includes('Ableton Live 12')) {
33 | bpm = parseFloat(
34 | jObj?.Ableton?.LiveSet?.MainTrack?.DeviceChain?.Mixer?.Tempo?.Manual
35 | ?.Value,
36 | );
37 | } else {
38 | bpm = parseFloat(
39 | jObj?.Ableton?.LiveSet?.MasterTrack?.DeviceChain?.Mixer?.Tempo?.Manual
40 | ?.Value,
41 | );
42 | }
43 |
44 | const audioTracks: Track[] = Array.isArray(
45 | jObj?.Ableton?.LiveSet?.Tracks?.AudioTrack,
46 | )
47 | ? jObj?.Ableton?.LiveSet?.Tracks?.AudioTrack.map((track: any) => {
48 | return { type: 'audio', ...this.parseTrack(track) };
49 | })
50 | : jObj?.Ableton?.LiveSet?.Tracks?.AudioTrack
51 | ? [
52 | {
53 | type: 'audio',
54 | ...this.parseTrack(jObj?.Ableton?.LiveSet?.Tracks?.AudioTrack),
55 | },
56 | ]
57 | : [];
58 |
59 | const midiTracks: Track[] = Array.isArray(
60 | jObj?.Ableton?.LiveSet?.Tracks?.MidiTrack,
61 | )
62 | ? jObj?.Ableton?.LiveSet?.Tracks?.MidiTrack.map((track: any) => {
63 | return { type: 'midi', ...this.parseTrack(track) };
64 | })
65 | : jObj?.Ableton?.LiveSet?.Tracks?.MidiTrack
66 | ? [
67 | {
68 | type: 'midi',
69 | ...this.parseTrack(jObj?.Ableton?.LiveSet?.Tracks?.MidiTrack),
70 | },
71 | ]
72 | : [];
73 |
74 | const returnTracks: Track[] = Array.isArray(
75 | jObj?.Ableton?.LiveSet?.Tracks?.ReturnTrack,
76 | )
77 | ? jObj?.Ableton?.LiveSet?.Tracks?.ReturnTrack.map((track: any) => {
78 | return { type: 'return', ...this.parseTrack(track) };
79 | })
80 | : jObj?.Ableton?.LiveSet?.Tracks?.ReturnTrack
81 | ? [
82 | {
83 | type: 'return',
84 | ...this.parseTrack(jObj?.Ableton?.LiveSet?.Tracks?.ReturnTrack),
85 | },
86 | ]
87 | : [];
88 |
89 | return { daw: version, bpm, audioTracks, midiTracks, returnTracks };
90 | }
91 |
92 | private parseTrack(track: any): { name: string; pluginNames: string[] } {
93 | const name = track.Name.EffectiveName.Value;
94 | const deviceChain = track.DeviceChain.DeviceChain.Devices;
95 | const pluginNames = this.parseDeviceChain(deviceChain);
96 |
97 | return {
98 | name,
99 | pluginNames,
100 | };
101 | }
102 |
103 | private parseDeviceChain(deviceChain: any): string[] {
104 | let pluginNames: string[] = [];
105 | for (let devices of Object.values(deviceChain)) {
106 | if (!Array.isArray(devices)) {
107 | devices = [devices];
108 | }
109 | for (const device of devices as any[]) {
110 | // Is a group
111 | if (device.Branches) {
112 | pluginNames = pluginNames.concat(
113 | this.parseGroupChain(device.Branches),
114 | );
115 | }
116 | if (device.PluginDesc) {
117 | const pluginDesc = device.PluginDesc;
118 | if (pluginDesc.AuPluginInfo) {
119 | pluginNames = pluginNames.concat(
120 | pluginDesc.AuPluginInfo.Name.Value,
121 | );
122 | } else if (pluginDesc.VstPluginInfo) {
123 | pluginNames = pluginNames.concat(
124 | pluginDesc.VstPluginInfo.PlugName.Value,
125 | );
126 | } else if (pluginDesc.Vst3PluginInfo) {
127 | pluginNames = pluginNames.concat(
128 | pluginDesc.Vst3PluginInfo.Name.Value,
129 | );
130 | }
131 | }
132 | }
133 | }
134 | return [...new Set(pluginNames)];
135 | }
136 |
137 | private parseGroupChain(groupChain: any): string[] {
138 | let pluginNames: string[] = [];
139 | for (let group of Object.values(groupChain)) {
140 | if (!Array.isArray(group)) {
141 | group = [group];
142 | }
143 | for (const device of group as any[]) {
144 | const deviceChain = device.DeviceChain;
145 | if (deviceChain.AudioToAudioDeviceChain) {
146 | pluginNames = pluginNames.concat(
147 | this.parseDeviceChain(deviceChain.AudioToAudioDeviceChain.Devices),
148 | );
149 | }
150 | if (deviceChain.MidiToAudioDeviceChain) {
151 | pluginNames = pluginNames.concat(
152 | this.parseDeviceChain(deviceChain.MidiToAudioDeviceChain.Devices),
153 | );
154 | }
155 | }
156 | }
157 | return pluginNames;
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/src/renderer/components/ui/command.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { type DialogProps } from '@radix-ui/react-dialog';
3 | import { SearchIcon } from 'lucide-react';
4 | import { Command as CommandPrimitive } from 'cmdk';
5 |
6 | import { cn } from '@/utils';
7 | import { Dialog, DialogContent } from '@/components/ui/dialog';
8 |
9 | const Command = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 | ));
22 | Command.displayName = CommandPrimitive.displayName;
23 |
24 | interface CommandDialogProps extends DialogProps {}
25 |
26 | function CommandDialog({ children, ...props }: CommandDialogProps) {
27 | return (
28 |
35 | );
36 | }
37 |
38 | const CommandInput = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
43 |
44 |
52 |
53 | ));
54 |
55 | CommandInput.displayName = CommandPrimitive.Input.displayName;
56 |
57 | const CommandList = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, ...props }, ref) => (
61 |
66 | ));
67 |
68 | CommandList.displayName = CommandPrimitive.List.displayName;
69 |
70 | const CommandEmpty = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >((props, ref) => (
74 |
79 | ));
80 |
81 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
82 |
83 | const CommandGroup = React.forwardRef<
84 | React.ElementRef,
85 | React.ComponentPropsWithoutRef
86 | >(({ className, ...props }, ref) => (
87 |
95 | ));
96 |
97 | CommandGroup.displayName = CommandPrimitive.Group.displayName;
98 |
99 | const CommandSeparator = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ));
109 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
110 |
111 | const CommandItem = React.forwardRef<
112 | React.ElementRef,
113 | React.ComponentPropsWithoutRef
114 | >(({ className, ...props }, ref) => (
115 |
123 | ));
124 |
125 | CommandItem.displayName = CommandPrimitive.Item.displayName;
126 |
127 | function CommandShortcut({
128 | className,
129 | ...props
130 | }: React.HTMLAttributes) {
131 | return (
132 |
139 | );
140 | }
141 | CommandShortcut.displayName = 'CommandShortcut';
142 |
143 | export {
144 | Command,
145 | CommandDialog,
146 | CommandInput,
147 | CommandList,
148 | CommandEmpty,
149 | CommandGroup,
150 | CommandItem,
151 | CommandShortcut,
152 | CommandSeparator,
153 | };
154 |
--------------------------------------------------------------------------------
/src/renderer/components/TagInput.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/control-has-associated-label */
2 | import React, { useCallback, useEffect, useRef, useState } from 'react';
3 | import { Input } from '@/components/ui/input';
4 | import { Badge } from '@/components/ui/badge';
5 | import { PlusIcon, XIcon } from 'lucide-react';
6 | import { cn } from '@/utils';
7 | import {
8 | Tooltip,
9 | TooltipContent,
10 | TooltipProvider,
11 | TooltipTrigger,
12 | } from './ui/tooltip';
13 |
14 | // eslint-disable-next-line react/function-component-definition
15 | const TagInput = ({
16 | value,
17 | onChange,
18 | className = '',
19 | placeholder = '',
20 | maxTags,
21 | variant = 'default',
22 | }: {
23 | value: string[];
24 | onChange: (e: any) => void;
25 | className?: string;
26 | placeholder?: string;
27 | maxTags?: number;
28 | variant?: 'default' | 'outline' | 'secondary' | 'destructive';
29 | }) => {
30 | const [tags, setTags] = useState(value);
31 | const [inputVisible, setInputVisible] = useState(false);
32 | const [inputValue, setInputValue] = useState('');
33 | const [editInputIndex, setEditInputIndex] = useState(-1);
34 | const [editInputValue, setEditInputValue] = useState('');
35 | const inputRef = useRef(null);
36 | const editInputRef = useRef(null);
37 |
38 | useEffect(() => {
39 | if (inputVisible) {
40 | inputRef.current?.focus();
41 | }
42 | }, [inputVisible]);
43 |
44 | useEffect(() => {
45 | editInputRef.current?.focus();
46 | }, [editInputValue]);
47 |
48 | const saveTags = useCallback(
49 | (newTags: string[]) => {
50 | setTags(newTags);
51 | onChange(newTags);
52 | },
53 | [setTags, onChange],
54 | );
55 |
56 | const handleClose = (removedTag: string) => {
57 | const newTags = tags.filter((tag) => tag !== removedTag);
58 | saveTags(newTags);
59 | };
60 |
61 | const showInput = () => {
62 | setInputVisible(true);
63 | };
64 |
65 | const handleInputChange = (e: React.ChangeEvent) => {
66 | setInputValue(e.target.value);
67 | };
68 |
69 | const handleInputConfirm = useCallback(() => {
70 | if (inputValue && !tags.includes(inputValue)) {
71 | saveTags([...tags, inputValue]);
72 | }
73 | setInputVisible(false);
74 | setInputValue('');
75 | }, [inputValue, tags, saveTags]);
76 |
77 | const handleEditInputChange = (e: React.ChangeEvent) => {
78 | setEditInputValue(e.target.value);
79 | };
80 |
81 | const handleEditInputConfirm = useCallback(() => {
82 | const newTags = [...tags];
83 | newTags[editInputIndex] = editInputValue;
84 |
85 | saveTags(newTags);
86 | setEditInputIndex(-1);
87 | setEditInputValue('');
88 | }, [editInputIndex, editInputValue, tags, saveTags]);
89 |
90 | useEffect(() => {
91 | const down = (e: KeyboardEvent) => {
92 | if (e.key === 'Enter') {
93 | e.preventDefault();
94 | e.stopPropagation();
95 | if (inputValue) handleInputConfirm();
96 | else if (editInputValue) handleEditInputConfirm();
97 | }
98 | };
99 |
100 | document.addEventListener('keydown', down);
101 | return () => document.removeEventListener('keydown', down);
102 | }, [handleInputConfirm, handleEditInputConfirm, inputValue, editInputValue]);
103 |
104 | return (
105 |
106 | {tags.map((tag, index) => {
107 | if (editInputIndex === index) {
108 | return (
109 |
117 | );
118 | }
119 | const isLongTag = tag.length > 20;
120 | const tagElem = (
121 |
126 | {
128 | setEditInputIndex(index);
129 | setEditInputValue(tag);
130 | e.preventDefault();
131 | }}
132 | className="truncate max-w-[102px]"
133 | >
134 | {tag}
135 |
136 |
139 |
140 | );
141 | return isLongTag ? (
142 |
143 |
144 |
145 | {tagElem}
146 |
147 |
148 | {tag}
149 |
150 |
151 |
152 | ) : (
153 | tagElem
154 | );
155 | })}
156 | {inputVisible ? (
157 |
165 | ) : maxTags && tags.length >= maxTags ? null : (
166 |
171 |
172 | {placeholder || 'Add Tag'}
173 |
174 | )}
175 |
176 | );
177 | };
178 |
179 | export default TagInput;
180 |
--------------------------------------------------------------------------------
/src/renderer/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import {
3 | ChevronsUpDownIcon,
4 | ChevronUpIcon,
5 | ChevronDownIcon,
6 | CheckIcon,
7 | } from 'lucide-react';
8 | import * as SelectPrimitive from '@radix-ui/react-select';
9 |
10 | import { cn } from '@/utils';
11 |
12 | const Select = SelectPrimitive.Root;
13 |
14 | const SelectGroup = SelectPrimitive.Group;
15 |
16 | const SelectValue = SelectPrimitive.Value;
17 |
18 | const SelectTrigger = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, children, ...props }, ref) => (
22 | span]:line-clamp-1',
26 | className,
27 | )}
28 | {...props}
29 | >
30 | {children}
31 |
32 |
33 |
34 |
35 | ));
36 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
37 |
38 | const SelectScrollUpButton = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
50 |
51 |
52 | ));
53 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
54 |
55 | const SelectScrollDownButton = React.forwardRef<
56 | React.ElementRef,
57 | React.ComponentPropsWithoutRef
58 | >(({ className, ...props }, ref) => (
59 |
67 |
68 |
69 | ));
70 | SelectScrollDownButton.displayName =
71 | SelectPrimitive.ScrollDownButton.displayName;
72 |
73 | const SelectContent = React.forwardRef<
74 | React.ElementRef,
75 | React.ComponentPropsWithoutRef
76 | >(({ className, children, position = 'popper', ...props }, ref) => (
77 |
78 |
89 |
90 |
97 | {children}
98 |
99 |
100 |
101 |
102 | ));
103 | SelectContent.displayName = SelectPrimitive.Content.displayName;
104 |
105 | const SelectLabel = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, ...props }, ref) => (
109 |
114 | ));
115 | SelectLabel.displayName = SelectPrimitive.Label.displayName;
116 |
117 | const SelectItem = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, children, ...props }, ref) => (
121 |
129 |
130 |
131 |
132 |
133 |
134 | {children}
135 |
136 | ));
137 | SelectItem.displayName = SelectPrimitive.Item.displayName;
138 |
139 | const SelectSeparator = React.forwardRef<
140 | React.ElementRef,
141 | React.ComponentPropsWithoutRef
142 | >(({ className, ...props }, ref) => (
143 |
148 | ));
149 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
150 |
151 | export {
152 | Select,
153 | SelectGroup,
154 | SelectValue,
155 | SelectTrigger,
156 | SelectContent,
157 | SelectLabel,
158 | SelectItem,
159 | SelectSeparator,
160 | SelectScrollUpButton,
161 | SelectScrollDownButton,
162 | };
163 |
--------------------------------------------------------------------------------
|