From 58cb4cebef1ade6bf37228ca11e9872063caad31 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:15:01 -0700 Subject: [PATCH 1/3] refactor: moved "Add track" button from bottom TrackControl to top-left-spacer for better discoverability --- README.md | 2 +- src/App.css | 25 ++--- src/App.tsx | 3 - src/components/MainContent.tsx | 169 ++++++++++++++++---------------- src/components/TrackControl.tsx | 18 ---- 5 files changed, 101 insertions(+), 116 deletions(-) delete mode 100644 src/components/TrackControl.tsx diff --git a/README.md b/README.md index d056edf..56a6dcb 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ Feature priorities might change. - [ ] Support WAV audio tracks - [ ] Filters and effects - [ ] MCP Support -- [ ] Add support for OpenAI's open source models (`gpt-oss-20b` and `gpt-oss-120b`) +- [X] Add support for OpenAI's open source models (`gpt-oss-20b` and `gpt-oss-120b`) - [ ] Automatically compact conversations when the context window runs low on space ## Help Needed diff --git a/src/App.css b/src/App.css index 5bbb13e..c0d216c 100644 --- a/src/App.css +++ b/src/App.css @@ -325,24 +325,27 @@ body { cursor: crosshair; } -.track-control { - padding: 5px; - background-color: #2d2d2d; - border-top: 1px solid #3a3a3a; -} - -.track-control button { +.add-track-btn { background: transparent; border: none; + outline: none; color: #999; cursor: pointer; - padding: 5px; - width: 100%; - text-align: left; font-size: 12px; + padding: 0; + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; } -.track-control button:hover { +.add-track-btn:focus, +.add-track-btn:focus-visible { + outline: none; +} + +.add-track-btn:hover { color: #e0e0e0; } diff --git a/src/App.tsx b/src/App.tsx index feb3e0a..f219f79 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,6 @@ import { useProjectStore } from './stores/projectStore'; import { useGlobalKeyboardHandler } from './hooks/useGlobalKeyboardHandler'; import Toolbar from './components/Toolbar'; import StatusBar from './components/StatusBar'; -import TrackControl from './components/TrackControl'; import MainContent from './components/MainContent'; import InstrumentSelection from './components/InstrumentSelection'; import ChatBox from './components/ChatBox'; @@ -135,8 +134,6 @@ function App() { - {/* Track Control */} - {/* Status Bar */} diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index 76e742c..d8fb397 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -18,7 +18,7 @@ interface MainContentProps { } const MainContent: React.FC = ({ - onTrackClick = () => {} // Default to empty function if not provided + onTrackClick = () => { } // Default to empty function if not provided }) => { const { tracks, @@ -33,18 +33,19 @@ const MainContent: React.FC = ({ showPianoRoll, activeRegionId, setShowPianoRoll, - setActiveRegionId + setActiveRegionId, + addTrack } = useProjectStore(); - + // State to store regions const [regions, setRegions] = useState([]); - + // Drag state for track grid highlighting const [draggedTrackIndex, setDraggedTrackIndex] = useState(null); const [dragOverTrackIndex, setDragOverTrackIndex] = useState(null); - + // Piano roll state is now managed by the store - removed local state - + // Region selection state const [selectedRegionId, setSelectedRegionId] = useState(null); @@ -64,16 +65,16 @@ const MainContent: React.FC = ({ // Register the delete function with the global manager useEffect(() => { regionDeleteManager.registerDeleteCallback(deleteSelectedRegions); - + // Cleanup on unmount return () => { regionDeleteManager.unregisterDeleteCallback(); }; }, [deleteSelectedRegions]); - + // Refs to track pending updates for verification const pendingUpdates = useRef>(new Map()); - + // Refs for bar numbers and loop range drag functionality const barNumbersRef = useRef(null); const isLoopDraggingRef = useRef(false); @@ -87,26 +88,26 @@ const MainContent: React.FC = ({ if (pendingUpdates.current.size > 0) { // Create a copy of the pending updates const updates = new Map(pendingUpdates.current); - + // Clear pending updates pendingUpdates.current.clear(); - + // Check each update updates.forEach((update, key) => { const { trackId, regionId, startBeat, length } = update; - + // Find the track const track = tracks.find(t => t.getId().toString() === trackId); if (track) { // Find the region const regions = track.getRegions(); const region = regions.find(r => r.getId() === regionId); - + if (region && DEBUG_MODE.MAIN_CONTENT) { console.log(`Verification - Region ${regionId} in track ${trackId}:`); console.log(` Expected: startBeat=${startBeat}, length=${length}`); console.log(` Actual: startBeat=${region.getStartFromBeat()}, length=${region.getLength()}, trackId=${region.getTrackId()}, trackIndex=${region.getTrackIndex()}`); - + // Check if the update was successful const success = region.getStartFromBeat() === startBeat && region.getLength() === length && region.getTrackId() === trackId; console.log(` Update successful: ${success}`); @@ -120,12 +121,12 @@ const MainContent: React.FC = ({ useEffect(() => { // Create a new array of RegionUI objects based on the current tracks const updatedRegions: RegionUI[] = []; - + // Iterate through all tracks tracks.forEach(track => { const trackId = track.getId().toString(); const trackIndex = track.getTrackIndex(); - + // Iterate through all regions in the track track.getRegions().forEach(region => { if (region instanceof KGMidiRegion) { @@ -133,7 +134,7 @@ const MainContent: React.FC = ({ const beatsPerBar = timeSignature.numerator; const barNumber = Math.floor(region.getStartFromBeat() / beatsPerBar) + 1; const length = region.getLength() / beatsPerBar; - + // Create a RegionUI object updatedRegions.push({ id: region.getId(), @@ -146,7 +147,7 @@ const MainContent: React.FC = ({ } }); }); - + // Update the regions state setRegions(updatedRegions); }, [tracks, timeSignature]); @@ -161,7 +162,7 @@ const MainContent: React.FC = ({ const handleTracksReordered = (fromIndex: number, toIndex: number) => { // Reorder tracks in the store - this will also update trackIndex in each KGTrack reorderTracks(fromIndex, toIndex); - + // Update regions to match the new track order setRegions(prevRegions => { return prevRegions.map(region => { @@ -171,17 +172,17 @@ const MainContent: React.FC = ({ } // If the region belongs to a track that was shifted due to the drag operation else if ( - (fromIndex < toIndex && - region.trackIndex > fromIndex && - region.trackIndex <= toIndex) + (fromIndex < toIndex && + region.trackIndex > fromIndex && + region.trackIndex <= toIndex) ) { // Shift up by 1 return { ...region, trackIndex: region.trackIndex - 1 }; } else if ( - (fromIndex > toIndex && - region.trackIndex < fromIndex && - region.trackIndex >= toIndex) + (fromIndex > toIndex && + region.trackIndex < fromIndex && + region.trackIndex >= toIndex) ) { // Shift down by 1 return { ...region, trackIndex: region.trackIndex + 1 }; @@ -190,7 +191,7 @@ const MainContent: React.FC = ({ return region; }); }); - + // Update the grid drag state to match setDraggedTrackIndex(null); setDragOverTrackIndex(null); @@ -200,53 +201,53 @@ const MainContent: React.FC = ({ const handleRegionCreated = (trackIndex: number, regionUI: RegionUI, midiRegion: KGMidiRegion) => { // Note: The region model is already created by the CreateRegionCommand // We just need to update the UI state and handle selection - + // Get the track for store updates const track = tracks[trackIndex]; - + // Update the track in the store to reflect the command changes updateTrack(track); - + // Select the track that contains the new region setSelectedTrack(track.getId().toString()); - + // Add the new region to the UI state and select it immediately setRegions(prevRegions => { const updatedRegions = [...prevRegions, regionUI]; - + // Select the region using the updated regions array selectRegion(regionUI.id, updatedRegions); - + // Manually trigger selection sync to ensure UI updates immediately const { syncSelectionFromCore } = useProjectStore.getState(); syncSelectionFromCore(); - + // If piano roll is visible, set this region as the active region if (showPianoRoll) { setActiveRegionId(regionUI.id); - + if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Newly created region ${regionUI.id} set as active region in piano roll`); } } - + return updatedRegions; }); }; - + // Handle region updates (resize, move, etc.) const handleRegionUpdated = ( - regionId: string, - updates: Partial, + regionId: string, + updates: Partial, expectedModelUpdates?: { startBeat: number, length: number } ) => { if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Updating region ${regionId} with:`, updates); } - + // Select the region when it's being updated (resize or move) selectRegion(regionId); - + // Find the region to determine which track to select const updatedRegion = regions.find(r => r.id === regionId); if (updatedRegion) { @@ -257,7 +258,7 @@ const MainContent: React.FC = ({ setSelectedTrack(track.getId().toString()); } } - + // Update the region in the UI state setRegions(prevRegions => { return prevRegions.map(region => { @@ -267,31 +268,31 @@ const MainContent: React.FC = ({ return region; }); }); - + // Find the region that was updated const region = regions.find(r => r.id === regionId); if (!region) return; - + // Check if the track ID is being updated (region moved to different track) if (updates.trackId && updates.trackId !== region.trackId) { if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Region ${regionId} moved from track ${region.trackId} to track ${updates.trackId}`); } - + // Get the original track const originalTrack = tracks.find(t => t.getId().toString() === region.trackId); - + // Get the target track const targetTrack = tracks.find(t => t.getId().toString() === updates.trackId); - + if (originalTrack && targetTrack) { // Select the target track that now contains the region setSelectedTrack(targetTrack.getId().toString()); - + // Update both tracks in the store updateTrack(originalTrack); updateTrack(targetTrack); - + // Add to pending updates for verification if (expectedModelUpdates) { const key = `${updates.trackId}-${regionId}-${Date.now()}`; @@ -310,14 +311,14 @@ const MainContent: React.FC = ({ // Log the track's regions before updating the store const trackRegions = track.getRegions(); const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined; - + if (midiRegion) { // If we have expected model updates, use those if (expectedModelUpdates) { if (DEBUG_MODE.MAIN_CONTENT) { console.log(`MainContent - Expected model updates: startBeat=${expectedModelUpdates.startBeat}, length=${expectedModelUpdates.length}`); } - + // Add to pending updates for verification const key = `${track.getId()}-${regionId}-${Date.now()}`; pendingUpdates.current.set(key, { @@ -330,11 +331,11 @@ const MainContent: React.FC = ({ // Otherwise use the current values (for backward compatibility) const startBeat = midiRegion.getStartFromBeat(); const length = midiRegion.getLength(); - + if (DEBUG_MODE.MAIN_CONTENT) { console.log(`MainContent - Region before store update: startBeat=${startBeat}, length=${length}`); } - + // Add to pending updates for verification const key = `${track.getId()}-${regionId}-${Date.now()}`; pendingUpdates.current.set(key, { @@ -345,16 +346,16 @@ const MainContent: React.FC = ({ }); } } - + // Update the track in the store to persist changes updateTrack(track); } } - + // If piano roll is visible, set this region as the active region if (showPianoRoll) { setActiveRegionId(regionId); - + if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Updated region ${regionId} set as active region in piano roll`); } @@ -365,7 +366,7 @@ const MainContent: React.FC = ({ const selectRegion = (regionId: string, regionsToSearch?: RegionUI[]) => { // Clear any existing selections using store method clearAllSelections(); - + // Find the region in the UI state (use provided regions or current state) const regionsToUse = regionsToSearch || regions; const region = regionsToUse.find(r => r.id === regionId); @@ -375,7 +376,7 @@ const MainContent: React.FC = ({ } return; } - + // Find the track that contains this region const track = tracks.find(t => t.getId().toString() === region.trackId); if (!track) { @@ -384,28 +385,28 @@ const MainContent: React.FC = ({ } return; } - + // Find the region in the track's model const trackRegions = track.getRegions(); const midiRegion = trackRegions.find(r => r.getId() === regionId) as KGMidiRegion | undefined; - + if (!midiRegion) { if (DEBUG_MODE.MAIN_CONTENT) { console.log(`MIDI region not found in track model: ${regionId}`); } return; } - + // Add the region to KGCore's selection const core = KGCore.instance(); core.addSelectedItem(midiRegion); - + // Update the region's internal selection state midiRegion.select(); - + // Set the selected region (this might be redundant now, but keeping for compatibility) setSelectedRegionId(regionId); - + if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Selected region: ${regionId} (added to KGCore selection)`); } @@ -416,10 +417,10 @@ const MainContent: React.FC = ({ if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Region clicked in MainContent (selection only): ${regionId}`); } - + // Select the region selectRegion(regionId); - + // Also select the containing track const region = regions.find(r => r.id === regionId); if (!region) return; @@ -433,10 +434,10 @@ const MainContent: React.FC = ({ if (DEBUG_MODE.MAIN_CONTENT) { console.log(`Open piano roll via pencil for region: ${regionId}`); } - + // Reuse selection logic handleRegionClick(regionId); - + // Activate and show piano roll setActiveRegionId(regionId); setShowPianoRoll(true); @@ -458,8 +459,8 @@ const MainContent: React.FC = ({ // Skip if user is typing in an input field (including ChatBox) const target = event.target as HTMLElement; if (target && ( - target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || target.contentEditable === 'true' || target.hasAttribute('data-chatbox-input') || target.closest('.chatbox-input') @@ -472,7 +473,7 @@ const MainContent: React.FC = ({ // Only handle if we're not in the piano roll (piano roll has its own delete handler) const isInPianoRoll = document.querySelector('.piano-roll')?.contains(event.target as Node); const isPianoRollOpen = showPianoRoll; - + if (!isInPianoRoll && !isPianoRollOpen) { const deleted = deleteSelectedRegions(); if (deleted) { @@ -482,10 +483,10 @@ const MainContent: React.FC = ({ } } }; - + // Add event listener window.addEventListener('keydown', handleKeyDown); - + // Remove event listener on cleanup return () => { window.removeEventListener('keydown', handleKeyDown); @@ -495,25 +496,25 @@ const MainContent: React.FC = ({ // Utility function to calculate playhead position from mouse coordinates (bar-level snapping) const calculatePlayheadFromMouse = useCallback((clientX: number): number | null => { if (!barNumbersRef.current) return null; - + const rect = barNumbersRef.current.getBoundingClientRect(); const relativeX = clientX - rect.left; - + // Calculate the width of each bar const barWidth = parseInt( getComputedStyle(document.documentElement).getPropertyValue('--track-grid-bar-width') ) || 40; - + // Find the closest bar start (using Math.round for nearest bar) const barIndex = Math.round(relativeX / barWidth); - + // Ensure we don't go below 0 const clampedBarIndex = Math.max(0, barIndex); - + // Calculate destination beat position (start of the bar) const beatsPerBar = timeSignature.numerator; const destinationBeatPosition = clampedBarIndex * beatsPerBar; - + return destinationBeatPosition; }, [timeSignature]); @@ -688,7 +689,9 @@ const MainContent: React.FC = ({
{/* Top-left spacer */} -
+
+ +
{/* Bar numbers at the top */}
= ({
))}
- +
{/* Fixed left panel with track info */} = ({ onTrackNameEdit={handleTrackNameEdit} onTracksReordered={handleTracksReordered} /> - + {/* Scrollable grid area */} = ({ />
- + {/* Piano Roll - render using portal */} {showPianoRoll && createPortal( - , diff --git a/src/components/TrackControl.tsx b/src/components/TrackControl.tsx deleted file mode 100644 index 3f7136c..0000000 --- a/src/components/TrackControl.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import { useProjectStore } from '../stores/projectStore'; - -const TrackControl: React.FC = () => { - const { addTrack } = useProjectStore(); - - const handleAddTrack = () => { - addTrack(); - }; - - return ( -
- -
- ); -}; - -export default TrackControl; \ No newline at end of file From 17610f12f35751f51f70e8d8f02b2b6391361055 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:31:49 -0700 Subject: [PATCH 2/3] refactor: split monolithic App.css into feature-based co-located CSS files --- src/App.css | 2029 --------------------- src/App.tsx | 1 + src/components/ChatBox.css | 313 ++++ src/components/ChatBox.tsx | 1 + src/components/InstrumentSelection.css | 123 ++ src/components/InstrumentSelection.tsx | 1 + src/components/MainContent.css | 105 ++ src/components/MainContent.tsx | 1 + src/components/StatusBar.css | 16 + src/components/StatusBar.tsx | 1 + src/components/Toolbar.css | 155 ++ src/components/Toolbar.tsx | 1 + src/components/common/FileImportModal.css | 161 ++ src/components/common/FileImportModal.tsx | 1 + src/components/common/LoadingOverlay.css | 36 + src/components/common/LoadingOverlay.tsx | 1 + src/components/piano-roll/PianoRoll.css | 265 +++ src/components/piano-roll/PianoRoll.tsx | 1 + src/components/settings/Settings.css | 389 ++++ src/components/settings/SettingsPanel.tsx | 1 + src/components/track/Region.css | 110 ++ src/components/track/RegionItem.tsx | 1 + src/components/track/Track.css | 207 +++ src/components/track/TrackInfoPanel.tsx | 1 + src/index.css | 6 + src/main.tsx | 1 + src/styles/shared.css | 125 ++ src/styles/variables.css | 11 + 28 files changed, 2035 insertions(+), 2029 deletions(-) create mode 100644 src/components/ChatBox.css create mode 100644 src/components/InstrumentSelection.css create mode 100644 src/components/MainContent.css create mode 100644 src/components/StatusBar.css create mode 100644 src/components/Toolbar.css create mode 100644 src/components/common/FileImportModal.css create mode 100644 src/components/common/LoadingOverlay.css create mode 100644 src/components/piano-roll/PianoRoll.css create mode 100644 src/components/settings/Settings.css create mode 100644 src/components/track/Region.css create mode 100644 src/components/track/Track.css create mode 100644 src/styles/shared.css create mode 100644 src/styles/variables.css diff --git a/src/App.css b/src/App.css index c0d216c..0c36aff 100644 --- a/src/App.css +++ b/src/App.css @@ -1,15 +1,3 @@ -:root { - --time-signature-numerator: 4; - --max-number-of-bars: 32; - --track-grid-bar-width: 40px; - --region-piano-key-width: 60px; - --region-piano-key-height: 20px; - --region-grid-beat-width: 40px; - --region-grid-bar-width: calc(var(--region-grid-beat-width) * var(--time-signature-numerator)); - --chat-box-width: 350px; - --instrument-selection-width: 300px; -} - /* Reset default styles */ * { margin: 0; @@ -50,994 +38,6 @@ body { overflow: hidden; } -/* Toolbar */ -.toolbar { - display: flex; - justify-content: space-between; - align-items: center; - background-color: #2d2d2d; - height: 50px; - padding: 0 10px; - border-bottom: 1px solid #3a3a3a; -} - -.toolbar-left, .toolbar-center, .toolbar-right { - display: flex; - align-items: center; -} - -.toolbar-left { - display: flex; - align-items: center; -} - -.logo-container { - margin-right: 10px; - display: flex; - align-items: center; -} - -.logo { - height: 30px; - width: auto; -} - -.project-name { - font-size: 14px; - color: #e0e0e0; - margin-right: 20px; - display: flex; - align-items: center; - pointer-events: auto; -} - -.toolbar button { - background: transparent; - border: none; - color: #e0e0e0; - margin: 0 5px; - padding: 5px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - border-radius: 3px; - font-size: 14px; - pointer-events: auto; -} - -.toolbar button:hover { - background-color: #3a3a3a; -} - -.toolbar button:disabled { - color: #666; - cursor: not-allowed; -} - -.record-btn { - color: #ff4444; -} - -.toolbar-separator { - width: 1px; - height: 20px; - background-color: #555; - margin: 0 10px; -} - -.transport-control { - display: flex; - align-items: center; - margin: 0 10px; - pointer-events: auto; -} - -.transport-item { - margin: 0 5px; - padding: 2px 2px; - background-color: #3a3a3a; - border-radius: 3px; - font-size: 12px; -} - -/* Fixed-width font for time display to prevent layout shifts */ -.current-time { - font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-weight: normal; - letter-spacing: 0.5px; - padding: 2px 4px; -} - -/* Clickable BPM styling */ -.current-bpm { - font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-weight: normal; - letter-spacing: 0.5px; - cursor: pointer; - transition: background-color 0.2s ease; - padding: 2px 4px; - border-radius: 2px; -} - -.current-bpm:hover { - background-color: #4a4a4a; - color: #fff; -} - -/* Clickable time signature styling */ -.current-time-signature { - font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-weight: normal; - letter-spacing: 0.5px; - cursor: pointer; - transition: background-color 0.2s ease; - padding: 2px 4px; - border-radius: 2px; -} - -.current-time-signature:hover { - background-color: #4a4a4a; - color: #fff; -} - -.current-key-signature { - font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - font-weight: normal; - letter-spacing: 0.5px; - cursor: pointer; - transition: background-color 0.2s ease; - padding: 2px 4px; - border-radius: 2px; -} - -.current-key-signature:hover { - background-color: #4a4a4a; - color: #fff; -} - -.export-dropdown .quant-dropdown { - width: 250px; - left: 0; -} - -/* Main content */ -.main-content { - display: flex; - flex-direction: column; - flex: 1; - overflow: auto; /* Main scrollable container */ - position: relative; -} - -.main-content-wrapper { - display: flex; - flex-direction: column; - min-width: calc(200px + var(--max-number-of-bars) * var(--track-grid-bar-width)); /* info width + grid width */ - min-height: fit-content; - position: relative; -} - -.top-left-spacer { - position: fixed; - top: 50px; - left: 0; - width: 200px; - height: 20px; - background-color: #2d2d2d; - border-bottom: 1px solid #3a3a3a; - border-right: 1px solid #3a3a3a; - z-index: 1002; /* Higher than other elements to ensure it's always visible */ -} - -/* Offset spacer when instrument selection panel is visible on the left */ -.main-content.has-left-instrument .top-left-spacer { - left: 300px; -} - -.bar-numbers { - position: sticky; - top: 0; - height: 20px; - display: flex; - border-bottom: 1px solid #3a3a3a; - background-color: #2d2d2d; - z-index: 20; - margin-left: 200px; /* Offset for info-container */ - width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); /* Exact width for 32 bars */ - cursor: pointer; /* Show pointer cursor on hover to indicate interactivity */ -} - -.bar-numbers:hover { - cursor: url("data:image/svg+xml,%3csvg width='16' height='16' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M4 6h8l-4 4-4-4z' fill='%23e0e0e0'/%3e%3c/svg%3e") 8 8, pointer; -} - -.bar-number-cell { - min-width: var(--track-grid-bar-width); - width: var(--track-grid-bar-width); - flex-shrink: 0; - flex-grow: 0; - height: 20px; - text-align: center; - font-size: 12px; - border-right: 1px solid #3a3a3a; - color: #999; - display: flex; - align-items: center; - justify-content: center; - box-sizing: border-box; -} - -.bar-number-cell.looped { - background-color: #e1ae01; - color: #1e1e1e; -} - -.main-content-body { - display: flex; - min-height: fit-content; -} - -.info-container { - position: sticky; - left: 0; - width: 200px; - z-index: 1002; - background-color: #2d2d2d; - align-self: flex-start; -} - -/* Shift the bar numbers and info panel when instrument selection is present */ -/* No need to shift bar numbers or info container; they live inside main content. - Only shift the fixed spacer when the left panel is present. */ - -.track-top-spacer { - height: 20px; - border-bottom: 1px solid #3a3a3a; - background-color: #2d2d2d; - position: sticky; - top: 0; - z-index: 15; -} - -.grid-container { - margin-left: 0; /* No need for margin since we're using flex */ - min-width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); /* Ensure minimum width */ - min-height: fit-content; - position: relative; /* Required for absolute positioned playhead */ -} - -.track-grid { - display: block; - height: 120px; - min-width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); /* 32 bars * --track-grid-bar-width */ - background-size: var(--track-grid-bar-width) 120px; - background-image: - /* Vertical lines for bars */ - linear-gradient(to right, - transparent calc(var(--track-grid-bar-width) - 1px), #3a3a3a calc(var(--track-grid-bar-width) - 1px), #3a3a3a var(--track-grid-bar-width) - ); - position: relative; - border-bottom: 1px solid #3a3a3a; -} - -.track-grid.pencil-cursor { - cursor: crosshair; -} - -.add-track-btn { - background: transparent; - border: none; - outline: none; - color: #999; - cursor: pointer; - font-size: 12px; - padding: 0; - height: 100%; - width: 100%; - display: flex; - align-items: center; - justify-content: center; -} - -.add-track-btn:focus, -.add-track-btn:focus-visible { - outline: none; -} - -.add-track-btn:hover { - color: #e0e0e0; -} - -.track { - display: flex; - height: 120px; - position: relative; - border-bottom: none; -} - -.track-info { - width: 200px; - height: 120px; - padding: 15px; - background-color: #2d2d2d; - display: flex; - flex-direction: column; - border-right: 1px solid #3a3a3a; - border-bottom: 1px solid #3a3a3a; - flex-shrink: 0; - box-sizing: border-box; - cursor: grab; - position: relative; -} - -.track-info:active { - cursor: grabbing; -} - -.track-info.selected { - background-color: #363636; -} - -/* Drag and drop styles */ -.track-info.drag-over { - border-top: 2px solid #7b68ee; -} - -.track-grid.drag-over { - border-top: 2px solid #7b68ee; -} - -/* Dragging state */ -.track-info.dragging { - opacity: 0.5; - background-color: #444; -} - -.track-grid.dragging { - opacity: 0.5; - background-color: #333; -} - -/* Remove drag indicator since hover cursor is sufficient */ - -.track-name { - font-size: 12px; - cursor: pointer; - position: relative; -} - -.track-name:hover { - color: #7b68ee; -} - -.track-name:hover::before { - content: "✎"; - position: absolute; - right: 0px; - font-size: 16px; - opacity: 1; -} - -.track-controls { - display: flex; - flex-direction: column; - width: 100%; - height: 100%; -} - -.track-name-and-volume { - display: flex; - flex-direction: row; - flex: 1; - align-items: flex-start; - gap: 8px; -} - -.instrument-image { - flex-shrink: 0; - margin-top: -10px; - margin-left: -10px; -} - -.instrument-image img { - display: block; - border-radius: 4px; -} - -.track-name-and-controls { - display: flex; - flex-direction: column; - flex: 1; - min-width: 0; -} - -.volume-slider { - margin-bottom: 15px; - width: 100%; - display: flex; - align-items: center; - gap: 6px; -} - -.volume-slider input[type="range"] { - flex: 1; - min-width: 0; - height: 6px; -} - -.volume-slider .reset-volume { - flex-shrink: 0; - width: 16px; - height: 16px; - border-radius: 8px; - background: #3a3a3a; - color: #e0e0e0; - /* border: 1px solid #444; */ - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; - font-size: 10px; -} - -.volume-slider .reset-volume:hover { - background: #4a4a4a; -} - -.pan-controls { - display: flex; -} - -.pan-controls button { - background: #3a3a3a; - border: none; - color: #e0e0e0; - margin-right: 5px; - width: 24px; - height: 24px; - font-size: 12px; - border-radius: 2px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; -} - -.pan-controls button.solo.active { - background: #c8a400; /* yellow */ - color: #101010; -} - -.pan-controls button.mute.active { - background: #d32f2f; /* red */ - color: #ffffff; -} - -/* Status bar */ -.status-bar { - display: flex; - justify-content: space-between; - align-items: center; - background-color: #2d2d2d; - padding: 5px 10px; - font-size: 12px; - color: #999; - border-top: 1px solid #3a3a3a; - height: 30px; -} - -.status-right span { - margin-left: 15px; -} - -/* For the piano roll (to be implemented later) */ -.piano-roll { - background-color: #7b68ee; - height: 100%; -} - -/* Piano Roll Panel */ -.piano-roll-panel { - position: fixed; - background-color: #2d2d2d; - border: 1px solid #3a3a3a; - box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); - display: flex; - flex-direction: column; - overflow: hidden; -} - -/* Piano notes */ -.piano-note { - position: absolute; - background-color: #ff5555; /* Red color */ - border: 1px solid #999; - border-radius: 2px; - z-index: 10; - cursor: pointer; - user-select: none; - box-sizing: border-box; -} - -.piano-note:hover { - filter: brightness(1.1); - box-shadow: 0 0 5px rgba(255, 85, 85, 0.5); -} - -.piano-note.dragging { - opacity: 0.8; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); - z-index: 100; - cursor: grabbing; -} - -.piano-note.resizing { - opacity: 0.8; - box-shadow: 0 0 10px rgba(255, 85, 85, 0.7); - z-index: 100; -} - -.piano-note.selected { - border: 2px solid white; - z-index: 50; -} - -.piano-roll-header { - height: 30px; - background-color: #1e1e1e; - border-bottom: 1px solid #3a3a3a; - display: flex; - align-items: center; - padding: 0 10px; - cursor: move; - user-select: none; -} - -/* Piano roll toolbar */ -.piano-roll-toolbar { - height: 30px; - background-color: #252525; - border-bottom: 1px solid #3a3a3a; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 10px; - user-select: none; - position: relative; - z-index: 100; /* Ensure toolbar and its dropdowns appear above piano roll content */ -} - -/* Override pointer-events for piano roll toolbar sections */ -.piano-roll-toolbar .toolbar-left, -.piano-roll-toolbar .toolbar-right { - pointer-events: auto; -} - -.piano-roll-toolbar .quant-button { - font-size: 10px; -} - -.piano-roll-toolbar .toolbar-left .quant-button { - margin-left: 0px; - margin-right: 5px; -} - -.piano-roll-toolbar .toolbar-left .quant-dropdown { - left: 0; -} - -/* Generic button blink effect */ -.quant-button.button-blink { - animation: buttonBlink 0.2s ease-in-out; -} - -@keyframes buttonBlink { - 0% { background-color: #333; } - 50% { background-color: #555; } - 100% { background-color: #333; } -} - -.toolbar-left, .toolbar-right { - display: flex; - align-items: center; - z-index: 1; - pointer-events: none; -} - -.toolbar-left { - width: 20%; - justify-content: flex-start; -} - -.toolbar-right { - width: 33%; - justify-content: flex-end; - z-index: 1500; -} - -.toolbar-center { - position: absolute; - left: 50%; - transform: translateX(-50%); - display: flex; - align-items: center; - justify-content: center; - z-index: 1005; - margin-left: -50px; -} - -.tool-button { - width: 30px; - height: 30px; - background-color: transparent; - border: none; - border-radius: 3px; - color: #999; - font-size: 14px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - margin: 0 2px; -} - -.tool-button:hover { - background-color: #3a3a3a; - color: #e0e0e0; -} - -.tool-button.active { - background-color: #4a4a4a; - color: #e0e0e0; -} - -.chatbox-export-dropdown .quant-dropdown { - width: 250px; - left: -200px; -} - -.key-signature-dropdown .quant-dropdown { - width: 100px; - left: 0; -} - -.quant-button { - background-color: #333; - border: 1px solid #444; - border-radius: 3px; - color: #e0e0e0; - font-size: 12px; - padding: 3px 8px; - margin-left: 5px; - cursor: pointer; - display: flex; - align-items: center; - gap: 5px; -} - -.quant-button:hover { - background-color: #444; -} - -.quant-dropdown-container { - position: relative; - display: inline-block; - z-index: 1500; -} - -.quant-dropdown { - position: absolute; - top: 100%; - right: 0; - background-color: #2d2d2d; - border: 1px solid #444; - border-radius: 3px; - width: 100px; - z-index: 1500; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); - margin-top: 2px; - max-height: 200px; - overflow-y: auto; -} - -.quant-option { - padding: 6px 10px; - font-size: 12px; - color: #e0e0e0; - cursor: pointer; - transition: background-color 0.2s; -} - -.quant-option:hover { - background-color: #444; -} - -.quant-option.active { - background-color: #4a6b8a; -} - -.piano-roll-title { - flex: 1; - text-align: center; - font-size: 14px; - color: #e0e0e0; - text-transform: uppercase; - cursor: pointer; - padding: 5px; - border-radius: 3px; -} - -.close-button { - background: transparent; - border: none; - color: #e0e0e0; - cursor: pointer; - font-size: 14px; - padding: 5px; -} - -.close-button:hover { - color: #ff4444; -} - -.piano-roll-content { - display: flex; - flex-direction: column; - flex: 1; - overflow: auto; -} - -.piano-grid-header { - display: grid; - grid-template-columns: repeat(var(--max-number-of-bars), var(--region-grid-bar-width)); - width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width) - 1px); - min-width: 100%; - height: 20px; - border-bottom: 1px solid #3a3a3a; - background-color: #1e1e1e; - padding-left: calc(var(--region-piano-key-width) - 1px); - box-sizing: border-box; - position: sticky; - top: 0; - z-index: 20; - cursor: pointer; /* Show pointer cursor on hover to indicate interactivity */ -} - -.piano-grid-header:hover { - cursor: url("data:image/svg+xml,%3csvg width='16' height='16' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M4 6h8l-4 4-4-4z' fill='%23e0e0e0'/%3e%3c/svg%3e") 8 8, pointer; -} - -.piano-bar-number { - border-left: 1px solid #3a3a3a; - padding-left: 10px; - font-size: 10px; -} - -.piano-roll-body { - display: flex; - min-height: calc(8 * 12 * var(--region-piano-key-height)); /* 8 octaves * 12 notes * piano key height */ - width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width)); /* 32 bars * 160px width + piano keys width */ -} - -.piano-keys-container { - width: var(--region-piano-key-width); - flex-shrink: 0; - overflow: hidden; - border-right: 1px solid #3a3a3a; - background-color: #252525; - position: sticky; - left: 0; - z-index: 10; -} - -.piano-grid-container { - flex: 1; - min-width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width)); - min-height: calc(8 * 12 * var(--region-piano-key-height)); - z-index: 5; -} - -.piano-octave { - display: flex; - flex-direction: column; -} - -.piano-key { - width: var(--region-piano-key-width); - height: var(--region-piano-key-height); - box-sizing: border-box; - display: flex; - align-items: center; - border-bottom: 1px solid #3a3a3a; -} - -.piano-key.natural { - background-color: #e0e0e0; - color: #222; -} - -.piano-key.sharp { - background-color: #222; - color: #e0e0e0; -} - -.key-label { - font-size: 10px; - padding-left: 5px; -} - -.piano-grid { - width: 100%; - height: 100%; - position: relative; - background-size: var(--region-grid-beat-width) var(--region-piano-key-height), 100% 100%; - /* background-image is now set dynamically via React inline styles in PianoGrid component */ -} - -.piano-grid.pencil-cursor { - cursor: crosshair; -} - -/* Piano Grid Cursor Highlights */ -.piano-grid-pitch-highlight { - position: absolute; - left: 0; - right: 0; - background-color: rgba(123, 104, 238, 0.15); - border-top: 1px solid rgba(123, 104, 238, 0.3); - border-bottom: 1px solid rgba(123, 104, 238, 0.3); - pointer-events: none; - z-index: 1; - transition: opacity 0.1s ease; -} - -.piano-grid-beat-highlight { - position: absolute; - top: 0; - bottom: 0; - background-color: rgba(123, 104, 238, 0.1); - border-left: 1px solid rgba(123, 104, 238, 0.25); - border-right: 1px solid rgba(123, 104, 238, 0.25); - pointer-events: none; - z-index: 1; - transition: opacity 0.1s ease; -} - -.piano-grid-chord-highlight { - position: absolute; - background-color: rgba(255, 77, 77, 0.25); - border: 1px solid rgba(255, 77, 77, 0.8); - pointer-events: none; - z-index: 2; - transition: opacity 0.1s ease; -} - -.resize-handle { - position: absolute; - right: 5px; - bottom: 5px; - width: 20px; - height: 20px; - cursor: nwse-resize; - color: #999; - display: flex; - align-items: center; - justify-content: center; - z-index: 100; -} - -/* Track Region Styles */ -.track-region { - position: absolute; - background-color: #4a6b8a; - border: 2px solid #5a7b9a; - border-radius: 3px; - height: calc(100%); - margin: 0px; - overflow: hidden; - display: flex; - flex-direction: column; - cursor: pointer; - user-select: none; - transition: box-shadow 0.1s ease; - will-change: transform; -} - -.track-region:hover { - box-shadow: 0 0 0 1px #7a9bba; -} - -.track-region.selected { - /* box-shadow: 0 0 0 2px #ffffff, 0 0 8px rgba(255, 255, 255, 0.3); */ /* let's only apply a border */ - border-color: #ffffff; -} - -.track-region.dragging { - opacity: 0.8; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); - z-index: 100; - pointer-events: none; - transform-origin: center center; - animation: pulse 1.5s infinite; - cursor: grabbing; - transition: none; /* Remove transition during drag for immediate response */ -} - -@keyframes pulse { - 0% { - box-shadow: 0 0 0 0 rgba(122, 155, 186, 0.7); - } - 70% { - box-shadow: 0 0 0 5px rgba(122, 155, 186, 0); - } - 100% { - box-shadow: 0 0 0 0 rgba(122, 155, 186, 0); - } -} - -.region-header { - background-color: #5a7b9a; - color: #fff; - padding: 2px 6px; - font-size: 11px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - cursor: move; - height: 18px; - user-select: none; -} - -.track-region:hover .region-header { - background-color: #6a8baa; -} - -.region-content { - height: calc(100% - 18px); - background-color: #87CEFA; /* Light blue */ - width: 100%; - position: relative; /* Allow overlayed controls */ -} - -/* Region pencil trigger inside content */ -.region-pencil-btn { - position: absolute; - top: 4px; - left: 4px; - background: rgba(0, 0, 0, 0.25); - color: #fff; - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 3px; - padding: 2px; - margin: 0; - cursor: pointer; - z-index: 2; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.region-pencil-btn:hover { - background: rgba(0, 0, 0, 0.35); -} - -/* Instrument dropdown specific styles */ -.instrument-dropdown .quant-dropdown { - min-width: 80px; - width: auto; - right: auto; - left: 0; -} - -/* Settings dropdown specific styles */ -.settings-dropdown .quant-dropdown { - min-width: 100px; - width: auto; - right: auto; - left: 0; -} - /* Update structure in JSX to match this pattern:
@@ -1049,1032 +49,3 @@ body {
*/ - -/* ChatBox */ -.chatbox { - display: flex; - flex-direction: column; - width: var(--chat-box-width); - background-color: #2d2d2d; - border-left: 1px solid #3a3a3a; - flex-shrink: 0; -} - -.chatbox.is-hidden { - display: none; -} - -/* Instrument Selection Panel */ -.instrument-selection { - display: flex; - flex-direction: column; - width: var(--instrument-selection-width); - background-color: #2d2d2d; - border-right: 1px solid #3a3a3a; - flex-shrink: 0; - position: relative; -} - -.instrument-selection-header { - display: flex; - align-items: center; - justify-content: space-between; - height: 40px; - padding: 0 12px; - background-color: transparent; - border: none; - position: absolute; - top: 0; - left: 0; - right: 0; - z-index: 2; -} - -.instrument-selection-header h3 { - color: #e0e0e0; - font-size: 12px; - font-weight: bold; -} - -.instrument-selection-close-btn { - background: transparent; - border: none; - color: #e0e0e0; - cursor: pointer; - padding: 4px; - border-radius: 3px; -} - -.instrument-selection-close-btn:hover { - background-color: #4a4a4a; -} - -.instrument-selection-top { - height: 300px; - position: relative; - border-bottom: 1px solid #3a3a3a; - overflow: hidden; - background-image: - linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.35)), - url('/resources/instrument_bg.png'); - background-size: cover; /* fill while keeping aspect ratio */ - background-position: center; -} - -.instrument-preview { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; -} - -.instrument-preview img { - width: 256px; - height: 256px; - object-fit: contain; - border-radius: 6px; - opacity: 1; -} - -.instrument-name-overlay { - position: absolute; - bottom: 16px; - left: 0; - right: 0; - text-align: center; - font-size: 12px; - color: #e0e0e0; - text-shadow: 0 1px 2px rgba(0,0,0,0.6); - z-index: 1; - font-size: 16px; - font-weight: bold; -} - -.instrument-selection-bottom { - flex: 1; - display: grid; - grid-template-columns: 1fr 1fr; - min-height: 0; /* allow children to scroll */ -} - -.instrument-groups, .instrument-list { - overflow: hidden; -} - -/* vertical divider between groups and instruments */ -.instrument-groups { - border-right: 1px solid #3a3a3a; -} - -.instrument-groups-list, .instrument-instruments-list { - height: 100%; - overflow-y: auto; -} - -.instrument-group-item, .instrument-instrument-item { - padding: 8px 12px; - /* remove per-item separators */ - border-bottom: none; - cursor: pointer; - font-size: 12px; - color: #e0e0e0; -} - -.instrument-group-item:hover, .instrument-instrument-item:hover { - background-color: #3a3a3a; -} - -.instrument-group-item.active, .instrument-instrument-item.active { - background-color: #4a4a4a; -} - -.chatbox-header { - display: flex; - align-items: center; - justify-content: space-between; /* left title, right actions */ - background-color: #3a3a3a; - height: 40px; - border-bottom: 1px solid #4a4a4a; - padding: 0 15px; -} - -.chatbox-actions { - display: flex; - gap: 8px; - align-items: center; -} - -.chatbox-action-btn { - background: transparent; - border: none; - cursor: pointer; - padding: 4px; - display: inline-flex; - align-items: center; - justify-content: center; - color: #e0e0e0; - font-size: 14px; -} - -.chatbox-action-btn:hover { - background-color: #4a4a4a; - border-radius: 3px; -} - -/* ChatBox export button wrapper and dropdown positioning */ -.chatbox-export-wrapper { - position: relative; - display: inline-block; -} - -.chatbox-export-btn { - display: flex; - align-items: center; - gap: 4px; -} - -.chatbox-export-dropdown-anchor { - position: absolute; - top: 100%; - left: 0; - z-index: 10000; -} - -.chatbox-header h3 { - color: #e0e0e0; - font-size: 12px; - font-weight: bold; - margin: 0; -} - -.chatbox-content { - flex: 1; - padding: 15px; - color: #e0e0e0; - font-size: 12px; - overflow-y: auto; -} - -/* Chat Messages */ -.chatbox-messages { - flex: 1; - padding: 10px; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 12px; -} - -.message-container { - width: 100%; - word-wrap: break-word; -} - -.message-user { - width: 100%; -} - -.message-assistant { - width: 100%; -} - -.message-content { - padding: 8px 12px; - border-radius: 8px; - font-size: 12px; - line-height: 1.4; -} - -/* User message styling - lighter background like input */ -.message-user .message-content { - background-color: #3a3a3a; - color: #e0e0e0; - border: 1px solid #4a4a4a; -} - -/* Assistant message styling - transparent */ -.message-assistant .message-content { - background-color: transparent; - color: #e0e0e0; - border: 1px solid #2a2a2a; -} - -/* Input Area */ -.chatbox-input-area { - padding: 10px; -} - -.chatbox-input { - width: 100%; - padding: 8px 12px; - background-color: #4a4a4a; - border: 1px solid #5a5a5a; - border-radius: 6px; - color: #e0e0e0; - font-size: 12px; - outline: none; - resize: none; - min-height: 36px; - max-height: 120px; -} - -.chatbox-input:focus { - border-color: #6a6a6a; - background-color: #4a4a4a; -} - -.chatbox-input::placeholder { - color: #999; -} - - -/* Markdown styling in messages */ -.message-content h1, -.message-content h2, -.message-content h3 { - margin: 8px 0 4px 0; - color: #e0e0e0; -} - -.message-content h3 { - font-size: 14px; -} - -.message-content p { - margin: 4px 0; -} - -.message-content ul, -.message-content ol { - margin: 4px 0; - padding-left: 16px; -} - -.message-content li { - margin: 2px 0; -} - -.message-content code { - background-color: #2a2a2a; - padding: 2px 4px; - border-radius: 3px; - font-family: 'Courier New', Courier, monospace; - font-size: 11px; -} - -.message-content pre { - background-color: #1a1a1a; - padding: 8px; - border-radius: 6px; - overflow-x: auto; - margin: 8px 0; -} - -.message-content blockquote { - border-left: 3px solid #5a5a5a; - padding-left: 12px; - margin: 8px 0; - color: #ccc; -} - -.message-content table { - border-collapse: collapse; - width: 100%; - margin: 8px 0; - font-size: 11px; -} - -.message-content th, -.message-content td { - border: 1px solid #4a4a4a; - padding: 4px 8px; - text-align: left; -} - -.message-content th { - background-color: #3a3a3a; - font-weight: bold; -} - -textarea { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - font-size: 12px; - line-height: 1.4; -} - -/* Abort link styling */ -.abort-link { - background: none !important; - border: none !important; - color: #7b68ee !important; - text-decoration: underline !important; - cursor: pointer !important; - padding: 0 !important; - font: inherit !important; - font-size: 12px !important; -} - -.abort-link:hover { - color: #9b88ff !important; - text-decoration: none !important; -} - -/* Tool XML Expander */ -.tool-xml-expander { - margin-bottom: 8px; -} - -.tool-xml-expander-header { - color: #e0e0e0; - cursor: pointer; - user-select: none; - padding: 4px 8px; - /* background-color: #f5f5f5; */ - border: 1px solid #2a2a2a; - border-radius: 4px; - display: flex; - align-items: center; - gap: 8px; -} - -.tool-xml-expander-arrow { - font-size: 12px; - color: #666; -} - -.tool-xml-expander-title { - /* font-family: 'Courier New', Monaco, 'Menlo', 'Ubuntu Mono', monospace; */ - font-size: 12px; - font-weight: bold; - color: #e0e0e0; -} - -.tool-xml-expander-content { - margin-top: 4px; - padding: 8px; - background-color: #f9f9f9; - border: 1px solid #ddd; - border-radius: 4px; - font-family: 'Courier New', Monaco, 'Menlo', 'Ubuntu Mono', monospace; - font-size: 12px; - white-space: pre-wrap; - overflow: auto; - max-height: 200px; - color: #333; -} - -/* Settings Panel Styles */ -.settings-panel { - width: 100%; - height: 100%; - background-color: #1e1e1e; - display: flex; - flex-direction: column; -} - -.settings-container { - display: flex; - height: 100%; - overflow: hidden; -} - -/* Settings Sidebar */ -.settings-sidebar { - width: 250px; - background-color: #2d2d2d; - border-right: 1px solid #3a3a3a; - display: flex; - flex-direction: column; -} - -.settings-sidebar-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 20px; - border-bottom: 1px solid #3a3a3a; -} - -.settings-sidebar-header h2 { - color: #e0e0e0; - font-size: 18px; - font-weight: 600; - margin: 0; -} - -.settings-close-btn { - background: transparent; - border: none; - color: #e0e0e0; - cursor: pointer; - padding: 5px; - border-radius: 3px; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; -} - -.settings-close-btn:hover { - background-color: #3a3a3a; - color: #fff; -} - -.settings-nav { - display: flex; - flex-direction: column; - padding: 10px; -} - -.settings-nav-item { - background: transparent; - border: none; - color: #b0b0b0; - padding: 12px 16px; - text-align: left; - cursor: pointer; - border-radius: 4px; - margin-bottom: 2px; - font-size: 14px; - font-weight: 500; - transition: all 0.2s ease; -} - -.settings-nav-item:hover { - background-color: #3a3a3a; - color: #e0e0e0; -} - -.settings-nav-item.active { - background-color: #4a4a4a; - color: #fff; -} - -/* Settings Content */ -.settings-content { - flex: 1; - overflow-y: auto; - background-color: #1e1e1e; - display: flex; - justify-content: center; -} - -.settings-section { - padding: 30px; - width: 800px; -} - -.settings-section-header { - margin-bottom: 30px; - border-bottom: 1px solid #3a3a3a; - padding-bottom: 15px; -} - -.settings-section-header h3 { - color: #fff; - font-size: 24px; - font-weight: 600; - margin: 0; -} - -.settings-section-content { - display: flex; - flex-direction: column; - gap: 30px; - padding-bottom: 30px; -} - -.settings-group { - background-color: #2d2d2d; - border-radius: 8px; - padding: 20px; - border: 1px solid #3a3a3a; -} - -.settings-group h4 { - color: #fff; - font-size: 16px; - font-weight: 600; - margin: 0 0 15px 0; -} - -.settings-group-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 15px; -} - -.settings-group-header h4 { - margin: 0; -} - -.settings-description { - color: #b0b0b0; - font-size: 14px; - line-height: 1.5; - margin-bottom: 15px; -} - -.settings-item { - margin-bottom: 15px; -} - -.settings-item:last-child { - margin-bottom: 0; -} - -.settings-label { - display: block; - color: #e0e0e0; - font-size: 14px; - font-weight: 500; - margin-bottom: 5px; -} - -.settings-input, .settings-select, .settings-textarea { - width: 100%; - /* max-width: 300px; */ - padding: 8px 12px; - background-color: #3a3a3a; - border: 1px solid #555; - border-radius: 4px; - color: #e0e0e0; - font-size: 14px; - transition: border-color 0.2s ease; - font-family: inherit; -} - -.settings-input:focus, .settings-select:focus, .settings-textarea:focus { - outline: none; - border-color: #5a9fd4; - box-shadow: 0 0 0 2px rgba(90, 159, 212, 0.2); -} - -.settings-textarea { - max-width: 600px; - resize: vertical; - min-height: 120px; -} - -.settings-input[type="number"] { - max-width: 100px; -} - -/* Custom Checkbox */ -.settings-checkbox-container { - display: flex; - align-items: center; - cursor: pointer; - color: #e0e0e0; - font-size: 14px; - user-select: none; -} - -.settings-checkbox-container input[type="checkbox"] { - display: none; -} - -.settings-checkmark { - width: 18px; - height: 18px; - background-color: #3a3a3a; - border: 1px solid #555; - border-radius: 3px; - margin-right: 10px; - position: relative; - transition: all 0.2s ease; -} - -.settings-checkbox-container input[type="checkbox"]:checked + .settings-checkmark { - background-color: #5a9fd4; - border-color: #5a9fd4; -} - -.settings-checkbox-container input[type="checkbox"]:checked + .settings-checkmark::after { - content: ''; - position: absolute; - left: 5px; - top: 2px; - width: 6px; - height: 10px; - border: solid white; - border-width: 0 2px 2px 0; - transform: rotate(45deg); -} - -/* Settings Buttons */ -.settings-btn { - background-color: #3a3a3a; - border: 1px solid #555; - color: #e0e0e0; - padding: 8px 16px; - border-radius: 4px; - cursor: pointer; - font-size: 14px; - font-weight: 500; - transition: all 0.2s ease; - display: inline-flex; - align-items: center; - gap: 6px; -} - -.settings-btn:hover { - background-color: #4a4a4a; - border-color: #666; -} - -.settings-btn-primary { - background-color: #5a9fd4; - border-color: #5a9fd4; - color: #fff; -} - -.settings-btn-primary:hover { - background-color: #4a8fc4; - border-color: #4a8fc4; -} - -.settings-btn-danger { - background-color: #d45a5a; - border-color: #d45a5a; - color: #fff; -} - -.settings-btn-danger:hover { - background-color: #c44a4a; - border-color: #c44a4a; -} - -.settings-btn-danger:disabled { - background-color: #555; - border-color: #555; - color: #888; - cursor: not-allowed; -} - -.settings-btn-small { - padding: 6px 10px; - font-size: 12px; -} - -/* Settings Help Links */ -.settings-help-links { - display: flex; - gap: 16px; - margin-bottom: 8px; -} - -button.settings-help { - color: #5a9fd4; - text-decoration: underline; - cursor: pointer; - font-size: 14px; - background: none; - border: none; - padding: 0; - font-family: inherit; -} - -button.settings-help:hover { - color: #7bbfef; -} - -/* Settings Validation Errors */ -.settings-validation-errors { - margin-top: 8px; - padding: 8px; - background-color: rgba(211, 90, 90, 0.1); - border: 1px solid #d35a5a; - border-radius: 4px; - max-height: 200px; - overflow-y: auto; -} - -.settings-validation-error { - color: #ff6b6b; - font-size: 12px; - line-height: 1.4; - margin-bottom: 4px; -} - -.settings-validation-error:last-child { - margin-bottom: 0; -} - -/* Templates List */ -.templates-list { - display: flex; - flex-direction: column; - gap: 10px; - margin-top: 15px; -} - -.template-item { - background-color: #3a3a3a; - border: 1px solid #555; - border-radius: 6px; - padding: 15px; - display: flex; - justify-content: space-between; - align-items: center; - transition: border-color 0.2s ease; -} - -.template-item:hover { - border-color: #666; -} - -.template-info { - flex: 1; -} - -.template-name { - color: #fff; - font-size: 16px; - font-weight: 600; - margin: 0 0 5px 0; -} - -.template-description { - color: #b0b0b0; - font-size: 14px; - margin: 0 0 5px 0; -} - -.template-tracks { - color: #888; - font-size: 12px; - font-weight: 500; -} - -.template-actions { - display: flex; - gap: 8px; -} - -/* File Import Modal */ -.file-import-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.7); - display: flex; - align-items: center; - justify-content: center; - z-index: 10001; - backdrop-filter: blur(2px); -} - -.file-import-modal { - background-color: #2d2d2d; - border: 1px solid #3a3a3a; - border-radius: 8px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); - width: 90%; - max-width: 500px; - max-height: 80vh; - overflow: hidden; - animation: fileImportFadeIn 0.2s ease-out; -} - -@keyframes fileImportFadeIn { - from { - opacity: 0; - transform: scale(0.95) translateY(-10px); - } - to { - opacity: 1; - transform: scale(1) translateY(0); - } -} - -.file-import-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 20px; - border-bottom: 1px solid #3a3a3a; - background-color: #252525; -} - -.file-import-title { - color: #e0e0e0; - font-size: 18px; - font-weight: 600; - margin: 0; -} - -.file-import-close-btn { - background: transparent; - border: none; - color: #b0b0b0; - cursor: pointer; - padding: 8px; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - transition: all 0.2s ease; -} - -.file-import-close-btn:hover { - background-color: #3a3a3a; - color: #e0e0e0; -} - -.file-import-drop-zone { - padding: 40px 20px; - margin: 20px; - border: 2px dashed #555; - border-radius: 8px; - background-color: #1e1e1e; - transition: all 0.3s ease; - cursor: pointer; -} - -.file-import-drop-zone:hover { - border-color: #7b68ee; - background-color: rgba(123, 104, 238, 0.05); -} - -.file-import-drop-zone.drag-over { - border-color: #7b68ee; - background-color: rgba(123, 104, 238, 0.1); - transform: scale(1.02); -} - -.file-import-drop-content { - text-align: center; - color: #e0e0e0; -} - -.file-import-icon { - font-size: 48px; - margin-bottom: 16px; - opacity: 0.7; -} - -.file-import-description { - font-size: 18px; - font-weight: 500; - margin: 0 0 8px 0; - color: #e0e0e0; -} - -.file-import-formats { - font-size: 14px; - color: #b0b0b0; - margin: 0 0 24px 0; -} - -.file-import-divider { - position: relative; - margin: 24px 0; - text-align: center; -} - -.file-import-divider::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 1px; - background-color: #3a3a3a; -} - -.file-import-divider span { - background-color: #1e1e1e; - padding: 0 12px; - color: #888; - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.file-import-browse-btn { - display: inline-block; - background-color: #5a9fd4; - color: #fff; - padding: 12px 24px; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - border: none; -} - -.file-import-browse-btn:hover { - background-color: #4a8fc4; - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(90, 159, 212, 0.3); -} - -/* Global Loading Overlay */ -.global-loading-overlay { - position: fixed; - inset: 0; - background-color: rgba(0, 0, 0, 0.4); - z-index: 20000; - display: flex; - align-items: center; - justify-content: center; - pointer-events: all; /* block interactions */ -} - -.global-loading-content { - display: flex; - flex-direction: column; - align-items: center; - gap: 10px; -} - -.global-loading-spinner { - width: 40px; - height: 40px; - border: 4px solid rgba(255, 255, 255, 0.25); - border-top-color: #ffffff; - border-radius: 50%; - animation: globalSpinner 1s linear infinite; -} - -.global-loading-text { - color: #e0e0e0; - font-size: 14px; -} - -@keyframes globalSpinner { - to { transform: rotate(360deg); } -} - -/* Processing wave animation */ -.processing-wave { - background: linear-gradient( - 90deg, - rgba(135, 206, 250, 0.5) 0%, - rgba(135, 206, 250, 0.8) 25%, - rgb(171, 218, 250) 50%, - rgba(135, 206, 250, 0.8) 75%, - rgba(135, 206, 250, 0.5) 100% - ); - background-size: 200% 100%; - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - animation: wave 2s linear infinite; -} - -@keyframes wave { - 0% { - background-position: 200% 0%; - } - 100% { - background-position: -200% 0%; - } -} diff --git a/src/App.tsx b/src/App.tsx index f219f79..fc635c3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import './App.css'; +import './styles/shared.css'; import { useProjectStore } from './stores/projectStore'; import { useGlobalKeyboardHandler } from './hooks/useGlobalKeyboardHandler'; import Toolbar from './components/Toolbar'; diff --git a/src/components/ChatBox.css b/src/components/ChatBox.css new file mode 100644 index 0000000..6018761 --- /dev/null +++ b/src/components/ChatBox.css @@ -0,0 +1,313 @@ +/* ChatBox */ +.chatbox { + display: flex; + flex-direction: column; + width: var(--chat-box-width); + background-color: #2d2d2d; + border-left: 1px solid #3a3a3a; + flex-shrink: 0; +} + +.chatbox.is-hidden { + display: none; +} + +.chatbox-header { + display: flex; + align-items: center; + justify-content: space-between; /* left title, right actions */ + background-color: #3a3a3a; + height: 40px; + border-bottom: 1px solid #4a4a4a; + padding: 0 15px; +} + +.chatbox-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.chatbox-action-btn { + background: transparent; + border: none; + cursor: pointer; + padding: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #e0e0e0; + font-size: 14px; +} + +.chatbox-action-btn:hover { + background-color: #4a4a4a; + border-radius: 3px; +} + +/* ChatBox export button wrapper and dropdown positioning */ +.chatbox-export-wrapper { + position: relative; + display: inline-block; +} + +.chatbox-export-btn { + display: flex; + align-items: center; + gap: 4px; +} + +.chatbox-export-dropdown-anchor { + position: absolute; + top: 100%; + left: 0; + z-index: 10000; +} + +.chatbox-export-dropdown .quant-dropdown { + width: 250px; + left: -200px; +} + +.chatbox-header h3 { + color: #e0e0e0; + font-size: 12px; + font-weight: bold; + margin: 0; +} + +.chatbox-content { + flex: 1; + padding: 15px; + color: #e0e0e0; + font-size: 12px; + overflow-y: auto; +} + +/* Chat Messages */ +.chatbox-messages { + flex: 1; + padding: 10px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; +} + +.message-container { + width: 100%; + word-wrap: break-word; +} + +.message-user { + width: 100%; +} + +.message-assistant { + width: 100%; +} + +.message-content { + padding: 8px 12px; + border-radius: 8px; + font-size: 12px; + line-height: 1.4; +} + +/* User message styling - lighter background like input */ +.message-user .message-content { + background-color: #3a3a3a; + color: #e0e0e0; + border: 1px solid #4a4a4a; +} + +/* Assistant message styling - transparent */ +.message-assistant .message-content { + background-color: transparent; + color: #e0e0e0; + border: 1px solid #2a2a2a; +} + +/* Input Area */ +.chatbox-input-area { + padding: 10px; +} + +.chatbox-input { + width: 100%; + padding: 8px 12px; + background-color: #4a4a4a; + border: 1px solid #5a5a5a; + border-radius: 6px; + color: #e0e0e0; + font-size: 12px; + outline: none; + resize: none; + min-height: 36px; + max-height: 120px; +} + +.chatbox-input:focus { + border-color: #6a6a6a; + background-color: #4a4a4a; +} + +.chatbox-input::placeholder { + color: #999; +} + + +/* Markdown styling in messages */ +.message-content h1, +.message-content h2, +.message-content h3 { + margin: 8px 0 4px 0; + color: #e0e0e0; +} + +.message-content h3 { + font-size: 14px; +} + +.message-content p { + margin: 4px 0; +} + +.message-content ul, +.message-content ol { + margin: 4px 0; + padding-left: 16px; +} + +.message-content li { + margin: 2px 0; +} + +.message-content code { + background-color: #2a2a2a; + padding: 2px 4px; + border-radius: 3px; + font-family: 'Courier New', Courier, monospace; + font-size: 11px; +} + +.message-content pre { + background-color: #1a1a1a; + padding: 8px; + border-radius: 6px; + overflow-x: auto; + margin: 8px 0; +} + +.message-content blockquote { + border-left: 3px solid #5a5a5a; + padding-left: 12px; + margin: 8px 0; + color: #ccc; +} + +.message-content table { + border-collapse: collapse; + width: 100%; + margin: 8px 0; + font-size: 11px; +} + +.message-content th, +.message-content td { + border: 1px solid #4a4a4a; + padding: 4px 8px; + text-align: left; +} + +.message-content th { + background-color: #3a3a3a; + font-weight: bold; +} + +/* Abort link styling */ +.abort-link { + background: none !important; + border: none !important; + color: #7b68ee !important; + text-decoration: underline !important; + cursor: pointer !important; + padding: 0 !important; + font: inherit !important; + font-size: 12px !important; +} + +.abort-link:hover { + color: #9b88ff !important; + text-decoration: none !important; +} + +/* Tool XML Expander */ +.tool-xml-expander { + margin-bottom: 8px; +} + +.tool-xml-expander-header { + color: #e0e0e0; + cursor: pointer; + user-select: none; + padding: 4px 8px; + /* background-color: #f5f5f5; */ + border: 1px solid #2a2a2a; + border-radius: 4px; + display: flex; + align-items: center; + gap: 8px; +} + +.tool-xml-expander-arrow { + font-size: 12px; + color: #666; +} + +.tool-xml-expander-title { + /* font-family: 'Courier New', Monaco, 'Menlo', 'Ubuntu Mono', monospace; */ + font-size: 12px; + font-weight: bold; + color: #e0e0e0; +} + +.tool-xml-expander-content { + margin-top: 4px; + padding: 8px; + background-color: #f9f9f9; + border: 1px solid #ddd; + border-radius: 4px; + font-family: 'Courier New', Monaco, 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; + white-space: pre-wrap; + overflow: auto; + max-height: 200px; + color: #333; +} + +/* Processing wave animation */ +.processing-wave { + background: linear-gradient( + 90deg, + rgba(135, 206, 250, 0.5) 0%, + rgba(135, 206, 250, 0.8) 25%, + rgb(171, 218, 250) 50%, + rgba(135, 206, 250, 0.8) 75%, + rgba(135, 206, 250, 0.5) 100% + ); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: wave 2s linear infinite; +} + +@keyframes wave { + 0% { + background-position: 200% 0%; + } + 100% { + background-position: -200% 0%; + } +} diff --git a/src/components/ChatBox.tsx b/src/components/ChatBox.tsx index 7852145..6fe7a06 100644 --- a/src/components/ChatBox.tsx +++ b/src/components/ChatBox.tsx @@ -1,4 +1,5 @@ import React, { useState, useRef, useEffect, memo, useCallback } from 'react'; +import './ChatBox.css'; import { FaPlus, FaBan, FaDownload } from 'react-icons/fa'; import { UserMessage, AssistantMessage } from './chat'; import { AgentCore } from '../agent/core/AgentCore'; diff --git a/src/components/InstrumentSelection.css b/src/components/InstrumentSelection.css new file mode 100644 index 0000000..0805958 --- /dev/null +++ b/src/components/InstrumentSelection.css @@ -0,0 +1,123 @@ +/* Instrument Selection Panel */ +.instrument-selection { + display: flex; + flex-direction: column; + width: var(--instrument-selection-width); + background-color: #2d2d2d; + border-right: 1px solid #3a3a3a; + flex-shrink: 0; + position: relative; +} + +.instrument-selection-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 40px; + padding: 0 12px; + background-color: transparent; + border: none; + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 2; +} + +.instrument-selection-header h3 { + color: #e0e0e0; + font-size: 12px; + font-weight: bold; +} + +.instrument-selection-close-btn { + background: transparent; + border: none; + color: #e0e0e0; + cursor: pointer; + padding: 4px; + border-radius: 3px; +} + +.instrument-selection-close-btn:hover { + background-color: #4a4a4a; +} + +.instrument-selection-top { + height: 300px; + position: relative; + border-bottom: 1px solid #3a3a3a; + overflow: hidden; + background-image: + linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.35)), + url('/resources/instrument_bg.png'); + background-size: cover; /* fill while keeping aspect ratio */ + background-position: center; +} + +.instrument-preview { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.instrument-preview img { + width: 256px; + height: 256px; + object-fit: contain; + border-radius: 6px; + opacity: 1; +} + +.instrument-name-overlay { + position: absolute; + bottom: 16px; + left: 0; + right: 0; + text-align: center; + font-size: 16px; + color: #e0e0e0; + text-shadow: 0 1px 2px rgba(0,0,0,0.6); + z-index: 1; + font-weight: bold; +} + +.instrument-selection-bottom { + flex: 1; + display: grid; + grid-template-columns: 1fr 1fr; + min-height: 0; /* allow children to scroll */ +} + +.instrument-groups, .instrument-list { + overflow: hidden; +} + +/* vertical divider between groups and instruments */ +.instrument-groups { + border-right: 1px solid #3a3a3a; +} + +.instrument-groups-list, .instrument-instruments-list { + height: 100%; + overflow-y: auto; +} + +.instrument-group-item, .instrument-instrument-item { + padding: 8px 12px; + /* remove per-item separators */ + border-bottom: none; + cursor: pointer; + font-size: 12px; + color: #e0e0e0; +} + +.instrument-group-item:hover, .instrument-instrument-item:hover { + background-color: #3a3a3a; +} + +.instrument-group-item.active, .instrument-instrument-item.active { + background-color: #4a4a4a; +} diff --git a/src/components/InstrumentSelection.tsx b/src/components/InstrumentSelection.tsx index 454a60c..b923e5e 100644 --- a/src/components/InstrumentSelection.tsx +++ b/src/components/InstrumentSelection.tsx @@ -1,4 +1,5 @@ import React, { useMemo, useState, useEffect } from 'react'; +import './InstrumentSelection.css'; import { useProjectStore } from '../stores/projectStore'; import { INSTRUMENT_GROUPS, FLUIDR3_INSTRUMENT_MAP } from '../constants/generalMidiConstants'; import { KGMidiTrack, type InstrumentType } from '../core/track/KGMidiTrack'; diff --git a/src/components/MainContent.css b/src/components/MainContent.css new file mode 100644 index 0000000..9dec725 --- /dev/null +++ b/src/components/MainContent.css @@ -0,0 +1,105 @@ +/* Main content */ +.main-content { + display: flex; + flex-direction: column; + flex: 1; + overflow: auto; /* Main scrollable container */ + position: relative; +} + +.main-content-wrapper { + display: flex; + flex-direction: column; + min-width: calc(200px + var(--max-number-of-bars) * var(--track-grid-bar-width)); /* info width + grid width */ + min-height: fit-content; + position: relative; +} + +.top-left-spacer { + position: fixed; + top: 50px; + left: 0; + width: 200px; + height: 20px; + background-color: #2d2d2d; + border-bottom: 1px solid #3a3a3a; + border-right: 1px solid #3a3a3a; + z-index: 1002; /* Higher than other elements to ensure it's always visible */ +} + +/* Offset spacer when instrument selection panel is visible on the left */ +.main-content.has-left-instrument .top-left-spacer { + left: 300px; +} + +.bar-numbers { + position: sticky; + top: 0; + height: 20px; + display: flex; + border-bottom: 1px solid #3a3a3a; + background-color: #2d2d2d; + z-index: 20; + margin-left: 200px; /* Offset for info-container */ + width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); /* Exact width for 32 bars */ + cursor: pointer; /* Show pointer cursor on hover to indicate interactivity */ +} + +.bar-numbers:hover { + cursor: url("data:image/svg+xml,%3csvg width='16' height='16' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M4 6h8l-4 4-4-4z' fill='%23e0e0e0'/%3e%3c/svg%3e") 8 8, pointer; +} + +.bar-number-cell { + min-width: var(--track-grid-bar-width); + width: var(--track-grid-bar-width); + flex-shrink: 0; + flex-grow: 0; + height: 20px; + text-align: center; + font-size: 12px; + border-right: 1px solid #3a3a3a; + color: #999; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; +} + +.bar-number-cell.looped { + background-color: #e1ae01; + color: #1e1e1e; +} + +.main-content-body { + display: flex; + min-height: fit-content; +} + +.info-container { + position: sticky; + left: 0; + width: 200px; + z-index: 1002; + background-color: #2d2d2d; + align-self: flex-start; +} + +/* Shift the bar numbers and info panel when instrument selection is present */ +/* No need to shift bar numbers or info container; they live inside main content. + Only shift the fixed spacer when the left panel is present. */ + +.track-top-spacer { + height: 20px; + border-bottom: 1px solid #3a3a3a; + background-color: #2d2d2d; + position: sticky; + top: 0; + z-index: 15; +} + +.grid-container { + margin-left: 0; /* No need for margin since we're using flex */ + min-width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); /* Ensure minimum width */ + min-height: fit-content; + position: relative; /* Required for absolute positioned playhead */ +} diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx index d8fb397..f09f55f 100644 --- a/src/components/MainContent.tsx +++ b/src/components/MainContent.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; +import './MainContent.css'; import { createPortal } from 'react-dom'; import { useProjectStore } from '../stores/projectStore'; import { KGCore } from '../core/KGCore'; diff --git a/src/components/StatusBar.css b/src/components/StatusBar.css new file mode 100644 index 0000000..e4c4feb --- /dev/null +++ b/src/components/StatusBar.css @@ -0,0 +1,16 @@ +/* Status bar */ +.status-bar { + display: flex; + justify-content: space-between; + align-items: center; + background-color: #2d2d2d; + padding: 5px 10px; + font-size: 12px; + color: #999; + border-top: 1px solid #3a3a3a; + height: 30px; +} + +.status-right span { + margin-left: 15px; +} diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index e52e4a4..17f23a5 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import './StatusBar.css'; import { useProjectStore } from '../stores/projectStore'; const StatusBar: React.FC = () => { diff --git a/src/components/Toolbar.css b/src/components/Toolbar.css new file mode 100644 index 0000000..6e7441c --- /dev/null +++ b/src/components/Toolbar.css @@ -0,0 +1,155 @@ +/* Toolbar */ +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + background-color: #2d2d2d; + height: 50px; + padding: 0 10px; + border-bottom: 1px solid #3a3a3a; +} + +.toolbar-left, .toolbar-center, .toolbar-right { + display: flex; + align-items: center; +} + +.toolbar-left { + display: flex; + align-items: center; +} + +.logo-container { + margin-right: 10px; + display: flex; + align-items: center; +} + +.logo { + height: 30px; + width: auto; +} + +.project-name { + font-size: 14px; + color: #e0e0e0; + margin-right: 20px; + display: flex; + align-items: center; + pointer-events: auto; +} + +.toolbar button { + background: transparent; + border: none; + color: #e0e0e0; + margin: 0 5px; + padding: 5px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 3px; + font-size: 14px; + pointer-events: auto; +} + +.toolbar button:hover { + background-color: #3a3a3a; +} + +.toolbar button:disabled { + color: #666; + cursor: not-allowed; +} + +.record-btn { + color: #ff4444; +} + +.toolbar-separator { + width: 1px; + height: 20px; + background-color: #555; + margin: 0 10px; +} + +.transport-control { + display: flex; + align-items: center; + margin: 0 10px; + pointer-events: auto; +} + +.transport-item { + margin: 0 5px; + padding: 2px 2px; + background-color: #3a3a3a; + border-radius: 3px; + font-size: 12px; +} + +/* Fixed-width font for time display to prevent layout shifts */ +.current-time { + font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-weight: normal; + letter-spacing: 0.5px; + padding: 2px 4px; +} + +/* Clickable BPM styling */ +.current-bpm { + font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-weight: normal; + letter-spacing: 0.5px; + cursor: pointer; + transition: background-color 0.2s ease; + padding: 2px 4px; + border-radius: 2px; +} + +.current-bpm:hover { + background-color: #4a4a4a; + color: #fff; +} + +/* Clickable time signature styling */ +.current-time-signature { + font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-weight: normal; + letter-spacing: 0.5px; + cursor: pointer; + transition: background-color 0.2s ease; + padding: 2px 4px; + border-radius: 2px; +} + +.current-time-signature:hover { + background-color: #4a4a4a; + color: #fff; +} + +.current-key-signature { + font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-weight: normal; + letter-spacing: 0.5px; + cursor: pointer; + transition: background-color 0.2s ease; + padding: 2px 4px; + border-radius: 2px; +} + +.current-key-signature:hover { + background-color: #4a4a4a; + color: #fff; +} + +.export-dropdown .quant-dropdown { + width: 250px; + left: 0; +} + +.key-signature-dropdown .quant-dropdown { + width: 100px; + left: 0; +} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 8ebef89..01d81b9 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import './Toolbar.css'; import { saveProject } from '../util/saveUtil'; import { KGStorage } from '../core/io/KGStorage'; import { DB_CONSTANTS } from '../constants/coreConstants'; diff --git a/src/components/common/FileImportModal.css b/src/components/common/FileImportModal.css new file mode 100644 index 0000000..5ce3f6f --- /dev/null +++ b/src/components/common/FileImportModal.css @@ -0,0 +1,161 @@ +/* File Import Modal */ +.file-import-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 10001; + backdrop-filter: blur(2px); +} + +.file-import-modal { + background-color: #2d2d2d; + border: 1px solid #3a3a3a; + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + width: 90%; + max-width: 500px; + max-height: 80vh; + overflow: hidden; + animation: fileImportFadeIn 0.2s ease-out; +} + +@keyframes fileImportFadeIn { + from { + opacity: 0; + transform: scale(0.95) translateY(-10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.file-import-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + border-bottom: 1px solid #3a3a3a; + background-color: #252525; +} + +.file-import-title { + color: #e0e0e0; + font-size: 18px; + font-weight: 600; + margin: 0; +} + +.file-import-close-btn { + background: transparent; + border: none; + color: #b0b0b0; + cursor: pointer; + padding: 8px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + transition: all 0.2s ease; +} + +.file-import-close-btn:hover { + background-color: #3a3a3a; + color: #e0e0e0; +} + +.file-import-drop-zone { + padding: 40px 20px; + margin: 20px; + border: 2px dashed #555; + border-radius: 8px; + background-color: #1e1e1e; + transition: all 0.3s ease; + cursor: pointer; +} + +.file-import-drop-zone:hover { + border-color: #7b68ee; + background-color: rgba(123, 104, 238, 0.05); +} + +.file-import-drop-zone.drag-over { + border-color: #7b68ee; + background-color: rgba(123, 104, 238, 0.1); + transform: scale(1.02); +} + +.file-import-drop-content { + text-align: center; + color: #e0e0e0; +} + +.file-import-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.7; +} + +.file-import-description { + font-size: 18px; + font-weight: 500; + margin: 0 0 8px 0; + color: #e0e0e0; +} + +.file-import-formats { + font-size: 14px; + color: #b0b0b0; + margin: 0 0 24px 0; +} + +.file-import-divider { + position: relative; + margin: 24px 0; + text-align: center; +} + +.file-import-divider::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background-color: #3a3a3a; +} + +.file-import-divider span { + background-color: #1e1e1e; + padding: 0 12px; + color: #888; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.file-import-browse-btn { + display: inline-block; + background-color: #5a9fd4; + color: #fff; + padding: 12px 24px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: none; +} + +.file-import-browse-btn:hover { + background-color: #4a8fc4; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(90, 159, 212, 0.3); +} diff --git a/src/components/common/FileImportModal.tsx b/src/components/common/FileImportModal.tsx index cd0e675..b57b90b 100644 --- a/src/components/common/FileImportModal.tsx +++ b/src/components/common/FileImportModal.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useState } from 'react'; +import './FileImportModal.css'; import { FaTimes } from 'react-icons/fa'; interface FileImportModalProps { diff --git a/src/components/common/LoadingOverlay.css b/src/components/common/LoadingOverlay.css new file mode 100644 index 0000000..56de905 --- /dev/null +++ b/src/components/common/LoadingOverlay.css @@ -0,0 +1,36 @@ +/* Global Loading Overlay */ +.global-loading-overlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.4); + z-index: 20000; + display: flex; + align-items: center; + justify-content: center; + pointer-events: all; /* block interactions */ +} + +.global-loading-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.global-loading-spinner { + width: 40px; + height: 40px; + border: 4px solid rgba(255, 255, 255, 0.25); + border-top-color: #ffffff; + border-radius: 50%; + animation: globalSpinner 1s linear infinite; +} + +.global-loading-text { + color: #e0e0e0; + font-size: 14px; +} + +@keyframes globalSpinner { + to { transform: rotate(360deg); } +} diff --git a/src/components/common/LoadingOverlay.tsx b/src/components/common/LoadingOverlay.tsx index 6b8259e..c6c1c6d 100644 --- a/src/components/common/LoadingOverlay.tsx +++ b/src/components/common/LoadingOverlay.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import './LoadingOverlay.css'; interface LoadingOverlayProps { visible: boolean; diff --git a/src/components/piano-roll/PianoRoll.css b/src/components/piano-roll/PianoRoll.css new file mode 100644 index 0000000..386b265 --- /dev/null +++ b/src/components/piano-roll/PianoRoll.css @@ -0,0 +1,265 @@ +/* For the piano roll (to be implemented later) */ +.piano-roll { + background-color: #7b68ee; + height: 100%; +} + +/* Piano Roll Panel */ +.piano-roll-panel { + position: fixed; + background-color: #2d2d2d; + border: 1px solid #3a3a3a; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Piano notes */ +.piano-note { + position: absolute; + background-color: #ff5555; /* Red color */ + border: 1px solid #999; + border-radius: 2px; + z-index: 10; + cursor: pointer; + user-select: none; + box-sizing: border-box; +} + +.piano-note:hover { + filter: brightness(1.1); + box-shadow: 0 0 5px rgba(255, 85, 85, 0.5); +} + +.piano-note.dragging { + opacity: 0.8; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + z-index: 100; + cursor: grabbing; +} + +.piano-note.resizing { + opacity: 0.8; + box-shadow: 0 0 10px rgba(255, 85, 85, 0.7); + z-index: 100; +} + +.piano-note.selected { + border: 2px solid white; + z-index: 50; +} + +.piano-roll-header { + height: 30px; + background-color: #1e1e1e; + border-bottom: 1px solid #3a3a3a; + display: flex; + align-items: center; + padding: 0 10px; + cursor: move; + user-select: none; +} + +/* Piano roll toolbar */ +.piano-roll-toolbar { + height: 30px; + background-color: #252525; + border-bottom: 1px solid #3a3a3a; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 10px; + user-select: none; + position: relative; + z-index: 100; /* Ensure toolbar and its dropdowns appear above piano roll content */ +} + +/* Override pointer-events for piano roll toolbar sections */ +.piano-roll-toolbar .toolbar-left, +.piano-roll-toolbar .toolbar-right { + pointer-events: auto; +} + +.piano-roll-toolbar .quant-button { + font-size: 10px; +} + +.piano-roll-toolbar .toolbar-left .quant-button { + margin-left: 0px; + margin-right: 5px; +} + +.piano-roll-toolbar .toolbar-left .quant-dropdown { + left: 0; +} + +.piano-roll-title { + flex: 1; + text-align: center; + font-size: 14px; + color: #e0e0e0; + text-transform: uppercase; + cursor: pointer; + padding: 5px; + border-radius: 3px; +} + +.close-button { + background: transparent; + border: none; + color: #e0e0e0; + cursor: pointer; + font-size: 14px; + padding: 5px; +} + +.close-button:hover { + color: #ff4444; +} + +.piano-roll-content { + display: flex; + flex-direction: column; + flex: 1; + overflow: auto; +} + +.piano-grid-header { + display: grid; + grid-template-columns: repeat(var(--max-number-of-bars), var(--region-grid-bar-width)); + width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width) - 1px); + min-width: 100%; + height: 20px; + border-bottom: 1px solid #3a3a3a; + background-color: #1e1e1e; + padding-left: calc(var(--region-piano-key-width) - 1px); + box-sizing: border-box; + position: sticky; + top: 0; + z-index: 20; + cursor: pointer; /* Show pointer cursor on hover to indicate interactivity */ +} + +.piano-grid-header:hover { + cursor: url("data:image/svg+xml,%3csvg width='16' height='16' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M4 6h8l-4 4-4-4z' fill='%23e0e0e0'/%3e%3c/svg%3e") 8 8, pointer; +} + +.piano-bar-number { + border-left: 1px solid #3a3a3a; + padding-left: 10px; + font-size: 10px; +} + +.piano-roll-body { + display: flex; + min-height: calc(8 * 12 * var(--region-piano-key-height)); /* 8 octaves * 12 notes * piano key height */ + width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width) + var(--region-piano-key-width)); /* 32 bars * 160px width + piano keys width */ +} + +.piano-keys-container { + width: var(--region-piano-key-width); + flex-shrink: 0; + overflow: hidden; + border-right: 1px solid #3a3a3a; + background-color: #252525; + position: sticky; + left: 0; + z-index: 10; +} + +.piano-grid-container { + flex: 1; + min-width: calc(var(--max-number-of-bars) * var(--region-grid-bar-width)); + min-height: calc(8 * 12 * var(--region-piano-key-height)); + z-index: 5; +} + +.piano-octave { + display: flex; + flex-direction: column; +} + +.piano-key { + width: var(--region-piano-key-width); + height: var(--region-piano-key-height); + box-sizing: border-box; + display: flex; + align-items: center; + border-bottom: 1px solid #3a3a3a; +} + +.piano-key.natural { + background-color: #e0e0e0; + color: #222; +} + +.piano-key.sharp { + background-color: #222; + color: #e0e0e0; +} + +.key-label { + font-size: 10px; + padding-left: 5px; +} + +.piano-grid { + width: 100%; + height: 100%; + position: relative; + background-size: var(--region-grid-beat-width) var(--region-piano-key-height), 100% 100%; + /* background-image is now set dynamically via React inline styles in PianoGrid component */ +} + +.piano-grid.pencil-cursor { + cursor: crosshair; +} + +/* Piano Grid Cursor Highlights */ +.piano-grid-pitch-highlight { + position: absolute; + left: 0; + right: 0; + background-color: rgba(123, 104, 238, 0.15); + border-top: 1px solid rgba(123, 104, 238, 0.3); + border-bottom: 1px solid rgba(123, 104, 238, 0.3); + pointer-events: none; + z-index: 1; + transition: opacity 0.1s ease; +} + +.piano-grid-beat-highlight { + position: absolute; + top: 0; + bottom: 0; + background-color: rgba(123, 104, 238, 0.1); + border-left: 1px solid rgba(123, 104, 238, 0.25); + border-right: 1px solid rgba(123, 104, 238, 0.25); + pointer-events: none; + z-index: 1; + transition: opacity 0.1s ease; +} + +.piano-grid-chord-highlight { + position: absolute; + background-color: rgba(255, 77, 77, 0.25); + border: 1px solid rgba(255, 77, 77, 0.8); + pointer-events: none; + z-index: 2; + transition: opacity 0.1s ease; +} + +.resize-handle { + position: absolute; + right: 5px; + bottom: 5px; + width: 20px; + height: 20px; + cursor: nwse-resize; + color: #999; + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index 53a71b4..b60ee28 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -1,4 +1,5 @@ import React, { useRef, useEffect, useState, useCallback } from 'react'; +import './PianoRoll.css'; import type { MouseEvent } from 'react'; import { useProjectStore } from '../../stores/projectStore'; import { FaGripLines } from 'react-icons/fa'; diff --git a/src/components/settings/Settings.css b/src/components/settings/Settings.css new file mode 100644 index 0000000..d916da9 --- /dev/null +++ b/src/components/settings/Settings.css @@ -0,0 +1,389 @@ +/* Settings Panel Styles */ +.settings-panel { + width: 100%; + height: 100%; + background-color: #1e1e1e; + display: flex; + flex-direction: column; +} + +.settings-container { + display: flex; + height: 100%; + overflow: hidden; +} + +/* Settings Sidebar */ +.settings-sidebar { + width: 250px; + background-color: #2d2d2d; + border-right: 1px solid #3a3a3a; + display: flex; + flex-direction: column; +} + +.settings-sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + border-bottom: 1px solid #3a3a3a; +} + +.settings-sidebar-header h2 { + color: #e0e0e0; + font-size: 18px; + font-weight: 600; + margin: 0; +} + +.settings-close-btn { + background: transparent; + border: none; + color: #e0e0e0; + cursor: pointer; + padding: 5px; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; +} + +.settings-close-btn:hover { + background-color: #3a3a3a; + color: #fff; +} + +.settings-nav { + display: flex; + flex-direction: column; + padding: 10px; +} + +.settings-nav-item { + background: transparent; + border: none; + color: #b0b0b0; + padding: 12px 16px; + text-align: left; + cursor: pointer; + border-radius: 4px; + margin-bottom: 2px; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; +} + +.settings-nav-item:hover { + background-color: #3a3a3a; + color: #e0e0e0; +} + +.settings-nav-item.active { + background-color: #4a4a4a; + color: #fff; +} + +/* Settings Content */ +.settings-content { + flex: 1; + overflow-y: auto; + background-color: #1e1e1e; + display: flex; + justify-content: center; +} + +.settings-section { + padding: 30px; + width: 800px; +} + +.settings-section-header { + margin-bottom: 30px; + border-bottom: 1px solid #3a3a3a; + padding-bottom: 15px; +} + +.settings-section-header h3 { + color: #fff; + font-size: 24px; + font-weight: 600; + margin: 0; +} + +.settings-section-content { + display: flex; + flex-direction: column; + gap: 30px; + padding-bottom: 30px; +} + +.settings-group { + background-color: #2d2d2d; + border-radius: 8px; + padding: 20px; + border: 1px solid #3a3a3a; +} + +.settings-group h4 { + color: #fff; + font-size: 16px; + font-weight: 600; + margin: 0 0 15px 0; +} + +.settings-group-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; +} + +.settings-group-header h4 { + margin: 0; +} + +.settings-description { + color: #b0b0b0; + font-size: 14px; + line-height: 1.5; + margin-bottom: 15px; +} + +.settings-item { + margin-bottom: 15px; +} + +.settings-item:last-child { + margin-bottom: 0; +} + +.settings-label { + display: block; + color: #e0e0e0; + font-size: 14px; + font-weight: 500; + margin-bottom: 5px; +} + +.settings-input, .settings-select, .settings-textarea { + width: 100%; + /* max-width: 300px; */ + padding: 8px 12px; + background-color: #3a3a3a; + border: 1px solid #555; + border-radius: 4px; + color: #e0e0e0; + font-size: 14px; + transition: border-color 0.2s ease; + font-family: inherit; +} + +.settings-input:focus, .settings-select:focus, .settings-textarea:focus { + outline: none; + border-color: #5a9fd4; + box-shadow: 0 0 0 2px rgba(90, 159, 212, 0.2); +} + +.settings-textarea { + max-width: 600px; + resize: vertical; + min-height: 120px; +} + +.settings-input[type="number"] { + max-width: 100px; +} + +/* Custom Checkbox */ +.settings-checkbox-container { + display: flex; + align-items: center; + cursor: pointer; + color: #e0e0e0; + font-size: 14px; + user-select: none; +} + +.settings-checkbox-container input[type="checkbox"] { + display: none; +} + +.settings-checkmark { + width: 18px; + height: 18px; + background-color: #3a3a3a; + border: 1px solid #555; + border-radius: 3px; + margin-right: 10px; + position: relative; + transition: all 0.2s ease; +} + +.settings-checkbox-container input[type="checkbox"]:checked + .settings-checkmark { + background-color: #5a9fd4; + border-color: #5a9fd4; +} + +.settings-checkbox-container input[type="checkbox"]:checked + .settings-checkmark::after { + content: ''; + position: absolute; + left: 5px; + top: 2px; + width: 6px; + height: 10px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +/* Settings Buttons */ +.settings-btn { + background-color: #3a3a3a; + border: 1px solid #555; + color: #e0e0e0; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.settings-btn:hover { + background-color: #4a4a4a; + border-color: #666; +} + +.settings-btn-primary { + background-color: #5a9fd4; + border-color: #5a9fd4; + color: #fff; +} + +.settings-btn-primary:hover { + background-color: #4a8fc4; + border-color: #4a8fc4; +} + +.settings-btn-danger { + background-color: #d45a5a; + border-color: #d45a5a; + color: #fff; +} + +.settings-btn-danger:hover { + background-color: #c44a4a; + border-color: #c44a4a; +} + +.settings-btn-danger:disabled { + background-color: #555; + border-color: #555; + color: #888; + cursor: not-allowed; +} + +.settings-btn-small { + padding: 6px 10px; + font-size: 12px; +} + +/* Settings Help Links */ +.settings-help-links { + display: flex; + gap: 16px; + margin-bottom: 8px; +} + +button.settings-help { + color: #5a9fd4; + text-decoration: underline; + cursor: pointer; + font-size: 14px; + background: none; + border: none; + padding: 0; + font-family: inherit; +} + +button.settings-help:hover { + color: #7bbfef; +} + +/* Settings Validation Errors */ +.settings-validation-errors { + margin-top: 8px; + padding: 8px; + background-color: rgba(211, 90, 90, 0.1); + border: 1px solid #d35a5a; + border-radius: 4px; + max-height: 200px; + overflow-y: auto; +} + +.settings-validation-error { + color: #ff6b6b; + font-size: 12px; + line-height: 1.4; + margin-bottom: 4px; +} + +.settings-validation-error:last-child { + margin-bottom: 0; +} + +/* Templates List */ +.templates-list { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 15px; +} + +.template-item { + background-color: #3a3a3a; + border: 1px solid #555; + border-radius: 6px; + padding: 15px; + display: flex; + justify-content: space-between; + align-items: center; + transition: border-color 0.2s ease; +} + +.template-item:hover { + border-color: #666; +} + +.template-info { + flex: 1; +} + +.template-name { + color: #fff; + font-size: 16px; + font-weight: 600; + margin: 0 0 5px 0; +} + +.template-description { + color: #b0b0b0; + font-size: 14px; + margin: 0 0 5px 0; +} + +.template-tracks { + color: #888; + font-size: 12px; + font-weight: 500; +} + +.template-actions { + display: flex; + gap: 8px; +} diff --git a/src/components/settings/SettingsPanel.tsx b/src/components/settings/SettingsPanel.tsx index 599a1ba..b4e6c5a 100644 --- a/src/components/settings/SettingsPanel.tsx +++ b/src/components/settings/SettingsPanel.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import './Settings.css'; import SettingsSidebar from './SettingsSidebar'; import GeneralSettings from './sections/GeneralSettings'; import BehaviorSettings from './sections/BehaviorSettings'; diff --git a/src/components/track/Region.css b/src/components/track/Region.css new file mode 100644 index 0000000..c1c17d5 --- /dev/null +++ b/src/components/track/Region.css @@ -0,0 +1,110 @@ +/* Track Region Styles */ +.track-region { + position: absolute; + background-color: #4a6b8a; + border: 2px solid #5a7b9a; + border-radius: 3px; + height: calc(100%); + margin: 0px; + overflow: hidden; + display: flex; + flex-direction: column; + cursor: pointer; + user-select: none; + transition: box-shadow 0.1s ease; + will-change: transform; +} + +.track-region:hover { + box-shadow: 0 0 0 1px #7a9bba; +} + +.track-region.selected { + /* box-shadow: 0 0 0 2px #ffffff, 0 0 8px rgba(255, 255, 255, 0.3); */ /* let's only apply a border */ + border-color: #ffffff; +} + +.track-region.dragging { + opacity: 0.8; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + z-index: 100; + pointer-events: none; + transform-origin: center center; + animation: pulse 1.5s infinite; + cursor: grabbing; + transition: none; /* Remove transition during drag for immediate response */ +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(122, 155, 186, 0.7); + } + 70% { + box-shadow: 0 0 0 5px rgba(122, 155, 186, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(122, 155, 186, 0); + } +} + +.region-header { + background-color: #5a7b9a; + color: #fff; + padding: 2px 6px; + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: move; + height: 18px; + user-select: none; +} + +.track-region:hover .region-header { + background-color: #6a8baa; +} + +.region-content { + height: calc(100% - 18px); + background-color: #87CEFA; /* Light blue */ + width: 100%; + position: relative; /* Allow overlayed controls */ +} + +/* Region pencil trigger inside content */ +.region-pencil-btn { + position: absolute; + top: 4px; + left: 4px; + background: rgba(0, 0, 0, 0.25); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 3px; + padding: 2px; + margin: 0; + cursor: pointer; + z-index: 2; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.region-pencil-btn:hover { + background: rgba(0, 0, 0, 0.35); +} + +/* Instrument dropdown specific styles */ +.instrument-dropdown .quant-dropdown { + min-width: 80px; + width: auto; + right: auto; + left: 0; +} + +/* Settings dropdown specific styles */ +.settings-dropdown .quant-dropdown { + min-width: 100px; + width: auto; + right: auto; + left: 0; +} diff --git a/src/components/track/RegionItem.tsx b/src/components/track/RegionItem.tsx index c54ba1d..084e021 100644 --- a/src/components/track/RegionItem.tsx +++ b/src/components/track/RegionItem.tsx @@ -1,4 +1,5 @@ import React, { useState, useRef, useEffect } from 'react'; +import './Region.css'; import { FaPencilAlt } from 'react-icons/fa'; import type { ResizeAction } from '../interfaces'; import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants'; diff --git a/src/components/track/Track.css b/src/components/track/Track.css new file mode 100644 index 0000000..67143bc --- /dev/null +++ b/src/components/track/Track.css @@ -0,0 +1,207 @@ +/* Track grid */ +.track-grid { + display: block; + height: 120px; + min-width: calc(var(--max-number-of-bars) * var(--track-grid-bar-width)); /* 32 bars * --track-grid-bar-width */ + background-size: var(--track-grid-bar-width) 120px; + background-image: + /* Vertical lines for bars */ + linear-gradient(to right, + transparent calc(var(--track-grid-bar-width) - 1px), #3a3a3a calc(var(--track-grid-bar-width) - 1px), #3a3a3a var(--track-grid-bar-width) + ); + position: relative; + border-bottom: 1px solid #3a3a3a; +} + +.track-grid.pencil-cursor { + cursor: crosshair; +} + +.add-track-btn { + background: transparent; + border: none; + outline: none; + color: #999; + cursor: pointer; + font-size: 12px; + padding: 0; + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.add-track-btn:focus, +.add-track-btn:focus-visible { + outline: none; +} + +.add-track-btn:hover { + color: #e0e0e0; +} + +.track { + display: flex; + height: 120px; + position: relative; + border-bottom: none; +} + +.track-info { + width: 200px; + height: 120px; + padding: 15px; + background-color: #2d2d2d; + display: flex; + flex-direction: column; + border-right: 1px solid #3a3a3a; + border-bottom: 1px solid #3a3a3a; + flex-shrink: 0; + box-sizing: border-box; + cursor: grab; + position: relative; +} + +.track-info:active { + cursor: grabbing; +} + +.track-info.selected { + background-color: #363636; +} + +/* Drag and drop styles */ +.track-info.drag-over { + border-top: 2px solid #7b68ee; +} + +.track-grid.drag-over { + border-top: 2px solid #7b68ee; +} + +/* Dragging state */ +.track-info.dragging { + opacity: 0.5; + background-color: #444; +} + +.track-grid.dragging { + opacity: 0.5; + background-color: #333; +} + +/* Remove drag indicator since hover cursor is sufficient */ + +.track-name { + font-size: 12px; + cursor: pointer; + position: relative; +} + +.track-name:hover { + color: #7b68ee; +} + +.track-name:hover::before { + content: "\270E"; + position: absolute; + right: 0px; + font-size: 16px; + opacity: 1; +} + +.track-controls { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +.track-name-and-volume { + display: flex; + flex-direction: row; + flex: 1; + align-items: flex-start; + gap: 8px; +} + +.instrument-image { + flex-shrink: 0; + margin-top: -10px; + margin-left: -10px; +} + +.instrument-image img { + display: block; + border-radius: 4px; +} + +.track-name-and-controls { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} + +.volume-slider { + margin-bottom: 15px; + width: 100%; + display: flex; + align-items: center; + gap: 6px; +} + +.volume-slider input[type="range"] { + flex: 1; + min-width: 0; + height: 6px; +} + +.volume-slider .reset-volume { + flex-shrink: 0; + width: 16px; + height: 16px; + border-radius: 8px; + background: #3a3a3a; + color: #e0e0e0; + /* border: 1px solid #444; */ + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 10px; +} + +.volume-slider .reset-volume:hover { + background: #4a4a4a; +} + +.pan-controls { + display: flex; +} + +.pan-controls button { + background: #3a3a3a; + border: none; + color: #e0e0e0; + margin-right: 5px; + width: 24px; + height: 24px; + font-size: 12px; + border-radius: 2px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.pan-controls button.solo.active { + background: #c8a400; /* yellow */ + color: #101010; +} + +.pan-controls button.mute.active { + background: #d32f2f; /* red */ + color: #ffffff; +} diff --git a/src/components/track/TrackInfoPanel.tsx b/src/components/track/TrackInfoPanel.tsx index 70090b9..6f00c64 100644 --- a/src/components/track/TrackInfoPanel.tsx +++ b/src/components/track/TrackInfoPanel.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import './Track.css'; import { KGTrack } from '../../core/track/KGTrack'; import { useProjectStore } from '../../stores/projectStore'; import TrackInfoItem from './TrackInfoItem'; diff --git a/src/index.css b/src/index.css index a60a5a5..26e4e0a 100644 --- a/src/index.css +++ b/src/index.css @@ -58,6 +58,12 @@ button:focus-visible { outline: 2px auto rgba(100, 108, 255, 0.5); } +textarea { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + font-size: 12px; + line-height: 1.4; +} + input[type="range"] { -webkit-appearance: none; appearance: none; diff --git a/src/main.tsx b/src/main.tsx index 65b9909..13b6272 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ import 'reflect-metadata'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import './styles/variables.css'; import './index.css'; import App from './App.tsx'; import { KGCore } from './core/KGCore'; diff --git a/src/styles/shared.css b/src/styles/shared.css new file mode 100644 index 0000000..2f1253e --- /dev/null +++ b/src/styles/shared.css @@ -0,0 +1,125 @@ +/* Shared toolbar layout classes (used by Toolbar and PianoRollToolbar) */ + +.toolbar-left, .toolbar-right { + display: flex; + align-items: center; + z-index: 1; + pointer-events: none; +} + +.toolbar-left { + width: 20%; + justify-content: flex-start; +} + +.toolbar-right { + width: 33%; + justify-content: flex-end; + z-index: 1500; +} + +.toolbar-center { + position: absolute; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + justify-content: center; + z-index: 1005; + margin-left: -50px; +} + +/* Tool buttons (used by Toolbar and PianoRollToolbar) */ + +.tool-button { + width: 30px; + height: 30px; + background-color: transparent; + border: none; + border-radius: 3px; + color: #999; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + margin: 0 2px; +} + +.tool-button:hover { + background-color: #3a3a3a; + color: #e0e0e0; +} + +.tool-button.active { + background-color: #4a4a4a; + color: #e0e0e0; +} + +/* Quantization dropdown system (used by KGDropdown) */ + +.quant-button { + background-color: #333; + border: 1px solid #444; + border-radius: 3px; + color: #e0e0e0; + font-size: 12px; + padding: 3px 8px; + margin-left: 5px; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; +} + +.quant-button:hover { + background-color: #444; +} + +.quant-dropdown-container { + position: relative; + display: inline-block; + z-index: 1500; +} + +.quant-dropdown { + position: absolute; + top: 100%; + right: 0; + background-color: #2d2d2d; + border: 1px solid #444; + border-radius: 3px; + width: 100px; + z-index: 1500; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + margin-top: 2px; + max-height: 200px; + overflow-y: auto; +} + +.quant-option { + padding: 6px 10px; + font-size: 12px; + color: #e0e0e0; + cursor: pointer; + transition: background-color 0.2s; +} + +.quant-option:hover { + background-color: #444; +} + +.quant-option.active { + background-color: #4a6b8a; +} + +/* Generic button blink effect */ +.quant-button.button-blink { + animation: buttonBlink 0.2s ease-in-out; +} + +@keyframes buttonBlink { + 0% { background-color: #333; } + 50% { background-color: #555; } + 100% { background-color: #333; } +} diff --git a/src/styles/variables.css b/src/styles/variables.css new file mode 100644 index 0000000..a08337e --- /dev/null +++ b/src/styles/variables.css @@ -0,0 +1,11 @@ +:root { + --time-signature-numerator: 4; + --max-number-of-bars: 32; + --track-grid-bar-width: 40px; + --region-piano-key-width: 60px; + --region-piano-key-height: 20px; + --region-grid-beat-width: 40px; + --region-grid-bar-width: calc(var(--region-grid-beat-width) * var(--time-signature-numerator)); + --chat-box-width: 350px; + --instrument-selection-width: 300px; +} From 2d3ea33ce620c821a463122926a595bb887fa29d Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:25:08 -0700 Subject: [PATCH 3/3] refactor: migrate project storage from IndexedDB to OPFS with folder-based structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace monolithic KGStorage with KGProjectStorage (OPFS) and KGConfigStorage (IndexedDB) - Each project stored as folder: project.json + meta.json + media/ - Add KGConfigUpgrader with V1 upgrader to auto-migrate existing IndexedDB projects to OPFS - Request persistent storage via navigator.storage.persist() to prevent browser eviction - Show loading spinner during one-time migration - Enforce safe project name characters (letters, numbers, space, hyphen, underscore, period, parens) - Export projects as .kgstudio zip bundles instead of raw JSON - Import supports .kgstudio bundles (with meta.json validation) and legacy JSON files - Auto-deduplicate project names on import with (1), (2), etc. suffix - Add OPFS shell debugger (pwd, ls, cd, cat, dl) accessible via KGDebugger.opfs() - Add ESLint semi rule for consistent semicolons - Delete KGStorage.ts — all logic absorbed by new storage classes - Add jszip dependency for zip export/import --- eslint.config.js | 3 +- package-lock.json | 52 ++- package.json | 1 + src/App.tsx | 22 + src/components/Toolbar.tsx | 150 ++++--- src/constants/coreConstants.ts | 12 + src/core/KGCore.ts | 38 +- src/core/KGDebugger.ts | 216 +++++++++- src/core/config-upgrader/KGConfigUpgrader.ts | 62 +++ .../config-upgrader/upgradeConfigToV1.test.ts | 180 ++++++++ src/core/config-upgrader/upgradeConfigToV1.ts | 144 +++++++ src/core/config/ConfigManager.ts | 13 +- src/core/io/KGConfigStorage.test.ts | 86 ++++ src/core/io/KGConfigStorage.ts | 119 ++++++ src/core/io/KGProjectStorage.test.ts | 230 ++++++++++ src/core/io/KGProjectStorage.ts | 398 ++++++++++++++++++ src/core/io/KGStorage.ts | 132 ------ src/types/opfs.d.ts | 9 + src/util/projectNameUtil.test.ts | 78 ++++ src/util/projectNameUtil.ts | 44 ++ src/util/saveUtil.ts | 27 +- 21 files changed, 1781 insertions(+), 235 deletions(-) create mode 100644 src/core/config-upgrader/KGConfigUpgrader.ts create mode 100644 src/core/config-upgrader/upgradeConfigToV1.test.ts create mode 100644 src/core/config-upgrader/upgradeConfigToV1.ts create mode 100644 src/core/io/KGConfigStorage.test.ts create mode 100644 src/core/io/KGConfigStorage.ts create mode 100644 src/core/io/KGProjectStorage.test.ts create mode 100644 src/core/io/KGProjectStorage.ts delete mode 100644 src/core/io/KGStorage.ts create mode 100644 src/types/opfs.d.ts create mode 100644 src/util/projectNameUtil.test.ts create mode 100644 src/util/projectNameUtil.ts diff --git a/eslint.config.js b/eslint.config.js index fd7545f..8e7e63c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -21,7 +21,8 @@ export default tseslint.config([ }, rules: { '@typescript-eslint/no-unused-vars': 'warn', // Downgrade from error to warning - 'no-unused-vars': 'warn' // Also set the base rule to warn + 'no-unused-vars': 'warn', // Also set the base rule to warn + 'semi': ['error', 'always'], } }, ]) diff --git a/package-lock.json b/package-lock.json index 76bc26e..9ebe900 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "K.G.Studio", - "version": "0.8.0-build.20260123", + "version": "0.9.0-build.20260406", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "K.G.Studio", - "version": "0.8.0-build.20260123", + "version": "0.9.0-build.20260406", "dependencies": { "class-transformer": "^0.5.1", "idb": "^8.0.3", + "jszip": "^3.10.1", "openai": "^6.33.0", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -3805,7 +3806,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, "license": "MIT" }, "node_modules/cosmiconfig": { @@ -5387,6 +5387,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5441,7 +5447,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -5656,7 +5661,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, "license": "MIT" }, "node_modules/isexe": { @@ -5931,6 +5935,18 @@ "node": "*" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5965,6 +5981,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -11030,6 +11055,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -11377,7 +11408,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, "license": "MIT" }, "node_modules/property-information": { @@ -11792,7 +11822,6 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", @@ -12156,7 +12185,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, "license": "MIT" }, "node_modules/safer-buffer": { @@ -12529,6 +12557,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -12836,7 +12870,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -13674,7 +13707,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/validate-npm-package-license": { diff --git a/package.json b/package.json index 50f19a3..50c926f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "class-transformer": "^0.5.1", "idb": "^8.0.3", + "jszip": "^3.10.1", "openai": "^6.33.0", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/src/App.tsx b/src/App.tsx index fc635c3..4f7fae2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -141,6 +141,9 @@ function App() { {/* Global Loading Overlay for instrument buffer loading */} + + {/* Migration Loading Overlay */} + ); } @@ -210,3 +213,22 @@ const GlobalLoadingOverlayContainer: React.FC = () => { /> ); }; + +// Migration overlay — shown during one-time IndexedDB -> OPFS migration +const MigrationOverlayContainer: React.FC = () => { + const [isMigrating, setIsMigrating] = useState(() => KGCore.instance().getIsMigrating()); + + useEffectReact(() => { + KGCore.instance().setMigrationStateChangeCallback(setIsMigrating); + return () => { + KGCore.instance().setMigrationStateChangeCallback(() => {}); + }; + }, []); + + return ( + + ); +}; diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 01d81b9..b9b155f 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -1,8 +1,8 @@ import React from 'react'; import './Toolbar.css'; import { saveProject } from '../util/saveUtil'; -import { KGStorage } from '../core/io/KGStorage'; -import { DB_CONSTANTS } from '../constants/coreConstants'; +import { KGProjectStorage } from '../core/io/KGProjectStorage'; +import { isValidProjectName } from '../util/projectNameUtil'; import { KGCore } from '../core/KGCore'; import { useProjectStore } from '../stores/projectStore'; import { DEBUG_MODE } from '../constants/uiConstants'; @@ -15,7 +15,7 @@ import { FaCog } from 'react-icons/fa'; import { KGProject, type KeySignature } from '../core/KGProject'; -import { plainToInstance, instanceToPlain } from 'class-transformer'; +import { plainToInstance } from 'class-transformer'; import { FaPencil, FaCopy, FaPaste, FaTrash } from 'react-icons/fa6'; import { KGMainContentState } from '../core/state/KGMainContentState'; import { regionDeleteManager } from '../util/regionDeleteUtil'; @@ -59,11 +59,17 @@ const Toolbar: React.FC = () => { const keySignatureOptions = Object.keys(KEY_SIGNATURE_MAP) as KeySignature[]; // Export options - const exportOptions = ["Export to KGStudio JSON file", "Export to MIDI file"]; + const exportOptions = ["Export to KGStudio file", "Export to MIDI file"]; const handleProjectNameClick = () => { const newName = prompt("Enter project name:", projectName); - if (newName) setProjectName(newName); + if (newName) { + if (!isValidProjectName(newName)) { + window.alert("Invalid project name. Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed."); + return; + } + setProjectName(newName); + } }; // Common project loading logic extracted for reuse @@ -145,14 +151,8 @@ const Toolbar: React.FC = () => { try { // Try to load the project from storage - const storage = KGStorage.getInstance(); - const loadedProject = await storage.load( - DB_CONSTANTS.DB_NAME, - DB_CONSTANTS.PROJECTS_STORE_NAME, - projectNameToLoad.trim(), - KGProject, - DB_CONSTANTS.DB_VERSION - ); + const storage = KGProjectStorage.getInstance(); + const loadedProject = await storage.load(projectNameToLoad.trim()); if (!loadedProject) { window.alert(`Project "${projectNameToLoad}" not found. Please check the project name and try again.`); @@ -182,8 +182,8 @@ const Toolbar: React.FC = () => { console.log("user selected export option:", exportType); } - if (exportType === "Export to KGStudio JSON file") { - handleExportKGStudioJSON(); + if (exportType === "Export to KGStudio file") { + handleExportKGStudio(); } else if (exportType === "Export to MIDI file") { handleExportMIDI(); } @@ -191,46 +191,39 @@ const Toolbar: React.FC = () => { setShowExportDropdown(false); }; - const handleExportKGStudioJSON = () => { + const handleExportKGStudio = async () => { if (DEBUG_MODE.TOOLBAR) { - console.log("exporting to KGStudio JSON file"); + console.log("exporting to KGStudio file"); } - + try { - // Get the current project from KGCore - const currentProject = KGCore.instance().getCurrentProject(); - - // Serialize the project to JSON (same format as saved to IndexedDB) - // Use instanceToPlain to include type information for class-transformer - const projectData = JSON.stringify(instanceToPlain(currentProject), null, 2); - - // Create a downloadable blob - const blob = new Blob([projectData], { type: 'application/json' }); - - // Create a temporary download link + // First save the current project to OPFS so the export reflects the latest state + const storage = KGProjectStorage.getInstance(); + await storage.save(projectName, KGCore.instance().getCurrentProject(), true); + + // Bundle the project folder into a zip + const blob = await storage.exportAsZip(projectName); + + // Trigger download const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.download = `${projectName}.json`; - - // Trigger download + link.download = `${projectName}.kgstudio`; document.body.appendChild(link); link.click(); - - // Cleanup document.body.removeChild(link); URL.revokeObjectURL(url); - - setStatus(`Project "${projectName}" exported as JSON file`); - + + setStatus(`Project "${projectName}" exported as KGStudio file`); + if (DEBUG_MODE.TOOLBAR) { - console.log("KGStudio JSON export completed successfully"); + console.log("KGStudio export completed successfully"); } - + } catch (error) { - console.error("Error exporting KGStudio JSON:", error); + console.error("Error exporting KGStudio file:", error); setStatus(`Error exporting project: ${error}`); - window.alert(`Failed to export project as JSON: ${error}`); + window.alert(`Failed to export project: ${error}`); } }; @@ -292,8 +285,11 @@ const Toolbar: React.FC = () => { const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); try { - if (fileExtension === '.json') { - // Handle KGStudio JSON import + if (fileExtension === '.kgstudio') { + // Handle KGStudio bundle import + await handleKGStudioFileImport(file); + } else if (fileExtension === '.json') { + // Handle legacy KGStudio JSON import await handleKGStudioJSONImport(file); } else if (fileExtension === '.mid' || fileExtension === '.midi') { // Handle MIDI import @@ -309,32 +305,64 @@ const Toolbar: React.FC = () => { } }; + const handleKGStudioFileImport = async (file: File) => { + const storage = KGProjectStorage.getInstance(); + + try { + const projectName = await storage.importFromZip(file); + + // Load the project from OPFS (this runs the upgrader) + const loaded = await storage.load(projectName); + if (!loaded) { + throw new Error('Failed to load imported project'); + } + + await loadProjectFromData(loaded, `KGStudio file "${file.name}"`); + + if (DEBUG_MODE.TOOLBAR) { + console.log("KGStudio file imported successfully:", projectName); + } + } catch (error) { + window.alert(`The .kgstudio file is corrupted or invalid: ${error}`); + throw error; + } + }; + const handleKGStudioJSONImport = async (file: File) => { try { - // Read the file content const fileContent = await file.text(); - const projectData = JSON.parse(fileContent); - - // Deserialize the project data using class-transformer (same as KGStorage) + + // Deserialize and handle potential array return const deserializedResult = plainToInstance(KGProject, projectData); - - // Handle case where plainToInstance might return an array - const deserializedProject = Array.isArray(deserializedResult) - ? deserializedResult[0] || null + const project = Array.isArray(deserializedResult) + ? deserializedResult[0] || null : deserializedResult; - - if (!deserializedProject) { + + if (!project) { throw new Error("Failed to deserialize project data"); } - - // Load the project using common loading logic - await loadProjectFromData(deserializedProject, `File "${file.name}"`); - - if (DEBUG_MODE.TOOLBAR) { - console.log("KGStudio JSON project imported successfully:", deserializedProject); + + // Use the project's name for the OPFS folder, auto-rename if it already exists + const storage = KGProjectStorage.getInstance(); + const importedName = await storage.resolveUniqueName(project.getName() || 'Imported Project'); + + // Save to OPFS as a proper folder-based project + project.setName(importedName); + await storage.save(importedName, project, false); + + // Load back from OPFS so the upgrader runs + const loaded = await storage.load(importedName); + if (!loaded) { + throw new Error('Failed to load imported project from storage'); } - + + await loadProjectFromData(loaded, `JSON file "${file.name}"`); + + if (DEBUG_MODE.TOOLBAR) { + console.log("KGStudio JSON project imported and saved to OPFS:", importedName); + } + } catch (error) { throw new Error(`Invalid KGStudio JSON file: ${error}`); } @@ -804,7 +832,7 @@ const Toolbar: React.FC = () => { isVisible={showImportModal} onClose={() => setShowImportModal(false)} onFileImport={handleFileImport} - acceptedTypes={['.json', '.mid', '.midi']} + acceptedTypes={['.kgstudio', '.json', '.mid', '.midi']} title="Import Project" description="Drag and drop your project file here" /> diff --git a/src/constants/coreConstants.ts b/src/constants/coreConstants.ts index 13454b4..6db6c1d 100644 --- a/src/constants/coreConstants.ts +++ b/src/constants/coreConstants.ts @@ -93,6 +93,18 @@ export const SAMPLER_CONSTANTS = { }, }; +export const OPFS_CONSTANTS = { + ROOT_DIR: 'projects', + PROJECT_FILE: 'project.json', + METADATA_FILE: 'meta.json', + MEDIA_DIR: 'media', +}; + +export const CONFIG_UPGRADER_CONSTANTS = { + VERSION_KEY: '__config_version', + CURRENT_VERSION: 1, +}; + export const URL_CONSTANTS = { DEFAULT_OPENAI_BASE_URL: 'https://api.openai.com/v1', }; diff --git a/src/core/KGCore.ts b/src/core/KGCore.ts index 829e9d8..6ebdffd 100644 --- a/src/core/KGCore.ts +++ b/src/core/KGCore.ts @@ -2,6 +2,8 @@ import type { Selectable } from '../components/interfaces'; import { KGProject } from './KGProject'; import { KGAudioInterface } from './audio-interface/KGAudioInterface'; import { ConfigManager } from './config/ConfigManager'; +import { KGProjectStorage } from './io/KGProjectStorage'; +import { KGConfigUpgrader } from './config-upgrader/KGConfigUpgrader'; import { KGMidiRegion } from './region/KGMidiRegion'; import { KGMidiNote } from './midi/KGMidiNote'; import { KGRegion } from './region/KGRegion'; @@ -30,6 +32,8 @@ export class KGCore { private copiedItems: Selectable[] = []; private isPlaying: boolean = false; + private isMigrating: boolean = false; + private migrationStateChangeCallback: ((isMigrating: boolean) => void) | null = null; // Timer management for playback private playbackIntervalId: number | null = null; @@ -71,11 +75,23 @@ export class KGCore { // Initialize configuration manager const configManager = ConfigManager.instance(); await configManager.initialize(); - + + // Initialize OPFS project storage + const projectStorage = KGProjectStorage.getInstance(); + await projectStorage.initialize(); + + // Run app-level migrations (e.g., IndexedDB -> OPFS project migration) + this.setMigrating(true); + try { + await KGConfigUpgrader.upgradeToLatest(); + } finally { + this.setMigrating(false); + } + // Initialize audio interface const audioInterface = KGAudioInterface.instance(); await audioInterface.initialize(); - + console.log("KGCore components initialized successfully"); } catch (error) { console.error("Failed to initialize KGCore:", error); @@ -194,7 +210,23 @@ export class KGCore { this.selectionChangeCallbacks.forEach(callback => callback()); } - // play + // Migration state + public getIsMigrating(): boolean { + return this.isMigrating; + } + + private setMigrating(value: boolean): void { + this.isMigrating = value; + if (this.migrationStateChangeCallback) { + this.migrationStateChangeCallback(value); + } + } + + public setMigrationStateChangeCallback(callback: (isMigrating: boolean) => void): void { + this.migrationStateChangeCallback = callback; + } + + // play public getIsPlaying(): boolean { return this.isPlaying; } diff --git a/src/core/KGDebugger.ts b/src/core/KGDebugger.ts index 23b7d52..7d01253 100644 --- a/src/core/KGDebugger.ts +++ b/src/core/KGDebugger.ts @@ -28,7 +28,8 @@ export class KGDebugger { 'createTestRegion()', 'testExtractXMLFromString(input)', 'testToolCall(jsonInput)', - 'inputChatBox(content, interval?)' + 'inputChatBox(content, interval?)', + 'opfs(command)', ]); } @@ -344,6 +345,7 @@ export class KGDebugger { console.log(" testExtractXMLFromString(input) - Test XML extraction from string"); console.log(" testToolCall(input) - Execute tool call(s) from JSON and show results"); console.log(" inputChatBox(content, interval?) - Type into ChatBox textarea and submit with Enter"); + console.log(" opfs(command) - OPFS file browser (pwd, ls, cd, cat, dl)"); console.log(" help() - Show this help"); console.log(""); console.log("💡 Usage tips:"); @@ -446,4 +448,216 @@ export class KGDebugger { console.error('❌ Error in inputChatBox:', error); } } + + // --- OPFS Shell --- + + /** Current working directory path segments (relative to OPFS root) */ + private opfsCwd: string[] = []; + + /** + * Simplified bash-like shell for browsing the OPFS filesystem. + * + * Supported commands: + * pwd — print current directory + * ls — list files/folders (like ls -lla) + * cd — change directory (supports .., /, relative, and quoted paths) + * cat — print file contents + * dl — download a file to your local machine + * + * Usage in console: + * await KGDebugger.opfs('pwd') + * await KGDebugger.opfs('ls') + * await KGDebugger.opfs('cd projects') + * await KGDebugger.opfs('cat project.json') + */ + public async opfs(command: string): Promise { + const parts = command.trim().match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [] + const cmd = parts[0] + // Strip surrounding quotes from arguments + const arg = parts.slice(1).map(p => p.replace(/^["']|["']$/g, '')).join(' ') + + try { + switch (cmd) { + case 'pwd': + console.log('/' + this.opfsCwd.join('/')) + break + + case 'ls': + await this.opfsLs() + break + + case 'cd': + await this.opfsCd(arg) + break + + case 'cat': + await this.opfsCat(arg) + break + + case 'dl': + await this.opfsDl(arg) + break + + default: + console.log(`opfs: command not found: ${cmd}`) + console.log('Available commands: pwd, ls, cd , cat , dl ') + } + } catch (error) { + console.error(`opfs: ${error}`) + } + } + + private async opfsResolveCwd(): Promise { + let dir = await navigator.storage.getDirectory() + for (const segment of this.opfsCwd) { + dir = await dir.getDirectoryHandle(segment) + } + return dir + } + + private async opfsLs(): Promise { + const dir = await this.opfsResolveCwd() + const entries: Array<{ kind: string; name: string; size: number; modified: string }> = [] + + for await (const entry of dir.values()) { + if (entry.kind === 'file') { + const fileHandle = entry as FileSystemFileHandle + const file = await fileHandle.getFile() + entries.push({ + kind: 'file', + name: entry.name, + size: file.size, + modified: new Date(file.lastModified).toISOString().replace('T', ' ').slice(0, 19), + }) + } else { + entries.push({ + kind: 'dir', + name: entry.name + '/', + size: 0, + modified: '-', + }) + } + } + + // Sort: directories first, then files, alphabetically within each group + entries.sort((a, b) => { + if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1 + return a.name.localeCompare(b.name) + }) + + if (entries.length === 0) { + console.log('(empty directory)') + return + } + + // Print header + const cwdPath = '/' + this.opfsCwd.join('/') + console.log(`total ${entries.length} (${cwdPath})`) + + // Format like ls -lla + const maxSizeLen = Math.max(...entries.map(e => String(e.size).length), 4) + for (const e of entries) { + const typeChar = e.kind === 'dir' ? 'd' : '-' + const perms = e.kind === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--' + const sizeStr = e.kind === 'dir' ? '-'.padStart(maxSizeLen) : String(e.size).padStart(maxSizeLen) + console.log(`${typeChar}${perms} ${sizeStr} ${e.modified} ${e.name}`) + } + } + + private async opfsCd(path: string): Promise { + if (!path || path === '') { + // cd with no args goes to root + this.opfsCwd = [] + return + } + + let segments: string[] + + if (path === '/') { + this.opfsCwd = [] + return + } else if (path.startsWith('/')) { + // Absolute path + segments = path.split('/').filter(Boolean) + } else { + // Relative path + segments = [...this.opfsCwd, ...path.split('/').filter(Boolean)] + } + + // Resolve . and .. + const resolved: string[] = [] + for (const seg of segments) { + if (seg === '.') continue + if (seg === '..') { + resolved.pop() + } else { + resolved.push(seg) + } + } + + // Verify the path exists + let dir = await navigator.storage.getDirectory() + for (const seg of resolved) { + try { + dir = await dir.getDirectoryHandle(seg) + } catch { + console.error(`opfs: cd: no such directory: ${path}`) + return + } + } + + this.opfsCwd = resolved + } + + private async opfsCat(fileName: string): Promise { + if (!fileName) { + console.error('opfs: cat: missing file name') + return + } + + const dir = await this.opfsResolveCwd() + try { + const fileHandle = await dir.getFileHandle(fileName) + const file = await fileHandle.getFile() + const text = await file.text() + + // Pretty-print JSON files + if (fileName.endsWith('.json')) { + try { + const parsed = JSON.parse(text) + console.log(JSON.stringify(parsed, null, 2)) + } catch { + console.log(text) + } + } else { + console.log(text) + } + } catch { + console.error(`opfs: cat: ${fileName}: No such file`) + } + } + + private async opfsDl(fileName: string): Promise { + if (!fileName) { + console.error('opfs: dl: missing file name') + return + } + + const dir = await this.opfsResolveCwd() + try { + const fileHandle = await dir.getFileHandle(fileName) + const file = await fileHandle.getFile() + const url = URL.createObjectURL(file) + const a = document.createElement('a') + a.href = url + a.download = fileName + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + console.log(`downloaded: ${fileName} (${file.size} bytes)`) + } catch { + console.error(`opfs: dl: ${fileName}: No such file`) + } + } } \ No newline at end of file diff --git a/src/core/config-upgrader/KGConfigUpgrader.ts b/src/core/config-upgrader/KGConfigUpgrader.ts new file mode 100644 index 0000000..63ee19f --- /dev/null +++ b/src/core/config-upgrader/KGConfigUpgrader.ts @@ -0,0 +1,62 @@ +import { KGConfigStorage } from '../io/KGConfigStorage'; +import { CONFIG_UPGRADER_CONSTANTS } from '../../constants/coreConstants'; +import { upgradeConfigToV1 } from './upgradeConfigToV1'; + +/** + * KGConfigUpgrader — Orchestrates app-level migrations (e.g., storage backend changes). + * Mirrors the KGProjectUpgrader pattern but operates on global app state, not individual projects. + * + * Version is tracked via a `__config_version` key in the IndexedDB config store. + */ +export class KGConfigUpgrader { + /** + * Run all pending config upgrades sequentially. + * Returns the number of upgrade steps that were executed. + */ + public static async upgradeToLatest(): Promise { + const storage = KGConfigStorage.getInstance(); + const currentVersion = await KGConfigUpgrader.getConfigVersion(storage); + const targetVersion = CONFIG_UPGRADER_CONSTANTS.CURRENT_VERSION; + + if (currentVersion >= targetVersion) { + console.log(`Config is up to date (version ${currentVersion})`); + return 0; + } + + console.log(`Config upgrade needed: v${currentVersion} -> v${targetVersion}`); + + let stepsExecuted = 0; + + for (let nextVersion = currentVersion + 1; nextVersion <= targetVersion; nextVersion++) { + switch (nextVersion) { + case 1: { + await upgradeConfigToV1(); + break; + } + default: { + throw new Error(`No config upgrader found for version ${nextVersion}`); + } + } + + // Persist the version after each successful step + await KGConfigUpgrader.setConfigVersion(storage, nextVersion); + stepsExecuted++; + console.log(`Config upgraded to version ${nextVersion}`); + } + + return stepsExecuted; + } + + private static async getConfigVersion(storage: KGConfigStorage): Promise { + const raw = await storage.getRaw(CONFIG_UPGRADER_CONSTANTS.VERSION_KEY); + if (!raw || typeof raw.version !== 'number') return 0; + return raw.version; + } + + private static async setConfigVersion(storage: KGConfigStorage, version: number): Promise { + await storage.saveRaw(CONFIG_UPGRADER_CONSTANTS.VERSION_KEY, { + version, + upgradedAt: Date.now(), + }); + } +} diff --git a/src/core/config-upgrader/upgradeConfigToV1.test.ts b/src/core/config-upgrader/upgradeConfigToV1.test.ts new file mode 100644 index 0000000..cc99d2f --- /dev/null +++ b/src/core/config-upgrader/upgradeConfigToV1.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { instanceToPlain } from 'class-transformer'; +import { KGProject } from '../KGProject'; + +// --- Mock idb for reading old IndexedDB projects --- +const mockProjects: Array<{ name: string; data: Record; lastModified: number }> = []; + +vi.mock('idb', () => { + const stores: Record> = {}; + const getStore = (name: string) => { + if (!stores[name]) stores[name] = new Map(); + return stores[name]; + }; + + return { + openDB: vi.fn(() => { + const db = { + getAll: vi.fn((storeName: string) => { + if (storeName === 'projects') return Promise.resolve([...mockProjects]); + return Promise.resolve([...getStore(storeName).values()]); + }), + get: vi.fn((storeName: string, key: string) => getStore(storeName).get(key)), + put: vi.fn((storeName: string, value: { name: string }) => { + getStore(storeName).set(value.name, value); + }), + close: vi.fn(), + objectStoreNames: { contains: () => false }, + }; + return Promise.resolve(db); + }), + __stores: stores, + __reset: () => Object.keys(stores).forEach((k) => delete stores[k]), + }; +}); + +// --- Mock OPFS for KGProjectStorage --- +class MockWritable { + data = ''; + async write(content: string) { this.data = content; } + async close() {} +} + +class MockFileHandle { + kind = 'file' as const; + private _content = ''; + constructor(public name: string) {} + async getFile() { return { text: () => Promise.resolve(this._content) }; } + async createWritable() { + const w = new MockWritable(); + const self = this; + const origClose = w.close.bind(w); + w.close = async () => { self._content = w.data; await origClose(); }; + return w; + } +} + +class MockDirHandle { + kind = 'directory' as const; + entries = new Map(); + constructor(public name: string) {} + + async getDirectoryHandle(name: string, opts?: { create?: boolean }) { + let e = this.entries.get(name); + if (!e || e.kind !== 'directory') { + if (opts?.create) { + e = new MockDirHandle(name); + this.entries.set(name, e); + } else { + throw new DOMException('Not found', 'NotFoundError'); + } + } + return e as MockDirHandle; + } + + async getFileHandle(name: string, opts?: { create?: boolean }) { + let e = this.entries.get(name); + if (!e || e.kind !== 'file') { + if (opts?.create) { + e = new MockFileHandle(name); + this.entries.set(name, e); + } else { + throw new DOMException('Not found', 'NotFoundError'); + } + } + return e as MockFileHandle; + } + + async removeEntry(name: string) { this.entries.delete(name); } + + async *values() { + for (const e of this.entries.values()) yield e; + } +} + +const mockRoot = new MockDirHandle('root'); + +vi.stubGlobal('navigator', { + ...navigator, + storage: { + getDirectory: vi.fn(() => Promise.resolve(mockRoot)), + persist: vi.fn(() => Promise.resolve(true)), + }, +}); + +// Now import the module under test +import { upgradeConfigToV1 } from './upgradeConfigToV1'; +import { KGProjectStorage } from '../io/KGProjectStorage'; + +describe('upgradeConfigToV1', () => { + beforeEach(async () => { + mockProjects.length = 0; + mockRoot.entries.clear(); + + // Reset KGProjectStorage singleton + ;(KGProjectStorage as unknown as { _instance: undefined })._instance = undefined; + const storage = KGProjectStorage.getInstance(); + await storage.initialize(); + }); + + it('migrates projects from IndexedDB to OPFS', async () => { + // Set up a mock IndexedDB project + const project = new KGProject('My Old Song', 32, 0, 130); + mockProjects.push({ + name: 'My Old Song', + data: instanceToPlain(project) as Record, + lastModified: 1700000000000, + }); + + await upgradeConfigToV1(); + + const storage = KGProjectStorage.getInstance(); + const names = await storage.list(); + expect(names).toContain('My Old Song'); + + const loaded = await storage.load('My Old Song'); + expect(loaded).not.toBeNull(); + expect(loaded!.getBpm()).toBe(130); + }); + + it('sanitizes project names with invalid characters', async () => { + const project = new KGProject('My:Song/Here', 32, 0, 120); + mockProjects.push({ + name: 'My:Song/Here', + data: instanceToPlain(project) as Record, + lastModified: Date.now(), + }); + + await upgradeConfigToV1(); + + const storage = KGProjectStorage.getInstance(); + const names = await storage.list(); + // Should be sanitized — no colons or slashes + expect(names).toContain('My_Song_Here'); + }); + + it('skips projects that already exist in OPFS', async () => { + // Pre-create a project in OPFS + const storage = KGProjectStorage.getInstance(); + const existing = new KGProject('Existing', 32, 0, 100); + await storage.save('Existing', existing); + + // Add same project to IndexedDB mock + mockProjects.push({ + name: 'Existing', + data: instanceToPlain(existing) as Record, + lastModified: Date.now(), + }); + + // Should not throw or overwrite + await upgradeConfigToV1(); + + const loaded = await storage.load('Existing'); + expect(loaded!.getBpm()).toBe(100); // Original BPM preserved + }); + + it('handles empty IndexedDB gracefully', async () => { + // No projects in IndexedDB + await expect(upgradeConfigToV1()).resolves.not.toThrow(); + }); +}); diff --git a/src/core/config-upgrader/upgradeConfigToV1.ts b/src/core/config-upgrader/upgradeConfigToV1.ts new file mode 100644 index 0000000..5edbb65 --- /dev/null +++ b/src/core/config-upgrader/upgradeConfigToV1.ts @@ -0,0 +1,144 @@ +import { openDB } from 'idb'; +import { plainToInstance } from 'class-transformer'; +import { KGProject } from '../KGProject'; +import { KGProjectStorage } from '../io/KGProjectStorage'; +import { upgradeProjectToLatest } from '../project-upgrader/KGProjectUpgrader'; +import { sanitizeProjectName } from '../../util/projectNameUtil'; +import { DB_CONSTANTS } from '../../constants/coreConstants'; + +/** + * Config upgrade V1: Migrate all projects from IndexedDB to OPFS. + * + * This reads directly from the old IndexedDB `projects` store (no dependency on KGStorage), + * sanitizes project names, and writes each project to the OPFS-backed KGProjectStorage. + * + * Idempotent: projects already present in OPFS are skipped. + * Non-destructive: old IndexedDB data is preserved as a backup. + */ +export async function upgradeConfigToV1(): Promise { + console.log('Config V1 upgrade: migrating projects from IndexedDB to OPFS...'); + + // Read all projects from the old IndexedDB store + const oldProjects = await readAllProjectsFromIndexedDB(); + + if (oldProjects.length === 0) { + console.log('Config V1 upgrade: no projects found in IndexedDB, nothing to migrate'); + return; + } + + const projectStorage = KGProjectStorage.getInstance(); + let migrated = 0; + let skipped = 0; + const errors: string[] = []; + + for (const { name, data, lastModified } of oldProjects) { + try { + // Deserialize the project + const instance = plainToInstance(KGProject, data); + const project = Array.isArray(instance) ? instance[0] : instance; + if (!project) { + errors.push(`${name}: deserialization returned null`); + continue; + } + + // Sanitize the project name for filesystem use + const sanitizedName = sanitizeProjectName(name); + project.setName(sanitizedName); + + // Run the project upgrader (handles schema changes like instrument mapping, etc.) + const upgradedProject = upgradeProjectToLatest(project); + + // Skip if already exists in OPFS + if (await projectStorage.exists(sanitizedName)) { + skipped++; + continue; + } + + // Save to OPFS — use overwrite=false since we checked exists() above + // We call save directly which writes meta.json with createdAt = now. + // Override createdAt with the original lastModified from IndexedDB afterwards. + await projectStorage.save(sanitizedName, upgradedProject, false); + + // Patch meta.json to use the original lastModified as createdAt + if (lastModified) { + await patchMetaCreatedAt(projectStorage, sanitizedName, lastModified); + } + + migrated++; + } catch (error) { + errors.push(`${name}: ${error}`); + console.error(`Config V1 upgrade: failed to migrate project "${name}":`, error); + } + } + + console.log( + `Config V1 upgrade complete: ${migrated} migrated, ${skipped} skipped, ${errors.length} errors`, + ); + if (errors.length > 0) { + console.warn('Config V1 upgrade errors:', errors); + } +} + +// --- Internal helpers --- + +interface OldProjectEntry { + name: string; + data: Record; + lastModified: number; +} + +/** + * Read all projects from the old IndexedDB store directly (no KGStorage dependency). + */ +async function readAllProjectsFromIndexedDB(): Promise { + try { + const db = await openDB(DB_CONSTANTS.DB_NAME, DB_CONSTANTS.DB_VERSION, { + upgrade(db) { + // Ensure stores exist (same logic as old KGStorage) + const requiredStores = [DB_CONSTANTS.PROJECTS_STORE_NAME, DB_CONSTANTS.CONFIG_STORE_NAME]; + for (const store of requiredStores) { + if (!db.objectStoreNames.contains(store)) { + db.createObjectStore(store, { keyPath: 'name' }); + } + } + }, + }); + + const allEntries = await db.getAll(DB_CONSTANTS.PROJECTS_STORE_NAME); + db.close(); + + return allEntries.map((entry) => ({ + name: entry.name as string, + data: entry.data as Record, + lastModified: (entry.lastModified as number) ?? Date.now(), + })); + } catch (error) { + console.error('Config V1 upgrade: failed to read from IndexedDB:', error); + return []; + } +} + +/** + * Patch a project's meta.json to set createdAt to the original IndexedDB lastModified. + * This is a best-effort operation — we access OPFS directly for this one-time patch. + */ +async function patchMetaCreatedAt( + projectStorage: KGProjectStorage, + projectName: string, + createdAt: number, +): Promise { + try { + const root = await navigator.storage.getDirectory(); + const projectsDir = await root.getDirectoryHandle('projects'); + const projectDir = await projectsDir.getDirectoryHandle(projectName); + const metaHandle = await projectDir.getFileHandle('meta.json'); + const file = await metaHandle.getFile(); + const meta = JSON.parse(await file.text()); + meta.createdAt = createdAt; + const writable = await metaHandle.createWritable(); + await writable.write(JSON.stringify(meta, null, 2)); + await writable.close(); + } catch { + // Non-critical — createdAt will just be the migration time + } +} diff --git a/src/core/config/ConfigManager.ts b/src/core/config/ConfigManager.ts index 8e17884..2b40a38 100644 --- a/src/core/config/ConfigManager.ts +++ b/src/core/config/ConfigManager.ts @@ -1,5 +1,4 @@ -import { KGStorage } from '../io/KGStorage'; -import { DB_CONSTANTS } from '../../constants/coreConstants'; +import { KGConfigStorage } from '../io/KGConfigStorage'; /** * Application configuration interface @@ -99,7 +98,7 @@ export class ConfigManager { // Configuration state private config: AppConfig; - private storage: KGStorage; + private storage: KGConfigStorage; private isInitialized: boolean = false; private defaultConfig: AppConfig | null = null; private changeListeners: Set<(changedKeys: string[]) => void> = new Set(); @@ -108,7 +107,7 @@ export class ConfigManager { private constructor() { // Initialize with empty config, will be loaded during initialize() this.config = {} as AppConfig; - this.storage = KGStorage.getInstance(); + this.storage = KGConfigStorage.getInstance(); console.log('ConfigManager initialized'); } @@ -262,11 +261,8 @@ export class ConfigManager { // For config, we don't use class-transformer since it's plain objects // So we'll use a simple object approach and handle it directly with KGStorage const savedConfigData = await this.storage.load( - DB_CONSTANTS.DB_NAME, - DB_CONSTANTS.CONFIG_STORE_NAME, ConfigManager.CONFIG_KEY, Object, // Simple object class - DB_CONSTANTS.DB_VERSION ); if (savedConfigData) { @@ -294,12 +290,9 @@ export class ConfigManager { : this.config; await this.storage.save( - DB_CONSTANTS.DB_NAME, - DB_CONSTANTS.CONFIG_STORE_NAME, ConfigManager.CONFIG_KEY, configToPersist, true, // Always overwrite config - DB_CONSTANTS.DB_VERSION ); console.log('Saved config to storage:', configToPersist); } catch (error) { diff --git a/src/core/io/KGConfigStorage.test.ts b/src/core/io/KGConfigStorage.test.ts new file mode 100644 index 0000000..3dbcc0f --- /dev/null +++ b/src/core/io/KGConfigStorage.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock idb before importing KGConfigStorage +vi.mock('idb', () => { + const stores: Record> = {}; + + const getStore = (name: string): Map => { + if (!stores[name]) stores[name] = new Map(); + return stores[name]; + }; + + const mockDB = { + get: vi.fn((storeName: string, key: string) => { + return getStore(storeName).get(key) ?? undefined; + }), + put: vi.fn((storeName: string, value: { name: string }) => { + getStore(storeName).set(value.name, value); + }), + delete: vi.fn((storeName: string, key: string) => { + getStore(storeName).delete(key); + }), + objectStoreNames: { contains: () => false }, + }; + + return { + openDB: vi.fn(() => Promise.resolve(mockDB)), + __stores: stores, + __reset: () => { + Object.keys(stores).forEach((k) => delete stores[k]); + }, + }; +}); + +import { KGConfigStorage } from './KGConfigStorage'; + +// Access mock internals +const idbMock = await import('idb') as unknown as { + __stores: Record>; + __reset: () => void; +}; + +describe('KGConfigStorage', () => { + let storage: KGConfigStorage; + + beforeEach(() => { + idbMock.__reset(); + // Reset singleton for test isolation + ;(KGConfigStorage as unknown as { _instance: undefined })._instance = undefined; + storage = KGConfigStorage.getInstance(); + }); + + it('saves and loads a config entry', async () => { + await storage.save('testKey', { foo: 'bar' }, true); + const result = await storage.load('testKey', Object); + + expect(result).toBeDefined(); + expect((result as Record).foo).toBe('bar'); + }); + + it('deletes a config entry', async () => { + await storage.save('toDelete', { x: 1 }, true); + await storage.delete('toDelete'); + const result = await storage.load('toDelete', Object); + + expect(result).toBeNull(); + }); + + it('saveRaw and getRaw work for version markers', async () => { + await storage.saveRaw('__config_version', { version: 1, upgradedAt: 123 }); + const raw = await storage.getRaw('__config_version'); + + expect(raw).toBeDefined(); + expect(raw!.version).toBe(1); + }); + + it('getRaw returns null for non-existent key', async () => { + const result = await storage.getRaw('nonexistent'); + expect(result).toBeNull(); + }); + + it('returns singleton instance', () => { + const a = KGConfigStorage.getInstance(); + const b = KGConfigStorage.getInstance(); + expect(a).toBe(b); + }); +}); diff --git a/src/core/io/KGConfigStorage.ts b/src/core/io/KGConfigStorage.ts new file mode 100644 index 0000000..bb9c3d2 --- /dev/null +++ b/src/core/io/KGConfigStorage.ts @@ -0,0 +1,119 @@ +import { openDB } from 'idb'; +import type { IDBPDatabase } from 'idb'; +import { instanceToPlain, plainToInstance } from 'class-transformer'; +import { DB_CONSTANTS } from '../../constants/coreConstants'; + +interface ConfigStorageEntry { + name: string; + data: Record; + lastModified: number; +} + +/** + * KGConfigStorage — IndexedDB-backed storage for application configuration. + * Extracted from the former KGStorage class; only manages the config object store. + */ +export class KGConfigStorage { + private static _instance: KGConfigStorage; + private dbPromise: Promise | null = null; + + private constructor() {} + + public static getInstance(): KGConfigStorage { + if (!KGConfigStorage._instance) { + KGConfigStorage._instance = new KGConfigStorage(); + } + return KGConfigStorage._instance; + } + + private getDB(): Promise { + if (!this.dbPromise) { + this.dbPromise = openDB(DB_CONSTANTS.DB_NAME, DB_CONSTANTS.DB_VERSION, { + upgrade(db) { + // Create required object stores if they don't exist + const requiredStores = [ + DB_CONSTANTS.PROJECTS_STORE_NAME, + DB_CONSTANTS.CONFIG_STORE_NAME, + ]; + for (const store of requiredStores) { + if (!db.objectStoreNames.contains(store)) { + db.createObjectStore(store, { keyPath: 'name' }); + console.log(`Created object store: ${store}`); + } + } + }, + }); + } + return this.dbPromise; + } + + public async save(name: string, data: unknown, overwrite: boolean = true): Promise { + const db = await this.getDB(); + const storeName = DB_CONSTANTS.CONFIG_STORE_NAME; + + if (!overwrite) { + const existing = await db.get(storeName, name); + if (existing) { + throw new Error(`Config entry "${name}" already exists`); + } + } + + const entry: ConfigStorageEntry = { + name, + data: instanceToPlain(data) as Record, + lastModified: Date.now(), + }; + await db.put(storeName, entry); + } + + public async load(name: string, classType: new () => T): Promise { + try { + const db = await this.getDB(); + const entry = await db.get(DB_CONSTANTS.CONFIG_STORE_NAME, name); + + if (!entry?.data) { + return null; + } + + const instance = plainToInstance(classType, entry.data); + return Array.isArray(instance) ? instance[0] || null : instance; + } catch (error) { + console.error(`Error loading config entry "${name}":`, error); + return null; + } + } + + public async delete(name: string): Promise { + const db = await this.getDB(); + await db.delete(DB_CONSTANTS.CONFIG_STORE_NAME, name); + } + + /** + * Get a raw value from the config store (no class-transformer deserialization). + * Used by KGConfigUpgrader to read the config version marker. + */ + public async getRaw(name: string): Promise | null> { + try { + const db = await this.getDB(); + const entry = await db.get(DB_CONSTANTS.CONFIG_STORE_NAME, name); + return entry?.data ?? null; + } catch (error) { + console.error(`Error loading raw config entry "${name}":`, error); + return null; + } + } + + /** + * Save a raw value to the config store (no class-transformer serialization). + * Used by KGConfigUpgrader to write the config version marker. + */ + public async saveRaw(name: string, data: Record): Promise { + const db = await this.getDB(); + const entry: ConfigStorageEntry = { + name, + data, + lastModified: Date.now(), + }; + await db.put(DB_CONSTANTS.CONFIG_STORE_NAME, entry); + } +} diff --git a/src/core/io/KGProjectStorage.test.ts b/src/core/io/KGProjectStorage.test.ts new file mode 100644 index 0000000..3ded8e9 --- /dev/null +++ b/src/core/io/KGProjectStorage.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { KGProjectStorage, DuplicateEntryError } from './KGProjectStorage'; +import { KGProject } from '../KGProject'; + +// --- OPFS mock infrastructure --- + +class MockFileSystemWritableFileStream { + public data = ''; + async write(content: string) { this.data = content; } + async close() {} +} + +class MockFileSystemFileHandle { + kind = 'file' as const; + constructor(public name: string, private _content: string = '') {} + async getFile() { + return { text: () => Promise.resolve(this._content) }; + } + async createWritable() { + const stream = new MockFileSystemWritableFileStream(); + // When stream closes, update our content + const self = this; + const origClose = stream.close.bind(stream); + stream.close = async () => { + self._content = stream.data; + await origClose(); + }; + return stream; + } +} + +class MockFileSystemDirectoryHandle { + kind = 'directory' as const; + private entries = new Map(); + + constructor(public name: string) {} + + async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise { + let entry = this.entries.get(name); + if (!entry || entry.kind !== 'directory') { + if (options?.create) { + entry = new MockFileSystemDirectoryHandle(name); + this.entries.set(name, entry); + } else { + throw new DOMException(`Directory "${name}" not found`, 'NotFoundError'); + } + } + return entry as MockFileSystemDirectoryHandle; + } + + async getFileHandle(name: string, options?: { create?: boolean }): Promise { + let entry = this.entries.get(name); + if (!entry || entry.kind !== 'file') { + if (options?.create) { + entry = new MockFileSystemFileHandle(name); + this.entries.set(name, entry); + } else { + throw new DOMException(`File "${name}" not found`, 'NotFoundError'); + } + } + return entry as MockFileSystemFileHandle; + } + + async removeEntry(name: string, _options?: { recursive?: boolean }): Promise { + if (!this.entries.has(name)) { + throw new DOMException(`Entry "${name}" not found`, 'NotFoundError'); + } + this.entries.delete(name); + } + + async *values(): AsyncIterableIterator { + for (const entry of this.entries.values()) { + yield entry; + } + } +} + +// Install the mock +const mockRoot = new MockFileSystemDirectoryHandle('root'); + +vi.stubGlobal('navigator', { + ...navigator, + storage: { + getDirectory: vi.fn(() => Promise.resolve(mockRoot)), + persist: vi.fn(() => Promise.resolve(true)), + estimate: vi.fn(() => Promise.resolve({ usage: 0, quota: 1e9 })), + }, +}); + +describe('KGProjectStorage', () => { + let storage: KGProjectStorage; + + beforeEach(async () => { + // Reset singleton and mock filesystem + ;(KGProjectStorage as unknown as { _instance: undefined })._instance = undefined; + // Clear the mock root directory entries + const entries = (mockRoot as unknown as { entries: Map }).entries; + entries.clear(); + + storage = KGProjectStorage.getInstance(); + await storage.initialize(); + }); + + function createTestProject(name = 'Test Project'): KGProject { + return new KGProject(name, 16, 0, 120); + } + + it('initializes and creates the projects directory', async () => { + // The projects directory should exist after init + const projects = await mockRoot.getDirectoryHandle('projects'); + expect(projects).toBeDefined(); + expect(projects.kind).toBe('directory'); + }); + + it('saves and loads a project', async () => { + const project = createTestProject('My Song'); + await storage.save('My Song', project); + + const loaded = await storage.load('My Song'); + expect(loaded).not.toBeNull(); + expect(loaded!.getName()).toBe('My Song'); + expect(loaded!.getBpm()).toBe(120); + }); + + it('creates meta.json and media/ directory on save', async () => { + const project = createTestProject('My Song'); + await storage.save('My Song', project); + + const projectsDir = await mockRoot.getDirectoryHandle('projects'); + const projectDir = await projectsDir.getDirectoryHandle('My Song'); + + // meta.json should exist + const metaHandle = await projectDir.getFileHandle('meta.json'); + const metaFile = await metaHandle.getFile(); + const meta = JSON.parse(await metaFile.text()); + expect(meta.name).toBe('My Song'); + expect(meta.createdAt).toBeGreaterThan(0); + expect(meta.updatedAt).toBeGreaterThan(0); + + // media/ directory should exist + const mediaDir = await projectDir.getDirectoryHandle('media'); + expect(mediaDir.kind).toBe('directory'); + }); + + it('throws DuplicateEntryError when overwrite is false', async () => { + const project = createTestProject('Duplicate'); + await storage.save('Duplicate', project); + + await expect(storage.save('Duplicate', project, false)).rejects.toThrow(DuplicateEntryError); + }); + + it('allows overwrite when overwrite is true', async () => { + const project = createTestProject('Overwrite Test'); + await storage.save('Overwrite Test', project); + + project.setBpm(140); + await storage.save('Overwrite Test', project, true); + + const loaded = await storage.load('Overwrite Test'); + expect(loaded!.getBpm()).toBe(140); + }); + + it('preserves createdAt on overwrite', async () => { + const project = createTestProject('Preserve'); + await storage.save('Preserve', project); + + // Read the original createdAt + const projectsDir = await mockRoot.getDirectoryHandle('projects'); + const projectDir = await projectsDir.getDirectoryHandle('Preserve'); + const metaHandle1 = await projectDir.getFileHandle('meta.json'); + const meta1 = JSON.parse(await (await metaHandle1.getFile()).text()); + + // Save again (overwrite) + await storage.save('Preserve', project, true); + + const metaHandle2 = await projectDir.getFileHandle('meta.json'); + const meta2 = JSON.parse(await (await metaHandle2.getFile()).text()); + + expect(meta2.createdAt).toBe(meta1.createdAt); + expect(meta2.updatedAt).toBeGreaterThanOrEqual(meta1.updatedAt); + }); + + it('lists project names', async () => { + await storage.save('Alpha', createTestProject('Alpha')); + await storage.save('Beta', createTestProject('Beta')); + await storage.save('Charlie', createTestProject('Charlie')); + + const names = await storage.list(); + expect(names).toEqual(['Alpha', 'Beta', 'Charlie']); + }); + + it('checks if project exists', async () => { + expect(await storage.exists('Nonexistent')).toBe(false); + + await storage.save('Exists', createTestProject('Exists')); + expect(await storage.exists('Exists')).toBe(true); + }); + + it('deletes a project', async () => { + await storage.save('ToDelete', createTestProject('ToDelete')); + expect(await storage.exists('ToDelete')).toBe(true); + + await storage.delete('ToDelete'); + expect(await storage.exists('ToDelete')).toBe(false); + }); + + it('returns null for non-existent project on load', async () => { + const result = await storage.load('Ghost'); + expect(result).toBeNull(); + }); + + it('rejects invalid project names on save', async () => { + const project = createTestProject(); + await expect(storage.save('my/song', project)).rejects.toThrow('Invalid project name'); + await expect(storage.save('my:song', project)).rejects.toThrow('Invalid project name'); + await expect(storage.save('', project)).rejects.toThrow('Invalid project name'); + }); + + it('renames a project', async () => { + await storage.save('Old Name', createTestProject('Old Name')); + + await storage.rename('Old Name', 'New Name'); + + expect(await storage.exists('Old Name')).toBe(false); + expect(await storage.exists('New Name')).toBe(true); + + const loaded = await storage.load('New Name'); + expect(loaded!.getName()).toBe('New Name'); + }); +}); diff --git a/src/core/io/KGProjectStorage.ts b/src/core/io/KGProjectStorage.ts new file mode 100644 index 0000000..ed92db8 --- /dev/null +++ b/src/core/io/KGProjectStorage.ts @@ -0,0 +1,398 @@ +import { instanceToPlain, plainToInstance } from 'class-transformer'; +import JSZip from 'jszip'; +import { KGProject } from '../KGProject'; +import { upgradeProjectToLatest } from '../project-upgrader/KGProjectUpgrader'; +import { isValidProjectName } from '../../util/projectNameUtil'; +import { OPFS_CONSTANTS } from '../../constants/coreConstants'; + +export class DuplicateEntryError extends Error { + constructor(name: string) { + super(`Entry "${name}" already exists`); + this.name = 'DuplicateEntryError'; + } +} + +interface ProjectMeta { + name: string; + createdAt: number; + updatedAt: number; +} + +/** + * KGProjectStorage — OPFS-backed storage for project files. + * Each project lives in its own directory under the OPFS `projects/` root. + * + * Folder structure: + * projects//meta.json + * projects//project.json + * projects//media/ + */ +export class KGProjectStorage { + private static _instance: KGProjectStorage; + private rootDirHandle: FileSystemDirectoryHandle | null = null; + private projectsDirHandle: FileSystemDirectoryHandle | null = null; + private _initialized = false; + + private constructor() {} + + public static getInstance(): KGProjectStorage { + if (!KGProjectStorage._instance) { + KGProjectStorage._instance = new KGProjectStorage(); + } + return KGProjectStorage._instance; + } + + /** + * Initialize OPFS root and request persistent storage. + * Must be called before any other method. + */ + public async initialize(): Promise { + if (this._initialized) return; + + this.rootDirHandle = await navigator.storage.getDirectory(); + this.projectsDirHandle = await this.rootDirHandle.getDirectoryHandle( + OPFS_CONSTANTS.ROOT_DIR, + { create: true }, + ); + + // Request persistent storage so the browser won't evict our data + try { + const persisted = await navigator.storage.persist(); + console.log(`Persistent storage ${persisted ? 'granted' : 'denied'}`); + } catch (error) { + console.warn('navigator.storage.persist() not available:', error); + } + + this._initialized = true; + console.log('KGProjectStorage initialized (OPFS)'); + } + + private ensureInitialized(): void { + if (!this._initialized || !this.projectsDirHandle) { + throw new Error('KGProjectStorage not initialized. Call initialize() first.'); + } + } + + /** + * Save a project. Creates the folder structure and writes meta.json + project.json. + */ + public async save(name: string, data: KGProject, overwrite: boolean = false): Promise { + this.ensureInitialized(); + + if (!isValidProjectName(name)) { + throw new Error( + `Invalid project name "${name}". Only letters, numbers, spaces, hyphens, underscores, periods, and parentheses are allowed.`, + ); + } + + const exists = await this.exists(name); + if (exists && !overwrite) { + throw new DuplicateEntryError(name); + } + + const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name, { create: true }); + + // Ensure media/ directory exists + await projectDir.getDirectoryHandle(OPFS_CONSTANTS.MEDIA_DIR, { create: true }); + + // Write project.json + const projectData = instanceToPlain(data) as Record; + const projectJson = JSON.stringify(projectData, null, 2); + await this.writeFile(projectDir, OPFS_CONSTANTS.PROJECT_FILE, projectJson); + + // Write/update meta.json + const now = Date.now(); + let meta: ProjectMeta; + try { + const existingMeta = await this.readFile(projectDir, OPFS_CONSTANTS.METADATA_FILE); + const parsed = JSON.parse(existingMeta) as ProjectMeta; + meta = { name, createdAt: parsed.createdAt, updatedAt: now }; + } catch { + meta = { name, createdAt: now, updatedAt: now }; + } + await this.writeFile(projectDir, OPFS_CONSTANTS.METADATA_FILE, JSON.stringify(meta, null, 2)); + } + + /** + * Load a project by name. Runs the project upgrader on the loaded data. + */ + public async load(name: string): Promise { + this.ensureInitialized(); + + try { + const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name); + const projectJson = await this.readFile(projectDir, OPFS_CONSTANTS.PROJECT_FILE); + const plainData = JSON.parse(projectJson); + + const instance = plainToInstance(KGProject, plainData); + const project = Array.isArray(instance) ? instance[0] || null : instance; + + if (!project) return null; + + project.setName(name); + return upgradeProjectToLatest(project); + } catch (error) { + console.error(`Error loading project "${name}":`, error); + return null; + } + } + + /** + * List all project names (folder names under projects/). + */ + public async list(): Promise { + this.ensureInitialized(); + + const names: string[] = []; + // FileSystemDirectoryHandle.entries() returns AsyncIterableIterator + // TypeScript's lib.dom.d.ts may lack full typing for this, so we iterate via values() + for await (const entry of this.projectsDirHandle!.values()) { + if (entry.kind === 'directory') { + names.push(entry.name); + } + } + return names.sort(); + } + + /** + * Delete a project and all its files. + */ + public async delete(name: string): Promise { + this.ensureInitialized(); + + try { + await this.projectsDirHandle!.removeEntry(name, { recursive: true }); + } catch (error) { + console.error(`Error deleting project "${name}":`, error); + throw error; + } + } + + /** + * Check if a project exists. + */ + public async exists(name: string): Promise { + this.ensureInitialized(); + + try { + await this.projectsDirHandle!.getDirectoryHandle(name); + return true; + } catch { + return false; + } + } + + /** + * Rename a project by copying its directory contents to a new name and deleting the old one. + */ + public async rename(oldName: string, newName: string): Promise { + this.ensureInitialized(); + + if (!isValidProjectName(newName)) { + throw new Error(`Invalid project name "${newName}".`); + } + + if (await this.exists(newName)) { + throw new DuplicateEntryError(newName); + } + + // Load the project from the old location + const project = await this.load(oldName); + if (!project) { + throw new Error(`Project "${oldName}" not found.`); + } + + // Save to new location + project.setName(newName); + await this.save(newName, project, false); + + // Delete old location + await this.delete(oldName); + } + + /** + * Export a project folder as a zip Blob (.kgstudio bundle). + * Includes project.json, meta.json, and all files in media/. + */ + public async exportAsZip(name: string): Promise { + this.ensureInitialized(); + + const projectDir = await this.projectsDirHandle!.getDirectoryHandle(name); + const zip = new JSZip(); + + await this.addDirectoryToZip(zip, projectDir); + + return zip.generateAsync({ type: 'blob' }); + } + + /** + * Recursively add all files and subdirectories from an OPFS directory to a JSZip instance. + */ + private async addDirectoryToZip( + zip: JSZip, + dirHandle: FileSystemDirectoryHandle, + path: string = '', + ): Promise { + for await (const entry of dirHandle.values()) { + const entryPath = path ? `${path}/${entry.name}` : entry.name; + + if (entry.kind === 'file') { + const fileHandle = entry as FileSystemFileHandle; + const file = await fileHandle.getFile(); + zip.file(entryPath, file.arrayBuffer()); + } else { + const subDir = entry as FileSystemDirectoryHandle; + await this.addDirectoryToZip(zip, subDir, entryPath); + } + } + } + + /** + * Import a .kgstudio zip bundle into OPFS. + * Validates that meta.json exists and is valid. + * Returns the project name on success. + * On failure, cleans up any partially written folder and throws. + */ + public async importFromZip(blob: Blob): Promise { + this.ensureInitialized(); + + const zip = await JSZip.loadAsync(blob); + + // Validate meta.json + const metaFile = zip.file(OPFS_CONSTANTS.METADATA_FILE); + if (!metaFile) { + throw new Error('Invalid .kgstudio file: missing meta.json'); + } + + let meta: { name?: string }; + try { + const metaText = await metaFile.async('text'); + meta = JSON.parse(metaText); + } catch { + throw new Error('Invalid .kgstudio file: meta.json is corrupted'); + } + + if (!meta.name || typeof meta.name !== 'string') { + throw new Error('Invalid .kgstudio file: meta.json missing project name'); + } + + const projectName = await this.resolveUniqueName(meta.name); + + const projectDir = await this.projectsDirHandle!.getDirectoryHandle(projectName, { create: true }); + + try { + // Write all files from the zip into the OPFS project directory + for (const [relativePath, zipEntry] of Object.entries(zip.files)) { + if (zipEntry.dir) { + // Create subdirectory + await this.getOrCreateSubDir(projectDir, relativePath); + } else { + // Write file + const data = await zipEntry.async('arraybuffer'); + const parts = relativePath.split('/'); + const fileName = parts.pop()!; + + let targetDir = projectDir; + if (parts.length > 0) { + targetDir = await this.getOrCreateSubDir(projectDir, parts.join('/')); + } + + const fileHandle = await targetDir.getFileHandle(fileName, { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(data); + await writable.close(); + } + } + + // If the name was deduplicated, update meta.json and project.json to reflect it + if (projectName !== meta.name) { + // Patch meta.json + try { + const metaHandle = await projectDir.getFileHandle(OPFS_CONSTANTS.METADATA_FILE); + const metaFileObj = await metaHandle.getFile(); + const metaData = JSON.parse(await metaFileObj.text()); + metaData.name = projectName; + const w1 = await metaHandle.createWritable(); + await w1.write(JSON.stringify(metaData, null, 2)); + await w1.close(); + } catch { /* best effort */ } + + // Patch project.json name field + try { + const projHandle = await projectDir.getFileHandle(OPFS_CONSTANTS.PROJECT_FILE); + const projFileObj = await projHandle.getFile(); + const projData = JSON.parse(await projFileObj.text()); + projData.name = projectName; + const w2 = await projHandle.createWritable(); + await w2.write(JSON.stringify(projData, null, 2)); + await w2.close(); + } catch { /* best effort */ } + } + + return projectName; + } catch (error) { + // Clean up the partially written folder + try { + await this.projectsDirHandle!.removeEntry(projectName, { recursive: true }); + } catch { + // Best effort cleanup + } + throw error; + } + } + + /** + * Get or create a nested subdirectory from a path like "media/subfolder". + */ + private async getOrCreateSubDir( + root: FileSystemDirectoryHandle, + path: string, + ): Promise { + const segments = path.replace(/\/$/, '').split('/').filter(Boolean); + let current = root; + for (const seg of segments) { + current = await current.getDirectoryHandle(seg, { create: true }); + } + return current; + } + + /** + * Return a unique project name by appending (1), (2), etc. if the name already exists. + */ + public async resolveUniqueName(name: string): Promise { + this.ensureInitialized(); + + if (!(await this.exists(name))) return name; + + let counter = 1; + let candidate: string; + do { + candidate = `${name} (${counter})`; + counter++; + } while (await this.exists(candidate)); + + return candidate; + } + + // --- File I/O helpers --- + + private async writeFile( + dirHandle: FileSystemDirectoryHandle, + fileName: string, + content: string, + ): Promise { + const fileHandle = await dirHandle.getFileHandle(fileName, { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(content); + await writable.close(); + } + + private async readFile( + dirHandle: FileSystemDirectoryHandle, + fileName: string, + ): Promise { + const fileHandle = await dirHandle.getFileHandle(fileName); + const file = await fileHandle.getFile(); + return file.text(); + } +} diff --git a/src/core/io/KGStorage.ts b/src/core/io/KGStorage.ts deleted file mode 100644 index a5441c1..0000000 --- a/src/core/io/KGStorage.ts +++ /dev/null @@ -1,132 +0,0 @@ -// src/core/io/KGStorage.ts - -import { openDB } from 'idb' -import type { IDBPDatabase } from 'idb' -import { plainToInstance, instanceToPlain } from 'class-transformer' -import { DB_CONSTANTS } from '../../constants/coreConstants' - -export interface StorageEntry { - name: string - data: Record - lastModified: number -} - -export class DuplicateEntryError extends Error { - constructor(name: string) { - super(`Entry "${name}" already exists`) - this.name = 'DuplicateEntryError' - } -} - -export class KGStorage { - private static instance: KGStorage - private dbPromises: Map> - - private constructor() { - this.dbPromises = new Map() - } - - public static getInstance(): KGStorage { - if (!KGStorage.instance) { - KGStorage.instance = new KGStorage() - } - return KGStorage.instance - } - - private getDB(dbName: string, _storeName: string, version: number = 1): Promise { - const key = `${dbName}_${version}` - - if (!this.dbPromises.has(key)) { - const dbPromise = openDB(dbName, version, { - upgrade(db) { - // Create all required object stores for this database - const requiredStores = [ - DB_CONSTANTS.PROJECTS_STORE_NAME, - DB_CONSTANTS.CONFIG_STORE_NAME - ]; - - for (const store of requiredStores) { - if (!db.objectStoreNames.contains(store)) { - db.createObjectStore(store, { keyPath: 'name' }) - console.log(`Created object store: ${store}`) - } - } - }, - }) - this.dbPromises.set(key, dbPromise) - } - - return this.dbPromises.get(key)! - } - - public async save( - dbName: string, - storeName: string, - name: string, - data: T, - overwrite: boolean = false, - version: number = 1 - ): Promise { - const db = await this.getDB(dbName, storeName, version) - const existing = await db.get(storeName, name) - if (existing && !overwrite) { - throw new DuplicateEntryError(name) - } - const entry: StorageEntry = { - name: name, - data: instanceToPlain(data) as Record, - lastModified: Date.now(), - } - await db.put(storeName, entry) - } - - public async load( - dbName: string, - storeName: string, - name: string, - classType: new() => T, - version: number = 1 - ): Promise { - try { - const db = await this.getDB(dbName, storeName, version) - const entry = await db.get(storeName, name) - - if (!entry?.data) { - console.log(`No data found for entry "${name}" in store "${storeName}" of database "${dbName}"`) - return null - } - - const instance = plainToInstance(classType, entry.data) - const loadedInstance = Array.isArray(instance) ? instance[0] || null : instance - - if (loadedInstance && typeof (loadedInstance as { setName?: (projectName: string) => void }).setName === 'function') { - (loadedInstance as { setName: (projectName: string) => void }).setName(name) - } - - return loadedInstance - } catch (error) { - console.log(`Error loading entry "${name}" from store "${storeName}" of database "${dbName}":`, error) - return null - } - } - - public async list( - dbName: string, - storeName: string, - version: number = 1 - ): Promise { - const db = await this.getDB(dbName, storeName, version) - const all = await db.getAllKeys(storeName) - return all as string[] - } - - public async delete( - dbName: string, - storeName: string, - name: string, - version: number = 1 - ): Promise { - const db = await this.getDB(dbName, storeName, version) - await db.delete(storeName, name) - } -} \ No newline at end of file diff --git a/src/types/opfs.d.ts b/src/types/opfs.d.ts new file mode 100644 index 0000000..c51e170 --- /dev/null +++ b/src/types/opfs.d.ts @@ -0,0 +1,9 @@ +/** + * Type augmentations for the Origin Private File System (OPFS) async iterable APIs. + * These are part of the File System Access API but not yet fully typed in TypeScript's lib.dom.d.ts. + */ +interface FileSystemDirectoryHandle { + values(): AsyncIterableIterator; + keys(): AsyncIterableIterator; + entries(): AsyncIterableIterator<[string, FileSystemDirectoryHandle | FileSystemFileHandle]>; +} diff --git a/src/util/projectNameUtil.test.ts b/src/util/projectNameUtil.test.ts new file mode 100644 index 0000000..03f6329 --- /dev/null +++ b/src/util/projectNameUtil.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { isValidProjectName, sanitizeProjectName } from './projectNameUtil'; + +describe('isValidProjectName', () => { + it('accepts simple alphanumeric names', () => { + expect(isValidProjectName('MyProject')).toBe(true); + expect(isValidProjectName('project123')).toBe(true); + }); + + it('accepts names with allowed special characters', () => { + expect(isValidProjectName('My Song')).toBe(true); + expect(isValidProjectName('song-v2')).toBe(true); + expect(isValidProjectName('song_final')).toBe(true); + expect(isValidProjectName('song.backup')).toBe(true); + expect(isValidProjectName('Song (v2)')).toBe(true); + }); + + it('rejects empty or whitespace-only names', () => { + expect(isValidProjectName('')).toBe(false); + expect(isValidProjectName(' ')).toBe(false); + }); + + it('rejects names starting with a dot', () => { + expect(isValidProjectName('.hidden')).toBe(false); + }); + + it('rejects names with disallowed characters', () => { + expect(isValidProjectName('my/song')).toBe(false); + expect(isValidProjectName('my\\song')).toBe(false); + expect(isValidProjectName('my:song')).toBe(false); + expect(isValidProjectName('my*song')).toBe(false); + expect(isValidProjectName('my?song')).toBe(false); + expect(isValidProjectName('my"song')).toBe(false); + expect(isValidProjectName('mysong')).toBe(false); + expect(isValidProjectName('my|song')).toBe(false); + }); + + it('accepts accented characters', () => { + expect(isValidProjectName('Café Waltz')).toBe(true); + expect(isValidProjectName('Ñoño')).toBe(true); + }); +}); + +describe('sanitizeProjectName', () => { + it('returns valid names unchanged', () => { + expect(sanitizeProjectName('My Song')).toBe('My Song'); + expect(sanitizeProjectName('project-123')).toBe('project-123'); + }); + + it('replaces disallowed characters with underscores', () => { + expect(sanitizeProjectName('my/song')).toBe('my_song'); + expect(sanitizeProjectName('my:song')).toBe('my_song'); + expect(sanitizeProjectName('a*b?c')).toBe('a_b_c'); + }); + + it('collapses consecutive underscores', () => { + expect(sanitizeProjectName('a///b')).toBe('a_b'); + expect(sanitizeProjectName('a__b')).toBe('a_b'); + }); + + it('collapses consecutive spaces', () => { + expect(sanitizeProjectName('a b')).toBe('a b'); + }); + + it('trims leading/trailing whitespace, underscores, and dots', () => { + expect(sanitizeProjectName(' My Song ')).toBe('My Song'); + expect(sanitizeProjectName('__song__')).toBe('song'); + expect(sanitizeProjectName('.hidden')).toBe('hidden'); + expect(sanitizeProjectName('...dots...')).toBe('dots'); + }); + + it('returns fallback for names that become empty after sanitization', () => { + expect(sanitizeProjectName('///')).toBe('Untitled Project'); + expect(sanitizeProjectName('...')).toBe('Untitled Project'); + expect(sanitizeProjectName('___')).toBe('Untitled Project'); + }); +}); diff --git a/src/util/projectNameUtil.ts b/src/util/projectNameUtil.ts new file mode 100644 index 0000000..6f8739c --- /dev/null +++ b/src/util/projectNameUtil.ts @@ -0,0 +1,44 @@ +/** + * Allowed characters for project names: letters, numbers, space, hyphen, underscore, period, parentheses. + * These are safe across Windows, macOS, and Linux as directory names. + */ +const VALID_PROJECT_NAME_REGEX = /^[a-zA-Z0-9 \-_.()\u00C0-\u024F]+$/; + +/** + * Characters that are NOT allowed in project names — replaced during sanitization. + */ +const DISALLOWED_CHARS_REGEX = /[^a-zA-Z0-9 \-_.()\u00C0-\u024F]/g; + +/** + * Validate whether a project name contains only allowed characters. + * Does NOT check for empty string — caller should check that separately. + */ +export function isValidProjectName(name: string): boolean { + if (!name || name.trim().length === 0) return false; + if (name.startsWith('.')) return false; // hidden files on Unix + return VALID_PROJECT_NAME_REGEX.test(name); +} + +/** + * Sanitize a project name by replacing disallowed characters with underscores, + * collapsing consecutive underscores/spaces, and trimming. + */ +export function sanitizeProjectName(name: string): string { + let sanitized = name.replace(DISALLOWED_CHARS_REGEX, '_'); + + // Collapse consecutive underscores + sanitized = sanitized.replace(/_{2,}/g, '_'); + + // Collapse consecutive spaces + sanitized = sanitized.replace(/ {2,}/g, ' '); + + // Trim leading/trailing whitespace, underscores, and dots + sanitized = sanitized.replace(/^[.\s_]+|[.\s_]+$/g, '').trim(); + + // If everything was stripped, provide a fallback + if (sanitized.length === 0) { + sanitized = 'Untitled Project'; + } + + return sanitized; +} diff --git a/src/util/saveUtil.ts b/src/util/saveUtil.ts index d54acd1..780e16a 100644 --- a/src/util/saveUtil.ts +++ b/src/util/saveUtil.ts @@ -1,5 +1,4 @@ -import { KGStorage, DuplicateEntryError } from '../core/io/KGStorage'; -import { DB_CONSTANTS } from '../constants/coreConstants'; +import { KGProjectStorage, DuplicateEntryError } from '../core/io/KGProjectStorage'; import { KGCore } from '../core/KGCore'; /** @@ -13,43 +12,37 @@ export const saveProject = async ( projectName: string, setStatus: (status: string) => void ): Promise => { - const storage = KGStorage.getInstance(); - + const storage = KGProjectStorage.getInstance(); + try { await storage.save( - DB_CONSTANTS.DB_NAME, - DB_CONSTANTS.PROJECTS_STORE_NAME, projectName, KGCore.instance().getCurrentProject(), false, - DB_CONSTANTS.DB_VERSION ); - + setStatus(`Project "${projectName}" has been saved`); console.log("project saved successfully"); return true; - + } catch (error) { console.error("Error saving project:", error); - + if (error instanceof DuplicateEntryError) { const confirmed = window.confirm(`Project "${projectName}" already exists. Do you want to overwrite it?`); - + if (confirmed) { try { await storage.save( - DB_CONSTANTS.DB_NAME, - DB_CONSTANTS.PROJECTS_STORE_NAME, projectName, KGCore.instance().getCurrentProject(), true, - DB_CONSTANTS.DB_VERSION ); - + setStatus(`Project "${projectName}" has been saved`); console.log("project saved successfully after overwrite"); return true; - + } catch (overwriteError) { console.error("Error overwriting project:", overwriteError); window.alert(`An error occurred while overwriting the project: ${overwriteError}`); @@ -65,4 +58,4 @@ export const saveProject = async ( return false; } } -}; \ No newline at end of file +};