Add boilerplate files and licenses

This commit is contained in:
2026-07-13 04:39:10 +02:00
parent 69cabda489
commit fac7e80bb7
202 changed files with 23125 additions and 0 deletions

127
src/routes/faq/Faq.tsx Normal file
View 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 doesnt 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">
Id love to. Reverse engineering DAW project formats takes a tremendous amount of time and
effort. Its easier for DAWs that uses text, XML or JSON formats (like REAPER or Ableton).
For proprietary binary formats, its 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 dont 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. Thats it. Theyve been chill
about this project so far.
</p>
</section>
</main>
);
}
export default Faq;

View 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;

View 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;

View 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 &amp; 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;

View 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;

View 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;

View 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;

View 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;

View 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',
];

View 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;

View 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
View 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
View 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;

View 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;

View 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;

View 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;

View 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>
);
}

View 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;

View 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>
);
}

View 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;

View 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;

View 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;