feat: public template catalog + Vercel git integration

Backend: service_templates.is_public lets owners publish user templates to
the anonymous catalog (GET /api/v1/templates), mirroring Railway's
marketplace publish flow. Migration, sqlc queries, visibility rules, and
openapi.yaml updated; types regenerated.

App: template editor gains a "List in the public catalog" checkbox;
Public badge on catalog cards and detail view.

Site: new templates.html catalog page - search, category filters, Official
vs Community badges, detail modal with variables and copyable import JSON.
Fetches the live catalog from a reachable instance (?api= or localStorage
override) and falls back to templates.json, generated from SeedTemplates
via cmd/dumptemplates. Shared styles extracted to style.css.

Vercel: project connected to GitHub repo with rootDirectory=site;
.vercelignore limits CLI uploads to site/ so `vercel deploy --prod` from
repo root matches git builds.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-23 16:40:58 +02:00
1 parent 2b38406a63
commit 117b469fae
18 files changed
+1589 -492

No files matched your search

@@ -48,6 +48,7 @@ const demoTemplates: TemplateEntity[] = [
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
isPublic: false,
},
{
id: 'tpl-go',
@@ -59,6 +60,7 @@ const demoTemplates: TemplateEntity[] = [
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
isPublic: false,
},
{
id: 'tpl-postgres',
@@ -70,6 +72,7 @@ const demoTemplates: TemplateEntity[] = [
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
isPublic: false,
},
{
id: 'tpl-mysql',
@@ -81,6 +84,7 @@ const demoTemplates: TemplateEntity[] = [
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
isPublic: false,
},
{
id: 'tpl-mariadb',
@@ -92,6 +96,7 @@ const demoTemplates: TemplateEntity[] = [
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
isPublic: false,
},
{
id: 'tpl-clickhouse',
@@ -103,6 +108,7 @@ const demoTemplates: TemplateEntity[] = [
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
isPublic: false,
},
{
id: 'tpl-dragonfly',
@@ -114,6 +120,7 @@ const demoTemplates: TemplateEntity[] = [
variablesRaw: '[]',
isOfficial: true,
ownerId: null,
isPublic: false,
},
];
@@ -445,6 +452,7 @@ export function TemplatesPage() {
const [editorOpen, setEditorOpen] = useState(false);
const [editorJson, setEditorJson] = useState(EMPTY_TEMPLATE_JSON);
const [editingTemplate, setEditingTemplate] = useState<TemplateEntity | null>(null);
const [editorIsPublic, setEditorIsPublic] = useState(false);
const [editorError, setEditorError] = useState<string | null>(null);
const [categoryFilter, setCategoryFilter] = useState('');
@@ -655,6 +663,7 @@ export function TemplatesPage() {
const openCreateEditor = () => {
setEditingTemplate(null);
setEditorJson(EMPTY_TEMPLATE_JSON);
setEditorIsPublic(false);
setEditorError(null);
setEditorOpen(true);
};
@@ -687,6 +696,7 @@ export function TemplatesPage() {
2,
),
);
setEditorIsPublic(template.isPublic);
setEditorError(null);
setEditorOpen(true);
};
@@ -711,6 +721,7 @@ export function TemplatesPage() {
logo: body.logo ?? '',
config: body.config as Record<string, unknown>,
variables: Array.isArray(body.variables) ? body.variables : [],
isPublic: editorIsPublic,
});
};
@@ -880,6 +891,11 @@ export function TemplatesPage() {
Mine
</span>
) : null}
{!template.isOfficial && template.isPublic ? (
<span className="px-1.5 py-0.5 rounded bg-[var(--info-soft)] text-[var(--info)] text-[10px] font-semibold">
Public
</span>
) : null}
</div>
<div className="flex items-center gap-2 mt-1">
<span
@@ -955,6 +971,11 @@ export function TemplatesPage() {
Official
</span>
)}
{!selectedDetail.template.isOfficial && selectedDetail.template.isPublic && (
<span className="px-2 py-0.5 rounded-full bg-[var(--info-soft)] text-[var(--info)] text-xs font-medium">
Public
</span>
)}
</div>
<p className="text-sm text-[var(--text-secondary)] mt-1">{selectedDetail.template.description}</p>
</div>
@@ -1230,9 +1251,15 @@ export function TemplatesPage() {
</div>
) : null}
<div className="flex items-center justify-between border-t border-[var(--border-subtle)] px-5 py-3.5">
<p className="text-[11px] text-[var(--text-tertiary)]">
Saved as a user template — only you (and admins) can edit or delete it.
</p>
<label className="inline-flex cursor-pointer items-center gap-2 text-[11px] text-[var(--text-secondary)]">
<input
type="checkbox"
checked={editorIsPublic}
onChange={(e) => setEditorIsPublic(e.target.checked)}
className="h-3.5 w-3.5 accent-[var(--accent-primary)]"
/>
List in the public catalog - anyone browsing this instance can see and deploy it.
</label>
<div className="flex items-center gap-2">
<button
type="button"