trying to port web app starter from PHP
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$services = $prop['services'] ?? [
|
||||
'google' => [
|
||||
'name' => 'Google',
|
||||
'color' => '#4285f4',
|
||||
'icon' => 'fab fa-google',
|
||||
'scope' => 'openid email profile',
|
||||
'auth_url' => 'https://accounts.google.com/oauth/authorize',
|
||||
'token_url' => 'https://oauth2.googleapis.com/token',
|
||||
'user_info_url' => 'https://www.googleapis.com/oauth2/v2/userinfo'
|
||||
],
|
||||
'github' => [
|
||||
'name' => 'GitHub',
|
||||
'color' => '#333333',
|
||||
'icon' => 'fab fa-github',
|
||||
'scope' => 'user:email',
|
||||
'auth_url' => 'https://github.com/login/oauth/authorize',
|
||||
'token_url' => 'https://github.com/login/oauth/access_token',
|
||||
'user_info_url' => 'https://api.github.com/user'
|
||||
],
|
||||
'discord' => [
|
||||
'name' => 'Discord',
|
||||
'color' => '#5865f2',
|
||||
'icon' => 'fab fa-discord',
|
||||
'scope' => 'identify email',
|
||||
'auth_url' => 'https://discord.com/api/oauth2/authorize',
|
||||
'token_url' => 'https://discord.com/api/oauth2/token',
|
||||
'user_info_url' => 'https://discord.com/api/users/@me'
|
||||
],
|
||||
'twitch' => [
|
||||
'name' => 'Twitch',
|
||||
'color' => '#9146ff',
|
||||
'icon' => 'fab fa-twitch',
|
||||
'scope' => 'user:read:email',
|
||||
'auth_url' => 'https://id.twitch.tv/oauth2/authorize',
|
||||
'token_url' => 'https://id.twitch.tv/oauth2/token',
|
||||
'user_info_url' => 'https://api.twitch.tv/helix/users'
|
||||
]
|
||||
];
|
||||
$title = $prop['title'] ?? 'Sign in with OAuth';
|
||||
$subtitle = $prop['subtitle'] ?? 'Choose your preferred authentication method';
|
||||
$callback_url = $prop['callback_url'] ?? URL::Link('auth/callback');
|
||||
|
||||
?>
|
||||
<form class="card">
|
||||
<div>
|
||||
<h2><?= safe($title) ?></h2>
|
||||
<label><?= safe($subtitle) ?></label>
|
||||
</div>
|
||||
|
||||
<?php foreach($services as $service_key => $service): ?>
|
||||
<div data-service="<?= safe($service_key) ?>">
|
||||
<button type="button" class="btn" onclick="initiateOAuth('<?= safe($service_key) ?>')">
|
||||
<i class="<?= safe($service['icon']) ?>" style="color: <?= safe($service['color']) ?>"></i>
|
||||
Continue with <?= safe($service['name']) ?>
|
||||
</button>
|
||||
|
||||
<div class="loading" style="display: none;">
|
||||
<span>Connecting...</span>
|
||||
</div>
|
||||
|
||||
<label>Secure authentication via <?= safe($service['name']) ?></label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div class="banner" id="oauth-status" style="display: none;">
|
||||
<div class="status-icon"></div>
|
||||
<div class="status-message"></div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card" id="oauth-debug" style="display: none;">
|
||||
<h4>Debug Information</h4>
|
||||
<pre id="oauth-debug-content"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// OAuth configuration for different services
|
||||
const oauthConfig = {
|
||||
<?php foreach($services as $service_key => $service): ?>
|
||||
<?= $service_key ?>: {
|
||||
clientId: '<?= safe($prop[$service_key . '_client_id'] ?? 'YOUR_' . strtoupper($service_key) . '_CLIENT_ID') ?>',
|
||||
clientSecret: '<?= safe($prop[$service_key . '_client_secret'] ?? '') ?>', // Only needed for server-side flow
|
||||
redirectUri: '<?= safe($callback_url) ?>',
|
||||
scope: '<?= safe($service['scope']) ?>',
|
||||
authUrl: '<?= safe($service['auth_url']) ?>',
|
||||
tokenUrl: '<?= safe($service['token_url']) ?>',
|
||||
userInfoUrl: '<?= safe($service['user_info_url']) ?>',
|
||||
additionalParams: {
|
||||
<?php
|
||||
// Add service-specific parameters
|
||||
switch ($service_key) {
|
||||
case 'google':
|
||||
echo "'access_type': 'offline', 'prompt': 'select_account'";
|
||||
break;
|
||||
case 'discord':
|
||||
echo "'prompt': 'consent'";
|
||||
break;
|
||||
case 'github':
|
||||
echo "'allow_signup': 'true'";
|
||||
break;
|
||||
case 'twitch':
|
||||
echo "'force_verify': 'true'";
|
||||
break;
|
||||
}
|
||||
?>
|
||||
}
|
||||
},
|
||||
<?php endforeach; ?>
|
||||
};
|
||||
|
||||
// Global OAuth functions
|
||||
window.initiateOAuth = function(service) {
|
||||
const $serviceDiv = $(`[data-service="${service}"]`);
|
||||
const $button = $serviceDiv.find('.btn');
|
||||
const $loading = $serviceDiv.find('.loading');
|
||||
const $status = $('#oauth-status');
|
||||
const $debug = $('#oauth-debug');
|
||||
|
||||
// Show loading state
|
||||
$button.hide();
|
||||
$loading.show();
|
||||
|
||||
// Hide previous status/debug info
|
||||
$status.hide();
|
||||
$debug.hide();
|
||||
|
||||
if (oauthConfig[service]) {
|
||||
initiateOAuthFlow(service);
|
||||
} else {
|
||||
showError('Unsupported OAuth service: ' + service);
|
||||
resetButton(service);
|
||||
}
|
||||
|
||||
function resetButton(service) {
|
||||
setTimeout(() => {
|
||||
$(`[data-service="${service}"] .btn`).show();
|
||||
$(`[data-service="${service}"] .loading`).hide();
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
function initiateOAuthFlow(service) {
|
||||
const config = oauthConfig[service];
|
||||
|
||||
if (config.clientId.includes('YOUR_')) {
|
||||
showError(`${service.charAt(0).toUpperCase() + service.slice(1)} OAuth not configured. Please set ${service}_client_id in the component properties.`);
|
||||
$(`[data-service="${service}"] .btn`).show();
|
||||
$(`[data-service="${service}"] .loading`).hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Build OAuth URL
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
redirect_uri: config.redirectUri,
|
||||
scope: config.scope,
|
||||
response_type: 'code',
|
||||
state: generateState()
|
||||
});
|
||||
|
||||
// Add service-specific parameters
|
||||
for (const [key, value] of Object.entries(config.additionalParams || {})) {
|
||||
params.set(key, value);
|
||||
}
|
||||
|
||||
const authUrl = `${config.authUrl}?${params.toString()}`;
|
||||
|
||||
// Store state in session storage for verification
|
||||
sessionStorage.setItem('oauth_state', params.get('state'));
|
||||
sessionStorage.setItem('oauth_service', service);
|
||||
|
||||
// Also store in PHP session for callback handler
|
||||
<?php if (session_status() === PHP_SESSION_ACTIVE): ?>
|
||||
$.post('<?= URL::Link('auth/store-oauth-session') ?>', {
|
||||
oauth_service: service,
|
||||
oauth_state: params.get('state')
|
||||
}).catch(console.error);
|
||||
<?php endif; ?>
|
||||
|
||||
// Debug information
|
||||
showDebug({
|
||||
service: service,
|
||||
authUrl: authUrl,
|
||||
clientId: config.clientId,
|
||||
redirectUri: config.redirectUri,
|
||||
scope: config.scope,
|
||||
state: params.get('state')
|
||||
});
|
||||
|
||||
// Redirect to OAuth provider
|
||||
window.location.href = authUrl;
|
||||
}
|
||||
|
||||
function generateState() {
|
||||
return btoa(Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15)).replace(/[^a-zA-Z0-9]/g, '');
|
||||
}
|
||||
|
||||
function showStatus(message, type = 'loading') {
|
||||
const $status = $('#oauth-status');
|
||||
const $icon = $status.find('.status-icon');
|
||||
const $message = $status.find('.status-message');
|
||||
|
||||
$status.removeClass('success error loading').addClass(type).show();
|
||||
|
||||
let icon = '';
|
||||
switch(type) {
|
||||
case 'success': icon = '✓'; break;
|
||||
case 'error': icon = '✗'; break;
|
||||
case 'loading': icon = '⏳'; break;
|
||||
}
|
||||
|
||||
$icon.text(icon);
|
||||
$message.text(message);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
showStatus(message, 'error');
|
||||
}
|
||||
|
||||
function showSuccess(message) {
|
||||
showStatus(message, 'success');
|
||||
}
|
||||
|
||||
function showDebug(data) {
|
||||
$('#oauth-debug-content').text(JSON.stringify(data, null, 2));
|
||||
$('#oauth-debug').show();
|
||||
}
|
||||
|
||||
// Check for OAuth callback parameters on page load
|
||||
$.ready(function() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const state = urlParams.get('state');
|
||||
const error = urlParams.get('error');
|
||||
|
||||
if (error) {
|
||||
showError('OAuth failed: ' + (urlParams.get('error_description') || error));
|
||||
return;
|
||||
}
|
||||
|
||||
if (code && state) {
|
||||
const storedState = sessionStorage.getItem('oauth_state');
|
||||
const service = sessionStorage.getItem('oauth_service');
|
||||
|
||||
if (state !== storedState) {
|
||||
showError('Invalid OAuth state. Possible security issue.');
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus('Authorization successful! Processing...', 'success');
|
||||
|
||||
showDebug({
|
||||
step: 'callback_received',
|
||||
service: service,
|
||||
code: code.substring(0, 20) + '...',
|
||||
state: state,
|
||||
next_steps: [
|
||||
'Send code to backend OAuth handler',
|
||||
'Exchange code for access token',
|
||||
'Get user profile from OAuth provider',
|
||||
'Create or login user account',
|
||||
'Set session and redirect to dashboard'
|
||||
]
|
||||
});
|
||||
|
||||
// Clean up session storage
|
||||
sessionStorage.removeItem('oauth_state');
|
||||
sessionStorage.removeItem('oauth_service');
|
||||
|
||||
// Remove OAuth parameters from URL for cleaner display
|
||||
const cleanUrl = window.location.pathname;
|
||||
window.history.replaceState({}, document.title, cleanUrl);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
},
|
||||
|
||||
'about' => 'OAuth authentication component with support for Google and other providers. Handles the complete OAuth flow including state verification and callback processing.'
|
||||
];
|
||||
@@ -0,0 +1,245 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
?>
|
||||
<!-- Cookie Consent Banner -->
|
||||
<div id="cookie-consent" style="
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-xl);
|
||||
z-index: 9998;
|
||||
padding: 1.5rem;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
" aria-live="polite">
|
||||
<div style="
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
">
|
||||
<div style="flex: 1; min-width: 300px;">
|
||||
<div style="
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" stroke-width="2" style="flex-shrink: 0;">
|
||||
<path d="M12 2C13.1 2 14 2.9 14 4C14 5.1 13.1 6 12 6C10.9 6 10 5.1 10 4C10 2.9 10.9 2 12 2M21 9V7L15 1L13 3L15 5H3V21A2 2 0 0 0 5 23H19A2 2 0 0 0 21 21V11L19 13V20H5V7H21M9 11V13H7V11H9M13 11V13H11V11H13M17 11V13H15V11H17M9 15V17H7V15H9M13 15V17H11V15H13M17 15V17H15V15H17Z"/>
|
||||
</svg>
|
||||
<h3 style="
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
">Cookie Notice</h3>
|
||||
</div>
|
||||
<p style="
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
">
|
||||
<?= first($prop['text'] ?? false,
|
||||
'We use cookies to enhance your browsing experience, serve personalized content, and analyze our traffic.
|
||||
By clicking "Accept All", you consent to our use of cookies.') ?>
|
||||
<a href="#" id="cookie-policy-link" style="
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 0.2s ease;
|
||||
"
|
||||
onmouseover="this.style.borderColor='var(--primary)';"
|
||||
onmouseout="this.style.borderColor='transparent';">
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div style="
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
">
|
||||
<button id="cookie-reject" style="
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
"
|
||||
onmouseover="this.style.background='var(--surface-elevated)'; this.style.borderColor='var(--primary)'; this.style.color='var(--text-primary)';"
|
||||
onmouseout="this.style.background='transparent'; this.style.borderColor='var(--border)'; this.style.color='var(--text-secondary)';">
|
||||
Reject All
|
||||
</button>
|
||||
<button id="cookie-accept" style="
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: 1px solid var(--primary);
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--shadow-sm);
|
||||
"
|
||||
onmouseover="this.style.background='var(--primary-dark, #3b82f6)'; this.style.transform='translateY(-1px)'; this.style.boxShadow='var(--shadow-md)';"
|
||||
onmouseout="this.style.background='var(--primary)'; this.style.transform='translateY(0)'; this.style.boxShadow='var(--shadow-sm)';">
|
||||
Accept All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Cookie consent functionality
|
||||
let cookieConsent = document.getElementById('cookie-consent');
|
||||
let acceptButton = document.getElementById('cookie-accept');
|
||||
let rejectButton = document.getElementById('cookie-reject');
|
||||
let policyLink = document.getElementById('cookie-policy-link');
|
||||
|
||||
if (!cookieConsent) return;
|
||||
|
||||
let COOKIE_NAME = <?= jsafe(first($prop['cookie_name'] ?? false, 'cookie_consent')) ?>;
|
||||
let COOKIE_EXPIRY_DAYS = <?= jsafe(first($prop['expiry_days'] ?? false, 365)) ?>;
|
||||
|
||||
// Check if user has already made a choice
|
||||
let existingConsent = getCookie(COOKIE_NAME);
|
||||
<?php
|
||||
if(!empty($prop['reset']))
|
||||
{
|
||||
$_COOKIE[$COOKIE_NAME] = '';
|
||||
?>existingConsent = false;<?php
|
||||
}
|
||||
?>
|
||||
|
||||
if (!existingConsent) {
|
||||
setTimeout(() => {
|
||||
cookieConsent.style.transform = 'translateY(0)';
|
||||
}, 1);
|
||||
}
|
||||
|
||||
// Handle accept button
|
||||
acceptButton.addEventListener('click', function() {
|
||||
setCookie(COOKIE_NAME, 'accepted', COOKIE_EXPIRY_DAYS);
|
||||
hideBanner();
|
||||
// Initialize analytics or other tracking here if needed
|
||||
console.log('Cookies accepted');
|
||||
});
|
||||
|
||||
// Handle reject button
|
||||
rejectButton.addEventListener('click', function() {
|
||||
setCookie(COOKIE_NAME, 'rejected', COOKIE_EXPIRY_DAYS);
|
||||
hideBanner();
|
||||
// Disable tracking here if needed
|
||||
console.log('Cookies rejected');
|
||||
});
|
||||
|
||||
// Handle policy link (you can customize this URL)
|
||||
policyLink.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
// You can change this to your actual privacy policy URL
|
||||
window.open('/privacy-policy', '_blank');
|
||||
});
|
||||
|
||||
function hideBanner() {
|
||||
cookieConsent.style.transform = 'translateY(100%)';
|
||||
setTimeout(() => {
|
||||
cookieConsent.style.display = 'none';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function setCookie(name, value, days) {
|
||||
let expires = new Date();
|
||||
expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
|
||||
document.cookie = name + '=' + value + ';expires=' + expires.toUTCString() + ';path=/;SameSite=Lax';
|
||||
}
|
||||
|
||||
function getCookie(name) {
|
||||
let nameEQ = name + '=';
|
||||
let ca = document.cookie.split(';');
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i];
|
||||
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
|
||||
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Keyboard accessibility
|
||||
cookieConsent.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
// Treat Escape as reject
|
||||
rejectButton.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Focus management for accessibility
|
||||
if (!existingConsent) {
|
||||
setTimeout(() => {
|
||||
acceptButton.focus();
|
||||
}, 1100);
|
||||
}
|
||||
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Responsive adjustments for cookie consent */
|
||||
@media (max-width: 768px) {
|
||||
#cookie-consent > div {
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
gap: 1rem !important;
|
||||
}
|
||||
|
||||
#cookie-consent > div > div:last-child {
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
#cookie-accept, #cookie-reject {
|
||||
flex: 1 !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
#cookie-consent {
|
||||
border-top-width: 2px !important;
|
||||
}
|
||||
|
||||
#cookie-accept, #cookie-reject {
|
||||
border-width: 2px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#cookie-consent, #cookie-accept, #cookie-reject {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<?php
|
||||
},
|
||||
|
||||
'about' => 'A GDPR-compliant cookie consent banner that appears at the bottom of the page with accept/reject options'
|
||||
];
|
||||
@@ -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
@@ -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',
|
||||
];
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$title = $prop['title'] ?? 'Trusted by Leading Companies';
|
||||
$logos = $prop['logos'] ?? [
|
||||
['name' => 'TechCorp', 'url' => 'img/cat01.jpg'],
|
||||
['name' => 'StartupX', 'url' => 'img/cat01.jpg'],
|
||||
['name' => 'DevStudio', 'url' => 'img/cat01.jpg'],
|
||||
['name' => 'WebFlow', 'url' => 'img/cat01.jpg'],
|
||||
['name' => 'CodeLab', 'url' => 'img/cat01.jpg'],
|
||||
['name' => 'AppCraft', 'url' => 'img/cat01.jpg']
|
||||
];
|
||||
|
||||
?>
|
||||
<div class="brands-section">
|
||||
<div class="brands-container">
|
||||
<h3 class="brands-title"><?= safe($title) ?></h3>
|
||||
<div class="brands-grid">
|
||||
<?php foreach($logos as $index => $logo): ?>
|
||||
<div class="brand-item" style="animation-delay: <?= $index * 0.1 ?>s">
|
||||
<img src="<?= safe($logo['url']) ?>" alt="<?= safe($logo['name']) ?>" />
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.brands-section {
|
||||
padding: 3rem 0;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brands-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brands-title {
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.brands-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius);
|
||||
transition: all 0.3s ease;
|
||||
animation: fadeInScale 0.6s ease-out forwards;
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
.brand-item:hover {
|
||||
transform: scale(1.05);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.brand-item img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.3s ease;
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
.brand-item:hover img {
|
||||
opacity: 1;
|
||||
filter: grayscale(0%);
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.brands-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.brands-section {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<?php
|
||||
|
||||
},
|
||||
|
||||
'about' => 'A brand/logo showcase section with hover effects and animations'
|
||||
]; ?>
|
||||
@@ -0,0 +1,224 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$title = $prop['title'] ?? 'Ready to Get Started?';
|
||||
$subtitle = $prop['subtitle'] ?? 'Join thousands of developers building amazing applications with our framework.';
|
||||
$cta_text = $prop['cta_text'] ?? 'Start Building Now';
|
||||
$cta_link = $prop['cta_link'] ?? '#';
|
||||
$secondary_text = $prop['secondary_text'] ?? 'View Documentation';
|
||||
$secondary_link = $prop['secondary_link'] ?? '#';
|
||||
|
||||
?>
|
||||
<div class="cta-section">
|
||||
<div class="cta-container">
|
||||
<div class="cta-content">
|
||||
<h2 class="cta-title"><?= safe($title) ?></h2>
|
||||
<p class="cta-subtitle"><?= safe($subtitle) ?></p>
|
||||
<div class="cta-actions">
|
||||
<a href="<?= safe($cta_link) ?>" class="btn btn-large cta-primary"><?= safe($cta_text) ?></a>
|
||||
<a href="<?= safe($secondary_link) ?>" class="btn btn-outline btn-large cta-secondary"><?= safe($secondary_text) ?></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cta-visual">
|
||||
<div class="floating-shapes">
|
||||
<div class="shape shape-1"></div>
|
||||
<div class="shape shape-2"></div>
|
||||
<div class="shape shape-3"></div>
|
||||
<div class="shape shape-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cta-section {
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
|
||||
color: white;
|
||||
padding: 5rem 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cta-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="hexagons" width="28" height="24" patternUnits="userSpaceOnUse"><polygon points="14,2 26,8 26,20 14,26 2,20 2,8" fill="none" stroke="white" stroke-width="0.5" opacity="0.1"/></pattern></defs><rect width="100%" height="100%" fill="url(%23hexagons)"/></svg>');
|
||||
}
|
||||
|
||||
.cta-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 4rem;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.cta-content {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.cta-subtitle {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 2.5rem;
|
||||
opacity: 0.9;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cta-actions {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cta-primary {
|
||||
background: white;
|
||||
color: var(--primary);
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.cta-primary:hover {
|
||||
background: transparent;
|
||||
color: white;
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
.cta-secondary {
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cta-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
.cta-visual {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.floating-shapes {
|
||||
position: relative;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.shape {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(5px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.shape-1 {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 20px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shape-2 {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
top: 20px;
|
||||
right: 0;
|
||||
animation: float 8s ease-in-out infinite reverse;
|
||||
}
|
||||
|
||||
.shape-3 {
|
||||
width: 100px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
bottom: 40px;
|
||||
left: 20px;
|
||||
animation: float 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shape-4 {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 10px;
|
||||
bottom: 0;
|
||||
right: 30px;
|
||||
animation: float 5s ease-in-out infinite reverse;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-15px) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 968px) {
|
||||
.cta-container {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.cta-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.cta-visual {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.floating-shapes {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cta-section {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.cta-subtitle {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.cta-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<?php
|
||||
|
||||
},
|
||||
|
||||
'about' => 'A compelling call-to-action section with animated floating shapes and gradient background'
|
||||
]; ?>
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$features = $prop['features'] ?? [
|
||||
[
|
||||
'icon' => '⚡',
|
||||
'title' => 'Lightning Fast',
|
||||
'description' => 'Optimized for speed and performance with modern web technologies.'
|
||||
],
|
||||
[
|
||||
'icon' => '🎨',
|
||||
'title' => 'Beautiful Design',
|
||||
'description' => 'Carefully crafted components with attention to detail and user experience.'
|
||||
],
|
||||
[
|
||||
'icon' => '📱',
|
||||
'title' => 'Mobile First',
|
||||
'description' => 'Fully responsive design that works perfectly on all devices.'
|
||||
],
|
||||
[
|
||||
'icon' => '🔧',
|
||||
'title' => 'Easy to Use',
|
||||
'description' => 'Simple and intuitive component system for rapid development.'
|
||||
],
|
||||
[
|
||||
'icon' => '🛡️',
|
||||
'title' => 'Secure',
|
||||
'description' => 'Built with security best practices and modern PHP standards.'
|
||||
],
|
||||
[
|
||||
'icon' => '🚀',
|
||||
'title' => 'Scalable',
|
||||
'description' => 'Architecture designed to grow with your application needs.'
|
||||
]
|
||||
];
|
||||
|
||||
?>
|
||||
<div class="features-section" id="features">
|
||||
<div class="features-header">
|
||||
<h2>Why Choose Our Framework?</h2>
|
||||
<p>Discover the powerful features that make development a breeze</p>
|
||||
</div>
|
||||
<div class="features-grid">
|
||||
<?php foreach($features as $index => $feature): ?>
|
||||
<div class="feature-card" style="animation-delay: <?= $index * 0.1 ?>s">
|
||||
<div class="feature-icon">
|
||||
<?= $feature['icon'] ?>
|
||||
</div>
|
||||
<h3 class="feature-title"><?= safe($feature['title']) ?></h3>
|
||||
<p class="feature-description"><?= safe($feature['description']) ?></p>
|
||||
<div class="feature-overlay"></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.features-section {
|
||||
padding: 4rem 0;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.features-header {
|
||||
text-align: center;
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
.features-header h2 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.features-header p {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--surface);
|
||||
padding: 2.5rem 2rem;
|
||||
border-radius: var(--radius-xl);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
animation: fadeInUp 0.6s ease-out forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: var(--shadow-xl);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.feature-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, var(--primary), var(--secondary));
|
||||
transform: scaleX(0);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover::before {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1.5rem;
|
||||
display: inline-block;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover .feature-icon {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.feature-description {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.feature-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover .feature-overlay {
|
||||
opacity: 0.05;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.features-header h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 2.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<?php
|
||||
},
|
||||
|
||||
'about' => 'A modern features grid with hover animations and gradient effects'
|
||||
]; ?>
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$title = $prop['title'] ?? 'Welcome to the Present';
|
||||
$subtitle = $prop['subtitle'] ?? 'Experience no-quite-modern web development with our cutting-edge framework';
|
||||
$cta_text = $prop['cta_text'] ?? 'Get Started';
|
||||
$cta_link = $prop['cta_link'] ?? '#';
|
||||
|
||||
?>
|
||||
<div class="hero-section">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title"><?= safe($title) ?></h1>
|
||||
<p class="hero-subtitle"><?= safe($subtitle) ?></p>
|
||||
<div class="hero-actions">
|
||||
<a href="<?= safe($cta_link) ?>" class="btn btn-large hero-cta"><?= safe($cta_text) ?></a>
|
||||
<a href="#features" class="btn btn-outline btn-large">Learn More</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-visual">
|
||||
<div class="floating-card">
|
||||
<div class="card-header"></div>
|
||||
<div class="card-content">
|
||||
<div class="line"></div>
|
||||
<div class="line short"></div>
|
||||
<div class="line"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="floating-elements">
|
||||
<div class="element element-1"></div>
|
||||
<div class="element element-2"></div>
|
||||
<div class="element element-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hero-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4rem;
|
||||
align-items: center;
|
||||
min-height: 70vh;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--bg-gradient);
|
||||
color: var(--text-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
margin-bottom: 4rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="25" cy="25" r="1" fill="white" opacity="0.1"/><circle cx="75" cy="75" r="1" fill="white" opacity="0.1"/><circle cx="50" cy="10" r="0.5" fill="white" opacity="0.1"/><circle cx="10" cy="90" r="0.5" fill="white" opacity="0.1"/></pattern></defs><rect width="100%" height="100%" fill="url(%23grain)"/></svg>');
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.1;
|
||||
background: none;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hero-title::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.9;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-cta {
|
||||
background: var(--surface);
|
||||
color: var(--primary);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.hero-cta:hover {
|
||||
background: var(--surface-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.hero-visual {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.floating-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
width: 280px;
|
||||
animation: float 6s ease-in-out infinite;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
height: 60px;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.3), rgba(255,255,255,0.1));
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-content .line {
|
||||
height: 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-content .line.short {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.floating-elements {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.element {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.element-1 {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
top: 20%;
|
||||
left: 10%;
|
||||
animation: float 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.element-2 {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
top: 60%;
|
||||
right: 15%;
|
||||
animation: float 5s ease-in-out infinite reverse;
|
||||
}
|
||||
|
||||
.element-3 {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
bottom: 20%;
|
||||
left: 20%;
|
||||
animation: float 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||
50% { transform: translateY(-20px) rotate(10deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-section {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.hero-visual {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.floating-card {
|
||||
width: 240px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
$.ready(() => {
|
||||
const heroTitle = document.querySelector('.hero-title');
|
||||
if (heroTitle) {
|
||||
const text = heroTitle.textContent;
|
||||
heroTitle.textContent = '';
|
||||
|
||||
let i = 0;
|
||||
const typeWriter = () => {
|
||||
if (i < text.length) {
|
||||
heroTitle.textContent += text.charAt(i);
|
||||
i++;
|
||||
setTimeout(typeWriter, 50);
|
||||
}
|
||||
};
|
||||
|
||||
// Start typing animation after a short delay
|
||||
setTimeout(typeWriter, 500);
|
||||
}
|
||||
const heroSection = document.querySelector('.hero-section');
|
||||
if (heroSection) {
|
||||
createFloatingParticles(heroSection);
|
||||
}
|
||||
function createFloatingParticles(container) {
|
||||
const particleCount = 20;
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const particle = document.createElement('div');
|
||||
particle.className = 'particle';
|
||||
|
||||
Object.assign(particle.style, {
|
||||
position: 'absolute',
|
||||
width: Math.random() * 4 + 1 + 'px',
|
||||
height: Math.random() * 4 + 1 + 'px',
|
||||
background: 'rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: '50%',
|
||||
left: Math.random() * 100 + '%',
|
||||
top: Math.random() * 100 + '%',
|
||||
animation: `float ${Math.random() * 3 + 2}s ease-in-out infinite`,
|
||||
animationDelay: Math.random() * 2 + 's'
|
||||
});
|
||||
|
||||
container.appendChild(particle);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
|
||||
},
|
||||
|
||||
'about' => 'A modern hero section with animated floating elements and gradient background'
|
||||
]; ?>
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$plans = $prop['plans'] ?? [
|
||||
[
|
||||
'name' => 'Starter',
|
||||
'price' => 'Free',
|
||||
'period' => '',
|
||||
'description' => 'Perfect for small projects and learning',
|
||||
'features' => [
|
||||
'Up to 3 projects',
|
||||
'Community support',
|
||||
'Core components',
|
||||
'Basic documentation'
|
||||
],
|
||||
'cta' => 'Get Started',
|
||||
'popular' => false
|
||||
],
|
||||
[
|
||||
'name' => 'Professional',
|
||||
'price' => '$29',
|
||||
'period' => '/month',
|
||||
'description' => 'Ideal for growing businesses and teams',
|
||||
'features' => [
|
||||
'Unlimited projects',
|
||||
'Advanced components',
|
||||
'Priority support',
|
||||
'Team collaboration',
|
||||
'Custom themes',
|
||||
'Analytics dashboard'
|
||||
],
|
||||
'cta' => 'Start Free Trial',
|
||||
'popular' => true
|
||||
],
|
||||
[
|
||||
'name' => 'Enterprise',
|
||||
'price' => '$99',
|
||||
'period' => '/month',
|
||||
'description' => 'For large organizations with advanced needs',
|
||||
'features' => [
|
||||
'Everything in Professional',
|
||||
'Custom integrations',
|
||||
'Dedicated support',
|
||||
'SLA guarantee',
|
||||
'Advanced security',
|
||||
'White-label options'
|
||||
],
|
||||
'cta' => 'Contact Sales',
|
||||
'popular' => false
|
||||
]
|
||||
];
|
||||
|
||||
?>
|
||||
<div class="pricing-section">
|
||||
<div class="pricing-container">
|
||||
<div class="pricing-header">
|
||||
<h2>Choose Your Plan</h2>
|
||||
<p>Start building great applications today with our flexible pricing options</p>
|
||||
</div>
|
||||
<div class="pricing-grid">
|
||||
<?php foreach($plans as $index => $plan): ?>
|
||||
<div class="pricing-card <?= $plan['popular'] ? 'popular' : '' ?>" style="animation-delay: <?= $index * 0.2 ?>s">
|
||||
<?php if($plan['popular']): ?>
|
||||
<div class="popular-badge">Most Popular</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="plan-header">
|
||||
<h3 class="plan-name"><?= safe($plan['name']) ?></h3>
|
||||
<div class="plan-price">
|
||||
<span class="price"><?= safe($plan['price']) ?></span>
|
||||
<?php if($plan['period']): ?>
|
||||
<span class="period"><?= safe($plan['period']) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<p class="plan-description"><?= safe($plan['description']) ?></p>
|
||||
</div>
|
||||
|
||||
<div class="plan-features">
|
||||
<ul>
|
||||
<?php foreach($plan['features'] as $feature): ?>
|
||||
<li>
|
||||
<span class="check-icon">✓</span>
|
||||
<?= safe($feature) ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="plan-footer">
|
||||
<button class="btn <?= $plan['popular'] ? 'btn-primary' : 'btn-outline' ?> btn-large plan-cta">
|
||||
<?= safe($plan['cta']) ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="pricing-footer">
|
||||
<p>All plans include a 30-day money-back guarantee. No setup fees.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pricing-section {
|
||||
padding: 5rem 0;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.pricing-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.pricing-header {
|
||||
text-align: center;
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
.pricing-header h2 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.pricing-header p {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.pricing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.pricing-card {
|
||||
background: var(--surface);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 2.5rem 2rem;
|
||||
position: relative;
|
||||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
animation: fadeInUp 0.6s ease-out forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
.pricing-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: var(--shadow-xl);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.pricing-card.popular {
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.pricing-card.popular:hover {
|
||||
transform: translateY(-8px) scale(1.07);
|
||||
}
|
||||
|
||||
.popular-badge {
|
||||
position: absolute;
|
||||
top: -1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
color: white;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-radius: 50px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.plan-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.plan-name {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.plan-price {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.period {
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.plan-description {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.plan-features {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.plan-features ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.plan-features li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.plan-features li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
margin-right: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.plan-footer {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.plan-cta {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pricing-footer {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pricing-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.pricing-card {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.pricing-card.popular {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.pricing-card.popular:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.pricing-header h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<?php
|
||||
},
|
||||
|
||||
'about' => 'A modern pricing table with popular plan highlighting and smooth animations'
|
||||
]; ?>
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$stats = $prop['stats'] ?? [
|
||||
['number' => '99.9%', 'label' => 'Uptime'],
|
||||
['number' => '500ms', 'label' => 'Average Response'],
|
||||
['number' => '50K+', 'label' => 'Active Users'],
|
||||
['number' => '24/7', 'label' => 'Support']
|
||||
];
|
||||
|
||||
?>
|
||||
<div class="stats-section">
|
||||
<div class="stats-container">
|
||||
<div class="stats-header">
|
||||
<h2>Trusted by Developers Worldwide</h2>
|
||||
<p>Join thousands of developers who have chosen our framework</p>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<?php foreach($stats as $index => $stat): ?>
|
||||
<div class="stat-item" style="animation-delay: <?= $index * 0.2 ?>s">
|
||||
<div class="stat-number" data-target="<?= safe($stat['number']) ?>"><?= safe($stat['number']) ?></div>
|
||||
<div class="stat-label"><?= safe($stat['label']) ?></div>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-fill" style="animation-delay: <?= ($index * 0.2) + 0.5 ?>s"></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stats-section {
|
||||
background: linear-gradient(135deg, var(--surface) 0%, var(--surface-elevated) 100%);
|
||||
color: var(--text-primary);
|
||||
padding: 4rem 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stats-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="dots" width="20" height="20" patternUnits="userSpaceOnUse"><circle cx="10" cy="10" r="1" fill="currentColor" opacity="0.1"/></pattern></defs><rect width="100%" height="100%" fill="url(%23dots)"/></svg>');
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
text-align: center;
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
.stats-header h2 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stats-header p {
|
||||
font-size: 1.25rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 3rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-xl);
|
||||
transition: all 0.3s ease;
|
||||
animation: fadeInScale 0.8s ease-out forwards;
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
transform: scale(1.05);
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--border-hover);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-bar {
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stat-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary), var(--accent));
|
||||
border-radius: 2px;
|
||||
width: 0;
|
||||
animation: fillBar 1.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fillBar {
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.stats-header h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<?php
|
||||
},
|
||||
|
||||
'about' => 'An animated statistics section with gradient backgrounds and progress bars'
|
||||
]; ?>
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$testimonials = $prop['testimonials'] ?? [
|
||||
[
|
||||
'name' => 'Sarah Johnson',
|
||||
'role' => 'Senior Developer at TechCorp',
|
||||
'avatar' => 'img/cat01.jpg',
|
||||
'content' => 'This framework has revolutionized our development process. The component system is intuitive and the performance is outstanding.',
|
||||
'rating' => 5
|
||||
],
|
||||
[
|
||||
'name' => 'Michael Chen',
|
||||
'role' => 'CTO at StartupX',
|
||||
'avatar' => 'img/cat01.jpg',
|
||||
'content' => 'We switched from our legacy system to this framework and saw immediate improvements in both development speed and code quality.',
|
||||
'rating' => 5
|
||||
],
|
||||
[
|
||||
'name' => 'Emily Rodriguez',
|
||||
'role' => 'Full Stack Developer',
|
||||
'avatar' => 'img/cat01.jpg',
|
||||
'content' => 'The documentation is excellent and the learning curve is gentle. Perfect for both beginners and experienced developers.',
|
||||
'rating' => 5
|
||||
]
|
||||
];
|
||||
|
||||
?>
|
||||
<div class="testimonials-section">
|
||||
<div class="testimonials-container">
|
||||
<div class="testimonials-header">
|
||||
<h2>What Developers Say</h2>
|
||||
<p>Don't just take our word for it - hear from the community</p>
|
||||
</div>
|
||||
<div class="testimonials-grid">
|
||||
<?php foreach($testimonials as $index => $testimonial): ?>
|
||||
<div class="testimonial-card" style="animation-delay: <?= $index * 0.2 ?>s">
|
||||
<div class="testimonial-content">
|
||||
<div class="quote-icon">"</div>
|
||||
<p><?= safe($testimonial['content']) ?></p>
|
||||
<div class="stars">
|
||||
<?php for($i = 0; $i < $testimonial['rating']; $i++): ?>
|
||||
<span class="star">★</span>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="testimonial-author">
|
||||
<img src="<?= safe($testimonial['avatar']) ?>" alt="<?= safe($testimonial['name']) ?>" class="avatar">
|
||||
<div class="author-info">
|
||||
<div class="author-name"><?= safe($testimonial['name']) ?></div>
|
||||
<div class="author-role"><?= safe($testimonial['role']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.testimonials-section {
|
||||
padding: 4rem 0;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.testimonials-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.testimonials-header {
|
||||
text-align: center;
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
.testimonials-header h2 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.testimonials-header p {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.testimonials-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.testimonial-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 2.5rem 2rem;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
animation: fadeInUp 0.6s ease-out forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
.testimonial-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: var(--shadow-xl);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.testimonial-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--primary), var(--secondary));
|
||||
}
|
||||
|
||||
.testimonial-content {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.quote-icon {
|
||||
font-size: 4rem;
|
||||
color: var(--primary);
|
||||
opacity: 0.3;
|
||||
line-height: 1;
|
||||
margin-bottom: 1rem;
|
||||
font-family: serif;
|
||||
}
|
||||
|
||||
.testimonial-content p {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.7;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stars {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.star {
|
||||
color: #fbbf24;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.testimonial-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid var(--border);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.testimonial-card:hover .avatar {
|
||||
border-color: var(--primary);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.author-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.author-role {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.testimonials-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.testimonial-card {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.testimonials-header h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<?php
|
||||
|
||||
},
|
||||
|
||||
'about' => 'A testimonials section with user photos, ratings, and smooth animations'
|
||||
]; ?>
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$themes = cfg('theme/options');
|
||||
$currentTheme = (string)cfg('theme/key');
|
||||
$currentLabel = (string)first(cfg('theme/label'), $themes[$currentTheme]['label'] ?? $currentTheme);
|
||||
$routePath = (string)(URL::$route['l-path'] ?? '');
|
||||
$buildThemeLink = function($themeKey) use($routePath) {
|
||||
return URL::Link($routePath, ['theme' => $themeKey]);
|
||||
};
|
||||
?>
|
||||
<div id="theme-switcher" style="position: fixed; right: 1.5rem; bottom: 1.5rem; z-index: 9999; font-family: inherit;">
|
||||
<style>
|
||||
#theme-switcher .theme-launcher {
|
||||
min-width: 56px;
|
||||
height: 56px;
|
||||
padding: 0 1rem;
|
||||
border-radius: 999px;
|
||||
background: var(--surface, #fff);
|
||||
border: 1px solid var(--border, rgba(0,0,0,0.15));
|
||||
box-shadow: var(--shadow-lg, 0 14px 32px rgba(0,0,0,0.18));
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.65rem;
|
||||
color: var(--text-primary, #111827);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
#theme-switcher .theme-launcher:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-xl, 0 22px 42px rgba(0,0,0,0.22));
|
||||
border-color: var(--primary, #2563eb);
|
||||
background: var(--surface-elevated, var(--surface, #fff));
|
||||
}
|
||||
#theme-switcher .theme-launcher-label {
|
||||
display: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#theme-switcher .theme-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 68px;
|
||||
width: min(280px, calc(100vw - 2rem));
|
||||
padding: 0.6rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border, rgba(0,0,0,0.15));
|
||||
background: color-mix(in srgb, var(--surface, #fff) 92%, transparent 8%);
|
||||
box-shadow: var(--shadow-xl, 0 22px 42px rgba(0,0,0,0.22));
|
||||
}
|
||||
#theme-switcher .theme-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
#theme-switcher .theme-menu-title {
|
||||
margin: 0 0 0.45rem;
|
||||
padding: 0.1rem 0.25rem 0.35rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
#theme-switcher .theme-menu-list {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
#theme-switcher .theme-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-primary, #111827);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
#theme-switcher .theme-option:hover {
|
||||
background: color-mix(in srgb, var(--primary, #2563eb) 10%, transparent 90%);
|
||||
border-color: color-mix(in srgb, var(--primary, #2563eb) 22%, transparent 78%);
|
||||
text-decoration: none;
|
||||
}
|
||||
#theme-switcher .theme-option.is-active {
|
||||
background: color-mix(in srgb, var(--primary, #2563eb) 16%, transparent 84%);
|
||||
border-color: color-mix(in srgb, var(--primary, #2563eb) 28%, transparent 72%);
|
||||
}
|
||||
#theme-switcher .theme-option small {
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@media (min-width: 860px) {
|
||||
#theme-switcher .theme-launcher-label {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<button id="theme-toggle" class="theme-launcher" aria-haspopup="true" aria-expanded="false" aria-label="Switch theme" title="Switch Theme" type="button">
|
||||
<i class="fas fa-palette" aria-hidden="true"></i>
|
||||
<span class="theme-launcher-label"><?= safe($currentLabel) ?></span>
|
||||
</button>
|
||||
<div id="theme-menu" class="theme-menu" hidden>
|
||||
<div class="theme-menu-title">Available Themes</div>
|
||||
<div class="theme-menu-list">
|
||||
<?php foreach($themes as $themeKey => $themeInfo): ?>
|
||||
<a class="theme-option<?= $themeKey === $currentTheme ? ' is-active' : '' ?>" href="<?= asafe($buildThemeLink($themeKey)) ?>">
|
||||
<span><?= safe((string)$themeInfo['label']) ?></span>
|
||||
<?php if($themeKey === $currentTheme): ?><small>Active</small><?php endif; ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const themeMenu = document.getElementById('theme-menu');
|
||||
|
||||
if (!themeToggle || !themeMenu) return;
|
||||
|
||||
themeToggle.addEventListener('click', function() {
|
||||
const isOpen = !themeMenu.hasAttribute('hidden');
|
||||
themeMenu.toggleAttribute('hidden', isOpen);
|
||||
themeToggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(event) {
|
||||
if (!themeMenu.hasAttribute('hidden') && !document.getElementById('theme-switcher').contains(event.target)) {
|
||||
themeMenu.setAttribute('hidden', 'hidden');
|
||||
themeToggle.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape' && !themeMenu.hasAttribute('hidden')) {
|
||||
themeMenu.setAttribute('hidden', 'hidden');
|
||||
themeToggle.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php
|
||||
},
|
||||
|
||||
'about' => 'A floating theme picker that switches between all configured starter theme families'
|
||||
];
|
||||
@@ -0,0 +1,105 @@
|
||||
<script>
|
||||
|
||||
window.ArcgaugeComponents = window.ArcgaugeComponents || {
|
||||
start_listen : function(prop) {
|
||||
$.events.on('value-broadcast', function(data) {
|
||||
if(!prop.items || !prop.items[data.name]) return;
|
||||
const item = Object.assign({}, prop.scale || {}, prop.items[data.name]);
|
||||
GaugeComponents.updateArcGauge({
|
||||
arcId: prop.id + '-' + data.name + '-arc',
|
||||
textId: prop.id + '-' + data.name + '-text',
|
||||
metaId: prop.id + '-' + data.name + '-meta',
|
||||
value: Number(data.value),
|
||||
max: Number(item.max || 100),
|
||||
suffix: item.unit || '',
|
||||
precision: item.precision,
|
||||
watermarkPrefix: item.watermark_prefix || data.name,
|
||||
color: item.color,
|
||||
meta: data.meta != null ? data.meta : null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
</script><?php
|
||||
|
||||
include_js('components/gauges/common.js');
|
||||
include_css('themes/common/css/gauges.css');
|
||||
|
||||
return [
|
||||
|
||||
'render' => function($prop) {
|
||||
$prop['id'] = !empty($prop['id']) ? $prop['id'] : 'arcgauge-'.uniqid();
|
||||
$prop['scale'] = $prop['scale'] ?? array();
|
||||
$prop['items'] = $prop['items'] ?? array();
|
||||
?>
|
||||
<div class="arcgauge-set" id="<?= asafe($prop['id']) ?>" style="<?= asafe((string)($prop['style'] ?? '')) ?>">
|
||||
<?php if(!empty($prop['title'])) { ?>
|
||||
<div class="arcgauge-set-header">
|
||||
<h3><?= safe((string)$prop['title']) ?></h3>
|
||||
<?php if(!empty($prop['subtitle'])) { ?><p><?= safe((string)$prop['subtitle']) ?></p><?php } ?>
|
||||
</div>
|
||||
<?php } ?>
|
||||
<div class="arcgauge-grid">
|
||||
<?php foreach($prop['items'] as $item_id => $item) {
|
||||
$item = array_merge($prop['scale'], $item);
|
||||
$value = (float)first($item['value'], 0);
|
||||
$max = (float)first($item['max'], 100);
|
||||
$precision = isset($item['precision']) ? (int)$item['precision'] : 1;
|
||||
$pct = $max > 0 ? clamp(($value / $max) * 100, 0, 100) : 0;
|
||||
$arc_length = round(($pct / 100) * 157.08, 1);
|
||||
$display_value = number_format($value, $precision).safe((string)($item['unit'] ?? ''));
|
||||
$resolved_color = '#10b981';
|
||||
if(isset($item['color']))
|
||||
{
|
||||
if(is_array($item['color']))
|
||||
{
|
||||
$color_entry = pick_entry_from_range($item['color'], $value);
|
||||
$resolved_color = first($color_entry['color'] ?? false, $resolved_color);
|
||||
}
|
||||
else
|
||||
{
|
||||
$resolved_color = (string)$item['color'];
|
||||
}
|
||||
}
|
||||
else if($pct >= 85)
|
||||
{
|
||||
$resolved_color = 'var(--error, #ef4444)';
|
||||
}
|
||||
else if($pct >= 60)
|
||||
{
|
||||
$resolved_color = 'var(--warning, #f59e0b)';
|
||||
}
|
||||
else
|
||||
{
|
||||
$resolved_color = 'var(--success, #10b981)';
|
||||
}
|
||||
?>
|
||||
<section class="arcgauge-card">
|
||||
<div class="arcgauge-label"><?= safe((string)first($item['label'], ucfirst((string)$item_id))) ?></div>
|
||||
<svg class="arcgauge-svg" viewBox="0 0 120 68" aria-hidden="true">
|
||||
<path class="arcgauge-track" d="M 10 60 A 50 50 0 0 1 110 60" fill="none" stroke-width="5" stroke-linecap="round"/>
|
||||
<path id="<?= asafe($prop['id']) ?>-<?= asafe($item_id) ?>-arc" class="arcgauge-arc-dyn" d="M 10 60 A 50 50 0 0 1 110 60" fill="none" stroke="<?= asafe($resolved_color) ?>" stroke-width="5" stroke-linecap="round" stroke-dasharray="<?= asafe((string)$arc_length) ?> 157.08"/>
|
||||
<?php if(!empty($item['watermark_prefix'])) { ?>
|
||||
<line id="<?= asafe($item['watermark_prefix']) ?>WmLo" class="arcgauge-watermark arcgauge-watermark-lo" x1="0" y1="0" x2="0" y2="0" opacity="0"/>
|
||||
<line id="<?= asafe($item['watermark_prefix']) ?>WmHi" class="arcgauge-watermark arcgauge-watermark-hi" x1="0" y1="0" x2="0" y2="0" opacity="0"/>
|
||||
<?php } ?>
|
||||
<text id="<?= asafe($prop['id']) ?>-<?= asafe($item_id) ?>-text" class="arcgauge-value" x="60" y="47" text-anchor="middle"><?= safe($display_value) ?></text>
|
||||
<text class="arcgauge-caption" x="60" y="62" text-anchor="middle"><?= safe((string)first($item['caption'], '')) ?></text>
|
||||
</svg>
|
||||
<div class="arcgauge-meta" id="<?= asafe($prop['id']) ?>-<?= asafe($item_id) ?>-meta"><?= safe((string)first($item['meta'], '--')) ?></div>
|
||||
</section>
|
||||
<?php } ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
if(!empty($prop['listen']))
|
||||
{
|
||||
?><script>
|
||||
ArcgaugeComponents.start_listen(<?= jsafe($prop) ?>);
|
||||
</script><?php
|
||||
}
|
||||
}
|
||||
|
||||
];
|
||||
@@ -0,0 +1,95 @@
|
||||
.horizontal .progressbar-item {
|
||||
display: flex;
|
||||
min-width: 100%;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.progressbar-container.vertical {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.vertical .progressbar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progressbar-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.horizontal .progressbar-label, .horizontal .progressbar-value {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.horizontal .progressbar-label {
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.vertical .progressbar-label {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.horizontal .progressbar-value {
|
||||
min-width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.progressbar-background {
|
||||
flex: 1;
|
||||
background: var(--bg-color);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.vertical .progressbar-background {
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.progressbar-bar {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.progressbar-marker {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
opacity: 0.5;
|
||||
background: var(--text-color, #333);
|
||||
}
|
||||
|
||||
.progressbar-marker:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.horizontal .progressbar-marker {
|
||||
border-width: 0 2px 0 2px;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
min-width: 4px;
|
||||
}
|
||||
|
||||
.vertical .progressbar-marker {
|
||||
border-width: 2px 0 2px 0;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
min-height: 4px;
|
||||
}
|
||||
|
||||
.progressbar-background {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.needlegauge-svg .needle {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.needlegauge-item {
|
||||
display: inline-block;
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// common code for gauges components
|
||||
|
||||
window.GaugeComponents = window.GaugeComponents || {};
|
||||
|
||||
Object.assign(window.GaugeComponents, { // as a namespace
|
||||
|
||||
// utility functions
|
||||
clampValue: function(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
},
|
||||
|
||||
pickEntryFromRange: function(ranges, value) {
|
||||
if (!Array.isArray(ranges)) return null;
|
||||
for (const entry of ranges) {
|
||||
if (value >= entry.from && value <= entry.to) return entry;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates an SVG element with namespace
|
||||
*/
|
||||
createSVGElement: function(tagName, attributes = {}) {
|
||||
const element = document.createElementNS('http://www.w3.org/2000/svg', tagName);
|
||||
Object.entries(attributes).forEach(([key, value]) => {
|
||||
element.setAttribute(key, value);
|
||||
});
|
||||
return element;
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets CSS custom property value from computed styles
|
||||
*/
|
||||
getCSSVar: function(varName) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
},
|
||||
|
||||
resolveColor: function(colorSpec, value, pct) {
|
||||
if (Array.isArray(colorSpec)) {
|
||||
const match = this.pickEntryFromRange(colorSpec, value);
|
||||
if (match && match.color) return match.color;
|
||||
}
|
||||
if (typeof colorSpec === 'string' && colorSpec !== '') return colorSpec;
|
||||
if (pct < 60) return this.getCSSVar('--success') || '#10b981';
|
||||
if (pct < 85) return this.getCSSVar('--warning') || '#f59e0b';
|
||||
return this.getCSSVar('--error') || '#ef4444';
|
||||
},
|
||||
|
||||
formatValue: function(value, precision, suffix) {
|
||||
const numericValue = Number(value);
|
||||
const normalizedPrecision = Number.isFinite(precision) ? precision : 1;
|
||||
if (!Number.isFinite(numericValue)) return '--';
|
||||
return numericValue.toFixed(normalizedPrecision) + (suffix || '');
|
||||
},
|
||||
|
||||
gaugeArcPoint: function(pct, radius = 50) {
|
||||
const angle = Math.PI - (pct / 100) * Math.PI;
|
||||
return {
|
||||
x: 60 + radius * Math.cos(angle),
|
||||
y: 60 - radius * Math.sin(angle),
|
||||
};
|
||||
},
|
||||
|
||||
updateWatermark: function(prefix, pct) {
|
||||
const now = Date.now();
|
||||
this._watermarks = this._watermarks || {};
|
||||
let watermark = this._watermarks[prefix];
|
||||
const resetWindow = 10 * 60 * 1000;
|
||||
if (!watermark || (now - watermark.resetTs) > resetWindow) {
|
||||
watermark = { lo: pct, hi: pct, resetTs: now };
|
||||
this._watermarks[prefix] = watermark;
|
||||
} else {
|
||||
if (pct < watermark.lo) watermark.lo = pct;
|
||||
if (pct > watermark.hi) watermark.hi = pct;
|
||||
}
|
||||
return watermark;
|
||||
},
|
||||
|
||||
renderWatermarkTick: function(lineId, pct) {
|
||||
const line = document.getElementById(lineId);
|
||||
if (!line) return;
|
||||
if (pct == null) {
|
||||
line.setAttribute('opacity', '0');
|
||||
return;
|
||||
}
|
||||
const outer = this.gaugeArcPoint(pct, 53);
|
||||
const inner = this.gaugeArcPoint(pct, 43);
|
||||
line.setAttribute('x1', outer.x.toFixed(1));
|
||||
line.setAttribute('y1', outer.y.toFixed(1));
|
||||
line.setAttribute('x2', inner.x.toFixed(1));
|
||||
line.setAttribute('y2', inner.y.toFixed(1));
|
||||
line.setAttribute('opacity', '0.7');
|
||||
},
|
||||
|
||||
updateArcGauge: function(options) {
|
||||
const value = Number(options.value);
|
||||
const max = Number(options.max || 100);
|
||||
const normalizedValue = Number.isFinite(value) ? value : 0;
|
||||
const pct = this.clampValue(max === 0 ? 0 : (normalizedValue / max) * 100, 0, 100);
|
||||
const arcLength = (pct / 100) * 157.08;
|
||||
const arc = document.getElementById(options.arcId);
|
||||
const text = document.getElementById(options.textId);
|
||||
const meta = options.metaId ? document.getElementById(options.metaId) : null;
|
||||
if (arc) {
|
||||
arc.setAttribute('stroke-dasharray', arcLength.toFixed(1) + ' 157.08');
|
||||
arc.setAttribute('stroke', this.resolveColor(options.color, normalizedValue, pct));
|
||||
}
|
||||
if (text) {
|
||||
text.textContent = this.formatValue(normalizedValue, options.precision, options.suffix);
|
||||
}
|
||||
if (meta && options.meta != null) {
|
||||
meta.textContent = options.meta;
|
||||
}
|
||||
if (options.watermarkPrefix) {
|
||||
const watermark = this.updateWatermark(options.watermarkPrefix, pct);
|
||||
this.renderWatermarkTick(options.watermarkPrefix + 'WmLo', watermark.lo);
|
||||
this.renderWatermarkTick(options.watermarkPrefix + 'WmHi', watermark.hi);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
<script>
|
||||
|
||||
window.NeedlegaugeComponents = window.NeedlegaugeComponents || {
|
||||
|
||||
start_listen : function(prop) {
|
||||
$.events.on('value-broadcast', function(data) {
|
||||
if(prop.items[data.name]) {
|
||||
// update value text
|
||||
let item = Object.assign({}, prop.scale, prop.items[data.name]);
|
||||
$('#' + prop.id + '-' + data.name + '-value').text(data.value + (item.unit || ''));
|
||||
let vrange = (item.max || 100) - (item.min || 0);
|
||||
let pct_value = GaugeComponents.clampValue((data.value - (item.min || 0)) / vrange, 0, 1);
|
||||
let angle = -Math.PI + item.angle_start + (pct_value * (item.angle_end - item.angle_start));
|
||||
$('#' + prop.id + '-' + data.name + '-needle').css('transform', 'rotate(' + angle + 'rad)');
|
||||
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
</script><?php
|
||||
|
||||
include_js('components/gauges/common.js');
|
||||
include_css('themes/common/css/gauges.css');
|
||||
|
||||
return [
|
||||
|
||||
'render' => function($prop) {
|
||||
$prop['id'] = !empty($prop['id']) ? $prop['id'] : 'needlegauge-' . uniqid();
|
||||
$prop['style'] = (string)($prop['style'] ?? '');
|
||||
$prop['items'] = (array)($prop['items'] ?? array());
|
||||
$prop['scale'] = (array)($prop['scale'] ?? array());
|
||||
$prop['size'] = first($prop['size'] ?? false, 200);
|
||||
$prop['subtitle'] = (string)($prop['subtitle'] ?? '');
|
||||
$prop['scale']['angle_start'] = first($prop['scale']['angle_start'], -pi());
|
||||
$prop['scale']['angle_end'] = first($prop['scale']['angle_end'], 0);
|
||||
$prop['img_height'] = first($prop['img_height'] ?? false,
|
||||
$prop['size'] * max(cos(first($prop['scale']['angle_start'], -pi())), cos(first($prop['scale']['angle_end'], 0))));
|
||||
?>
|
||||
<section class="gauge-set needlegauge-set" id="<?= asafe($prop['id']) ?>" style="<?= asafe($prop['style']) ?>">
|
||||
<?php if(isset($prop['title'])) { ?>
|
||||
<div class="gauge-set-header">
|
||||
<h3><?= safe($prop['title']) ?></h3>
|
||||
<?php if($prop['subtitle'] !== '') { ?><p><?= safe($prop['subtitle']) ?></p><?php } ?>
|
||||
</div>
|
||||
<?php } ?>
|
||||
<div class="needlegauge-grid">
|
||||
<?php
|
||||
foreach($prop['items'] as $item_id => $item)
|
||||
{
|
||||
$item = array_merge($prop['scale'], $item);
|
||||
$item['min'] = first($item['min'] ?? false, 0);
|
||||
$item['max'] = first($item['max'] ?? false, 100);
|
||||
$item['tooltip'] = (string)($item['tooltip'] ?? '');
|
||||
$item['unit'] = (string)($item['unit'] ?? '');
|
||||
$item['color'] = $item['color'] ?? false;
|
||||
$item['label'] = (string)first($item['label'] ?? false, ucfirst((string)$item_id));
|
||||
$vrange = ($item['max'] - $item['min']);
|
||||
$pct_value = clamp(($item['value'] - $item['min']) / $vrange, 0, 1);
|
||||
$needle_angle = -pi() + $item['angle_start'] + ($pct_value * ($item['angle_end'] - $item['angle_start']));
|
||||
$needle_color = first($item['color'] ?? false, '#888888');
|
||||
if(is_array($item['color'])) {
|
||||
$color_entry = pick_entry_from_range($item['color'], $item['value']);
|
||||
$needle_color = first($color_entry['color'] ?? false, '#888888');
|
||||
}
|
||||
$tick_interval = first($item['ticks-every'], $vrange / 20);
|
||||
$label_interval = first($item['value-labels-every'], $vrange / 4);
|
||||
$tick_number = 0;
|
||||
$ticks_html = '';
|
||||
$min_angle = first($item['angle_start']);
|
||||
$max_angle = first($item['angle_end']);
|
||||
$tick_color = first($item['tick-color'], '#888888');
|
||||
|
||||
for($v = $item['min']; $v <= $item['max']; $v += $tick_interval)
|
||||
{
|
||||
$tick_number++;
|
||||
$angle = $min_angle + (($v-$item['min'])/$vrange) * ($max_angle - $min_angle);
|
||||
|
||||
$x1 = $prop['size']/2 + cos($angle) * $prop['size']*0.38;
|
||||
$y1 = $prop['size']/2 + sin($angle) * $prop['size']*0.38;
|
||||
$x2 = $prop['size']/2 + cos($angle) * $prop['size']*0.41;
|
||||
$y2 = $prop['size']/2 + sin($angle) * $prop['size']*0.41;
|
||||
|
||||
$ticks_html .= '<line x1="'.($x1).'" y1="'.($y1).'" x2="'.($x2).'" y2="'.($y2).'"
|
||||
stroke="'. $tick_color .'" stroke-width="1"/>';
|
||||
|
||||
}
|
||||
|
||||
for($v = $item['min']; $v <= $item['max']; $v += $label_interval)
|
||||
{
|
||||
$tick_number++;
|
||||
$angle = $min_angle + (($v-$item['min'])/$vrange) * ($max_angle - $min_angle);
|
||||
|
||||
$x1 = $prop['size']/2 + cos($angle) * $prop['size']*0.35;
|
||||
$y1 = $prop['size']/2 + sin($angle) * $prop['size']*0.35;
|
||||
$x2 = $prop['size']/2 + cos($angle) * $prop['size']*0.41;
|
||||
$y2 = $prop['size']/2 + sin($angle) * $prop['size']*0.41;
|
||||
|
||||
$ticks_html .= '<line x1="'.($x1).'" y1="'.($y1).'" x2="'.($x2).'" y2="'.($y2).'"
|
||||
stroke="'. $tick_color .'" stroke-width="3"/>';
|
||||
|
||||
$lx = $prop['size']/2 + cos($angle) * $prop['size']*0.45;
|
||||
$ly = $prop['size']/2 + sin($angle) * $prop['size']*0.45;
|
||||
$ticks_html .= '<text x="'.($lx).'" y="'.($ly).'" text-anchor="middle" dominant-baseline="central"
|
||||
font-size="10" fill="'. $tick_color .'">'.($v == 0 ? '0' : $v).'</text>';
|
||||
}
|
||||
|
||||
?>
|
||||
<section class="gauge-card needlegauge-card">
|
||||
<div class="gauge-metric-label needlegauge-label-head"><?= safe($item['label']) ?></div>
|
||||
|
||||
<div class="needlegauge-visual">
|
||||
<svg id="<?= $prop['id'] ?>-<?= $item_id ?>-svg" width="<?= $prop['size'] ?>" height="<?= $prop['img_height'] ?>" class="needlegauge-svg"
|
||||
viewBox="0 0 <?= $prop['size'] ?> <?= $prop['img_height'] ?>">
|
||||
|
||||
<?php
|
||||
if(is_array($item['color']))
|
||||
{
|
||||
foreach($item['color'] as $range)
|
||||
{
|
||||
$angle_from = $min_angle + ((max($range['from'], $item['min']) - $item['min']) / $vrange) * ($max_angle - $min_angle);
|
||||
$angle_to = $min_angle + ((min($range['to'], $item['max']) - $item['min']) / $vrange) * ($max_angle - $min_angle);
|
||||
$color = $range['color'] ?? 'rgba(120,120,120,0.5)';
|
||||
SVG::circle_segment($prop['size']/2, $prop['size']/2, $prop['size']*0.4,
|
||||
$angle_from, $angle_to, $color, 8, 'rgba(0,0,0,0)', 'opacity:0.25');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
SVG::circle_segment($prop['size']/2, $prop['size']/2, $prop['size']*0.4,
|
||||
$min_angle, $max_angle, $tick_color, 8, 'rgba(0,0,0,0)', 'opacity:0.25');
|
||||
}
|
||||
?>
|
||||
|
||||
<g class="ticks">
|
||||
<?= $ticks_html ?>
|
||||
</g>
|
||||
|
||||
<line id="<?= $prop['id'] ?>-<?= $item_id ?>-needle" class="needle"
|
||||
x1="<?= $prop['size']*0.55 ?>" y1="<?= $prop['size']*0.5 ?>" x2="<?= $prop['size']*0.1 ?>" y2="<?= $prop['size']*0.5 ?>"
|
||||
stroke="<?= ($needle_color) ?>" stroke-width="3" stroke-linecap="round"
|
||||
style="transform-origin: <?= $prop['size']/2 ?>px <?= $prop['size']/2 ?>px;
|
||||
transform: rotate(<?= ($needle_angle) ?>rad); transition: transform 0.3s ease;"/>
|
||||
|
||||
<circle cx="<?= $prop['size']/2 ?>" cy="<?= $prop['size']/2 ?>" r="6" fill="<?= $needle_color ?>"/>
|
||||
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="needlegauge-info">
|
||||
<div class="gauge-metric-value needlegauge-value" id="<?= $prop['id'] ?>-<?= $item_id ?>-value"
|
||||
title="<?= asafe($item['tooltip']) ?>">
|
||||
<?= safe($item['value']) ?><?= safe($item['unit']) ?>
|
||||
</div>
|
||||
<?php if($item['tooltip'] !== '') { ?><div class="gauge-metric-meta needlegauge-meta"><?= safe($item['tooltip']) ?></div><?php } ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</section>
|
||||
<?php
|
||||
if(!empty($prop['listen']))
|
||||
{
|
||||
?><script>
|
||||
NeedlegaugeComponents.start_listen(<?= jsafe($prop) ?>);
|
||||
</script><?php
|
||||
}
|
||||
}
|
||||
|
||||
];
|
||||
@@ -0,0 +1,203 @@
|
||||
<script>
|
||||
|
||||
window.ProgressbarComponents = window.ProgressbarComponents || {
|
||||
|
||||
start_listen : function(prop) {
|
||||
$.events.on('value-broadcast', function(data) {
|
||||
if(prop.items[data.name]) {
|
||||
// update value text
|
||||
let bar = Object.assign({}, prop.scale, prop.items[data.name]);
|
||||
$('#' + prop.id + '-' + data.name + '-value').text(data.value + (bar.unit || ''));
|
||||
// update bar width/height
|
||||
let vrange = (bar.max || 100) - (bar.min || 0);
|
||||
let pct_value = GaugeComponents.clampValue((data.value - (bar.min || 0)) / vrange * 100, 0, 100);
|
||||
if(Array.isArray(bar.color)) {
|
||||
let colorMatch = GaugeComponents.pickEntryFromRange(bar.color, Number(data.value));
|
||||
if(colorMatch && colorMatch.color)
|
||||
$('#' + prop.id + '-' + data.name + '-bar').css('background', colorMatch.color);
|
||||
}
|
||||
if(prop.layout === 'horizontal') {
|
||||
$('#' + prop.id + '-' + data.name + '-bar').css('width', pct_value + '%');
|
||||
} else {
|
||||
$('#' + prop.id + '-' + data.name + '-bar').css('height', pct_value + '%');
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
</script><?php
|
||||
|
||||
include_js('components/gauges/common.js');
|
||||
include_css('themes/common/css/gauges.css');
|
||||
|
||||
return [
|
||||
|
||||
'render' => function($prop) {
|
||||
$prop['id'] = !empty($prop['id']) ? $prop['id'] : 'progressbar-'.uniqid();
|
||||
$prop['style'] = (string)($prop['style'] ?? '');
|
||||
$prop['item-style'] = (string)($prop['item-style'] ?? '');
|
||||
$prop['label-style'] = (string)($prop['label-style'] ?? '');
|
||||
$prop['value-style'] = (string)($prop['value-style'] ?? '');
|
||||
$prop['bar-style'] = (string)($prop['bar-style'] ?? '');
|
||||
$prop['items'] = (array)($prop['items'] ?? array());
|
||||
$default_palette = [
|
||||
'var(--success, #10b981)',
|
||||
'var(--primary, #60a5fa)',
|
||||
'var(--accent, #22d3ee)',
|
||||
'var(--warning, #f59e0b)',
|
||||
];
|
||||
$auto_color_counter = 0;
|
||||
if(empty($prop['scale'])) $prop['scale'] = [];
|
||||
$layout = first($prop['layout'] ?? false, 'horizontal');
|
||||
?>
|
||||
<section class="gauge-set progressbar-set progressbar-set-<?= asafe($layout) ?>" id="<?= $prop['id'] ?>" style="<?= $prop['style'] ?>">
|
||||
<?php if(isset($prop['title'])) { ?>
|
||||
<div class="gauge-set-header">
|
||||
<h3><?= safe($prop['title']) ?></h3>
|
||||
<?php if(!empty($prop['subtitle'])) { ?><p><?= safe((string)$prop['subtitle']) ?></p><?php } ?>
|
||||
</div>
|
||||
<?php } ?>
|
||||
<?php if($layout == 'horizontal') { ?>
|
||||
<div class="progressbar-grid progressbar-grid-horizontal">
|
||||
<?php foreach($prop['items'] as $bar_id => $bar)
|
||||
{
|
||||
$bar = array_merge($prop['scale'], $bar);
|
||||
$bar['style'] = (string)($bar['style'] ?? '');
|
||||
$bar['tooltip'] = (string)($bar['tooltip'] ?? '');
|
||||
$bar['before'] = $bar['before'] ?? '';
|
||||
$bar['after'] = $bar['after'] ?? '';
|
||||
$bar['unit'] = (string)($bar['unit'] ?? '');
|
||||
$bar['color'] = $bar['color'] ?? false;
|
||||
$vrange = (first($bar['max'], 100) - first($bar['min'], 0));
|
||||
$bar['pct-value'] = clamp(($bar['value'] - first($bar['min'], 0)) / $vrange * 100, 0, 100);
|
||||
if(is_array($bar['color']))
|
||||
{
|
||||
$color_entry = pick_entry_from_range($bar['color'], $bar['value']);
|
||||
$bar['color'] = $color_entry['color'] ?? false;
|
||||
}
|
||||
if(!$bar['color'])
|
||||
$bar['color'] = first($prop['bar-color'] ?? false, $default_palette[$auto_color_counter++ % sizeof($default_palette)]);
|
||||
?>
|
||||
<section class="gauge-card progressbar-card progressbar-card-horizontal" id="<?= $prop['id'] ?>-<?= safe($bar_id) ?>"
|
||||
title="<?= asafe($bar['tooltip']) ?>"
|
||||
style="<?= $prop['item-style'] ?>;<?= $bar['style'] ?>">
|
||||
<?= $bar['before'] ?>
|
||||
<div class="progressbar-card-head">
|
||||
<div class="gauge-metric-label progressbar-label" style="<?= $prop['label-style'] ?>">
|
||||
<?= safe($bar['label']) ?>
|
||||
</div>
|
||||
<div class="gauge-metric-value progressbar-value" style="<?= $prop['value-style'] ?>"
|
||||
id="<?= $prop['id'] ?>-<?= safe($bar_id) ?>-value">
|
||||
<?= safe($bar['value']) ?><?= safe($bar['unit']) ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progressbar-background progressbar-background-horizontal">
|
||||
<div class="progressbar-bar" id="<?= $prop['id'] ?>-<?= safe($bar_id) ?>-bar"
|
||||
style="background-color: <?= safe($bar['color']) ?>;
|
||||
<?= $prop['bar-style'] ?>
|
||||
opacity: <?= isset($bar['opacity']) ? safe($bar['opacity']) : '0.75' ?>;
|
||||
width: <?= safe($bar['pct-value']) ?>%;">
|
||||
</div>
|
||||
<?php
|
||||
if(isset($prop['markers'])) {
|
||||
foreach($prop['markers'] as $marker_id => $marker) {
|
||||
$marker_pct = clamp(($marker['value'] - first($bar['min'], 0)) / $vrange * 100, 0, 100);
|
||||
?>
|
||||
<div class="progressbar-marker"
|
||||
title="<?= asafe($marker['label']) ?>"
|
||||
style="left: <?= ($marker_pct) ?>%;
|
||||
background: <?= first($marker['color'] ?? false, 'var(--primary-light)') ?>;">
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<?php if($bar['tooltip'] !== '') { ?><div class="gauge-metric-meta progressbar-meta"><?= safe($bar['tooltip']) ?></div><?php } ?>
|
||||
<?= $bar['after'] ?>
|
||||
</section>
|
||||
<?php } ?>
|
||||
</div>
|
||||
<?php } else { ?>
|
||||
<div class="progressbar-grid progressbar-grid-vertical" style="--progressbar-height: <?= safe((string)first($prop['height'] ?? false, 240)) ?>px;">
|
||||
<?php
|
||||
if(sizeof($prop['items']) > 0)
|
||||
{
|
||||
foreach($prop['items'] as $bar_id => $bar)
|
||||
{
|
||||
$bar = array_merge($prop['scale'], $bar);
|
||||
$bar['style'] = (string)($bar['style'] ?? '');
|
||||
$bar['tooltip'] = (string)($bar['tooltip'] ?? '');
|
||||
$bar['before'] = $bar['before'] ?? '';
|
||||
$bar['after'] = $bar['after'] ?? '';
|
||||
$bar['unit'] = (string)($bar['unit'] ?? '');
|
||||
$bar['color'] = $bar['color'] ?? false;
|
||||
$vrange = (first($bar['max'], 100) - first($bar['min'], 0));
|
||||
$bar['pct-value'] = clamp(($bar['value'] - first($bar['min'], 0)) / $vrange * 100, 0, 100);
|
||||
if(is_array($bar['color']))
|
||||
{
|
||||
$color_entry = pick_entry_from_range($bar['color'], $bar['value']);
|
||||
$bar['color'] = $color_entry['color'] ?? false;
|
||||
}
|
||||
if(!$bar['color'])
|
||||
$bar['color'] = first($prop['bar-color'] ?? false, $default_palette[$auto_color_counter++ % sizeof($default_palette)]);
|
||||
?>
|
||||
<section class="gauge-card progressbar-card progressbar-card-vertical" id="<?= $prop['id'] ?>-<?= safe($bar_id) ?>"
|
||||
title="<?= asafe($bar['tooltip']) ?>"
|
||||
style="<?= $prop['item-style'] ?>;<?= $bar['style'] ?>">
|
||||
<?= $bar['before'] ?>
|
||||
<div class="gauge-metric-label progressbar-label" style="<?= $prop['label-style'] ?>">
|
||||
<?= safe($bar['label']) ?>
|
||||
</div>
|
||||
|
||||
<div class="progressbar-background progressbar-background-vertical">
|
||||
<div class="progressbar-bar" id="<?= $prop['id'] ?>-<?= safe($bar_id) ?>-bar"
|
||||
style="background-color: <?= safe($bar['color']) ?>;
|
||||
<?= $prop['bar-style'] ?>
|
||||
opacity: <?= isset($bar['opacity']) ? safe($bar['opacity']) : '0.75' ?>;
|
||||
height: <?= safe($bar['pct-value']) ?>%;">
|
||||
</div>
|
||||
<?php
|
||||
// Render markers for vertical layout
|
||||
if(isset($prop['markers'])) {
|
||||
foreach($prop['markers'] as $marker_id => $marker) {
|
||||
$marker_pct = clamp(($marker['value'] - first($bar['min'], 0)) / $vrange * 100, 0, 100);
|
||||
?>
|
||||
<div class="progressbar-marker"
|
||||
title="<?= asafe($marker['label']) ?>"
|
||||
style="bottom: <?= ($marker_pct) ?>%;
|
||||
background: <?= first($marker['color'] ?? false, 'var(--primary-light)') ?>;">
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
|
||||
<div class="gauge-metric-value progressbar-value" style="<?= $prop['value-style'] ?>"
|
||||
id="<?= $prop['id'] ?>-<?= safe($bar_id) ?>-value">
|
||||
<?= safe($bar['value']) ?><?= safe($bar['unit']) ?>
|
||||
</div>
|
||||
<?php if($bar['tooltip'] !== '') { ?><div class="gauge-metric-meta progressbar-meta"><?= safe($bar['tooltip']) ?></div><?php } ?>
|
||||
|
||||
<?= $bar['after'] ?>
|
||||
</section>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<?php } ?>
|
||||
</section>
|
||||
<?php
|
||||
if(!empty($prop['listen']))
|
||||
{
|
||||
?><script>
|
||||
ProgressbarComponents.start_listen(<?= jsafe($prop) ?>);
|
||||
</script><?php
|
||||
}
|
||||
}
|
||||
|
||||
];
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
include_css('themes/common/css/workspace.css');
|
||||
include_js('js/u-workspace-shell.js');
|
||||
|
||||
$id = trim((string)($prop['id'] ?? ''));
|
||||
$class = trim((string)($prop['class'] ?? ''));
|
||||
$sidebar = (string)($prop['sidebar_html'] ?? '');
|
||||
$main = (string)($prop['main_html'] ?? '');
|
||||
$overlayId = trim((string)($prop['overlay_id'] ?? ''));
|
||||
ob_start();
|
||||
?>
|
||||
<div<?= $id !== '' ? ' id="' . asafe($id) . '"' : '' ?> class="ws-app<?= $class !== '' ? ' ' . asafe($class) : '' ?>">
|
||||
<?= $sidebar ?>
|
||||
<div<?= $overlayId !== '' ? ' id="' . asafe($overlayId) . '"' : '' ?> class="ws-sidebar-overlay"></div>
|
||||
<main class="ws-main">
|
||||
<?= $main ?>
|
||||
</main>
|
||||
</div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
},
|
||||
|
||||
'about' => 'Generic workspace app shell with sidebar, overlay, and main content area',
|
||||
];
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$id = trim((string)($prop['id'] ?? ''));
|
||||
$class = trim((string)($prop['class'] ?? ''));
|
||||
$icon = trim((string)($prop['icon_class'] ?? 'fas fa-layer-group'));
|
||||
$title = (string)($prop['title'] ?? '');
|
||||
$text = (string)($prop['text'] ?? '');
|
||||
$actionHtml = (string)($prop['action_html'] ?? '');
|
||||
ob_start();
|
||||
?>
|
||||
<div<?= $id !== '' ? ' id="' . asafe($id) . '"' : '' ?> class="ws-empty-state<?= $class !== '' ? ' ' . asafe($class) : '' ?>">
|
||||
<div class="ws-empty-icon"><i class="<?= asafe($icon) ?>"></i></div>
|
||||
<h2><?= safe($title) ?></h2>
|
||||
<p><?= safe($text) ?></p>
|
||||
<?= $actionHtml ?>
|
||||
</div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
},
|
||||
|
||||
'about' => 'Centered empty-state block for shell panels and placeholder screens',
|
||||
];
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$tag = trim((string)($prop['tag'] ?? 'button'));
|
||||
if (!in_array($tag, ['button', 'span', 'a'], true)) $tag = 'button';
|
||||
$id = trim((string)($prop['id'] ?? ''));
|
||||
$class = trim((string)($prop['class'] ?? ''));
|
||||
$title = trim((string)($prop['title'] ?? ''));
|
||||
$icon = trim((string)($prop['icon_class'] ?? ''));
|
||||
$text = (string)($prop['text'] ?? '');
|
||||
$attrs = trim((string)($prop['attrs'] ?? ''));
|
||||
$href = trim((string)($prop['href'] ?? ''));
|
||||
$type = trim((string)($prop['type'] ?? 'button'));
|
||||
ob_start();
|
||||
?>
|
||||
<<?= $tag ?><?= $id !== '' ? ' id="' . asafe($id) . '"' : '' ?> class="ws-icon-btn<?= $class !== '' ? ' ' . asafe($class) : '' ?>"<?= $title !== '' ? ' title="' . asafe($title) . '"' : '' ?><?= $tag === 'button' ? ' type="' . asafe($type) . '"' : '' ?><?= $tag === 'a' && $href !== '' ? ' href="' . asafe($href) . '"' : '' ?><?= $attrs !== '' ? ' ' . $attrs : '' ?>><?php if ($icon !== ''): ?><i class="<?= asafe($icon) ?>"></i><?php endif; ?><?php if ($text !== ''): ?><span><?= safe($text) ?></span><?php endif; ?></<?= $tag ?>>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
},
|
||||
|
||||
'about' => 'Small utility icon button for shell toolbars and compact actions',
|
||||
];
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$id = trim((string)($prop['id'] ?? ''));
|
||||
$class = trim((string)($prop['class'] ?? ''));
|
||||
$icon = trim((string)($prop['icon_class'] ?? ''));
|
||||
$text = (string)($prop['text'] ?? '');
|
||||
ob_start();
|
||||
?>
|
||||
<div<?= $id !== '' ? ' id="' . asafe($id) . '"' : '' ?> class="ws-list-state<?= $class !== '' ? ' ' . asafe($class) : '' ?>"><?php if ($icon !== ''): ?><i class="<?= asafe($icon) ?>"></i><?php endif; ?><span><?= safe($text) ?></span></div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
},
|
||||
|
||||
'about' => 'Compact sidebar/list placeholder state with optional icon',
|
||||
];
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$id = trim((string)($prop['id'] ?? ''));
|
||||
$class = trim((string)($prop['class'] ?? ''));
|
||||
$buttonId = trim((string)($prop['button_id'] ?? ''));
|
||||
$buttonClass = trim((string)($prop['button_class'] ?? ''));
|
||||
$titleId = trim((string)($prop['title_id'] ?? ''));
|
||||
$titleClass = trim((string)($prop['title_class'] ?? ''));
|
||||
$title = (string)($prop['title'] ?? '');
|
||||
$icon = trim((string)($prop['icon_class'] ?? 'fas fa-bars'));
|
||||
ob_start();
|
||||
?>
|
||||
<div<?= $id !== '' ? ' id="' . asafe($id) . '"' : '' ?> class="ws-mobile-bar<?= $class !== '' ? ' ' . asafe($class) : '' ?>">
|
||||
<button<?= $buttonId !== '' ? ' id="' . asafe($buttonId) . '"' : '' ?> class="ws-mobile-toggle<?= $buttonClass !== '' ? ' ' . asafe($buttonClass) : '' ?>" title="Toggle sidebar" type="button">
|
||||
<i class="<?= asafe($icon) ?>"></i>
|
||||
</button>
|
||||
<span<?= $titleId !== '' ? ' id="' . asafe($titleId) . '"' : '' ?> class="ws-mobile-title<?= $titleClass !== '' ? ' ' . asafe($titleClass) : '' ?>"><?= safe($title) ?></span>
|
||||
</div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
},
|
||||
|
||||
'about' => 'Compact mobile header bar for workspace layouts with a sidebar toggle',
|
||||
];
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$class = trim((string)($prop['class'] ?? ''));
|
||||
$title = (string)($prop['title'] ?? '');
|
||||
$subtitle = (string)($prop['subtitle'] ?? '');
|
||||
$titleId = trim((string)($prop['title_id'] ?? ''));
|
||||
$subtitleId = trim((string)($prop['subtitle_id'] ?? ''));
|
||||
$actionsHtml = (string)($prop['actions_html'] ?? '');
|
||||
ob_start();
|
||||
?>
|
||||
<div class="ws-panel-header<?= $class !== '' ? ' ' . asafe($class) : '' ?>">
|
||||
<div class="ws-panel-title-group">
|
||||
<h2<?= $titleId !== '' ? ' id="' . asafe($titleId) . '"' : '' ?>><?= safe($title) ?></h2>
|
||||
<?php if ($subtitle !== ''): ?>
|
||||
<p<?= $subtitleId !== '' ? ' id="' . asafe($subtitleId) . '"' : '' ?> class="ws-subtitle"><?= safe($subtitle) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($actionsHtml !== ''): ?><div class="ws-header-actions"><?= $actionsHtml ?></div><?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
},
|
||||
|
||||
'about' => 'Panel heading with title, optional subtitle, and actions slot',
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$id = trim((string)($prop['id'] ?? ''));
|
||||
$class = trim((string)($prop['class'] ?? ''));
|
||||
$attrs = trim((string)($prop['attrs'] ?? ''));
|
||||
$header = (string)($prop['header_html'] ?? '');
|
||||
$body = (string)($prop['body_html'] ?? '');
|
||||
ob_start();
|
||||
?>
|
||||
<section<?= $id !== '' ? ' id="' . asafe($id) . '"' : '' ?> class="ws-panel<?= $class !== '' ? ' ' . asafe($class) : '' ?>"<?= $attrs !== '' ? ' ' . $attrs : '' ?>>
|
||||
<?= $header ?>
|
||||
<?= $body ?>
|
||||
</section>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
},
|
||||
|
||||
'about' => 'Flexible workspace panel container with separate header and body slots',
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$class = trim((string)($prop['class'] ?? ''));
|
||||
$title = (string)($prop['title'] ?? '');
|
||||
$actionsHtml = (string)($prop['actions_html'] ?? '');
|
||||
ob_start();
|
||||
?>
|
||||
<div class="ws-section-head<?= $class !== '' ? ' ' . asafe($class) : '' ?>">
|
||||
<h3><?= safe($title) ?></h3>
|
||||
<?php if ($actionsHtml !== ''): ?><div class="ws-header-actions"><?= $actionsHtml ?></div><?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
},
|
||||
|
||||
'about' => 'Compact section heading for grouped workspace content',
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$id = trim((string)($prop['id'] ?? ''));
|
||||
$class = trim((string)($prop['class'] ?? ''));
|
||||
$attrs = trim((string)($prop['attrs'] ?? ''));
|
||||
$header = (string)($prop['header_html'] ?? '');
|
||||
$body = (string)($prop['body_html'] ?? '');
|
||||
ob_start();
|
||||
?>
|
||||
<section<?= $id !== '' ? ' id="' . asafe($id) . '"' : '' ?> class="ws-section<?= $class !== '' ? ' ' . asafe($class) : '' ?>"<?= $attrs !== '' ? ' ' . $attrs : '' ?>>
|
||||
<?= $header ?>
|
||||
<?= $body ?>
|
||||
</section>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
},
|
||||
|
||||
'about' => 'Stacked workspace section wrapper for grouped content blocks',
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$id = trim((string)($prop['id'] ?? ''));
|
||||
$class = trim((string)($prop['class'] ?? ''));
|
||||
$top = (string)($prop['top_html'] ?? '');
|
||||
$body = (string)($prop['body_html'] ?? '');
|
||||
ob_start();
|
||||
?>
|
||||
<aside<?= $id !== '' ? ' id="' . asafe($id) . '"' : '' ?> class="ws-sidebar<?= $class !== '' ? ' ' . asafe($class) : '' ?>">
|
||||
<?= $top ?>
|
||||
<?= $body ?>
|
||||
</aside>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
},
|
||||
|
||||
'about' => 'Generic workspace sidebar wrapper with separate top and body slots',
|
||||
];
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$id = trim((string)($prop['id'] ?? ''));
|
||||
$class = trim((string)($prop['class'] ?? ''));
|
||||
$actionHtml = (string)($prop['action_html'] ?? '');
|
||||
$searchId = trim((string)($prop['search_id'] ?? ''));
|
||||
$searchClass = trim((string)($prop['search_class'] ?? ''));
|
||||
$searchInputId = trim((string)($prop['search_input_id'] ?? ''));
|
||||
$searchInputName = trim((string)($prop['search_input_name'] ?? 'search'));
|
||||
$searchInputClass = trim((string)($prop['search_input_class'] ?? ''));
|
||||
$searchPlaceholder = (string)($prop['search_placeholder'] ?? 'Search...');
|
||||
ob_start();
|
||||
?>
|
||||
<div<?= $id !== '' ? ' id="' . asafe($id) . '"' : '' ?> class="ws-sidebar-top<?= $class !== '' ? ' ' . asafe($class) : '' ?>">
|
||||
<?= $actionHtml ?>
|
||||
<div<?= $searchId !== '' ? ' id="' . asafe($searchId) . '"' : '' ?> class="ws-search-wrap<?= $searchClass !== '' ? ' ' . asafe($searchClass) : '' ?>">
|
||||
<i class="fas fa-search ws-search-icon"></i>
|
||||
<input type="search"<?= $searchInputId !== '' ? ' id="' . asafe($searchInputId) . '"' : '' ?> name="<?= asafe($searchInputName) ?>" class="ws-search-input<?= $searchInputClass !== '' ? ' ' . asafe($searchInputClass) : '' ?>" placeholder="<?= asafe($searchPlaceholder) ?>" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
},
|
||||
|
||||
'about' => 'Sidebar toolbar with optional action area and compact search field',
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php return [
|
||||
'render' => function($prop) {
|
||||
$id = trim((string)($prop['id'] ?? ''));
|
||||
$class = trim((string)($prop['class'] ?? ''));
|
||||
$label = trim((string)($prop['label'] ?? $prop['text'] ?? ''));
|
||||
$variant = preg_replace('/[^a-z0-9_-]/i', '', strtolower(trim((string)($prop['variant'] ?? 'neutral'))));
|
||||
if ($variant === '') $variant = 'neutral';
|
||||
$title = trim((string)($prop['title'] ?? ''));
|
||||
ob_start();
|
||||
?>
|
||||
<span<?= $id !== '' ? ' id="' . asafe($id) . '"' : '' ?> class="ws-status-pill ws-status-pill-<?= asafe($variant) ?><?= $class !== '' ? ' ' . asafe($class) : '' ?>"<?= $title !== '' ? ' title="' . asafe($title) . '"' : '' ?>><?= safe($label) ?></span>
|
||||
<?php
|
||||
return ob_get_clean();
|
||||
},
|
||||
|
||||
'about' => 'Compact semantic status badge for neutral, info, success, warning, and danger states',
|
||||
];
|
||||
Reference in New Issue
Block a user