Files
rsgp/src/App.svelte
2025-10-13 10:58:03 +02:00

1140 lines
26 KiB
Svelte

<script lang="ts">
import { onMount } from "svelte";
import WaveformDisplay from "./lib/components/WaveformDisplay.svelte";
import VUMeter from "./lib/components/VUMeter.svelte";
import WelcomeModal from "./lib/components/WelcomeModal.svelte";
import ProcessorPopup from "./lib/components/ProcessorPopup.svelte";
import { engines } from "./lib/audio/engines/registry";
import type { SynthEngine, PitchLock } from "./lib/audio/engines/SynthEngine";
import type { EngineType } from "./lib/audio/engines/SynthEngine";
import { AudioService } from "./lib/audio/services/AudioService";
import { downloadWAV } from "./lib/audio/utils/WAVEncoder";
import { loadVolume, saveVolume, loadDuration, saveDuration, loadPitchLockEnabled, savePitchLockEnabled, loadPitchLockFrequency, savePitchLockFrequency } from "./lib/utils/settings";
import { cropAudio, cutAudio, processSelection } from "./lib/audio/utils/AudioEdit";
import { generateRandomColor } from "./lib/utils/colors";
import { getRandomProcessor } from "./lib/audio/processors/registry";
import type { AudioProcessor } from "./lib/audio/processors/AudioProcessor";
import { Sample } from "./lib/audio/engines/Sample";
import { Input } from "./lib/audio/engines/Input";
import { createKeyboardHandler } from "./lib/utils/keyboard";
import { parseFrequencyInput, formatFrequency } from "./lib/utils/pitch";
import { UndoManager, type AudioState } from "./lib/utils/UndoManager";
let currentEngineIndex = $state(0);
const engine = $derived(engines[currentEngineIndex]);
const engineType = $derived(engine.getType());
const audioService = new AudioService();
const undoManager = new UndoManager(20);
let currentParams = $state<any>(null);
let currentBuffer = $state<AudioBuffer | null>(null);
let duration = $state(loadDuration());
let volume = $state(loadVolume());
let playbackPosition = $state(-1);
let waveformColor = $state(generateRandomColor());
let showModal = $state(true);
let isProcessed = $state(false);
let showProcessorPopup = $state(false);
let popupTimeout = $state<ReturnType<typeof setTimeout> | null>(null);
let isRecording = $state(false);
let isDragOver = $state(false);
let pitchLockEnabled = $state(loadPitchLockEnabled());
let pitchLockFrequency = $state(loadPitchLockFrequency());
let pitchLockInput = $state(formatFrequency(loadPitchLockFrequency()));
let pitchLockInputValid = $state(true);
let selectionStart = $state<number | null>(null);
let selectionEnd = $state<number | null>(null);
let canUndo = $state(false);
const showDuration = $derived(engineType !== 'sample');
const showRandomButton = $derived(engineType === 'generative');
const showRecordButton = $derived(engineType === 'input');
const showFileDropZone = $derived(engineType === 'sample' && !currentBuffer);
const showMutateButton = $derived(engineType === 'generative' && !isProcessed && currentBuffer);
const showPitchLock = $derived(engineType === 'generative');
const pitchLock = $derived<PitchLock>({ enabled: pitchLockEnabled, frequency: pitchLockFrequency });
const hasSelection = $derived(selectionStart !== null && selectionEnd !== null && currentBuffer !== null);
const showEditButtons = $derived(hasSelection);
$effect(() => {
audioService.setVolume(volume);
saveVolume(volume);
});
$effect(() => {
saveDuration(duration);
});
$effect(() => {
savePitchLockEnabled(pitchLockEnabled);
});
$effect(() => {
savePitchLockFrequency(pitchLockFrequency);
});
onMount(() => {
audioService.setPlaybackUpdateCallback((position) => {
playbackPosition = position;
});
generateRandom();
});
function captureCurrentState(): AudioState | null {
return UndoManager.captureState(
currentBuffer,
currentParams,
isProcessed,
waveformColor,
currentEngineIndex
);
}
function pushState(): void {
const state = captureCurrentState();
if (state) {
undoManager.pushState(state);
canUndo = true;
}
}
function restoreState(state: AudioState): void {
currentBuffer = audioService.createAudioBuffer([state.leftChannel, state.rightChannel]);
currentParams = state.params;
isProcessed = state.isProcessed;
waveformColor = state.waveformColor;
if (state.engineIndex !== currentEngineIndex) {
currentEngineIndex = state.engineIndex;
}
clearSelection();
audioService.play(currentBuffer);
}
function undo(): void {
const previousState = undoManager.undo();
if (previousState) {
restoreState(previousState);
canUndo = undoManager.canUndo();
}
}
const keyboardHandler = createKeyboardHandler({
onMutate: mutate,
onRandom: generateRandom,
onProcess: processSound,
onDownload: download,
onUndo: undo,
onDurationDecrease: (large) => {
duration = Math.max(0.05, duration - (large ? 1 : 0.05));
},
onDurationIncrease: (large) => {
duration = Math.min(32, duration + (large ? 1 : 0.05));
},
onVolumeDecrease: (large) => {
volume = Math.max(0, volume - (large ? 0.2 : 0.05));
},
onVolumeIncrease: (large) => {
volume = Math.min(1, volume + (large ? 0.2 : 0.05));
},
onEscape: () => {
if (hasSelection) {
clearSelection();
} else if (showModal) {
closeModal();
}
},
});
function generateRandom() {
pushState();
currentParams = engine.randomParams(pitchLock);
waveformColor = generateRandomColor();
isProcessed = false;
clearSelection();
regenerateBuffer();
}
function mutate() {
if (!currentParams) {
generateRandom();
return;
}
pushState();
currentParams = engine.mutateParams(currentParams, 0.15, pitchLock);
waveformColor = generateRandomColor();
regenerateBuffer();
}
function handlePitchLockInput(event: Event) {
const input = event.target as HTMLInputElement;
pitchLockInput = input.value;
const parsed = parseFrequencyInput(pitchLockInput);
if (parsed !== null) {
pitchLockFrequency = parsed;
pitchLockInputValid = true;
} else {
pitchLockInputValid = false;
}
}
function togglePitchLock() {
pitchLockEnabled = !pitchLockEnabled;
}
function regenerateBuffer() {
if (!currentParams) return;
const sampleRate = audioService.getSampleRate();
const data = engine.generate(currentParams, sampleRate, duration);
currentBuffer = audioService.createAudioBuffer(data);
audioService.play(currentBuffer);
}
function replaySound() {
if (currentBuffer) {
audioService.play(currentBuffer);
}
}
function download() {
if (!currentBuffer) return;
downloadWAV(currentBuffer, "synth-sound.wav");
}
function processSound() {
if (!currentBuffer) return;
applyProcessor(getRandomProcessor());
}
async function applyProcessor(processor: AudioProcessor) {
if (!currentBuffer) return;
pushState();
let processedLeft: Float32Array;
let processedRight: Float32Array;
if (hasSelection) {
const start = Math.min(selectionStart!, selectionEnd!);
const end = Math.max(selectionStart!, selectionEnd!);
const sampleRate = audioService.getSampleRate();
[processedLeft, processedRight] = await processSelection(
currentBuffer,
start,
end,
processor,
sampleRate
);
} else {
const leftChannel = currentBuffer.getChannelData(0);
const rightChannel = currentBuffer.getChannelData(1);
[processedLeft, processedRight] = await processor.process(leftChannel, rightChannel);
}
currentBuffer = audioService.createAudioBuffer([processedLeft, processedRight]);
isProcessed = true;
audioService.play(currentBuffer);
hideProcessorPopup();
}
function switchEngine(index: number) {
pushState();
currentEngineIndex = index;
currentBuffer = null;
currentParams = null;
isProcessed = false;
clearSelection();
if (engineType === 'generative') {
generateRandom();
}
}
async function handleFileInput(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files?.length) return;
await loadAudioFile(input.files[0]);
}
async function loadAudioFile(file: File) {
if (!(engine instanceof Sample)) return;
pushState();
try {
await engine.loadFile(file);
currentParams = engine.randomParams(pitchLock);
waveformColor = generateRandomColor();
isProcessed = false;
regenerateBuffer();
} catch (error) {
console.error('Failed to load audio file:', error);
alert(`Failed to load audio file: ${error}`);
}
}
async function recordAudio() {
if (!(engine instanceof Input) || isRecording) return;
pushState();
try {
isRecording = true;
await engine.record(duration);
currentParams = engine.randomParams(pitchLock);
waveformColor = generateRandomColor();
isProcessed = false;
regenerateBuffer();
} catch (error) {
console.error('Failed to record audio:', error);
alert(`Failed to record audio: ${error}`);
} finally {
isRecording = false;
}
}
function handleDrop(event: DragEvent) {
event.preventDefault();
isDragOver = false;
const files = event.dataTransfer?.files;
if (!files?.length) return;
const file = files[0];
if (!file.type.startsWith('audio/')) {
alert('Please drop an audio file');
return;
}
loadAudioFile(file);
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
isDragOver = true;
}
function handleDragLeave(event: DragEvent) {
event.preventDefault();
isDragOver = false;
}
function showPopup() {
if (popupTimeout) clearTimeout(popupTimeout);
showProcessorPopup = true;
popupTimeout = setTimeout(() => showProcessorPopup = false, 2000);
}
function scheduleHidePopup() {
if (popupTimeout) clearTimeout(popupTimeout);
popupTimeout = setTimeout(() => showProcessorPopup = false, 200);
}
function hideProcessorPopup() {
if (popupTimeout) clearTimeout(popupTimeout);
showProcessorPopup = false;
}
function handleSelectionChange(start: number | null, end: number | null) {
selectionStart = start;
selectionEnd = end;
}
function clearSelection() {
selectionStart = null;
selectionEnd = null;
}
function cropSelection() {
if (!currentBuffer || selectionStart === null || selectionEnd === null) return;
pushState();
const start = Math.min(selectionStart, selectionEnd);
const end = Math.max(selectionStart, selectionEnd);
const sampleRate = audioService.getSampleRate();
const [newLeft, newRight] = cropAudio(currentBuffer, start, end, sampleRate);
currentBuffer = audioService.createAudioBuffer([newLeft, newRight]);
clearSelection();
audioService.play(currentBuffer);
}
function cutSelection() {
if (!currentBuffer || selectionStart === null || selectionEnd === null) return;
pushState();
const start = Math.min(selectionStart, selectionEnd);
const end = Math.max(selectionStart, selectionEnd);
const sampleRate = audioService.getSampleRate();
const [newLeft, newRight] = cutAudio(currentBuffer, start, end, sampleRate);
currentBuffer = audioService.createAudioBuffer([newLeft, newRight]);
clearSelection();
audioService.play(currentBuffer);
}
async function closeModal() {
showModal = false;
await audioService.initialize();
}
</script>
<svelte:window onkeydown={keyboardHandler} />
<div class="container">
<div class="top-bar">
<div class="mode-buttons">
{#each engines as currentEngine, index}
<button
class="engine-button"
class:active={currentEngineIndex === index}
data-description={currentEngine.getDescription()}
onclick={() => switchEngine(index)}
>
{currentEngine.getName()}
</button>
{/each}
</div>
<div class="controls-group">
{#if showPitchLock}
<div class="control-item pitch-lock-control">
<div class="control-header">
<label for="pitch">Pitch</label>
<label class="custom-checkbox" title="Lock pitch across random/mutate">
<input
type="checkbox"
bind:checked={pitchLockEnabled}
/>
<span class="checkbox-box" class:checked={pitchLockEnabled}>
{#if pitchLockEnabled}
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M1 5L4 8L9 2" stroke="currentColor" stroke-width="2" stroke-linecap="square"/>
</svg>
{/if}
</span>
<span class="checkbox-text">Lock</span>
</label>
</div>
<input
id="pitch"
type="text"
class="pitch-input"
class:locked={pitchLockEnabled}
class:invalid={!pitchLockInputValid}
value={pitchLockInput}
oninput={handlePitchLockInput}
placeholder="440 or A4"
title="Enter frequency (Hz) or note (e.g., A4, C#3)"
/>
</div>
{/if}
{#if showDuration}
<div class="control-item">
<div class="control-header">
<label for="duration">Duration</label>
<span class="control-value-display">{duration.toFixed(2)}s</span>
</div>
<input
id="duration"
type="range"
min="0.05"
max="32"
step="0.01"
bind:value={duration}
/>
</div>
{/if}
<div class="control-item">
<div class="control-header">
<label for="volume">Volume</label>
<span class="control-value-display">{Math.round(volume * 100)}%</span>
</div>
<input
id="volume"
type="range"
min="0"
max="1"
step="0.01"
bind:value={volume}
/>
</div>
</div>
</div>
<div class="main-area">
<div class="waveform-container">
{#if showFileDropZone}
<div
class="file-drop-zone"
class:drag-over={isDragOver}
role="button"
tabindex="0"
ondrop={handleDrop}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
>
<div class="drop-zone-content">
<h2>Drop an audio file here</h2>
<label for="file-input" class="file-input-label">
<input
id="file-input"
type="file"
accept="audio/*"
onchange={handleFileInput}
style="display: none;"
/>
Choose a file
</label>
</div>
</div>
{:else}
<WaveformDisplay
buffer={currentBuffer}
color={waveformColor}
{playbackPosition}
{selectionStart}
{selectionEnd}
onselectionchange={handleSelectionChange}
onclick={replaySound}
/>
{/if}
<div class="bottom-controls">
{#if showRandomButton}
<button onclick={generateRandom}>Random (R)</button>
{/if}
{#if showRecordButton}
<button onclick={recordAudio} disabled={isRecording}>
{isRecording ? 'Recording...' : 'Record'}
</button>
{/if}
{#if showMutateButton}
<button onclick={mutate}>Mutate (M)</button>
{/if}
{#if showEditButtons}
<button onclick={cropSelection}>Crop</button>
<button onclick={cutSelection}>Cut</button>
{/if}
{#if currentBuffer}
<div
class="process-button-container"
role="group"
onmouseenter={showPopup}
onmouseleave={scheduleHidePopup}
>
<button onclick={processSound}>Process (P)</button>
{#if showProcessorPopup}
<ProcessorPopup onselect={applyProcessor} />
{/if}
</div>
<button onclick={download}>Download (D)</button>
<button onclick={undo} disabled={!canUndo}>Undo (Z)</button>
{/if}
</div>
</div>
<div class="vu-meter-container">
<VUMeter buffer={currentBuffer} {playbackPosition} />
</div>
</div>
{#if showModal}
<WelcomeModal onclose={closeModal} />
{/if}
</div>
<style>
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.top-bar {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem;
background-color: #1a1a1a;
border-bottom: 1px solid #333;
}
.mode-buttons {
display: flex;
gap: 0.5rem;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: #444 transparent;
padding-bottom: 0.25rem;
}
.mode-buttons::-webkit-scrollbar {
height: 4px;
}
.mode-buttons::-webkit-scrollbar-track {
background: transparent;
}
.mode-buttons::-webkit-scrollbar-thumb {
background: #444;
border-radius: 0;
}
.engine-button {
opacity: 0.7;
position: relative;
flex-shrink: 0;
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
white-space: nowrap;
min-width: calc(25vw - 1rem);
}
.engine-button:hover {
z-index: 1001;
}
.engine-button.active {
opacity: 1;
border-color: #646cff;
}
.engine-button::after {
content: attr(data-description);
position: absolute;
top: 100%;
left: 0;
padding: 0.5rem 0.75rem;
background-color: #0a0a0a;
border: 1px solid #444;
color: #ccc;
font-size: 0.85rem;
width: 200px;
max-width: 90vw;
white-space: normal;
word-wrap: break-word;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
z-index: 1000;
display: none;
}
.controls-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
}
.control-item {
display: flex;
flex-direction: column;
gap: 0.35rem;
background-color: #0f0f0f;
padding: 0.5rem 0.65rem;
border: 1px solid #2a2a2a;
transition: border-color 0.2s;
}
.control-item:hover {
border-color: #3a3a3a;
}
.control-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.control-header label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #999;
font-weight: 500;
white-space: nowrap;
}
.control-value-display {
font-size: 0.8rem;
color: #fff;
font-weight: 600;
font-variant-numeric: tabular-nums;
min-width: 3.5rem;
text-align: right;
}
.control-item input[type="range"] {
width: 100%;
margin: 0;
}
.custom-checkbox {
display: flex;
align-items: center;
gap: 0.35rem;
cursor: pointer;
user-select: none;
}
.custom-checkbox input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.checkbox-box {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border: 1px solid #3a3a3a;
background-color: #1a1a1a;
transition: all 0.2s;
color: #0a0a0a;
}
.checkbox-box.checked {
background-color: #646cff;
border-color: #646cff;
color: #fff;
}
.custom-checkbox:hover .checkbox-box {
border-color: #4a4a4a;
}
.custom-checkbox:hover .checkbox-box.checked {
background-color: #535bf2;
border-color: #535bf2;
}
.checkbox-text {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #999;
font-weight: 500;
}
.custom-checkbox:hover .checkbox-text {
color: #aaa;
}
.pitch-input {
width: 100%;
min-width: 0;
padding: 0.35rem 0.5rem;
background-color: #1a1a1a;
border: 1px solid #3a3a3a;
color: #fff;
font-size: 0.85rem;
font-weight: 600;
transition: border-color 0.2s, background-color 0.2s, box-shadow 0.2s;
font-variant-numeric: tabular-nums;
box-sizing: border-box;
}
.pitch-input:focus {
outline: none;
border-color: #646cff;
background-color: #0a0a0a;
}
.pitch-input.locked {
border-color: #646cff;
background-color: #0a0a0a;
box-shadow: 0 0 0 1px rgba(100, 108, 255, 0.2);
}
.pitch-input.locked:focus {
box-shadow: 0 0 0 1px rgba(100, 108, 255, 0.4);
}
.pitch-input.invalid {
border-color: #ff4444;
box-shadow: 0 0 0 1px rgba(255, 68, 68, 0.2);
}
.pitch-input::placeholder {
color: #555;
font-weight: 400;
}
@media (min-width: 640px) {
.controls-group {
flex-direction: row;
flex-wrap: wrap;
}
.control-item {
min-width: 140px;
flex: 1;
}
.pitch-lock-control {
min-width: 160px;
}
.control-item input[type="range"] {
min-width: 80px;
}
}
@media (min-width: 768px) {
.top-bar {
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.mode-buttons {
flex: 1;
overflow-x: auto;
padding-bottom: 0;
max-width: 60%;
}
.engine-button {
font-size: 0.9rem;
padding: 0.6rem 1rem;
min-width: auto;
}
.engine-button::after {
display: block;
width: 250px;
}
.engine-button:hover::after {
opacity: 1;
}
.controls-group {
width: auto;
flex-shrink: 0;
gap: 0.5rem;
flex-wrap: nowrap;
}
.control-item {
min-width: 120px;
padding: 0.45rem 0.6rem;
}
.pitch-lock-control {
min-width: 140px;
}
.control-header label {
font-size: 0.7rem;
}
.control-value-display {
font-size: 0.75rem;
min-width: 3rem;
}
.checkbox-text {
font-size: 0.65rem;
}
.checkbox-box {
width: 13px;
height: 13px;
}
.checkbox-box svg {
width: 9px;
height: 9px;
}
.pitch-input {
font-size: 0.8rem;
padding: 0.3rem 0.45rem;
}
.control-item input[type="range"] {
min-width: 70px;
}
}
@media (min-width: 1024px) {
.mode-buttons {
max-width: 65%;
}
.control-item {
min-width: 140px;
padding: 0.5rem 0.65rem;
}
.pitch-lock-control {
min-width: 160px;
}
.control-header label {
font-size: 0.75rem;
}
.control-value-display {
font-size: 0.8rem;
min-width: 3.5rem;
}
.checkbox-text {
font-size: 0.7rem;
}
.checkbox-box {
width: 14px;
height: 14px;
}
.checkbox-box svg {
width: 10px;
height: 10px;
}
.pitch-input {
font-size: 0.85rem;
padding: 0.35rem 0.5rem;
}
.control-item input[type="range"] {
min-width: 90px;
}
}
@media (min-width: 1280px) {
.control-item {
min-width: 160px;
}
.pitch-lock-control {
min-width: 180px;
}
.control-item input[type="range"] {
min-width: 110px;
}
}
.main-area {
flex: 1;
display: flex;
flex-direction: column;
background-color: #0a0a0a;
overflow: hidden;
}
.waveform-container {
flex: 1;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
min-height: 200px;
}
.vu-meter-container {
width: 100%;
height: 60px;
border-top: 1px solid #333;
}
.bottom-controls {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
padding: 0 0.5rem;
max-width: 95%;
}
.bottom-controls button {
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
white-space: nowrap;
}
.process-button-container {
position: relative;
}
@media (min-width: 768px) {
.main-area {
flex-direction: row;
}
.waveform-container {
min-height: auto;
}
.vu-meter-container {
width: 5%;
min-width: 50px;
max-width: 80px;
height: auto;
border-left: 1px solid #333;
border-top: none;
}
.bottom-controls {
gap: 0.75rem;
bottom: 1.5rem;
}
.bottom-controls button {
font-size: 0.9rem;
padding: 0.6rem 1rem;
}
}
@media (min-width: 1024px) {
.bottom-controls {
gap: 1rem;
bottom: 2rem;
}
.bottom-controls button {
font-size: 1rem;
padding: 0.75rem 1.25rem;
}
}
input[type="range"] {
cursor: pointer;
-webkit-appearance: none;
appearance: none;
background: transparent;
height: 20px;
border-radius: 0;
}
input[type="range"]::-webkit-slider-track {
background: #333;
height: 4px;
border: 1px solid #444;
border-radius: 0;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: #fff;
border: 1px solid #000;
border-radius: 0;
cursor: pointer;
margin-top: -6px;
}
input[type="range"]::-webkit-slider-thumb:hover {
background: #ddd;
}
input[type="range"]::-moz-range-track {
background: #333;
height: 4px;
border: 1px solid #444;
border-radius: 0;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
background: #fff;
border: 1px solid #000;
border-radius: 0;
cursor: pointer;
}
input[type="range"]::-moz-range-thumb:hover {
background: #ddd;
}
.file-drop-zone {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed #444;
background-color: #0a0a0a;
transition: all 0.2s;
padding: 1rem;
}
.file-drop-zone.drag-over {
border-color: #646cff;
background-color: #1a1a1a;
}
.drop-zone-content {
text-align: center;
color: #ccc;
}
.drop-zone-content h2 {
font-size: 1.2rem;
margin-bottom: 0.75rem;
color: #fff;
}
.file-input-label {
display: inline-block;
padding: 0.65rem 1.25rem;
background-color: #646cff;
color: #fff;
border: 1px solid #646cff;
cursor: pointer;
transition: background-color 0.2s;
font-size: 0.95rem;
}
.file-input-label:hover {
background-color: #535bf2;
}
@media (min-width: 768px) {
.drop-zone-content h2 {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.file-input-label {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>