community templates (#691)

This commit is contained in:
Will Chen
2025-07-23 10:11:16 -07:00
committed by GitHub
parent 9edd0fa80f
commit e947eede7a
37 changed files with 544 additions and 135 deletions

View File

@@ -876,11 +876,11 @@ export class PageObject {
await this.page.getByRole("link", { name: "Hub" }).click(); await this.page.getByRole("link", { name: "Hub" }).click();
} }
private async selectTemplate(templateName: string) { async selectTemplate(templateName: string) {
await this.page.getByRole("img", { name: templateName }).click(); await this.page.getByRole("img", { name: templateName }).click();
} }
async selectHubTemplate(templateName: "Next.js Template") { async goToHubAndSelectTemplate(templateName: "Next.js Template") {
await this.goToHubTab(); await this.goToHubTab();
await this.selectTemplate(templateName); await this.selectTemplate(templateName);
await this.goToAppsTab(); await this.goToAppsTab();

View File

@@ -112,7 +112,7 @@ export default App;
test("problems - manual edit (next.js)", async ({ po }) => { test("problems - manual edit (next.js)", async ({ po }) => {
await po.setUp({ enableAutoFixProblems: true }); await po.setUp({ enableAutoFixProblems: true });
await po.selectHubTemplate("Next.js Template"); await po.goToHubAndSelectTemplate("Next.js Template");
await po.sendPrompt("tc=1"); await po.sendPrompt("tc=1");
const appPath = await po.getCurrentAppPath(); const appPath = await po.getCurrentAppPath();

View File

@@ -81,7 +81,7 @@ testSkipIfWindows("upgrade app to select component", async ({ po }) => {
testSkipIfWindows("select component next.js", async ({ po }) => { testSkipIfWindows("select component next.js", async ({ po }) => {
await po.setUp(); await po.setUp();
await po.selectHubTemplate("Next.js Template"); await po.goToHubAndSelectTemplate("Next.js Template");
await po.sendPrompt("tc=basic"); await po.sendPrompt("tc=basic");
await po.clickTogglePreviewPanel(); await po.clickTogglePreviewPanel();

View File

@@ -11,6 +11,7 @@
"lastShownReleaseNotesVersion": "[scrubbed]", "lastShownReleaseNotesVersion": "[scrubbed]",
"enableProLazyEditsMode": true, "enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true, "enableProSmartFilesContextMode": true,
"selectedTemplateId": "react",
"selectedChatMode": "build", "selectedChatMode": "build",
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": false, "enableAutoUpdate": false,

View File

@@ -11,6 +11,7 @@
"lastShownReleaseNotesVersion": "[scrubbed]", "lastShownReleaseNotesVersion": "[scrubbed]",
"enableProLazyEditsMode": true, "enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true, "enableProSmartFilesContextMode": true,
"selectedTemplateId": "react",
"selectedChatMode": "build", "selectedChatMode": "build",
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,

View File

@@ -13,6 +13,7 @@
"maxChatTurnsInContext": 5, "maxChatTurnsInContext": 5,
"enableProLazyEditsMode": true, "enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true, "enableProSmartFilesContextMode": true,
"selectedTemplateId": "react",
"selectedChatMode": "build", "selectedChatMode": "build",
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,

View File

@@ -11,6 +11,7 @@
"lastShownReleaseNotesVersion": "[scrubbed]", "lastShownReleaseNotesVersion": "[scrubbed]",
"enableProLazyEditsMode": true, "enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true, "enableProSmartFilesContextMode": true,
"selectedTemplateId": "react",
"selectedChatMode": "build", "selectedChatMode": "build",
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,

View File

@@ -11,6 +11,7 @@
"lastShownReleaseNotesVersion": "[scrubbed]", "lastShownReleaseNotesVersion": "[scrubbed]",
"enableProLazyEditsMode": true, "enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true, "enableProSmartFilesContextMode": true,
"selectedTemplateId": "react",
"selectedChatMode": "build", "selectedChatMode": "build",
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,

View File

@@ -10,6 +10,7 @@
"experiments": {}, "experiments": {},
"enableProLazyEditsMode": true, "enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true, "enableProSmartFilesContextMode": true,
"selectedTemplateId": "react",
"selectedChatMode": "build", "selectedChatMode": "build",
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,

View File

@@ -11,6 +11,7 @@
"lastShownReleaseNotesVersion": "[scrubbed]", "lastShownReleaseNotesVersion": "[scrubbed]",
"enableProLazyEditsMode": true, "enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true, "enableProSmartFilesContextMode": true,
"selectedTemplateId": "react",
"selectedChatMode": "build", "selectedChatMode": "build",
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,

View File

@@ -10,6 +10,7 @@
"experiments": {}, "experiments": {},
"enableProLazyEditsMode": true, "enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true, "enableProSmartFilesContextMode": true,
"selectedTemplateId": "react",
"selectedChatMode": "build", "selectedChatMode": "build",
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,

View File

@@ -11,6 +11,7 @@
"lastShownReleaseNotesVersion": "[scrubbed]", "lastShownReleaseNotesVersion": "[scrubbed]",
"enableProLazyEditsMode": true, "enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true, "enableProSmartFilesContextMode": true,
"selectedTemplateId": "react",
"selectedChatMode": "build", "selectedChatMode": "build",
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,

View File

@@ -10,6 +10,7 @@
"experiments": {}, "experiments": {},
"enableProLazyEditsMode": true, "enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true, "enableProSmartFilesContextMode": true,
"selectedTemplateId": "react",
"selectedChatMode": "build", "selectedChatMode": "build",
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,

View File

@@ -11,6 +11,7 @@
"lastShownReleaseNotesVersion": "[scrubbed]", "lastShownReleaseNotesVersion": "[scrubbed]",
"enableProLazyEditsMode": true, "enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true, "enableProSmartFilesContextMode": true,
"selectedTemplateId": "react",
"selectedChatMode": "build", "selectedChatMode": "build",
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,

View File

@@ -0,0 +1,20 @@
{
"selectedModel": {
"name": "auto",
"provider": "auto"
},
"providerSettings": {},
"telemetryConsent": "unset",
"telemetryUserId": "[UUID]",
"hasRunBefore": true,
"experiments": {},
"lastShownReleaseNotesVersion": "[scrubbed]",
"enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true,
"selectedTemplateId": "react",
"selectedChatMode": "build",
"enableAutoFixProblems": false,
"enableAutoUpdate": true,
"releaseChannel": "stable",
"isTestMode": true
}

View File

@@ -0,0 +1,21 @@
{
"selectedModel": {
"name": "auto",
"provider": "auto"
},
"providerSettings": {},
"telemetryConsent": "unset",
"telemetryUserId": "[UUID]",
"hasRunBefore": true,
"experiments": {},
"lastShownReleaseNotesVersion": "[scrubbed]",
"enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true,
"selectedTemplateId": "jeff-kazzee/dyad-template-angular",
"selectedChatMode": "build",
"acceptedCommunityCode": true,
"enableAutoFixProblems": false,
"enableAutoUpdate": true,
"releaseChannel": "stable",
"isTestMode": true
}

View File

@@ -0,0 +1,21 @@
{
"selectedModel": {
"name": "test-model",
"provider": "custom::testing",
"customModelId": 1
},
"providerSettings": {},
"telemetryConsent": "unset",
"telemetryUserId": "[UUID]",
"hasRunBefore": true,
"experiments": {},
"lastShownReleaseNotesVersion": "[scrubbed]",
"enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true,
"selectedTemplateId": "next",
"selectedChatMode": "build",
"enableAutoFixProblems": false,
"enableAutoUpdate": true,
"releaseChannel": "stable",
"isTestMode": true
}

View File

@@ -20,6 +20,7 @@
"thinkingBudget": "low", "thinkingBudget": "low",
"enableProLazyEditsMode": true, "enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true, "enableProSmartFilesContextMode": true,
"selectedTemplateId": "react",
"selectedChatMode": "build", "selectedChatMode": "build",
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,

View File

@@ -20,6 +20,7 @@
"thinkingBudget": "medium", "thinkingBudget": "medium",
"enableProLazyEditsMode": true, "enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true, "enableProSmartFilesContextMode": true,
"selectedTemplateId": "react",
"selectedChatMode": "build", "selectedChatMode": "build",
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,

View File

@@ -20,6 +20,7 @@
"thinkingBudget": "high", "thinkingBudget": "high",
"enableProLazyEditsMode": true, "enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true, "enableProSmartFilesContextMode": true,
"selectedTemplateId": "react",
"selectedChatMode": "build", "selectedChatMode": "build",
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,

View File

@@ -0,0 +1,19 @@
import { test } from "./helpers/test_helper";
test("template - community", async ({ po }) => {
await po.goToHubTab();
// This is a community template, so we should see the consent dialog
await po.selectTemplate("Angular");
await po.page.getByRole("button", { name: "Cancel" }).click();
await po.snapshotSettings();
await po.selectTemplate("Angular");
await po.page.getByRole("button", { name: "Accept" }).click();
await po.page
.locator("section")
.filter({ hasText: "Community" })
.locator("div")
.first()
.click();
await po.snapshotSettings();
});

View File

@@ -3,7 +3,8 @@ import { expect } from "@playwright/test";
test("create next.js app", async ({ po }) => { test("create next.js app", async ({ po }) => {
await po.setUp(); await po.setUp();
await po.selectHubTemplate("Next.js Template"); await po.goToHubAndSelectTemplate("Next.js Template");
await po.snapshotSettings();
// Create an app // Create an app
await po.sendPrompt("tc=edit-made-with-dyad"); await po.sendPrompt("tc=edit-made-with-dyad");

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { safeStorage } from "electron"; import { safeStorage } from "electron";
import { readSettings, getSettingsFilePath } from "@/main/settings"; import { readSettings, getSettingsFilePath } from "@/main/settings";
import { getUserDataPath } from "@/paths/paths"; import { getUserDataPath } from "@/paths/paths";
import { UserSettings } from "@/lib/schemas";
// Mock dependencies // Mock dependencies
vi.mock("node:fs"); vi.mock("node:fs");
@@ -50,23 +51,26 @@ describe("readSettings", () => {
mockSettingsPath, mockSettingsPath,
expect.stringContaining('"selectedModel"'), expect.stringContaining('"selectedModel"'),
); );
expect(result).toEqual({ expect(scrubSettings(result)).toMatchInlineSnapshot(`
selectedModel: { {
name: "auto", "enableAutoFixProblems": false,
provider: "auto", "enableAutoUpdate": true,
}, "enableProLazyEditsMode": true,
providerSettings: {}, "enableProSmartFilesContextMode": true,
telemetryConsent: "unset", "experiments": {},
telemetryUserId: expect.any(String), "hasRunBefore": false,
hasRunBefore: false, "providerSettings": {},
experiments: {}, "releaseChannel": "stable",
enableProLazyEditsMode: true, "selectedChatMode": "build",
enableProSmartFilesContextMode: true, "selectedModel": {
selectedChatMode: "build", "name": "auto",
enableAutoFixProblems: false, "provider": "auto",
enableAutoUpdate: true, },
releaseChannel: "stable", "selectedTemplateId": "react",
}); "telemetryConsent": "unset",
"telemetryUserId": "[scrubbed]",
}
`);
}); });
}); });
@@ -293,23 +297,26 @@ describe("readSettings", () => {
const result = readSettings(); const result = readSettings();
expect(result).toEqual({ expect(scrubSettings(result)).toMatchInlineSnapshot(`
selectedModel: { {
name: "auto", "enableAutoFixProblems": false,
provider: "auto", "enableAutoUpdate": true,
}, "enableProLazyEditsMode": true,
providerSettings: {}, "enableProSmartFilesContextMode": true,
telemetryConsent: "unset", "experiments": {},
telemetryUserId: expect.any(String), "hasRunBefore": false,
hasRunBefore: false, "providerSettings": {},
experiments: {}, "releaseChannel": "stable",
enableProLazyEditsMode: true, "selectedChatMode": "build",
enableProSmartFilesContextMode: true, "selectedModel": {
selectedChatMode: "build", "name": "auto",
enableAutoFixProblems: false, "provider": "auto",
enableAutoUpdate: true, },
releaseChannel: "stable", "selectedTemplateId": "react",
}); "telemetryConsent": "unset",
"telemetryUserId": "[scrubbed]",
}
`);
}); });
it("should return default settings when JSON parsing fails", () => { it("should return default settings when JSON parsing fails", () => {
@@ -389,3 +396,10 @@ describe("readSettings", () => {
}); });
}); });
}); });
function scrubSettings(result: UserSettings) {
return {
...result,
telemetryUserId: "[scrubbed]",
};
}

View File

@@ -0,0 +1,51 @@
import React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface CommunityCodeConsentDialogProps {
isOpen: boolean;
onAccept: () => void;
onCancel: () => void;
}
export const CommunityCodeConsentDialog: React.FC<
CommunityCodeConsentDialogProps
> = ({ isOpen, onAccept, onCancel }) => {
return (
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Community Code Notice</AlertDialogTitle>
<AlertDialogDescription className="space-y-3">
<p>
This code was created by a Dyad community member, not our core
team.
</p>
<p>
Community code can be very helpful, but since it's built
independently, it may have bugs, security risks, or could cause
issues with your system. We can't provide official support if
problems occur.
</p>
<p>
We recommend reviewing the code on GitHub first. Only proceed if
you're comfortable with these risks.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onAccept}>Accept</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,134 @@
import React, { useState } from "react";
import { ArrowLeft } from "lucide-react";
import { IpcClient } from "@/ipc/ipc_client";
import { useSettings } from "@/hooks/useSettings";
import { CommunityCodeConsentDialog } from "./CommunityCodeConsentDialog";
import type { Template } from "@/shared/templates";
interface TemplateCardProps {
template: Template;
isSelected: boolean;
onSelect: (templateId: string) => void;
}
export const TemplateCard: React.FC<TemplateCardProps> = ({
template,
isSelected,
onSelect,
}) => {
const { settings, updateSettings } = useSettings();
const [showConsentDialog, setShowConsentDialog] = useState(false);
const handleCardClick = () => {
// If it's a community template and user hasn't accepted community code yet, show dialog
if (!template.isOfficial && !settings?.acceptedCommunityCode) {
setShowConsentDialog(true);
return;
}
// Otherwise, proceed with selection
onSelect(template.id);
};
const handleConsentAccept = () => {
// Update settings to accept community code
updateSettings({ acceptedCommunityCode: true });
// Select the template
onSelect(template.id);
// Close dialog
setShowConsentDialog(false);
};
const handleConsentCancel = () => {
// Just close dialog, don't update settings or select template
setShowConsentDialog(false);
};
const handleGithubClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (template.githubUrl) {
IpcClient.getInstance().openExternalUrl(template.githubUrl);
}
};
return (
<>
<div
onClick={handleCardClick}
className={`
bg-white dark:bg-gray-800 rounded-xl shadow-sm overflow-hidden
transform transition-all duration-300 ease-in-out
cursor-pointer group relative
${
isSelected
? "ring-2 ring-blue-500 dark:ring-blue-400 shadow-xl"
: "hover:shadow-lg hover:-translate-y-1"
}
`}
>
<div className="relative">
<img
src={template.imageUrl}
alt={template.title}
className={`w-full h-52 object-cover transition-opacity duration-300 group-hover:opacity-80 ${
isSelected ? "opacity-75" : ""
}`}
/>
{isSelected && (
<span className="absolute top-3 right-3 bg-blue-600 text-white text-xs font-bold px-3 py-1.5 rounded-md shadow-lg">
Selected
</span>
)}
</div>
<div className="p-4">
<div className="flex justify-between items-center mb-1.5">
<h2
className={`text-lg font-semibold ${
isSelected
? "text-blue-600 dark:text-blue-400"
: "text-gray-900 dark:text-white"
}`}
>
{template.title}
</h2>
{template.isOfficial && (
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
isSelected
? "bg-blue-100 text-blue-700 dark:bg-blue-600 dark:text-blue-100"
: "bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-200"
}`}
>
Official
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3 h-8 overflow-y-auto">
{template.description}
</p>
{template.githubUrl && (
<a
className={`inline-flex items-center text-sm font-medium transition-colors duration-200 ${
isSelected
? "text-blue-500 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200"
: "text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
}`}
onClick={handleGithubClick}
>
View on GitHub{" "}
<ArrowLeft className="w-4 h-4 ml-1 transform rotate-180" />
</a>
)}
</div>
</div>
<CommunityCodeConsentDialog
isOpen={showConsentDialog}
onAccept={handleConsentAccept}
onCancel={handleConsentCancel}
/>
</>
);
};

24
src/hooks/useTemplates.ts Normal file
View File

@@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { IpcClient } from "@/ipc/ipc_client";
import { localTemplatesData, type Template } from "@/shared/templates";
export function useTemplates() {
const query = useQuery({
queryKey: ["templates"],
queryFn: async (): Promise<Template[]> => {
const ipcClient = IpcClient.getInstance();
return ipcClient.getTemplates();
},
initialData: localTemplatesData,
meta: {
showErrorToast: true,
},
});
return {
templates: query.data,
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
};
}

View File

@@ -5,7 +5,7 @@ import http from "isomorphic-git/http/node";
import { app } from "electron"; import { app } from "electron";
import { copyDirectoryRecursive } from "../utils/file_utils"; import { copyDirectoryRecursive } from "../utils/file_utils";
import { readSettings } from "@/main/settings"; import { readSettings } from "@/main/settings";
import { DEFAULT_TEMPLATE_ID, getTemplateOrThrow } from "@/shared/templates"; import { getTemplateOrThrow } from "../utils/template_utils";
import log from "electron-log"; import log from "electron-log";
const logger = log.scope("createFromTemplate"); const logger = log.scope("createFromTemplate");
@@ -16,7 +16,7 @@ export async function createFromTemplate({
fullAppPath: string; fullAppPath: string;
}) { }) {
const settings = readSettings(); const settings = readSettings();
const templateId = settings.selectedTemplateId ?? DEFAULT_TEMPLATE_ID; const templateId = settings.selectedTemplateId;
if (templateId === "react") { if (templateId === "react") {
await copyDirectoryRecursive( await copyDirectoryRecursive(
@@ -26,7 +26,7 @@ export async function createFromTemplate({
return; return;
} }
const template = getTemplateOrThrow(templateId); const template = await getTemplateOrThrow(templateId);
if (!template.githubUrl) { if (!template.githubUrl) {
throw new Error(`Template ${templateId} has no GitHub URL`); throw new Error(`Template ${templateId} has no GitHub URL`);
} }

View File

@@ -0,0 +1,19 @@
import { createLoggedHandler } from "./safe_handle";
import log from "electron-log";
import { getAllTemplates } from "../utils/template_utils";
import { localTemplatesData, type Template } from "../../shared/templates";
const logger = log.scope("template_handlers");
const handle = createLoggedHandler(logger);
export function registerTemplateHandlers() {
handle("get-templates", async (): Promise<Template[]> => {
try {
const templates = await getAllTemplates();
return templates;
} catch (error) {
logger.error("Error fetching templates:", error);
return localTemplatesData;
}
});
}

View File

@@ -52,6 +52,7 @@ import type {
UpdateChatParams, UpdateChatParams,
FileAttachment, FileAttachment,
} from "./ipc_types"; } from "./ipc_types";
import type { Template } from "../shared/templates";
import type { AppChatContext, ProposalResult } from "@/lib/schemas"; import type { AppChatContext, ProposalResult } from "@/lib/schemas";
import { showError } from "@/lib/toast"; import { showError } from "@/lib/toast";
@@ -1024,4 +1025,9 @@ export class IpcClient {
}): Promise<ProblemReport> { }): Promise<ProblemReport> {
return this.ipcRenderer.invoke("check-problems", params); return this.ipcRenderer.invoke("check-problems", params);
} }
// Template methods
public async getTemplates(): Promise<Template[]> {
return this.ipcRenderer.invoke("get-templates");
}
} }

View File

@@ -25,6 +25,7 @@ import { registerAppUpgradeHandlers } from "./handlers/app_upgrade_handlers";
import { registerCapacitorHandlers } from "./handlers/capacitor_handlers"; import { registerCapacitorHandlers } from "./handlers/capacitor_handlers";
import { registerProblemsHandlers } from "./handlers/problems_handlers"; import { registerProblemsHandlers } from "./handlers/problems_handlers";
import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers"; import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers";
import { registerTemplateHandlers } from "./handlers/template_handlers";
export function registerIpcHandlers() { export function registerIpcHandlers() {
// Register all IPC handlers by category // Register all IPC handlers by category
@@ -55,4 +56,5 @@ export function registerIpcHandlers() {
registerAppUpgradeHandlers(); registerAppUpgradeHandlers();
registerCapacitorHandlers(); registerCapacitorHandlers();
registerAppEnvVarsHandlers(); registerAppEnvVarsHandlers();
registerTemplateHandlers();
} }

View File

@@ -0,0 +1,82 @@
import {
type Template,
type ApiTemplate,
localTemplatesData,
} from "../../shared/templates";
import log from "electron-log";
const logger = log.scope("template_utils");
// In-memory cache for API templates
let apiTemplatesCache: Template[] | null = null;
let apiTemplatesFetchPromise: Promise<Template[]> | null = null;
// Convert API template to our Template interface
function convertApiTemplate(apiTemplate: ApiTemplate): Template {
return {
id: `${apiTemplate.githubOrg}/${apiTemplate.githubRepo}`,
title: apiTemplate.title,
description: apiTemplate.description,
imageUrl: apiTemplate.imageUrl,
githubUrl: `https://github.com/${apiTemplate.githubOrg}/${apiTemplate.githubRepo}`,
isOfficial: false,
};
}
// Fetch templates from API with caching
export async function fetchApiTemplates(): Promise<Template[]> {
// Return cached data if available
if (apiTemplatesCache) {
return apiTemplatesCache;
}
// Return existing promise if fetch is already in progress
if (apiTemplatesFetchPromise) {
return apiTemplatesFetchPromise;
}
// Start new fetch
apiTemplatesFetchPromise = (async (): Promise<Template[]> => {
try {
const response = await fetch("https://api.dyad.sh/v1/templates");
if (!response.ok) {
throw new Error(
`Failed to fetch templates: ${response.status} ${response.statusText}`,
);
}
const apiTemplates: ApiTemplate[] = await response.json();
const convertedTemplates = apiTemplates.map(convertApiTemplate);
// Cache the result
apiTemplatesCache = convertedTemplates;
return convertedTemplates;
} catch (error) {
logger.error("Failed to fetch API templates:", error);
// Reset the promise so we can retry later
apiTemplatesFetchPromise = null;
return []; // Return empty array on error
}
})();
return apiTemplatesFetchPromise;
}
// Get all templates (local + API)
export async function getAllTemplates(): Promise<Template[]> {
const apiTemplates = await fetchApiTemplates();
return [...localTemplatesData, ...apiTemplates];
}
export async function getTemplateOrThrow(
templateId: string,
): Promise<Template> {
const allTemplates = await getAllTemplates();
const template = allTemplates.find((template) => template.id === templateId);
if (!template) {
throw new Error(
`Template ${templateId} not found. Please select a different template.`,
);
}
return template;
}

View File

@@ -149,9 +149,10 @@ export const UserSettingsSchema = z.object({
thinkingBudget: z.enum(["low", "medium", "high"]).optional(), thinkingBudget: z.enum(["low", "medium", "high"]).optional(),
enableProLazyEditsMode: z.boolean().optional(), enableProLazyEditsMode: z.boolean().optional(),
enableProSmartFilesContextMode: z.boolean().optional(), enableProSmartFilesContextMode: z.boolean().optional(),
selectedTemplateId: z.string().optional(), selectedTemplateId: z.string(),
enableSupabaseWriteSqlMigration: z.boolean().optional(), enableSupabaseWriteSqlMigration: z.boolean().optional(),
selectedChatMode: ChatModeSchema.optional(), selectedChatMode: ChatModeSchema.optional(),
acceptedCommunityCode: z.boolean().optional(),
enableAutoFixProblems: z.boolean().optional(), enableAutoFixProblems: z.boolean().optional(),
enableNativeGit: z.boolean().optional(), enableNativeGit: z.boolean().optional(),

View File

@@ -64,7 +64,7 @@ export const showError = (message: any) => {
onCopy={() => onCopy(t)} onCopy={() => onCopy(t)}
/> />
), ),
{ duration: 4000 }, { duration: 8_000 },
); );
return toastId; return toastId;

View File

@@ -5,6 +5,7 @@ import { UserSettingsSchema, type UserSettings, Secret } from "../lib/schemas";
import { safeStorage } from "electron"; import { safeStorage } from "electron";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import log from "electron-log"; import log from "electron-log";
import { DEFAULT_TEMPLATE_ID } from "@/shared/templates";
const logger = log.scope("settings"); const logger = log.scope("settings");
@@ -26,6 +27,7 @@ const DEFAULT_SETTINGS: UserSettings = {
enableAutoFixProblems: false, enableAutoFixProblems: false,
enableAutoUpdate: true, enableAutoUpdate: true,
releaseChannel: "stable", releaseChannel: "stable",
selectedTemplateId: DEFAULT_TEMPLATE_ID,
}; };
const SETTINGS_FILE = "user-settings.json"; const SETTINGS_FILE = "user-settings.json";

View File

@@ -3,20 +3,26 @@ import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { useRouter } from "@tanstack/react-router"; import { useRouter } from "@tanstack/react-router";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { IpcClient } from "@/ipc/ipc_client"; import { useTemplates } from "@/hooks/useTemplates";
import { DEFAULT_TEMPLATE_ID, templatesData } from "@/shared/templates"; import { TemplateCard } from "@/components/TemplateCard";
const HubPage: React.FC = () => { const HubPage: React.FC = () => {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const router = useRouter(); const router = useRouter();
const { templates, isLoading } = useTemplates();
const selectedTemplateId = const selectedTemplateId = settings?.selectedTemplateId;
settings?.selectedTemplateId || DEFAULT_TEMPLATE_ID;
const handleTemplateSelect = (templateId: string) => { const handleTemplateSelect = (templateId: string) => {
updateSettings({ selectedTemplateId: templateId }); updateSettings({ selectedTemplateId: templateId });
}; };
// Separate templates into official and community
const officialTemplates =
templates?.filter((template) => template.isOfficial) || [];
const communityTemplates =
templates?.filter((template) => !template.isOfficial) || [];
return ( return (
<div className="min-h-screen px-8 py-4"> <div className="min-h-screen px-8 py-4">
<div className="max-w-5xl mx-auto"> <div className="max-w-5xl mx-auto">
@@ -35,78 +41,47 @@ const HubPage: React.FC = () => {
</h1> </h1>
<p className="text-md text-gray-600 dark:text-gray-400"> <p className="text-md text-gray-600 dark:text-gray-400">
Choose a starting point for your new project. Choose a starting point for your new project.
{isLoading && " Loading additional templates..."}
</p> </p>
</header> </header>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> {/* Official Templates Section */}
{templatesData.map((template) => { {officialTemplates.length > 0 && (
const isSelected = template.id === selectedTemplateId; <section className="mb-12">
return ( <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
<div Official templates
key={template.id} </h2>
onClick={() => handleTemplateSelect(template.id)} <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
className={` {officialTemplates.map((template) => (
bg-white dark:bg-gray-800 rounded-xl shadow-sm overflow-hidden <TemplateCard
transform transition-all duration-300 ease-in-out key={template.id}
cursor-pointer group relative template={template}
${ isSelected={template.id === selectedTemplateId}
isSelected onSelect={handleTemplateSelect}
? "ring-2 ring-blue-500 dark:ring-blue-400 shadow-xl" />
: "hover:shadow-lg hover:-translate-y-1" ))}
} </div>
`} </section>
> )}
<div className="relative">
<img {/* Community Templates Section */}
src={template.imageUrl} {communityTemplates.length > 0 && (
alt={template.title} <section className="mb-12">
className={`w-full h-52 object-cover transition-opacity duration-300 group-hover:opacity-80 ${isSelected ? "opacity-75" : ""}`} <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
/> Community templates
{isSelected && ( </h2>
<span className="absolute top-3 right-3 bg-blue-600 text-white text-xs font-bold px-3 py-1.5 rounded-md shadow-lg"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
Selected {communityTemplates.map((template) => (
</span> <TemplateCard
)} key={template.id}
</div> template={template}
<div className="p-4"> isSelected={template.id === selectedTemplateId}
<div className="flex justify-between items-center mb-1.5"> onSelect={handleTemplateSelect}
<h2 />
className={`text-lg font-semibold ${isSelected ? "text-blue-600 dark:text-blue-400" : "text-gray-900 dark:text-white"}`} ))}
> </div>
{template.title} </section>
</h2> )}
{template.isOfficial && (
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${isSelected ? "bg-blue-100 text-blue-700 dark:bg-blue-600 dark:text-blue-100" : "bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-200"}`}
>
Official
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3 h-8 overflow-y-auto">
{template.description}
</p>
{template.githubUrl && (
<a
className={`inline-flex items-center text-sm font-medium transition-colors duration-200 ${isSelected ? "text-blue-500 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200" : "text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"}`}
onClick={(e) => {
e.stopPropagation();
if (template.githubUrl) {
IpcClient.getInstance().openExternalUrl(
template.githubUrl,
);
}
}}
>
View on GitHub{" "}
<ArrowLeft className="w-4 h-4 ml-1 transform rotate-180" />
</a>
)}
</div>
</div>
);
})}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -100,6 +100,7 @@ const validInvokeChannels = [
"open-android", "open-android",
"check-problems", "check-problems",
"restart-dyad", "restart-dyad",
"get-templates",
// Test-only channels // Test-only channels
// These should ALWAYS be guarded with IS_TEST_BUILD in the main process. // These should ALWAYS be guarded with IS_TEST_BUILD in the main process.
// We can't detect with IS_TEST_BUILD in the preload script because // We can't detect with IS_TEST_BUILD in the preload script because

View File

@@ -7,17 +7,27 @@ export interface Template {
isOfficial: boolean; isOfficial: boolean;
} }
export const DEFAULT_TEMPLATE_ID = "react"; // API Template interface from the external API
export interface ApiTemplate {
githubOrg: string;
githubRepo: string;
title: string;
description: string;
imageUrl: string;
}
export const templatesData: Template[] = [ export const DEFAULT_TEMPLATE_ID = "react";
{ export const DEFAULT_TEMPLATE = {
id: "react", id: "react",
title: "React.js Template", title: "React.js Template",
description: "Uses React.js, Vite, Shadcn, Tailwind and TypeScript.", description: "Uses React.js, Vite, Shadcn, Tailwind and TypeScript.",
imageUrl: imageUrl:
"https://github.com/user-attachments/assets/5b700eab-b28c-498e-96de-8649b14c16d9", "https://github.com/user-attachments/assets/5b700eab-b28c-498e-96de-8649b14c16d9",
isOfficial: true, isOfficial: true,
}, };
export const localTemplatesData: Template[] = [
DEFAULT_TEMPLATE,
{ {
id: "next", id: "next",
title: "Next.js Template", title: "Next.js Template",
@@ -28,11 +38,3 @@ export const templatesData: Template[] = [
isOfficial: true, isOfficial: true,
}, },
]; ];
export function getTemplateOrThrow(templateId: string): Template {
const template = templatesData.find((template) => template.id === templateId);
if (!template) {
throw new Error(`Template ${templateId} not found`);
}
return template;
}