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

75
src/hooks/useAppState.ts Normal file
View File

@@ -0,0 +1,75 @@
import { useAtomValue } from 'jotai';
import { useMemo } from 'preact/hooks';
import { droppedBackupFileAtom } from '~/atoms/droppedProjectFile';
import { projectIdAtom } from '../atoms/project';
import useDevice from './useDevice';
import useProject from './useProject';
export const APP_STATES = {
CAN_DISPLAY_PROJECT: 'CAN_DISPLAY_PROJECT',
NO_DEVICE_CONNECTED: 'NO_DEVICE_CONNECTED',
ERROR: 'ERROR',
LOADING: 'LOADING',
NO_PROJECT_SELECTED: 'NO_PROJECT_SELECTED',
CAN_SELECT_PROJECT: 'CAN_SELECT_PROJECT',
CAN_RELOAD_PROJECT: 'CAN_RELOAD_PROJECT',
DEVICE_READY: 'DEVICE_READY',
CAN_EXPORT_PROJECT: 'CAN_EXPORT_PROJECT',
HAS_BACKUP_FILE: 'HAS_BACKUP_FILE',
};
export function useAppState() {
const projectId = useAtomValue(projectIdAtom);
const { isLoading: isLoadingProject, isRefetching } = useProject(projectId);
const { device, error } = useDevice();
const droppedBackupFile = useAtomValue(droppedBackupFileAtom);
const isLoading = isLoadingProject || isRefetching;
const noDevice = !device;
const hasBackupFile = !!droppedBackupFile;
return useMemo(() => {
const finalState = [];
if (hasBackupFile) {
finalState.push(APP_STATES.HAS_BACKUP_FILE);
}
if (projectId && !isLoading && (!noDevice || hasBackupFile)) {
finalState.push(APP_STATES.CAN_DISPLAY_PROJECT);
}
if (noDevice && !error && !hasBackupFile) {
finalState.push(APP_STATES.NO_DEVICE_CONNECTED);
}
if (error) {
finalState.push(APP_STATES.ERROR);
}
if (isLoading) {
finalState.push(APP_STATES.LOADING);
}
if (!projectId && !isLoading && (!noDevice || hasBackupFile)) {
finalState.push(APP_STATES.NO_PROJECT_SELECTED);
}
if (!noDevice && !isLoading) {
finalState.push(APP_STATES.CAN_SELECT_PROJECT);
}
if (hasBackupFile && !isLoading) {
finalState.push(APP_STATES.CAN_SELECT_PROJECT);
}
if (projectId && (!noDevice || hasBackupFile) && !isLoading) {
if (!hasBackupFile) {
finalState.push(APP_STATES.CAN_RELOAD_PROJECT);
}
finalState.push(APP_STATES.CAN_EXPORT_PROJECT);
}
return finalState;
}, [isLoading, noDevice, projectId, error, hasBackupFile]);
}

View File

@@ -0,0 +1,46 @@
import { useAtom } from 'jotai';
import { useCallback } from 'preact/hooks';
import { customSceneNamesByProjectAtom } from '~/atoms/project';
function checkHasCustomSceneNames(projectPrefix: string): boolean {
if (projectPrefix === 'p') {
return false;
}
try {
return Object.keys(localStorage).some((key) => key.startsWith(projectPrefix));
} catch (error) {
console.warn(`Error checking custom scene names:`, error);
return false;
}
}
function useCustomSceneNames(projectId: string) {
const [customSceneNamesByProject, setCustomSceneNamesByProject] = useAtom(
customSceneNamesByProjectAtom,
);
const projectPrefix = `p${projectId}`;
const hasCustomNames =
customSceneNamesByProject[projectId] ?? checkHasCustomSceneNames(projectPrefix);
const syncCustomSceneNames = useCallback(() => {
setCustomSceneNamesByProject((prev) => ({
...prev,
[projectId]: checkHasCustomSceneNames(projectPrefix),
}));
}, [projectId, projectPrefix, setCustomSceneNamesByProject]);
const setHasCustomNames = useCallback(
(value: boolean) => {
setCustomSceneNamesByProject((prev) => ({
...prev,
[projectId]: value,
}));
},
[projectId, setCustomSceneNamesByProject],
);
return { hasCustomNames, setHasCustomNames, syncCustomSceneNames };
}
export default useCustomSceneNames;

14
src/hooks/useDevice.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { useContext } from 'preact/hooks';
import DeviceContext from '../components/DeviceContext';
function useDevice() {
const context = useContext(DeviceContext);
if (context === undefined) {
throw new Error('useDevice must be used within a DeviceProvider');
}
return context;
}
export default useDevice;

123
src/hooks/useDroppedFile.ts Normal file
View File

@@ -0,0 +1,123 @@
import { useAtom } from 'jotai';
import JSZip from 'jszip';
import type { DropTargetMonitor } from 'react-dnd';
import { useDrop } from 'react-dnd';
import { NativeTypes } from 'react-dnd-html5-backend';
import {
backupProjectIdsAtom,
backupSkuAtom,
droppedBackupFileAtom,
droppedProjectFileAtom,
unzippedBackupAtom,
} from '~/atoms/droppedProjectFile';
import { projectIdAtom } from '~/atoms/project';
import { store } from '~/lib/store';
import { showToast } from '~/lib/toast';
import useDevice from './useDevice';
export const DROPPED_FILE_ID = '10';
function useDroppedFile() {
const { device } = useDevice();
const [, setProjectId] = useAtom(projectIdAtom);
const [, setDroppedProjectFile] = useAtom(droppedProjectFileAtom);
const [, setDroppedBackupFile] = useAtom(droppedBackupFileAtom);
const [, setBackupSku] = useAtom(backupSkuAtom);
const [, setBackupProjectIds] = useAtom(backupProjectIdsAtom);
const [{ isOver }, dropRef] = useDrop(
() => ({
accept: [NativeTypes.FILE],
drop: async (_item: unknown, monitor: DropTargetMonitor) => {
const item = monitor.getItem() as { files?: File[] };
const file = item?.files?.[0];
if (!file) {
return;
}
const fileNameLower = file.name.toLowerCase();
if (
!(
fileNameLower.endsWith('.tar') ||
fileNameLower.endsWith('.ppak') ||
fileNameLower.endsWith('.pak')
)
) {
showToast('Unsupported file. Use a .tar or .ppak. or .pak', 'error');
return;
}
if (!device && fileNameLower.endsWith('.tar')) {
showToast(
'Connect a device to load .tar files, or use a .pak/.ppak backup file',
'error',
);
return;
}
if (device && (fileNameLower.endsWith('.pak') || fileNameLower.endsWith('.ppak'))) {
showToast('Disconnect the device to use a .pak/.ppak backup file', 'error');
return;
}
try {
const buf = await file.arrayBuffer();
if (fileNameLower.endsWith('.tar')) {
setDroppedProjectFile({ name: file.name, data: new Uint8Array(buf) });
setDroppedBackupFile(null);
store.set(unzippedBackupAtom, null);
setBackupSku(null);
setBackupProjectIds([]);
setProjectId(DROPPED_FILE_ID);
showToast(`Added "${file.name}" to projects`, 'info');
} else if (fileNameLower.endsWith('.pak') || fileNameLower.endsWith('.ppak')) {
const backupData = new Uint8Array(buf);
setDroppedBackupFile(backupData);
setDroppedProjectFile(null);
const unzipped = await JSZip.loadAsync(backupData);
store.set(unzippedBackupAtom, unzipped);
// Read backup metadata
const metaFile = unzipped.file('meta.json');
if (metaFile) {
try {
const metaText = await metaFile.async('text');
const meta = JSON.parse(metaText);
const sku = meta.device_sku || meta.base_sku || null;
setBackupSku(sku);
} catch {
setBackupSku(null);
}
} else {
setBackupSku(null);
}
// Scan for available projects
const projectIds: number[] = [];
for (const zipFile of Object.values(unzipped.files)) {
const match = zipFile.name.match(/\/projects\/P(\d{2})\.tar$/);
if (match) {
projectIds.push(Number(match[1]));
}
}
setBackupProjectIds(projectIds.sort((a, b) => a - b));
showToast(`Loaded backup file "${file.name}"`, 'info');
}
} catch {
showToast('Failed to read dropped file', 'error');
}
},
collect: (monitor: DropTargetMonitor) => ({
isOver: monitor.isOver(),
}),
}),
[device, setDroppedProjectFile, setProjectId, setBackupSku, setBackupProjectIds],
);
return { dropRef, isOver };
}
export default useDroppedFile;

View File

@@ -0,0 +1,195 @@
import * as Sentry from '@sentry/react';
import { useAtomValue } from 'jotai';
import { useState } from 'preact/hooks';
import { projectIdAtom } from '../atoms/project';
import { trackEvent } from '../lib/ga';
import { AbortError } from '../lib/utils';
import {
ExporterParams,
ExportFormat,
ExportFormatId,
ExportResult,
ExportStatus,
ProjectRawData,
SampleReport,
} from '../types/types';
import useProject from './useProject';
import { getCustomSceneNames } from './useSceneName';
export const EXPORT_FORMATS: ExportFormat[] = [
{
name: 'Ableton 11+',
value: 'ableton',
},
{
name: 'DAWproject',
value: 'dawproject',
},
{
name: 'MIDI',
value: 'midi',
},
{
name: 'REAPER',
value: 'reaper',
},
];
async function getExporterFn(
format: ExportFormatId,
): Promise<
(
projectId: string,
data: any,
progressCallback: any,
exporterParams: ExporterParams,
abortSignal: AbortSignal,
) => Promise<any>
> {
switch (format) {
case 'ableton':
return (await import('../lib/exporters/ableton')).default;
case 'dawproject':
return (await import('../lib/exporters/dawProject')).default;
case 'midi':
return (await import('../lib/exporters/midi')).default;
case 'reaper':
return (await import('../lib/exporters/reaper')).default;
default:
throw new Error(`Unknown export format: ${format}`);
}
}
function filterScenes(data: ProjectRawData, params: ExporterParams): ProjectRawData {
if (params.allScenes) {
return data;
}
return {
...data,
scenes: data.scenes.filter((scene) => params.selectedScenes?.includes(scene.name)),
};
}
function useExportProject() {
const projectId = useAtomValue(projectIdAtom);
const { data: projectRawData } = useProject(projectId);
const [isPending, setIsPending] = useState(false);
const [pendingStatus, setPendingStatus] = useState('');
const [percentage, setPercentage] = useState(0);
const [error, setError] = useState<any>(null);
const [result, setResult] = useState<ExportResult | null>(null);
const [sampleReport, setSampleReport] = useState<SampleReport | null>(null);
const [abortController, setAbortController] = useState<AbortController | null>(null);
const startExport = async (params: ExporterParams & { format: ExportFormatId }) => {
const { format, ...exporterParams } = params;
trackEvent('export_start', {
format,
});
try {
const formatData = EXPORT_FORMATS.find((f) => f.value === format);
if (!formatData || !projectRawData) {
return;
}
setPendingStatus('Starting export...');
setError(null);
setIsPending(true);
setPercentage(1);
const controller = new AbortController();
setAbortController(controller);
const exportFn = await getExporterFn(format);
const filteredData = filterScenes(projectRawData, exporterParams);
const customSceneNames = getCustomSceneNames(
projectId,
filteredData.scenes.map((s) => s.name),
);
const result = await exportFn(
projectId,
filteredData,
(stat: ExportStatus) => {
setPercentage(stat.progress);
setPendingStatus(stat.status);
},
{ ...exporterParams, customSceneNames },
controller.signal,
);
setResult(result);
setSampleReport(result.sampleReport || null);
setIsPending(false);
setAbortController(null);
trackEvent('export_end');
} catch (err) {
if (err instanceof AbortError) {
setPendingStatus('Export cancelled');
setIsPending(false);
setAbortController(null);
return;
}
console.error(err);
Sentry.captureException(err);
setError(err);
setIsPending(false);
setAbortController(null);
trackEvent('export_error');
}
};
const cancelExport = () => {
if (abortController) {
abortController.abort();
setAbortController(null);
}
};
const reset = () => {
setError(null);
setIsPending(false);
setPercentage(0);
setPendingStatus('');
setSampleReport(null);
if (abortController) {
abortController.abort();
setAbortController(null);
}
if (!result) {
return;
}
for (const file of result.files) {
URL.revokeObjectURL(file.url);
}
setResult(null);
};
return {
startExport,
cancelExport,
reset,
isPending,
pendingStatus,
percentage,
result,
error,
sampleReport,
};
}
export default useExportProject;

View File

@@ -0,0 +1,54 @@
import { useEffect } from 'preact/hooks';
import { UseFormReturn, useWatch } from 'react-hook-form';
import { ZodType } from 'zod';
const STORAGE_KEY = 'export_form';
function pick<T extends Record<string, any>>(obj: T, keys: (keyof T)[]): Partial<T> {
const result: Partial<T> = {};
for (const key of keys) {
if (key in obj) {
result[key] = obj[key];
}
}
return result;
}
export function usePersistedFormValues<T extends Record<string, any>>(
form: UseFormReturn<T>,
persistedFields: (keyof T)[],
schema?: ZodType<T>,
) {
useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
const picked = pick(parsed, persistedFields);
if (schema) {
const result = schema.safeParse({ ...form.getValues(), ...picked });
if (result.success) {
form.reset(result.data as T);
} else {
localStorage.removeItem(STORAGE_KEY);
}
} else {
form.reset({ ...form.getValues(), ...picked } as T);
}
}
} catch (e) {
console.warn('Failed to load persisted form values:', e);
}
}, []);
const values = useWatch({ control: form.control });
useEffect(() => {
try {
const toSave = pick(values as T, persistedFields);
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
} catch (e) {
console.warn('Failed to persist form values:', e);
}
}, [values]);
}

View File

@@ -0,0 +1,27 @@
import { useState } from 'preact/hooks';
function usePersistedState<T>(key: string, defaultValue: T): [T, (value: T) => void] {
const [state, setState] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return defaultValue;
}
});
const setPersistedState = (value: T) => {
try {
setState(value);
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.warn(`Error writing to localStorage key "${key}":`, error);
setState(value);
}
};
return [state, setPersistedState];
}
export default usePersistedState;

111
src/hooks/useProject.ts Normal file
View File

@@ -0,0 +1,111 @@
import { useQuery } from '@tanstack/react-query';
import { useAtomValue } from 'jotai';
import JSZip from 'jszip';
import { deviceSkuAtom } from '~/atoms/deviceSku';
import {
backupSkuAtom,
droppedBackupFileAtom,
droppedProjectFileAtom,
unzippedBackupAtom,
} from '~/atoms/droppedProjectFile';
import { getProjectFile } from '../lib/midi';
import {
collectEffects,
collectPads,
collectScenesAndPatterns,
collectScenesSettings,
collectSettings,
collectSounds,
loadSoundsFromBackup,
} from '../lib/parsers';
import { store } from '../lib/store';
import { showToast } from '../lib/toast';
import { untar } from '../lib/untar';
import { ProjectRawData } from '../types/types';
import useDevice from './useDevice';
import { DROPPED_FILE_ID } from './useDroppedFile';
function useProject(id?: number | string) {
const { device } = useDevice();
const deviceSku = useAtomValue(deviceSkuAtom);
const droppedProjectFile = useAtomValue(droppedProjectFileAtom);
const droppedBackupFile = useAtomValue(droppedBackupFileAtom);
const backupSku = useAtomValue(backupSkuAtom);
const result = useQuery<ProjectRawData | null>({
queryKey: ['project', id],
queryFn: async () => {
if (!id) {
return null;
}
if (!droppedBackupFile && !device) {
return null;
}
let archiveData: Uint8Array | undefined;
let unzippedBackup: JSZip | undefined;
try {
if (droppedBackupFile) {
unzippedBackup = store.get(unzippedBackupAtom) ?? undefined;
if (!unzippedBackup) {
unzippedBackup = await JSZip.loadAsync(droppedBackupFile);
}
const projectFile = unzippedBackup.file(`/projects/P${String(id).padStart(2, '0')}.tar`);
if (!projectFile) {
throw new Error('No project file in backup');
}
const projectFileData = await projectFile.async('uint8array');
archiveData = projectFileData;
} else if (id === DROPPED_FILE_ID && droppedProjectFile) {
archiveData = droppedProjectFile.data;
} else {
const archive = await getProjectFile(Number(id));
archiveData = archive.data;
}
const files = await untar(archiveData);
const sounds = unzippedBackup
? await loadSoundsFromBackup(unzippedBackup, files)
: await collectSounds(files);
const settings = collectSettings(files);
const pads = collectPads(files, sounds);
const scenes = collectScenesAndPatterns(files, device?.sku || backupSku || deviceSku);
const scenesSettings = collectScenesSettings(files);
const effects = collectEffects(files);
// @ts-expect-error wrong typing?
const projectFileBlob = new Blob([archiveData]);
const projectFile = new File(
[projectFileBlob],
`project-${String(id).padStart(2, '0')}.tar`,
{
type: 'application/x-tar',
lastModified: Date.now(),
},
);
return {
pads,
scenes,
settings,
sounds,
effects,
projectFile,
scenesSettings,
};
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load project';
showToast(message, 'error');
return null;
}
},
retry: false,
enabled: !!id && (!!device || !!droppedBackupFile),
});
return result;
}
export default useProject;

View File

@@ -0,0 +1,42 @@
import { useAtomValue } from 'jotai';
import {
backupProjectIdsAtom,
droppedBackupFileAtom,
droppedProjectFileAtom,
} from '~/atoms/droppedProjectFile';
import useDevice from './useDevice';
import { DROPPED_FILE_ID } from './useDroppedFile';
export type ProjectOption = {
value: string;
label: string;
};
function useProjectsList(): ProjectOption[] {
const { device } = useDevice();
const droppedProjectFile = useAtomValue(droppedProjectFileAtom);
const droppedBackupFile = useAtomValue(droppedBackupFileAtom);
const backupProjectIds = useAtomValue(backupProjectIdsAtom);
const base: ProjectOption[] = [];
if (device) {
for (let i = 1; i <= 9; i++) {
base.push({ value: String(i), label: `Project ${i}` });
}
} else if (droppedBackupFile) {
if (backupProjectIds.length > 0) {
for (const id of backupProjectIds) {
base.push({ value: String(id), label: `Project ${id}` });
}
}
}
if (droppedProjectFile) {
base.push({ value: DROPPED_FILE_ID, label: droppedProjectFile.name });
}
return base;
}
export default useProjectsList;

122
src/hooks/useSceneName.ts Normal file
View File

@@ -0,0 +1,122 @@
import { useCallback, useEffect } from 'preact/hooks';
import useCustomSceneNames from '~/hooks/useCustomSceneNames';
import usePersistedState from '~/hooks/usePersistedState.ts';
function getSceneNameKey(name: string) {
return `_s${name}_scene_name`;
}
export function getDefaultSceneName(name: string) {
return `SCENE ${name}`;
}
export function getCustomSceneNames(
projectId: string,
sceneNames: string[],
): Record<string, string> {
const projectPrefix = `p${projectId}`;
const result: Record<string, string> = {};
for (const sceneName of sceneNames) {
const defaultName = getDefaultSceneName(sceneName);
const key = projectPrefix + getSceneNameKey(sceneName);
try {
const storedValue = localStorage.getItem(key);
if (storedValue) {
const parsed = JSON.parse(storedValue);
if (parsed) {
result[sceneName] = parsed;
continue;
}
}
} catch {
// ignore
}
result[sceneName] = defaultName;
}
return result;
}
function useSceneName(projectId: string, originalName: string = '') {
const projectPrefix = `p${projectId}`;
const key = projectPrefix + getSceneNameKey(originalName);
const defaultName = getDefaultSceneName(originalName);
const [sceneName, setSceneNameState] = usePersistedState<string>(key, defaultName);
const { hasCustomNames, setHasCustomNames, syncCustomSceneNames } =
useCustomSceneNames(projectId);
useEffect(() => {
if (originalName === '') {
return;
}
if (!hasCustomNames) {
setSceneNameState(defaultName);
try {
localStorage.removeItem(key);
} catch (error) {
console.warn(`Error removing localStorage key "${key}":`, error);
}
}
}, [defaultName, hasCustomNames, key, originalName, setSceneNameState]);
const setSceneName = useCallback(
(value: string) => {
const normalizedValue = value.trim() === '' ? defaultName : value.trim();
setSceneNameState(normalizedValue);
if (normalizedValue === defaultName) {
try {
localStorage.removeItem(key);
} catch (error) {
console.warn(`Error removing localStorage key "${key}":`, error);
}
}
syncCustomSceneNames();
},
[defaultName, key, setSceneNameState, syncCustomSceneNames],
);
const resetSceneNames = useCallback(() => {
try {
const keysToRemove = Object.keys(localStorage).filter((key) => key.startsWith(projectPrefix));
keysToRemove.forEach((key) => {
localStorage.removeItem(key);
});
setHasCustomNames(false);
} catch (error) {
console.warn(`Error removing localStorage keys for "${projectPrefix}":`, error);
}
}, [projectPrefix, setHasCustomNames]);
function getSceneName(name: string) {
const defaultName = getDefaultSceneName(name);
try {
const key = projectPrefix + getSceneNameKey(name);
const storedValue = localStorage.getItem(key);
if (!storedValue) {
return defaultName;
}
try {
return JSON.parse(storedValue);
} catch {
return storedValue;
}
} catch (error) {
console.warn(`Error getSceneName "${key}":`, error);
return defaultName;
}
}
return { sceneName, setSceneName, getSceneName, resetSceneNames };
}
export default useSceneName;

View File

@@ -0,0 +1,53 @@
import { useMutation } from '@tanstack/react-query';
import { useAtomValue } from 'jotai';
import ky from 'ky';
import { projectIdAtom } from '../atoms/project';
import useDevice from './useDevice';
import useProject from './useProject';
interface SubmitFeedbackParams {
description: string;
email: string;
files: File[] | null;
attachProject: boolean;
}
function useSubmitFeedback() {
const projectId = useAtomValue(projectIdAtom);
const { device } = useDevice();
const { data } = useProject(projectId);
return useMutation({
mutationFn: async (params: SubmitFeedbackParams) => {
const formData = new FormData();
formData.append('description', params.description);
formData.append('metadata', JSON.stringify(device?.metadata || {}));
if (params.email) {
formData.append('email', params.email);
}
if (params.files) {
for (let i = 0; i < params.files.length; i++) {
formData.append('files', params.files[i]);
}
}
if (params.attachProject && data?.projectFile) {
formData.append('files', data.projectFile);
}
return ky
.post(`${import.meta.env.VITE_API_URL}/support/submit`, {
body: formData,
timeout: 100_000,
})
.json();
},
retry: 10,
retryDelay: 2000,
});
}
export default useSubmitFeedback;

28
src/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,28 @@
import { useAtomValue } from 'jotai';
import { useMemo } from 'preact/hooks';
import { useSearch } from 'wouter-preact';
import { deviceSkuAtom } from '~/atoms/deviceSku';
import { SKU_EP133, SKU_TO_NAME } from '~/lib/constants';
function useTheme(): { id: string; name: string } {
const deviceSku = useAtomValue(deviceSkuAtom);
const search = useSearch();
const theme = useMemo(() => {
const urlParams = new URLSearchParams(search);
const themeParam = urlParams.get('theme');
if (themeParam) {
const themeEntry = Object.entries(SKU_TO_NAME).find(([, value]) => value.id === themeParam);
if (themeEntry) {
return themeEntry[1];
}
}
return SKU_TO_NAME[deviceSku] || SKU_TO_NAME[SKU_EP133];
}, [deviceSku, search]);
return theme;
}
export default useTheme;