Apps
The File Manager, the Calendar, the Task board, Messages and Support Tickets are complete screens with their interactions already written. In the template they work in the browser only — this page shows how to feed them your data and save what users change.
How to connect an app
Each app follows the same three steps:
- Render the data on the server with the same markup the template uses — the items in the HTML, or a JSON block for the calendar and messages.
- Listen for events where the app fires one —
odin:task-move,odin:task-change,odin:message-*. - Replace a method where the app only changes the page — upload, delete, create. Each app's class is available as
Odin.FileManager,Odin.TaskCalendar,Odin.TaskBoard,Odin.MessagesandOdin.TicketDesk, so your own script can swap in a version that calls your server first.
Put that code in your own file, loaded after the app's script:
<script src="js/app.js"></script>
<script src="js/file-manager.js"></script>
<script src="js/my-file-manager.js"></script> <!-- your server calls -->
Your server will usually expect a CSRF token on POST, PATCH and DELETE requests. The examples below read it from a meta tag — <meta name="csrf-token" content="…"> — through this small helper:
const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
function api(url, method, data) {
const isForm = data instanceof FormData;
return fetch(url, {
method,
headers: { 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json', ...(isForm ? {} : { 'Content-Type': 'application/json' }) },
body: isForm ? data : JSON.stringify(data)
}).then(res => { if (!res.ok) throw new Error(res.statusText); return res.json(); });
}
The URLs (/admin/media, /admin/tasks) are examples — use your own routes.
File Manager
A three-pane file browser: folders and storage on the left, files in the middle (grid or list, type filters, search, sort, stars, multi-select), and the selected file's details on the right. It needs js/file-manager.js and css/pages/file-manager.css. Open the app
Rendering the files
Each file is a .fm-file inside .fm-files. The data attributes drive the search, filters, sorting and the details panel:
| Attribute | Value |
|---|---|
data-name | File name, e.g. hero-banner.jpg |
data-kind | image, pdf, doc, sheet, slides, archive, video, audio, code or other |
data-ext | Extension, e.g. jpg |
data-size | Size in bytes |
data-modified | Y-m-d H:i, e.g. 2026-10-02 14:30 |
data-owner | Who uploaded it |
data-starred | 1 or 0 |
data-src, data-dims | Image URL for the preview, and its size, e.g. 1920 × 1080 |
The easiest way to get the inner markup right is to copy one file from file-manager.html into your server template, or to build it in JavaScript with Odin.FileManager.fileHtml(item) — the same markup the template uses for new uploads. Add your own data-id too; the examples below use it. The card's colour class and icon follow the kind:
| Kind | Colour class (tone) | Icon (icon) |
|---|---|---|
image | violet | fas fa-file-image |
pdf | danger | fas fa-file-pdf |
doc | info | fas fa-file-word |
sheet | success | fas fa-file-excel |
slides | warning | fas fa-file-powerpoint |
archive | dark | fas fa-file-zipper |
video | danger | fas fa-file-video |
audio | info | fas fa-file-audio |
code | dark | fas fa-file-code |
other | light | fas fa-file |
Uploading
Users upload with the Upload button or by dropping files on the list. The template only previews them in the browser, with a simulated progress bar. Replace upload() to send them:
Odin.FileManager.prototype.upload = async function (files) {
for (const file of files) {
const body = new FormData();
body.append('file', file);
try {
// the server stores the file and answers with
// { id, name, kind, ext, size, modified, owner, src, tone, icon }
const item = await api('/admin/media', 'POST', body);
const wrap = document.createElement('div');
wrap.innerHTML = Odin.FileManager.fileHtml(item);
const el = wrap.firstElementChild;
el.dataset.id = item.id;
this.list.prepend(el);
} catch (err) {
Odin.toast({ type: 'danger', title: 'Upload failed', text: file.name });
}
}
this.apply(); // re-applies search, filters and sorting
};
Deleting and starring
remove(files) handles both the single "Move to trash" item and the bulk bar; star(file) toggles a star. Replace the first, and wrap the second so the template still updates the page:
Odin.FileManager.prototype.remove = async function (files) {
if (!files.length || !confirm(`Move ${files.length} file(s) to the trash?`)) return;
await api('/admin/media/trash', 'POST', { ids: files.map(f => f.dataset.id) });
files.forEach(f => f.remove());
this.updateChecked(); // hides the bulk bar
this.apply();
};
const star = Odin.FileManager.prototype.star;
Odin.FileManager.prototype.star = function (file) {
star.call(this, file); // the template's own toggle
api(`/admin/media/${file.dataset.id}`, 'PATCH', { starred: file.dataset.starred === '1' });
};
Calendar
Tasks by date on FullCalendar: month, week and list views, coloured by status, overdue tasks flagged. Users drag a task to move it, drag its end to change the due date, select days to add a task and click a task for its details. It needs FullCalendar 6.1, js/forms.js and js/calendar.js. Open the app
Loading the tasks
The calendar reads its tasks from a JSON block named by data-source. Print it from your server:
<div data-task-calendar data-source="#calendar-tasks"></div>
<script type="application/json" id="calendar-tasks">
[
{
"id": 1,
"title": "Homepage hero redesign",
"project": "Website",
"status": "progress",
"priority": "high",
"start": "2026-10-12",
"due": "2026-10-20",
"assignees": [{ "initials": "SL", "name": "Sophie Laurent", "tone": "c2" }],
"checklist": [3, 5]
}
]
</script>
| Field | Value |
|---|---|
status | assigned, progress, review or completed |
priority | high, medium or low |
start, due | Y-m-d; a task runs from start to due |
assignees | Initials, full name and an avatar colour, c1–c5 |
checklist | [done, total] |
Optional: data-today="Y-m-d" on the calendar sets the date used for overdue marks and the first month shown (default: today).
Saving changes
After a task is dragged or resized, the calendar fires odin:task-change:
document.addEventListener('odin:task-change', e => {
const { id, start, due } = e.detail;
api(`/admin/tasks/${id}`, 'PATCH', { start, due });
});
New tasks come from the "Add Task" form (#task-new-modal); create() runs once the form is valid. Replace it to save the task first and show what the server returns:
Odin.TaskCalendar.prototype.create = async function () {
const task = await api('/admin/tasks', 'POST', new FormData(this.newForm));
this.calendar.addEvent(this.toEvent(task)); // task in the JSON shape above
this.filter();
bootstrap.Modal.getOrCreateInstance(this.newModal).hide();
};
The details window's "Mark complete" button calls complete(); wrap it the same way as star() above to save the new status (this.current.id is the task's id).
Task board
A kanban board with four columns — Assigned, In Progress, Review, Completed — and summary blocks on top. Users drag cards between columns, search, filter by project and add tasks. It needs SortableJS, js/forms.js and js/kanban.js. Open the app
Rendering the cards
Render each task as a .task-card in its column's .kanban-list. The column's data-status sets the status, so you don't repeat it on the card:
<div data-task-board>
…
<div class="kanban-col" data-status="progress">
…
<div class="kanban-list">
<div class="task-card" data-id="1" data-due="2026-10-20" data-project="Website">
… <!-- as in task-management.html -->
</div>
</div>
</div>
</div>
data-due (Y-m-d) marks overdue cards in red, and data-project is used by the project filter. Counts in the column headers and the summary blocks update by themselves. In JavaScript, Odin.TaskBoard.cardHtml(task) builds a card from an object with id, title, desc, project, priority, due and assignees.
Saving changes
After a card is dropped, the board fires odin:task-move with the card's new column and position:
document.addEventListener('odin:task-move', e => {
const { id, status, sort } = e.detail; // sort: 1 = top of the column
api(`/admin/tasks/${id}/move`, 'PATCH', { status, sort });
});
// new task: save first, then add the card the server returns
Odin.TaskBoard.prototype.create = async function () {
const task = await api('/admin/tasks', 'POST', new FormData(this.form));
const list = this.$(`.kanban-col[data-status="${task.status}"] .kanban-list`);
list.insertAdjacentHTML('afterbegin', Odin.TaskBoard.cardHtml(task));
this.refreshCard(list.firstElementChild);
this.update(); // counts and summary blocks
this.applyFilter();
bootstrap.Modal.getOrCreateInstance(this.modal).hide();
};
// delete: ask, delete on the server, then remove the card
Odin.TaskBoard.prototype.remove = async function (card) {
if (!confirm('Delete this task?')) return;
await api(`/admin/tasks/${card.dataset.id}`, 'DELETE');
card.remove();
this.update();
};
The calendar and the board share one task model — same fields, same statuses, same "Add Task" form fields (title, desc, project, status, priority, start, due, assignee). One /admin/tasks API can serve both.
Messages
An inbox for the messages sent through the website forms: a list of conversations with Open / Closed / Starred / All tabs and search, the open conversation with a reply box (reply or private note, quick replies), and the sender's details. It needs js/messages.js; js/alerts.js (toasts) and js/modals.js with SweetAlert2 (delete confirmation) are optional. Open the app
Loading the conversations
Like the calendar, the inbox reads a JSON block named by data-source:
<div class="msg" data-messages data-source="#message-threads"> … </div>
<script type="application/json" id="message-threads">
[{ "id": 2748, "name": "Sophie Laurent", "initials": "SL", "tone": "c4",
"email": "sophie@example.com", "phone": "+33 6 12 45 78 90", "location": "Lyon, France",
"subject": "Quote request: website redesign", "tag": "Quote request",
"status": "open", "starred": true, "unread": 2, "date": "10:42",
"ref": "INQ-2748", "form": "Quote request", "page": "/services", "language": "French",
"received": "19 Oct 2026, 09:58",
"assigned": [{ "initials": "JF", "name": "Julia Fischer", "tone": "c3" }],
"messages": [
{ "type": "in", "author": "Sophie Laurent", "initials": "SL", "tone": "c4",
"time": "Today, 09:58", "text": ["Hello,", "We would like …"], "files": ["moodboard.jpg"] },
{ "type": "event", "text": ["Nurdin Mukhambet assigned Julia Fischer · 10:05"] }
] }]
</script>
Message type: in (from the sender), out (your reply), note (private note, yellow) or event (a grey line, e.g. an assignment). text is a list of paragraphs, plain text — it is escaped. tone is the avatar colour (c1–c5). In Laravel: @json($threads).
Saving changes
The inbox fires an event for every change; e.detail.thread is the conversation object (with your id):
document.addEventListener('odin:message-reply', e => {
const { thread, message } = e.detail; // message.type: 'out' (reply) or 'note'
api(`/admin/messages/${thread.id}/replies`, 'POST', { type: message.type, body: message.text.join('\n\n') });
});
document.addEventListener('odin:message-open', e => api(`/admin/messages/${e.detail.thread.id}/read`, 'PATCH'));
document.addEventListener('odin:message-status', e => api(`/admin/messages/${e.detail.thread.id}`, 'PATCH', { status: e.detail.status }));
document.addEventListener('odin:message-star', e => api(`/admin/messages/${e.detail.thread.id}`, 'PATCH', { starred: e.detail.starred }));
document.addEventListener('odin:message-delete', e => api(`/admin/messages/${e.detail.thread.id}`, 'DELETE'));
To check with the server before the page changes, replace a method instead — Odin.Messages.prototype.reply, setStatus(thread, status), star(thread) or remove(thread) — as shown for the other apps. Quick replies are links with data-msg-insert="text"; render your own saved replies the same way.
Support Tickets
A help-desk queue: views (My tickets, Unassigned, All open, Urgent & high, SLA breached, Waiting on customer, Solved), search and filters, sortable columns, paging and bulk actions; a ticket opens in a side panel with status, priority and agent, the conversation and a reply / private-note box; “New ticket” opens a form. It needs js/data-source.js and js/support-tickets.js; js/forms.js (validation) and js/alerts.js (toasts) are optional. Open the app
Loading the tickets
The data comes from json/tickets.json — point data-source at your own route to load real tickets:
<div class="tk" data-ticket-desk data-source="/admin/tickets.json" data-fallback="#tickets-data"> … </div>
{ "now": "2026-10-20T14:30:00", "me": "NM",
"agents": [{ "id": "NM", "name": "Nurdin Mukhambet", "tone": "c2", "team": "Websites" }],
"sla": { "urgent": 60, "high": 240, "normal": 1440, "low": 4320 },
"tickets": [{ "id": "T-48213", "subject": "Online shop checkout returns an error",
"customer": "Carter Sports", "email": "james@cartersports.com", "channel": "Phone",
"priority": "urgent", "status": "open", "agent": "PJ",
"created": "2026-10-20T13:52:00", "updated": "2026-10-20T14:24:00", "due": "2026-10-20T14:52:00",
"tags": ["shop", "bug"],
"messages": [{ "type": "in", "author": "Carter Sports", "time": "…", "text": "…" }] }] }
status: new, open, pending (waiting on customer — the SLA timer pauses), on-hold or solved. me is the signed-in agent for “My tickets”. In Laravel return response()->json([...]) from an admin route.
Saving changes
document.addEventListener('odin:ticket-update', e => {
const { ticket, field, value } = e.detail; // field: status | priority | agent
api(`/admin/tickets/${ticket.id}`, 'PATCH', { [field]: value });
});
document.addEventListener('odin:ticket-reply', e => api(`/admin/tickets/${e.detail.ticket.id}/messages`, 'POST', e.detail.message));
document.addEventListener('odin:ticket-create', e => api('/admin/tickets', 'POST', e.detail.ticket));
document.addEventListener('odin:ticket-bulk', e => api('/admin/tickets/bulk', 'PATCH', {
ids: e.detail.tickets.map(t => t.id), [e.detail.field]: e.detail.value
}));