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

+24
View File
@@ -0,0 +1,24 @@
// dumptemplates prints the seeded official template catalog as JSON in the same
// shape as GET /api/v1/templates. The landing site (site/templates.json) uses it
// as the offline fallback catalog: `go run ./cmd/dumptemplates > ../../site/templates.json`.
package main
import (
"containr/internal/api"
"encoding/json"
"fmt"
"os"
)
func main() {
out := struct {
Templates []api.ServiceTemplate `json:"templates"`
}{Templates: api.SeedTemplates()}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(out); err != nil {
fmt.Fprintln(os.Stderr, "encode:", err)
os.Exit(1)
}
}
+9 -3
View File
@@ -28,6 +28,7 @@ type ServiceTemplate struct {
Variables string `json:"variables" db:"variables"`
IsOfficial bool `json:"is_official" db:"is_official"`
OwnerID string `json:"owner_id,omitempty" db:"owner_id"`
IsPublic bool `json:"is_public" db:"is_public"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
@@ -383,6 +384,7 @@ type templateWriteRequest struct {
Logo string `json:"logo"`
Config json.RawMessage `json:"config" binding:"required"`
Variables json.RawMessage `json:"variables"`
IsPublic bool `json:"is_public"`
}
var templateCategories = map[string]bool{
@@ -483,6 +485,7 @@ func handleCreateTemplate(c *gin.Context) {
Config: req.Config,
Variables: templateVariablesParam(req.Variables),
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
IsPublic: req.IsPublic,
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"})
return
@@ -529,6 +532,7 @@ func handleUpdateTemplate(c *gin.Context) {
Logo: nullableText(req.Logo),
Config: req.Config,
Variables: templateVariablesParam(req.Variables),
IsPublic: req.IsPublic,
ID: templateID,
OwnerID: uuid.NullUUID{UUID: ownerUUID, Valid: true},
})
@@ -1013,10 +1017,11 @@ func defaultTemplateResources(serviceType string) (cpu, memory string) {
}
}
// templateVisibleTo reports whether the caller may see a template: official
// templates are public, user templates are owner/admin only.
// templateVisibleTo reports whether the caller may see a template: official and
// published (is_public) templates are public, other user templates are
// owner/admin only.
func templateVisibleTo(c *gin.Context, t ServiceTemplate) bool {
if t.IsOfficial {
if t.IsOfficial || t.IsPublic {
return true
}
if contextIsAdmin(c) {
@@ -1047,6 +1052,7 @@ func mapSQLCTemplate(row sqlcdb.ServiceTemplate) ServiceTemplate {
Variables: variables,
IsOfficial: row.IsOfficial.Valid && row.IsOfficial.Bool,
OwnerID: ownerID,
IsPublic: row.IsPublic,
CreatedAt: templateNullTime(row.CreatedAt),
UpdatedAt: templateNullTime(row.UpdatedAt),
}
@@ -207,6 +207,7 @@ type ServiceTemplate struct {
Variables pqtype.NullRawMessage `json:"variables"`
IsOfficial sql.NullBool `json:"is_official"`
OwnerID uuid.NullUUID `json:"owner_id"`
IsPublic bool `json:"is_public"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
@@ -125,7 +125,7 @@ func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServi
}
const createUserTemplate = `-- name: CreateUserTemplate :exec
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id)
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id, is_public)
VALUES (
$1,
$2,
@@ -135,7 +135,8 @@ VALUES (
$6,
$7,
false,
$8
$8,
$9
)
`
@@ -148,6 +149,7 @@ type CreateUserTemplateParams struct {
Config json.RawMessage `json:"config"`
Variables pqtype.NullRawMessage `json:"variables"`
OwnerID uuid.NullUUID `json:"owner_id"`
IsPublic bool `json:"is_public"`
}
func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error {
@@ -160,6 +162,7 @@ func (q *Queries) CreateUserTemplate(ctx context.Context, arg CreateUserTemplate
arg.Config,
arg.Variables,
arg.OwnerID,
arg.IsPublic,
)
return err
}
@@ -198,7 +201,7 @@ func (q *Queries) GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUI
}
const getServiceTemplateByID = `-- name: GetServiceTemplateByID :one
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates
WHERE id = $1
`
@@ -216,6 +219,7 @@ func (q *Queries) GetServiceTemplateByID(ctx context.Context, id string) (Servic
&i.Variables,
&i.IsOfficial,
&i.OwnerID,
&i.IsPublic,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -223,10 +227,10 @@ func (q *Queries) GetServiceTemplateByID(ctx context.Context, id string) (Servic
}
const listServiceTemplatesByCategoryForUser = `-- name: ListServiceTemplatesByCategoryForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates
WHERE category = $1
AND (is_official = true OR owner_id = $2)
AND (is_official = true OR is_public = true OR owner_id = $2)
ORDER BY is_official DESC, name ASC
`
@@ -254,6 +258,7 @@ func (q *Queries) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg
&i.Variables,
&i.IsOfficial,
&i.OwnerID,
&i.IsPublic,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -271,9 +276,9 @@ func (q *Queries) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg
}
const listServiceTemplatesForUser = `-- name: ListServiceTemplatesForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates
WHERE is_official = true OR owner_id = $1
WHERE is_official = true OR is_public = true OR owner_id = $1
ORDER BY is_official DESC, name ASC
`
@@ -296,6 +301,7 @@ func (q *Queries) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.
&i.Variables,
&i.IsOfficial,
&i.OwnerID,
&i.IsPublic,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -320,9 +326,10 @@ SET name = $1,
logo = $4,
config = $5,
variables = $6,
is_public = $7,
updated_at = NOW()
WHERE id = $7
AND owner_id = $8
WHERE id = $8
AND owner_id = $9
AND is_official = false
`
@@ -333,6 +340,7 @@ type UpdateUserTemplateParams struct {
Logo sql.NullString `json:"logo"`
Config json.RawMessage `json:"config"`
Variables pqtype.NullRawMessage `json:"variables"`
IsPublic bool `json:"is_public"`
ID string `json:"id"`
OwnerID uuid.NullUUID `json:"owner_id"`
}
@@ -345,6 +353,7 @@ func (q *Queries) UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplate
arg.Logo,
arg.Config,
arg.Variables,
arg.IsPublic,
arg.ID,
arg.OwnerID,
)
@@ -0,0 +1,11 @@
-- +goose Up
-- Railway-style template publishing: owners can mark a user template public so
-- it appears in the anonymous catalog (GET /api/v1/templates) and on the
-- landing page. Official templates stay official; is_public only matters for
-- user templates.
ALTER TABLE public.service_templates ADD COLUMN IF NOT EXISTS is_public boolean NOT NULL DEFAULT false;
CREATE INDEX IF NOT EXISTS idx_service_templates_public ON public.service_templates(is_public) WHERE is_public;
-- +goose Down
DROP INDEX IF EXISTS public.idx_service_templates_public;
ALTER TABLE public.service_templates DROP COLUMN IF EXISTS is_public;
+9 -7
View File
@@ -1,18 +1,18 @@
-- name: ListServiceTemplatesForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates
WHERE is_official = true OR owner_id = sqlc.narg(owner_id)
WHERE is_official = true OR is_public = true OR owner_id = sqlc.narg(owner_id)
ORDER BY is_official DESC, name ASC;
-- name: ListServiceTemplatesByCategoryForUser :many
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates
WHERE category = sqlc.arg(category)
AND (is_official = true OR owner_id = sqlc.narg(owner_id))
AND (is_official = true OR is_public = true OR owner_id = sqlc.narg(owner_id))
ORDER BY is_official DESC, name ASC;
-- name: GetServiceTemplateByID :one
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, created_at, updated_at
SELECT id, name, description, category, logo, config, variables, is_official, owner_id, is_public, created_at, updated_at
FROM service_templates
WHERE id = $1;
@@ -39,7 +39,7 @@ SET name = EXCLUDED.name,
updated_at = NOW();
-- name: CreateUserTemplate :exec
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id)
INSERT INTO service_templates (id, name, description, category, logo, config, variables, is_official, owner_id, is_public)
VALUES (
sqlc.arg(id),
sqlc.arg(name),
@@ -49,7 +49,8 @@ VALUES (
sqlc.arg(config),
sqlc.narg(variables),
false,
sqlc.arg(owner_id)
sqlc.arg(owner_id),
sqlc.arg(is_public)
);
-- name: UpdateUserTemplate :execrows
@@ -60,6 +61,7 @@ SET name = sqlc.arg(name),
logo = sqlc.narg(logo),
config = sqlc.arg(config),
variables = sqlc.narg(variables),
is_public = sqlc.arg(is_public),
updated_at = NOW()
WHERE id = sqlc.arg(id)
AND owner_id = sqlc.arg(owner_id)
+1
View File
@@ -84,6 +84,7 @@ CREATE TABLE service_templates (
variables JSONB DEFAULT '[]',
is_official BOOLEAN DEFAULT false,
owner_id UUID,
is_public BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
@@ -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"
+4
View File
@@ -7806,6 +7806,8 @@ export interface components {
is_official?: boolean;
/** @description Owning user ID for user-created templates; null for official presets */
owner_id?: string | null;
/** @description Whether a user-created template is published to the public catalog */
is_public?: boolean;
/**
* Format: date-time
* @description Creation timestamp
@@ -7830,6 +7832,8 @@ export interface components {
config: Record<string, never>;
/** @description Configurable variables exposed at deploy time */
variables?: components["schemas"]["TemplateVariable"][];
/** @description Publish this template to the public catalog (visible anonymously) */
is_public?: boolean;
};
TemplateConfig: {
/** @description Service type for created service */
+4
View File
@@ -124,6 +124,7 @@ export type TemplateEntity = {
variablesRaw: string;
isOfficial: boolean;
ownerId: string | null;
isPublic: boolean;
createdAt?: string;
updatedAt?: string;
};
@@ -615,6 +616,7 @@ function normalizeTemplate(template: RawServiceTemplate): TemplateEntity | null
variablesRaw: template.variables ?? '',
isOfficial: Boolean(template.is_official),
ownerId: template.owner_id ?? null,
isPublic: Boolean(template.is_public),
createdAt: template.created_at,
updatedAt: template.updated_at,
};
@@ -1358,6 +1360,7 @@ export type TemplateWriteInput = {
logo?: string;
config: Record<string, unknown>;
variables?: TemplateVariableEntity[];
isPublic?: boolean;
};
function templateWriteBody(input: TemplateWriteInput): string {
@@ -1368,6 +1371,7 @@ function templateWriteBody(input: TemplateWriteInput): string {
logo: input.logo ?? '',
config: input.config,
variables: input.variables ?? [],
is_public: input.isPublic ?? false,
});
}