ODIN ADMIN Docs v1.0
Building

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:

  1. 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.
  2. Listen for events where the app fires one — odin:task-move, odin:task-change, odin:message-*.
  3. 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.Messages and Odin.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:

file-manager.html — end of <body>
<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:

js/my-api.js
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:

AttributeValue
data-nameFile name, e.g. hero-banner.jpg
data-kindimage, pdf, doc, sheet, slides, archive, video, audio, code or other
data-extExtension, e.g. jpg
data-sizeSize in bytes
data-modifiedY-m-d H:i, e.g. 2026-10-02 14:30
data-ownerWho uploaded it
data-starred1 or 0
data-src, data-dimsImage 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:

KindColour class (tone)Icon (icon)
imagevioletfas fa-file-image
pdfdangerfas fa-file-pdf
docinfofas fa-file-word
sheetsuccessfas fa-file-excel
slideswarningfas fa-file-powerpoint
archivedarkfas fa-file-zipper
videodangerfas fa-file-video
audioinfofas fa-file-audio
codedarkfas fa-file-code
otherlightfas 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:

js/my-file-manager.js
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:

js/my-file-manager.js
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:

calendar.html
<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>
FieldValue
statusassigned, progress, review or completed
priorityhigh, medium or low
start, dueY-m-d; a task runs from start to due
assigneesInitials, 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:

js/my-calendar.js
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:

js/my-calendar.js
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:

task-management.html
<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:

js/my-board.js
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:

messages.html
<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):

js/my-messages.js
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:

support-tickets.html
<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

js/my-tickets.js
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
}));
ODIN ADMIN v1.0 · Documentation Last updated October 18, 2026