Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Effy small UI polish #411

Merged
merged 11 commits into from
Oct 24, 2024
7 changes: 2 additions & 5 deletions packages/web/src/components/apps/create-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export default function CreateAppModal({ onClose, onCreate }: PropsType) {
</DialogHeader>
<form name="app" onSubmit={onSubmit} className="flex flex-col gap-6">
<div className="space-y-1">
<label htmlFor="name" className="text-sm font-medium text-tertiary-foreground">
<label htmlFor="name" className="text-sm font-medium">
App name
</label>
<Input
Expand All @@ -107,10 +107,7 @@ export default function CreateAppModal({ onClose, onCreate }: PropsType) {

<div className="space-y-1">
<div className="flex justify-between items-center">
<label
htmlFor="name"
className="text-sm text-tertiary-foreground font-medium flex items-center gap-1.5"
>
<label htmlFor="name" className="text-sm font-medium flex items-center gap-1.5">
What are you building? <Sparkles size={14} />
</label>
<TooltipProvider>
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/components/collapsible-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default function CollapsibleContainer(props: {
<div className={cn('w-full border rounded-sm', props.className)}>
<CollapsibleTrigger className="block w-full">
<div className="p-3 flex items-center justify-between">
<h5 className="font-bold leading-none">{title}</h5>
<h5 className="font-medium leading-none">{title}</h5>
<ChevronRight
className={cn('w-4 h-4 transition-transform text-tertiary-foreground', {
'transform rotate-90': open,
Expand Down
51 changes: 51 additions & 0 deletions packages/web/src/components/onboarding.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from 'react';
import { LayoutGridIcon, FileTextIcon } from 'lucide-react';
import { AiSettings } from '@/routes/settings';

const OnboardingModal: React.FunctionComponent = () => {
return (
<div className="flex flex-col gap-6">
<h2 className="text-3xl font-medium">Welcome to Srcbook!</h2>
<p>Srcbook is an AI-powered TypeScript app builder and interactive playground.</p>

<div className="flex flex-col gap-3">
<h4 className="font-medium">With Srcbook you can:</h4>
<div className="flex flex-col md:flex-row gap-6">
<div
className="p-5 rounded-lg bg-[#FFD9E1]"
style={{
background:
'linear-gradient(180deg, rgba(255,217,225,1) 0%, rgba(219,183,223,1) 100%)',
}}
>
<div className="flex items-center mb-6">
<LayoutGridIcon size={24} className="text-ai-btn" />
</div>
<h3 className="text-ai-btn font-medium mt-3">App builder</h3>
<p className="text-ai-btn mt-2">Create Web Applications with the speed of thinking</p>
</div>

<div className="border p-5 rounded-lg">
<div className="flex items-center mb-6">
<FileTextIcon size={24} className="text-button-secondary" />
</div>
<h3 className="text-primary font-medium mt-3">Notebook</h3>
<p className="text-primary mt-2">
Experimenting without the hassle of setting up environments
</p>
</div>
</div>
</div>

<div>
<label htmlFor="aiProvider" className="block mb-3">
To get started, select your AI provider and enter your API key:
</label>

<AiSettings saveButtonLabel="Continue" />
</div>
</div>
);
};

export default OnboardingModal;
8 changes: 8 additions & 0 deletions packages/web/src/routes/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import MailingListCard from '@/components/mailing-list-card';
import CreateAppModal from '@/components/apps/create-modal';
import { createApp, loadApps } from '@/clients/http/apps';
import DeleteAppModal from '@/components/delete-app-dialog';
import Onboarding from '@/components/onboarding';
import { useSettings } from '@/components/use-settings';

export async function loader() {
const [{ result: config }, { result: srcbooks }, { result: examples }, { data: apps }] =
Expand Down Expand Up @@ -66,6 +68,8 @@ export default function Home() {
const [appToDelete, setAppToDelete] = useState<AppType | null>(null);
const [showCreateAppModal, setShowCreateAppModal] = useState(false);

const { aiEnabled } = useSettings();

function onDeleteSrcbook(srcbook: SessionType) {
setSrcbookToDelete(srcbook);
setShowDelete(true);
Expand All @@ -91,6 +95,10 @@ export default function Home() {
openSrcbook(result.dir);
}

if (!aiEnabled) {
return <Onboarding />;
}

return (
<div className="divide-y divide-border space-y-8 pb-10">
{showCreateAppModal && (
Expand Down
257 changes: 135 additions & 122 deletions packages/web/src/routes/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,55 +17,18 @@ import { Button } from '@srcbook/components/src/components/ui/button';
import { toast } from 'sonner';

function Settings() {
const {
aiProvider,
aiModel,
aiBaseUrl,
openaiKey: configOpenaiKey,
anthropicKey: configAnthropicKey,
updateConfig: updateConfigContext,
defaultLanguage,
subscriptionEmail,
} = useSettings();
const { updateConfig: updateConfigContext, defaultLanguage, subscriptionEmail } = useSettings();

const isSubscribed = subscriptionEmail && subscriptionEmail !== 'dismissed';

const [openaiKey, setOpenaiKey] = useState<string>(configOpenaiKey ?? '');
const [anthropicKey, setAnthropicKey] = useState<string>(configAnthropicKey ?? '');
const [model, setModel] = useState<string>(aiModel);
const [baseUrl, setBaseUrl] = useState<string>(aiBaseUrl || '');
const [email, setEmail] = useState<string>(isSubscribed ? subscriptionEmail : '');

const updateDefaultLanguage = (value: CodeLanguageType) => {
updateConfigContext({ defaultLanguage: value });
};

const setAiProvider = (provider: AiProviderType) => {
const model = getDefaultModel(provider);
setModel(model);
updateConfigContext({ aiProvider: provider, aiModel: model });
};

const { theme, toggleTheme } = useTheme();

// Either the key from the server is null/undefined and the user entered input
// or the key from the server is a string and the user entered input is different.
const openaiKeySaveEnabled =
(typeof configOpenaiKey === 'string' && openaiKey !== configOpenaiKey) ||
((configOpenaiKey === null || configOpenaiKey === undefined) && openaiKey.length > 0) ||
model !== aiModel;

const anthropicKeySaveEnabled =
(typeof configAnthropicKey === 'string' && anthropicKey !== configAnthropicKey) ||
((configAnthropicKey === null || configAnthropicKey === undefined) &&
anthropicKey.length > 0) ||
model !== aiModel;

const customModelSaveEnabled =
(typeof aiBaseUrl === 'string' && baseUrl !== aiBaseUrl) ||
((aiBaseUrl === null || aiBaseUrl === undefined) && baseUrl.length > 0) ||
model !== aiModel;

const handleSubscribe = async () => {
try {
const response = await subscribeToMailingList(email);
Expand Down Expand Up @@ -122,91 +85,8 @@ function Settings() {
<label className="opacity-70 text-sm pb-3" htmlFor="ai-provider-selector">
Select your preferred LLM and enter your credentials to use Srcbook's AI features.
</label>
<div className="flex items-center justify-between w-full mb-2 min-h-10">
<div className="flex items-center gap-2">
<Select onValueChange={setAiProvider}>
<SelectTrigger id="ai-provider-selector" className="w-[180px]">
<SelectValue placeholder={aiProvider} />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">openai</SelectItem>
<SelectItem value="anthropic">anthropic</SelectItem>
<SelectItem value="custom">custom</SelectItem>
</SelectContent>
</Select>
<Input
name="aiModel"
className="w-[200px]"
placeholder="AI model"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
</div>
<AiInfoBanner />
</div>

{aiProvider === 'openai' && (
<div className="flex gap-2">
<Input
name="openaiKey"
placeholder="openAI API key"
type="password"
value={openaiKey}
onChange={(e) => setOpenaiKey(e.target.value)}
/>
<Button
className="px-5"
onClick={() => updateConfigContext({ openaiKey, aiModel: model })}
disabled={!openaiKeySaveEnabled}
>
Save
</Button>
</div>
)}

{aiProvider === 'anthropic' && (
<div className="flex gap-2">
<Input
name="anthropicKey"
placeholder="anthropic API key"
type="password"
value={anthropicKey}
onChange={(e) => setAnthropicKey(e.target.value)}
/>
<Button
className="px-5"
onClick={() => updateConfigContext({ anthropicKey, aiModel: model })}
disabled={!anthropicKeySaveEnabled}
>
Save
</Button>
</div>
)}

{aiProvider === 'custom' && (
<div>
<p className="opacity-70 text-sm mb-4">
If you want to use an openai-compatible model (for example when running local
models with Ollama), choose this option and set the baseUrl.
</p>
<div className="flex gap-2">
<Input
name="baseUrl"
placeholder="http://localhost:11434/v1"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
/>
<Button
className="px-5"
onClick={() => updateConfigContext({ aiBaseUrl: baseUrl, aiModel: model })}
disabled={!customModelSaveEnabled}
>
Save
</Button>
</div>
</div>
)}
</div>
<AiSettings />
</div>

<div>
Expand Down Expand Up @@ -336,4 +216,137 @@ const TestAiButton = () => {
);
};

type AiSettingsProps = {
saveButtonLabel?: string;
};

export function AiSettings({ saveButtonLabel }: AiSettingsProps) {
const {
aiProvider,
aiModel,
aiBaseUrl,
openaiKey: configOpenaiKey,
anthropicKey: configAnthropicKey,
updateConfig: updateConfigContext,
} = useSettings();

const [openaiKey, setOpenaiKey] = useState<string>(configOpenaiKey ?? '');
const [anthropicKey, setAnthropicKey] = useState<string>(configAnthropicKey ?? '');
const [model, setModel] = useState<string>(aiModel);
const [baseUrl, setBaseUrl] = useState<string>(aiBaseUrl || '');

const setAiProvider = (provider: AiProviderType) => {
const model = getDefaultModel(provider);
setModel(model);
updateConfigContext({ aiProvider: provider, aiModel: model });
};

// Either the key from the server is null/undefined and the user entered input
// or the key from the server is a string and the user entered input is different.
const openaiKeySaveEnabled =
(typeof configOpenaiKey === 'string' && openaiKey !== configOpenaiKey) ||
((configOpenaiKey === null || configOpenaiKey === undefined) && openaiKey.length > 0) ||
model !== aiModel;

const anthropicKeySaveEnabled =
(typeof configAnthropicKey === 'string' && anthropicKey !== configAnthropicKey) ||
((configAnthropicKey === null || configAnthropicKey === undefined) &&
anthropicKey.length > 0) ||
model !== aiModel;

const customModelSaveEnabled =
(typeof aiBaseUrl === 'string' && baseUrl !== aiBaseUrl) ||
((aiBaseUrl === null || aiBaseUrl === undefined) && baseUrl.length > 0) ||
model !== aiModel;

return (
<>
<div className="flex items-center justify-between w-full mb-2 min-h-10">
<div className="flex items-center gap-2">
<Select onValueChange={setAiProvider}>
<SelectTrigger id="ai-provider-selector" className="w-[180px]">
<SelectValue placeholder={aiProvider} />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">openai</SelectItem>
<SelectItem value="anthropic">anthropic</SelectItem>
<SelectItem value="custom">custom</SelectItem>
</SelectContent>
</Select>
<Input
name="aiModel"
className="w-[200px]"
placeholder="AI model"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
</div>
<AiInfoBanner />
</div>

{aiProvider === 'openai' && (
<div className="flex gap-2">
<Input
name="openaiKey"
placeholder="openAI API key"
type="password"
value={openaiKey}
onChange={(e) => setOpenaiKey(e.target.value)}
/>
<Button
className="px-5"
onClick={() => updateConfigContext({ openaiKey, aiModel: model })}
disabled={!openaiKeySaveEnabled}
>
{saveButtonLabel ?? 'Save'}
</Button>
</div>
)}

{aiProvider === 'anthropic' && (
<div className="flex gap-2">
<Input
name="anthropicKey"
placeholder="anthropic API key"
type="password"
value={anthropicKey}
onChange={(e) => setAnthropicKey(e.target.value)}
/>
<Button
className="px-5"
onClick={() => updateConfigContext({ anthropicKey, aiModel: model })}
disabled={!anthropicKeySaveEnabled}
>
{saveButtonLabel ?? 'Save'}
</Button>
</div>
)}

{aiProvider === 'custom' && (
<div>
<p className="opacity-70 text-sm mb-4">
If you want to use an openai-compatible model (for example when running local models
with Ollama), choose this option and set the baseUrl.
</p>
<div className="flex gap-2">
<Input
name="baseUrl"
placeholder="http://localhost:11434/v1"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
/>
<Button
className="px-5"
onClick={() => updateConfigContext({ aiBaseUrl: baseUrl, aiModel: model })}
disabled={!customModelSaveEnabled}
>
{saveButtonLabel ?? 'Save'}
</Button>
</div>
</div>
)}
</>
);
}

export default Settings;