Building an app's screens
هذا المحتوى غير متوفر بلغتك بعد.
An app can bring screens of its own beside its dashboard pages: a staff side for your team and
a customer side for the public. Each is a small React app in the app’s folder, built by
adminium app build and served by Adminium at
/apps/<key>/staff/ and /apps/<key>/customer/. This page is about writing them.
An app in your project covers the folder, the manifest and the commands.
The folder
Section titled “The folder”apps/repairs/├── manifest/app.json "frontends": [{ "side": "staff", "kind": "spa" }, …]├── staff/│ ├── src/main.tsx where the side starts│ ├── src/App.tsx your screens│ ├── src/theme.css the look: colours, type, corners (imported from main.tsx)│ ├── src/app.css the parts a screen is made of, drawn from theme.css│ ├── nav.json its screens, for the dashboard's sidebar (optional)│ └── public/ files served as they are (optional)└── customer/ └── src/main.tsxmain.tsx mounts the app on the page’s root element:
import { createRoot } from 'react-dom/client';
import { App } from './App';import './theme.css';import './app.css';
createRoot(document.getElementById('root')!).render(<App />);Everything main.tsx imports is bundled: your components, CSS, and images and fonts imported by
address (import logo from './logo.svg'). The page itself is written by the build. There is no
index.html to edit.
The look
Section titled “The look”The starter’s screens come with a look, so a first build is something to show. It is two files on each side:
src/theme.cssholds the look as values: the page and card colours, the text and muted text, one accent, the corner radius, the shadow, and the typefaces for text and for headings, in a light and a dark set. Change the look here, in one place.src/app.cssholds the parts, drawn from those values. Use their class names instead of writing styles of your own for what a part already does:
| For | Classes |
|---|---|
| The page | page (narrow for one column), site-header with brand and brand-mark, hero with eyebrow and lead, section with section-head, layout (a wide column and an aside that stays in view), site-footer |
| What is on offer | a grid of cards, each with card-media, card-title, card-row and price; stepper for a quantity; summary with a total line |
| Forms | form of fields (a label, the input, an optional hint); btn btn-primary for the one main action, btn and btn btn-quiet for the rest; btn-small, btn-block |
| What the page says back | notice ok, notice error, empty, badge with accent, good, warn or bad |
| For staff | toolbar, a list of list-rows, a board of columns; row, muted, small |
A new side starts in the direction clean. There are four: clean, warm, bold and calm.
Adminium Designer asks which one when it first adds a side (unless the request already said how it
should look), keeps the answer in the app’s look.json, and its Change the look writes
theme.css again in another direction. By hand, edit the values in theme.css.
Only the typefaces a device already has are used: a served screen may load no font from another host (see below).
The header shows the app’s name. APP_NAME from @adminiumjs/adminium/side is the name in the
app’s manifest when the side was built, so renaming the app renames the header at the next build;
the config’s appName is the operator’s own name for the app, when they set one:
import { APP_NAME } from '@adminiumjs/adminium/side';
const name = config.appName ?? APP_NAME;The two sides are not alike
Section titled “The two sides are not alike”| Staff side | Customer side | |
|---|---|---|
| Who uses it | Someone signed in to Adminium | Anyone |
| How it reaches data | The data API, with that person’s own session | The public API, with a browser key Adminium serves |
| What it may reach | What the person’s roles allow | Only what the manifest’s publicAccess grants |
| Key in the code | None | None: it is served, never written into the bundle |
Both import their plumbing from @adminiumjs/adminium/side. The build supplies that module from
the Adminium doing the building.
A staff side
Section titled “A staff side”import { useEffect, useState } from 'react';import { useStaff, type Row, type StaffSession } from '@adminiumjs/adminium/side';
export function App() { const loaded = useStaff(); if (loaded.state === 'loading') return <p>Loading…</p>; if (loaded.state === 'error') return <p>{loaded.message}</p>; return <Jobs staff={loaded.value} />;}
function Jobs({ staff }: { staff: StaffSession }) { const [rows, setRows] = useState<Row[]>([]); useEffect(() => { void staff.list('jobs', { order: 'id.desc' }).then((listed) => setRows(listed.rows)); }, [staff]); return <ul>{rows.map((row) => <li key={String(row.id)}>{String(row.title)}</li>)}</ul>;}The session useStaff() gives:
list(table, options?) |
Rows and the total. options: limit (at most 200, default 50), offset, order ("created_at.desc"), q (a search), where (a filter, as the data API takes it) |
get(table, id) |
One row |
create(table, values) |
Adds a row and returns it |
update(table, id, values) |
Changes a row and returns it |
remove(table, id) |
Deletes a row |
can(table, action) |
Whether the signed-in person may read, create, update or delete there. Use it to leave out a button whose write would be refused |
user |
{ id, name, email } of the person signed in |
settings |
The app’s settings values |
timezone, timezoneIsFallback, currency |
The venue’s, see below |
tables |
The real name of each table in the database |
table is the short name: the ref in manifest/tables/<ref>.json. Adminium names the real table
<key>_<ref> when the app is prefixed, and the session maps one to the other.
A refused write throws an error whose message is the server’s own sentence and whose code is
its error code. On a staff screen, show the message: it names the column and says what is wrong.
A column that holds personal data (a name, a phone, an email) reads as empty to a person whose
role lacks read_pii on that table. A screen that shows customers’ details needs a role that
grants it; see Add a role.
adminium app build bundles the code and does not type-check it. To type-check a side, add
typescript to the project and a tsconfig.json that includes apps/.
The person must hold a role that may open the app’s staff screens (app:@:staff in the app’s
roles.json), and their grants on the app’s tables decide what the reads and writes above may do.
See App roles and staff access.
Its place in the dashboard
Section titled “Its place in the dashboard”nav.json lists the screens the staff side offers the dashboard’s sidebar:
[ { "id": "jobs", "path": "", "label": "Jobs", "icon": "wrench" }, { "id": "done", "path": "done", "label": "Done" }]path is the address of that screen inside the side, without its first slash: "" is the first
page, "done" is the page at /done. It is added to wherever the side is opened, so it never
starts with /. icon is a Lucide icon name. Each entry needs a page at
its address: see Pages and their addresses.
A customer side
Section titled “A customer side”A customer side reaches the public API, and there only what the app’s access.json grants. Write
access.json first, run adminium app check to read back what it grants, then write the screen.
import { useMemo } from 'react';import { createPublicClient } from '@adminiumjs/public-client';import { useCustomer, type CustomerConfig } from '@adminiumjs/adminium/side';
export function App() { const loaded = useCustomer(); if (loaded.state === 'loading') return <p>Loading…</p>; if (loaded.state === 'error') return <p>{loaded.message}</p>; return <Menu config={loaded.value} />;}
function Menu({ config }: { config: CustomerConfig }) { const client = useMemo(() => createPublicClient(config), [config]); const items = config.tables['items'] ?? 'items'; // client.list(items, { limit: 50 }) → { data: rows }, in key order // client.create(requests, { message }) → the new row, as far as `select` shows it return null;}config.tables maps each table’s short name to the name its public endpoint goes by on this
install. It is the customer config’s (useCustomer()’s loaded.value), not the client’s:
client.config() answers other things and has no tables. The client also signs guests in, opens a guest’s own rows, reads free and full times, and
more; see An app’s public access and
A person and their own rows.
Until someone allows the app’s public access, no key is served and useCustomer() answers error
with a sentence saying so.
Five things a customer screen needs that the example above leaves out:
-
A list comes in key order, and the caller cannot change it. An app’s public endpoint takes
limit,offsetandcursor, and refuses a sort, a filter or a search of the caller’s (order,where,q) with400 PUBLIC_QUERY_REFUSED. Sort the rows in the screen. To keep rows out of the public list, writefilterson the entry inaccess.json: that is decided by the app, not by whoever calls. -
createPublicClientmay returnnull, when it is given no address or no key. Check for it and show a “not connected” line. -
The venue’s clock and money come from the client, not from
useCustomer():await client.config()givestimezoneandcurrency, andawait client.now()gives the server’s time, so “today” is right on a device whose clock is wrong. -
A public error’s
messageis for developers, not for visitors. CatchPublicApiError, read itscode, and say your own sentence. This is the opposite of the staff side, where the server’s message is written to be shown. -
The human check is automatic. When an entry asks for it (
"humanCheck": trueinaccess.json), the client solves it in the background and sends again. PasshumanCheck: { refs: [requests] }tocreatePublicClientto solve it up front for the endpoints you know ask for it, and save the refused first try.
What a public create can and cannot hold a stranger to is in Limits on a stranger’s create. A rule you need that is not there, such as “the pickup date is not in the past”, can only be checked in the screen: say so to whoever asked for it.
The public API does not take payments, run a query you write, or run code of yours on the server. What a customer may do is exactly the list the manifest grants.
Pages and their addresses
Section titled “Pages and their addresses”A side with more than one page gives each page an address of its own: / for the first, /menu,
/menu/spicy-wings. Then a page can be refreshed, opened in a new tab, sent to somebody and gone
back to, and the dashboard’s sidebar can open each screen of a staff side. The side module has
what it takes; there is no router package to add.
import { Link, usePath, pathParams, go } from '@adminiumjs/adminium/side';
function Site() { const path = usePath(); // '/', '/menu', '/menu/spicy-wings' const item = pathParams('/menu/:slug', path); // { slug: 'spicy-wings' }, or null if (path === '/') return <Home />; if (path === '/menu') return <Menu />; if (item !== null) return <Item slug={item.slug} />; return ( <main> <strong>{en('This page does not exist')}</strong> <Link to="/">{en('Back to the first page')}</Link> </main> );}usePath()is the page’s path under the side, kept current. Call it at the top of the component that chooses what to draw, before anyreturn. The screen draws again when the path changes.<Link to="/menu">Menu</Link>is a real link that is followed without loading the side again. It takes what an<a>takes (className,aria-current) excepthref.go('/menu')goes to a page from code: after a form is sent, say.go(path, { replace: true })puts the new address in place of this one.pathParams(pattern, path)reads the parts a pattern names with:. It gives{}for a pattern with none, andnullwhen the path is another page. Values come decoded.pageHref('/menu')is the full address of a page, for the rare place that needs it as text.
A path always starts with one / and is the same wherever the side is served: at the app’s own
address, in a second instance of it, and on a domain mapped to it. So:
- Never keep the page in a state (
const [page, setPage] = useState('home')). Every page is then at/: a refresh goes back to the first one, and a staff side’s second sidebar entry opens the first screen. - Never write an
hrefthat starts with/. That address is from the server’s root and leaves the app.<Link>adds where the side is served. - Draw the page that does not exist. Adminium answers the side’s own page for any address under it, so the screen is the one that knows a path is none of its pages.
- Name each page: set
document.titlewhen the page changes. The browser’s tab and history show it.
Inside the dashboard, and in a preview, a side changes its address in place, so the browser’s Back
leaves the app instead of stepping through its screens. Opened in its own tab, Back steps through
the pages like any site. A staff side keeps the dashboard’s sidebar in step by itself: the entry
whose path is the page’s address is the one shown as open.
The starter screens adminium app new writes are two pages each, built this way.
The venue’s clock and money
Section titled “The venue’s clock and money”Times belong to the venue, not to whoever is reading. A staff session carries timezone (an IANA
zone such as Europe/Copenhagen) and currency. When the database has no time zone set,
timezone is UTC and timezoneIsFallback is true: show a line saying times are in UTC.
Never use the browser’s own zone
(Intl.DateTimeFormat().resolvedOptions().timeZone): it is the reader’s, and it is wrong by a
whole number of hours without anyone noticing. @adminiumjs/public-client exports toTenantDay,
toTenantMinutes, fromTenantLocal and formatTenantMoney for both sides.
Looking at a side without Adminium
Section titled “Looking at a side without Adminium”Give useStaff sample rows and open the staff screen with ?demo in its address. It then holds
those rows in memory and saves nothing. sampleRows works out the common sample directives (@ref,
@ago, @in, @day) near enough to look at a screen:
import { sampleRows, useStaff } from '@adminiumjs/adminium/side';import sample from '../../seeds/sample.json';
const loaded = useStaff({ demo: sampleRows(sample) });To see a side with real data, run the project: under adminium dev
the app is installed from its folder, the side is built on every save and served at
/apps/<key>/<side>/, and an open screen reloads by itself when you save. A screen that wants to
keep its state instead calls stopReloading() and handles onAppChanged(listener) itself.
adminium app try proves the app installs on a fresh Adminium, and
adminium app pack makes the file to install on another one.
What a served screen may not load
Section titled “What a served screen may not load”Adminium serves a side under its own content policy, so a screen may load only from the address it came from:
- No script, stylesheet or font from another host. Bundle them: install the package and import it, or put the file in the side’s folder.
- No inline script. The build writes none, and one added by hand would not run.
- No request to another host. A side cannot call a third-party API from the browser.
- Pictures from the same address, or from a host the operator has listed in
ADMINIUM_CSP_IMG_HOSTS.
Text in other languages
Section titled “Text in other languages”A new app is in English. Wrap text people read in en():
import { en } from '@adminiumjs/adminium/side';
<button>{en('Add')}</button>en() returns the text unchanged. It is a mark, so that the day the app gains a second language
every string to translate is found by searching for en(.