const ROUTES = {
HOME: '/',
SEARCH: '/search',
CONVERSATION: '/c/:id',
CONVERSATION_MESSAGE: '/c/:id/m/:msgId',
SETTINGS: '/settings',
STATS: '/stats',
};
const routeHandlers = new Map();
let currentRoute = {
path: '/',
params: {},
query: {},
raw: '',
};
let routerInstance = null;
export function parseRouteIdSegment(segment) {
if (typeof segment !== 'string' || !/^[1-9]\d*$/.test(segment)) {
return null;
}
const value = Number.parseInt(segment, 10);
return Number.isSafeInteger(value) ? value : null;
}
export function parseConversationRouteParts(parts) {
if (!Array.isArray(parts) || parts[0] !== 'c') {
return null;
}
if (parts.length !== 2 && !(parts.length === 4 && parts[2] === 'm' && parts[3])) {
return null;
}
const conversationId = parseRouteIdSegment(parts[1]);
if (conversationId === null) {
return null;
}
const messageId = parts.length === 4 ? parseRouteIdSegment(parts[3]) : null;
if (parts.length === 4 && messageId === null) {
return null;
}
return {
conversationId,
messageId,
};
}
export function splitRouteQuery(route) {
const queryStart = route.indexOf('?');
if (queryStart === -1) {
return [route, ''];
}
return [
route.slice(0, queryStart),
route.slice(queryStart + 1),
];
}
class Router {
constructor(options = {}) {
this.onNavigate = options.onNavigate || (() => {});
this.autoInit = options.autoInit !== false;
this._boundHashHandler = this._handleHashChange.bind(this);
if (this.autoInit) {
this.init();
}
}
init() {
window.addEventListener('hashchange', this._boundHashHandler);
this._handleHashChange();
console.debug('[Router] Initialized');
}
destroy() {
window.removeEventListener('hashchange', this._boundHashHandler);
console.debug('[Router] Destroyed');
}
navigate(path, options = {}) {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const newHash = `#${normalizedPath}`;
if (options.replace) {
window.location.replace(newHash);
} else {
window.location.hash = normalizedPath;
}
}
goHome(query = null) {
if (query) {
this.navigate(`/search?q=${encodeURIComponent(query)}`);
} else {
this.navigate('/');
}
}
goToConversation(conversationId, messageId = null) {
if (messageId) {
this.navigate(`/c/${conversationId}/m/${messageId}`);
} else {
this.navigate(`/c/${conversationId}`);
}
}
goToSettings() {
this.navigate('/settings');
}
goToStats() {
this.navigate('/stats');
}
back() {
window.history.back();
}
getCurrentRoute() {
return { ...currentRoute };
}
_handleHashChange() {
const hash = (window.location.hash || '').slice(1) || '/';
const parsed = this._parseHash(hash);
currentRoute = parsed;
this.onNavigate(parsed);
window.dispatchEvent(new CustomEvent('cass:route-change', {
detail: parsed,
}));
}
_parseHash(hash) {
const [pathPart, queryPart] = splitRouteQuery(hash);
const path = pathPart || '/';
const query = {};
if (queryPart) {
const searchParams = new URLSearchParams(queryPart);
for (const [key, value] of searchParams) {
query[key] = value;
}
}
const { view, params } = this._matchRoute(path);
return {
path,
view,
params,
query,
raw: hash,
};
}
_matchRoute(path) {
const parts = path.split('/').filter(Boolean);
if (parts.length === 0) {
return { view: 'search', params: {} };
}
if (parts[0] === 'search' && parts.length === 1) {
return { view: 'search', params: {} };
}
if (parts[0] === 'c') {
const conversationParams = parseConversationRouteParts(parts);
if (conversationParams) {
return {
view: 'conversation',
params: conversationParams,
};
}
return { view: 'not-found', params: { path } };
}
if (parts[0] === 'settings' && parts.length === 1) {
return { view: 'settings', params: {} };
}
if (parts[0] === 'stats' && parts.length === 1) {
return { view: 'stats', params: {} };
}
return { view: 'not-found', params: { path } };
}
}
export function createRouter(options = {}) {
if (routerInstance) {
console.warn('[Router] Router already exists, destroying old instance');
routerInstance.destroy();
}
routerInstance = new Router(options);
return routerInstance;
}
export function getRouter() {
return routerInstance;
}
export function navigate(path, options = {}) {
if (!routerInstance) {
console.error('[Router] Router not initialized');
return;
}
routerInstance.navigate(path, options);
}
export function getCurrentRoute() {
return { ...currentRoute };
}
export function buildConversationPath(conversationId, messageId = null) {
if (messageId) {
return `/c/${conversationId}/m/${messageId}`;
}
return `/c/${conversationId}`;
}
export function buildSearchPath(query = '', filters = {}) {
const params = new URLSearchParams();
const hasExplicitFilterValue = (value) => value !== undefined && value !== null && value !== '';
if (query) {
params.set('q', query);
}
if (hasExplicitFilterValue(filters.agent)) {
params.set('agent', filters.agent);
}
if (hasExplicitFilterValue(filters.timePreset) && filters.timePreset !== 'custom') {
params.set('time', filters.timePreset);
} else {
if (hasExplicitFilterValue(filters.since)) {
params.set('since', filters.since);
}
if (hasExplicitFilterValue(filters.until)) {
params.set('until', filters.until);
}
}
const queryString = params.toString();
return queryString ? `/search?${queryString}` : '/search';
}
export function parseSearchParams(route) {
return {
query: route.query.q || '',
agent: route.query.agent || null,
timePreset: route.query.time || null,
since: route.query.since || null,
until: route.query.until || null,
};
}
export { ROUTES };
export { Router };
export default {
createRouter,
getRouter,
navigate,
getCurrentRoute,
parseConversationRouteParts,
parseRouteIdSegment,
buildConversationPath,
buildSearchPath,
parseSearchParams,
ROUTES,
Router,
};