Add boilerplate files and licenses
This commit is contained in:
75
src/hooks/useAppState.ts
Normal file
75
src/hooks/useAppState.ts
Normal 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]);
|
||||
}
|
||||
46
src/hooks/useCustomSceneNames.ts
Normal file
46
src/hooks/useCustomSceneNames.ts
Normal 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
14
src/hooks/useDevice.tsx
Normal 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
123
src/hooks/useDroppedFile.ts
Normal 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;
|
||||
195
src/hooks/useExportProject.ts
Normal file
195
src/hooks/useExportProject.ts
Normal 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;
|
||||
54
src/hooks/usePersistedFormValues.ts
Normal file
54
src/hooks/usePersistedFormValues.ts
Normal 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]);
|
||||
}
|
||||
27
src/hooks/usePersistedState.ts
Normal file
27
src/hooks/usePersistedState.ts
Normal 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
111
src/hooks/useProject.ts
Normal 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;
|
||||
42
src/hooks/useProjectsList.ts
Normal file
42
src/hooks/useProjectsList.ts
Normal 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
122
src/hooks/useSceneName.ts
Normal 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;
|
||||
53
src/hooks/useSubmitFeedback.ts
Normal file
53
src/hooks/useSubmitFeedback.ts
Normal 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
28
src/hooks/useTheme.ts
Normal 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;
|
||||
Reference in New Issue
Block a user