Files
moreminimore-vibe/src/pages/home.tsx
2025-05-29 00:03:51 -07:00

268 lines
9.6 KiB
TypeScript

import { useNavigate, useSearch } from "@tanstack/react-router";
import { useAtom, useSetAtom } from "jotai";
import { homeChatInputValueAtom } from "../atoms/chatAtoms";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { IpcClient } from "@/ipc/ipc_client";
import { generateCuteAppName } from "@/lib/utils";
import { useLoadApps } from "@/hooks/useLoadApps";
import { useSettings } from "@/hooks/useSettings";
import { SetupBanner } from "@/components/SetupBanner";
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
import { useState, useEffect, useCallback } from "react";
import { useStreamChat } from "@/hooks/useStreamChat";
import { HomeChatInput } from "@/components/chat/HomeChatInput";
import { usePostHog } from "posthog-js/react";
import { PrivacyBanner } from "@/components/TelemetryBanner";
import { INSPIRATION_PROMPTS } from "@/prompts/inspiration_prompts";
import { useAppVersion } from "@/hooks/useAppVersion";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useTheme } from "@/contexts/ThemeContext";
import { Button } from "@/components/ui/button";
import { ExternalLink } from "lucide-react";
import { ImportAppButton } from "@/components/ImportAppButton";
import { showError } from "@/lib/toast";
// Adding an export for attachments
export interface HomeSubmitOptions {
attachments?: File[];
}
export default function HomePage() {
const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom);
const navigate = useNavigate();
const search = useSearch({ from: "/" });
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
const { refreshApps } = useLoadApps();
const { settings, updateSettings } = useSettings();
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
const [isLoading, setIsLoading] = useState(false);
const { streamMessage } = useStreamChat({ hasChatId: false });
const posthog = usePostHog();
const appVersion = useAppVersion();
const [releaseNotesOpen, setReleaseNotesOpen] = useState(false);
const [releaseUrl, setReleaseUrl] = useState("");
const { theme } = useTheme();
useEffect(() => {
const updateLastVersionLaunched = async () => {
if (
appVersion &&
appVersion.match(/^\d+\.\d+\.\d+$/) &&
settings &&
settings.lastShownReleaseNotesVersion !== appVersion
) {
await updateSettings({
lastShownReleaseNotesVersion: appVersion,
});
try {
const result = await IpcClient.getInstance().doesReleaseNoteExist({
version: appVersion,
});
if (result.exists && result.url) {
setReleaseUrl(result.url + "?hideHeader=true&theme=" + theme);
setReleaseNotesOpen(true);
}
} catch (err) {
console.warn(
"Unable to check if release note exists for: " + appVersion,
err,
);
}
}
};
updateLastVersionLaunched();
}, [appVersion, settings, updateSettings, theme]);
// Get the appId from search params
const appId = search.appId ? Number(search.appId) : null;
// State for random prompts
const [randomPrompts, setRandomPrompts] = useState<
typeof INSPIRATION_PROMPTS
>([]);
// Function to get random prompts
const getRandomPrompts = useCallback(() => {
const shuffled = [...INSPIRATION_PROMPTS].sort(() => 0.5 - Math.random());
return shuffled.slice(0, 5);
}, []);
// Initialize random prompts
useEffect(() => {
setRandomPrompts(getRandomPrompts());
}, [getRandomPrompts]);
// Redirect to app details page if appId is present
useEffect(() => {
if (appId) {
navigate({ to: "/app-details", search: { appId } });
}
}, [appId, navigate]);
const handleSubmit = async (options?: HomeSubmitOptions) => {
const attachments = options?.attachments || [];
if (!inputValue.trim() && attachments.length === 0) return;
try {
setIsLoading(true);
// Create the chat and navigate
const result = await IpcClient.getInstance().createApp({
name: generateCuteAppName(),
});
// Stream the message with attachments
streamMessage({
prompt: inputValue,
chatId: result.chatId,
attachments,
});
await new Promise((resolve) =>
setTimeout(resolve, settings?.isTestMode ? 0 : 2000),
);
setInputValue("");
setSelectedAppId(result.app.id);
setIsPreviewOpen(false);
await refreshApps(); // Ensure refreshApps is awaited if it's async
posthog.capture("home:chat-submit");
navigate({ to: "/chat", search: { id: result.chatId } });
} catch (error) {
console.error("Failed to create chat:", error);
showError("Failed to create app. " + (error as any).toString());
setIsLoading(false); // Ensure loading state is reset on error
}
// No finally block needed for setIsLoading(false) here if navigation happens on success
};
// Loading overlay for app creation
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center max-w-3xl m-auto p-8">
<div className="w-full flex flex-col items-center">
{/* Loading Spinner */}
<div className="relative w-24 h-24 mb-8">
<div className="absolute top-0 left-0 w-full h-full border-8 border-gray-200 dark:border-gray-700 rounded-full"></div>
<div className="absolute top-0 left-0 w-full h-full border-8 border-t-primary rounded-full animate-spin"></div>
</div>
<h2 className="text-2xl font-bold mb-2 text-gray-800 dark:text-gray-200">
Building your app
</h2>
<p className="text-gray-600 dark:text-gray-400 text-center max-w-md mb-8">
We're setting up your app with AI magic. <br />
This might take a moment...
</p>
</div>
</div>
);
}
// Main Home Page Content
return (
<div className="flex flex-col items-center justify-center max-w-3xl m-auto p-8">
<SetupBanner />
<div className="w-full">
<ImportAppButton />
<HomeChatInput onSubmit={handleSubmit} />
<div className="flex flex-col gap-4 mt-4">
<div className="flex flex-wrap gap-4 justify-center">
{randomPrompts.map((item, index) => (
<button
type="button"
key={index}
onClick={() => setInputValue(`Build me a ${item.label}`)}
className="flex items-center gap-3 px-4 py-2 rounded-xl border border-gray-200
bg-white/50 backdrop-blur-sm
transition-all duration-200
hover:bg-white hover:shadow-md hover:border-gray-300
active:scale-[0.98]
dark:bg-gray-800/50 dark:border-gray-700
dark:hover:bg-gray-800 dark:hover:border-gray-600"
>
<span className="text-gray-700 dark:text-gray-300">
{item.icon}
</span>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{item.label}
</span>
</button>
))}
</div>
<button
type="button"
onClick={() => setRandomPrompts(getRandomPrompts())}
className="self-center flex items-center gap-2 px-4 py-2 rounded-xl border border-gray-200
bg-white/50 backdrop-blur-sm
transition-all duration-200
hover:bg-white hover:shadow-md hover:border-gray-300
active:scale-[0.98]
dark:bg-gray-800/50 dark:border-gray-700
dark:hover:bg-gray-800 dark:hover:border-gray-600"
>
<svg
className="w-5 h-5 text-gray-700 dark:text-gray-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
More ideas
</span>
</button>
</div>
</div>
<PrivacyBanner />
{/* Release Notes Dialog */}
<Dialog open={releaseNotesOpen} onOpenChange={setReleaseNotesOpen}>
<DialogContent className="max-w-4xl bg-(--docs-bg) pr-0 pt-4 pl-4 gap-1">
<DialogHeader>
<DialogTitle>What's new in v{appVersion}?</DialogTitle>
<Button
variant="ghost"
size="sm"
className="absolute right-10 top-2 focus-visible:ring-0 focus-visible:ring-offset-0"
onClick={() =>
window.open(
releaseUrl.replace("?hideHeader=true&theme=" + theme, ""),
"_blank",
)
}
>
<ExternalLink className="w-4 h-4" />
</Button>
</DialogHeader>
<div className="overflow-auto h-[70vh] flex flex-col ">
{releaseUrl && (
<div className="flex-1">
<iframe
src={releaseUrl}
className="w-full h-full border-0 rounded-lg"
title={`Release notes for v${appVersion}`}
/>
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}