834 lines
19 KiB
Svelte
834 lines
19 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import WaveformDisplay from "./lib/components/WaveformDisplay.svelte";
|
|
import VUMeter from "./lib/components/VUMeter.svelte";
|
|
import { engines } from "./lib/audio/engines/registry";
|
|
import type { SynthEngine } 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,
|
|
} from "./lib/utils/settings";
|
|
import { generateRandomColor } from "./lib/utils/colors";
|
|
import {
|
|
getRandomProcessor,
|
|
getAllProcessors,
|
|
} 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";
|
|
|
|
let currentEngineIndex = 0;
|
|
let engine = engines[currentEngineIndex];
|
|
let engineType: EngineType = engine.getType();
|
|
|
|
const audioService = new AudioService();
|
|
|
|
let currentParams: any = null;
|
|
let currentBuffer: AudioBuffer | null = null;
|
|
let duration = loadDuration();
|
|
let volume = loadVolume();
|
|
let playbackPosition = -1;
|
|
let waveformColor = generateRandomColor();
|
|
let showModal = true;
|
|
let isProcessed = false;
|
|
let showProcessorPopup = false;
|
|
let popupTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let isRecording = false;
|
|
let isDragOver = false;
|
|
|
|
const allProcessors = getAllProcessors();
|
|
|
|
$: showDuration = engineType !== 'sample';
|
|
$: showRandomButton = engineType === 'generative';
|
|
$: showRecordButton = engineType === 'input';
|
|
$: showFileDropZone = engineType === 'sample' && !currentBuffer;
|
|
$: showMutateButton = engineType === 'generative' && !isProcessed && currentBuffer;
|
|
|
|
onMount(() => {
|
|
audioService.setVolume(volume);
|
|
audioService.setPlaybackUpdateCallback((position) => {
|
|
playbackPosition = position;
|
|
});
|
|
generateRandom();
|
|
});
|
|
|
|
function generateRandom() {
|
|
currentParams = engine.randomParams();
|
|
waveformColor = generateRandomColor();
|
|
isProcessed = false;
|
|
regenerateBuffer();
|
|
}
|
|
|
|
function mutate() {
|
|
if (!currentParams) {
|
|
generateRandom();
|
|
return;
|
|
}
|
|
currentParams = engine.mutateParams(currentParams);
|
|
waveformColor = generateRandomColor();
|
|
regenerateBuffer();
|
|
}
|
|
|
|
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;
|
|
|
|
const processor = getRandomProcessor();
|
|
applyProcessor(processor);
|
|
}
|
|
|
|
function processWithSpecificProcessor(processor: AudioProcessor) {
|
|
if (!currentBuffer) return;
|
|
|
|
hideProcessorPopup();
|
|
applyProcessor(processor);
|
|
}
|
|
|
|
function handlePopupMouseEnter() {
|
|
if (popupTimeout) {
|
|
clearTimeout(popupTimeout);
|
|
}
|
|
showProcessorPopup = true;
|
|
popupTimeout = setTimeout(() => {
|
|
showProcessorPopup = false;
|
|
}, 2000);
|
|
}
|
|
|
|
function handlePopupMouseLeave() {
|
|
if (popupTimeout) {
|
|
clearTimeout(popupTimeout);
|
|
}
|
|
popupTimeout = setTimeout(() => {
|
|
showProcessorPopup = false;
|
|
}, 200);
|
|
}
|
|
|
|
function hideProcessorPopup() {
|
|
if (popupTimeout) {
|
|
clearTimeout(popupTimeout);
|
|
}
|
|
showProcessorPopup = false;
|
|
}
|
|
|
|
async function applyProcessor(processor: AudioProcessor) {
|
|
if (!currentBuffer) return;
|
|
|
|
const leftChannel = currentBuffer.getChannelData(0);
|
|
const rightChannel = currentBuffer.getChannelData(1);
|
|
|
|
const [processedLeft, processedRight] = await processor.process(
|
|
leftChannel,
|
|
rightChannel,
|
|
);
|
|
|
|
currentBuffer = audioService.createAudioBuffer([
|
|
processedLeft,
|
|
processedRight,
|
|
]);
|
|
isProcessed = true;
|
|
audioService.play(currentBuffer);
|
|
}
|
|
|
|
function handleVolumeChange(event: Event) {
|
|
const target = event.target as HTMLInputElement;
|
|
volume = parseFloat(target.value);
|
|
audioService.setVolume(volume);
|
|
saveVolume(volume);
|
|
}
|
|
|
|
function handleDurationChange(event: Event) {
|
|
const target = event.target as HTMLInputElement;
|
|
duration = parseFloat(target.value);
|
|
saveDuration(duration);
|
|
}
|
|
|
|
function switchEngine(index: number) {
|
|
currentEngineIndex = index;
|
|
engine = engines[index];
|
|
engineType = engine.getType();
|
|
currentBuffer = null;
|
|
currentParams = null;
|
|
isProcessed = false;
|
|
|
|
if (engineType === 'generative') {
|
|
generateRandom();
|
|
}
|
|
}
|
|
|
|
async function handleFileInput(event: Event) {
|
|
const input = event.target as HTMLInputElement;
|
|
if (!input.files || input.files.length === 0) return;
|
|
|
|
const file = input.files[0];
|
|
await loadAudioFile(file);
|
|
}
|
|
|
|
async function loadAudioFile(file: File) {
|
|
if (!(engine instanceof Sample)) return;
|
|
|
|
try {
|
|
await engine.loadFile(file);
|
|
currentParams = engine.randomParams();
|
|
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)) return;
|
|
if (isRecording) return;
|
|
|
|
try {
|
|
isRecording = true;
|
|
await engine.record(duration);
|
|
currentParams = engine.randomParams();
|
|
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;
|
|
|
|
if (!event.dataTransfer) return;
|
|
const files = event.dataTransfer.files;
|
|
if (files.length === 0) 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;
|
|
}
|
|
|
|
async function closeModal() {
|
|
showModal = false;
|
|
await audioService.initialize();
|
|
}
|
|
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
// Ignore if typing in an input
|
|
if (event.target instanceof HTMLInputElement) return;
|
|
|
|
const key = event.key.toLowerCase();
|
|
|
|
// Close modal with Escape key
|
|
if (key === "escape" && showModal) {
|
|
closeModal();
|
|
return;
|
|
}
|
|
|
|
switch (key) {
|
|
case "m":
|
|
mutate();
|
|
break;
|
|
case "r":
|
|
generateRandom();
|
|
break;
|
|
case "p":
|
|
processSound();
|
|
break;
|
|
case "s":
|
|
download();
|
|
break;
|
|
case "arrowleft":
|
|
event.preventDefault();
|
|
const durationDecrement = event.shiftKey ? 1 : 0.05;
|
|
duration = Math.max(0.05, duration - durationDecrement);
|
|
saveDuration(duration);
|
|
break;
|
|
case "arrowright":
|
|
event.preventDefault();
|
|
const durationIncrement = event.shiftKey ? 1 : 0.05;
|
|
duration = Math.min(32, duration + durationIncrement);
|
|
saveDuration(duration);
|
|
break;
|
|
case "arrowdown":
|
|
event.preventDefault();
|
|
const volumeDecrement = event.shiftKey ? 0.2 : 0.05;
|
|
volume = Math.max(0, volume - volumeDecrement);
|
|
audioService.setVolume(volume);
|
|
saveVolume(volume);
|
|
break;
|
|
case "arrowup":
|
|
event.preventDefault();
|
|
const volumeIncrement = event.shiftKey ? 0.2 : 0.05;
|
|
volume = Math.min(1, volume + volumeIncrement);
|
|
audioService.setVolume(volume);
|
|
saveVolume(volume);
|
|
break;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onkeydown={handleKeydown} />
|
|
|
|
<div class="container">
|
|
<div class="top-bar">
|
|
<div class="mode-buttons">
|
|
{#each engines as engine, index}
|
|
<button
|
|
class="engine-button"
|
|
class:active={currentEngineIndex === index}
|
|
data-description={engine.getDescription()}
|
|
onclick={() => switchEngine(index)}
|
|
>
|
|
{engine.getName()}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<div class="controls-group">
|
|
{#if showDuration}
|
|
<div class="slider-control duration-slider">
|
|
<label for="duration">Duration: {duration.toFixed(2)}s</label>
|
|
<input
|
|
id="duration"
|
|
type="range"
|
|
min="0.05"
|
|
max="32"
|
|
step="0.01"
|
|
value={duration}
|
|
oninput={handleDurationChange}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
<div class="slider-control">
|
|
<label for="volume">Volume</label>
|
|
<input
|
|
id="volume"
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.01"
|
|
value={volume}
|
|
oninput={handleVolumeChange}
|
|
/>
|
|
</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}
|
|
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 currentBuffer}
|
|
<div
|
|
class="process-button-container"
|
|
role="group"
|
|
onmouseenter={handlePopupMouseEnter}
|
|
onmouseleave={handlePopupMouseLeave}
|
|
>
|
|
<button onclick={processSound}>Process (P)</button>
|
|
{#if showProcessorPopup}
|
|
<div class="processor-popup">
|
|
{#each allProcessors as processor}
|
|
<button
|
|
class="processor-tile"
|
|
data-description={processor.getDescription()}
|
|
onclick={() => processWithSpecificProcessor(processor)}
|
|
>
|
|
{processor.getName()}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<button onclick={download}>Download (D)</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div class="vu-meter-container">
|
|
<VUMeter buffer={currentBuffer} {playbackPosition} />
|
|
</div>
|
|
</div>
|
|
|
|
{#if showModal}
|
|
<div
|
|
class="modal-overlay"
|
|
role="button"
|
|
tabindex="0"
|
|
onclick={closeModal}
|
|
onkeydown={(e) => e.key === "Enter" && closeModal()}
|
|
>
|
|
<div
|
|
class="modal-content"
|
|
role="dialog"
|
|
aria-labelledby="modal-title"
|
|
tabindex="-1"
|
|
onclick={(e) => e.stopPropagation()}
|
|
onkeydown={(e) => e.stopPropagation()}
|
|
>
|
|
<h1 id="modal-title">Vending Machine</h1>
|
|
<p class="description">
|
|
Oh, looks like you found a sound vending machine. This one seems
|
|
slightly broken and it seems that you can get sounds for free... Have
|
|
fun!
|
|
</p>
|
|
<div class="modal-links">
|
|
<p>
|
|
Created by <a
|
|
href="https://raphaelforment.fr"
|
|
target="_blank"
|
|
rel="noopener noreferrer">Raphaël Forment (BuboBubo)</a
|
|
>
|
|
</p>
|
|
<p>
|
|
Licensed under <a
|
|
href="https://www.gnu.org/licenses/gpl-3.0.html"
|
|
target="_blank"
|
|
rel="noopener noreferrer">GPL 3.0</a
|
|
>
|
|
</p>
|
|
</div>
|
|
<button class="modal-close" onclick={closeModal}>Start</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.container {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.top-bar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
padding: 0.5rem;
|
|
background-color: #1a1a1a;
|
|
border-bottom: 1px solid #333;
|
|
}
|
|
|
|
.mode-buttons {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.engine-button {
|
|
opacity: 0.7;
|
|
position: relative;
|
|
}
|
|
|
|
.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: 30vw;
|
|
white-space: normal;
|
|
word-wrap: break-word;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.engine-button:hover::after {
|
|
opacity: 1;
|
|
}
|
|
|
|
.controls-group {
|
|
display: flex;
|
|
gap: 1rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.slider-control {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.slider-control label {
|
|
font-size: 0.9rem;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.slider-control input[type="range"] {
|
|
width: 100px;
|
|
}
|
|
|
|
.duration-slider input[type="range"] {
|
|
width: 300px;
|
|
}
|
|
|
|
.main-area {
|
|
flex: 1;
|
|
display: flex;
|
|
background-color: #0a0a0a;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.waveform-container {
|
|
flex: 1;
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.vu-meter-container {
|
|
width: 5%;
|
|
min-width: 40px;
|
|
max-width: 80px;
|
|
border-left: 1px solid #333;
|
|
}
|
|
|
|
.bottom-controls {
|
|
position: absolute;
|
|
bottom: 2rem;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
display: flex;
|
|
gap: 1rem;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
.modal-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background-color: rgba(0, 0, 0, 0.8);
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
z-index: 2000;
|
|
}
|
|
|
|
.modal-content {
|
|
background-color: #000;
|
|
border: 2px solid #fff;
|
|
padding: 2rem;
|
|
max-width: 500px;
|
|
width: 90%;
|
|
color: #fff;
|
|
}
|
|
|
|
.modal-content h1 {
|
|
margin: 0 0 1rem 0;
|
|
font-size: 2rem;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.modal-content .description {
|
|
margin: 0 0 1.5rem 0;
|
|
line-height: 1.6;
|
|
color: #ccc;
|
|
}
|
|
|
|
.modal-links {
|
|
margin: 1.5rem 0;
|
|
padding: 1rem 0;
|
|
border-top: 1px solid #333;
|
|
border-bottom: 1px solid #333;
|
|
}
|
|
|
|
.modal-links p {
|
|
margin: 0.5rem 0;
|
|
font-size: 0.9rem;
|
|
color: #ccc;
|
|
}
|
|
|
|
.modal-links a {
|
|
color: #646cff;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.modal-links a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.modal-close {
|
|
margin-top: 1rem;
|
|
width: 100%;
|
|
padding: 0.75rem;
|
|
font-size: 1.1rem;
|
|
background-color: #fff;
|
|
color: #000;
|
|
border: none;
|
|
cursor: pointer;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.modal-close:hover {
|
|
background-color: #ddd;
|
|
}
|
|
|
|
.process-button-container {
|
|
position: relative;
|
|
}
|
|
|
|
.processor-popup {
|
|
position: absolute;
|
|
bottom: 100%;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background-color: #000;
|
|
border: 2px solid #fff;
|
|
padding: 0.75rem;
|
|
z-index: 1000;
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 0.5rem;
|
|
width: 600px;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.processor-tile {
|
|
background-color: #1a1a1a;
|
|
border: 1px solid #444;
|
|
padding: 0.6rem 0.4rem;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition: background-color 0.2s, border-color 0.2s;
|
|
font-size: 0.85rem;
|
|
color: #fff;
|
|
position: relative;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.processor-tile:hover {
|
|
background-color: #2a2a2a;
|
|
border-color: #646cff;
|
|
}
|
|
|
|
.processor-tile::after {
|
|
content: attr(data-description);
|
|
position: absolute;
|
|
bottom: 100%;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
padding: 0.5rem 0.75rem;
|
|
background-color: #0a0a0a;
|
|
border: 1px solid #444;
|
|
color: #ccc;
|
|
font-size: 0.85rem;
|
|
width: max-content;
|
|
max-width: 300px;
|
|
white-space: normal;
|
|
word-wrap: break-word;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
z-index: 1001;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.processor-tile:hover::after {
|
|
opacity: 1;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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.5rem;
|
|
margin-bottom: 1rem;
|
|
color: #fff;
|
|
}
|
|
|
|
.drop-zone-content p {
|
|
margin: 1rem 0;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.file-input-label {
|
|
display: inline-block;
|
|
padding: 0.75rem 1.5rem;
|
|
background-color: #646cff;
|
|
color: #fff;
|
|
border: 1px solid #646cff;
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.file-input-label:hover {
|
|
background-color: #535bf2;
|
|
}
|
|
|
|
button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
</style>
|