A Structured Starting Point for Open-Source Frontend Applications.
Hathor is an open-source starter template designed to streamline the creation of structured, maintainable, themeable and scalable frontend applications. Leveraging modern best practices, Hathor provides a robust foundational framework that developers can easily customize to kickstart their projects.
- React with TypeScript
- Vite for fast and efficient builds
- Material UI (MUI) for consistent and configurable UI components
- MapLibre for interactive map functionalities
-
Configurable Theming:
Customize your application's look and feel dynamically via a simple configuration file—perfect for branding, logos, and color schemes.
-
Responsive Layout:
Ensures your application maintains aesthetic appeal across desktop and mobile devices.
-
State Management:
Integrates modern state management techniques (React Context, optional Redux) for efficient application state handling.
-
Interactive Maps:
Pre-configured interactive mapping components powered by MapLibre, excellent for building location-based applications.
-
Clone the repository:
git clone https://github.com/entur/hathor.git
-
Install dependencies:
npm install
-
Run the development server:
npm run dev
-
Update Theme Configuration:
Modify
public/custom-theme-config.jsonorpublic/default-theme-config.jsonto adjust colors, logos, typography, and other MUI theme options. -
Add New Pages and Components:
Follow provided examples (e.g.,
Home.tsx,MapView.tsx) to create pages. Components are organized in thesrc/components/directory. -
Customize the Map:
Adjust the map style via
src/mapStyle.ts, or add layers and interactivity directly. -
Bring Your Own Icons:
Add custom icons to
public/static/customIcons/(SVG or PNG). Override default icons by matching filenames.
Your application can switch between a default theme and a custom theme. This behavior is controlled by the Enable Custom Theme & Icons switch in the settings dialog, which toggles a value in localStorage.
-
CustomizationContext.tsx: Manages theuseCustomFeaturesstate. Whentrue, the app attempts to load the custom theme. -
App.tsx:- Uses the
useCustomFeatureshook. - If enabled, fetches
/custom-theme-config.json. - Otherwise or on failure, fetches
/default-theme-config.json.
- Uses the
-
public/default-theme-config.json: Default Hathor theme settings. -
public/custom-theme-config.json: Define or override any MUI theme options here. -
src/utils/createThemeFromConfig.ts: Converts the JSON configuration into an MUI theme object.
-
Edit
public/custom-theme-config.json:{ "applicationName": "My Custom App", "companyName": "My Company", "palette": { "primary": { "main": "#A020F0" }, "secondary": { "main": "#00BFFF" }, "background": { "default": "#F5F5F5" } }, "typography": { "fontFamily": "\"Open Sans\", \"Helvetica\", \"Arial\", sans-serif", "h1": { "fontSize": "2.8rem" } }, "shape": { "borderRadius": 8 }, "components": { "MuiButton": { "styleOverrides": { "root": { "textTransform": "capitalize" } } } }, "logoUrl": "/assets/my-custom-logo.png", "logoHeight": 32 } -
Enable Custom Features:
- Start the app.
- Open settings (gear icon).
- Toggle Enable Custom Theme & Icons.
- The app will apply
custom-theme-config.jsonon reload.
The application’s icon loader resolves custom and default icons based on the Enable Custom Theme & Icons setting.
-
src/data/iconLoaderUtils.ts–getIconUrl(name: string)checks:-
If custom features enabled:
public/static/customIcons/[name].svgor.png
-
Otherwise or not found:
public/static/defaultIcons/[name].svgor.png
-
Fallback to
default.svg/default.pngindefaultIcons.
-
-
Prepare icons in SVG or PNG.
-
Place in
public/static/customIcons/:- Override: same filename as default.
- Add new: unique filename (e.g.,
analytics.svg).
-
Use in components:
import { getIconUrl } from '../data/iconLoader'; import { Box } from '@mui/material'; const analyticsIconUrl = getIconUrl('analytics'); return ( <Box component="img" src={analyticsIconUrl} alt="Analytics" sx={{ width: 24, height: 24 }} /> );
-
Enable Custom Features in settings to view your icons.
Extend MUI’s theme object in src/types/theme-config.d.ts for additional custom properties.
-
ThemeConfigInterface: Extends MUI’sThemeOptionswith custom fields. -
Module Augmentation: Adds these fields to
ThemeandThemeOptionsviadeclare module '@mui/material/styles'.
-
Define new properties in
src/types/theme-config.d.ts:import type { ThemeOptions } from '@mui/material/styles'; export interface ThemeConfig extends ThemeOptions { applicationName: string; companyName: string; logoUrl: string; logoHeight: number; customSpacing: { small: number; medium: number; large: string }; brandColors: { accentFocus: string }; } declare module '@mui/material/styles' { interface Theme { applicationName: string; companyName: string; logoUrl: string; logoHeight: number; customSpacing: { small: number; medium: number; large: string }; brandColors: { accentFocus: string }; } interface ThemeOptions { applicationName?: string; companyName?: string; logoUrl?: string; logoHeight?: number; customSpacing?: { small?: number; medium?: number; large?: string }; brandColors?: { accentFocus?: string }; } }
-
Add to JSON configs:
-
In
public/default-theme-config.json:{ "applicationName": "INANNA", "companyName": "ROR", "logoUrl": "/assets/hathor-logo.png", "logoHeight": 48, "customSpacing": { "small": 8, "medium": 16, "large": "32px" }, "brandColors": { "accentFocus": "#FFD700" } } -
In
public/custom-theme-config.json:{ "applicationName": "My Custom App", "companyName": "My Company", "logoUrl": "/assets/my-custom-logo.png", "logoHeight": 32, "customSpacing": { "small": 4, "medium": 12, "large": "24px" }, "brandColors": { "accentFocus": "#10A37F" } }
-
-
Use custom properties in components:
import { Box, Typography, useTheme } from '@mui/material'; function MyCustomComponent() { const theme = useTheme(); return ( <Box sx={{ padding: theme.customSpacing.medium, border: `2px solid ${theme.brandColors.accentFocus}`, }}> <Typography sx={{ marginBottom: theme.customSpacing.small }}> This component uses custom theme properties! </Typography> <Typography sx={{ fontSize: theme.customSpacing.large }}> Large Text! </Typography> </Box> ); } export default MyCustomComponent;
The createThemeFromConfig utility in src/utils/createThemeFromConfig.ts will automatically include all custom properties defined in your theme configs.
By following these instructions, you can fully customize the Hathor application’s theme, icons, and extend its theming system in a type-safe manner.
This guide will walk you through the steps to create a new, fully-featured data table page, complete with sorting, filtering, searching, and editing capabilities.
First, you need to define the shape of your data. Create a new file in your feature's folder (e.g., /src/data/your-feature/yourFeatureTypes.ts).
You will need two types:
- An interface for your main data object.
- A type for the keys of the properties you want to be sortable.
// The main data structure
export interface Product {
id: string;
version: number;
name: string;
price: number;
stock: number;
category: 'Electronics' | 'Books' | 'Clothing';
}
// The keys that the user can sort the table by
export type ProductSortKey = 'name' | 'price' | 'stock';This hook is responsible for fetching, sorting, and paginating your data. It can get data from a live API or, for this example, from a mock data file. Create a file like /src/data/your-feature/useYourFeature.ts. This hook must return an object with a specific shape that the generic page understands.
import { useState, useMemo } from 'react';
import type { ProductSortKey } from './productTypes.ts';
import type { Order } from '../stop-places/useStopPlaces.ts';
import { MOCK_PRODUCTS } from './data/mockProducts.ts';
// ... (helper functions for sorting)
export function useProducts() {
// State for sorting and pagination
const [order, setOrder] = useState<Order>('asc');
const [orderBy, setOrderBy] = useState<ProductSortKey>('name');
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
// Logic to handle sort requests
const handleRequestSort = (property: ProductSortKey) => {
// ...
};
// Memoized sorting logic
const sortedData = useMemo(() => {
// ...
}, [order, orderBy]);
// Return the data and state handlers
return {
allData: sortedData,
totalCount: MOCK_PRODUCTS.length,
loading: false, // Set to true while fetching from an API
error: null, // Set to an error message on failure
order,
orderBy,
handleRequestSort,
page,
rowsPerPage,
setPage,
setRowsPerPage,
};
}This is the component that will appear in the sidebar when a user clicks the "Edit" button on a table row. It must accept a prop named itemId. Create a file like /src/data/your-feature/YourFeatureEditor.tsx.
import { Box, Typography, Button } from '@mui/material';
import { useEditing } from '../../contexts/EditingContext.tsx';
interface ProductEditorProps {
itemId: string; // This prop is required
}
export default function ProductEditor({ itemId }: ProductEditorProps) {
const { setEditingItem } = useEditing();
return (
<Box sx={{ p: 2 }}>
<Typography variant="h6">Edit Product</Typography>
<Typography>ID: {itemId}</Typography>
{/* Your form fields would go here */}
<Button onClick={() => setEditingItem(null)}>Close</Button>
</Box>
);
}For each column in your table, you need to define how it renders.
- For simple text, you can do this inline in the main config file.
- For complex rendering (like a formatted price or an "Edit" button), create a dedicated component.
interface PriceCellProps {
price: number;
}
export default function PriceCell({ price }: PriceCellProps) {
const formattedPrice = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price);
return <span>{formattedPrice}</span>;
}This cell is crucial. It links the table row to the EditingContext and tells the sidebar which editor to open.
import { IconButton, Tooltip } from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import { useEditing } from '../../../contexts/EditingContext.tsx';
import type { Product } from '../productTypes.ts';
import ProductEditor from '../ProductEditor.tsx'; // Import your editor
interface EditActionCellProps {
item: Product;
}
export default function EditActionCell({ item }: EditActionCellProps) {
const { setEditingItem } = useEditing();
return (
<Tooltip title="Edit Product">
<IconButton
onClick={() =>
// Provide the item's ID and the Editor Component
setEditingItem({ id: item.id, EditorComponent: ProductEditor })
}
color="primary"
>
<EditIcon />
</IconButton>
</Tooltip>
);
}This hook connects your data to the application's global search bar. It registers a function that knows how to filter your specific data. Create a file like /src/data/your-feature/useYourFeatureSearch.ts.
import { useCallback, useEffect } from 'react';
import { useSearch } from '../../components/search';
import type { SearchResultItem } from '../../components/search/searchTypes';
import type { Product } from './productTypes.ts';
export function useProductSearch(allProducts: Product[] | null, productsLoading: boolean) {
const { setActiveSearchContext, registerSearchFunction } = useSearch();
// This function contains the logic to filter your data
const searchProductData = useCallback(
async (query: string, filters: string[]): Promise<SearchResultItem[]> => {
if (productsLoading || !allProducts) return [];
// ... your filtering logic here
return allProducts
.filter(p => {
// ...
})
.map(p => ({
id: p.id,
name: p.name,
type: 'data' as const,
originalData: p,
}));
},
[productsLoading, allProducts]
);
// This effect registers the search function with the context
useEffect(() => {
setActiveSearchContext('data');
registerSearchFunction('data', searchProductData);
return () => {
registerSearchFunction('data', null); // Cleanup
};
}, [setActiveSearchContext, registerSearchFunction, searchProductData]);
}This is the central file that brings everything together. It exports a single configuration object that the generic page component will use. Create a file like /src/data/your-feature/yourFeatureViewConfig.tsx.
// Generic Imports
import { useDataViewTableLogic } from '../../hooks/useDataViewTableLogic';
import DataPageContent from '../../components/data/DataPageContent';
import type { ColumnDefinition } from '../../components/data/dataTableTypes.ts';
import type { FilterDefinition } from '../../components/search/searchTypes.ts';
// Your Feature-Specific Imports
import { useProducts } from './useProducts.ts';
import { useProductSearch } from './useProductSearch.ts';
import type { Product, ProductSortKey } from './productTypes.ts';
import PriceCell from './cells/PriceCell.tsx';
import EditActionCell from './cells/EditActionCell.tsx';
// 1. Define your table columns
const productColumns: ColumnDefinition<Product, ProductSortKey>[] = [
{ id: 'name', headerLabel: 'Product Name', isSortable: true, renderCell: item => item.name, display: 'always' },
{ id: 'price', headerLabel: 'Price', isSortable: true, renderCell: item => <PriceCell price={item.price} />, display: 'desktop-only' },
// ... more columns
{ id: 'actions', headerLabel: 'Actions', align: 'center', renderCell: item => <EditActionCell item={item} />, display: 'always' },
];
// 2. Define your filter and sort helper functions
const getProductFilterKey = (item: Product): string => item.category;
const getProductSortValue = (item: Product, key: ProductSortKey): string | number => item[key];
// 3. Define your filter UI options
const productFilters: FilterDefinition[] = [
{ id: 'Electronics', labelKey: 'product.electronics', defaultLabel: 'Electronics' },
// ... more filters
];
// 4. Export the final configuration object
export const productViewConfig = {
useData: useProducts,
useSearchRegistration: useProductSearch,
useTableLogic: useDataViewTableLogic,
PageContentComponent: DataPageContent,
columns: productColumns,
getFilterKey: getProductFilterKey,
getSortValue: getProductSortValue,
filters: productFilters,
};This is the easiest step. Create a new page component that imports your viewConfig and the GenericDataViewPage. Create a file like /src/data/your-feature/YourFeatureView.tsx.
import { productViewConfig } from './productViewConfig.tsx';
import GenericDataViewPage from '../../pages/GenericDataViewPage.tsx';
import type { Product, ProductSortKey } from './productTypes.ts';
export default function ProductView() {
// Pass the config and explicit types to the generic page
return <GenericDataViewPage<Product, ProductSortKey> viewConfig={productViewConfig} />;
}Finally, add a route for your new page in your main router (e.g., App.tsx) and a link to it in the main menu (e.g., Menu.tsx). By following these steps, you can quickly and cleanly add new data views to the application, leveraging the reusable architecture to minimize boilerplate and ensure consistency.
A fully funct ioning version of the Product example can be found in /src/data/products/ . Alongside another example under /src/data/stop-places.