ericsnfabro's picture
Web Application Builder / Dashboard
08df82b verified
Raw History Blame Contribute Delete
24.9 kB
/* ========================================
Admin Dashboard SPA - No Build Tools
======================================== */
/* Utilities */
const $ = (sel, ctx=document) => ctx.querySelector(sel);
const $$ = (sel, ctx=document) => Array.from(ctx.querySelectorAll(sel));
const DB_KEY = 'admin_dashboard_db_v1';
function uid(prefix='id') {
return `${prefix}_${Math.random().toString(36).slice(2,10)}_${Date.now().toString(36)}`;
}
function formatDate(ts) {
const d = new Date(ts);
return d.toLocaleDateString();
}
function formatDateTime(ts) {
const d = new Date(ts);
return d.toLocaleString();
}
function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
/* Persist DB */
function loadDB() {
const raw = localStorage.getItem(DB_KEY);
if (raw) {
try { return JSON.parse(raw); } catch {}
}
const seeded = seedDB();
saveDB(seeded);
return seeded;
}
function saveDB(db) {
localStorage.setItem(DB_KEY, JSON.stringify(db));
}
/* Seed Demo Data */
function seedDB() {
const now = Date.now();
const categories = ['Movies', 'Series', 'Anime', 'Documentaries', 'Shorts'];
const genres = ['Action','Drama','Comedy','Sci-Fi','Fantasy','Romance','Thriller','Horror','Mystery','Adventure','Animation'];
const studios = ['Studio A','Studio B','Studio C','Studio D','Studio E'];
const producers = ['Producer X','Producer Y','Producer Z','Producer W'];
const types = ['Movies','Series','Anime','Documentaries','Shorts'];
const users = Array.from({length: 18}, (_,i) => ({
id: uid('usr'),
name: `User ${i+1}`,
email: `user${i+1}@example.com`,
role: i % 9 === 0 ? 'Admin' : 'User',
status: i % 11 === 0 ? 'Blocked' : 'Active',
joinedAt: now - (i * 86400000 * 2),
avatar: `https://i.pravatar.cc/100?img=${(i%70)+1}`,
}));
const items = Array.from({length: 72}, (_,i) => {
const cat = categories[i % categories.length];
const type = types[i % types.length];
const g1 = genres[i % genres.length];
const g2 = genres[(i+3) % genres.length];
const studio = studios[i % studios.length];
const producer = producers[i % producers.length];
const year = 2016 + (i % 9);
const views = 100 + (i * 37) % 100000;
const likes = 10 + (i * 7) % 1500;
const status = ['Active','Pending','Archived'][i % 3];
return {
id: uid('itm'),
title: `${cat} Item ${i+1}`,
description: `This is a ${cat.toLowerCase()} item description. Lorem ipsum dolor sit amet, consectetur adipiscing elit.`,
category: cat,
type,
year,
genres: [g1, g2],
studio,
producer,
themes: ['Trending','Featured','Top Pick'][i%3],
views,
likes,
image: `https://picsum.photos/seed/item${i+1}/600/400`,
status,
createdAt: now - (i * 86400000),
};
});
const polls = Array.from({length: 12}, (_,i) => ({
id: uid('pol'),
title: `Poll Question ${i+1}`,
category: categories[i % categories.length],
year: 2020 + (i % 5),
type: types[i % types.length],
views: 200 + (i*19)%2000,
totalVotes: 50 + (i*13)%800,
status: ['Winner','Ongoing','Canceled'][i % 3],
createdAt: now - (i * 86400000 * 4),
}));
const attributes = {
categories: categories.map((c,i)=>({id: uid('cat'), name: c, count: items.filter(it=>it.category===c).length})),
genres: genres.map((g,i)=>({id: uid('gen'), name: g, count: items.flatMap(it=>it.genres).filter(x=>x===g).length})),
studios: studios.map((s,i)=>({id: uid('stu'), name: s, count: items.filter(it=>it.studio===s).length})),
producers: producers.map((p,i)=>({id: uid('pro'), name: p, count: items.filter(it=>it.producer===p).length})),
};
return { users, items, polls, attributes };
}
/* Router */
const Router = {
routes: {},
init() {
window.addEventListener('hashchange', Router.resolve);
Router.resolve();
},
add(path, handler) {
Router.routes[path] = handler;
},
resolve() {
const hash = location.hash || '#/admin/dashboard';
const path = hash.replace('#','');
if (Router.routes[path]) Router.routes[path]();
else if (path.startsWith('/admin')) Router.routes['/admin/dashboard']();
}
};
/* Charts (destroy-safe) */
const Charts = {
registry: new Map(),
make(ctx, cfg) {
if (Charts.registry.has(ctx)) {
Charts.registry.get(ctx).destroy();
Charts.registry.delete(ctx);
}
const chart = new Chart(ctx, cfg);
Charts.registry.set(ctx, chart);
return chart;
},
destroyAll() {
for (const [ctx, c] of Charts.registry.entries()) {
try { c.destroy(); } catch {}
}
Charts.registry.clear();
}
};
/* Global Modal System */
const Modal = {
root: null,
init() {
Modal.root = $('#modalRoot');
},
open({title='Modal', body='', footer='', size='max-w-2xl', closable=true}) {
Modal.close();
const backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
backdrop.innerHTML = `
<div class="modal ${size}">
<div class="modal-header">
<div class="font-semibold text-lg">${title}</div>
${closable ? '<button id="modalClose" class="p-1 hover:bg-gray-100 rounded-lg"><i data-feather="x"></i></button>' : ''}
</div>
<div class="modal-body">${body}</div>
${footer ? `<div class="modal-footer">${footer}</div>` : ''}
</div>
`;
Modal.root.appendChild(backdrop);
feather.replace();
if (closable) {
$('#modalClose', backdrop).onclick = Modal.close;
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) Modal.close();
});
}
return backdrop;
},
close() {
const active = $('.modal-backdrop');
if (active) active.remove();
}
};
/* Toast */
function toast(msg, type='info') {
const t = document.createElement('div');
t.className = `fixed bottom-4 right-4 z-[70] px-4 py-3 rounded-lg shadow-lg text-white ${type==='success'?'bg-green-600':type==='error'?'bg-red-600':'bg-gray-800'}`;
t.textContent = msg;
document.body.appendChild(t);
setTimeout(()=> t.remove(), 2400);
}
/* DB state */
let db = loadDB();
/* Header interactions */
(function initHeader() {
const profileBtn = $('#profileBtn');
const dropdown = $('#profileDropdown');
profileBtn?.addEventListener('click', () => dropdown.classList.toggle('hidden'));
document.addEventListener('click', (e) => {
if (!dropdown.contains(e.target) && !profileBtn.contains(e.target)) dropdown.classList.add('hidden');
});
dropdown?.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const action = btn.dataset.action;
dropdown.classList.add('hidden');
if (action === 'logout') {
location.hash = '#/login';
toast('Logged out. Redirecting...', 'info');
} else if (action === 'viewProfile') {
showProfileModal('view');
} else if (action === 'editProfile') {
showProfileModal('edit');
} else if (action === 'support') {
window.open('https://example.com/support', '_blank');
}
});
$('#openCreateItem')?.addEventListener('click', () => openItemModal());
})();
/* Profile Modal */
function showProfileModal(mode='view') {
const admin = { name: 'Administrator', email: 'admin@example.com', avatar: 'https://i.pravatar.cc/200?img=5' };
const body = `
<div class="flex items-center gap-4">
<img src="${admin.avatar}" class="w-16 h-16 rounded-full object-cover ring-2 ring-primary/20" alt="Profile"/>
<div>
<div class="text-lg font-semibold">${admin.name}</div>
<div class="text-sm text-gray-500">${admin.email}</div>
<div class="mt-2 flex gap-2">
${mode==='view' ? '<span class="badge badge-blue">Admin</span>' : ''}
</div>
</div>
</div>
${mode==='edit' ? `
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="text-sm text-gray-600">Full Name</label>
<input class="w-full mt-1 px-3 py-2 border rounded-lg" value="${admin.name}">
</div>
<div>
<label class="text-sm text-gray-600">Email</label>
<input class="w-full mt-1 px-3 py-2 border rounded-lg" value="${admin.email}">
</div>
</div>` : `
<div class="text-sm text-gray-700">
You can update your profile details or contact support for help.
</div>
`}
`;
const footer = `
<button class="btn" onclick="Modal.close()">Close</button>
${mode==='edit' ? '<button class="btn btn-primary" onclick="saveProfile()">Save Changes</button>' : ''}
`;
Modal.open({ title: 'Profile', body, footer });
}
function saveProfile() {
Modal.close();
toast('Profile updated', 'success');
}
/* Item CRUD */
function openItemModal(item=null, options={}) {
const isEdit = !!item;
const title = isEdit ? 'Edit Item' : 'Create Item';
const body = `
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="text-sm text-gray-600">Title</label>
<input id="fTitle" class="w-full mt-1 px-3 py-2 border rounded-lg" placeholder="Item title" value="${item?.title||''}">
</div>
<div>
<label class="text-sm text-gray-600">Year</label>
<input id="fYear" type="number" min="1900" max="2100" class="w-full mt-1 px-3 py-2 border rounded-lg" value="${item?.year||new Date().getFullYear()}">
</div>
<div>
<label class="text-sm text-gray-600">Category</label>
<select id="fCategory" class="w-full mt-1 px-3 py-2 border rounded-lg">
${['Movies','Series','Anime','Documentaries','Shorts'].map(c=>`<option ${item?.category===c?'selected':''}>${c}</option>`).join('')}
</select>
</div>
<div>
<label class="text-sm text-gray-600">Type</label>
<select id="fType" class="w-full mt-1 px-3 py-2 border rounded-lg">
${['Movies','Series','Anime','Documentaries','Shorts'].map(c=>`<option ${item?.type===c?'selected':''}>${c}</option>`).join('')}
</select>
</div>
<div>
<label class="text-sm text-gray-600">Genres</label>
<select id="fGenres" class="w-full mt-1 px-3 py-2 border rounded-lg" multiple>
${['Action','Drama','Comedy','Sci-Fi','Fantasy','Romance','Thriller','Horror','Mystery','Adventure','Animation'].map(g=>`<option ${item?.genres?.includes(g)?'selected':''}>${g}</option>`).join('')}
</select>
</div>
<div>
<label class="text-sm text-gray-600">Studio</label>
<input id="fStudio" class="w-full mt-1 px-3 py-2 border rounded-lg" placeholder="Studio" value="${item?.studio||''}">
</div>
<div>
<label class="text-sm text-gray-600">Producer</label>
<input id="fProducer" class="w-full mt-1 px-3 py-2 border rounded-lg" placeholder="Producer" value="${item?.producer||''}">
</div>
<div>
<label class="text-sm text-gray-600">Themes</label>
<input id="fThemes" class="w-full mt-1 px-3 py-2 border rounded-lg" placeholder="Comma separated e.g., Trending,Top Pick" value="${item?.themes||''}">
</div>
</div>
<div>
<label class="text-sm text-gray-600">Description</label>
<div id="fDesc" class="mt-1 bg-white border rounded-lg min-h-[140px]"></div>
</div>
<div>
<label class="text-sm text-gray-600">Image</label>
<div class="mt-1 flex items-center gap-4">
<img id="fImgPreview" class="w-40 h-28 object-cover rounded-lg border" src="${item?.image||'https://picsum.photos/seed/placeholder/600/400'}" alt="">
<input id="fImage" type="file" accept="image/*" class="block w-full text-sm">
</div>
</div>
`;
const footer = `
<button class="btn" onclick="Modal.close()">Cancel</button>
<button class="btn btn-primary" onclick="${isEdit?'submitEditItem('+JSON.stringify(item?.id||'')+')':'submitCreateItem()' }">${isEdit?'Update Item':'Create Item'}</button>
`;
const m = Modal.open({ title, body, footer, size: 'max-w-3xl' });
setTimeout(()=> {
if ($('#fDesc')) {
const q = new Quill('#fDesc', { theme: 'snow', placeholder: 'Write a description...' });
if (item?.description) q.setContents([]);
q.root.innerHTML = item?.description || '';
m._quill = q;
}
}, 0);
$('#fImage')?.addEventListener('change', (e)=> {
const f = e.target.files[0];
if (f) {
const url = URL.createObjectURL(f);
$('#fImgPreview').src = url;
}
});
}
function submitCreateItem() {
const item = collectItemForm();
const confirmBody = `
<div class="space-y-2">
<div><span class="font-semibold">Title:</span> ${item.title}</div>
<div><span class="font-semibold">Category:</span> ${item.category} | <span class="font-semibold">Type:</span> ${item.type}</div>
<div><span class="font-semibold">Year:</span> ${item.year}</div>
<div><span class="font-semibold">Genres:</span> ${item.genres.join(', ')}</div>
<div><span class="font-semibold">Studio:</span> ${item.studio} | <span class="font-semibold">Producer:</span> ${item.producer}</div>
<div><span class="font-semibold">Themes:</span> ${item.themes}</div>
</div>
`;
Modal.open({
title: 'Confirm Item',
body: confirmBody,
footer: `<button class="btn" onclick="Modal.close()">No, I will update</button><button class="btn btn-primary" onclick="confirmCreateItem()">Yes, Correct</button>`
});
Modal._pendingItem = item;
}
function confirmCreateItem() {
const item = Modal._pendingItem;
Modal._pendingItem = null;
Modal.close(); // close confirmation
db.items.unshift({
id: uid('itm'),
title: item.title,
description: item.description,
category: item.category,
type: item.type,
year: +item.year,
genres: item.genres,
studio: item.studio,
producer: item.producer,
themes: item.themes,
views: 0, likes: 0,
image: item.image,
status: 'Active',
createdAt: Date.now(),
});
saveDB(db);
toast('Item created', 'success');
Router.resolve();
}
function collectItemForm() {
const title = $('#fTitle').value.trim();
const year = $('#fYear').value;
const category = $('#fCategory').value;
const type = $('#fType').value;
const genres = Array.from($('#fGenres').selectedOptions).map(o=>o.value);
const studio = $('#fStudio').value.trim();
const producer = $('#fProducer').value.trim();
const themes = $('#fThemes').value.trim();
const desc = (Modal.open.lastModal?._quill?.getContents ? Modal.open.lastModal?._quill.root.innerHTML : ($('#fDesc')?.innerHTML || ''));
let image = db.items.find(x=>x.id===Modal.open.currentItemId)?.image || 'https://picsum.photos/seed/newitem/600/400';
const fImg = $('#fImage').files[0];
if (fImg) image = URL.createObjectURL(fImg);
return { title, year, category, type, genres, studio, producer, themes, description: desc, image };
}
function submitEditItem(id) {
const item = collectItemForm();
const confirmBody = `
<div class="space-y-2">
<div><span class="font-semibold">Title:</span> ${item.title}</div>
<div><span class="font-semibold">Category:</span> ${item.category} | <span class="font-semibold">Type:</span> ${item.type}</div>
<div><span class="font-semibold">Year:</span> ${item.year}</div>
<div><span class="font-semibold">Genres:</span> ${item.genres.join(', ')}</div>
<div><span class="font-semibold">Studio:</span> ${item.studio} | <span class="font-semibold">Producer:</span> ${item.producer}</div>
<div><span class="font-semibold">Themes:</span> ${item.themes}</div>
</div>
`;
Modal.open({
title: 'Confirm Changes',
body: confirmBody,
footer: `<button class="btn" onclick="Modal.close()">No, I will update</button><button class="btn btn-primary" onclick="confirmEditItem('${id}')">Yes, Correct</button>`
});
Modal._pendingItem = item;
}
function confirmEditItem(id) {
const item = Modal._pendingItem;
Modal._pendingItem = null;
Modal.close();
const idx = db.items.findIndex(x=>x.id===id);
if (idx >= 0) {
const prev = db.items[idx];
db.items[idx] = { ...prev,
title: item.title,
description: item.description,
category: item.category,
type: item.type,
year: +item.year,
genres: item.genres,
studio: item.studio,
producer: item.producer,
themes: item.themes,
image: item.image
};
saveDB(db);
toast('Item updated', 'success');
Router.resolve();
}
}
function previewItem(item) {
const body = `
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<img src="${item.image}" class="w-full h-48 object-cover rounded-lg border" alt="">
<div>
<div class="text-xl font-semibold">${item.title}</div>
<div class="text-sm text-gray-500 mb-2">${item.category} • ${item.type} • ${item.year}</div>
<div class="flex gap-2 flex-wrap mb-2">${item.genres.map(g=>`<span class="chip">${g}</span>`).join('')}</div>
<div class="text-sm"><span class="font-semibold">Studio:</span> ${item.studio} &nbsp; <span class="font-semibold">Producer:</span> ${item.producer}</div>
<div class="text-sm"><span class="font-semibold">Themes:</span> ${item.themes}</div>
<div class="mt-3 flex gap-3 text-sm text-gray-600">
<span><i data-feather="eye" class="w-4 h-4 inline"></i> ${item.views}</span>
<span><i data-feather="thumbs-up" class="w-4 h-4 inline"></i> ${item.likes}</span>
<span class="badge badge-blue">${item.status}</span>
</div>
</div>
</div>
<div class="mt-3">
<div class="font-semibold mb-1">Description</div>
<div class="prose max-w-none">${item.description||'<em>No description</em>'}</div>
</div>
`;
const footer = `
<button class="btn" onclick="Modal.close()">Close</button>
<button class="btn btn-primary" onclick="Modal.close(); openItemModal(db.items.find(x=>x.id==='${item.id}'), {edit:true})">Edit</button>
`;
Modal.open({ title: 'Preview Item', body, footer, size: 'max-w-3xl' });
feather.replace();
}
function deleteItem(item) {
Modal.open({
title: 'Delete Item',
body: `<div>Are you sure you want to delete <span class="font-semibold">${item.title}</span>? This action cannot be undone.</div>`,
footer: `<button class="btn" onclick="Modal.close()">Cancel</button><button class="btn btn-danger" onclick="confirmDeleteItem('${item.id}')">Delete</button>`
});
}
function confirmDeleteItem(id) {
const idx = db.items.findIndex(x=>x.id===id);
if (idx >= 0) {
db.items.splice(idx, 1);
saveDB(db);
Modal.close();
toast('Item deleted', 'success');
Router.resolve();
}
}
/* Items Table Component */
function renderItemsTable({container, items, pageSize=10, showActions=true, showStatus=true, onPage=()=>{}}) {
const state = { page: 1, search: '', filterCat: 'All', filterYear: 'All', filterType: 'All', sortBy: 'dateCreated', sortDir: 'desc' };
function getFilteredSorted() {
let arr = [...items];
if (state.search) {
const q = state.search.toLowerCase();
arr = arr.filter(x => x.title.toLowerCase().includes(q));
}
if (state.filterCat !== 'All') arr = arr.filter(x => x.category===state.filterCat);
if (state.filterYear !== 'All') arr = arr.filter(x => String(x.year)===state.filterYear);
if (state.filterType !== 'All') arr = arr.filter(x => x.type===state.filterType);
arr.sort((a,b)=> {
const dir = state.sortDir === 'asc' ? 1 : -1;
if (state.sortBy==='title') return a.title.localeCompare(b.title) * dir;
if (state.sortBy==='category') return a.category.localeCompare(b.category) * dir;
if (state.sortBy==='year') return (a.year - b.year) * dir;
if (state.sortBy==='type') return a.type.localeCompare(b.type) * dir;
if (state.sortBy==='views') return (a.views - b.views) * dir;
if (state.sortBy==='likes') return (a.likes - b.likes) * dir;
if (state.sortBy==='dateCreated') return (a.createdAt - b.createdAt) * dir;
return 0;
});
return arr;
}
function render() {
const wrap = typeof container === 'string' ? $(container) : container;
const filtered = getFilteredSorted();
const total = filtered.length;
const pages = Math.max(1, Math.ceil(total / pageSize));
state.page = clamp(state.page, 1, pages);
const start = (state.page-1)*pageSize;
const pageItems = filtered.slice(start, start+pageSize);
const years = Array.from(new Set(items.map(x=>x.year))).sort((a,b)=>b-a);
const categories = Array.from(new Set(items.map(x=>x.category))).sort();
const types = Array.from(new Set(items.map(x=>x.type))).sort();
wrap.innerHTML = `
<div class="card p-4 mb-4">
<div class="flex flex-col md:flex-row md:items-center gap-3 justify-between">
<div class="flex items-center gap-2 w-full md:w-auto">
<div class="relative w-full md:w-80">
<i data-feather="search" class="w-4 h-4 absolute left-3 top-3 text-gray-400"></i>
<input id="itemsSearch" class="pl-10 pr-3 py-2 w-full border rounded-lg" placeholder="Search by Item Name" value="${state.search}">
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<select id="fCat" class="px-3 py-2 border rounded-lg">
<option>All</option>
${categories.map(c=>`<option ${state.filterCat===c?'selected':''}>${c}</option>`).join('')}
</select>
<select id="fYear" class="px-3 py-2 border rounded-lg">
<option>All</option>
${years.map(y=>`<option ${state.filterYear===String(y)?'selected':''}>${y}</option>`).join('')}
</select>
<select id="fType" class="px-3 py-2 border rounded-lg">
<option>All</option>
${types.map(t=>`<option ${state.filterType===t?'selected':''}>${t}</option>`).join('')}
</select>
</div>
</div>
</div>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th data-sort="title">Title</th>
<th data-sort="category">Category</th>
<th data-sort="year">Year</th>
<th data-sort="type">Type</th>
${showStatus?'<th>Status</th>':''}
<th data-sort="views">Views</th>
<th data-sort="likes">Likes</th>
<th data-sort="dateCreated">Date Created</th>
${showActions?'<th>Actions</th>':''}
</tr>
</thead>
<tbody>
${pageItems.map(it=>`
<tr>
<td class="font-medium">${it.title}</td>
<td>${it.category}</td>
<td>${it.year}</td>
<td>${it.type}</td>
${showStatus?`<td><span class="badge badge-${it.status==='Active'?'green':it.status==='Pending'?'yellow':'gray'}">${it.status}</span></td>`:''}
<td>${it.views}</td>
<td>${it.likes}</td>
<td>${formatDate(it.createdAt)}</td>
${showActions?`
<td>
<div class="flex gap-2">
<button class="btn" onclick="previewItem(db.items.find(x=>x.id==='${it.id}'))"><i data-feather="eye" class="w-4 h-4"></i> Preview</button>
<button class="btn" onclick="openItemModal(db.items.find(x=>x.id==='${it.id}'), {edit:true})"><i data-feather="edit-2" class="w-4 h-4"></i> Edit</button>
<button class="btn btn-danger" onclick="deleteItem(db.items.find(x=>x.id==='${it.id}'))"><i data-feather="trash-2" class="w-4 h-4"></i> Delete</button>
</div>
</td>`:''}
</tr>
`).join('')}
${pageItems.length===0 ? `<tr><td colspan="${8+(showActions?1:0)}" class="text-center text-gray-500 py-6">No items found</td></tr>`:''}
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between">
<div class="text-sm text-gray-600">Showing ${total===0?0:start+1}–${Math.min(start+pageSize,total)} of ${total}</div>
<div class="flex items-center gap-2">
<button class="btn" ${state.page===1?'disabled':''} onclick="gotoPage(${state.page-1})"><i data-feather="chevron-left"></i></button>
<span class="px-3 py-1 border rounded-lg">${state.page} / ${Math.max(1,Math.ceil(total/pageSize))}</span>
<button class="btn" ${state.page===Math.ceil(total/pageSize)?'disabled':''} onclick="gotoPage(${state.page+1})"><i data-feather="chevron-right"></i></button>
</div>
</div>
`;
feather.replace();
// events
$('#itemsSearch', wrap).addEventListener('input', (e)=>{ state.search = e.target.value; state.page = 1; render(); });
$('#fCat', wrap