Introduce initial version of the Lonc app with core features, styling, and configurations.

- Add base app structure, including Bootstrap setup and Alpine.js integration.
- Implement authentication flow with session handling.
- Integrate stock management and label creation functionalities.
- Include responsive styling and theme using CSS variables and custom components.
- Add API clients for Tryton-based backend.
- Set up kitchen and dashboard navigation workflows.
- Configure service worker for PWA support.
This commit is contained in:
2026-04-06 09:24:22 +02:00
commit 929ee6557a
48 changed files with 4879 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
import Alpine from 'alpinejs';
import { logout, restoreSession, verifyConnection } from '../api/auth.js';
import { listKitchens } from '../api/kitchens.js';
import { APP_NAME } from './config.js';
import { createRouter, navigate } from './router.js';
import { createAppStore } from './store.js';
import { appShell } from '../components/app-shell.js';
import { registerFeatureData } from '../features/register.js';
async function installServiceWorker() {
if ('serviceWorker' in navigator) {
await navigator.serviceWorker.register('/service-worker.js');
}
}
export function bootstrapApp() {
const store = createAppStore();
Alpine.store('app', store);
registerFeatureData(Alpine, store);
const appRoot = document.querySelector('#app');
appRoot.innerHTML = appShell(APP_NAME);
Alpine.initTree(appRoot);
const router = createRouter({
Alpine,
store,
outlet: document.querySelector('#route-view'),
});
window.__loncApp = {
navigate,
async refreshKitchens() {
const kitchens = await listKitchens(store);
store.setKitchens(kitchens);
if (!store.activeKitchen && kitchens.length) {
store.setActiveKitchen(kitchens[0]);
}
return kitchens;
},
async restoreSession() {
try {
await restoreSession(store);
if (store.isConnected) {
await window.__loncApp.refreshKitchens();
}
} catch (error) {
if (window.location.hash !== '#/login') {
navigate('/login');
}
}
},
async verifyConnection() {
await verifyConnection(store);
if (store.isConnected) {
await window.__loncApp.refreshKitchens();
}
return store.session;
},
async logout() {
await logout(store);
navigate('/login');
},
router,
};
window.addEventListener('online', () => {
store.addAlert({ type: 'success', message: 'Connection restored.' });
});
window.addEventListener('offline', () => {
store.addAlert({
type: 'warning',
message: 'You are offline. Cached screens stay available, but API actions may fail.',
});
});
window.__loncApp
.restoreSession()
.finally(() => router.start())
.catch(() => router.start());
installServiceWorker().catch(() => {
store.addAlert({
type: 'warning',
message: 'PWA installation support could not be initialized.',
});
});
}
+39
View File
@@ -0,0 +1,39 @@
export const APP_NAME = 'Lonc';
export const TRYTON_APPLICATION = 'kitchen';
export const CONNECTION_STATES = {
notConnected: 'not_connected',
pendingValidation: 'pending_validation',
connected: 'connected',
invalidKey: 'invalid_key',
};
export const STORAGE_KEYS = {
appConfig: 'lonc.app.config',
session: 'lonc.auth.session',
activeKitchen: 'lonc.kitchen.active',
labelDraft: 'lonc.labels.draft',
};
export const DEFAULT_CONFIG = {
baseUrl: '',
database: '',
};
export const API_PATHS = {
userApplication: 'user/application/',
kitchens: 'kitchen/kitchens',
items: 'kitchen/items',
stockEntries: 'stock',
locations: 'kitchen/locations',
};
export const ROUTES = {
login: '/login',
home: '/',
stock: '/stock',
stockNew: '/stock/new',
stockDetail: '/stock/:id',
labelsNew: '/labels/new',
settings: '/settings',
};
+95
View File
@@ -0,0 +1,95 @@
import { ROUTES } from './config.js';
import { renderDashboardPage } from '../features/dashboard/dashboard-page.js';
import { renderKitchenSelector } from '../features/kitchens/kitchen-selector.js';
import { renderLoginPage } from '../features/auth/login-page.js';
import { renderLabelCreatePage } from '../features/labels/label-create-page.js';
import { renderSettingsPage } from '../features/auth/settings-page.js';
import { renderStockDetailPage } from '../features/stock/stock-detail-page.js';
import { renderStockListPage } from '../features/stock/stock-list-page.js';
const routeDefinitions = [
{ path: ROUTES.login, render: renderLoginPage, protected: false },
{ path: ROUTES.home, render: renderDashboardPage, protected: true },
{ path: ROUTES.stock, render: renderStockListPage, protected: true },
{ path: ROUTES.stockNew, render: renderLabelCreatePage, protected: true },
{ path: ROUTES.stockDetail, render: renderStockDetailPage, protected: true },
{ path: ROUTES.labelsNew, render: renderLabelCreatePage, protected: true },
{ path: ROUTES.settings, render: renderSettingsPage, protected: false },
];
function normalizeHashRoute() {
const route = window.location.hash.replace(/^#/, '') || ROUTES.home;
return route.startsWith('/') ? route : `/${route}`;
}
function matchRoute(pathname) {
for (const definition of routeDefinitions) {
const keys = [];
const pattern = definition.path.replace(/:([^/]+)/g, (_, key) => {
keys.push(key);
return '([^/]+)';
});
const regex = new RegExp(`^${pattern}$`);
const match = pathname.match(regex);
if (!match) {
continue;
}
const params = keys.reduce((accumulator, key, index) => {
accumulator[key] = decodeURIComponent(match[index + 1]);
return accumulator;
}, {});
return { ...definition, params };
}
return null;
}
export function navigate(path) {
window.location.hash = path;
}
export function getRouteContext() {
return window.__loncRouteContext || { path: ROUTES.home, params: {} };
}
export function createRouter({ Alpine, store, outlet }) {
const render = async () => {
const pathname = normalizeHashRoute();
const match = matchRoute(pathname);
if (!match) {
navigate(ROUTES.home);
return;
}
if (match.protected && !store.isConnected) {
navigate(ROUTES.login);
return;
}
if (
store.isConnected &&
!store.activeKitchen &&
pathname !== ROUTES.login &&
pathname !== ROUTES.settings
) {
outlet.innerHTML = renderKitchenSelector();
Alpine.initTree(outlet);
return;
}
window.__loncRouteContext = { path: pathname, params: match.params };
outlet.innerHTML = match.render();
Alpine.initTree(outlet);
};
window.addEventListener('hashchange', render);
return {
start: render,
render,
};
}
+104
View File
@@ -0,0 +1,104 @@
import {
CONNECTION_STATES,
DEFAULT_CONFIG,
STORAGE_KEYS,
TRYTON_APPLICATION,
} from './config.js';
import {
clearStoredValue,
loadStoredValue,
saveStoredValue,
} from '../features/shared/storage.js';
const defaultState = {
config: { ...DEFAULT_CONFIG },
session: null,
kitchens: [],
activeKitchen: null,
alerts: [],
};
export function createAppStore() {
const state = {
...defaultState,
config: loadStoredValue(STORAGE_KEYS.appConfig, { ...DEFAULT_CONFIG }),
session: loadStoredValue(STORAGE_KEYS.session, null),
activeKitchen: loadStoredValue(STORAGE_KEYS.activeKitchen, null),
alerts: [],
};
return {
...state,
get isAuthenticated() {
return Boolean(this.session?.applicationKey);
},
get isConnected() {
return this.session?.state === CONNECTION_STATES.connected;
},
get needsValidation() {
return this.session?.state === CONNECTION_STATES.pendingValidation;
},
setConfig(nextConfig) {
this.config = { ...this.config, ...nextConfig };
saveStoredValue(STORAGE_KEYS.appConfig, this.config);
},
setSession(session) {
this.session = session
? {
application: TRYTON_APPLICATION,
state: CONNECTION_STATES.notConnected,
hasValidated: false,
...session,
}
: null;
if (session) {
saveStoredValue(STORAGE_KEYS.session, this.session);
return;
}
clearStoredValue(STORAGE_KEYS.session);
},
setKitchens(kitchens) {
this.kitchens = kitchens;
if (
this.activeKitchen &&
!kitchens.some((kitchen) => kitchen.id === this.activeKitchen.id)
) {
this.activeKitchen = null;
clearStoredValue(STORAGE_KEYS.activeKitchen);
}
},
setActiveKitchen(kitchen) {
this.activeKitchen = kitchen;
if (kitchen) {
saveStoredValue(STORAGE_KEYS.activeKitchen, kitchen);
return;
}
clearStoredValue(STORAGE_KEYS.activeKitchen);
},
addAlert(alert) {
const nextAlert = {
id: crypto.randomUUID(),
type: 'info',
timeout: 5000,
...alert,
};
this.alerts = [...this.alerts, nextAlert];
if (nextAlert.timeout) {
window.setTimeout(() => this.removeAlert(nextAlert.id), nextAlert.timeout);
}
},
removeAlert(alertId) {
this.alerts = this.alerts.filter((alert) => alert.id !== alertId);
},
clearSessionState() {
this.setSession(null);
this.setKitchens([]);
this.setActiveKitchen(null);
},
};
}