Grow the bundled template library to 83 homelab apps (0.43.0)
CI / build-and-push (push) Successful in 1m46s
CI / build-and-push (push) Successful in 1m46s
The library shipped five templates. This adds 78 more, covering what
homelab lists and the self-hosted community actually run: media servers
and the *arr automation chain, DNS ad-blocking, reverse proxies, VPN,
SSO, monitoring and dashboards, files/backup, notes and wikis, home
automation, dev tooling, databases, local AI, finance and notifications.
Every template follows the existing shape — compose.yaml, .env.example,
template.json — with PUID/PGID/TZ/DATA_PATH/HTTP_PORT knobs and no
literal secrets: anything that must be set uses ${VAR:?...} so deploy
fails loudly instead of coming up with a default password. Six ship the
extra config file their app needs (prometheus.yml, Caddyfile,
mosquitto.conf, frigate config.yml, Authelia's two files,
zigbee2mqtt configuration.yaml), which the folder-copy pull already
carries into the new stack.
All 95 referenced images were verified pullable against their
registries. Default host ports were deconflicted so several templates
can be pulled side by side; the only remaining overlaps are between
services you would never run together anyway (two DNS blockers on 53,
three reverse proxies on 80/443).
The Templates page would have been an unusable 83-card grid, so it now
has a search box and tag filter chips, with the tags on each card
clickable to filter by.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.42.2",
|
||||
"version": "0.43.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { LayoutTemplate, Cpu, Package, Trash2, FileCode } from "lucide-react";
|
||||
import { LayoutTemplate, Cpu, Package, Trash2, FileCode, Search } from "lucide-react";
|
||||
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
|
||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||
import { templatesApi, type TemplateDetail, type TemplateSummary } from "@/api/templates";
|
||||
@@ -18,8 +18,31 @@ export function Templates() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selected, setSelected] = useState<TemplateDetail | null>(null);
|
||||
const [toDelete, setToDelete] = useState<TemplateSummary | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [tag, setTag] = useState<string | null>(null);
|
||||
const { data, isLoading } = useQuery({ queryKey: ["templates"], queryFn: templatesApi.list });
|
||||
|
||||
// Tags sorted by how many templates carry them — the useful ones surface first.
|
||||
const tags = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const t of data ?? []) {
|
||||
for (const tg of t.tags) counts.set(tg, (counts.get(tg) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.map(([name, count]) => ({ name, count }));
|
||||
}, [data]);
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return (data ?? []).filter((t) => {
|
||||
if (tag && !t.tags.includes(tag)) return false;
|
||||
if (!q) return true;
|
||||
const haystack = `${t.name} ${t.description ?? ""} ${t.tags.join(" ")}`.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
});
|
||||
}, [data, search, tag]);
|
||||
|
||||
const open = async (t: TemplateSummary) => {
|
||||
try {
|
||||
setSelected(await templatesApi.get(t.id));
|
||||
@@ -50,8 +73,36 @@ export function Templates() {
|
||||
Templates are stored as ready-to-run stack folders. Pull one to copy it into a new stack,
|
||||
then edit it like any other.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder={`Search ${data?.length ?? 0} templates…`}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<TagChip label="All" active={tag === null} onClick={() => setTag(null)} />
|
||||
{tags.map((tg) => (
|
||||
<TagChip
|
||||
key={tg.name}
|
||||
label={`${tg.name} ${tg.count}`}
|
||||
active={tag === tg.name}
|
||||
onClick={() => setTag(tag === tg.name ? null : tg.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shown.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-slate-500">No template matches that filter.</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{data?.map((t) => (
|
||||
{shown.map((t) => (
|
||||
<Card key={t.id} className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{t.source === "custom" ? (
|
||||
@@ -64,10 +115,15 @@ export function Templates() {
|
||||
</div>
|
||||
{t.description && <p className="text-sm text-slate-500">{t.description}</p>}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{t.tags.map((tag) => (
|
||||
<span key={tag} className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-700 dark:text-slate-300">
|
||||
{tag}
|
||||
</span>
|
||||
{t.tags.map((tg) => (
|
||||
<button
|
||||
key={tg}
|
||||
type="button"
|
||||
onClick={() => setTag(tag === tg ? null : tg)}
|
||||
className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"
|
||||
>
|
||||
{tg}
|
||||
</button>
|
||||
))}
|
||||
{t.gpu && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
@@ -109,6 +165,30 @@ export function Templates() {
|
||||
);
|
||||
}
|
||||
|
||||
function TagChip({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={
|
||||
active
|
||||
? "rounded-full bg-accent px-2.5 py-1 text-xs font-medium text-white dark:bg-accent-dark"
|
||||
: "rounded-full bg-slate-100 px-2.5 py-1 text-xs text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function UseTemplateDialog({
|
||||
template,
|
||||
onClose,
|
||||
|
||||
Reference in New Issue
Block a user