mirror of
https://github.com/aljazceru/goose.git
synced 2026-02-20 22:14:21 +01:00
feat: improve UX for saving recipes (#3214)
This commit is contained in:
@@ -4,13 +4,15 @@ import { Buffer } from 'buffer';
|
||||
import { FullExtensionConfig } from '../extensions';
|
||||
import { Geese } from './icons/Geese';
|
||||
import Copy from './icons/Copy';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Check, Save, Calendar } from 'lucide-react';
|
||||
import { useConfig } from './ConfigContext';
|
||||
import { FixedExtensionEntry } from './ConfigContext';
|
||||
import RecipeActivityEditor from './RecipeActivityEditor';
|
||||
import RecipeInfoModal from './RecipeInfoModal';
|
||||
import RecipeExpandableInfo from './RecipeExpandableInfo';
|
||||
import { ScheduleFromRecipeModal } from './schedule/ScheduleFromRecipeModal';
|
||||
import { saveRecipe, generateRecipeFilename } from '../recipe/recipeStorage';
|
||||
import { toastSuccess, toastError } from '../toasts';
|
||||
|
||||
interface RecipeEditorProps {
|
||||
config?: Recipe;
|
||||
@@ -35,6 +37,10 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isRecipeInfoModalOpen, setRecipeInfoModalOpen] = useState(false);
|
||||
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
const [saveRecipeName, setSaveRecipeName] = useState('');
|
||||
const [saveGlobal, setSaveGlobal] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [recipeInfoModelProps, setRecipeInfoModelProps] = useState<{
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -177,6 +183,57 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveRecipe = async () => {
|
||||
if (!saveRecipeName.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const currentRecipe = getCurrentConfig();
|
||||
|
||||
if (!currentRecipe.title || !currentRecipe.description || !currentRecipe.instructions) {
|
||||
throw new Error('Invalid recipe configuration: missing required fields');
|
||||
}
|
||||
|
||||
await saveRecipe(currentRecipe, {
|
||||
name: saveRecipeName.trim(),
|
||||
global: saveGlobal,
|
||||
});
|
||||
|
||||
// Reset dialog state
|
||||
setShowSaveDialog(false);
|
||||
setSaveRecipeName('');
|
||||
|
||||
toastSuccess({
|
||||
title: saveRecipeName.trim(),
|
||||
msg: `Recipe saved successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save recipe:', error);
|
||||
|
||||
toastError({
|
||||
title: 'Save Failed',
|
||||
msg: `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
traceback: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRecipeClick = () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRecipe = getCurrentConfig();
|
||||
// Generate a suggested name from the recipe title
|
||||
const suggestedName = generateRecipeFilename(currentRecipe);
|
||||
setSaveRecipeName(suggestedName);
|
||||
setShowSaveDialog(true);
|
||||
};
|
||||
|
||||
const onClickEditTextArea = ({
|
||||
label,
|
||||
value,
|
||||
@@ -331,20 +388,31 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
)}
|
||||
</div>
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col space-y-2 pt-1">
|
||||
<button
|
||||
onClick={() => setIsScheduleModalOpen(true)}
|
||||
disabled={!requiredFieldsAreFilled()}
|
||||
className="w-full h-[60px] rounded-none border-t text-gray-900 dark:text-white hover:bg-gray-50 dark:border-gray-600 text-lg font-medium"
|
||||
>
|
||||
Create Schedule from Recipe
|
||||
</button>
|
||||
<div className="flex flex-col space-y-3 pt-4">
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSaveRecipeClick}
|
||||
disabled={!requiredFieldsAreFilled() || saving}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-3 bg-bgStandard text-textStandard border border-borderStandard rounded-lg hover:bg-bgSubtle transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? 'Saving...' : 'Save Recipe'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsScheduleModalOpen(true)}
|
||||
disabled={!requiredFieldsAreFilled()}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-3 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Calendar className="w-4 h-4" />
|
||||
Create Schedule
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.removeItem('recipe_editor_extensions');
|
||||
window.close();
|
||||
}}
|
||||
className="w-full p-3 text-textSubtle rounded-lg hover:bg-bgSubtle"
|
||||
className="w-full p-3 text-textSubtle rounded-lg hover:bg-bgSubtle transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
@@ -376,6 +444,87 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
localStorage.setItem('pendingScheduleDeepLink', deepLink);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Save Recipe Dialog */}
|
||||
{showSaveDialog && (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black bg-opacity-50">
|
||||
<div className="bg-bgApp border border-borderSubtle rounded-lg p-6 w-96 max-w-[90vw]">
|
||||
<h3 className="text-lg font-medium text-textProminent mb-4">Save Recipe</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="recipe-name"
|
||||
className="block text-sm font-medium text-textStandard mb-2"
|
||||
>
|
||||
Recipe Name
|
||||
</label>
|
||||
<input
|
||||
id="recipe-name"
|
||||
type="text"
|
||||
value={saveRecipeName}
|
||||
onChange={(e) => setSaveRecipeName(e.target.value)}
|
||||
className="w-full p-3 border border-borderSubtle rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
|
||||
placeholder="Enter recipe name"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-textStandard mb-2">
|
||||
Save Location
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="save-location"
|
||||
checked={saveGlobal}
|
||||
onChange={() => setSaveGlobal(true)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-textStandard">
|
||||
Global - Available across all Goose sessions
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="save-location"
|
||||
checked={!saveGlobal}
|
||||
onChange={() => setSaveGlobal(false)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-textStandard">
|
||||
Directory - Available in the working directory
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowSaveDialog(false);
|
||||
setSaveRecipeName('');
|
||||
}}
|
||||
className="px-4 py-2 text-textSubtle hover:text-textStandard transition-colors"
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveRecipe}
|
||||
disabled={!saveRecipeName.trim() || saving}
|
||||
className="px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Recipe'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { listSavedRecipes, archiveRecipe, SavedRecipe } from '../recipe/recipeStorage';
|
||||
import { FileText, Trash2, Bot, Calendar, Globe, Folder } from 'lucide-react';
|
||||
import {
|
||||
listSavedRecipes,
|
||||
archiveRecipe,
|
||||
SavedRecipe,
|
||||
saveRecipe,
|
||||
generateRecipeFilename,
|
||||
} from '../recipe/recipeStorage';
|
||||
import { FileText, Trash2, Bot, Calendar, Globe, Folder, Download } from 'lucide-react';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import BackButton from './ui/BackButton';
|
||||
import MoreMenuLayout from './more_menu/MoreMenuLayout';
|
||||
import { Recipe } from '../recipe';
|
||||
import { Buffer } from 'buffer';
|
||||
import { toastSuccess, toastError } from '../toasts';
|
||||
|
||||
interface RecipesViewProps {
|
||||
onBack: () => void;
|
||||
@@ -15,6 +24,11 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<SavedRecipe | null>(null);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [importDeeplink, setImportDeeplink] = useState('');
|
||||
const [importRecipeName, setImportRecipeName] = useState('');
|
||||
const [importGlobal, setImportGlobal] = useState(true);
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadSavedRecipes();
|
||||
@@ -81,6 +95,97 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
setShowPreview(true);
|
||||
};
|
||||
|
||||
// Function to parse deeplink and extract recipe
|
||||
const parseDeeplink = (deeplink: string): Recipe | null => {
|
||||
try {
|
||||
const cleanLink = deeplink.trim();
|
||||
|
||||
if (!cleanLink.startsWith('goose://recipe?config=')) {
|
||||
throw new Error('Invalid deeplink format. Expected: goose://recipe?config=...');
|
||||
}
|
||||
|
||||
// Extract and decode the base64 config
|
||||
const configBase64 = cleanLink.replace('goose://recipe?config=', '');
|
||||
|
||||
if (!configBase64) {
|
||||
throw new Error('No recipe configuration found in deeplink');
|
||||
}
|
||||
const configJson = Buffer.from(configBase64, 'base64').toString('utf-8');
|
||||
const recipe = JSON.parse(configJson) as Recipe;
|
||||
|
||||
if (!recipe.title || !recipe.description || !recipe.instructions) {
|
||||
throw new Error('Recipe is missing required fields (title, description, instructions)');
|
||||
}
|
||||
|
||||
return recipe;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse deeplink:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportRecipe = async () => {
|
||||
if (!importDeeplink.trim() || !importRecipeName.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const recipe = parseDeeplink(importDeeplink.trim());
|
||||
|
||||
if (!recipe) {
|
||||
throw new Error('Invalid deeplink or recipe format');
|
||||
}
|
||||
|
||||
await saveRecipe(recipe, {
|
||||
name: importRecipeName.trim(),
|
||||
global: importGlobal,
|
||||
});
|
||||
|
||||
// Reset dialog state
|
||||
setShowImportDialog(false);
|
||||
setImportDeeplink('');
|
||||
setImportRecipeName('');
|
||||
|
||||
await loadSavedRecipes();
|
||||
|
||||
toastSuccess({
|
||||
title: importRecipeName.trim(),
|
||||
msg: 'Recipe imported successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to import recipe:', error);
|
||||
|
||||
toastError({
|
||||
title: 'Import Failed',
|
||||
msg: `Failed to import recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
traceback: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
setImportDeeplink('');
|
||||
setImportRecipeName('');
|
||||
setImportGlobal(true);
|
||||
setShowImportDialog(true);
|
||||
};
|
||||
|
||||
// Auto-generate recipe name when deeplink changes
|
||||
const handleDeeplinkChange = (value: string) => {
|
||||
setImportDeeplink(value);
|
||||
|
||||
if (value.trim()) {
|
||||
const recipe = parseDeeplink(value.trim());
|
||||
if (recipe && recipe.title) {
|
||||
const suggestedName = generateRecipeFilename(recipe);
|
||||
setImportRecipeName(suggestedName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-screen w-full animate-[fadein_200ms_ease-in_forwards]">
|
||||
@@ -101,7 +206,7 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={loadSavedRecipes}
|
||||
className="px-4 py-2 bg-borderProminent text-white rounded-lg hover:bg-opacity-90"
|
||||
className="px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -118,7 +223,16 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
<div className="flex flex-col pb-24">
|
||||
<div className="px-8 pt-6 pb-4">
|
||||
<BackButton onClick={onBack} />
|
||||
<h1 className="text-3xl font-medium text-textStandard mt-1">Saved Recipes</h1>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<h1 className="text-3xl font-medium text-textStandard">Saved Recipes</h1>
|
||||
<button
|
||||
onClick={handleImportClick}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Import Recipe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
@@ -128,7 +242,10 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
<FileText className="w-16 h-16 text-textSubtle mb-4" />
|
||||
<h3 className="text-lg font-medium text-textStandard mb-2">No saved recipes</h3>
|
||||
<p className="text-textSubtle">
|
||||
Save a recipe from an active session to see it here.
|
||||
Create and save recipes from the Recipe Editor to see them here.
|
||||
</p>
|
||||
<p className="text-textSubtle text-sm mt-2">
|
||||
You can also save recipes from active recipe sessions using the Settings menu.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -168,7 +285,7 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePreviewRecipe(savedRecipe)}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-borderSubtle rounded-lg hover:border-borderStandard transition-colors text-sm"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-bgStandard text-textStandard border border-borderStandard rounded-lg hover:bg-bgSubtle transition-colors text-sm"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
Preview
|
||||
@@ -275,6 +392,108 @@ export default function RecipesView({ onBack }: RecipesViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Recipe Dialog */}
|
||||
{showImportDialog && (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black bg-opacity-50">
|
||||
<div className="bg-bgApp border border-borderSubtle rounded-lg p-6 w-[500px] max-w-[90vw]">
|
||||
<h3 className="text-lg font-medium text-textProminent mb-4">Import Recipe</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="import-deeplink"
|
||||
className="block text-sm font-medium text-textStandard mb-2"
|
||||
>
|
||||
Recipe Deeplink
|
||||
</label>
|
||||
<textarea
|
||||
id="import-deeplink"
|
||||
value={importDeeplink}
|
||||
onChange={(e) => handleDeeplinkChange(e.target.value)}
|
||||
className="w-full p-3 border border-borderSubtle rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent resize-none"
|
||||
placeholder="Paste your goose://recipe?config=... deeplink here"
|
||||
rows={3}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-textSubtle mt-1">
|
||||
Paste a recipe deeplink starting with "goose://recipe?config="
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="import-recipe-name"
|
||||
className="block text-sm font-medium text-textStandard mb-2"
|
||||
>
|
||||
Recipe Name
|
||||
</label>
|
||||
<input
|
||||
id="import-recipe-name"
|
||||
type="text"
|
||||
value={importRecipeName}
|
||||
onChange={(e) => setImportRecipeName(e.target.value)}
|
||||
className="w-full p-3 border border-borderSubtle rounded-lg bg-bgApp text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent"
|
||||
placeholder="Enter a name for the imported recipe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-textStandard mb-2">
|
||||
Save Location
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="import-save-location"
|
||||
checked={importGlobal}
|
||||
onChange={() => setImportGlobal(true)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-textStandard">
|
||||
Global - Available across all Goose sessions
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="import-save-location"
|
||||
checked={!importGlobal}
|
||||
onChange={() => setImportGlobal(false)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm text-textStandard">
|
||||
Directory - Available in the working directory
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowImportDialog(false);
|
||||
setImportDeeplink('');
|
||||
setImportRecipeName('');
|
||||
}}
|
||||
className="px-4 py-2 text-textSubtle hover:text-textStandard transition-colors"
|
||||
disabled={importing}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImportRecipe}
|
||||
disabled={!importDeeplink.trim() || !importRecipeName.trim() || importing}
|
||||
className="px-4 py-2 bg-textProminent text-bgApp rounded-lg hover:bg-opacity-90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{importing ? 'Importing...' : 'Import Recipe'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user