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
| Need | Tool |
|---|---|
| Shared app-wide settings | App Global Settings (settings.json) |
| Per-card configuration | App Instance Settings (groups + placeholders) |
| Visitor submissions | inputs.json |
| Custom owner dashboard or workflow | App 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.
| Key | Required | Meaning |
|---|---|---|
key | Yes | Stable tab identifier |
title or title_key | Yes | Visible tab label |
entry | Yes | HTML file inside the uploaded bundle |
icon | No | Optional 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 helperwindow.__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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
})();
</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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
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,'&').replace(/</g,'<').replace(/>/g,'>');
}
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
- Always use an IIFE — wrap your code in
(function () { ... })()to avoid conflicts with other scripts on the page. - Escape all user-provided output — never insert unescaped data into innerHTML.
- Use
shkApi.admin.*for all data operations — never hardcode internal API routes. - Handle loading, empty, and error states — the owner sees these pages; make them informative.
- Do not replace schema-driven settings — if a field can be expressed in
settings.jsonorsettings-global.json, do not build a custom admin page for it. - Read
window.__shuukaCtxfor all runtime values — see App Platform Values.
Decision Rule
| Need | Use |
|---|---|
| Shared configuration (API key, feature flag) | App Global Settings |
| Per-card configuration (title, colors, layout) | App Instance Settings |
| Visitor submission fields | inputs.json |
| Custom owner dashboard or workflow | App Admin Pages |