Add boilerplate files and licenses
This commit is contained in:
127
src/routes/faq/Faq.tsx
Normal file
127
src/routes/faq/Faq.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import IconArrowLeft from '~/components/icons/arrow-left.svg?react';
|
||||
import Button from '~/components/ui/Button';
|
||||
|
||||
function Faq() {
|
||||
return (
|
||||
<main className="my-4 max-w-250 bg-white border border-black p-4 mx-auto flex flex-col gap-6 shadow-my">
|
||||
<Button size="xs" className="mr-auto" to="/">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<IconArrowLeft className="w-4 h-4" />
|
||||
Back
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<h1 className="text-2xl font-bold mb-4">FAQ</h1>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
Why the resulting project doesn’t sound the same as on the device?
|
||||
</h2>
|
||||
<p className="text-sm font-mono">
|
||||
Several reasons. The main one is that the EP devices uses a custom FX processor that is
|
||||
hard to recreate in a DAW one to one. Also, fader automation is not exported yet, so if
|
||||
you used fader automation, the result will differ. Sample stretching algorithms are
|
||||
different as well.
|
||||
<br />
|
||||
<br />
|
||||
Initially this project was intended to export notes and samples so you could continue
|
||||
working in a DAW. I personally use the EP133 mainly for sketching ideas, not for finishing
|
||||
tracks. Later, many people asked to export with FX applied, so I added FX export as well.
|
||||
Still, the sound will not be exactly the same.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-2">Please add support for [my DAW here]</h2>
|
||||
<p className="text-sm font-mono">
|
||||
I’d love to. Reverse engineering DAW project formats takes a tremendous amount of time and
|
||||
effort. It’s easier for DAWs that uses text, XML or JSON formats (like REAPER or Ableton).
|
||||
For proprietary binary formats, it’s much more challenging. But if you have a specific DAW
|
||||
in mind, let me know.
|
||||
<br />
|
||||
<br />
|
||||
If your DAW is not supported natively, you can always export to MIDI. Every DAW supports
|
||||
MIDI today. Also check DAWproject - lots of DAWs supports it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
Can I export audio for individual tracks with this tool?
|
||||
</h2>
|
||||
<p className="text-sm font-mono">
|
||||
No. If you're thinking of something like Overbridge - it is not possible, the device
|
||||
cannot stream multichannel audio directly to your DAW. You need to record tracks manually,
|
||||
one by one, muting the others.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
I found a bug or the page crashed. What should I do?
|
||||
</h2>
|
||||
<p className="text-sm font-mono">
|
||||
PLEASE submit a report. There are several ways to do it:
|
||||
</p>
|
||||
|
||||
<ul className="list-disc list-inside my-2 text-sm font-mono">
|
||||
<li>
|
||||
Open an issue on{' '}
|
||||
<a
|
||||
href="https://github.com/phones24/ep133-export-to-daw"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
Email me at{' '}
|
||||
<a href="mailto:ep133todaw@proton.me" className="text-blue-600 underline">
|
||||
ep133todaw@proton.me
|
||||
</a>
|
||||
</li>
|
||||
<li>Use the "Feedback" button on the main page</li>
|
||||
</ul>
|
||||
<p className="text-sm font-mono">
|
||||
I can only fix issues if you report them, so please do. I don’t have a QA team to test the
|
||||
app thoroughly, so your help is very much appreciated.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
Can I use this tool without connecting my device?
|
||||
</h2>
|
||||
<p className="text-sm font-mono">
|
||||
Yes! You can drop a <code>.pak</code> backup file directly onto the page. The backup file
|
||||
contains all your projects and samples, so you can browse and export them without
|
||||
connecting your device. To create a backup file, use the official EP Sample Tool from
|
||||
Teenage Engineering.
|
||||
<br />
|
||||
<br />
|
||||
Note: <code>.tar</code> and <code>.ppak</code> project files still require a device
|
||||
connection because they don't include the sample data.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
Is this officially approved by Teenage Engineering?
|
||||
</h2>
|
||||
<p className="text-sm font-mono">
|
||||
No. This is an open source project made by a fan, not affiliated with Teenage Engineering
|
||||
in any way. Initially it used source code from the official EP SAMPLE TOOL that had been
|
||||
reverse engineered with help from AI, but the SysEx library has since been rewritten.
|
||||
<br />
|
||||
<br />
|
||||
TE contacted me to ask for a disclaimer in a few places. That’s it. They’ve been chill
|
||||
about this project so far.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Faq;
|
||||
15
src/routes/home/AppStateDisplay.tsx
Normal file
15
src/routes/home/AppStateDisplay.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
interface AppStateDisplayProps {
|
||||
title: string;
|
||||
message: string | React.ReactNode;
|
||||
}
|
||||
|
||||
function AppStateDisplay({ title, message }: AppStateDisplayProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-xl font-semibold">{title}</p>
|
||||
<p className="text-sm text-center">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppStateDisplay;
|
||||
98
src/routes/home/Donate.tsx
Normal file
98
src/routes/home/Donate.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'preact/hooks';
|
||||
import Button from '../../components/ui/Button';
|
||||
import Dialog from '../../components/ui/Dialog';
|
||||
|
||||
type Wallet = {
|
||||
currency: string;
|
||||
address: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const WALLETS: Wallet[] = [
|
||||
{
|
||||
currency: 'BTC',
|
||||
address: 'bc1qclpnhdlvk607llzuux609nezay8fk3vyhphgsr',
|
||||
label: 'BTC',
|
||||
},
|
||||
{
|
||||
currency: 'ETH',
|
||||
address: '0x3f8072285e1C3a2F651bF98A5A839f6cFf8C4047',
|
||||
label: 'ETH',
|
||||
},
|
||||
{
|
||||
currency: 'USDT TRC20',
|
||||
address: 'TDqf9eJPGWPZafP3ASS37r2cWvkKsp1aL8',
|
||||
label: 'USDT',
|
||||
},
|
||||
];
|
||||
|
||||
function Donate({ className = '' }: { className?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [copiedAddress, setCopiedAddress] = useState<string | null>(null);
|
||||
|
||||
const copyToClipboard = async (address: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
setCopiedAddress(label);
|
||||
setTimeout(() => setCopiedAddress(null), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy address:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(className, 'italic animate-pulse-slow')}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Donate!
|
||||
</button>
|
||||
<Dialog isOpen={open} onClose={() => setOpen(false)}>
|
||||
<div className="flex flex-col gap-2 min-w-150">
|
||||
<h1 className="text-xl font-semibold">Donate</h1>
|
||||
<p>If you like this tool, please consider donating to support the development.</p>
|
||||
<p>Thank you!</p>
|
||||
|
||||
<div className="mt-10">
|
||||
<h2 className="text-xl leading-12">Crypto</h2>
|
||||
<ul className="space-y-3">
|
||||
{WALLETS.map((wallet) => (
|
||||
<li key={wallet.label} className="flex items-center gap-2">
|
||||
<span className="font-medium">{wallet.currency}:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(wallet.address, wallet.label)}
|
||||
className="bg-gray-100 hover:bg-gray-200 px-3 py-1 rounded text-sm font-mono transition-colors cursor-pointer select-all"
|
||||
title="Copy address"
|
||||
>
|
||||
{wallet.address}
|
||||
</button>
|
||||
{copiedAddress === wallet.label && (
|
||||
<span className="text-green-600 text-sm font-medium">✓ Copied!</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl leading-12">Ko-fi</h2>
|
||||
<a href="https://ko-fi.com/M4M11KIZGJ" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="ko-fi" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 justify-end mt-20">
|
||||
<Button onClick={() => setOpen(false)} variant="secondary">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Donate;
|
||||
80
src/routes/home/EPToolkitDialog.tsx
Normal file
80
src/routes/home/EPToolkitDialog.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import Button from '~/components/ui/Button';
|
||||
import Dialog from '../../components/ui/Dialog';
|
||||
|
||||
function EPToolkitDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!localStorage.getItem('eptoolkit-dialog-shown')) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
localStorage.setItem('eptoolkit-dialog-shown', 'true');
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog isOpen={open} onClose={handleClose} containerClassName="bg-creme! p-6! bg-grid">
|
||||
<div className="flex gap-6 min-w-200 ">
|
||||
<div className="flex flex-col gap-3 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="/eptoolkit/eptoolkit-logo.png" alt="EP Toolkit" className="w-10 h-10" />
|
||||
<h2 className="text-xl font-bold">EP Toolkit is here!</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-base leading-5">
|
||||
Hey! I just released <strong>EP Toolkit</strong> - an all-in-one toolkit for EP series
|
||||
devices.
|
||||
</p>
|
||||
<p className="text-sm text-black">A standalone cross-platform app for all your needs:</p>
|
||||
<ul className="text-sm text-black/70 list-disc list-inside space-y-1">
|
||||
<li>Export projects to popular DAW formats</li>
|
||||
<li>
|
||||
Manage samples: batch copy from local drive, copy & paste between slots, decode to
|
||||
mono, trim silence, normalize, batch remove
|
||||
</li>
|
||||
<li>Create and explore backups</li>
|
||||
<li>No browser required!</li>
|
||||
</ul>
|
||||
|
||||
<a
|
||||
href="https://eptoolkit.ep133-to-daw.cc/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-orange-500 font-bold text-base mt-1 underline focus:outline-none"
|
||||
>
|
||||
https://eptoolkit.ep133-to-daw.cc
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="relative w-80 shrink-0">
|
||||
<img
|
||||
src="/eptoolkit/eptoolkit-2.png"
|
||||
alt="EP Toolkit screenshot 2"
|
||||
className="h-37 rounded border border-black/10 shadow-md absolute top-11 left-1"
|
||||
/>
|
||||
<img
|
||||
src="/eptoolkit/eptoolkit-3.png"
|
||||
alt="EP Toolkit screenshot 3"
|
||||
className="h-37 rounded border border-black/10 shadow-md absolute top-30 left-6"
|
||||
/>
|
||||
<img
|
||||
src="/eptoolkit/eptoolkit-1.png"
|
||||
alt="EP Toolkit screenshot 1"
|
||||
className="h-37 rounded border border-black/10 shadow-md absolute top-20 left-18"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end mt-auto pt-4">
|
||||
<Button onClick={handleClose} variant="secondary">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default EPToolkitDialog;
|
||||
227
src/routes/home/Export/ExportOptions.tsx
Normal file
227
src/routes/home/Export/ExportOptions.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { projectIdAtom } from '~/atoms/project';
|
||||
import CheckboxField from '~/components/form/CheckboxField';
|
||||
import useProject from '~/hooks/useProject';
|
||||
import { hasMultipleNoteVariations } from '~/lib/utils';
|
||||
import { ExportFormValues } from './exportFormSchema';
|
||||
|
||||
function ExportOptions({ disabled = false }: { disabled?: boolean }) {
|
||||
const { control } = useFormContext<ExportFormValues>();
|
||||
const format = useWatch({ control, name: 'format' });
|
||||
const includeArchivedSamples = useWatch({ control, name: 'includeArchivedSamples' });
|
||||
const drumRackGroupA = useWatch({ control, name: 'drumRackGroupA' });
|
||||
const drumRackGroupB = useWatch({ control, name: 'drumRackGroupB' });
|
||||
const drumRackGroupC = useWatch({ control, name: 'drumRackGroupC' });
|
||||
const drumRackGroupD = useWatch({ control, name: 'drumRackGroupD' });
|
||||
|
||||
const projectId = useAtomValue(projectIdAtom);
|
||||
const { data: projectData } = useProject(projectId);
|
||||
|
||||
const notesVariationWarnings = useMemo(() => {
|
||||
if (format === 'reaper') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: string[] = [];
|
||||
const scenes = projectData?.scenes ?? [];
|
||||
|
||||
if (drumRackGroupA && hasMultipleNoteVariations('a', scenes)) {
|
||||
result.push('Group A');
|
||||
}
|
||||
if (drumRackGroupB && hasMultipleNoteVariations('b', scenes)) {
|
||||
result.push('Group B');
|
||||
}
|
||||
if (drumRackGroupC && hasMultipleNoteVariations('c', scenes)) {
|
||||
result.push('Group C');
|
||||
}
|
||||
if (drumRackGroupD && hasMultipleNoteVariations('d', scenes)) {
|
||||
result.push('Group D');
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [format, drumRackGroupA, drumRackGroupB, drumRackGroupC, drumRackGroupD, projectData]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 min-w-1/2">
|
||||
<h4 className="font-semibold">Options</h4>
|
||||
{format === 'dawproject' && (
|
||||
<>
|
||||
<CheckboxField
|
||||
name="includeArchivedSamples"
|
||||
title="Include archived WAV samples"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<CheckboxField
|
||||
name="exportAllPadsWithSamples"
|
||||
title="Export all pads with assigned samples"
|
||||
disabled={disabled || !includeArchivedSamples}
|
||||
helperText="Export all pads with assigned samples even if they are not used in patterns"
|
||||
/>
|
||||
<CheckboxField name="clips" title="Export with clips" disabled={disabled} />
|
||||
<CheckboxField
|
||||
name="drumRackGroupA"
|
||||
title="Merge tracks of group A into one track"
|
||||
disabled={disabled}
|
||||
helperText="Useful for drum kits. Make sure your drum pads are not playing chromatically."
|
||||
/>
|
||||
<CheckboxField
|
||||
name="drumRackGroupB"
|
||||
title="Merge tracks of group B into one track"
|
||||
disabled={disabled}
|
||||
helperText="Useful for drum kits. Make sure your drum pads are not playing chromatically."
|
||||
/>
|
||||
<CheckboxField
|
||||
name="drumRackGroupC"
|
||||
title="Merge tracks of group C into one track"
|
||||
disabled={disabled}
|
||||
helperText="Useful for drum kits. Make sure your drum pads are not playing chromatically."
|
||||
/>
|
||||
<CheckboxField
|
||||
name="drumRackGroupD"
|
||||
title="Merge tracks of group D into one track"
|
||||
disabled={disabled}
|
||||
helperText="Useful for drum kits. Make sure your drum pads are not playing chromatically."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{format === 'ableton' && (
|
||||
<>
|
||||
<CheckboxField
|
||||
name="includeArchivedSamples"
|
||||
title="Include samples"
|
||||
disabled={disabled}
|
||||
helperText="Samples will be exported as separate WAV files and bundled with the project. Sampler instrument will be assigned to each track that has a sample."
|
||||
/>
|
||||
<CheckboxField
|
||||
name="drumRackGroupA"
|
||||
title="Use «Drum Rack» for group A"
|
||||
disabled={disabled || !includeArchivedSamples}
|
||||
className="ml-4"
|
||||
helperText="Tracks in group A will be exported as Drum Rack. Choke groups are supported! Make sure your drum pads are not playing chromatically."
|
||||
/>
|
||||
<CheckboxField
|
||||
name="drumRackGroupB"
|
||||
title="Use «Drum Rack» for group B"
|
||||
disabled={disabled || !includeArchivedSamples}
|
||||
className="ml-4"
|
||||
helperText="Tracks in group B will be exported as Drum Rack. Choke groups are supported! Make sure your drum pads are not playing chromatically."
|
||||
/>
|
||||
<CheckboxField
|
||||
name="drumRackGroupC"
|
||||
title="Use «Drum Rack» for group C"
|
||||
disabled={disabled || !includeArchivedSamples}
|
||||
className="ml-4"
|
||||
helperText="Tracks in group C will be exported as Drum Rack. Choke groups are supported! Make sure your drum pads are not playing chromatically."
|
||||
/>
|
||||
<CheckboxField
|
||||
name="drumRackGroupD"
|
||||
title="Use «Drum Rack» for group D"
|
||||
disabled={disabled || !includeArchivedSamples}
|
||||
className="ml-4"
|
||||
helperText="Tracks in group D will be exported as Drum Rack. Choke groups are supported! Make sure your drum pads are not playing chromatically."
|
||||
/>
|
||||
<CheckboxField
|
||||
name="exportAllPadsWithSamples"
|
||||
title="Export all pads with assigned samples"
|
||||
disabled={disabled || !includeArchivedSamples}
|
||||
helperText="Export all samples and create tracks for all pads with assigned samples, even if they are not used in patterns"
|
||||
/>
|
||||
<CheckboxField
|
||||
name="clips"
|
||||
title="Session clips instead of arrangements"
|
||||
disabled={disabled}
|
||||
helperText="Export as session clips for live performance."
|
||||
/>
|
||||
<CheckboxField
|
||||
name="groupTracks"
|
||||
title="Group tracks"
|
||||
disabled={disabled}
|
||||
helperText="Tracks will be grouped by their groups (A, B, C, D) same as on the device."
|
||||
/>
|
||||
<CheckboxField
|
||||
name="sendEffects"
|
||||
title="Send effects"
|
||||
disabled={disabled}
|
||||
helperText="Return track with effect will be added. Each individual track will be sending to the return track. If you select «Group tracks», each group will send its audio to the return track."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{format === 'reaper' && (
|
||||
<>
|
||||
<CheckboxField
|
||||
name="includeArchivedSamples"
|
||||
title="Include samples"
|
||||
disabled={disabled}
|
||||
helperText="Samples will be exported as WAV files and bundled with the project in Media/samples folder."
|
||||
/>
|
||||
<CheckboxField
|
||||
name="exportAllPadsWithSamples"
|
||||
title="Export all pads with assigned samples"
|
||||
disabled={disabled || !includeArchivedSamples}
|
||||
helperText="Export all pads with assigned samples even if they are not used in patterns"
|
||||
/>
|
||||
<CheckboxField
|
||||
name="groupTracks"
|
||||
title="Group tracks"
|
||||
disabled={disabled}
|
||||
helperText="Tracks will be grouped by their groups (A, B, C, D) same as on the device."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{format === 'midi' && (
|
||||
<>
|
||||
<CheckboxField
|
||||
name="includeArchivedSamples"
|
||||
title="Include archived WAV samples"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<CheckboxField
|
||||
name="exportAllPadsWithSamples"
|
||||
title="Export all pads with assigned samples"
|
||||
disabled={disabled || !includeArchivedSamples}
|
||||
helperText="Export all pads with assigned samples even if they are not used in patterns"
|
||||
/>
|
||||
<CheckboxField
|
||||
name="drumRackGroupA"
|
||||
title="Merge tracks of group A into one track"
|
||||
disabled={disabled}
|
||||
helperText="Useful for drum kits. Make sure your drum pads are not playing chromatically."
|
||||
/>
|
||||
<CheckboxField
|
||||
name="drumRackGroupB"
|
||||
title="Merge tracks of group B into one track"
|
||||
disabled={disabled}
|
||||
helperText="Useful for drum kits. Make sure your drum pads are not playing chromatically."
|
||||
/>
|
||||
<CheckboxField
|
||||
name="drumRackGroupC"
|
||||
title="Merge tracks of group C into one track"
|
||||
disabled={disabled}
|
||||
helperText="Useful for drum kits. Make sure your drum pads are not playing chromatically."
|
||||
/>
|
||||
<CheckboxField
|
||||
name="drumRackGroupD"
|
||||
title="Merge tracks of group D into one track"
|
||||
disabled={disabled}
|
||||
helperText="Useful for drum kits. Make sure your drum pads are not playing chromatically."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{notesVariationWarnings.length > 0 && (
|
||||
<div className="bg-yellow-100 border border-yellow-400 text-yellow-800 px-3 py-2 rounded text-sm mt-4">
|
||||
<strong>Warning:</strong> The following groups contain tracks with multiple note
|
||||
variations. Merging them into a single track or Drum Rack will flatten all notes to a
|
||||
single pitch: {notesVariationWarnings.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExportOptions;
|
||||
32
src/routes/home/Export/ExportProject.tsx
Normal file
32
src/routes/home/Export/ExportProject.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import IconArrowDialog from '~/components/icons/arrow-dialog.svg?react';
|
||||
import Button from '~/components/ui/Button';
|
||||
import { APP_STATES, useAppState } from '~/hooks/useAppState';
|
||||
import ExportProjectDialog from './ExportProjectDialog';
|
||||
|
||||
function ExportProject() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const appState = useAppState();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2 ">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={!appState.includes(APP_STATES.CAN_EXPORT_PROJECT)}
|
||||
size="sm"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<IconArrowDialog className="w-4 h-4" />
|
||||
<span>Export</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open && <ExportProjectDialog open={open} onOpenChange={setOpen} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExportProject;
|
||||
232
src/routes/home/Export/ExportProjectDialog.tsx
Normal file
232
src/routes/home/Export/ExportProjectDialog.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { feedbackDialogAtom } from '~/atoms/feedbackDialog';
|
||||
import { projectIdAtom } from '~/atoms/project';
|
||||
import InputField from '~/components/form/InputField';
|
||||
import SelectField from '~/components/form/SelectField';
|
||||
import IconFile from '~/components/icons/file.svg?react';
|
||||
import Button from '~/components/ui/Button';
|
||||
import Dialog from '~/components/ui/Dialog';
|
||||
import useExportProject, { EXPORT_FORMATS } from '~/hooks/useExportProject';
|
||||
import { usePersistedFormValues } from '~/hooks/usePersistedFormValues';
|
||||
import { ExportFormatId } from '~/types/types';
|
||||
import ExportOptions from './ExportOptions';
|
||||
import ExportScenes from './ExportScenes';
|
||||
import { ExportFormValues, exportFormSchema, PERSISTED_FIELDS } from './exportFormSchema';
|
||||
|
||||
const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g;
|
||||
|
||||
function sanitizeFileName(value: string): string {
|
||||
return value.replace(INVALID_FILENAME_CHARS, '');
|
||||
}
|
||||
|
||||
const NOTES: Record<ExportFormatId, string> = {
|
||||
ableton: `Please note that the exported project won't sound exactly the same as it does on the device.`,
|
||||
dawproject: `Unfortunately, the DAWproject format does not currently support the "Sampler" instrument, so you will need to manually assign the samples in your DAW.`,
|
||||
midi: `The simplest format supported by any DAW. But you have to assign the samples manually.`,
|
||||
reaper: `Only basic sampler features are supported`,
|
||||
};
|
||||
|
||||
function ExportProjectDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const projectId = useAtomValue(projectIdAtom);
|
||||
|
||||
const form = useForm<ExportFormValues>({
|
||||
resolver: zodResolver(exportFormSchema),
|
||||
defaultValues: {
|
||||
format: EXPORT_FORMATS[0].value,
|
||||
projectName: `Project${projectId}`,
|
||||
includeArchivedSamples: true,
|
||||
exportAllPadsWithSamples: false,
|
||||
clips: false,
|
||||
groupTracks: true,
|
||||
drumRackGroupA: false,
|
||||
drumRackGroupB: false,
|
||||
drumRackGroupC: false,
|
||||
drumRackGroupD: false,
|
||||
sendEffects: true,
|
||||
allScenes: true,
|
||||
selectedScenes: [],
|
||||
},
|
||||
});
|
||||
|
||||
usePersistedFormValues(form, PERSISTED_FIELDS, exportFormSchema);
|
||||
|
||||
const format = useWatch({ control: form.control, name: 'format' });
|
||||
const projectName = useWatch({ control: form.control, name: 'projectName' });
|
||||
const allScenes = useWatch({ control: form.control, name: 'allScenes' });
|
||||
const selectedScenes = useWatch({ control: form.control, name: 'selectedScenes' });
|
||||
const trimmedName = (projectName ?? '').trim();
|
||||
const canExport = (allScenes || (selectedScenes?.length ?? 0) > 0) && trimmedName.length > 0;
|
||||
|
||||
const {
|
||||
startExport,
|
||||
cancelExport,
|
||||
isPending,
|
||||
pendingStatus,
|
||||
percentage,
|
||||
reset,
|
||||
result,
|
||||
error,
|
||||
sampleReport,
|
||||
} = useExportProject();
|
||||
const [_, openFeedbackDialog] = useAtom(feedbackDialogAtom);
|
||||
|
||||
const handleExport = form.handleSubmit(async (values) => {
|
||||
await startExport({
|
||||
...values,
|
||||
projectName: values.projectName.trim(),
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [format]);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Dialog isOpen={open} onClose={() => onOpenChange(false)} className="min-w-200 max-w-175">
|
||||
<div className="flex flex-col gap-2 min-w-150">
|
||||
<h3 className="text-lg font-semibold">Export</h3>
|
||||
<SelectField name="format" disabled={isPending} className="mr-auto mb-4">
|
||||
{EXPORT_FORMATS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</SelectField>
|
||||
<h4 className="font-semibold">Project name</h4>
|
||||
<InputField
|
||||
name="projectName"
|
||||
disabled={isPending}
|
||||
className="w-100"
|
||||
placeholder="Enter project name"
|
||||
transform={sanitizeFileName}
|
||||
/>
|
||||
<div className="flex gap-14 mt-2">
|
||||
<ExportOptions disabled={isPending} />
|
||||
<ExportScenes disabled={isPending} />
|
||||
</div>
|
||||
|
||||
{NOTES[format] && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-semibold">Notes</h3>
|
||||
<div className="text-sm whitespace-pre-line">{NOTES[format]}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!result && !percentage && (
|
||||
<a
|
||||
href="https://eptoolkit.ep133-to-daw.cc/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bg-creme w-full bg-grid border border-black gap-2 p-2 text-black flex mt-4 justify-center items-center"
|
||||
>
|
||||
<p>
|
||||
Want fader automation support? Get <strong>EP Toolkit</strong>: desktop app for
|
||||
exporting, sample management, backups and more.{' '}
|
||||
</p>
|
||||
<img
|
||||
src="/eptoolkit/eptoolkit-logo.png"
|
||||
alt="EP Toolkit"
|
||||
className="size-10 shrink-0 ml-auto"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 min-h-13.5">
|
||||
{percentage > 0 && (
|
||||
<>
|
||||
<div className="h-7.5 border border-black bg-[#ccc]">
|
||||
<div className="h-full bg-brand" style={{ width: `${percentage}%` }} />
|
||||
</div>
|
||||
<div className="text-sm text-black min-h-5 text-center mt-1">{pendingStatus}</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{result && percentage >= 100 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-semibold">Files to download</h3>
|
||||
{result.files.map((f) => (
|
||||
<div key={f.name} className="flex gap-2 items-center">
|
||||
<IconFile className="w-6 h-6" />
|
||||
<a href={f.url} download={f.name} className="text-blue-500">
|
||||
{f.name}
|
||||
</a>
|
||||
<span className="mr-auto text-sm opacity-80 ml-2">
|
||||
{(f.size / 1024).toFixed(2)}KB
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{sampleReport && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-semibold">Sample Report</h3>
|
||||
<div className="text-sm">
|
||||
<p className="text-green-600">
|
||||
✓ Downloaded: {sampleReport.downloaded.length} samples
|
||||
</p>
|
||||
{sampleReport.missing.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-red-600">
|
||||
✗ Missing: {sampleReport.missing.length} samples
|
||||
</p>
|
||||
<ul className="mt-1 ml-4 max-h-25 overflow-y-auto">
|
||||
{sampleReport.missing.map((missing, index) => (
|
||||
<li key={index} className="text-red-500 text-xs truncate">
|
||||
{missing.name}: {missing.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="my-4 bg-red-100/50 p-4">
|
||||
<h3 className="text-lg font-semibold text-center">Error</h3>
|
||||
<p className="text-sm text-red-500 text-center">
|
||||
Opps, that's not good. Hope the error tracking system helps me to find the problem.
|
||||
If you can, please submit an error report - it really helps me fix things faster.
|
||||
Thanks!
|
||||
</p>
|
||||
<div className="text-center mt-5"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mt-5">
|
||||
<Button variant="secondary" className="mr-auto" onClick={() => openFeedbackDialog(true)}>
|
||||
Submit error report
|
||||
</Button>
|
||||
{isPending ? (
|
||||
<Button variant="secondary" onClick={cancelExport}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleExport} disabled={isPending || !canExport} variant="primary">
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExportProjectDialog;
|
||||
68
src/routes/home/Export/ExportScenes.tsx
Normal file
68
src/routes/home/Export/ExportScenes.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Controller, useFormContext, useWatch } from 'react-hook-form';
|
||||
import { projectIdAtom } from '~/atoms/project';
|
||||
import CheckBox from '~/components/ui/CheckBox';
|
||||
import useProject from '~/hooks/useProject';
|
||||
import useSceneName from '~/hooks/useSceneName.ts';
|
||||
import { Scene } from '~/types/types';
|
||||
import { ExportFormValues } from './exportFormSchema';
|
||||
|
||||
function ExportScenes({ disabled = false }: { disabled?: boolean }) {
|
||||
const projectId = useAtomValue(projectIdAtom);
|
||||
const { data } = useProject(projectId);
|
||||
const { getSceneName } = useSceneName(projectId);
|
||||
const { control, setValue } = useFormContext<ExportFormValues>();
|
||||
|
||||
const scenes: Scene[] = data?.scenes ?? [];
|
||||
const allScenes = useWatch({ control, name: 'allScenes' });
|
||||
const selectedScenes = useWatch({ control, name: 'selectedScenes' });
|
||||
|
||||
const handleSceneToggle = (sceneName: string, checked: boolean) => {
|
||||
const current = selectedScenes ?? [];
|
||||
if (checked) {
|
||||
setValue('selectedScenes', [...current, sceneName]);
|
||||
} else {
|
||||
setValue(
|
||||
'selectedScenes',
|
||||
current.filter((s) => s !== sceneName),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 min-w-40">
|
||||
<h4 className="font-semibold">Scenes</h4>
|
||||
<Controller
|
||||
name="allScenes"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<CheckBox
|
||||
checked={field.value}
|
||||
onChange={(checked) => field.onChange(checked)}
|
||||
title="All scenes"
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={clsx('flex flex-col gap-2 w-50', {
|
||||
'max-h-35 overflow-y-auto': scenes.length > 5,
|
||||
})}
|
||||
>
|
||||
{scenes.map((scene) => (
|
||||
<CheckBox
|
||||
key={scene.name}
|
||||
checked={allScenes || (selectedScenes ?? []).includes(scene.name)}
|
||||
onChange={(checked) => handleSceneToggle(scene.name, checked)}
|
||||
title={getSceneName(scene.name)}
|
||||
disabled={disabled || allScenes}
|
||||
className="ml-4"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExportScenes;
|
||||
40
src/routes/home/Export/exportFormSchema.ts
Normal file
40
src/routes/home/Export/exportFormSchema.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod';
|
||||
import { ExportFormatId } from '../../../types/types';
|
||||
|
||||
const FORMAT_IDS: [ExportFormatId, ...ExportFormatId[]] = [
|
||||
'ableton',
|
||||
'dawproject',
|
||||
'midi',
|
||||
'reaper',
|
||||
];
|
||||
|
||||
export const exportFormSchema = z.object({
|
||||
format: z.enum(FORMAT_IDS),
|
||||
projectName: z.string(),
|
||||
includeArchivedSamples: z.boolean(),
|
||||
exportAllPadsWithSamples: z.boolean(),
|
||||
clips: z.boolean(),
|
||||
groupTracks: z.boolean(),
|
||||
drumRackGroupA: z.boolean(),
|
||||
drumRackGroupB: z.boolean(),
|
||||
drumRackGroupC: z.boolean(),
|
||||
drumRackGroupD: z.boolean(),
|
||||
sendEffects: z.boolean(),
|
||||
allScenes: z.boolean(),
|
||||
selectedScenes: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type ExportFormValues = z.infer<typeof exportFormSchema>;
|
||||
|
||||
export const PERSISTED_FIELDS: (keyof ExportFormValues)[] = [
|
||||
'format',
|
||||
'includeArchivedSamples',
|
||||
'exportAllPadsWithSamples',
|
||||
'clips',
|
||||
'groupTracks',
|
||||
'drumRackGroupA',
|
||||
'drumRackGroupB',
|
||||
'drumRackGroupC',
|
||||
'drumRackGroupD',
|
||||
'sendEffects',
|
||||
];
|
||||
52
src/routes/home/FacePlateHeader.tsx
Normal file
52
src/routes/home/FacePlateHeader.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { droppedBackupFileAtom } from '~/atoms/droppedProjectFile';
|
||||
import useTheme from '~/hooks/useTheme';
|
||||
import useDevice from '../../hooks/useDevice';
|
||||
|
||||
function FacePlateHeader() {
|
||||
const { device } = useDevice();
|
||||
const droppedBackupFile = useAtomValue(droppedBackupFileAtom);
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 bg-face px-3 py-2 border border-black shadow-my">
|
||||
<img src={`/${theme.id}-on.png`} className="h-[100px] w-auto" alt={theme.id} />
|
||||
<div className="flex flex-col gap-2 relative">
|
||||
<h1 className="text-[30px] font-medium leading-6">
|
||||
{theme.name}: <i>Export To DAW</i>
|
||||
</h1>
|
||||
<h2 className="text-xs">
|
||||
Export your projects to Ableton Live, DAWproject, REAPER or MIDI
|
||||
</h2>
|
||||
|
||||
<div className="absolute left-0 bottom-0 leading-4">
|
||||
{!device && !droppedBackupFile && (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="border border-black bg-gray-300 size-4 rounded-full" />
|
||||
No device connected
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!device && !!droppedBackupFile && (
|
||||
<div className="flex gap-2 items-center font-xs">
|
||||
<div className="border border-black bg-amber-400 size-3 rounded-full" />
|
||||
Using backup file
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!device && (
|
||||
<div className="flex gap-2 items-center font-xs">
|
||||
<div className="border border-black bg-brand size-3 rounded-full" />
|
||||
Connected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs absolute right-0 bottom-0 opacity-40">
|
||||
Firmware version: {device?.metadata.os_version || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FacePlateHeader;
|
||||
104
src/routes/home/FeedbackDialog.tsx
Normal file
104
src/routes/home/FeedbackDialog.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { feedbackDialogAtom } from '../../atoms/feedbackDialog';
|
||||
import { projectIdAtom } from '../../atoms/project';
|
||||
import Button from '../../components/ui/Button';
|
||||
import CheckBox from '../../components/ui/CheckBox';
|
||||
import Dialog from '../../components/ui/Dialog';
|
||||
import FileInput from '../../components/ui/FileInput';
|
||||
import Input from '../../components/ui/Input';
|
||||
import useSubmitFeedback from '../../hooks/useSubmitFeedback';
|
||||
import { showToast } from '../../lib/toast';
|
||||
|
||||
function FeedbackDialog() {
|
||||
const [open, setOpen] = useAtom(feedbackDialogAtom);
|
||||
const [description, setDescription] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [attachProject, setAttachProject] = useState(true);
|
||||
const { mutate: submitErrorReport, isPending, isError } = useSubmitFeedback();
|
||||
const projectId = useAtomValue(projectIdAtom);
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
submitErrorReport(
|
||||
{
|
||||
description,
|
||||
email,
|
||||
files: files.length > 0 ? files : null,
|
||||
attachProject,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
showToast('Feedback submitted successfully');
|
||||
|
||||
setOpen(false);
|
||||
setDescription('');
|
||||
setEmail('');
|
||||
setFiles([]);
|
||||
setAttachProject(true);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog isOpen={open} onClose={() => setOpen(false)}>
|
||||
<div className="flex flex-col gap-4 min-w-150">
|
||||
<h3 className="text-lg font-semibold">Feedback / error report / feature request</h3>
|
||||
<p className="text-sm">
|
||||
If your project fails to export, or something looks wrong, please submit an error report
|
||||
to the developer. You can also use this form to send general feedback or request new
|
||||
features.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Message"
|
||||
type="textarea"
|
||||
value={description}
|
||||
disabled={isPending}
|
||||
onChange={(e: Event) => setDescription((e.target as HTMLTextAreaElement).value)}
|
||||
required
|
||||
rows={4}
|
||||
/>
|
||||
<Input
|
||||
label="Your email (optional)"
|
||||
type="email"
|
||||
value={email}
|
||||
disabled={isPending}
|
||||
onChange={(e: Event) => setEmail((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<FileInput files={files} onFilesChange={setFiles} disabled={isPending} />
|
||||
<CheckBox
|
||||
title="Attach current project"
|
||||
checked={attachProject}
|
||||
onChange={setAttachProject}
|
||||
disabled={!projectId || isPending}
|
||||
helperText="If enabled, the selected project will be attached to the report. Useful for bug reports. I promise I remove it as soon as I fix the issue."
|
||||
/>
|
||||
<div className="flex gap-4 justify-end mt-6">
|
||||
<Button
|
||||
onClick={() => setOpen(false)}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || description.trim() === ''}>
|
||||
{isPending ? 'Submitting...' : 'Submit'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{isError && (
|
||||
<div className="my-4 bg-red-100/50 p-4 text-sm text-red-500 text-center">
|
||||
Failed to submit error report. Please try again.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default FeedbackDialog;
|
||||
114
src/routes/home/Home.tsx
Normal file
114
src/routes/home/Home.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useAtom } from 'jotai';
|
||||
|
||||
import { projectIdAtom } from '~/atoms/project';
|
||||
import OfflineInformer from '~/components/OfflineInformer';
|
||||
import UpdateNotifier from '~/components/UpdateNotifier';
|
||||
import Toast from '~/components/ui/Toast';
|
||||
import { APP_STATES, useAppState } from '~/hooks/useAppState';
|
||||
import useDevice from '~/hooks/useDevice';
|
||||
import useDroppedFile from '~/hooks/useDroppedFile';
|
||||
import AppStateDisplay from './AppStateDisplay';
|
||||
import EPToolkitDialog from './EPToolkitDialog';
|
||||
import FacePlateHeader from './FacePlateHeader';
|
||||
import FeedbackDialog from './FeedbackDialog';
|
||||
import Menu from './Menu';
|
||||
import ProjectMeta from './ProjectMeta';
|
||||
import Arrangements from './ProjectView/Arrangements';
|
||||
import RiddimDialog from './RiddimDialog';
|
||||
|
||||
function Home() {
|
||||
const [projectId] = useAtom(projectIdAtom);
|
||||
const { error } = useDevice();
|
||||
const appState = useAppState();
|
||||
const { dropRef } = useDroppedFile();
|
||||
|
||||
return (
|
||||
<div className="min-w-275 max-w-450 p-4 h-screen grid grid-rows-[auto_1fr] mx-auto">
|
||||
<div className="mb-2 empty:mb-0">
|
||||
<UpdateNotifier />
|
||||
<OfflineInformer />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-rows-[auto_1fr_auto] gap-2 max-h-full min-h-0">
|
||||
<header className="flex flex-col gap-2">
|
||||
<div className="flex justify-between">
|
||||
<FacePlateHeader />
|
||||
|
||||
<div className="self-start flex flex-col gap-2">
|
||||
<Menu />
|
||||
<a
|
||||
href="https://eptoolkit.ep133-to-daw.cc/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bg-creme bg-grid border border-black shadow-[1px_1px_0px_1px_#00000099] flex items-center gap-2 h-15 px-3"
|
||||
>
|
||||
<div className="flex flex-col justify-center min-w-0">
|
||||
<span className="text-2xl font-bold leading-tight text-brand">EP Toolkit</span>
|
||||
<span className="text-xs text-black/60 leading-tight">
|
||||
Standalone app: export, samples, backups
|
||||
</span>
|
||||
</div>
|
||||
<img
|
||||
src="/eptoolkit/eptoolkit-logo.png"
|
||||
alt="EP Toolkit"
|
||||
className="size-10 shrink-0 ml-auto"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<ProjectMeta projectId={projectId} />
|
||||
</header>
|
||||
|
||||
<main
|
||||
ref={dropRef as any}
|
||||
className="bg-white h-full border p-4 shadow-[1px_1px_0px_1px_#00000099] overflow-scroll"
|
||||
>
|
||||
{appState.includes(APP_STATES.CAN_DISPLAY_PROJECT) && (
|
||||
<Arrangements projectId={projectId} />
|
||||
)}
|
||||
|
||||
{appState.includes(APP_STATES.LOADING) && (
|
||||
<AppStateDisplay
|
||||
title="Loading..."
|
||||
message="Please wait while the project is loading."
|
||||
/>
|
||||
)}
|
||||
|
||||
{appState.includes(APP_STATES.NO_PROJECT_SELECTED) && (
|
||||
<AppStateDisplay
|
||||
title="Ready to Export!"
|
||||
message="Select a project from the dropdown."
|
||||
/>
|
||||
)}
|
||||
|
||||
{appState.includes(APP_STATES.NO_DEVICE_CONNECTED) && (
|
||||
<AppStateDisplay
|
||||
title="No Device Connected"
|
||||
message={
|
||||
<>
|
||||
Please connect a device and allow access to MIDI
|
||||
<br />
|
||||
or drop a .pak/.ppak backup file
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{appState.includes(APP_STATES.ERROR) && error && (
|
||||
<AppStateDisplay title="Error" message={error?.message || 'Unknown error'} />
|
||||
)}
|
||||
</main>
|
||||
<div className="bg-face px-3 py-2 border border-black text-xs text-black/70 shadow-[1px_1px_0px_1px_#00000099]">
|
||||
This project is not affiliated with or officially authorized by Teenage Engineering
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FeedbackDialog />
|
||||
<EPToolkitDialog />
|
||||
<RiddimDialog />
|
||||
<Toast />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
40
src/routes/home/Menu.tsx
Normal file
40
src/routes/home/Menu.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useAtom } from 'jotai';
|
||||
import { Link } from 'wouter';
|
||||
import { feedbackDialogAtom } from '~/atoms/feedbackDialog';
|
||||
import Donate from './Donate';
|
||||
|
||||
const menuItemClassName =
|
||||
'text-black underline hover:no-underline cursor-pointer px-1 font-semibold';
|
||||
|
||||
function Menu() {
|
||||
const [_, openFeedbackDialog] = useAtom(feedbackDialogAtom);
|
||||
|
||||
return (
|
||||
<div className="bg-face px-3 py-2 border border-black shadow-my flex justify-center">
|
||||
<Link href="/faq" className={menuItemClassName}>
|
||||
FAQ
|
||||
</Link>
|
||||
|
|
||||
<button type="button" className={menuItemClassName} onClick={() => openFeedbackDialog(true)}>
|
||||
Feedback
|
||||
</button>
|
||||
|
|
||||
<Donate className={menuItemClassName} />|
|
||||
<a
|
||||
className={menuItemClassName}
|
||||
href="https://github.com/phones24/ep133-export-to-daw"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View on GitHub"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
|
|
||||
<a href="mailto:ep133todaw@proton.me" title="Mail me" className={menuItemClassName}>
|
||||
Email
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Menu;
|
||||
67
src/routes/home/ProjectManager.tsx
Normal file
67
src/routes/home/ProjectManager.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useAtom } from 'jotai';
|
||||
import { JSX } from 'preact';
|
||||
import IconReload from '~/components/icons/reload.svg?react';
|
||||
import IconResetSettings from '~/components/icons/reset-settings.svg?react';
|
||||
import Button from '~/components/ui/Button';
|
||||
import Select from '~/components/ui/Select';
|
||||
import useCustomSceneNames from '~/hooks/useCustomSceneNames';
|
||||
import useSceneName from '~/hooks/useSceneName.ts';
|
||||
import { projectIdAtom } from '../../atoms/project';
|
||||
import { APP_STATES, useAppState } from '../../hooks/useAppState';
|
||||
import useProject from '../../hooks/useProject';
|
||||
import useProjectsList from '../../hooks/useProjectsList';
|
||||
import ExportProject from './Export/ExportProject';
|
||||
|
||||
function ProjectManager() {
|
||||
const [projectId, setProjectId] = useAtom(projectIdAtom);
|
||||
const { refetch } = useProject(projectId);
|
||||
const { resetSceneNames } = useSceneName(projectId);
|
||||
const { hasCustomNames } = useCustomSceneNames(projectId);
|
||||
const appState = useAppState();
|
||||
const projects = useProjectsList();
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select
|
||||
onChange={(e: JSX.TargetedEvent<HTMLSelectElement, Event>) =>
|
||||
setProjectId((e.target as HTMLSelectElement).value)
|
||||
}
|
||||
value={projectId}
|
||||
name="project"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!appState.includes(APP_STATES.CAN_SELECT_PROJECT)}
|
||||
>
|
||||
<option value="">Select project</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
onClick={() => refetch()}
|
||||
disabled={!appState.includes(APP_STATES.CAN_RELOAD_PROJECT)}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
title="Reload project data from device"
|
||||
>
|
||||
<IconReload className="w-4 h-4" />
|
||||
</Button>
|
||||
{hasCustomNames && (
|
||||
<Button
|
||||
onClick={resetSceneNames}
|
||||
disabled={!appState.includes(APP_STATES.CAN_DISPLAY_PROJECT)}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
title="Reset custom scene names"
|
||||
>
|
||||
<IconResetSettings className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<ExportProject />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectManager;
|
||||
130
src/routes/home/ProjectMeta.tsx
Normal file
130
src/routes/home/ProjectMeta.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import clsx from 'clsx';
|
||||
import { ReactNode } from 'preact/compat';
|
||||
import IconMusic from '~/components/icons/music.svg?react';
|
||||
import useDevice from '../../hooks/useDevice';
|
||||
import useProject from '../../hooks/useProject';
|
||||
import { EFFECTS_SHORT, NOTE_NAMES, SCALES_SHORT } from '../../lib/constants';
|
||||
import ProjectManager from './ProjectManager';
|
||||
|
||||
function Knob({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="121"
|
||||
height="117"
|
||||
viewBox="0 0 121 117"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M0 0 C8.91 0 17.82 0 27 0 C28.32 2.64 29.64 5.28 31 8 C35.01629283 12.90880235 37.83738043 13.91869021 44 17 C44 25.91 44 34.82 44 44 C37.33124081 43.1664051 33.81260421 42.47573783 28.1875 39.625 C27.4960791 39.2748584 26.8046582 38.9247168 26.09228516 38.56396484 C14.28021969 32.23989081 6.21007705 22.04282388 1.125 9.75 C0 6 0 6 0 0 Z "
|
||||
fill="#EE5922"
|
||||
transform="translate(8,66)"
|
||||
/>
|
||||
<path
|
||||
d="M0 0 C0 8.91 0 17.82 0 27 C-4.455 29.475 -4.455 29.475 -9 32 C-12.0487268 35.16164261 -14.36272153 37.63506632 -16.20678711 41.5847168 C-18 44 -18 44 -20.72729492 44.56762695 C-21.80084229 44.54144287 -22.87438965 44.51525879 -23.98046875 44.48828125 C-25.14384766 44.47216797 -26.30722656 44.45605469 -27.50585938 44.43945312 C-29.32827148 44.37661133 -29.32827148 44.37661133 -31.1875 44.3125 C-32.41404297 44.28994141 -33.64058594 44.26738281 -34.90429688 44.24414062 C-37.93741559 44.18513053 -40.96810222 44.10284592 -44 44 C-42.953127 30.34340939 -36.25738171 19.88244924 -26 11 C-18.43888217 5.17414622 -9.73059956 0 0 0 Z "
|
||||
fill="#E22818"
|
||||
transform="translate(52,6)"
|
||||
/>
|
||||
<path
|
||||
d="M0 0 C8.91 0 17.82 0 27 0 C24.76978015 13.38131907 19.01934678 24.61798427 8.41015625 33.37109375 C1.26322866 38.20135113 -8.28083542 44 -17 44 C-17.29121422 39.62641047 -17.46824053 35.25530741 -17.625 30.875 C-17.75068359 29.01875 -17.75068359 29.01875 -17.87890625 27.125 C-17.97636817 23.49941673 -17.94674493 20.50568395 -17 17 C-14.62226415 14.71718053 -12.46766207 13.49948845 -9.45849609 12.17871094 C-4.38972694 9.74852052 -2.43529654 4.87059307 0 0 Z "
|
||||
fill="#5293B3"
|
||||
transform="translate(84,66)"
|
||||
/>
|
||||
<path
|
||||
d="M0 0 C13.52550105 1.47109462 24.37025022 7.21229311 33.3125 17.6875 C39.01920519 25.29644025 42.96213662 33.51607601 44 43 C39.62641047 43.29121422 35.25530741 43.46824053 30.875 43.625 C29.6375 43.70878906 28.4 43.79257812 27.125 43.87890625 C23.49964573 43.97636201 20.5053268 43.94733952 17 43 C14.73546791 40.6186564 13.53191706 38.46677964 12.23364258 35.45849609 C10.22572817 31.45697263 6.85331072 29.97184344 3 28 C2.01 27.34 1.02 26.68 0 26 C-0.34057617 23.38623047 -0.34057617 23.38623047 -0.29296875 20.1171875 C-0.2784668 18.36728516 -0.2784668 18.36728516 -0.26367188 16.58203125 C-0.23853516 15.35871094 -0.21339844 14.13539062 -0.1875 12.875 C-0.17396484 11.64394531 -0.16042969 10.41289062 -0.14648438 9.14453125 C-0.11103543 6.09592157 -0.06162079 3.048175 0 0 Z "
|
||||
fill="#BB487C"
|
||||
transform="translate(67,7)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function valueToPercent(value: number | undefined) {
|
||||
if (value === undefined) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const val = Math.round(value * 100);
|
||||
|
||||
return `${val < 10 ? '0' : ''}${val.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function Value({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number | undefined | ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<p className="flex gap-1 text-white pr-3 w-fit min-h-0 items-center">
|
||||
{label}:<span className="font-bold">{value || 'N/A'}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectMeta({ projectId }: { projectId?: string }) {
|
||||
const { data } = useProject(projectId);
|
||||
const { device } = useDevice();
|
||||
|
||||
return (
|
||||
<div className="flex bg-screen p-2 pl-4 shadow-my">
|
||||
<div className={clsx('flex gap-4 text-xl', (!data || !device) && 'opacity-70')}>
|
||||
<Value
|
||||
label="TEMPO"
|
||||
value={
|
||||
data?.settings.bpm ? (
|
||||
<span>
|
||||
{data.settings.bpm}
|
||||
<span className="text-gray-300 font-normal ml-1">
|
||||
({data.scenesSettings.timeSignature.numerator}/
|
||||
{data.scenesSettings.timeSignature.denominator})
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
'N/A'
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Value label="SCENES" value={data?.scenes.length ?? 'N/A'} />
|
||||
|
||||
<div className="text-white px-3 w-fit min-h-0 flex gap-1 items-center">
|
||||
FX:
|
||||
<span className="font-bold">
|
||||
{data?.effects.effectType !== undefined
|
||||
? EFFECTS_SHORT[data?.effects.effectType]
|
||||
: 'N/A'}
|
||||
</span>
|
||||
<div
|
||||
className={clsx('flex gap-1 items-center ml-2', {
|
||||
'opacity-60': data?.effects.effectType === 0,
|
||||
})}
|
||||
>
|
||||
<Knob className="size-5" />
|
||||
{data?.effects.param1 !== undefined ? valueToPercent(data?.effects.param1) : 'N/A'}
|
||||
<Knob className="size-5 ml-2" />
|
||||
{data?.effects.param2 !== undefined ? valueToPercent(data?.effects.param2) : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-white px-3 w-fit min-h-0 flex gap-1 items-center">
|
||||
<div className="inline-flex items-center">
|
||||
<IconMusic className="size-5" />:
|
||||
</div>
|
||||
<span className="font-bold">
|
||||
{data?.settings.scale !== undefined && data?.settings.rootNote !== undefined
|
||||
? `${SCALES_SHORT[data.settings.scale] ?? 'N/A'}/${NOTE_NAMES[data.settings.rootNote] ?? ''}`
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<ProjectManager />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectMeta;
|
||||
67
src/routes/home/ProjectView/Arrangements.tsx
Normal file
67
src/routes/home/ProjectView/Arrangements.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import useProject from '~/hooks/useProject';
|
||||
import { getQuarterNotesPerBar } from '~/lib/exporters/utils';
|
||||
import webViewTransformer from '~/lib/transformers/webView';
|
||||
import { Bar } from './Bar';
|
||||
import SceneName from './SceneName';
|
||||
import { SingleNote } from './SingleNote';
|
||||
import Track from './Track';
|
||||
import TrackMeta from './TrackMeta';
|
||||
|
||||
function Arrangements({ projectId }: { projectId: string }) {
|
||||
const { data } = useProject(projectId);
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const transformedData = webViewTransformer(data);
|
||||
const barLength =
|
||||
getQuarterNotesPerBar(
|
||||
transformedData.scenesSettings.timeSignature.numerator,
|
||||
transformedData.scenesSettings.timeSignature.denominator,
|
||||
) * 4;
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 h-full">
|
||||
{transformedData.scenes.map((sceneData) => (
|
||||
<div className="flex flex-col gap-2 min-w-max" key={sceneData.name}>
|
||||
<SceneName projectId={projectId} defaultName={sceneData.name} />
|
||||
{sceneData.patterns.map((pattern) => (
|
||||
<div className="flex gap-2" key={pattern.group + pattern.padNumber}>
|
||||
<TrackMeta pattern={pattern} />
|
||||
<div
|
||||
style={{ width: sceneData.maxBars * barLength * 24 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<Track>
|
||||
{pattern.bars > 0 && sceneData.maxBars % pattern.bars === 0
|
||||
? [...Array(sceneData.maxBars / pattern.bars).keys()].map((index) => (
|
||||
<Bar lengthInBars={pattern.bars} barLength={barLength} index={index}>
|
||||
{pattern.notes.map((note) => (
|
||||
<SingleNote key={`${note.note}-${note.position}`} note={note} />
|
||||
))}
|
||||
</Bar>
|
||||
))
|
||||
: null}
|
||||
{pattern.bars > 0 && sceneData.maxBars % pattern.bars !== 0 ? (
|
||||
<Bar lengthInBars={pattern.bars} barLength={barLength} index={0}>
|
||||
{pattern.notes.map((note) => (
|
||||
<SingleNote key={`${note.note}-${note.position}`} note={note} />
|
||||
))}
|
||||
</Bar>
|
||||
) : null}
|
||||
</Track>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{transformedData.scenes.length === 0 && (
|
||||
<div className="w-full h-full flex items-center justify-center">No arrangements found</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Arrangements;
|
||||
27
src/routes/home/ProjectView/Bar.tsx
Normal file
27
src/routes/home/ProjectView/Bar.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import clsx from 'clsx';
|
||||
import { ComponentChild as ReactNode } from 'preact';
|
||||
|
||||
export function Bar({
|
||||
children,
|
||||
lengthInBars,
|
||||
barLength,
|
||||
index,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
lengthInBars: number;
|
||||
barLength: number;
|
||||
index: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={clsx('h-full relative overflow-hidden', {
|
||||
hidden: lengthInBars === 0,
|
||||
'bg-red-500/10': index % 2 === 0,
|
||||
'bg-blue-500/10': index % 2 === 1,
|
||||
})}
|
||||
style={{ width: lengthInBars * barLength * 24 }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
src/routes/home/ProjectView/SceneName.tsx
Normal file
91
src/routes/home/ProjectView/SceneName.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import IconSave from '~/components/icons/check.svg?react';
|
||||
import IconCancel from '~/components/icons/close.svg?react';
|
||||
import IconEdit from '~/components/icons/edit.svg?react';
|
||||
import Button from '~/components/ui/Button.tsx';
|
||||
import Input from '~/components/ui/Input';
|
||||
import useSceneName, { getDefaultSceneName } from '~/hooks/useSceneName';
|
||||
|
||||
function SceneName({ projectId, defaultName }: { projectId: string; defaultName: string }) {
|
||||
const { sceneName, setSceneName } = useSceneName(projectId, defaultName);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [temporalSceneName, setTemporalSceneName] = useState(sceneName);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleCancel = () => {
|
||||
setTemporalSceneName(sceneName);
|
||||
setIsRenaming(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setSceneName(temporalSceneName);
|
||||
setIsRenaming(false);
|
||||
|
||||
if (temporalSceneName.trim() === '') {
|
||||
setTemporalSceneName(getDefaultSceneName(defaultName));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = (e: Event) => {
|
||||
setTemporalSceneName((e.target as HTMLInputElement).value);
|
||||
};
|
||||
|
||||
const handleRenameClick = () => {
|
||||
setTemporalSceneName(sceneName);
|
||||
setIsRenaming(true);
|
||||
};
|
||||
|
||||
const handleKeyboard = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenaming && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isRenaming]);
|
||||
|
||||
return (
|
||||
<div className="bg-brand p-2 w-full flex">
|
||||
<div className="font-semibold w-fit text-white gap-2 flex sticky left-0">
|
||||
{isRenaming ? (
|
||||
<>
|
||||
<Input
|
||||
type="text"
|
||||
value={temporalSceneName}
|
||||
onChange={handleRename}
|
||||
className="text-black text-sm py-0 h-full"
|
||||
ref={inputRef}
|
||||
onKeyDown={handleKeyboard}
|
||||
/>
|
||||
<Button variant="ghost" size="xs" onClick={handleSave} title="Save">
|
||||
<IconSave className="w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="xs" onClick={handleCancel} title="Cancel">
|
||||
<IconCancel className="w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-hidden truncate max-w-75" title={sceneName}>
|
||||
{sceneName}
|
||||
</div>
|
||||
<Button variant="ghost" size="xs" onClick={handleRenameClick} title="Rename">
|
||||
<IconEdit className="w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SceneName;
|
||||
16
src/routes/home/ProjectView/SingleNote.tsx
Normal file
16
src/routes/home/ProjectView/SingleNote.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ViewNote } from '~/lib/transformers/webView';
|
||||
|
||||
export function SingleNote({ note }: { note: ViewNote }) {
|
||||
return (
|
||||
<div
|
||||
className="p-1 rounded-sm bg-[#192a3c] h-10 text-white text-[8px] border-l border-white absolute overflow-hidden truncate"
|
||||
style={{
|
||||
left: note.position,
|
||||
width: Math.max(note.duration, 2),
|
||||
zIndex: note.position,
|
||||
}}
|
||||
>
|
||||
{note.duration > 16 ? note.name : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
src/routes/home/ProjectView/Track.tsx
Normal file
14
src/routes/home/ProjectView/Track.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ComponentChild as ReactNode } from 'preact';
|
||||
|
||||
function Track({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="relative h-[40px] bg-gray-200 full-w bg-[url(/track-bg.svg)] bg-repeat-x flex"
|
||||
style={{ backgroundSize: 'auto 100%' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Track;
|
||||
31
src/routes/home/ProjectView/TrackMeta.tsx
Normal file
31
src/routes/home/ProjectView/TrackMeta.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ViewPattern } from '~/lib/transformers/webView';
|
||||
import { getPadDisplayName } from '~/lib/utils';
|
||||
|
||||
function TrackMeta({ pattern }: { pattern: ViewPattern }) {
|
||||
let name = pattern.soundName;
|
||||
|
||||
if (!name && pattern.midiChannel) {
|
||||
name = `[MIDI ch. ${pattern.midiChannel}]`;
|
||||
}
|
||||
|
||||
if (!name && pattern.soundId >= 1000) {
|
||||
name = `[SUPERTONE]`;
|
||||
}
|
||||
|
||||
const isEmptyWithSample = pattern.notes.length === 0 && pattern.soundId > 0;
|
||||
const padDisplay = getPadDisplayName(pattern.group, pattern.padNumber);
|
||||
|
||||
return (
|
||||
<div className="p-2 h-10 bg-[#b0babe] rounded w-[200px] flex items-center gap-2 ">
|
||||
<div className="shrink-0 text-sm capitalize whitespace-nowrap font-bold size-[28px] gap-0.5 bg-[#9ba4a7] rounded-sm flex justify-center items-center">
|
||||
<span className="text-white/80">{padDisplay[0]}</span>
|
||||
<span className="text-white">{padDisplay.slice(2)}</span>
|
||||
</div>
|
||||
<div className={`truncate text-sm ${isEmptyWithSample ? 'text-gray-500' : ''}`} title={name}>
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TrackMeta;
|
||||
48
src/routes/home/RiddimDialog.tsx
Normal file
48
src/routes/home/RiddimDialog.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { deviceSkuAtom } from '~/atoms/deviceSku';
|
||||
import Button from '~/components/ui/Button';
|
||||
import { SKU_EP40 } from '~/lib/constants';
|
||||
import Dialog from '../../components/ui/Dialog';
|
||||
|
||||
function FeedbackDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const deviceSku = useAtomValue(deviceSkuAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (deviceSku && !localStorage.getItem('riddim-dialog-shown') && deviceSku === SKU_EP40) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [deviceSku]);
|
||||
|
||||
const handleClose = () => {
|
||||
localStorage.setItem('riddim-dialog-shown', 'true');
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog isOpen={open} onClose={handleClose}>
|
||||
<div className="flex flex-col gap-4 min-w-150">
|
||||
<img src="/riddim.png" alt="Riddim Logo" className="w-20" />
|
||||
<p>Hey, looks like you are using EP-40 Riddim!</p>
|
||||
<p>
|
||||
Unfortunately not all features are supported at the moment. Since I don't own the real
|
||||
device I can only explore the backups and blindly guess how things work.
|
||||
</p>
|
||||
<p>
|
||||
If you would like to help me improve the support for EP-40 Riddim, please share your
|
||||
experience with the current implementation via feedback form or email.
|
||||
</p>
|
||||
<p>And if you like the project, please consider donating!</p>
|
||||
<p>Thank you for your understanding and support.</p>
|
||||
<div className="flex gap-4 justify-end mt-6">
|
||||
<Button onClick={() => setOpen(false)} variant="secondary">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default FeedbackDialog;
|
||||
Reference in New Issue
Block a user