A small, modular JavaScript front end built to sit on top of FlightPHP. It mirrors the way a Flight app is organised. Routes, pages, reusable pieces, so the browser side of your project reads the same way as the PHP side.
- No build step. Plain ES modules loaded straight from
assets/js. Edit, refresh, done. - No dependencies. Nothing to install; the whole thing is a handful of small files.
- Lazy by default. Each URL loads only the page module it needs, and that page imports only the components and tools it uses.
- API-first. Pages talk to Flight's JSON routes through a thin fetch wrapper. HTML stays in Flight's views; JS fills in the dynamic parts.
The components/ and pages/ in this repo are there as examples of the pattern. Keep the core and tools, replace the examples with your own.
| FlightPHP | FlightJS | |
|---|---|---|
routes.php |
routes.js |
URL → handler. @name in PHP is :name in JS. |
| Controllers | pages/ |
One class per screen, given the route's config and params. |
| Views / includes | components/ |
Reusable bits of UI that render into an element. |
| Libraries | tools/ |
Framework-agnostic helpers: API client, forms, tables, CSV. |
Flight::before() |
global_ui.js |
Runs on every page: menu, search box, nav state, toasts. |
A typical request: Flight renders the view with the static shell, then FlightJS resolves the same URL on the client, loads the matching page and fetches the dynamic data from your /api/... routes.
assets/js/
├── app.js entry point — waits for the DOM, starts Core
├── core.js route matching + page loading
├── routes.js Routes (URL → page) and Configs (URL → options)
├── global_ui.js site-wide behaviour, runs on every page
├── pages/ one module per screen (examples)
├── components/ notify, product_card, product_list (examples)
└── tools/ api_client, form_handler, tablesorter, export_csv
- Copy
src/into your Flight project aspublic/assets/js/(or wherever you serve static files). - Add the entry script to your layout / views:
<script type="module" src="/assets/js/app.js"></script>- Register your pages in
routes.js.
That's it. app.js runs Core, which matches window.location.pathname against Routes, imports that page and constructs it.
routes.js exports two objects.
export const Routes = {
"/": () => import("./pages/products.js"),
"/items": () => import("./pages/products.js"),
"/items/:category": () => import("./pages/products.js"),
"/search/:item": () => import("./pages/products.js"),
"/signin": () => import("./pages/signin.js")
};
export const Configs = {
"/items/:category": {
product_lists: [{ element: "browse-items", endpoint: "/api/items/category" }]
},
"/signin": { form: "login-form", endpoint: "/api/auth/signin" }
};- Routes map a path to a dynamic
import(). The module is only downloaded when that URL is visited. - Configs hold per-route options. Whatever you put here is passed to the page's constructor, so pages stay generic and the route decides what they do.
- Matching tries an exact path first, then patterns. A
:namesegment matches exactly one URL segment and is exposed asparams.name. Segment counts must match:/item/:uuidmatches/item/42but not/itemor/item/42/edit. - The same page can serve many routes.
products.jshandles the home page, the shop, categories and search; only the config changes.
A page is a default-exported class. Core constructs it with the route's config plus params:
// pages/products.js
import ProductList from "../components/product_list.js";
export default class Products {
product_lists = [];
params = {};
constructor(options = {}) {
Object.assign(this, options); // { product_lists, params }
this.product_lists.forEach(cfg =>
new ProductList({ ...cfg, params: this.params })
);
}
}Nothing else is required. A page can be as small as attaching a click handler (pages/cart.js) or can wire up several tools.
global_ui.js runs before the page on every request. It looks for elements by id and quietly skips any that aren't on the page:
| Element | Behaviour |
|---|---|
#menu-toggle + #nav-container |
Mobile menu toggle (.active) |
.nav-links a |
Adds .active to the link matching the current path |
#search |
Enter navigates to /search/<term>; the box is pre-filled on search pages |
#year |
Filled with the current year |
It also creates the Notify toaster, so window.notify is available everywhere.
Tools are standalone modules with no knowledge of routing. Import what you need.
Thin fetch wrapper that always resolves — no try/catch needed at the call site.
import ApiClient from "../tools/api_client.js";
const api = new ApiClient();
const r = await api.get("/api/items/search", { params: { q: "mouse" } });
const r = await api.post("/api/auth/signin", { email, password });
// r = { success: true, status, data }
// r = { success: false, status, error, raw }Objects passed as a body are JSON-encoded; JSON responses are decoded. put and delete work the same way.
Turns a normal <form> into an AJAX form.
new FormHandler({
form: "login-form", // form id
endpoint: "/api/auth/signin",
method: "POST", // GET sends fields as query params
validate: data => true, // return false to stop
beforeSend: data => {},
onSuccess: (result, form) => {},
onError: (result, form) => {}
});Fields are collected with FormData. If the submit button has data-csrf="...", it's sent as csrf_token. Inputs are disabled while the request is in flight.
Server-side paging, sorting and search for a <table>. The browser never sorts what's on screen — every change asks the API for the right rows.
<input id="orders-search">
<select id="orders-rows"><option>10</option><option>25</option></select>
<table id="orders">
<thead><tr>
<th data-sort="id">#</th>
<th data-sort="created_at">Date</th>
<th data-key="status">Status</th> <!-- not sortable -->
</tr></thead>
<tbody></tbody>
</table>
<div id="orders-info"></div>
<ul id="orders-pager" class="pagination"></ul>const table = new TableSorter({
table: "orders", endpoint: "/api/orders",
rows_select: "orders-rows", info: "orders-info",
pager: "orders-pager", search: "orders-search",
sort: "created_at", order: "desc",
columns: [null, null, { key: "total", render: v => `$${Number(v).toFixed(2)}` }]
});Other options: rows (default 10), pages_shown (5), search_delay (ms, 0 = Enter only), method (POST/GET), signin_path, empty_text, autoload, onLoad(rows, table). Methods: go(page), sort_by(key), search(text), set_rows(n), refresh(). The last loaded rows are on table.data.
Request { request: "page" | "search", input?, rows, offset, sort?, order? }
Response { success: true, rows: [...], total: 123 } — or { success: false, signin: true } to redirect to sign-in.
Whitelist sort on the PHP side; ORDER BY can't take a bound parameter:
$in = Flight::request()->data;
$cols = ['id' => 'o.id', 'created_at' => 'o.created_at', 'total' => 'o.total'];
$sort = $cols[$in->sort ?? ''] ?? 'o.id';
$order = strtolower($in->order ?? '') === 'desc' ? 'DESC' : 'ASC';
$rows = min(max((int)($in->rows ?? 10), 1), 100);
$off = max((int)($in->offset ?? 0), 0);
// SELECT ... ORDER BY $sort $order LIMIT :rows OFFSET :off
Flight::json(['success' => true, 'rows' => $list, 'total' => $count]);Download rows as a CSV, from an endpoint or from data already in the page.
// from the API
new ExportCSV({ button: "export-btn", endpoint: "/api/orders/export", select: "store-list", filename: "orders" });
// from a TableSorter's current page
new ExportCSV({ button: "export-page", rows: () => table.data, columns: { id: "Order #", total: "Total" } });
// no button at all
ExportCSV.download(ExportCSV.to_csv(rows), "orders.csv");Options: columns (array of keys, or { key: "Header" }), data (extra request fields), select / select_key, method, filename (string or function), bom (UTF-8 BOM for Excel), safe (neutralise =/+/-/@ formula injection), onExport. The endpoint returns { success: true, rows: [...] }.
Created by GlobalUI; no CSS required.
window.notify.success("Saved.");
window.notify.error("Something went wrong.");
window.notify.info("Heads up.");ProductList—{ element, endpoint, params }. Fetchesendpoint(appending/params.categoryor?q=params.itemwhen present) and renders aProductCardper product into#element.ProductCard— builds one card from{ id, name, price, image, badge }.
These are specific to the storefront and are meant to be copied, not kept.
// pages/contact.js
import FormHandler from "../tools/form_handler.js";
export default class Contact {
constructor({ form, endpoint }) {
new FormHandler({
form, endpoint,
onSuccess: () => window.notify.success("Sent!"),
onError: r => window.notify.error(r.error)
});
}
}// routes.js
Routes["/contact"] = () => import("./pages/contact.js");
Configs["/contact"] = { form: "contact-form", endpoint: "/api/contact" };- Files and options are
snake_case; classes arePascalCase. - Every tool takes a single options object and warns in the console (rather than throwing) if an element is missing.
- API responses are
{ success: bool, ... }. Returnsignin: trueto send the user to the sign-in page. - Tools report failures through
window.notify.errorwhen it exists, otherwiseconsole.error.
MIT © Ryan Autet