Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1365,6 +1365,56 @@ async fn pull_upstream(root: String) -> Result<String, String> {
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<String, String> {
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<String>) -> Result<String, String> {
run_git(&root, &["add", "-A"])?;
Expand Down Expand Up @@ -1585,6 +1635,8 @@ pub fn run() {
fetch_upstream,
pull_upstream,
publish,
themes_api,
clone_template,
watch_root
])
.build(tauri::generate_context!())
Expand Down
151 changes: 151 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
53 changes: 38 additions & 15 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<FileGroup | null>(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);
Expand Down Expand Up @@ -1066,23 +1069,28 @@ function App() {
</Button>
<Menu position="bottom-start" width={220}>
<Menu.Target>
<Button
size="xs"
variant="default"
px={6}
aria-label="Recent sites"
disabled={recentOptions.length === 0}
>
<Button size="xs" variant="default" px={6} aria-label="Site menu">
<ChevronDown size={14} />
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Recent sites</Menu.Label>
{recentOptions.map((dir) => (
<Menu.Item key={dir} title={dir} onClick={() => void selectRoot(dir)}>
{dir.split("/").filter(Boolean).pop()}
</Menu.Item>
))}
<Menu.Item
leftSection={<Plus size={14} />}
onClick={() => setShowTemplates(true)}
>
New site from template
</Menu.Item>
{recentOptions.length > 0 && (
<>
<Menu.Divider />
<Menu.Label>Recent sites</Menu.Label>
{recentOptions.map((dir) => (
<Menu.Item key={dir} title={dir} onClick={() => void selectRoot(dir)}>
{dir.split("/").filter(Boolean).pop()}
</Menu.Item>
))}
</>
)}
</Menu.Dropdown>
</Menu>
</Button.Group>
Expand Down Expand Up @@ -1153,6 +1161,16 @@ function App() {
</Button>
</Modal>

{showTemplates && (
<TemplateGallery
onClose={() => setShowTemplates(false)}
onCloned={(dir) => {
setShowTemplates(false);
void selectRoot(dir);
}}
/>
)}

{root && newFileGroup && (
<NewFileModal
root={root}
Expand All @@ -1166,8 +1184,13 @@ function App() {

{!root ? (
<div className="empty-state">
<p>Select the folder that holds your site to get started.</p>
<Button onClick={() => void chooseDirectory()}>Choose directory</Button>
<p>Select the folder that holds your site, or start fresh from a template.</p>
<div className="empty-state-actions">
<Button onClick={() => void chooseDirectory()}>Choose directory</Button>
<Button variant="default" onClick={() => setShowTemplates(true)}>
Start with a template
</Button>
</div>
</div>
) : (
<div className="body">
Expand Down
Loading