diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 34f83be..af37e84 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -36,7 +36,7 @@ import { clearChatHistoryAndUI } from '../util/chatUtil'; import PianoIcon from './common/icons/PianoIcon'; import MetronomeIcon from './common/icons/MetronomeIcon'; import { ConfigManager } from '../core/config/ConfigManager'; -import { showAlert, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil'; +import { showAlert, showChoice, showConfirm, showPrompt, showTimeSigPrompt } from '../util/dialogUtil'; const Toolbar: React.FC = () => { const { @@ -112,8 +112,28 @@ const Toolbar: React.FC = () => { return; } - // Conflict check: only relevant when targeting a different OPFS folder - if (newName !== savedProjectName) { + // If the name hasn't changed from the saved state, just save without prompting + if (newName === savedProjectName) { + setProjectName(newName); + await saveProject(newName, savedProjectName, setStatus, (finalName) => { + setSavedProjectName(finalName); + if (finalName !== newName) setProjectName(finalName); + }); + return; + } + + // Ask whether the user wants to rename or save as a copy + const choice = await showChoice( + "Would you like to rename this project, or save it as a new copy?", + [ + { label: 'Save as Copy', value: 'saveas' }, + { label: 'Rename', value: 'rename' }, + ] + ); + if (!choice) return; + + if (choice === 'rename') { + // Conflict check: only relevant when targeting a different OPFS folder const storage = KGProjectStorage.getInstance(); const exists = await storage.exists(newName); if (exists) { @@ -121,7 +141,6 @@ const Toolbar: React.FC = () => { `Project "${newName}" already exists. Do you want to overwrite it?` ); if (!confirmed) return; - // Confirmed: update in-memory name then save immediately, overwriting the existing project setProjectName(newName); await saveProject(newName, savedProjectName, setStatus, (finalName) => { setSavedProjectName(finalName); @@ -129,14 +148,25 @@ const Toolbar: React.FC = () => { }, true /* forceOverwrite */); return; } + setProjectName(newName); + await saveProject(newName, savedProjectName, setStatus, (finalName) => { + setSavedProjectName(finalName); + if (finalName !== newName) setProjectName(finalName); + }); + } else { + // Save as Copy: save current state under a unique new name, then switch to it + const storage = KGProjectStorage.getInstance(); + const finalName = await storage.resolveUniqueName(newName); + try { + await storage.saveAs(savedProjectName, finalName, KGCore.instance().getCurrentProject()); + setProjectName(finalName); + setSavedProjectName(finalName); + setStatus(`Saved as "${finalName}"`); + } catch (error) { + console.error('Error saving project as copy:', error); + await showAlert(`An error occurred while saving: ${error}`); + } } - - // Name is available — update and save immediately - setProjectName(newName); - await saveProject(newName, savedProjectName, setStatus, (finalName) => { - setSavedProjectName(finalName); - if (finalName !== newName) setProjectName(finalName); - }); }; // Common project loading logic extracted for reuse diff --git a/src/components/common/DialogProvider.css b/src/components/common/DialogProvider.css index 4fba3e8..2ecc648 100644 --- a/src/components/common/DialogProvider.css +++ b/src/components/common/DialogProvider.css @@ -183,4 +183,15 @@ .dialog-btn-cancel:hover { background-color: #4a4a4a; color: #e0e0e0; +} + +.dialog-btn-secondary { + background-color: transparent; + color: #5a9fd4; + border: 1px solid #5a9fd4; +} + +.dialog-btn-secondary:hover { + background-color: rgba(90, 159, 212, 0.12); + transform: translateY(-1px); } \ No newline at end of file diff --git a/src/components/common/DialogProvider.tsx b/src/components/common/DialogProvider.tsx index eadeffb..b0c8199 100644 --- a/src/components/common/DialogProvider.tsx +++ b/src/components/common/DialogProvider.tsx @@ -2,14 +2,15 @@ import React, { useState, useCallback, useRef } from 'react'; import './DialogProvider.css'; import { FaTimes } from 'react-icons/fa'; import { registerDialogFns } from '../../util/dialogUtil'; -import type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil'; +import type { ChoiceOption, ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil'; interface DialogInfo { - type: 'alert' | 'confirm' | 'prompt' | 'timesig'; + type: 'alert' | 'confirm' | 'prompt' | 'timesig' | 'choice'; message: string; options?: ConfirmOptions | PromptOptions; defaultValue?: string; defaultTimeSig?: TimeSigResult; + choices?: ChoiceOption[]; } const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -53,6 +54,13 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = }); }, []); + const openChoice = useCallback((message: string, choices: ChoiceOption[]): Promise => { + return new Promise((resolve) => { + resolveRef.current = resolve; + setDialog({ type: 'choice', message, choices }); + }); + }, []); + const close = useCallback((value: unknown) => { pendingValueRef.current = value; setIsClosing(true); @@ -77,7 +85,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = const registered = useRef(false); if (!registered.current) { registered.current = true; - registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig); + registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig, openChoice); } if (!dialog) { @@ -87,6 +95,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = const isAlert = dialog.type === 'alert'; const isPrompt = dialog.type === 'prompt'; const isTimeSig = dialog.type === 'timesig'; + const isChoice = dialog.type === 'choice'; const promptOptions = isPrompt ? (dialog.options as PromptOptions | undefined) : undefined; const title = isAlert ? 'Notice' : isTimeSig ? 'Time Signature' : isPrompt ? 'Input' : 'Confirm'; @@ -97,11 +106,11 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = const handleOverlayClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget && mouseDownOnOverlay.current) { - close(isAlert ? undefined : (isPrompt || isTimeSig) ? null : false); + close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice) ? null : false); } }; - const handleCancel = () => close(isAlert ? undefined : (isPrompt || isTimeSig) ? null : false); + const handleCancel = () => close(isAlert ? undefined : (isPrompt || isTimeSig || isChoice) ? null : false); const handleConfirm = () => { if (isAlert) { close(undefined); return; } @@ -182,13 +191,26 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) = {(dialog.options as ConfirmOptions | PromptOptions | undefined)?.cancelLabel ?? 'Cancel'} )} - + {isChoice ? ( + dialog.choices?.map((choice, i) => ( + + )) + ) : ( + + )} diff --git a/src/core/io/KGProjectStorage.ts b/src/core/io/KGProjectStorage.ts index 7bdac74..8349d1e 100644 --- a/src/core/io/KGProjectStorage.ts +++ b/src/core/io/KGProjectStorage.ts @@ -371,6 +371,20 @@ export class KGProjectStorage { } } + /** + * Save the current in-memory project under a new name without removing the source folder. + * Used for "Save as Copy": the original project remains intact in OPFS. + */ + public async saveAs(sourceName: string, targetName: string, data: KGProject): Promise { + this.ensureInitialized(); + + await this.save(targetName, data, false); + + if (await this.exists(sourceName)) { + await this.copyMediaFiles(sourceName, targetName); + } + } + /** * Export a project folder as a zip Blob (.kgstudio bundle). * Includes project.json, meta.json, and all files in media/. diff --git a/src/util/dialogUtil.ts b/src/util/dialogUtil.ts index c9b84a8..ac40880 100644 --- a/src/util/dialogUtil.ts +++ b/src/util/dialogUtil.ts @@ -14,21 +14,29 @@ export interface TimeSigResult { denominator: number; } +export interface ChoiceOption { + label: string; + value: string; +} + let _showAlertFn: ((message: string) => Promise) | null = null; let _showConfirmFn: ((message: string, options?: ConfirmOptions) => Promise) | null = null; let _showPromptFn: ((message: string, defaultValue?: string, options?: PromptOptions) => Promise) | null = null; let _showTimeSigFn: ((message: string, defaultValue?: TimeSigResult) => Promise) | null = null; +let _showChoiceFn: ((message: string, choices: ChoiceOption[]) => Promise) | null = null; export function registerDialogFns( alertFn: (message: string) => Promise, confirmFn: (message: string, options?: ConfirmOptions) => Promise, promptFn: (message: string, defaultValue?: string, options?: PromptOptions) => Promise, timeSigFn: (message: string, defaultValue?: TimeSigResult) => Promise, + choiceFn?: (message: string, choices: ChoiceOption[]) => Promise, ) { _showAlertFn = alertFn; _showConfirmFn = confirmFn; _showPromptFn = promptFn; _showTimeSigFn = timeSigFn; + if (choiceFn) _showChoiceFn = choiceFn; } export function showAlert(message: string): Promise { @@ -53,6 +61,13 @@ export function showPrompt(message: string, defaultValue?: string, options?: Pro return _showPromptFn(message, defaultValue, options); } +export function showChoice(message: string, choices: ChoiceOption[]): Promise { + if (!_showChoiceFn) { + return Promise.resolve(window.confirm(message) ? choices[0]?.value ?? null : null); + } + return _showChoiceFn(message, choices); +} + export function showTimeSigPrompt(message: string, defaultValue?: TimeSigResult): Promise { if (!_showTimeSigFn) { const raw = window.prompt(message, defaultValue ? `${defaultValue.numerator}/${defaultValue.denominator}` : '4/4');