Add microapp platform scaffold

This commit is contained in:
Ruslan Bakiev
2026-03-23 11:47:14 +07:00
parent 3d7e963087
commit d4fc4f66cc
11 changed files with 544 additions and 1 deletions

View File

@@ -0,0 +1,28 @@
export default {
manifestVersion: 1,
name: "PocketBase Deno Dashboard",
slug: "pocketbase-deno-dashboard",
runtime: "deno-gvisor",
entrypoint: "./main.ts",
pocketbase: {
collections: ["dashboard_cards"],
collectionPrefix: "dashboard",
},
permissions: {
allowEnv: [
"PORT",
"POCKETBASE_URL",
"POCKETBASE_SERVICE_EMAIL",
"POCKETBASE_SERVICE_PASSWORD",
"APP_OWNER_TEAM_ID",
"APP_SLUG",
],
allowNet: ["pocketbase.internal"],
allowRead: ["./"],
allowWrite: [],
},
libraries: [
"internal/pocketbase-client",
"internal/dashboard-ui",
],
} as const;

View File

@@ -0,0 +1,17 @@
{
"tasks": {
"dev": "deno run --watch --allow-read=. --allow-env=PORT,POCKETBASE_URL,POCKETBASE_SERVICE_EMAIL,POCKETBASE_SERVICE_PASSWORD,APP_OWNER_TEAM_ID,APP_SLUG --allow-net=127.0.0.1,localhost,pocketbase.internal main.ts",
"check": "deno check main.ts pocketbase/client.ts app.config.ts"
},
"fmt": {
"lineWidth": 100,
"singleQuote": false
},
"lint": {
"rules": {
"tags": [
"recommended"
]
}
}
}

View File

@@ -0,0 +1,162 @@
import appConfig from "./app.config.ts";
import { createPocketBaseClient } from "./pocketbase/client.ts";
const port = Number(Deno.env.get("PORT") || "8080");
const pocketbaseUrl = mustGetEnv("POCKETBASE_URL");
const pocketbaseEmail = mustGetEnv("POCKETBASE_SERVICE_EMAIL");
const pocketbasePassword = mustGetEnv("POCKETBASE_SERVICE_PASSWORD");
const ownerTeamId = mustGetEnv("APP_OWNER_TEAM_ID");
const appSlug = Deno.env.get("APP_SLUG") || appConfig.slug;
const pocketbase = createPocketBaseClient({
baseUrl: pocketbaseUrl,
email: pocketbaseEmail,
password: pocketbasePassword,
});
type DashboardCard = {
id: string;
title: string;
metric: string;
description: string | null;
};
function renderHtml(cards: DashboardCard[]) {
const cardsMarkup = cards.length === 0
? `<p class="empty">No dashboard cards yet.</p>`
: cards.map((card) => `
<article class="card">
<p class="label">${escapeHtml(card.title)}</p>
<strong>${escapeHtml(card.metric)}</strong>
<span>${escapeHtml(card.description || "No description")}</span>
</article>
`).join("");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(appConfig.name)}</title>
<style>
:root {
color-scheme: light;
--bg: #f4efe5;
--panel: #fffaf2;
--ink: #17231f;
--muted: #52615a;
--accent: #0d8a62;
--border: #d7cbb8;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background:
radial-gradient(circle at top left, rgba(13, 138, 98, 0.2), transparent 32%),
linear-gradient(180deg, #f8f3ea 0%, var(--bg) 100%);
color: var(--ink);
}
main {
max-width: 960px;
margin: 0 auto;
padding: 48px 20px 80px;
}
.hero {
padding: 28px;
border: 1px solid var(--border);
border-radius: 28px;
background: rgba(255, 250, 242, 0.92);
backdrop-filter: blur(10px);
}
.eyebrow {
margin: 0 0 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 12px;
color: var(--accent);
}
h1 {
margin: 0;
font-size: clamp(32px, 5vw, 56px);
line-height: 0.96;
}
.meta {
margin-top: 18px;
color: var(--muted);
font-size: 15px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-top: 24px;
}
.card {
border-radius: 20px;
padding: 20px;
border: 1px solid rgba(23, 35, 31, 0.08);
background: var(--panel);
box-shadow: 0 10px 24px rgba(23, 35, 31, 0.08);
}
.label {
margin: 0 0 12px;
color: var(--muted);
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
strong {
display: block;
font-size: 34px;
margin-bottom: 8px;
}
span, .empty {
color: var(--muted);
}
</style>
</head>
<body>
<main>
<section class="hero">
<p class="eyebrow">PocketBase + Deno</p>
<h1>${escapeHtml(appConfig.name)}</h1>
<p class="meta">App slug: ${escapeHtml(appSlug)} • Team: ${escapeHtml(ownerTeamId)}</p>
</section>
<section class="grid">${cardsMarkup}</section>
</main>
</body>
</html>`;
}
function mustGetEnv(name: string) {
const value = Deno.env.get(name);
if (!value) {
throw new Error(`Missing required env: ${name}`);
}
return value;
}
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
async function handleRequest() {
const cards = await pocketbase.listRecords<DashboardCard>({
collectionName: `${appConfig.pocketbase.collectionPrefix}_cards`,
filter: `teamId = "${ownerTeamId}"`,
});
return new Response(renderHtml(cards), {
headers: {
"content-type": "text/html; charset=utf-8",
},
});
}
Deno.serve({ port }, () => handleRequest());

View File

@@ -0,0 +1,73 @@
type PocketBaseClientOptions = {
baseUrl: string;
email: string;
password: string;
};
type PocketBaseListOptions = {
collectionName: string;
filter?: string;
};
type AuthResponse = {
token: string;
};
type RecordListResponse<TRecord> = {
items: TRecord[];
};
export function createPocketBaseClient(options: PocketBaseClientOptions) {
let cachedToken: string | null = null;
async function authenticate() {
if (cachedToken) return cachedToken;
const response = await fetch(
`${options.baseUrl}/api/collections/_superusers/auth-with-password`,
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
identity: options.email,
password: options.password,
}),
},
);
if (!response.ok) {
throw new Error(`PocketBase auth failed with status ${response.status}`);
}
const payload = await response.json() as AuthResponse;
cachedToken = payload.token;
return cachedToken;
}
return {
async listRecords<TRecord>(input: PocketBaseListOptions) {
const token = await authenticate();
const url = new URL(
`${options.baseUrl}/api/collections/${input.collectionName}/records`,
);
if (input.filter) {
url.searchParams.set("filter", input.filter);
}
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`PocketBase list failed with status ${response.status}`);
}
const payload = await response.json() as RecordListResponse<TRecord>;
return payload.items;
},
};
}