trying to port web app starter from PHP

This commit is contained in:
udo
2026-04-19 09:38:23 +00:00
parent 46d98a092f
commit be514d63d6
546 changed files with 76910 additions and 2807 deletions
@@ -0,0 +1,130 @@
<?php
if (!function_exists('sortable_table_format_bytes')) {
function sortable_table_format_bytes($bytes, $disk = false)
{
if ($bytes === null || $bytes === '') return '--';
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
$value = (float)$bytes;
$unitIndex = 0;
while (abs($value) >= 1024 && $unitIndex < count($units) - 1) {
$value /= 1024;
$unitIndex += 1;
}
$decimals = $disk ? ($unitIndex >= 4 ? 2 : ($unitIndex >= 1 ? 1 : 0)) : ($unitIndex === 0 ? 0 : 1);
return number_format($value, $decimals) . ' ' . $units[$unitIndex];
}
}
if (!function_exists('sortable_table_format_value')) {
function sortable_table_format_value($value, $row, $column)
{
$format = $column['format'] ?? null;
if (is_callable($format)) {
return [$format($value, $row, $column), $value];
}
switch ($format) {
case 'number':
return [number_format((float)$value), (float)$value];
case 'bytes':
return [sortable_table_format_bytes($value, false), (float)$value];
case 'disk-bytes':
return [sortable_table_format_bytes($value, true), (float)$value];
case 'percent':
return [number_format((float)$value, 1) . '%', (float)$value];
case 'duration-ms':
$number = (float)$value;
if ($number >= 1000) {
return [number_format($number / 1000, $number >= 10000 ? 0 : 1) . ' s', $number];
}
return [number_format($number, $number >= 100 ? 0 : 1) . ' ms', $number];
case 'bool':
return [$value ? 'Yes' : 'No', $value ? 1 : 0];
default:
if (is_bool($value)) {
return [$value ? 'Yes' : 'No', $value ? 1 : 0];
}
return [(string)$value, is_scalar($value) ? $value : ''];
}
}
}
return [
'render' => function($prop) {
include_js('js/u-format.js');
include_js('js/u-sortable-table.js');
$tableId = (string)($prop['id'] ?? ('sortable-table-' . uniqid()));
$title = (string)($prop['title'] ?? '');
$subtitle = (string)($prop['subtitle'] ?? '');
$columns = is_array($prop['columns'] ?? null) ? $prop['columns'] : [];
$rows = is_array($prop['rows'] ?? null) ? $prop['rows'] : [];
$emptyLabel = (string)($prop['empty_label'] ?? 'No data available');
$storageKey = (string)($prop['storage_key'] ?? ('starter.sort.' . $tableId));
$sort = is_array($prop['sort'] ?? null) ? $prop['sort'] : [];
$initOptions = ['storageKey' => $storageKey];
if (isset($sort['column'])) {
$initOptions['initialSort'] = [
'column' => (int)$sort['column'],
'direction' => (($sort['direction'] ?? 'asc') === 'desc') ? 'desc' : 'asc',
];
}
$jsonFlags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
ob_start();
?>
<div class="dashboard-panel">
<?php if ($title !== '' || $subtitle !== ''): ?>
<div class="dashboard-panel-header">
<?php if ($title !== ''): ?><h2><?= safe($title) ?></h2><?php endif; ?>
<?php if ($subtitle !== ''): ?><p><?= safe($subtitle) ?></p><?php endif; ?>
</div>
<?php endif; ?>
<div class="dashboard-table-wrap">
<table id="<?= asafe($tableId) ?>" class="u-sortable-table">
<thead>
<tr>
<?php foreach ($columns as $column): ?>
<?php
$align = (string)($column['align'] ?? 'left');
$sortable = !isset($column['sortable']) || $column['sortable'];
?>
<th scope="col" class="align-<?= asafe($align) ?>"<?= !$sortable ? ' data-sortable="false"' : '' ?>><?= safe((string)($column['label'] ?? $column['key'] ?? 'Column')) ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php if (!$rows): ?>
<tr data-empty-row="1"><td colspan="<?= count($columns) ?: 1 ?>" class="muted"><?= safe($emptyLabel) ?></td></tr>
<?php endif; ?>
<?php foreach ($rows as $row): ?>
<tr>
<?php foreach ($columns as $column): ?>
<?php
$key = (string)($column['key'] ?? '');
$value = $row[$key] ?? '';
list($displayValue, $sortValue) = sortable_table_format_value($value, $row, $column);
if (isset($column['sort_value']) && is_callable($column['sort_value'])) {
$sortValue = $column['sort_value']($value, $row, $column);
}
?>
<td class="align-<?= asafe((string)($column['align'] ?? 'left')) ?>" data-sort-value="<?= asafe((string)$sortValue) ?>"><?= safe((string)$displayValue) ?></td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<script>
(function () {
if (typeof USortableTable === 'undefined') return;
USortableTable.init(<?= jsafe($tableId) ?>, <?= json_encode($initOptions, $jsonFlags) ?>);
}());
</script>
<?php
return ob_get_clean();
},
'about' => 'Lightweight sortable HTML table with remembered sort state and human-readable data formatting',
];
@@ -0,0 +1,37 @@
<?php return [
'render' => function($prop) {
$items = is_array($prop['items'] ?? null) ? $prop['items'] : [];
$title = (string)($prop['title'] ?? '');
$subtitle = (string)($prop['subtitle'] ?? '');
ob_start();
?>
<div class="dashboard-panel">
<?php if ($title !== '' || $subtitle !== ''): ?>
<div class="dashboard-panel-header">
<?php if ($title !== ''): ?><h2><?= safe($title) ?></h2><?php endif; ?>
<?php if ($subtitle !== ''): ?><p><?= safe($subtitle) ?></p><?php endif; ?>
</div>
<?php endif; ?>
<div class="dashboard-stat-grid">
<?php foreach ($items as $item): ?>
<?php
$tone = preg_replace('/[^a-z0-9_-]+/i', '', (string)($item['tone'] ?? 'info'));
$tag = !empty($item['href']) ? 'a' : 'div';
$href = !empty($item['href']) ? ' href="' . asafe((string)$item['href']) . '"' : '';
?>
<<?= $tag ?> class="dashboard-stat-card tone-<?= asafe($tone) ?>"<?= $href ?>>
<div class="dashboard-stat-label"><?= safe((string)($item['label'] ?? 'Metric')) ?></div>
<div class="dashboard-stat-value"><?= safe((string)($item['value'] ?? '--')) ?></div>
<?php if (!empty($item['meta'])): ?>
<div class="dashboard-stat-meta"><?= safe((string)$item['meta']) ?></div>
<?php endif; ?>
</<?= $tag ?>>
<?php endforeach; ?>
</div>
</div>
<?php
return ob_get_clean();
},
'about' => 'Responsive metric cards extracted from dashboard-style pages and adapted for generic starter overviews',
];
+297
View File
@@ -0,0 +1,297 @@
<?php return [
'render' => function($prop) {
// Generate unique ID for this table instance
$tableId = $prop['id'] ?? 'data-grid-' . uniqid();
$data = $prop['items'] ?? [];
$columns = $prop['columns'] ?? null;
$options = $prop['options'] ?? [];
// Auto-generate columns if not provided
if (!$columns && !empty($data)) {
$columns = [];
$firstItem = reset($data);
if (is_array($firstItem)) {
foreach (array_keys($firstItem) as $key) {
$columns[] = [
'field' => $key,
'headerName' => ucfirst(str_replace('_', ' ', $key)),
'sortable' => true,
'filter' => true,
'resizable' => true
];
}
}
}
// Default grid options (Community edition compatible)
$defaultOptions = [
'pagination' => true,
'paginationPageSize' => 20,
'suppressMenuHide' => true,
'animateRows' => true,
'defaultColDef' => [
'sortable' => true,
'filter' => true,
'resizable' => true,
'minWidth' => 100
]
];
$gridOptions = array_merge($defaultOptions, $options);
$gridOptions['columnDefs'] = $columns;
$gridOptions['rowData'] = $data;
?>
<!-- ag-Grid Data Table Component -->
<div class="ag-grid-container" style="margin: 1rem 0;">
<div class="ag-grid-toolbar" style="
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--surface-elevated);
border: 1px solid var(--border);
border-radius: 0.5rem 0.5rem 0 0;
border-bottom: none;
">
<div style="display: flex; gap: 1rem; align-items: center; flex: 1;">
<span style="color: var(--text-secondary); font-size: 0.875rem; font-weight: 500;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 0.25rem;">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<line x1="9" y1="9" x2="15" y2="9"/>
<line x1="9" y1="15" x2="15" y2="15"/>
</svg>
<?= count($data) ?> rows
</span>
<!-- Search Input -->
<div style="position: relative; display: flex; align-items: center;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" stroke-width="2" style="position: absolute; left: 0.75rem; z-index: 1;">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.35-4.35"/>
</svg>
<input
type="text"
id="<?= $tableId ?>-search"
placeholder="Search all columns..."
style="
padding: 0.5rem 0.75rem 0.5rem 2.5rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--surface);
color: var(--text-primary);
font-size: 0.875rem;
width: 250px;
transition: all 0.2s ease;
"
onFocus="this.style.borderColor='var(--primary)'; this.style.boxShadow='0 0 0 2px var(--primary-light)';"
onBlur="this.style.borderColor='var(--border)'; this.style.boxShadow='none';"
/>
</div>
</div>
<div style="display: flex; gap: 0.5rem;">
<button id="<?= $tableId ?>-export-csv" style="
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-secondary);
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.75rem;
transition: all 0.2s ease;
"
onmouseover="this.style.background='var(--primary)'; this.style.color='white'; this.style.borderColor='var(--primary)';"
onmouseout="this.style.background='var(--surface)'; this.style.color='var(--text-secondary)'; this.style.borderColor='var(--border)';">
📊 Export CSV
</button>
<button id="<?= $tableId ?>-clear-filters" style="
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-secondary);
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.75rem;
transition: all 0.2s ease;
"
onmouseover="this.style.background='var(--surface-elevated)'; this.style.borderColor='var(--primary)';"
onmouseout="this.style.background='var(--surface)'; this.style.borderColor='var(--border)';">
🔄 Clear Filters
</button>
</div>
</div>
<div id="<?= $tableId ?>" class="ag-theme-alpine-dark" style="
height: <?= $prop['height'] ?? '400px' ?>;
width: 100%;
border: 1px solid var(--border);
border-radius: 0 0 0.5rem 0.5rem;
overflow: hidden;
"></div>
</div>
<!-- ag-Grid Theme Override Styles -->
<style>
.ag-theme-alpine-dark {
--ag-background-color: var(--surface);
--ag-foreground-color: var(--text-primary);
--ag-border-color: var(--border);
--ag-secondary-border-color: var(--border);
--ag-header-background-color: var(--surface-elevated);
--ag-header-foreground-color: var(--text-primary);
--ag-odd-row-background-color: var(--surface);
--ag-even-row-background-color: var(--surface-elevated);
--ag-row-hover-color: var(--surface-hover);
--ag-selected-row-background-color: var(--primary-light);
--ag-range-selection-background-color: var(--primary-light);
--ag-range-selection-border-color: var(--primary);
--ag-input-focus-border-color: var(--primary);
--ag-minier-foreground-color: var(--text-secondary);
--ag-subtle-text-color: var(--text-secondary);
--ag-disabled-foreground-color: var(--text-muted);
}
.ag-theme-alpine-dark .ag-header-cell-label {
font-weight: 600;
}
.ag-theme-alpine-dark .ag-cell {
border-right: 1px solid var(--border);
}
.ag-theme-alpine-dark .ag-header-cell {
border-right: 1px solid var(--border);
}
.ag-theme-alpine-dark .ag-paging-panel {
border-top: 1px solid var(--border);
background: var(--surface-elevated);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.ag-grid-toolbar {
flex-direction: column !important;
gap: 0.75rem !important;
align-items: stretch !important;
}
.ag-grid-toolbar > div {
justify-content: center !important;
}
}
</style>
<script>
if (typeof agGrid === 'undefined') {
const agGridCSS = document.createElement('link');
agGridCSS.rel = 'stylesheet';
agGridCSS.href = 'js/ag-grid/ag-grid.css';
document.head.appendChild(agGridCSS);
const agGridThemeCSS = document.createElement('link');
agGridThemeCSS.rel = 'stylesheet';
agGridThemeCSS.href = 'js/ag-grid/ag-theme-alpine.css';
document.head.appendChild(agGridThemeCSS);
const agGridScript = document.createElement('script');
agGridScript.src = 'js/ag-grid/ag-grid-community.min.js';
agGridScript.onload = function() {
initializeGrid_<?= $tableId ?>();
};
document.head.appendChild(agGridScript);
} else {
initializeGrid_<?= $tableId ?>();
}
function initializeGrid_<?= $tableId ?>() {
const gridOptions = <?= json_encode($gridOptions, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
// Enhanced column definitions with better formatting
gridOptions.columnDefs = gridOptions.columnDefs.map(col => {
// Add value formatters based on data type
if (col.field && gridOptions.rowData.length > 0) {
const sampleValue = gridOptions.rowData[0][col.field];
if (typeof sampleValue === 'number') {
col.type = 'numericColumn';
col.cellClass = 'ag-right-aligned-cell';
col.valueFormatter = params => {
if (params.value != null) {
return new Intl.NumberFormat().format(params.value);
}
return '';
};
} else if (typeof sampleValue === 'string' && !isNaN(Date.parse(sampleValue)) && sampleValue.includes('-')) {
col.valueFormatter = params => {
if (params.value) {
const date = new Date(params.value);
return date.toLocaleDateString();
}
return '';
};
} else if (typeof sampleValue === 'boolean') {
col.cellRenderer = params => {
return params.value ?
'<span style="color: #10b981;">✓ Yes</span>' :
'<span style="color: #ef4444;">✗ No</span>';
};
}
}
return col;
});
// Initialize the grid
const gridDiv = document.querySelector('#<?= $tableId ?>');
if (gridDiv && window.agGrid) {
const gridApi = agGrid.createGrid(gridDiv, gridOptions);
// Toolbar button handlers
document.getElementById('<?= $tableId ?>-export-csv')?.addEventListener('click', () => {
gridApi.exportDataAsCsv({
fileName: 'data-export-' + new Date().toISOString().split('T')[0] + '.csv'
});
});
document.getElementById('<?= $tableId ?>-clear-filters')?.addEventListener('click', () => {
gridApi.setFilterModel(null);
if (gridApi.setQuickFilter) {
gridApi.setQuickFilter('');
}
// Clear search input
const searchInput = document.getElementById('<?= $tableId ?>-search');
if (searchInput) {
searchInput.value = '';
}
});
// Auto-size columns on first data render
gridApi.addEventListener('firstDataRendered', () => {
gridApi.autoSizeAllColumns();
});
// Connect search input to quick filter
const searchInput = document.getElementById('<?= $tableId ?>-search');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const filterText = e.target.value;
if (gridApi.setQuickFilter) {
gridApi.setQuickFilter(filterText);
}
});
}
// Store grid API for external access
window['gridApi_<?= $tableId ?>'] = gridApi;
console.log('ag-Grid initialized for <?= $tableId ?> with', gridOptions.rowData.length, 'rows');
}
}
</script>
<?php
},
'about' => 'A powerful data grid component powered by ag-Grid with sorting, filtering, pagination, and export capabilities'
];
@@ -0,0 +1,44 @@
<?php return [
'render' => function($prop) {
include_js('js/u-format.js');
include_js('js/u-timeseries-chart.js');
$chartId = (string)($prop['id'] ?? ('ts-chart-' . uniqid()));
$canvasId = $chartId . '-canvas';
$title = (string)($prop['title'] ?? '');
$subtitle = (string)($prop['subtitle'] ?? '');
$height = max(180, (int)($prop['height'] ?? 320));
$series = array_values(is_array($prop['series'] ?? null) ? $prop['series'] : []);
$xLabels = array_values(is_array($prop['x_labels'] ?? null) ? $prop['x_labels'] : []);
$jsonFlags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
ob_start();
?>
<div class="dashboard-panel">
<?php if ($title !== '' || $subtitle !== ''): ?>
<div class="dashboard-panel-header">
<?php if ($title !== ''): ?><h2><?= safe($title) ?></h2><?php endif; ?>
<?php if ($subtitle !== ''): ?><p><?= safe($subtitle) ?></p><?php endif; ?>
</div>
<?php endif; ?>
<canvas id="<?= asafe($canvasId) ?>" class="dashboard-chart-canvas" style="height: <?= $height ?>px"></canvas>
</div>
<script>
(function () {
if (typeof UTimeSeriesChart === 'undefined') return;
var chart = new UTimeSeriesChart(<?= jsafe($canvasId) ?>, {
xAxisLabel: <?= jsafe((string)($prop['x_axis_label'] ?? 'Time')) ?>,
yAxisLeftLabel: <?= jsafe((string)($prop['y_axis_left_label'] ?? 'Value')) ?>,
yAxisRightLabel: <?= jsafe((string)($prop['y_axis_right_label'] ?? '')) ?>,
yAxisLeftFormat: <?= jsafe((string)($prop['y_axis_left_format'] ?? 'number')) ?>,
yAxisRightFormat: <?= jsafe((string)($prop['y_axis_right_format'] ?? 'number')) ?>
});
chart.setData(<?= json_encode($series, $jsonFlags) ?>, <?= json_encode($xLabels, $jsonFlags) ?>);
window[<?= jsafe('chart_' . $chartId) ?>] = chart;
}());
</script>
<?php
return ob_get_clean();
},
'about' => 'Canvas-based multi-series time-series chart component backported from a production dashboard',
];