diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1e55a4d..46e01dd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1365,6 +1365,56 @@ async fn pull_upstream(root: String) -> Result { Ok("Updated from server.".to_string()) } +/// GET against the Astro themes catalog API (portal.astro.build). Done in +/// Rust because the portal sends no CORS headers, so the webview can't fetch +/// it directly. curl ships with macOS, Windows 10+, and virtually all Linux +/// distros (same assumption install_node makes). +#[tauri::command] +async fn themes_api(path: String) -> Result { + if !path.starts_with("/api/themes") || path.contains(|c: char| c.is_whitespace()) { + return Err(format!("Invalid themes API path: {path}")); + } + let url = format!("https://portal.astro.build{path}"); + let output = Command::new("curl") + .args(["-fsSL", "--retry", "2", "--max-time", "30", &url]) + .stdin(Stdio::null()) + .output() + .map_err(|e| format!("Failed to run curl: {e}"))?; + if !output.status.success() { + return Err("Could not reach the Astro themes catalog — check your connection and retry.".to_string()); + } + String::from_utf8(output.stdout).map_err(|e| format!("Unreadable catalog response: {e}")) +} + +/// Clones a template repository into `dest` (which must not exist yet), then +/// detaches it from the template author's remote — otherwise Publish would +/// push to the theme's repo and the upstream poll would offer its updates. +#[tauri::command] +async fn clone_template(app: tauri::AppHandle, url: String, dest: String) -> Result<(), String> { + if !url.starts_with("https://") { + return Err(format!("Invalid repository URL: {url}")); + } + if Path::new(&dest).exists() { + return Err(format!("{dest} already exists — pick a different name")); + } + let output = Command::new("git") + .args(["clone", "--depth", "1", "--single-branch", &url, &dest]) + .env("PATH", setup_path(&app)) + // Fail on private/missing repos instead of hanging on a credentials prompt. + .env("GIT_TERMINAL_PROMPT", "0") + .stdin(Stdio::null()) + .output() + .map_err(|e| format!("Failed to run git: {e}"))?; + if !output.status.success() { + return Err(format!( + "Could not clone the template: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let _ = run_git(&dest, &["remote", "remove", "origin"]); + Ok(()) +} + #[tauri::command] async fn publish(root: String, message: Option) -> Result { run_git(&root, &["add", "-A"])?; @@ -1585,6 +1635,8 @@ pub fn run() { fetch_upstream, pull_upstream, publish, + themes_api, + clone_template, watch_root ]) .build(tauri::generate_context!()) diff --git a/src/App.css b/src/App.css index f818bc4..f8e4954 100644 --- a/src/App.css +++ b/src/App.css @@ -39,6 +39,157 @@ body, color: var(--mantine-color-dimmed); } +.empty-state-actions { + display: flex; + gap: 0.5rem; +} + +/* Template gallery: a full-window page layered over the app. */ +.template-page { + position: fixed; + inset: 0; + z-index: 150; + display: flex; + flex-direction: column; + background: var(--mantine-color-body); +} + +.template-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--mantine-color-default-border); +} + +.template-heading { + flex: 1; + min-width: 0; +} + +.template-heading h2 { + margin: 0; + font-size: 1rem; +} + +.template-note { + margin: 0.15rem 0 0; + font-size: 0.8rem; + color: var(--mantine-color-dimmed); +} + +.template-status { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--mantine-color-dimmed); +} + +.template-grid { + flex: 1; + overflow: auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + /* The cards' overflow:hidden zeroes their automatic minimum size, so + auto rows would squish to fit the container instead of scrolling. */ + grid-auto-rows: max-content; + gap: 1rem; + padding: 1rem; + align-content: start; +} + +.template-card { + display: flex; + flex-direction: column; + padding: 0; + text-align: left; + font: inherit; + color: inherit; + cursor: pointer; + background: var(--mantine-color-default); + border: 1px solid var(--mantine-color-default-border); + border-radius: 8px; + overflow: hidden; +} + +.template-card:hover { + border-color: var(--mantine-color-default-color); +} + +.template-card-image { + width: 100%; + height: 240px; + flex-shrink: 0; + object-fit: cover; + object-position: top; + display: block; + background: var(--mantine-color-default-hover); +} + +.template-card-body { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.6rem 0.75rem 0.75rem; +} + +.template-card-title-row { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + +.template-card-title { + font-weight: 600; + font-size: 0.9rem; +} + +.template-card-description { + margin: 0; + font-size: 0.8rem; + color: var(--mantine-color-dimmed); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.template-card-author { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.75rem; + color: var(--mantine-color-dimmed); +} + +.template-card-author img { + width: 16px; + height: 16px; + border-radius: 50%; +} + +.template-modal-image { + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + display: block; + border-radius: 6px; +} + +.template-modal-description { + margin: 0.75rem 0 0; + font-size: 0.85rem; +} + +.template-modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1rem; +} + .body { display: flex; flex: 1; diff --git a/src/App.tsx b/src/App.tsx index 0b26d1e..8501f70 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ import { parseFile, type ParsedFile } from "./pagescms/frontmatter"; import { FormEditor } from "./components/FormEditor"; import { NewFileModal } from "./components/NewFileModal"; import { SeoPreview } from "./components/SeoPreview"; +import { TemplateGallery } from "./components/TemplateGallery"; import "@mantine/core/styles.css"; import "@mantine/tiptap/styles.css"; @@ -273,6 +274,8 @@ function App() { const [commitMessage, setCommitMessage] = useState(DEFAULT_COMMIT_MESSAGE); // Directory the "new file" dialog is creating into, when open. const [newFileGroup, setNewFileGroup] = useState(null); + // Whether the "start with a template" gallery covers the app. + const [showTemplates, setShowTemplates] = useState(false); // While the split divider is being dragged, the preview iframe must not // receive pointer events or it swallows the drag mid-motion. const [dragging, setDragging] = useState(false); @@ -1066,23 +1069,28 @@ function App() { - - Recent sites - {recentOptions.map((dir) => ( - void selectRoot(dir)}> - {dir.split("/").filter(Boolean).pop()} - - ))} + } + onClick={() => setShowTemplates(true)} + > + New site from template + + {recentOptions.length > 0 && ( + <> + + Recent sites + {recentOptions.map((dir) => ( + void selectRoot(dir)}> + {dir.split("/").filter(Boolean).pop()} + + ))} + + )} @@ -1153,6 +1161,16 @@ function App() { + {showTemplates && ( + setShowTemplates(false)} + onCloned={(dir) => { + setShowTemplates(false); + void selectRoot(dir); + }} + /> + )} + {root && newFileGroup && ( -

Select the folder that holds your site to get started.

- +

Select the folder that holds your site, or start fresh from a template.

+
+ + +
) : (
diff --git a/src/components/TemplateGallery.tsx b/src/components/TemplateGallery.tsx new file mode 100644 index 0000000..30281c1 --- /dev/null +++ b/src/components/TemplateGallery.tsx @@ -0,0 +1,311 @@ +import { useEffect, useMemo, useState } from "react"; +import { + ActionIcon, + Alert, + Anchor, + Badge, + Button, + Loader, + Modal, + Select, + TextInput, +} from "@mantine/core"; +import { ArrowLeft, Search } from "lucide-react"; +import { invoke, openDirectory, openUrl } from "../ipc"; + +/** One row of the portal's /api/themes response (one row per theme×category). */ +type CatalogRow = { + Theme: { + slug: string; + title: string; + description: string; + image: string; + }; + Author?: { name: string; avatar: string | null }; + ThemeCategory?: { value: string; name: string }; +}; + +type CatalogTheme = { + slug: string; + title: string; + description: string; + image: string; + author: { name: string; avatar: string | null } | null; + categories: { value: string; name: string }[]; +}; + +/** The join in /api/themes repeats a theme once per category; fold those. */ +function parseCatalog(raw: string): CatalogTheme[] { + const rows = JSON.parse(raw) as CatalogRow[]; + const bySlug = new Map(); + for (const row of rows) { + if (!row?.Theme?.slug) continue; + let theme = bySlug.get(row.Theme.slug); + if (!theme) { + theme = { + slug: row.Theme.slug, + title: row.Theme.title, + description: row.Theme.description, + image: row.Theme.image, + author: row.Author ? { name: row.Author.name, avatar: row.Author.avatar } : null, + categories: [], + }; + bySlug.set(theme.slug, theme); + } + if (row.ThemeCategory && !theme.categories.some((c) => c.value === row.ThemeCategory!.value)) { + theme.categories.push(row.ThemeCategory); + } + } + return [...bySlug.values()]; +} + +const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +export function TemplateGallery(props: { + onClose: () => void; + /** Called with the cloned project's root once the template is on disk. */ + onCloned: (dir: string) => void; +}) { + const [themes, setThemes] = useState(null); + const [loadError, setLoadError] = useState(null); + const [search, setSearch] = useState(""); + const [category, setCategory] = useState(null); + + const [selected, setSelected] = useState(null); + // null while the details request (which carries the repo URL) is in flight. + const [repoUrl, setRepoUrl] = useState(null); + const [detailsError, setDetailsError] = useState(null); + const [name, setName] = useState(""); + const [cloning, setCloning] = useState(false); + const [cloneError, setCloneError] = useState(null); + + useEffect(() => { + let cancelled = false; + invoke("themes_api", { path: "/api/themes?price%5B%5D=free&technology%5B%5D=mdx" }) + .then((raw) => { + if (!cancelled) setThemes(parseCatalog(raw)); + }) + .catch((e) => { + if (!cancelled) setLoadError(String(e)); + }); + return () => { + cancelled = true; + }; + }, []); + + // The list response has no repo URL; the details endpoint does. + useEffect(() => { + if (!selected) return; + let cancelled = false; + setRepoUrl(null); + setDetailsError(null); + invoke("themes_api", { + path: `/api/themes/details?slug=${encodeURIComponent(selected.slug)}`, + }) + .then((raw) => { + if (cancelled) return; + const url: unknown = (JSON.parse(raw) as { Theme?: { repoUrl?: unknown } })?.Theme?.repoUrl; + if (typeof url === "string" && url.startsWith("https://")) { + setRepoUrl(url); + } else { + setDetailsError("This template doesn't provide a public repository to clone."); + } + }) + .catch((e) => { + if (!cancelled) setDetailsError(String(e)); + }); + return () => { + cancelled = true; + }; + }, [selected]); + + const categoryOptions = useMemo(() => { + const seen = new Map(); + for (const theme of themes ?? []) { + for (const cat of theme.categories) seen.set(cat.value, cat.name); + } + return [...seen] + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [themes]); + + const visible = useMemo(() => { + const term = search.trim().toLowerCase(); + return (themes ?? []).filter( + (theme) => + (!category || theme.categories.some((c) => c.value === category)) && + (!term || + theme.title.toLowerCase().includes(term) || + theme.description.toLowerCase().includes(term)), + ); + }, [themes, search, category]); + + function openTheme(theme: CatalogTheme) { + setSelected(theme); + setName(theme.slug); + setCloneError(null); + } + + function closeModal() { + if (cloning) return; // mid-clone; let it finish or fail + setSelected(null); + } + + const nameValid = NAME_PATTERN.test(name.trim()); + + async function createSite() { + if (!repoUrl || !nameValid) return; + // The dialog picks the parent folder; the project lands in a new + // subfolder named by the user. + const parent = await openDirectory(); + if (typeof parent !== "string") return; + const dest = parent.replace(/[/\\]+$/, "") + "/" + name.trim(); + setCloning(true); + setCloneError(null); + try { + await invoke("clone_template", { url: repoUrl, dest }); + } catch (e) { + setCloneError(String(e)); + setCloning(false); + return; + } + props.onCloned(dest); + } + + return ( +
+
+ + + +
+

Start with a template

+

+ Free MDX templates from the{" "} + { + e.preventDefault(); + void openUrl("https://astro.build/themes/"); + }} + > + Astro Themes + {" "} + catalog, built by independent authors. Some customizations may require editing code. +

+
+ } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + /> +