Skip to main content

App Admin Pages

App Admin Pages is the Shuuka term for custom owner workflow pages.

Use them when App Global Settings and App Instance Settings are not enough. Good use cases:

  • Analytics dashboards
  • Entry and participant management
  • Winner draw tools
  • Export screens
  • Moderation tools
  • Status boards

When To Use What

NeedTool
Shared app-wide settingsApp Global Settings (settings.json)
Per-card configurationApp Instance Settings (groups + placeholders)
Visitor submissionsinputs.json
Custom owner dashboard or workflowApp Admin Pages (admin_pages in manifest.json)

Start with App Global Settings or App Instance Settings. Add App Admin Pages only for workflow-heavy screens that cannot be expressed as settings fields.


admin_pages Manifest Contract

Declare custom admin tabs in manifest.json:

{
"admin_pages": [
{
"key": "overview",
"title": "Overview",
"entry": "admin/overview.html",
"icon": "chart-bar"
},
{
"key": "participants",
"title": "Participants",
"entry": "admin/participants.html",
"icon": "users"
},
{
"key": "draw",
"title": "Draw Winner",
"entry": "admin/draw.html",
"icon": "trophy"
}
]
}

Each entry becomes an owner-facing tab in the app administration area.

KeyRequiredMeaning
keyYesStable tab identifier
title or title_keyYesVisible tab label
entryYesHTML file inside the uploaded bundle
iconNoOptional dashboard icon

What Shuuka Injects

Every admin page receives:

  • window.__shuukaCtx — runtime context (see App Platform Values)
  • window.ShuukaApi / window.shkApi — pre-built admin API helper
  • window.__shuukaCtx.accessToken — bearer token for authenticated admin requests

Admin pages run inside the owner's authenticated dashboard session. The shkApi.admin.* helpers add the required authentication headers automatically.


Admin Page Structure

Admin pages are full HTML documents — not fragments.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>App — Admin</title>
</head>
<body>
<!-- your admin UI -->

<script>
(function () {
var ctx = window.__shuukaCtx || {};
var shkApi = ctx.shkApi || window.ShuukaApi;

// ... your code
})();
</script>
</body>
</html>

Always scope your code inside an IIFE to avoid naming conflicts with the platform's injected globals.


Example 1: Overview Dashboard

Displays participant count, campaign status, and current winner.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Overview</title>
<style>
body { font-family: -apple-system, sans-serif; margin: 0; padding: 20px; color: #111; background: #f9fafb; }
.stats { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
.stat { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 16px 24px; min-width: 140px; }
.stat-value { font-size: 2rem; font-weight: 700; line-height: 1; }
.stat-label { font-size: 0.8rem; color: #6b7280; margin-top: 4px; }
.section { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 20px; }
h2 { font-size: 1rem; margin: 0 0 12px; }
.winner { font-size: 1.25rem; font-weight: 600; }
.status { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 0.8rem; }
.status--active { background: #d1fae5; color: #065f46; }
.status--inactive { background: #f3f4f6; color: #6b7280; }
</style>
</head>
<body>
<div class="stats">
<div class="stat">
<div class="stat-value" id="total-count"></div>
<div class="stat-label">Total entries</div>
</div>
<div class="stat">
<div class="stat-value" id="status-badge"></div>
<div class="stat-label">Status</div>
</div>
</div>

<div class="section">
<h2>Current Winner</h2>
<div id="winner-block">Loading…</div>
</div>

<script>
(function () {
var ctx = window.__shuukaCtx || {};
var shkApi = ctx.shkApi || window.ShuukaApi;

// Load total entry count
shkApi.admin.entries.list({ formId: 'giveaway', perPage: 1 })
.then(function (r) { return r.json(); })
.then(function (d) {
document.getElementById('total-count').textContent = d.meta && d.meta.total || 0;
})
.catch(function () {
document.getElementById('total-count').textContent = 'Error';
});

// Load campaign status and winner from storage
Promise.all([
shkApi.admin.storage.getValue('campaign_status'),
shkApi.admin.storage.getValue('winner')
])
.then(function (results) {
var status = results[0] && results[0].value || 'inactive';
var winner = results[1] && results[1].value;

var statusEl = document.getElementById('status-badge');
statusEl.innerHTML = '<span class="status status--' + status + '">' + status + '</span>';

var winnerEl = document.getElementById('winner-block');
if (winner && winner.confirmed) {
winnerEl.innerHTML = '<p class="winner">🏆 ' + esc(winner.display_nickname) + '</p>'
+ '<p style="color:#6b7280;font-size:0.85rem">Drawn on ' + esc(winner.confirmed_at) + '</p>';
} else {
winnerEl.textContent = 'No winner drawn yet.';
}
});

function esc(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
})();
</script>
</body>
</html>

Example 2: Participants List with Pagination and Export

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Participants</title>
<style>
body { font-family: -apple-system, sans-serif; margin: 0; padding: 20px; color: #111; background: #f9fafb; }
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
h1 { font-size: 1.1rem; margin: 0; }
button { padding: 8px 16px; border-radius: 8px; border: 1px solid #d1d5db; background: #fff; cursor: pointer; font-size: 0.875rem; }
button:hover { background: #f3f4f6; }
.btn-primary { background: #111; color: #fff; border-color: #111; }
.btn-primary:hover { background: #374151; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 10px; overflow: hidden; border: 1px solid #e5e7eb; }
th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #f3f4f6; font-size: 0.875rem; }
th { background: #f9fafb; font-weight: 600; color: #374151; }
tr:last-child td { border-bottom: none; }
.pagination { display: flex; gap: 8px; align-items: center; margin-top: 16px; justify-content: flex-end; }
.pagination span { font-size: 0.875rem; color: #6b7280; }
#empty { text-align: center; padding: 40px; color: #9ca3af; display: none; }
</style>
</head>
<body>

<div class="toolbar">
<h1>Participants <span id="total-label" style="color:#6b7280;font-weight:400"></span></h1>
<div style="display:flex;gap:8px">
<button id="refresh-btn">Refresh</button>
<button id="export-btn" class="btn-primary">Export CSV</button>
</div>
</div>

<table id="participants-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Email</th>
<th>Entry date</th>
</tr>
</thead>
<tbody id="rows">
<tr><td colspan="4" style="text-align:center;color:#9ca3af;padding:32px">Loading…</td></tr>
</tbody>
</table>

<p id="empty">No entries yet.</p>

<div class="pagination">
<button id="prev-btn" disabled>← Prev</button>
<span id="page-label">Page 1</span>
<button id="next-btn" disabled>Next →</button>
</div>

<script>
(function () {
var ctx = window.__shuukaCtx || {};
var shkApi = ctx.shkApi || window.ShuukaApi;

var currentPage = 1;
var lastPage = 1;
var PER_PAGE = 20;

function esc(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

function formatDate(iso) {
try { return new Date(iso).toLocaleString(); } catch (e) { return iso; }
}

function loadPage(page) {
document.getElementById('rows').innerHTML =
'<tr><td colspan="4" style="text-align:center;color:#9ca3af;padding:24px">Loading…</td></tr>';

shkApi.admin.entries.list({ formId: 'giveaway', perPage: PER_PAGE, page: page })
.then(function (r) { return r.json(); })
.then(function (d) {
var data = d.data || [];
var meta = d.meta || {};

lastPage = meta.last_page || 1;
currentPage = meta.current_page || page;

// Update summary
document.getElementById('total-label').textContent = '(' + (meta.total || 0) + ')';
document.getElementById('page-label').textContent = 'Page ' + currentPage + ' / ' + lastPage;
document.getElementById('prev-btn').disabled = currentPage <= 1;
document.getElementById('next-btn').disabled = currentPage >= lastPage;

if (data.length === 0) {
document.getElementById('participants-table').style.display = 'none';
document.getElementById('empty').style.display = 'block';
return;
}

document.getElementById('participants-table').style.display = '';
document.getElementById('empty').style.display = 'none';

var offset = (currentPage - 1) * PER_PAGE;
var rows = data.map(function (entry, i) {
var vals = entry.input_values || {};
return '<tr>'
+ '<td>' + (offset + i + 1) + '</td>'
+ '<td>' + esc(vals.full_name || entry.display_value) + '</td>'
+ '<td>' + esc(vals.email) + '</td>'
+ '<td>' + esc(formatDate(entry.entry_date)) + '</td>'
+ '</tr>';
});

document.getElementById('rows').innerHTML = rows.join('');
})
.catch(function (err) {
document.getElementById('rows').innerHTML =
'<tr><td colspan="4" style="color:red;padding:16px">Failed to load entries.</td></tr>';
console.error(err);
});
}

document.getElementById('prev-btn').addEventListener('click', function () {
if (currentPage > 1) loadPage(currentPage - 1);
});

document.getElementById('next-btn').addEventListener('click', function () {
if (currentPage < lastPage) loadPage(currentPage + 1);
});

document.getElementById('refresh-btn').addEventListener('click', function () {
loadPage(currentPage);
});

document.getElementById('export-btn').addEventListener('click', function () {
window.location.href = shkApi.admin.entries.exportUrl({ formId: 'giveaway' });
});

// Initial load
loadPage(1);
})();
</script>
</body>
</html>

Example 3: Draw Winner

Picks a random participant. Allows re-drawing while excluding previous picks.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Draw Winner</title>
<style>
body { font-family: -apple-system, sans-serif; margin: 0; padding: 20px; color: #111; background: #f9fafb; }
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 24px; max-width: 480px; }
h1 { font-size: 1.1rem; margin: 0 0 20px; }
button { padding: 10px 20px; border-radius: 8px; border: none; background: #111; color: #fff; cursor: pointer; font-size: 0.9rem; font-weight: 600; width: 100%; }
button:hover { background: #374151; }
button:disabled { background: #9ca3af; cursor: not-allowed; }
.result { margin-top: 20px; padding: 16px; background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; display: none; }
.winner-name { font-size: 1.5rem; font-weight: 700; }
.meta { font-size: 0.8rem; color: #6b7280; margin-top: 4px; }
.history { margin-top: 20px; }
.history h2 { font-size: 0.9rem; color: #6b7280; margin-bottom: 8px; }
.history-item { font-size: 0.875rem; padding: 6px 0; border-bottom: 1px solid #f3f4f6; }
.redraw-btn { margin-top: 12px; background: #f3f4f6; color: #374151; border: 1px solid #d1d5db; }
.redraw-btn:hover { background: #e5e7eb; }
.confirm-btn { margin-top: 8px; background: #059669; }
.confirm-btn:hover { background: #047857; }
</style>
</head>
<body>

<div class="card">
<h1>Draw a Winner</h1>

<button id="draw-btn">Draw random winner</button>

<div class="result" id="result">
<div class="winner-name" id="winner-name"></div>
<div class="meta" id="winner-meta"></div>
<button class="confirm-btn" id="confirm-btn" style="margin-top:16px">Confirm this winner</button>
<button class="redraw-btn" id="redraw-btn" style="margin-top:8px">Re-draw (exclude this pick)</button>
</div>

<div class="history" id="history-section" style="display:none">
<h2>Previous draws (excluded from next draw)</h2>
<div id="history-list"></div>
</div>
</div>

<script>
(function () {
var ctx = window.__shuukaCtx || {};
var shkApi = ctx.shkApi || window.ShuukaApi;

var excludedIds = [];
var lastEntryId = null;
var lastNickname = '';

function esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

function showResult(entryId, nickname, pool) {
lastEntryId = entryId;
lastNickname = nickname;
var resultEl = document.getElementById('result');
document.getElementById('winner-name').textContent = '🏆 ' + nickname;
document.getElementById('winner-meta').textContent = 'Pool: ' + pool + ' eligible entries · Entry ID: ' + entryId;
resultEl.style.display = 'block';
}

function addToHistory(entryId, nickname) {
excludedIds.push(entryId);
var listEl = document.getElementById('history-list');
var item = document.createElement('div');
item.className = 'history-item';
item.textContent = '#' + entryId + ' — ' + nickname;
listEl.appendChild(item);
document.getElementById('history-section').style.display = 'block';
}

function doDraw() {
document.getElementById('draw-btn').disabled = true;
document.getElementById('draw-btn').textContent = 'Drawing…';
document.getElementById('result').style.display = 'none';

shkApi.admin.entries.randomSelect({ formId: 'giveaway', excludeIds: excludedIds })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) {
alert('No eligible entries remaining.');
return;
}
showResult(d.entry_id, d.display_value, d.total_pool);
})
.catch(function () {
alert('Failed to draw. Please try again.');
})
.finally(function () {
document.getElementById('draw-btn').disabled = false;
document.getElementById('draw-btn').textContent = 'Draw again';
});
}

document.getElementById('draw-btn').addEventListener('click', doDraw);

document.getElementById('redraw-btn').addEventListener('click', function () {
addToHistory(lastEntryId, lastNickname);
doDraw();
});

document.getElementById('confirm-btn').addEventListener('click', function () {
if (!lastEntryId) return;
shkApi.admin.storage.set(
'winner',
{
display_nickname: lastNickname,
entry_id: lastEntryId,
confirmed: true,
confirmed_at: new Date().toISOString()
},
{ isPublic: true }
)
.then(function () {
alert('Winner confirmed and published!');
document.getElementById('confirm-btn').disabled = true;
document.getElementById('confirm-btn').textContent = '✓ Confirmed';
})
.catch(function () {
alert('Failed to save winner. Please try again.');
});
});

})();
</script>
</body>
</html>

Building Admin Pages: Key Rules

  1. Always use an IIFE — wrap your code in (function () { ... })() to avoid conflicts with other scripts on the page.
  2. Escape all user-provided output — never insert unescaped data into innerHTML.
  3. Use shkApi.admin.* for all data operations — never hardcode internal API routes.
  4. Handle loading, empty, and error states — the owner sees these pages; make them informative.
  5. Do not replace schema-driven settings — if a field can be expressed in settings.json or settings-global.json, do not build a custom admin page for it.
  6. Read window.__shuukaCtx for all runtime values — see App Platform Values.

Decision Rule

NeedUse
Shared configuration (API key, feature flag)App Global Settings
Per-card configuration (title, colors, layout)App Instance Settings
Visitor submission fieldsinputs.json
Custom owner dashboard or workflowApp Admin Pages