community templates (#691)
This commit is contained in:
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { safeStorage } from "electron";
|
||||
import { readSettings, getSettingsFilePath } from "@/main/settings";
|
||||
import { getUserDataPath } from "@/paths/paths";
|
||||
import { UserSettings } from "@/lib/schemas";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("node:fs");
|
||||
@@ -50,23 +51,26 @@ describe("readSettings", () => {
|
||||
mockSettingsPath,
|
||||
expect.stringContaining('"selectedModel"'),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
selectedModel: {
|
||||
name: "auto",
|
||||
provider: "auto",
|
||||
},
|
||||
providerSettings: {},
|
||||
telemetryConsent: "unset",
|
||||
telemetryUserId: expect.any(String),
|
||||
hasRunBefore: false,
|
||||
experiments: {},
|
||||
enableProLazyEditsMode: true,
|
||||
enableProSmartFilesContextMode: true,
|
||||
selectedChatMode: "build",
|
||||
enableAutoFixProblems: false,
|
||||
enableAutoUpdate: true,
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
expect(scrubSettings(result)).toMatchInlineSnapshot(`
|
||||
{
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"enableProLazyEditsMode": true,
|
||||
"enableProSmartFilesContextMode": true,
|
||||
"experiments": {},
|
||||
"hasRunBefore": false,
|
||||
"providerSettings": {},
|
||||
"releaseChannel": "stable",
|
||||
"selectedChatMode": "build",
|
||||
"selectedModel": {
|
||||
"name": "auto",
|
||||
"provider": "auto",
|
||||
},
|
||||
"selectedTemplateId": "react",
|
||||
"telemetryConsent": "unset",
|
||||
"telemetryUserId": "[scrubbed]",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -293,23 +297,26 @@ describe("readSettings", () => {
|
||||
|
||||
const result = readSettings();
|
||||
|
||||
expect(result).toEqual({
|
||||
selectedModel: {
|
||||
name: "auto",
|
||||
provider: "auto",
|
||||
},
|
||||
providerSettings: {},
|
||||
telemetryConsent: "unset",
|
||||
telemetryUserId: expect.any(String),
|
||||
hasRunBefore: false,
|
||||
experiments: {},
|
||||
enableProLazyEditsMode: true,
|
||||
enableProSmartFilesContextMode: true,
|
||||
selectedChatMode: "build",
|
||||
enableAutoFixProblems: false,
|
||||
enableAutoUpdate: true,
|
||||
releaseChannel: "stable",
|
||||
});
|
||||
expect(scrubSettings(result)).toMatchInlineSnapshot(`
|
||||
{
|
||||
"enableAutoFixProblems": false,
|
||||
"enableAutoUpdate": true,
|
||||
"enableProLazyEditsMode": true,
|
||||
"enableProSmartFilesContextMode": true,
|
||||
"experiments": {},
|
||||
"hasRunBefore": false,
|
||||
"providerSettings": {},
|
||||
"releaseChannel": "stable",
|
||||
"selectedChatMode": "build",
|
||||
"selectedModel": {
|
||||
"name": "auto",
|
||||
"provider": "auto",
|
||||
},
|
||||
"selectedTemplateId": "react",
|
||||
"telemetryConsent": "unset",
|
||||
"telemetryUserId": "[scrubbed]",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return default settings when JSON parsing fails", () => {
|
||||
@@ -389,3 +396,10 @@ describe("readSettings", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function scrubSettings(result: UserSettings) {
|
||||
return {
|
||||
...result,
|
||||
telemetryUserId: "[scrubbed]",
|
||||
};
|
||||
}
|
||||
|
||||
51
src/components/CommunityCodeConsentDialog.tsx
Normal file
51
src/components/CommunityCodeConsentDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
134
src/components/TemplateCard.tsx
Normal file
134
src/components/TemplateCard.tsx
Normal 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
24
src/hooks/useTemplates.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import http from "isomorphic-git/http/node";
|
||||
import { app } from "electron";
|
||||
import { copyDirectoryRecursive } from "../utils/file_utils";
|
||||
import { readSettings } from "@/main/settings";
|
||||
import { DEFAULT_TEMPLATE_ID, getTemplateOrThrow } from "@/shared/templates";
|
||||
import { getTemplateOrThrow } from "../utils/template_utils";
|
||||
import log from "electron-log";
|
||||
|
||||
const logger = log.scope("createFromTemplate");
|
||||
@@ -16,7 +16,7 @@ export async function createFromTemplate({
|
||||
fullAppPath: string;
|
||||
}) {
|
||||
const settings = readSettings();
|
||||
const templateId = settings.selectedTemplateId ?? DEFAULT_TEMPLATE_ID;
|
||||
const templateId = settings.selectedTemplateId;
|
||||
|
||||
if (templateId === "react") {
|
||||
await copyDirectoryRecursive(
|
||||
@@ -26,7 +26,7 @@ export async function createFromTemplate({
|
||||
return;
|
||||
}
|
||||
|
||||
const template = getTemplateOrThrow(templateId);
|
||||
const template = await getTemplateOrThrow(templateId);
|
||||
if (!template.githubUrl) {
|
||||
throw new Error(`Template ${templateId} has no GitHub URL`);
|
||||
}
|
||||
|
||||
19
src/ipc/handlers/template_handlers.ts
Normal file
19
src/ipc/handlers/template_handlers.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import type {
|
||||
UpdateChatParams,
|
||||
FileAttachment,
|
||||
} from "./ipc_types";
|
||||
import type { Template } from "../shared/templates";
|
||||
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
|
||||
import { showError } from "@/lib/toast";
|
||||
|
||||
@@ -1024,4 +1025,9 @@ export class IpcClient {
|
||||
}): Promise<ProblemReport> {
|
||||
return this.ipcRenderer.invoke("check-problems", params);
|
||||
}
|
||||
|
||||
// Template methods
|
||||
public async getTemplates(): Promise<Template[]> {
|
||||
return this.ipcRenderer.invoke("get-templates");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { registerAppUpgradeHandlers } from "./handlers/app_upgrade_handlers";
|
||||
import { registerCapacitorHandlers } from "./handlers/capacitor_handlers";
|
||||
import { registerProblemsHandlers } from "./handlers/problems_handlers";
|
||||
import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers";
|
||||
import { registerTemplateHandlers } from "./handlers/template_handlers";
|
||||
|
||||
export function registerIpcHandlers() {
|
||||
// Register all IPC handlers by category
|
||||
@@ -55,4 +56,5 @@ export function registerIpcHandlers() {
|
||||
registerAppUpgradeHandlers();
|
||||
registerCapacitorHandlers();
|
||||
registerAppEnvVarsHandlers();
|
||||
registerTemplateHandlers();
|
||||
}
|
||||
|
||||
82
src/ipc/utils/template_utils.ts
Normal file
82
src/ipc/utils/template_utils.ts
Normal 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;
|
||||
}
|
||||
@@ -149,9 +149,10 @@ export const UserSettingsSchema = z.object({
|
||||
thinkingBudget: z.enum(["low", "medium", "high"]).optional(),
|
||||
enableProLazyEditsMode: z.boolean().optional(),
|
||||
enableProSmartFilesContextMode: z.boolean().optional(),
|
||||
selectedTemplateId: z.string().optional(),
|
||||
selectedTemplateId: z.string(),
|
||||
enableSupabaseWriteSqlMigration: z.boolean().optional(),
|
||||
selectedChatMode: ChatModeSchema.optional(),
|
||||
acceptedCommunityCode: z.boolean().optional(),
|
||||
|
||||
enableAutoFixProblems: z.boolean().optional(),
|
||||
enableNativeGit: z.boolean().optional(),
|
||||
|
||||
@@ -64,7 +64,7 @@ export const showError = (message: any) => {
|
||||
onCopy={() => onCopy(t)}
|
||||
/>
|
||||
),
|
||||
{ duration: 4000 },
|
||||
{ duration: 8_000 },
|
||||
);
|
||||
|
||||
return toastId;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { UserSettingsSchema, type UserSettings, Secret } from "../lib/schemas";
|
||||
import { safeStorage } from "electron";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import log from "electron-log";
|
||||
import { DEFAULT_TEMPLATE_ID } from "@/shared/templates";
|
||||
|
||||
const logger = log.scope("settings");
|
||||
|
||||
@@ -26,6 +27,7 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
enableAutoFixProblems: false,
|
||||
enableAutoUpdate: true,
|
||||
releaseChannel: "stable",
|
||||
selectedTemplateId: DEFAULT_TEMPLATE_ID,
|
||||
};
|
||||
|
||||
const SETTINGS_FILE = "user-settings.json";
|
||||
|
||||
@@ -3,20 +3,26 @@ import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { DEFAULT_TEMPLATE_ID, templatesData } from "@/shared/templates";
|
||||
import { useTemplates } from "@/hooks/useTemplates";
|
||||
import { TemplateCard } from "@/components/TemplateCard";
|
||||
|
||||
const HubPage: React.FC = () => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const router = useRouter();
|
||||
const { templates, isLoading } = useTemplates();
|
||||
|
||||
const selectedTemplateId =
|
||||
settings?.selectedTemplateId || DEFAULT_TEMPLATE_ID;
|
||||
const selectedTemplateId = settings?.selectedTemplateId;
|
||||
|
||||
const handleTemplateSelect = (templateId: string) => {
|
||||
updateSettings({ selectedTemplateId: templateId });
|
||||
};
|
||||
|
||||
// Separate templates into official and community
|
||||
const officialTemplates =
|
||||
templates?.filter((template) => template.isOfficial) || [];
|
||||
const communityTemplates =
|
||||
templates?.filter((template) => !template.isOfficial) || [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen px-8 py-4">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
@@ -35,78 +41,47 @@ const HubPage: React.FC = () => {
|
||||
</h1>
|
||||
<p className="text-md text-gray-600 dark:text-gray-400">
|
||||
Choose a starting point for your new project.
|
||||
{isLoading && " Loading additional templates..."}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{templatesData.map((template) => {
|
||||
const isSelected = template.id === selectedTemplateId;
|
||||
return (
|
||||
<div
|
||||
key={template.id}
|
||||
onClick={() => handleTemplateSelect(template.id)}
|
||||
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={(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>
|
||||
{/* Official Templates Section */}
|
||||
{officialTemplates.length > 0 && (
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
Official templates
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{officialTemplates.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
isSelected={template.id === selectedTemplateId}
|
||||
onSelect={handleTemplateSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Community Templates Section */}
|
||||
{communityTemplates.length > 0 && (
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
Community templates
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{communityTemplates.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
isSelected={template.id === selectedTemplateId}
|
||||
onSelect={handleTemplateSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -100,6 +100,7 @@ const validInvokeChannels = [
|
||||
"open-android",
|
||||
"check-problems",
|
||||
"restart-dyad",
|
||||
"get-templates",
|
||||
// Test-only channels
|
||||
// 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
|
||||
|
||||
@@ -7,17 +7,27 @@ export interface Template {
|
||||
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[] = [
|
||||
{
|
||||
id: "react",
|
||||
title: "React.js Template",
|
||||
description: "Uses React.js, Vite, Shadcn, Tailwind and TypeScript.",
|
||||
imageUrl:
|
||||
"https://github.com/user-attachments/assets/5b700eab-b28c-498e-96de-8649b14c16d9",
|
||||
isOfficial: true,
|
||||
},
|
||||
export const DEFAULT_TEMPLATE_ID = "react";
|
||||
export const DEFAULT_TEMPLATE = {
|
||||
id: "react",
|
||||
title: "React.js Template",
|
||||
description: "Uses React.js, Vite, Shadcn, Tailwind and TypeScript.",
|
||||
imageUrl:
|
||||
"https://github.com/user-attachments/assets/5b700eab-b28c-498e-96de-8649b14c16d9",
|
||||
isOfficial: true,
|
||||
};
|
||||
|
||||
export const localTemplatesData: Template[] = [
|
||||
DEFAULT_TEMPLATE,
|
||||
{
|
||||
id: "next",
|
||||
title: "Next.js Template",
|
||||
@@ -28,11 +38,3 @@ export const templatesData: Template[] = [
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user