feat: added save as option when renaming the project
This commit is contained in:
@@ -36,7 +36,7 @@ import { clearChatHistoryAndUI } from '../util/chatUtil';
|
|||||||
import PianoIcon from './common/icons/PianoIcon';
|
import PianoIcon from './common/icons/PianoIcon';
|
||||||
import MetronomeIcon from './common/icons/MetronomeIcon';
|
import MetronomeIcon from './common/icons/MetronomeIcon';
|
||||||
import { ConfigManager } from '../core/config/ConfigManager';
|
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 Toolbar: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
@@ -112,8 +112,28 @@ const Toolbar: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Conflict check: only relevant when targeting a different OPFS folder
|
||||||
if (newName !== savedProjectName) {
|
|
||||||
const storage = KGProjectStorage.getInstance();
|
const storage = KGProjectStorage.getInstance();
|
||||||
const exists = await storage.exists(newName);
|
const exists = await storage.exists(newName);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
@@ -121,7 +141,6 @@ const Toolbar: React.FC = () => {
|
|||||||
`Project "${newName}" already exists. Do you want to overwrite it?`
|
`Project "${newName}" already exists. Do you want to overwrite it?`
|
||||||
);
|
);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
// Confirmed: update in-memory name then save immediately, overwriting the existing project
|
|
||||||
setProjectName(newName);
|
setProjectName(newName);
|
||||||
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
|
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
|
||||||
setSavedProjectName(finalName);
|
setSavedProjectName(finalName);
|
||||||
@@ -129,14 +148,25 @@ const Toolbar: React.FC = () => {
|
|||||||
}, true /* forceOverwrite */);
|
}, true /* forceOverwrite */);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Name is available — update and save immediately
|
|
||||||
setProjectName(newName);
|
setProjectName(newName);
|
||||||
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
|
await saveProject(newName, savedProjectName, setStatus, (finalName) => {
|
||||||
setSavedProjectName(finalName);
|
setSavedProjectName(finalName);
|
||||||
if (finalName !== newName) setProjectName(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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Common project loading logic extracted for reuse
|
// Common project loading logic extracted for reuse
|
||||||
|
|||||||
@@ -184,3 +184,14 @@
|
|||||||
background-color: #4a4a4a;
|
background-color: #4a4a4a;
|
||||||
color: #e0e0e0;
|
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);
|
||||||
|
}
|
||||||
@@ -2,14 +2,15 @@ import React, { useState, useCallback, useRef } from 'react';
|
|||||||
import './DialogProvider.css';
|
import './DialogProvider.css';
|
||||||
import { FaTimes } from 'react-icons/fa';
|
import { FaTimes } from 'react-icons/fa';
|
||||||
import { registerDialogFns } from '../../util/dialogUtil';
|
import { registerDialogFns } from '../../util/dialogUtil';
|
||||||
import type { ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
|
import type { ChoiceOption, ConfirmOptions, PromptOptions, TimeSigResult } from '../../util/dialogUtil';
|
||||||
|
|
||||||
interface DialogInfo {
|
interface DialogInfo {
|
||||||
type: 'alert' | 'confirm' | 'prompt' | 'timesig';
|
type: 'alert' | 'confirm' | 'prompt' | 'timesig' | 'choice';
|
||||||
message: string;
|
message: string;
|
||||||
options?: ConfirmOptions | PromptOptions;
|
options?: ConfirmOptions | PromptOptions;
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
defaultTimeSig?: TimeSigResult;
|
defaultTimeSig?: TimeSigResult;
|
||||||
|
choices?: ChoiceOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
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<string | null> => {
|
||||||
|
return new Promise<string | null>((resolve) => {
|
||||||
|
resolveRef.current = resolve;
|
||||||
|
setDialog({ type: 'choice', message, choices });
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const close = useCallback((value: unknown) => {
|
const close = useCallback((value: unknown) => {
|
||||||
pendingValueRef.current = value;
|
pendingValueRef.current = value;
|
||||||
setIsClosing(true);
|
setIsClosing(true);
|
||||||
@@ -77,7 +85,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
|||||||
const registered = useRef(false);
|
const registered = useRef(false);
|
||||||
if (!registered.current) {
|
if (!registered.current) {
|
||||||
registered.current = true;
|
registered.current = true;
|
||||||
registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig);
|
registerDialogFns(openAlert, openConfirm, openPrompt, openTimeSig, openChoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!dialog) {
|
if (!dialog) {
|
||||||
@@ -87,6 +95,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
|||||||
const isAlert = dialog.type === 'alert';
|
const isAlert = dialog.type === 'alert';
|
||||||
const isPrompt = dialog.type === 'prompt';
|
const isPrompt = dialog.type === 'prompt';
|
||||||
const isTimeSig = dialog.type === 'timesig';
|
const isTimeSig = dialog.type === 'timesig';
|
||||||
|
const isChoice = dialog.type === 'choice';
|
||||||
const promptOptions = isPrompt ? (dialog.options as PromptOptions | undefined) : undefined;
|
const promptOptions = isPrompt ? (dialog.options as PromptOptions | undefined) : undefined;
|
||||||
|
|
||||||
const title = isAlert ? 'Notice' : isTimeSig ? 'Time Signature' : isPrompt ? 'Input' : 'Confirm';
|
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) => {
|
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||||
if (e.target === e.currentTarget && mouseDownOnOverlay.current) {
|
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 = () => {
|
const handleConfirm = () => {
|
||||||
if (isAlert) { close(undefined); return; }
|
if (isAlert) { close(undefined); return; }
|
||||||
@@ -182,6 +191,18 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
|||||||
{(dialog.options as ConfirmOptions | PromptOptions | undefined)?.cancelLabel ?? 'Cancel'}
|
{(dialog.options as ConfirmOptions | PromptOptions | undefined)?.cancelLabel ?? 'Cancel'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isChoice ? (
|
||||||
|
dialog.choices?.map((choice, i) => (
|
||||||
|
<button
|
||||||
|
key={choice.value}
|
||||||
|
className={`dialog-btn ${i === (dialog.choices!.length - 1) ? 'dialog-btn-primary' : 'dialog-btn-secondary'}`}
|
||||||
|
onClick={() => close(choice.value)}
|
||||||
|
autoFocus={i === dialog.choices!.length - 1}
|
||||||
|
>
|
||||||
|
{choice.label}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
className="dialog-btn dialog-btn-primary"
|
className="dialog-btn dialog-btn-primary"
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
@@ -189,6 +210,7 @@ const DialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
|||||||
>
|
>
|
||||||
{isAlert ? 'OK' : ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel ?? (isPrompt || isTimeSig ? 'OK' : 'Yes'))}
|
{isAlert ? 'OK' : ((dialog.options as ConfirmOptions | PromptOptions | undefined)?.confirmLabel ?? (isPrompt || isTimeSig ? 'OK' : 'Yes'))}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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).
|
* Export a project folder as a zip Blob (.kgstudio bundle).
|
||||||
* Includes project.json, meta.json, and all files in media/.
|
* Includes project.json, meta.json, and all files in media/.
|
||||||
|
|||||||
@@ -14,21 +14,29 @@ export interface TimeSigResult {
|
|||||||
denominator: number;
|
denominator: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChoiceOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
let _showAlertFn: ((message: string) => Promise<void>) | null = null;
|
let _showAlertFn: ((message: string) => Promise<void>) | null = null;
|
||||||
let _showConfirmFn: ((message: string, options?: ConfirmOptions) => Promise<boolean>) | null = null;
|
let _showConfirmFn: ((message: string, options?: ConfirmOptions) => Promise<boolean>) | null = null;
|
||||||
let _showPromptFn: ((message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>) | null = null;
|
let _showPromptFn: ((message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>) | null = null;
|
||||||
let _showTimeSigFn: ((message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>) | null = null;
|
let _showTimeSigFn: ((message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>) | null = null;
|
||||||
|
let _showChoiceFn: ((message: string, choices: ChoiceOption[]) => Promise<string | null>) | null = null;
|
||||||
|
|
||||||
export function registerDialogFns(
|
export function registerDialogFns(
|
||||||
alertFn: (message: string) => Promise<void>,
|
alertFn: (message: string) => Promise<void>,
|
||||||
confirmFn: (message: string, options?: ConfirmOptions) => Promise<boolean>,
|
confirmFn: (message: string, options?: ConfirmOptions) => Promise<boolean>,
|
||||||
promptFn: (message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>,
|
promptFn: (message: string, defaultValue?: string, options?: PromptOptions) => Promise<string | null>,
|
||||||
timeSigFn: (message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>,
|
timeSigFn: (message: string, defaultValue?: TimeSigResult) => Promise<TimeSigResult | null>,
|
||||||
|
choiceFn?: (message: string, choices: ChoiceOption[]) => Promise<string | null>,
|
||||||
) {
|
) {
|
||||||
_showAlertFn = alertFn;
|
_showAlertFn = alertFn;
|
||||||
_showConfirmFn = confirmFn;
|
_showConfirmFn = confirmFn;
|
||||||
_showPromptFn = promptFn;
|
_showPromptFn = promptFn;
|
||||||
_showTimeSigFn = timeSigFn;
|
_showTimeSigFn = timeSigFn;
|
||||||
|
if (choiceFn) _showChoiceFn = choiceFn;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showAlert(message: string): Promise<void> {
|
export function showAlert(message: string): Promise<void> {
|
||||||
@@ -53,6 +61,13 @@ export function showPrompt(message: string, defaultValue?: string, options?: Pro
|
|||||||
return _showPromptFn(message, defaultValue, options);
|
return _showPromptFn(message, defaultValue, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function showChoice(message: string, choices: ChoiceOption[]): Promise<string | null> {
|
||||||
|
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<TimeSigResult | null> {
|
export function showTimeSigPrompt(message: string, defaultValue?: TimeSigResult): Promise<TimeSigResult | null> {
|
||||||
if (!_showTimeSigFn) {
|
if (!_showTimeSigFn) {
|
||||||
const raw = window.prompt(message, defaultValue ? `${defaultValue.numerator}/${defaultValue.denominator}` : '4/4');
|
const raw = window.prompt(message, defaultValue ? `${defaultValue.numerator}/${defaultValue.denominator}` : '4/4');
|
||||||
|
|||||||
Reference in New Issue
Block a user