Browse Source

feat(01-01): add Providers tab to ProjectSetupModal

Add tabbed interface with Project and Providers tabs. Providers tab
shows Gemini (env var configured), Replicate, and fal.ai with toggle
switches and API key inputs. Settings persist to localStorage on save.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
0364a8a3ea
  1. 331
      src/components/ProjectSetupModal.tsx

331
src/components/ProjectSetupModal.tsx

@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import { generateWorkflowId, useWorkflowStore } from "@/store/workflowStore";
import { ProviderType, ProviderSettings } from "@/types";
interface ProjectSetupModalProps {
isOpen: boolean;
@ -16,8 +17,20 @@ export function ProjectSetupModal({
onSave,
mode,
}: ProjectSetupModalProps) {
const { workflowName, saveDirectoryPath, useExternalImageStorage, setUseExternalImageStorage } = useWorkflowStore();
const {
workflowName,
saveDirectoryPath,
useExternalImageStorage,
setUseExternalImageStorage,
providerSettings,
updateProviderApiKey,
toggleProvider,
} = useWorkflowStore();
// Tab state
const [activeTab, setActiveTab] = useState<"project" | "providers">("project");
// Project tab state
const [name, setName] = useState("");
const [directoryPath, setDirectoryPath] = useState("");
const [externalStorage, setExternalStorage] = useState(true);
@ -25,18 +38,38 @@ export function ProjectSetupModal({
const [isBrowsing, setIsBrowsing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Provider tab state
const [localProviders, setLocalProviders] = useState<ProviderSettings>(providerSettings);
const [showApiKey, setShowApiKey] = useState<Record<ProviderType, boolean>>({
gemini: false,
replicate: false,
fal: false,
});
// Pre-fill when opening in settings mode
useEffect(() => {
if (isOpen && mode === "settings") {
setName(workflowName || "");
setDirectoryPath(saveDirectoryPath || "");
setExternalStorage(useExternalImageStorage);
} else if (isOpen && mode === "new") {
setName("");
setDirectoryPath("");
setExternalStorage(true);
if (isOpen) {
// Reset to project tab when opening
if (mode === "new") {
setActiveTab("project");
}
if (mode === "settings") {
setName(workflowName || "");
setDirectoryPath(saveDirectoryPath || "");
setExternalStorage(useExternalImageStorage);
} else if (mode === "new") {
setName("");
setDirectoryPath("");
setExternalStorage(true);
}
// Sync local providers state
setLocalProviders(providerSettings);
setShowApiKey({ gemini: false, replicate: false, fal: false });
setError(null);
}
}, [isOpen, mode, workflowName, saveDirectoryPath, useExternalImageStorage]);
}, [isOpen, mode, workflowName, saveDirectoryPath, useExternalImageStorage, providerSettings]);
const handleBrowse = async () => {
setIsBrowsing(true);
@ -67,7 +100,7 @@ export function ProjectSetupModal({
}
};
const handleSave = async () => {
const handleSaveProject = async () => {
if (!name.trim()) {
setError("Project name is required");
return;
@ -113,6 +146,34 @@ export function ProjectSetupModal({
}
};
const handleSaveProviders = () => {
// Save each provider's settings
const providerIds: ProviderType[] = ["gemini", "replicate", "fal"];
for (const providerId of providerIds) {
const local = localProviders.providers[providerId];
const current = providerSettings.providers[providerId];
// Update enabled state if changed
if (local.enabled !== current.enabled) {
toggleProvider(providerId, local.enabled);
}
// Update API key if changed
if (local.apiKey !== current.apiKey) {
updateProviderApiKey(providerId, local.apiKey);
}
}
onClose();
};
const handleSave = () => {
if (activeTab === "project") {
handleSaveProject();
} else {
handleSaveProviders();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isValidating && !isBrowsing) {
handleSave();
@ -122,6 +183,21 @@ export function ProjectSetupModal({
}
};
const updateLocalProvider = (
providerId: ProviderType,
updates: { enabled?: boolean; apiKey?: string | null }
) => {
setLocalProviders((prev) => ({
providers: {
...prev.providers,
[providerId]: {
...prev.providers[providerId],
...updates,
},
},
}));
};
if (!isOpen) return null;
return (
@ -134,66 +210,190 @@ export function ProjectSetupModal({
{mode === "new" ? "New Project" : "Project Settings"}
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm text-neutral-400 mb-1">
Project Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="my-project"
autoFocus
className="w-full px-3 py-2 bg-neutral-900 border border-neutral-600 rounded text-neutral-100 text-sm focus:outline-none focus:border-neutral-500"
/>
</div>
{/* Tab Bar */}
<div className="flex gap-4 border-b border-neutral-700 mb-4">
<button
onClick={() => setActiveTab("project")}
className={`pb-2 text-sm ${activeTab === "project" ? "text-neutral-100 border-b-2 border-white" : "text-neutral-400"}`}
>
Project
</button>
<button
onClick={() => setActiveTab("providers")}
className={`pb-2 text-sm ${activeTab === "providers" ? "text-neutral-100 border-b-2 border-white" : "text-neutral-400"}`}
>
Providers
</button>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">
Project Directory
</label>
<div className="flex gap-2">
{/* Project Tab Content */}
{activeTab === "project" && (
<div className="space-y-4">
<div>
<label className="block text-sm text-neutral-400 mb-1">
Project Name
</label>
<input
type="text"
value={directoryPath}
onChange={(e) => setDirectoryPath(e.target.value)}
placeholder="/Users/username/projects/my-project"
className="flex-1 px-3 py-2 bg-neutral-900 border border-neutral-600 rounded text-neutral-100 text-sm focus:outline-none focus:border-neutral-500"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="my-project"
autoFocus
className="w-full px-3 py-2 bg-neutral-900 border border-neutral-600 rounded text-neutral-100 text-sm focus:outline-none focus:border-neutral-500"
/>
<button
type="button"
onClick={handleBrowse}
disabled={isBrowsing}
className="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 disabled:bg-neutral-700 disabled:opacity-50 text-neutral-200 text-sm rounded transition-colors"
>
{isBrowsing ? "..." : "Browse"}
</button>
</div>
<p className="text-xs text-neutral-500 mt-1">
Workflow files and images will be saved here. Subfolders for inputs and generations will be auto-created.
</p>
</div>
<div className="pt-2 border-t border-neutral-700">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={!externalStorage}
onChange={(e) => setExternalStorage(!e.target.checked)}
className="w-4 h-4 rounded border-neutral-600 bg-neutral-900 text-blue-500 focus:ring-blue-500 focus:ring-offset-neutral-800"
/>
<div>
<span className="text-sm text-neutral-200">Embed images as base64</span>
<p className="text-xs text-neutral-500">
Embeds all images in workflow, larger workflow files. Can hit memory limits on very large workflows.
</p>
<div>
<label className="block text-sm text-neutral-400 mb-1">
Project Directory
</label>
<div className="flex gap-2">
<input
type="text"
value={directoryPath}
onChange={(e) => setDirectoryPath(e.target.value)}
placeholder="/Users/username/projects/my-project"
className="flex-1 px-3 py-2 bg-neutral-900 border border-neutral-600 rounded text-neutral-100 text-sm focus:outline-none focus:border-neutral-500"
/>
<button
type="button"
onClick={handleBrowse}
disabled={isBrowsing}
className="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 disabled:bg-neutral-700 disabled:opacity-50 text-neutral-200 text-sm rounded transition-colors"
>
{isBrowsing ? "..." : "Browse"}
</button>
</div>
</label>
<p className="text-xs text-neutral-500 mt-1">
Workflow files and images will be saved here. Subfolders for inputs and generations will be auto-created.
</p>
</div>
<div className="pt-2 border-t border-neutral-700">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={!externalStorage}
onChange={(e) => setExternalStorage(!e.target.checked)}
className="w-4 h-4 rounded border-neutral-600 bg-neutral-900 text-blue-500 focus:ring-blue-500 focus:ring-offset-neutral-800"
/>
<div>
<span className="text-sm text-neutral-200">Embed images as base64</span>
<p className="text-xs text-neutral-500">
Embeds all images in workflow, larger workflow files. Can hit memory limits on very large workflows.
</p>
</div>
</label>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
)}
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
{/* Providers Tab Content */}
{activeTab === "providers" && (
<div className="space-y-4">
{/* Gemini Provider */}
<div className="p-4 bg-neutral-900 rounded-lg border border-neutral-700">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-100">
{localProviders.providers.gemini.name}
</span>
<span className="px-2 py-0.5 text-xs bg-green-900/50 text-green-400 rounded">
Default
</span>
</div>
<span className="text-xs text-green-400">Enabled</span>
</div>
<p className="text-xs text-neutral-500">
Configured via GEMINI_API_KEY environment variable
</p>
</div>
{/* Replicate Provider */}
<div className="p-4 bg-neutral-900 rounded-lg border border-neutral-700">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-neutral-100">
{localProviders.providers.replicate.name}
</span>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localProviders.providers.replicate.enabled}
onChange={(e) => updateLocalProvider("replicate", { enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-neutral-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{localProviders.providers.replicate.enabled && (
<div className="space-y-2">
<label className="block text-xs text-neutral-400">API Key</label>
<div className="flex gap-2">
<input
type={showApiKey.replicate ? "text" : "password"}
value={localProviders.providers.replicate.apiKey || ""}
onChange={(e) => updateLocalProvider("replicate", { apiKey: e.target.value || null })}
placeholder="r8_..."
className="flex-1 px-3 py-2 bg-neutral-800 border border-neutral-600 rounded text-neutral-100 text-sm focus:outline-none focus:border-neutral-500"
/>
<button
type="button"
onClick={() => setShowApiKey((prev) => ({ ...prev, replicate: !prev.replicate }))}
className="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 text-sm rounded transition-colors"
>
{showApiKey.replicate ? "Hide" : "Show"}
</button>
</div>
</div>
)}
</div>
{/* fal.ai Provider */}
<div className="p-4 bg-neutral-900 rounded-lg border border-neutral-700">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-neutral-100">
{localProviders.providers.fal.name}
</span>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localProviders.providers.fal.enabled}
onChange={(e) => updateLocalProvider("fal", { enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-neutral-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{localProviders.providers.fal.enabled && (
<div className="space-y-2">
<label className="block text-xs text-neutral-400">API Key</label>
<div className="flex gap-2">
<input
type={showApiKey.fal ? "text" : "password"}
value={localProviders.providers.fal.apiKey || ""}
onChange={(e) => updateLocalProvider("fal", { apiKey: e.target.value || null })}
placeholder="fal_..."
className="flex-1 px-3 py-2 bg-neutral-800 border border-neutral-600 rounded text-neutral-100 text-sm focus:outline-none focus:border-neutral-500"
/>
<button
type="button"
onClick={() => setShowApiKey((prev) => ({ ...prev, fal: !prev.fal }))}
className="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-300 text-sm rounded transition-colors"
>
{showApiKey.fal ? "Hide" : "Show"}
</button>
</div>
</div>
)}
</div>
<p className="text-xs text-neutral-500">
API keys are stored locally in your browser and never sent to our servers.
</p>
</div>
)}
<div className="flex justify-end gap-2 mt-6">
<button
@ -204,10 +404,13 @@ export function ProjectSetupModal({
</button>
<button
onClick={handleSave}
disabled={isValidating || isBrowsing}
disabled={activeTab === "project" && (isValidating || isBrowsing)}
className="px-4 py-2 text-sm bg-white text-neutral-900 rounded hover:bg-neutral-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isValidating ? "Validating..." : mode === "new" ? "Create" : "Save"}
{activeTab === "project"
? (isValidating ? "Validating..." : mode === "new" ? "Create" : "Save")
: "Save"
}
</button>
</div>
</div>

Loading…
Cancel
Save