Skip to content

Commit

Permalink
Effy small UI polish (#411)
Browse files Browse the repository at this point in the history
* onboarding-card

* fix a few bugs

* feat: adjust markup in OnboardingModal to match figma a bit closer and reuse existing ai key management code

* refactor: rename OnboardingModal to Onboarding

* feat: only render the Onboarding component if aiEnabled is set

* fix: remove added line from tsconfig.json

* fix: revert markdown file changes

* feat: add linear gradient as replacement for gradient raster image

* fix: run npm run format

* fix: address linting issues

* fix: adjust gradient rotation

---------

Co-authored-by: Ryan Gaus <[email protected]>
  • Loading branch information
effyyzhang and 1egoman authored Oct 24, 2024
1 parent 48f0b91 commit e275e85
Show file tree
Hide file tree
Showing 5 changed files with 197 additions and 128 deletions.
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;

0 comments on commit e275e85

Please sign in to comment.