Playground interstitial (#16)
This commit is contained in:
5
.changeset/fuzzy-coins-march.md
Normal file
5
.changeset/fuzzy-coins-march.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@emdash-cms/cloudflare": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
DIsplay an interstitial when loading playground
|
||||||
@@ -28,6 +28,11 @@
|
|||||||
"zone_name": "emdashcms.com",
|
"zone_name": "emdashcms.com",
|
||||||
"custom_domain": true,
|
"custom_domain": true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"pattern": "www.emdashcms.com",
|
||||||
|
"zone_name": "emdashcms.com",
|
||||||
|
"custom_domain": true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// No R2 -- media uploads are blocked in playground mode
|
// No R2 -- media uploads are blocked in playground mode
|
||||||
// No D1 -- database is inside the Durable Object
|
// No D1 -- database is inside the Durable Object
|
||||||
|
|||||||
260
packages/cloudflare/src/db/playground-loading.ts
Normal file
260
packages/cloudflare/src/db/playground-loading.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* Playground Loading Page
|
||||||
|
*
|
||||||
|
* Rendered when a user first hits /playground. Shows an animated loading state
|
||||||
|
* while the client-side JS calls /_playground/init to create the DO, run
|
||||||
|
* migrations, and apply the seed. Once init completes, redirects to the admin.
|
||||||
|
*
|
||||||
|
* No dependencies -- plain HTML with inline styles and a <script> tag.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function renderPlaygroundLoadingPage(): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>EmDash Playground</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #0a0a0a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-loading {
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-logo {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-logo span {
|
||||||
|
color: #facc15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-spinner-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-top-color: #facc15;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pg-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pg-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-message {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #888;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-steps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-step.active {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-step.done {
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-step-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #333;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-step.active .pg-step-dot {
|
||||||
|
background: #facc15;
|
||||||
|
box-shadow: 0 0 6px rgba(250, 204, 21, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-step.done .pg-step-dot {
|
||||||
|
background: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-error {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-error.visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-error-message {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #f87171;
|
||||||
|
max-width: 360px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-retry-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(250, 204, 21, 0.12);
|
||||||
|
color: #facc15;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pg-retry-btn:hover {
|
||||||
|
background: rgba(250, 204, 21, 0.22);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="pg-loading">
|
||||||
|
<div class="pg-logo">Em<span>Dash</span></div>
|
||||||
|
|
||||||
|
<div class="pg-spinner-wrap">
|
||||||
|
<div class="pg-spinner" id="pg-spinner"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="pg-message" id="pg-message">Creating your playground…</div>
|
||||||
|
<div class="pg-steps" id="pg-steps">
|
||||||
|
<div class="pg-step active" id="step-db">
|
||||||
|
<span class="pg-step-dot"></span>
|
||||||
|
Setting up database
|
||||||
|
</div>
|
||||||
|
<div class="pg-step" id="step-content">
|
||||||
|
<span class="pg-step-dot"></span>
|
||||||
|
Loading demo content
|
||||||
|
</div>
|
||||||
|
<div class="pg-step" id="step-ready">
|
||||||
|
<span class="pg-step-dot"></span>
|
||||||
|
Almost ready
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-error" id="pg-error">
|
||||||
|
<div class="pg-error-message" id="pg-error-message"></div>
|
||||||
|
<button class="pg-retry-btn" id="pg-retry">Try again</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var steps = ["step-db", "step-content", "step-ready"];
|
||||||
|
var currentStep = 0;
|
||||||
|
|
||||||
|
function setStep(index) {
|
||||||
|
for (var i = 0; i < steps.length; i++) {
|
||||||
|
var el = document.getElementById(steps[i]);
|
||||||
|
if (!el) continue;
|
||||||
|
el.className = "pg-step" + (i < index ? " done" : i === index ? " active" : "");
|
||||||
|
}
|
||||||
|
currentStep = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
document.getElementById("pg-spinner").style.display = "none";
|
||||||
|
document.getElementById("pg-message").textContent = "Something went wrong";
|
||||||
|
document.getElementById("pg-steps").style.display = "none";
|
||||||
|
var errorEl = document.getElementById("pg-error");
|
||||||
|
var errorMsg = document.getElementById("pg-error-message");
|
||||||
|
if (errorEl) errorEl.className = "pg-error visible";
|
||||||
|
if (errorMsg) errorMsg.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
setStep(0);
|
||||||
|
document.getElementById("pg-spinner").style.display = "";
|
||||||
|
document.getElementById("pg-message").textContent = "Creating your playground\\u2026";
|
||||||
|
document.getElementById("pg-steps").style.display = "";
|
||||||
|
var errorEl = document.getElementById("pg-error");
|
||||||
|
if (errorEl) errorEl.className = "pg-error";
|
||||||
|
|
||||||
|
// Advance steps on a timer for visual feedback while init runs.
|
||||||
|
// The actual init is a single server call -- these steps are cosmetic.
|
||||||
|
var stepTimer = setTimeout(function() { setStep(1); }, 800);
|
||||||
|
var stepTimer2 = setTimeout(function() { setStep(2); }, 2000);
|
||||||
|
|
||||||
|
fetch("/_playground/init", { method: "POST", credentials: "same-origin" })
|
||||||
|
.then(function(res) {
|
||||||
|
clearTimeout(stepTimer);
|
||||||
|
clearTimeout(stepTimer2);
|
||||||
|
if (!res.ok) {
|
||||||
|
return res.json().then(function(body) {
|
||||||
|
throw new Error(body.error?.message || "Initialization failed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(function() {
|
||||||
|
// Mark all steps done
|
||||||
|
setStep(steps.length);
|
||||||
|
document.getElementById("pg-message").textContent = "Ready!";
|
||||||
|
// Brief pause so the user sees "Ready!" before navigating
|
||||||
|
setTimeout(function() {
|
||||||
|
location.replace("/_emdash/admin");
|
||||||
|
}, 400);
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
clearTimeout(stepTimer);
|
||||||
|
clearTimeout(stepTimer2);
|
||||||
|
showError(err.message || "Failed to create playground. Please try again.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("pg-retry").addEventListener("click", init);
|
||||||
|
|
||||||
|
init();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import type { EmDashPreviewDB } from "./do-class.js";
|
|||||||
import { PreviewDODialect } from "./do-dialect.js";
|
import { PreviewDODialect } from "./do-dialect.js";
|
||||||
import type { PreviewDBStub } from "./do-dialect.js";
|
import type { PreviewDBStub } from "./do-dialect.js";
|
||||||
import { isBlockedInPlayground } from "./do-playground-routes.js";
|
import { isBlockedInPlayground } from "./do-playground-routes.js";
|
||||||
|
import { renderPlaygroundLoadingPage } from "./playground-loading.js";
|
||||||
import { renderPlaygroundToolbar } from "./playground-toolbar.js";
|
import { renderPlaygroundToolbar } from "./playground-toolbar.js";
|
||||||
|
|
||||||
/** Default TTL for playground data (1 hour) */
|
/** Default TTL for playground data (1 hour) */
|
||||||
@@ -244,6 +245,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||||||
const binding = getBindingName();
|
const binding = getBindingName();
|
||||||
|
|
||||||
// --- Entry point: /playground ---
|
// --- Entry point: /playground ---
|
||||||
|
// Show a loading page immediately. The page calls /_playground/init via
|
||||||
|
// fetch to do the actual setup, then redirects to admin when ready.
|
||||||
|
// If the session is already initialized, skip the loading page.
|
||||||
if (url.pathname === "/playground") {
|
if (url.pathname === "/playground") {
|
||||||
let token = cookies.get(COOKIE_NAME)?.value;
|
let token = cookies.get(COOKIE_NAME)?.value;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -256,19 +260,49 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Already initialized? Skip the loading page and go straight to admin.
|
||||||
|
if (initializedSessions.has(token)) {
|
||||||
|
return context.redirect("/_emdash/admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(renderPlaygroundLoadingPage(), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/html; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Init endpoint: called by the loading page ---
|
||||||
|
if (url.pathname === "/_playground/init" && context.request.method === "POST") {
|
||||||
|
const token = cookies.get(COOKIE_NAME)?.value;
|
||||||
|
if (!token) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: { code: "NO_SESSION", message: "No playground session" } },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initializedSessions.has(token)) {
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
const stub = getStub(binding, token);
|
const stub = getStub(binding, token);
|
||||||
const dialect = new PreviewDODialect({ getStub: () => stub });
|
const dialect = new PreviewDODialect({ getStub: () => stub });
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const db = new Kysely<any>({ dialect });
|
const db = new Kysely<any>({ dialect });
|
||||||
|
|
||||||
if (!initializedSessions.has(token)) {
|
try {
|
||||||
await initializePlayground(db, token);
|
await initializePlayground(db, token);
|
||||||
initializedSessions.add(token);
|
initializedSessions.add(token);
|
||||||
const fullStub = getFullStub(binding, token);
|
const fullStub = getFullStub(binding, token);
|
||||||
await fullStub.setTtlAlarm(ttl);
|
await fullStub.setTtlAlarm(ttl);
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Playground initialization failed:", error);
|
||||||
|
return Response.json(
|
||||||
|
{ error: { code: "PLAYGROUND_INIT_ERROR", message: "Failed to initialize playground" } },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return context.redirect("/_emdash/admin");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Reset endpoint ---
|
// --- Reset endpoint ---
|
||||||
|
|||||||
Reference in New Issue
Block a user