Building SaaS Website #14: Single Page Applications (SPA) - Admin Panel (Part 2)
Introduction
In the previous blog of this series, we introduced the foundational setup for the TotalGPT SaaS admin panel. This time, we focus on creating the views and understanding how Single Page Applications (SPA) leverage the power of the Total.js server and the JComponent library.
This part of the series lays the groundwork for structuring the admin panel’s HTML and integrates JComponent to handle dynamic UI elements and routing seamlessly.
Definitions
Total.js Framework
Total.js is the core of the Total.js Platform, a robust ecosystem designed for building fast and real-time applications. It includes tools, libraries, and ready-to-use apps, all integrated into a seamless development environment. This project uses the latest version of Total.js Framework as of January 2025.
JComponent (Total.js UI)
JComponent is a client-side library within the Total.js ecosystem, built to create interactive user interfaces similar to Angular or React. It features:
- A core for SPA creation and reusable components.
- Client-side routing for HTML history management.
- Tangular template engine.
- Helpers for arrays, numbers, strings, dates, and more.
- Compatibility with jQuery.
With these tools, developers can also create hybrid mobile applications (PWA).
Integration is straightforward:
<!-- Adding JComponent from the CDN -->
<script src="https://cdn.componentator.com/spa.min.js"></script>
Note: This blog focuses on setting up the admin panel and doesn’t cover the full library. A separate series will delve into Total.js UI.
JComponent Integration
We will set up admin.html
in the views folder. Unlike the website, this admin panel won’t split views into layout_admin.html
and admin.html
, so you can see everything in one place.
Setting Up admin.html
Create /views/admin.html
and add the following:
<!-- We are not using any layout file, so set it to an empty value -->
@{layout('')}
<!-- Use the app name as the page title from the config file -->
@{title(config.name)}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=11" />
<meta name="format-detection" content="telephone=no" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="all,follow" />
<!-- Import JComponent library -->
<link href="@{'%cdn'}/spa.min@19.css" rel="stylesheet" />
<script src="@{'%cdn'}/spa.min@19.js"></script>
<!-- Import components declared in definitions/init.js -->
<script src="@{REPO.ui}"></script>
<!-- Custom styles and scripts from the public folder -->
@{import('meta', 'head', 'default.js + func.js', 'default.css', 'favicon.ico')}
</head>
<body class="invisible">
Header and Components Setup
Add the header with a breadcrumb UI component:
<header>
<!-- Link to the website from the admin panel -->
<a href="/" title="@(Website)" target="_blank"><i class="ti ti-globe"></i></a>
<!-- Breadcrumb component -->
<ui-component name="breadcrumb" path="common.breadcrumb" config="$assign:BREADCRUMB;style:2;root:@(Home);icon:ti ti-home;rooturl:/admin/;title:@{'%name'}" class="invisible">
<!-- Virtual wire links the breadcrumb to nested UI plugins -->
<ui-component name="virtualwire" path="common.page" class="toolbar" style="float:right"></ui-component>
</ui-component>
</header>
Define the side navigation menu using navlayout
:
<ui-component name="navlayout" path="?.menu" config="parent:window;width:200" class="invisible">
<section>
<ui-component name="aselected" path="common.page" config="selector:div.item;attr:data-url">
<ui-component name="viewbox" path="common.page" config="parent:window;margin:60">
<div class="nav">
<ui-bind path="common.plugins" config="template" class="block">
<script type="text/html">
<nav>
{{ foreach m in value }}
{{ if !m.hidden }}
<div data-url="{{ m.url }}" class="item hellip exec{{ if m.class }} {{ m.class }}{{ fi }}" data-if="{{ m.id }}" data-exec="common/redirect">
<i class="{{ m.icon }}"></i>{{ m.name }}
</div>
{{ fi }}
{{ end }}
</nav>
</script>
</ui-bind>
</div>
</ui-component>
</ui-component>
</section>
<main>
<ui-bind path="common.plugins" config="template" class="block">
<script type="text/html">
{{ foreach m in value }}
{{ if !m.hidden }}
<ui-component name="page" path="common.page" config="if:plugin{{ m.id }};url:/_{{ m.id }}/index.html;reload:?/reload;hidden:?/hide;id:_{{ m.id }}" class="hidden invisible"></ui-component>
{{ fi }}
{{ end }}
</script>
</ui-bind>
</main>
</ui-component>
JSON Model for Plugins
Add the JSON model to load plugins dynamically:
@{json(model, 'pluginsdata')}
Adding the client side common logic:
Certainly! Part 3 of your SPA admin panel blog post focuses on Plugins and their functionality in the Total.js framework. Below is the detailed blog post in Markdown format, incorporating code snippets, explanations, and comments.
Client side Plugins
In this section, we dive into the client side Plugins feature of JComponent, which provides a modular and flexible way to handle dynamic content like pages, forms, windows, or other components in a SPA (Single Page Application). We'll explore the implementation, usage, and best practices
What Are client side Plugins in JComponent?
Plugins are self-contained modules that:
- Handle dynamic parts of your application.
- Can be easily added or removed without affecting the rest of your application.
- Manage their own events and watchers, which are automatically cleaned up when the plugin is removed.
In Total.js, plugins allow you to dynamically build and manage content in your admin panel while keeping the codebase modular and maintainable.
Plugin Implementation: Common Script Example
Below is the core script for defining and managing plugins in your Total.js SPA. The code also includes comments explaining each part.
// PLUGINS
// Plugins are targeted for dynamic parts, which can be removed when they aren't used.
// Plugins are the best way to handle dynamic pages, forms, windows, etc., in SPA applications.
// IMPORTANT: Events or watchers declared in the plugin will be removed automatically after the plugin is removed.
// Declarations
// A plugin name can contain only a-z characters (no numbers or white spaces).
// Common variables
var user = null; // Holds the logged-in user object.
var common = {}; // Object to store common application data.
common.breadcrumb = []; // Breadcrumb paths for navigation.
common.clientid = GUID(5) + Date.now().toString(36); // Generate a unique client ID.
common.name = document.title; // Store the app name.
common.openplatform = NAV.query.openplatform || ''; // OpenPlatform integration token.
common.plugins = PARSE('#pluginsdata'); // Parse plugin data from server-side JSON.
common.api = common.root = '@{CONF.$api}'; // API endpoint for client-server integration.
common.redirect = REDIRECT; // Define redirection logic.
Environment Variables and Definitions
Define useful environment variables and configure fallback behaviors.
// Useful environment variables
ENV('indentation', 201);
ENV('margin', 60);
// Server-side or client-side definitions can modify default behavior.
DEF.fallback = '@{#}/cdn/j-{0}.html'; // Fallback URL for downloading components.
DEF.versionhtml = '@{CONF.version}'; // Define app version.
DEF.languagehtml = '@{user.language}'; // Define app language.
// Inject Token to every API request for OpenPlatform integration.
(function() {
var openplatform = NAV.query.openplatform || '';
if (openplatform) {
var hostname = openplatform.substring(0, openplatform.indexOf('/', 10));
openplatform = '?openplatform=' + encodeURIComponent(openplatform);
}
common.ready = true;
DEF.api = common.api + openplatform;
common.openplatform = openplatform;
$('body').rclass('invisible', 200); // Remove invisibility class after initialization.
})();
Plugin Management
Defining a Plugin
Plugins are defined using the PLUGIN
method. Here’s an example:
// Define a plugin called "common".
PLUGIN('common', function(exports) {
var model = exports.model;
// Initialize plugin routes and import components.
(function() {
model.plugins.quicksort('position'); // Sort plugins by position.
// Define client-side routes for each plugin.
model.plugins.forEach(function(plugin) {
plugin.url = model.root + plugin.id + '/';
if (!plugin.hidden) {
ROUTE(plugin.url, () => exports.set('page', 'plugin' + plugin.id), 'init');
plugin.routes && plugin.routes.forEach(item =>
ROUTE('@{#}' + item.url, () => exports.set('panel', 'plugin' + plugin.id + item.html), 'init')
);
}
// Import plugin components dynamically.
plugin.import && $(document.body).append(
'<ui-import config="url:{0};id:_{1}"></ui-import>'
.format('@{#}/_' + plugin.id + '/' + plugin.import, plugin.id)
);
});
})();
// Refresh the user data by calling the API.
exports.refresh = function() {
TAPI('account', 'user'); // Fetch user data and store it in `user`.
};
// Redirection logic.
exports.redirect = function(el) {
REDIRECT(el.attrd('url')); // Perform redirection.
};
// Navigation logic.
exports.navigation = function(el) {
REDIRECT('/'); // Redirect to the homepage.
};
// Logout function.
exports.logout = function() {
exports.ajax('GET @{#}{0}logout/ ERROR'.format(model.root), () => location.href = model.root);
};
// Service event (triggered every minute).
ON('service', function(counter) {
if (counter % 5 === 0) // Refresh data every 5 minutes.
exports.refresh();
});
// Refresh when the page is ready.
ON('ready', exports.refresh);
});
Middleware and Routing
Middleware is used to manage route initialization and redirections.
// Client-side routing middleware.
MIDDLEWARE('init', function(next) {
WAIT(() => common.ready && W.user && common.breadcrumb && W.BREADCRUMB, next, null, 60000);
});
// Redirect to root.
ROUTE('/', function() {
REDIRECT(common.root);
}, 'init');
// Update breadcrumb and open the welcome page.
ROUTE(common.root, function() {
BREADCRUMB.add();
SET('common.page', 'welcome');
}, 'init');
Event Listeners and Flags
Use event listeners to manage UI states like loading animations.
// Event listeners for flags.
ON('@flag diff', function(path, value) {
FUNC.diff(path, value, 'id');
});
ON('@flag showloading', function() {
SETTER('loading/show');
});
ON('@flag hideloading', function() {
SETTER('loading/hide', 1000);
});
Code Initialization
Load required data during app initialization.
// Load country lists from the server.
CLINIT('countries', function(next) {
TAPI('countries', response => next(response));
}, true);
Summary
In this part, we implemented a modular plugin system that allows for:
- Easy management of dynamic content.
- Client-side routing for individual plugins.
- Seamless integration with OpenPlatform.
- Efficient handling of UI states and data fetching.