initial public release.

This commit is contained in:
Xiaohan-Tian
2025-08-11 18:37:21 -07:00
commit de51967b49
186 changed files with 32322 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
import React, { useCallback, useState } from 'react';
import { FaTimes } from 'react-icons/fa';
interface FileImportModalProps {
isVisible: boolean;
onClose: () => void;
onFileImport: (file: File) => void;
acceptedTypes?: string[];
title?: string;
description?: string;
}
const FileImportModal: React.FC<FileImportModalProps> = ({
isVisible,
onClose,
onFileImport,
acceptedTypes = ['.json'],
title = 'Import Project',
description = 'Drag and drop your project file here'
}) => {
const [isDragOver, setIsDragOver] = useState(false);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only set drag over to false if we're leaving the drop zone entirely
if (e.currentTarget === e.target) {
setIsDragOver(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
const file = files[0];
// Check if file type is accepted
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
if (acceptedTypes.includes(fileExtension)) {
onFileImport(file);
onClose();
} else {
alert(`Invalid file type. Please select a file with one of these extensions: ${acceptedTypes.join(', ')}`);
}
}
}, [acceptedTypes, onFileImport, onClose]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
onFileImport(files[0]);
onClose();
}
}, [onFileImport, onClose]);
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
// Only close if clicking on the overlay itself, not the modal content
if (e.target === e.currentTarget) {
onClose();
}
}, [onClose]);
if (!isVisible) {
return null;
}
return (
<div className="file-import-overlay" onClick={handleOverlayClick}>
<div className="file-import-modal">
<div className="file-import-header">
<h3 className="file-import-title">{title}</h3>
<button
className="file-import-close-btn"
onClick={onClose}
aria-label="Close import modal"
>
<FaTimes />
</button>
</div>
<div
className={`file-import-drop-zone ${isDragOver ? 'drag-over' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div className="file-import-drop-content">
<div className="file-import-icon">📁</div>
<p className="file-import-description">{description}</p>
<p className="file-import-formats">
Supported formats: {acceptedTypes.join(', ')}
</p>
<div className="file-import-divider">
<span>or</span>
</div>
<label className="file-import-browse-btn">
Browse Files
<input
type="file"
accept={acceptedTypes.join(',')}
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
</label>
</div>
</div>
</div>
</div>
);
};
export default FileImportModal;
+107
View File
@@ -0,0 +1,107 @@
import React, { useState, useRef, useEffect } from 'react';
import { FaCaretDown } from 'react-icons/fa';
type DropdownOption = string | { label: string; value: string };
interface KGDropdownProps {
options: DropdownOption[];
value: string;
onChange: (value: string) => void;
label: string;
className?: string;
buttonClassName?: string;
optionClassName?: string;
showValueAsLabel?: boolean;
hideButton?: boolean;
isOpen?: boolean;
onToggle?: (open: boolean) => void;
}
const KGDropdown: React.FC<KGDropdownProps> = ({
options,
value,
onChange,
label,
className = '',
buttonClassName = '',
optionClassName = '',
showValueAsLabel = false,
hideButton = false,
isOpen: externalIsOpen,
onToggle
}) => {
const [internalIsOpen, setInternalIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Use external state if provided, otherwise use internal state
const isOpen = externalIsOpen !== undefined ? externalIsOpen : internalIsOpen;
const setIsOpen = onToggle || setInternalIsOpen;
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
isOpen &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Handle option selection
const handleSelect = (option: DropdownOption) => {
const value = typeof option === 'string' ? option : option.value;
onChange(value);
setIsOpen(false);
};
const resolveLabel = (option: DropdownOption) => (typeof option === 'string' ? option : option.label);
const resolveValue = (option: DropdownOption) => (typeof option === 'string' ? option : option.value);
const selectedLabel = (() => {
if (!showValueAsLabel) return label;
// Try to find the label for the current value
const match = options.find(opt => resolveValue(opt) === value);
return match ? resolveLabel(match) : value;
})();
const buttonText = showValueAsLabel ? selectedLabel : label;
return (
<div className={`quant-dropdown-container ${className}`} ref={dropdownRef}>
{!hideButton && (
<button
className={`quant-button ${buttonClassName}`}
onClick={() => setIsOpen(!isOpen)}
>
{buttonText} <FaCaretDown />
</button>
)}
{isOpen && (
<div className="quant-dropdown">
{options.map((option) => {
const optionValue = resolveValue(option);
return (
<div
key={optionValue}
className={`quant-option ${value === optionValue ? 'active' : ''} ${optionClassName}`}
onClick={() => handleSelect(option)}
>
{resolveLabel(option)}
</div>
);
})}
</div>
)}
</div>
);
};
export default KGDropdown;
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
interface LoadingOverlayProps {
visible: boolean;
message?: string;
}
const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ visible, message = 'Loading ...' }) => {
if (!visible) return null;
return (
<div className="global-loading-overlay" role="status" aria-live="polite" aria-busy={true}>
<div className="global-loading-content">
<div className="global-loading-spinner" />
<div className="global-loading-text">{message}</div>
</div>
</div>
);
};
export default LoadingOverlay;
+82
View File
@@ -0,0 +1,82 @@
import React from 'react';
import { KGCore } from '../../core/KGCore';
import { useProjectStore } from '../../stores/projectStore';
interface PlayheadProps {
/** Context where the playhead is being rendered */
context: 'main-grid' | 'piano-roll';
/** For piano roll context, the region start beat offset */
regionStartBeat?: number;
}
const Playhead: React.FC<PlayheadProps> = ({ context, regionStartBeat = 0 }) => {
const { timeSignature, playheadPosition } = useProjectStore();
// Calculate the pixel position based on context
const getPixelPosition = (): number => {
if (context === 'main-grid') {
// In main grid, convert beats to bars, then bars to pixels
const beatsPerBar = timeSignature.numerator;
const barPosition = playheadPosition / beatsPerBar;
// Get bar width from CSS variable
const barWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width')
) || 40;
return barPosition * barWidth;
} else {
// In piano roll, use beat-based positioning
// Get beat width from CSS variable
const beatWidth = parseInt(
getComputedStyle(document.documentElement).getPropertyValue('--region-grid-beat-width')
) || 40;
return playheadPosition * beatWidth;
}
};
const pixelPosition = getPixelPosition();
// Don't render if position is negative (before region start in piano roll)
if (pixelPosition < 0) {
return null;
}
const playheadStyle: React.CSSProperties = {
position: 'absolute',
left: `${pixelPosition}px`,
top: 0,
bottom: 0,
width: '2px',
backgroundColor: '#4ECDC4', // Blue-green color similar to the reference image
zIndex: 1000,
pointerEvents: 'none', // Allow clicks to pass through
boxShadow: '0 0 4px rgba(78, 205, 196, 0.5)', // Subtle glow effect
};
// Triangle indicator style (only for main-grid context)
const triangleStyle: React.CSSProperties = {
position: 'absolute',
left: `${pixelPosition - 5}px`, // Center the triangle on the playhead line
top: '-2px', // Position slightly above the top
width: 0,
height: 0,
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderTop: '8px solid #4ECDC4',
zIndex: 1001,
pointerEvents: 'none',
};
return (
<>
<div className="playhead" style={playheadStyle} />
{context === 'main-grid' && (
<div className="playhead-triangle" style={triangleStyle} />
)}
</>
);
};
export default Playhead;
+46
View File
@@ -0,0 +1,46 @@
import React from 'react';
type PianoIconProps = React.SVGProps<SVGSVGElement>;
/**
* PianoIcon keyboard-style icon in a Font Awesome-like solid style
* - Uses currentColor
* - Scales with font size (1em)
* - viewBox matches FA dimensions
*/
const PianoIcon: React.FC<PianoIconProps> = (props) => (
<svg
viewBox="0 0 576 512"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
focusable="false"
{...props}
>
{/*
Build a frame with even-odd fill, then add inner black keys as filled bars.
Outer frame: 48,64 → 528x320
Inner hole: 96,112 → 384x224
Top slot: 112,128 → 352x32
Black keys: four bars centered
*/}
<path
fillRule="evenodd"
clipRule="evenodd"
d="
M48 64h480v320H48V64z
M96 112h384v224H96V112z
M112 128h352v32H112v-32z
M176 160h24v136h-24V160z
M240 160h24v136h-24V160z
M304 160h24v136h-24V160z
M368 160h24v136h-24V160z
"
/>
</svg>
);
export default PianoIcon;
+4
View File
@@ -0,0 +1,4 @@
export { default as KGDropdown } from './KGDropdown';
export { default as Playhead } from './Playhead';
export { default as FileImportModal } from './FileImportModal';
export { default as LoadingOverlay } from './LoadingOverlay';