trying to port web app starter from PHP

This commit is contained in:
udo
2026-04-19 09:38:23 +00:00
parent 46d98a092f
commit be514d63d6
546 changed files with 76910 additions and 2807 deletions
@@ -0,0 +1,32 @@
<?php
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$res = User::AuthWithPassword($_POST['password'] ?? '');
if ($res['result']) {
header('Location: '.URL::link('account/profile'));
exit;
} else {
$errors[] = $res['message'];
}
}
?><!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<?php if ($errors): ?>
<div style="color: red"><?php echo htmlspecialchars(implode(', ', $errors)); ?></div>
<?php endif; ?>
<form method="post">
<label>Email: <input type="email" name="email" required></label><br>
<label>Password: <input type="password" name="password" required></label><br>
<button type="submit">Login</button>
</form>
<p><a href="<?= URL::link('account/register') ?>">Register</a></p>
</body>
</html>
@@ -0,0 +1,4 @@
<?php
User::Logout();
header('Location: '.URL::link('account/login'));
exit;
@@ -0,0 +1,20 @@
<?php
if (!User::IsSignedIn()) {
header('Location: '.URL::link('account/login'));
exit;
}
$u = User::$current_profile;
?><!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Profile</title>
</head>
<body>
<h1>Profile</h1>
<p>Email: <?php echo htmlspecialchars($u['email']); ?></p>
<p>Roles: <?php echo htmlspecialchars(implode(', ', $u['roles'] ?? [])); ?></p>
<p>Created: <?php echo htmlspecialchars(date('c', $u['created'] ?? time())); ?></p>
<p><a href="<?= URL::link('account/logout') ?>">Logout</a></p>
</body>
</html>
@@ -0,0 +1,36 @@
<?php
$errors = [];
$success = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';
$res = User::Create(['email' => $email, 'password' => $password]);
if ($res['result']) {
$success = true;
} else {
$errors[] = $res['message'];
}
}
?><!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Register</title>
</head>
<body>
<h1>Register</h1>
<?php if ($success): ?>
<div style="color: green">Registration successful. <a href="<?= URL::link('account/login') ?>">Log in</a></div>
<?php endif; ?>
<?php if ($errors): ?>
<div style="color: red"><?php echo htmlspecialchars(implode(', ', $errors)); ?></div>
<?php endif; ?>
<form method="post">
<label>Email: <input type="email" name="email" required></label><br>
<label>Password: <input type="password" name="password" required></label><br>
<button type="submit">Register</button>
</form>
</body>
</html>
@@ -0,0 +1,485 @@
<?php
// OAuth Callback Handler
// This page handles the OAuth callback from external providers
$code = $_GET['code'] ?? null;
$state = $_GET['state'] ?? null;
$error = $_GET['error'] ?? null;
$error_description = $_GET['error_description'] ?? null;
// OAuth Configuration (in production, store these securely)
$oauth_config = [
'google' => [
'client_id' => 'YOUR_GOOGLE_CLIENT_ID',
'client_secret' => 'YOUR_GOOGLE_CLIENT_SECRET',
'token_url' => 'https://oauth2.googleapis.com/token',
'userinfo_url' => 'https://www.googleapis.com/oauth2/v2/userinfo',
'redirect_uri' => URL::Link('auth/callback')
]
];
// Check for OAuth errors
if ($error) {
$error_message = $error_description ?: $error;
URL::$fragments['error'] = "OAuth Error: " . htmlspecialchars($error_message);
URL::$fragments['error_type'] = 'oauth_error';
} else if ($code && $state) {
// Determine which OAuth provider this is for (you'd need to store this during the auth initiation)
$service = $_SESSION['oauth_service'] ?? 'google';
if (!isset($oauth_config[$service])) {
URL::$fragments['error'] = "Unknown OAuth service: $service";
URL::$fragments['error_type'] = 'invalid_service';
} else {
$config = $oauth_config[$service];
// Check if configuration is complete
if ($config['client_id'] === 'YOUR_GOOGLE_CLIENT_ID' ||
$config['client_secret'] === 'YOUR_GOOGLE_CLIENT_SECRET') {
URL::$fragments['demo_mode'] = true;
URL::$fragments['success'] = "OAuth callback received successfully! (Demo Mode)";
URL::$fragments['code'] = substr($code, 0, 20) . '...';
URL::$fragments['state'] = $state;
URL::$fragments['service'] = $service;
} else {
// STEP 1: Exchange authorization code for access token
$token_response = HTTP::post($config['token_url'], [
'client_id' => $config['client_id'],
'client_secret' => $config['client_secret'],
'code' => $code,
'grant_type' => 'authorization_code',
'redirect_uri' => $config['redirect_uri']
]);
if (!$token_response['success']) {
URL::$fragments['error'] = "Token exchange failed: " . ($token_response['error'] ?? 'Unknown error');
URL::$fragments['error_type'] = 'token_exchange_failed';
URL::$fragments['debug'] = $token_response;
} else {
$tokens = $token_response['data'];
$access_token = $tokens['access_token'];
// STEP 2: Get user profile information
$profile_response = HTTP::get_with_token($config['userinfo_url'], $access_token);
if (!$profile_response['success']) {
URL::$fragments['error'] = "Failed to get user profile: " . ($profile_response['error'] ?? 'Unknown error');
URL::$fragments['error_type'] = 'profile_fetch_failed';
URL::$fragments['debug'] = $profile_response;
} else {
$profile = $profile_response['data'];
// STEP 3: Create or login user
// This is where you'd typically:
// 1. Check if user exists by email
// 2. Create new user if doesn't exist
// 3. Update user profile with OAuth data
// 4. Set session variables
// For demo purposes, just set session data
$_SESSION['user_id'] = $profile['id'];
$_SESSION['user_email'] = $profile['email'];
$_SESSION['user_name'] = $profile['name'];
$_SESSION['user_picture'] = $profile['picture'] ?? null;
$_SESSION['oauth_provider'] = $service;
$_SESSION['logged_in_at'] = time();
URL::$fragments['success'] = "Successfully logged in with " . ucfirst($service) . "!";
URL::$fragments['user_profile'] = $profile;
URL::$fragments['redirect_to'] = 'dashboard'; // Where to redirect after success
}
}
}
}
// Clean up temporary OAuth session data
unset($_SESSION['oauth_service']);
unset($_SESSION['oauth_state']);
} else {
URL::$fragments['error'] = "Invalid OAuth callback - missing required parameters";
URL::$fragments['error_type'] = 'invalid_callback';
}
?>
<div class="oauth-callback-page">
<div class="callback-container">
<?php if (isset(URL::$fragments['error'])): ?>
<div class="callback-result error">
<div class="result-icon">✗</div>
<h2>Authentication Failed</h2>
<p><?= safe(URL::$fragments['error']) ?></p>
<div class="callback-actions">
<a href="<?= URL::Link('auth') ?>" class="btn btn-primary">Try Again</a>
<a href="<?= URL::Link('') ?>" class="btn btn-outline">Go Home</a>
</div>
</div>
<?php elseif (isset(URL::$fragments['success'])): ?>
<div class="callback-result success">
<div class="result-icon">✓</div>
<h2>Authentication Successful</h2>
<p><?= safe(URL::$fragments['success']) ?></p>
<?php if (isset(URL::$fragments['user_profile'])): ?>
<div class="user-profile">
<h3>Welcome back!</h3>
<div class="profile-info">
<?php if (URL::$fragments['user_profile']['picture']): ?>
<img src="<?= safe(URL::$fragments['user_profile']['picture']) ?>" alt="Profile Picture" class="profile-picture">
<?php endif; ?>
<div class="profile-details">
<p><strong>Name:</strong> <?= safe(URL::$fragments['user_profile']['name']) ?></p>
<p><strong>Email:</strong> <?= safe(URL::$fragments['user_profile']['email']) ?></p>
<p><strong>Provider:</strong> <?= safe(ucfirst($_SESSION['oauth_provider'])) ?></p>
</div>
</div>
</div>
<div class="callback-actions">
<a href="<?= URL::Link(URL::$fragments['redirect_to'] ?? '') ?>" class="btn btn-primary">Continue to Dashboard</a>
<a href="<?= URL::Link('auth') ?>" class="btn btn-outline">Back to Auth Demo</a>
</div>
<?php elseif (isset(URL::$fragments['demo_mode'])): ?>
<div class="callback-details">
<h3>Demo Mode - Next Steps for Implementation:</h3>
<div class="demo-info">
<p><strong>Service:</strong> <?= safe(URL::$fragments['service']) ?></p>
<p><strong>Code:</strong> <?= safe(URL::$fragments['code']) ?></p>
<p><strong>State:</strong> <?= safe(URL::$fragments['state']) ?></p>
</div>
<ol>
<li><strong>Configure OAuth credentials</strong>
<div class="code-snippet">
<code>Replace 'YOUR_GOOGLE_CLIENT_ID' and 'YOUR_GOOGLE_CLIENT_SECRET' with actual values</code>
</div>
</li>
<li><strong>Exchange authorization code for access token</strong></li>
<li><strong>Use access token to get user profile</strong></li>
<li><strong>Create or login user account</strong></li>
<li><strong>Set session variables</strong></li>
<li><strong>Redirect to dashboard/profile</strong></li>
</ol>
<div class="implementation-example">
<h4>Complete Implementation Example:</h4>
<div class="code-block-container">
<div class="terminal-header primary-gradient"></div>
<div class="terminal-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
<span class="mono-text">oauth-handler.php</span>
</div>
<pre class="code-block"><code><?= htmlspecialchars('// Exchange code for token
$token_response = HTTP::post("https://oauth2.googleapis.com/token", [
"client_id" => $client_id,
"client_secret" => $client_secret,
"code" => $code,
"grant_type" => "authorization_code",
"redirect_uri" => $redirect_uri
]);
// Get user profile
$profile_response = HTTP::get_with_token(
"https://www.googleapis.com/oauth2/v2/userinfo",
$token_response["data"]["access_token"]
);
// Login/create user
$profile = $profile_response["data"];
$user = User::FindByEmail($profile["email"]) ?: User::Create([
"email" => $profile["email"],
"name" => $profile["name"],
"picture" => $profile["picture"],
"oauth_provider" => "google",
"oauth_id" => $profile["id"]
]);
// Set session
$_SESSION["user_id"] = $user["id"];
$_SESSION["user_email"] = $user["email"];
$_SESSION["logged_in_at"] = time();
// Redirect to dashboard
URL::Redirect("dashboard");') ?></code></pre>
</div>
</div>
</div>
<div class="callback-actions">
<a href="<?= URL::Link('auth') ?>" class="btn btn-primary">Back to Auth Demo</a>
<a href="<?= URL::Link('') ?>" class="btn btn-outline">Go Home</a>
</div>
<?php endif; ?>
<?php if (isset(URL::$fragments['debug'])): ?>
<div class="debug-section">
<h4>Debug Information</h4>
<pre class="debug-content"><?= htmlspecialchars(json_encode(URL::$fragments['debug'], JSON_PRETTY_PRINT)) ?></pre>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
<style>
.oauth-callback-page {
min-height: 70vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.callback-container {
max-width: 800px;
width: 100%;
}
.callback-result {
background: var(--surface);
border-radius: var(--radius-lg);
padding: 3rem;
text-align: center;
border: 1px solid;
box-shadow: var(--shadow-lg);
}
.callback-result.success {
border-color: var(--success, #22c55e);
background: var(--success-bg, rgba(34, 197, 94, 0.05));
}
.callback-result.error {
border-color: var(--error, #ef4444);
background: var(--error-bg, rgba(239, 68, 68, 0.05));
}
.result-icon {
font-size: 4rem;
margin-bottom: 1rem;
font-weight: bold;
}
.callback-result.success .result-icon {
color: var(--success, #22c55e);
}
.callback-result.error .result-icon {
color: var(--error, #ef4444);
}
.callback-result h2 {
margin-bottom: 1rem;
color: var(--text-primary);
font-size: 1.8rem;
}
.callback-result > p {
margin-bottom: 2rem;
color: var(--text-secondary);
font-size: 1.1rem;
}
.callback-details {
text-align: left;
background: var(--bg-secondary);
border-radius: var(--radius-md);
padding: 2rem;
margin: 2rem 0;
border: 1px solid var(--border);
}
.callback-details h3 {
margin-bottom: 1rem;
color: var(--text-primary);
font-size: 1.2rem;
}
.callback-details ol {
margin-bottom: 2rem;
padding-left: 1.5rem;
}
.callback-details li {
margin-bottom: 1rem;
color: var(--text-secondary);
line-height: 1.5;
}
.code-snippet {
margin-top: 0.5rem;
padding: 0.5rem;
background: var(--bg-color);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
font-family: 'Courier New', monospace;
}
.code-snippet code {
color: var(--text-primary);
font-size: 0.9rem;
}
.implementation-example h4 {
margin-bottom: 1rem;
color: var(--text-primary);
font-size: 1.1rem;
}
.callback-actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.callback-actions .btn {
min-width: 140px;
}
.user-profile {
text-align: left;
background: var(--bg-secondary);
border-radius: var(--radius-md);
padding: 1.5rem;
margin: 2rem 0;
border: 1px solid var(--border);
}
.user-profile h3 {
margin-bottom: 1rem;
color: var(--text-primary);
text-align: center;
}
.profile-info {
display: flex;
align-items: center;
gap: 1rem;
}
.profile-picture {
width: 60px;
height: 60px;
border-radius: 50%;
border: 2px solid var(--border);
object-fit: cover;
}
.profile-details {
flex: 1;
}
.profile-details p {
margin-bottom: 0.5rem;
color: var(--text-secondary);
font-size: 0.9rem;
}
.profile-details strong {
color: var(--text-primary);
}
.demo-info {
background: var(--bg-color);
padding: 1rem;
border-radius: var(--radius-sm);
margin-bottom: 1rem;
border: 1px solid var(--border);
}
.demo-info p {
margin-bottom: 0.5rem;
font-family: 'Courier New', monospace;
font-size: 0.85rem;
color: var(--text-secondary);
}
.demo-info strong {
color: var(--text-primary);
}
.debug-section {
margin-top: 2rem;
padding: 1rem;
background: var(--bg-color);
border-radius: var(--radius-md);
border: 1px solid var(--border);
}
.debug-section h4 {
margin-bottom: 1rem;
color: var(--text-primary);
font-size: 1rem;
}
.debug-content {
background: none;
margin: 0;
padding: 0;
font-size: 0.75rem;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
font-family: 'Courier New', monospace;
}
@media (max-width: 600px) {
.profile-info {
flex-direction: column;
text-align: center;
}
.profile-picture {
align-self: center;
}
}
.oauth-callback-page {
padding: 1rem;
}
.callback-result {
padding: 2rem;
}
.callback-details {
padding: 1.5rem;
}
.result-icon {
font-size: 3rem;
}
.callback-result h2 {
font-size: 1.5rem;
}
.callback-actions {
flex-direction: column;
}
.callback-actions .btn {
width: 100%;
}
}
</style>
<script>
// Auto-redirect after successful authentication (optional)
$(document).ready(function() {
const isSuccess = <?= isset(URL::$fragments['success']) ? 'true' : 'false' ?>;
if (isSuccess) {
// You could add a countdown timer here
// setTimeout(() => {
// window.location.href = "<?= URL::Link('') ?>";
// }, 5000);
}
});
</script>
@@ -0,0 +1,170 @@
<?php include_css('marketing.css') ?>
<h1>Authentication Demo</h1>
<div class="card">
<h2>OAuth Authentication</h2>
<p>This component provides secure OAuth authentication with popular identity providers.</p>
<?= component('components/auth/oauth-client', [
'title' => 'Sign In to Your Account',
'subtitle' => 'Choose your preferred authentication method to continue',
'google_client_id' => 'YOUR_GOOGLE_CLIENT_ID',
'github_client_id' => 'YOUR_GITHUB_CLIENT_ID',
'discord_client_id' => 'YOUR_DISCORD_CLIENT_ID',
'twitch_client_id' => 'YOUR_TWITCH_CLIENT_ID',
'callback_url' => URL::Link('auth/callback'),
'debug' => true // Enable debug mode to see OAuth details
]) ?>
</div>
<div class="card">
<h2>Setup Instructions</h2>
<div>
<div>
<h3><strong>1.</strong> Create OAuth Applications</h3>
<p>Create OAuth applications with your preferred providers:</p>
<ul>
<li><strong>Google:</strong> <a href="https://console.developers.google.com/" target="_blank">Google Cloud Console</a> - Enable Google+ API, create OAuth 2.0 credentials</li>
<li><strong>GitHub:</strong> <a href="https://github.com/settings/applications/new" target="_blank">GitHub Developer Settings</a> - Create new OAuth App</li>
<li><strong>Discord:</strong> <a href="https://discord.com/developers/applications" target="_blank">Discord Developer Portal</a> - Create new application with OAuth2</li>
<li><strong>Twitch:</strong> <a href="https://dev.twitch.tv/console/apps" target="_blank">Twitch Developer Console</a> - Register new application</li>
</ul>
<p>For all providers, add callback URL: <code><?= URL::Link('auth/callback') ?></code></p>
</div>
<div>
<h3><strong>2.</strong> Configure OAuth Component</h3>
<p>Update the OAuth component with your client IDs from each provider:</p>
<div class="code-block-container">
<div class="terminal-header primary-gradient"></div>
<div class="terminal-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
<span class="mono-text">views/account.php</span>
</div>
<pre class="code-block"><code><span style="color: var(--accent);">&lt;?php</span> <span style="color: var(--secondary);">component</span>(<span style="color: var(--success);">'components/auth/oauth-client'</span>, [
<span style="color: var(--primary);">'google_client_id'</span> <span style="color: var(--text-muted);">=></span> <span style="color: var(--success);">'your-google-client-id'</span>,
<span style="color: var(--primary);">'github_client_id'</span> <span style="color: var(--text-muted);">=></span> <span style="color: var(--success);">'your-github-client-id'</span>,
<span style="color: var(--primary);">'discord_client_id'</span> <span style="color: var(--text-muted);">=></span> <span style="color: var(--success);">'your-discord-client-id'</span>,
<span style="color: var(--primary);">'twitch_client_id'</span> <span style="color: var(--text-muted);">=></span> <span style="color: var(--success);">'your-twitch-client-id'</span>,
<span style="color: var(--primary);">'callback_url'</span> <span style="color: var(--text-muted);">=></span> <span style="color: var(--secondary);">URL::Link</span>(<span style="color: var(--success);">'auth/callback'</span>)
]); <span style="color: var(--accent);">?&gt;</span></code></pre>
</div>
<p><em>Note: Only providers with valid client IDs will appear as login options.</em></p>
</div>
<div>
<h3><strong>3.</strong> Implement Backend Handler</h3>
<p>Create <code>views/account/callback.php</code> to handle the OAuth callback and exchange the authorization code for tokens:</p>
<div class="code-block-container">
<div class="terminal-header success-gradient"></div>
<div class="terminal-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
<span class="mono-text">views/account/callback.php</span>
</div>
<pre class="code-block"><code><span style="color: var(--accent);">&lt;?php</span>
<span style="color: var(--text-muted);">// Handle OAuth callback</span>
<span style="color: var(--secondary);">if</span> (<span style="color: var(--accent);">$_GET</span>[<span style="color: var(--success);">'code'</span>]) {
<span style="color: var(--text-muted);">// Exchange code for access token</span>
<span style="color: var(--text-muted);">// Get user profile from provider</span>
<span style="color: var(--text-muted);">// Create/login user account</span>
<span style="color: var(--text-muted);">// Set session and redirect</span>
}
<span style="color: var(--accent);">?&gt;</span></code></pre>
</div>
</div>
</div>
</div>
<div class="card">
<h2>Built-in Provider Support</h2>
<p>The OAuth component comes with built-in support for popular providers:</p>
<div class="components-grid">
<div class="component-card">
<h4><i class="fab fa-google" style="color: #4285f4;"></i> Google OAuth</h4>
<div class="code-block-container">
<div class="terminal-header success-gradient"></div>
<div class="terminal-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
<span class="mono-text">google.config</span>
</div>
<pre class="code-block"><code><span style="color: var(--text-muted);">// Scope: openid, email, profile</span>
<span style="color: var(--text-muted);">// Additional params: access_type=offline</span>
<span style="color: var(--success);">'google_client_id'</span> <span style="color: var(--text-muted);">=></span> <span style="color: var(--success);">'your-client-id'</span></code></pre>
</div>
</div>
<div class="component-card">
<h4><i class="fab fa-github" style="color: #333;"></i> GitHub OAuth</h4>
<div class="code-block-container">
<div class="terminal-header warning-gradient"></div>
<div class="terminal-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
<span class="mono-text">github.config</span>
</div>
<pre class="code-block"><code><span style="color: var(--text-muted);">// Scope: user:email</span>
<span style="color: var(--text-muted);">// Additional params: allow_signup=true</span>
<span style="color: var(--success);">'github_client_id'</span> <span style="color: var(--text-muted);">=></span> <span style="color: var(--success);">'your-client-id'</span></code></pre>
</div>
</div>
<div class="component-card">
<h4><i class="fab fa-discord" style="color: #5865f2;"></i> Discord OAuth</h4>
<div class="code-block-container">
<div class="terminal-header primary-gradient"></div>
<div class="terminal-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
<span class="mono-text">discord.config</span>
</div>
<pre class="code-block"><code><span style="color: var(--text-muted);">// Scope: identify, email</span>
<span style="color: var(--text-muted);">// Additional params: prompt=consent</span>
<span style="color: var(--success);">'discord_client_id'</span> <span style="color: var(--text-muted);">=></span> <span style="color: var(--success);">'your-client-id'</span></code></pre>
</div>
</div>
<div class="component-card">
<h4><i class="fab fa-twitch" style="color: #9146ff;"></i> Twitch OAuth</h4>
<div class="code-block-container">
<div class="terminal-header warning-gradient"></div>
<div class="terminal-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
<span class="mono-text">twitch.config</span>
</div>
<pre class="code-block"><code><span style="color: var(--text-muted);">// Scope: user:read:email</span>
<span style="color: var(--text-muted);">// Additional params: force_verify=true</span>
<span style="color: var(--success);">'twitch_client_id'</span> <span style="color: var(--text-muted);">=></span> <span style="color: var(--success);">'your-client-id'</span></code></pre>
</div>
</div>
</div>
</div>
<div class="card">
<h2>Current Session Status</h2>
<?php if (isset($_SESSION['user_id'])): ?>
<div class="success-card">
<h3>✓ Logged In</h3>
<p>User ID: <?= safe($_SESSION['user_id']) ?></p>
<?php if (isset($_SESSION['user_email'])): ?>
<p>Email: <?= safe($_SESSION['user_email']) ?></p>
<?php endif; ?>
</div>
<?php else: ?>
<div class="component-card">
<h3>ⓘ Not Logged In</h3>
<p>Use the OAuth component above to sign in with Google, GitHub, Discord, or Twitch.</p>
</div>
<?php endif; ?>
</div>
@@ -0,0 +1,21 @@
<?php
// Simple endpoint to store OAuth session data
// This allows the frontend to communicate the OAuth service to the backend
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
if ($input && isset($input['oauth_service']) && isset($input['oauth_state'])) {
$_SESSION['oauth_service'] = $input['oauth_service'];
$_SESSION['oauth_state'] = $input['oauth_state'];
echo json_encode(['status' => 'success']);
} else {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Invalid input']);
}
} else {
http_response_code(405);
echo json_encode(['status' => 'error', 'message' => 'Method not allowed']);
}
+165
View File
@@ -0,0 +1,165 @@
<?= component('components/example/hero-section', [
'title' => 'Dark Theme Demo',
'subtitle' => 'Experience our beautiful dark mode with enhanced readability and modern aesthetics.',
'cta_text' => 'Explore Features',
'cta_link' => '#features'
]) ?>
<?= component('components/example/features-grid', [
'features' => [
[
'icon' => '🌙',
'title' => 'Dark Mode',
'description' => 'Beautiful dark theme with carefully chosen colors for optimal readability.'
],
[
'icon' => '⚡',
'title' => 'Performance',
'description' => 'Optimized for speed with reduced eye strain in low-light environments.'
],
[
'icon' => '🎨',
'title' => 'Design',
'description' => 'Modern dark UI that adapts seamlessly across all components.'
]
]
]) ?>
<?= component('components/example/pricing-table', [
'plans' => [
[
'name' => 'Dark Starter',
'price' => 'Free',
'period' => '',
'description' => 'Perfect for trying out dark mode',
'features' => [
'Dark theme support',
'Basic components',
'Community support'
],
'cta' => 'Try Dark Mode',
'popular' => false
],
[
'name' => 'Dark Pro',
'price' => '$19',
'period' => '/month',
'description' => 'Professional dark theme experience',
'features' => [
'Advanced dark components',
'Theme customization',
'Priority support',
'Custom color schemes'
],
'cta' => 'Go Dark Pro',
'popular' => true
]
]
]) ?>
<div class="demo-section">
<div class="demo-container">
<h2>Dark Theme Components</h2>
<p>See how beautiful our components look in dark mode</p>
<div class="demo-grid">
<div class="demo-card">
<h3>Dark Forms</h3>
<form class="demo-form">
<div>
<label>Name</label>
<input type="text" placeholder="Your name" />
</div>
<div>
<label>Email</label>
<input type="email" placeholder="your@email.com" />
</div>
<button type="submit">Submit</button>
</form>
</div>
<div class="demo-card">
<h3>Notifications</h3>
<div class="notification-demo">
<div class="banner success">✓ Dark theme activated successfully</div>
<div class="banner warning">⚠ Theme preferences saved</div>
<div class="banner error">✗ Error example in dark mode</div>
</div>
</div>
</div>
</div>
</div>
<?= component('components/example/theme-switcher') ?>
<style>
.demo-section {
padding: 4rem 0;
background: var(--bg-color);
}
.demo-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
text-align: center;
}
.demo-container 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;
}
.demo-container > p {
font-size: 1.25rem;
color: var(--text-secondary);
margin-bottom: 3rem;
}
.demo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 2rem;
margin-top: 3rem;
}
.demo-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: 2.5rem;
box-shadow: var(--shadow-md);
text-align: left;
}
.demo-card h3 {
margin-bottom: 2rem;
color: var(--text-primary);
text-align: center;
}
.demo-form {
max-width: none;
}
.notification-demo {
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (max-width: 768px) {
.demo-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.demo-card {
padding: 2rem 1.5rem;
}
}
</style>
@@ -0,0 +1,205 @@
.dashboard-intro p,
.dashboard-panel-header p,
.dashboard-note {
color: var(--text-secondary);
max-width: 72ch;
}
.dashboard-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
margin-bottom: 2rem;
overflow: hidden;
padding: 1.5rem;
}
.dashboard-panel-header {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 1.25rem;
}
.dashboard-panel-header h2 {
font-size: 1.4rem;
margin-bottom: 0;
}
.dashboard-stat-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.dashboard-stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
color: inherit;
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 1rem;
text-decoration: none;
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
position: relative;
overflow: hidden;
}
.dashboard-stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
text-decoration: none;
}
.dashboard-stat-card::before {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: 4px;
background: var(--primary);
}
.dashboard-stat-card.tone-success::before {
background: var(--success);
}
.dashboard-stat-card.tone-warning::before {
background: var(--warning);
}
.dashboard-stat-card.tone-danger::before {
background: var(--error);
}
.dashboard-stat-card.tone-info::before {
background: var(--info);
}
.dashboard-stat-label {
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.dashboard-stat-value {
font-size: clamp(1.6rem, 2vw, 2.1rem);
font-variant-numeric: tabular-nums;
font-weight: 700;
line-height: 1.1;
}
.dashboard-stat-meta {
color: var(--text-secondary);
font-size: 0.92rem;
}
.dashboard-chart-canvas {
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.05), transparent 45%),
linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(15, 23, 42, 0.08));
border: 1px solid var(--border);
border-radius: var(--radius);
display: block;
width: 100%;
min-height: 180px;
}
.dashboard-table-wrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.u-sortable-table {
border-collapse: collapse;
font-size: 0.95rem;
min-width: 720px;
width: 100%;
}
.u-sortable-table thead th {
background: var(--surface-elevated);
border-bottom: 1px solid var(--border);
color: var(--text-primary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.05em;
padding: 0.85rem 1rem;
position: relative;
text-transform: uppercase;
white-space: nowrap;
}
.u-sortable-table thead th.sortable {
cursor: pointer;
padding-right: 2rem;
user-select: none;
}
.u-sortable-table thead th.sortable::after {
content: ':';
color: var(--text-muted);
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
font-size: 0.8rem;
}
.u-sortable-table thead th.sorted-asc::after {
content: '^';
color: var(--primary);
}
.u-sortable-table thead th.sorted-desc::after {
content: 'v';
color: var(--primary);
}
.u-sortable-table tbody tr:nth-child(odd) {
background: rgba(148, 163, 184, 0.05);
}
.u-sortable-table tbody tr:hover {
background: rgba(96, 165, 250, 0.08);
}
.u-sortable-table td {
border-bottom: 1px solid var(--border);
padding: 0.85rem 1rem;
vertical-align: top;
font-variant-numeric: tabular-nums;
}
.u-sortable-table tbody tr:last-child td {
border-bottom: none;
}
.u-sortable-table .align-right {
text-align: right;
}
.u-sortable-table .align-center {
text-align: center;
}
.u-sortable-table .muted {
color: var(--text-secondary);
text-align: center;
}
@media (max-width: 768px) {
.dashboard-panel {
padding: 1rem;
}
.u-sortable-table {
min-width: 600px;
}
}
@@ -0,0 +1,90 @@
<?php
URL::$route['page-title'] = 'Dashboard';
include_css('dashboard.css');
$trafficSeries = [
[
'key' => 'requests',
'label' => 'Requests',
'color' => '#60a5fa',
'axis' => 'left',
'format' => 'count',
'decimals' => 0,
'values' => [240, 268, 294, 322, 301, 356, 388],
],
[
'key' => 'latency',
'label' => 'Latency',
'color' => '#f59e0b',
'axis' => 'right',
'format' => 'duration-ms',
'values' => [182, 176, 191, 204, 188, 166, 159],
],
];
$serviceRows = [
['service' => 'router', 'uptime' => '12 days', 'requests' => 142890, 'memory' => 402653184, 'p95_latency' => 148, 'healthy' => true],
['service' => 'queue-worker', 'uptime' => '8 days', 'requests' => 98214, 'memory' => 654311424, 'p95_latency' => 231, 'healthy' => true],
['service' => 'vector-index', 'uptime' => '5 days', 'requests' => 44892, 'memory' => 1241513984, 'p95_latency' => 312, 'healthy' => true],
['service' => 'sandbox', 'uptime' => '19 hours', 'requests' => 12810, 'memory' => 295698432, 'p95_latency' => 418, 'healthy' => false],
];
?>
<h1>Dashboard Primitives</h1>
<div class="card dashboard-intro">
<p>
This page is the first backport slice from the LocalAI dashboard frontend. It keeps the parts that are generic enough for the starter itself:
metric cards, a canvas time-series chart, and a lightweight sortable table that remembers its last sort choice.
</p>
</div>
<?= component('components/data/summary-metrics', [
'title' => 'Starter-Friendly Overview Cards',
'subtitle' => 'Small summary tiles work well across admin pages, internal tools, and SSR dashboards.',
'items' => [
['label' => '24h Requests', 'value' => '18,420', 'meta' => '+12.8% vs yesterday', 'tone' => 'info'],
['label' => 'Median Latency', 'value' => '182 ms', 'meta' => 'stable over last 7 samples', 'tone' => 'success'],
['label' => 'Resident Memory', 'value' => '2.4 GB', 'meta' => 'combined across workers', 'tone' => 'warning'],
['label' => 'Healthy Services', 'value' => '3 / 4', 'meta' => 'one degraded background worker', 'tone' => 'danger'],
],
]) ?>
<?= component('components/data/timeseries-chart', [
'id' => 'dashboard-demo-traffic',
'title' => 'Requests vs Latency',
'subtitle' => 'Same generic chart primitive can track throughput, job backlog, token volume, or queue time.',
'height' => 320,
'x_axis_label' => 'Last 7 Hours',
'y_axis_left_label' => 'Requests',
'y_axis_right_label' => 'Latency',
'y_axis_left_format' => 'count',
'y_axis_right_format' => 'duration-ms',
'x_labels' => ['08:00', '09:00', '10:00', '11:00', '12:00', '13:00', '14:00'],
'series' => $trafficSeries,
]) ?>
<?= component('components/data/sortable-table', [
'id' => 'dashboard-service-table',
'title' => 'Service Snapshot',
'subtitle' => 'Vanilla HTML table enhancement for cases where ag-Grid is overkill.',
'storage_key' => 'starter.dashboard.services',
'sort' => ['column' => 2, 'direction' => 'desc'],
'columns' => [
['key' => 'service', 'label' => 'Service'],
['key' => 'uptime', 'label' => 'Uptime'],
['key' => 'requests', 'label' => 'Requests', 'align' => 'right', 'format' => 'number'],
['key' => 'memory', 'label' => 'Memory', 'align' => 'right', 'format' => 'bytes'],
['key' => 'p95_latency', 'label' => 'P95 Latency', 'align' => 'right', 'format' => 'duration-ms'],
['key' => 'healthy', 'label' => 'Healthy', 'align' => 'center', 'format' => 'bool'],
],
'rows' => $serviceRows,
]) ?>
<div class="card">
<h2>What Was Backported</h2>
<p class="dashboard-note">
The charting logic and data-formatting ideas come directly from the more complex dashboard frontend on <code>uh-llm2</code>, but the starter version is stripped down to generic building blocks.
That keeps the repo useful as a baseline instead of baking in LocalAI-specific assumptions.
</p>
</div>
+5
View File
@@ -0,0 +1,5 @@
<?php return(function($prop) {
?><div class="banner error"><?= safe($prop['text']) ?></div><?php
});
+232
View File
@@ -0,0 +1,232 @@
<?php include_css('themes/common/css/gauges.css'); ?>
<?php URL::$route['page-title'] = 'Gauges'; ?>
<h1>Gauge Components Demo</h1>
<div class="demo-section gauge-demo-row">
<?= component('components/gauges/progressbar', [
'id' => 'horizontal_demo',
'title' => 'Horizontal Progress Bar',
'subtitle' => 'The original bar gauges, restyled to share the same card and token language as the new arc gauges.',
'layout' => 'horizontal',
'style' => 'flex:1 1 24rem',
'listen' => true,
'markers' => [
'zero' => [
'value' => 0,
'label' => 'Zero',
'color' => 'var(--bg-color)',
],
'high' => [
'value' => 100,
'label' => 'High',
],
],
'items' => [
'cpu' => [
'value' => 45,
'min' => 0,
'max' => 200,
'label' => 'CPU',
'tooltip' => 'CPU Usage',
'color' => [
['from' => -50, 'to' => 20, 'color' => 'var(--text-muted)'],
['from' => 20, 'to' => 80, 'color' => 'var(--primary)'],
['from' => 80, 'to' => 100, 'color' => 'var(--warning)'],
],
],
'memory' => [
'value' => 92,
'min' => -50,
'max' => 100,
'label' => 'Memory',
'tooltip' => 'Memory Usage',
],
'disk' => [
'value' => 28,
'min' => 0,
'max' => 100,
'label' => 'Disk I/O',
'tooltip' => 'Disk I/O',
],
],
]) ?>
<?= component('components/gauges/progressbar', [
'id' => 'vertical_demo',
'title' => 'Vertical Progress Bar',
'subtitle' => 'Same abstraction, but stacked as compact KPI cards.',
'layout' => 'vertical',
'style' => 'flex:1 1 24rem',
'height' => 350,
'listen' => true,
'markers' => [
'zero' => [
'value' => 0,
'label' => 'Zero',
'color' => 'var(--bg-color)',
],
'high' => [
'value' => 100,
'label' => 'High',
],
],
'items' => [
'cpu' => [
'value' => 45,
'min' => 0,
'max' => 200,
'label' => 'CPU',
'tooltip' => 'CPU Usage',
],
'memory' => [
'value' => 92,
'min' => -50,
'max' => 100,
'label' => 'RAM',
'tooltip' => 'Memory Usage',
],
'disk' => [
'value' => 28,
'min' => 0,
'max' => 100,
'label' => 'I/O',
'tooltip' => 'Disk I/O',
],
],
]) ?>
<div class="gauge-control-panel">
<div class="control-group">
<h4>Event Binding</h4>
<div class="gauge-slider-group">
<label for="cpu-slider">CPU</label>
<input type="range" id="cpu-slider" min="0" max="200" value="45"
onchange="$.events.emit('value-broadcast', { name: 'cpu', value: this.value });">
</div>
<div class="gauge-slider-group">
<label for="memory-slider">Memory</label>
<input type="range" id="memory-slider" min="-50" max="100" value="92"
onchange="$.events.emit('value-broadcast', { name: 'memory', value: this.value });">
</div>
<div class="gauge-slider-group">
<label for="disk-slider">Disk I/O</label>
<input type="range" id="disk-slider" min="0" max="100" value="28"
onchange="$.events.emit('value-broadcast', { name: 'disk', value: this.value });">
</div>
</div>
</div>
</div>
<div class="demo-section gauge-demo-row">
<?= component('components/gauges/needlegauge', [
'title' => 'Needle Gauge',
'subtitle' => 'The original analog gauge now uses the same elevated panels, typography, and theme-token palette as the arc gauges.',
'style' => 'flex:1 1 24rem',
'listen' => true,
'label' => 'CPU',
'tooltip' => 'CPU Usage',
'scale' => [
'angle_start' => -1.2*pi(),
'angle_end' => 0.2*pi(),
'max' => 100,
'unit' => '%',
'ticks-every' => 10,
'value-labels-every' => 20,
'tick-color' => 'var(--text-muted)',
'color' => [
['from' => -50, 'to' => 10, 'color' => 'var(--primary)'],
['from' => 10, 'to' => 70, 'color' => 'var(--text-muted)'],
['from' => 70, 'to' => 90, 'color' => 'var(--warning)'],
['from' => 90, 'to' => 200, 'color' => 'var(--error)'],
],
],
'items' => [
'cpu' => [
'max' => 200,
'value' => 45,
'label' => 'CPU',
],
'memory' => [
'value' => 92,
'min' => -50,
'label' => 'Memory',
'tooltip' => 'Memory Usage',
],
'disk' => [
'value' => 28,
'label' => 'Disk I/O',
'tooltip' => 'Disk I/O',
],
],
]) ?>
</div>
<div class="demo-section gauge-demo-row">
<?= component('components/gauges/arcgauge', [
'id' => 'arc_demo',
'title' => 'SVG Arc Gauges',
'subtitle' => 'Backported from the llm2 overview as reusable KPI-style gauges with optional watermark tracking.',
'style' => 'flex: 2 1 36rem',
'listen' => true,
'items' => [
'load' => [
'label' => 'System Load',
'value' => 1.8,
'max' => 8,
'precision' => 1,
'caption' => 'LOAD 1M',
'meta' => '4 cores available',
'watermark_prefix' => 'loadDemo',
'color' => [
['from' => 0, 'to' => 3.5, 'color' => 'var(--success, #10b981)'],
['from' => 3.5, 'to' => 6, 'color' => 'var(--warning, #f59e0b)'],
['from' => 6, 'to' => 8, 'color' => 'var(--error, #ef4444)'],
],
],
'memory_arc' => [
'label' => 'Memory',
'value' => 62,
'max' => 100,
'precision' => 0,
'unit' => '%',
'caption' => 'MEMORY',
'meta' => '9.9 / 16 GB',
'watermark_prefix' => 'memoryDemo',
],
'network' => [
'label' => 'Network',
'value' => 18,
'max' => 100,
'precision' => 0,
'unit' => ' MB/s',
'caption' => 'THROUGHPUT',
'meta' => 'Inbound + outbound',
'watermark_prefix' => 'networkDemo',
],
],
]) ?>
<div class="gauge-control-panel">
<h4>Arc Gauge Controls</h4>
<div class="gauge-slider-group">
<label for="load-slider">Load</label>
<input type="range" id="load-slider" min="0" max="8" value="1.8" step="0.1"
oninput="$.events.emit('value-broadcast', { name: 'load', value: this.value, meta: this.value + ' / 8.0 load' });">
</div>
<div class="gauge-slider-group">
<label for="memory-arc-slider">Memory</label>
<input type="range" id="memory-arc-slider" min="0" max="100" value="62" step="1"
oninput="$.events.emit('value-broadcast', { name: 'memory_arc', value: this.value, meta: this.value + '% used' });">
</div>
<div class="gauge-slider-group">
<label for="network-slider">Network Throughput</label>
<input type="range" id="network-slider" min="0" max="100" value="18" step="1"
oninput="$.events.emit('value-broadcast', { name: 'network', value: this.value, meta: this.value + ' MB/s aggregate' });">
</div>
</div>
</div>
+146
View File
@@ -0,0 +1,146 @@
<?php
include_css('marketing.css');
$themeOptions = (array)cfg('theme/options');
$featuredThemeKeys = array('portal-light', 'portal-dark', 'localfirst');
?>
<?= component('components/example/hero-section', [
'title' => 'Stunning Apps',
'subtitle' => 'Experience the power of super bloated PHP development with our gigantic and truly unwieldy component-based framework.
Seriously though this page only serves as an example repository of
different styles and blocks.',
'cta_text' => 'Get Started Free(mium)',
'cta_link' => '#features'
]) ?>
<?= component('components/example/features-grid') ?>
<?= component('components/example/stats-section', [
'stats' => [
['number' => '10K+', 'label' => 'Happy Vibe Coders'],
['number' => '<1s', 'label' => 'Page Load Time'],
['number' => '99.9%', 'label' => 'Uptime'],
['number' => '24/7', 'label' => 'Support']
]
]) ?>
<?= component('components/example/brands-showcase') ?>
<?= component('components/example/testimonials') ?>
<?= component('components/example/pricing-table') ?>
<div class="demo-section">
<div class="demo-container">
<h2>Demo</h2>
<p>Try our UNBELIEVABLE components in action</p>
<div class="demo-grid">
<div class="demo-card">
<h3>Modern Forms</h3>
<form class="demo-form">
<div>
<label for="demo-full-name">Full Name</label>
<input id="demo-full-name" name="full_name" type="text" placeholder="Enter your name" autocomplete="name" />
</div>
<div>
<label for="demo-email-address">Email Address</label>
<input id="demo-email-address" name="email" type="email" placeholder="you@example.com" autocomplete="email" />
</div>
<div>
<label for="demo-message">Message</label>
<textarea id="demo-message" name="message" placeholder="Tell us what you think..." rows="4"></textarea>
</div>
<button type="submit">Send Message</button>
</form>
</div>
<div class="demo-card">
<h3>Button Variations</h3>
<div class="button-showcase">
<button class="btn">Primary Button</button>
<button class="btn btn-secondary">Secondary</button>
<button class="btn btn-outline">Outline</button>
<button class="btn btn-large">Large Button</button>
</div>
<div class="notification-demo">
<div class="banner success">✓ Success message example</div>
<div class="banner warning">⚠ Warning message example</div>
<div class="banner error">✗ Error message example</div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<h2>Theme Families</h2>
<p>Compare the starters built-in theme families and jump to the live gallery.</p>
<div class="theme-grid">
<?php foreach($featuredThemeKeys as $themeKey): $themeInfo = $themeOptions[$themeKey] ?? null; if(!$themeInfo) continue; ?>
<div class="component-card">
<h3><?= safe((string)$themeInfo['label']) ?></h3>
<p><?= safe((string)first($themeInfo['description'] ?? false, 'Reusable starter theme family.')) ?></p>
<a class="btn" href="<?= URL::Link('themes', ['theme' => $themeKey]) ?>">Preview Theme</a>
</div>
<?php endforeach; ?>
<div class="component-card">
<h3>Starter Themes</h3>
<p>The original light and dark starter skins remain available in the same gallery for side-by-side checks.</p>
<a class="btn btn-secondary" href="<?= URL::Link('themes') ?>">Open Gallery</a>
</div>
</div>
</div>
<?= component('components/example/cta-section', [
'title' => 'Ready to Transform Your Development?',
'subtitle' => 'Join thousands of developers who have already modernized their workflow with our framework.',
'cta_text' => 'Start Your Project',
'secondary_text' => 'View GitHub'
]) ?>
<div class="card">
<h2>Component Development Guidelines</h2>
<div class="guidelines-grid">
<div class="guideline-item" style="border-left-color: var(--primary);">
<span class="guideline-icon">🏷️</span>
<span>Use semantic HTML5 elements</span>
</div>
<div class="guideline-item" style="border-left-color: var(--secondary);">
<span class="guideline-icon">🎨</span>
<span>Implement CSS custom properties for theming</span>
</div>
<div class="guideline-item" style="border-left-color: var(--accent);">
<span class="guideline-icon">📱</span>
<span>Add responsive design with mobile-first approach</span>
</div>
<div class="guideline-item" style="border-left-color: var(--success);">
<span class="guideline-icon">♿</span>
<span>Include accessibility attributes (ARIA, alt text)</span>
</div>
<div class="guideline-item" style="border-left-color: var(--warning);">
<span class="guideline-icon">⚡</span>
<span>Use progressive enhancement for JavaScript features</span>
</div>
</div>
</div>
<div class="card">
<h2>Simple Data Table (ag-Grid)</h2>
<p>Basic data table with auto-generated columns, sorting, and filtering:</p>
<?= component('components/data/table', [
'items' => [
['name' => 'John Doe', 'age' => 25, 'gender' => 'male', 'department' => 'Engineering', 'salary' => 75000, 'active' => true],
['name' => 'Jane Smith', 'age' => 30, 'gender' => 'female', 'department' => 'Design', 'salary' => 82000, 'active' => true],
['name' => 'Bob Johnson', 'age' => 35, 'gender' => 'male', 'department' => 'Marketing', 'salary' => 68000, 'active' => false],
['name' => 'Alice Brown', 'age' => 40, 'gender' => 'female', 'department' => 'Engineering', 'salary' => 95000, 'active' => true],
['name' => 'Dave Wilson', 'age' => 45, 'gender' => 'male', 'department' => 'Sales', 'salary' => 72000, 'active' => true],
['name' => 'Eve Davis', 'age' => 50, 'gender' => 'non-binary', 'department' => 'Management', 'salary' => 110000, 'active' => true],
['name' => 'Charlie Miller', 'age' => 28, 'gender' => 'male', 'department' => 'Engineering', 'salary' => 78000, 'active' => true],
['name' => 'Sarah Taylor', 'age' => 33, 'gender' => 'female', 'department' => 'Design', 'salary' => 85000, 'active' => false],
],
'height' => '350px'
]) ?>
</div>
+233
View File
@@ -0,0 +1,233 @@
/* Shared base styles */
.mono-text,
.code-block {
font-family: 'SF Mono', 'Monaco', 'Menlo', monospace;
}
/* Code block container with terminal styling */
.code-block-container {
background: linear-gradient(135deg, var(--surface) 0%, var(--surface-elevated) 100%);
padding: 2rem;
border-radius: var(--radius-lg);
border: 1px solid var(--border);
margin: 1rem 0;
position: relative;
overflow: hidden;
}
/* Terminal header bar */
.terminal-header {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
}
.terminal-header.primary-gradient {
background: linear-gradient(90deg, var(--primary) 0%, var(--secondary) 50%, var(--accent) 100%);
}
.terminal-header.success-gradient {
background: linear-gradient(90deg, var(--success) 0%, var(--primary) 50%, var(--secondary) 100%);
}
.terminal-header.warning-gradient {
background: linear-gradient(90deg, var(--warning) 0%, var(--accent) 50%, var(--primary) 100%);
}
/* Terminal window controls */
.terminal-controls {
display: flex;
align-items: center;
margin-bottom: 1rem;
}
.window-dot {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
}
.window-dot.red { background: #ff5f56; }
.window-dot.yellow { background: #ffbd2e; }
.window-dot.green {
background: #27ca3f;
margin-right: 1rem;
}
/* Monospace text styling */
.mono-text {
font-size: 0.875rem;
color: var(--text-muted);
}
/* Code block styling */
.code-block {
margin: 0;
font-size: 0.9rem;
line-height: 1.6;
color: var(--text-primary);
background: none;
}
/* Card components - using shared base styles */
.component-card,
.success-card,
.enhancement-card {
padding: 1rem;
border-radius: var(--radius);
border: 1px solid var(--border);
}
.component-card {
background: var(--surface-elevated);
}
.success-card {
background: var(--success-bg);
border-color: var(--success-border);
}
.enhancement-card {
color: var(--bg-color);
text-align: center;
border: none;
}
/* Grid layouts - shared base properties */
.components-grid,
.theme-grid,
.enhancements-grid,
.guidelines-grid {
display: grid;
gap: 1rem;
}
.components-grid {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
margin: 1rem 0;
}
.theme-grid {
grid-template-columns: 1fr 1fr;
margin: 1rem 0;
}
.enhancements-grid {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
.guidelines-grid {
gap: 0.5rem;
}
/* Guideline items */
.guideline-item {
display: flex;
align-items: center;
background: var(--surface-elevated);
padding: 1rem;
border-radius: var(--radius);
border-left: 4px solid;
}
.guideline-icon {
font-size: 1.5rem;
margin-right: 1rem;
}
/* Demo section styling */
.demo-section {
padding: 4rem 0;
background: var(--bg-color);
}
.demo-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
text-align: center;
}
.demo-container 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;
}
.demo-container > p {
font-size: 1.25rem;
color: var(--text-secondary);
margin-bottom: 3rem;
}
.demo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
gap: 2rem;
margin-top: 3rem;
}
.demo-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: 2.5rem;
box-shadow: var(--shadow-md);
text-align: left;
}
.demo-card h3 {
margin-bottom: 2rem;
color: var(--text-primary);
text-align: center;
}
.demo-form {
max-width: none;
}
/* Flex layouts */
.button-showcase,
.notification-demo {
display: flex;
gap: 1rem;
}
.button-showcase {
flex-wrap: wrap;
margin-bottom: 2rem;
justify-content: center;
}
.notification-demo {
flex-direction: column;
}
/* Responsive design lol */
@media (max-width: 768px) {
.demo-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.demo-card {
padding: 2rem 1.5rem;
}
.button-showcase {
flex-direction: column;
align-items: center;
}
.button-showcase .btn {
width: 100%;
max-width: 250px;
}
}
+126
View File
@@ -0,0 +1,126 @@
<h1>Component System</h1>
<div class="card">
<h2>Component Declaration</h2>
<?php include_css('marketing.css') ?>
<div class="code-block-container">
<div class="terminal-header primary-gradient"></div>
<div class="terminal-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
<span class="mono-text">component.php</span>
</div>
<pre class="code-block"><code><span style="color: var(--accent);">&lt;?php</span> <span style="color: var(--secondary);">return</span> [
<span style="color: var(--primary);">'render'</span> <span style="color: var(--text-muted);">=></span> <span style="color: var(--secondary);">function</span>(<span style="color: var(--accent);">$prop</span>) {
<span style="color: var(--text-muted);">// render the component</span>
},
<span style="color: var(--primary);">'about'</span> <span style="color: var(--text-muted);">=></span> <span style="color: var(--success);">'A floating theme switcher button that toggles between light and dark themes'</span>,
];
</code></pre>
</div>
</div>
<div class="card">
<h2>Example Components</h2>
<div class="components-grid">
<div class="component-card">
<h4>hero-section</h4>
<p>Landing page hero with CTA buttons</p>
</div>
<div class="component-card">
<h4>features-grid</h4>
<p>3-column feature showcase</p>
</div>
<div class="component-card">
<h4>stats-section</h4>
<p>Animated statistics display</p>
</div>
<div class="component-card">
<h4>testimonials</h4>
<p>Customer testimonial carousel</p>
</div>
<div class="component-card">
<h4>cta-section</h4>
<p>Call-to-action with background</p>
</div>
<div class="component-card">
<h4>pricing-table</h4>
<p>Responsive pricing tiers</p>
</div>
<div class="component-card">
<h4>brands-showcase</h4>
<p>Logo grid with animations</p>
</div>
<div class="component-card">
<h4>theme-switcher</h4>
<p>Light/dark theme toggle</p>
</div>
</div>
</div>
<div class="card">
<h2>Usage Examples</h2>
<div class="code-block-container">
<div class="terminal-header success-gradient"></div>
<div class="terminal-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
<span class="mono-text">usage-examples.php</span>
</div>
<pre class="code-block"><code><span style="color: var(--text-muted);">// Basic component</span>
<span style="color: var(--accent);">&lt;?php</span> <span style="color: var(--secondary);">component</span>(<span style="color: var(--success);">'components/example/hero-section'</span>); <span style="color: var(--accent);">?&gt;</span>
<span style="color: var(--text-muted);">// Component with data</span>
<span style="color: var(--accent);">&lt;?php</span> <span style="color: var(--secondary);">component</span>(<span style="color: var(--success);">'components/example/stats-section'</span>, [
<span style="color: var(--primary);">'title'</span> <span style="color: var(--text-muted);">=></span> <span style="color: var(--success);">'Our Growth'</span>,
<span style="color: var(--primary);">'stats'</span> <span style="color: var(--text-muted);">=></span> [
[<span style="color: var(--primary);">'number'</span> <span style="color: var(--text-muted);">=></span> <span style="color: var(--success);">'50K+'</span>, <span style="color: var(--primary);">'label'</span> <span style="color: var(--text-muted);">=></span> <span style="color: var(--success);">'Users'</span>],
[<span style="color: var(--primary);">'number'</span> <span style="color: var(--text-muted);">=></span> <span style="color: var(--success);">'99.9%'</span>, <span style="color: var(--primary);">'label'</span> <span style="color: var(--text-muted);">=></span> <span style="color: var(--success);">'Uptime'</span>],
[<span style="color: var(--primary);">'number'</span> <span style="color: var(--text-muted);">=></span> <span style="color: var(--success);">'24/7'</span>, <span style="color: var(--primary);">'label'</span> <span style="color: var(--text-muted);">=></span> <span style="color: var(--success);">'Support'</span>]
]
]); <span style="color: var(--accent);">?&gt;</span></code></pre>
</div>
</div>
<div class="card">
<h2>Theme Info</h2>
<p>This starter pack includes a light and a dark theme</p>
<div class="theme-grid">
<div class="success-card">
<h4>Light Theme</h4>
<code>themes/light/css/style.css</code>
</div>
<div class="component-card">
<h4>Dark Theme</h4>
<code>themes/dark/css/style.css</code>
</div>
</div>
<div class="code-block-container">
<div class="terminal-header warning-gradient"></div>
<div class="terminal-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
<span class="mono-text">variables.css</span>
</div>
<h4 style="margin: 0 0 1rem 0; color: var(--text-primary);">CSS Variables</h4>
<pre class="code-block"><code><span style="color: var(--text-muted);">/* Core theme variables */</span>
<span style="color: var(--primary);">--bg-color</span>, <span style="color: var(--primary);">--bg-secondary</span>, <span style="color: var(--primary);">--surface</span>
<span style="color: var(--secondary);">--text-primary</span>, <span style="color: var(--secondary);">--text-secondary</span>, <span style="color: var(--secondary);">--text-muted</span>
<span style="color: var(--accent);">--primary</span>, <span style="color: var(--accent);">--primary-dark</span>, <span style="color: var(--accent);">--primary-light</span>
<span style="color: var(--success);">--border</span>, <span style="color: var(--success);">--border-hover</span>
<span style="color: var(--warning);">--shadow-sm</span>, <span style="color: var(--warning);">--shadow-md</span>, <span style="color: var(--warning);">--shadow-lg</span></code></pre>
</div>
</div>
</div>
+9
View File
@@ -0,0 +1,9 @@
<?php
URL::$page_type = 'blank';
print(microtime(true) . ' - Page 2 Section 1 loaded');
print('<pre>Can haz ODT? ');
print_r(ODT::check_requirements());
print('</pre>');
+15
View File
@@ -0,0 +1,15 @@
<h1>Ajax Demo</h1>
<div id="page2-section1">
<p>This is the content of Page 2. You can add more information here.</p>
<?= nl2br('Sed at dolor leo. Morbi a tellus sed nisl dictum ultricies sit amet at purus. Nam mattis metus sed nunc egestas convallis. Fusce sagittis tellus convallis sem volutpat, a aliquam nulla posuere. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed malesuada, nunc at iaculis tempus, augue sem venenatis augue, nec hendrerit dolor arcu tempus tortor. Proin euismod nunc mi, ut ultrices elit porttitor et.
Aliquam erat volutpat. Ut non purus sit amet orci pulvinar elementum. Curabitur pharetra mi eget viverra sagittis. Maecenas consequat metus vitae gravida commodo. Sed vitae neque sed massa ultricies convallis. Duis venenatis, lacus non volutpat aliquam, augue elit aliquam odio, vitae iaculis tortor urna rhoncus magna. Morbi lacus dui, egestas vitae turpis quis, volutpat aliquam purus. Fusce in libero sapien. Pellentesque quis neque eget mauris scelerisque tempus quis iaculis arcu. Duis sollicitudin sit amet magna non finibus. Donec hendrerit erat vel nunc scelerisque viverra. Donec et elementum augue, eget fringilla arcu.') ?>
<br/>
<button onclick="$(this).parent().load('<?= URL::Link('page2-section1') ?>')">
Load new text
</button>
</div>
@@ -0,0 +1,102 @@
<?php
include_css('themes.css');
URL::$route['page-title'] = 'Theme Preview';
$currentThemeKey = (string)cfg('theme/key');
$themeLabel = (string)first(cfg('theme/label'), $currentThemeKey);
$themeMode = (string)cfg('theme/mode');
$previewStats = [
['label' => 'Current Theme', 'value' => $themeLabel],
['label' => 'Mode', 'value' => ucfirst($themeMode)],
['label' => 'Preview Route', 'value' => '/?theme-preview'],
];
?>
<div class="theme-preview-shell">
<section class="theme-preview-hero">
<span class="theme-preview-kicker">Starter Theme Preview</span>
<h1><?= safe($themeLabel) ?></h1>
<p>This route renders the same neutral content inside each theme so downstream projects can compare layout, typography, color tokens, and chrome without switching between unrelated pages.</p>
<div class="theme-preview-actions">
<a class="btn" href="<?= URL::Link('themes', ['theme' => $currentThemeKey]) ?>">Back to Gallery</a>
<a class="btn btn-secondary" href="<?= URL::Link('', ['theme' => $currentThemeKey]) ?>">Open Home in This Theme</a>
</div>
</section>
<section class="theme-preview-grid">
<div class="theme-preview-card">
<h2>Tokens at a Glance</h2>
<div class="theme-preview-stat-grid">
<?php foreach($previewStats as $stat): ?>
<div class="theme-preview-stat">
<span><?= safe($stat['label']) ?></span>
<strong><?= safe($stat['value']) ?></strong>
</div>
<?php endforeach; ?>
</div>
<div class="theme-preview-swatches">
<span style="background: var(--primary);">Primary</span>
<span style="background: var(--secondary);">Secondary</span>
<span style="background: var(--accent);">Accent</span>
<span style="background: var(--surface-elevated, var(--surface)); color: var(--text-primary); border: 1px solid var(--border);">Surface</span>
</div>
</div>
<div class="theme-preview-card">
<h2>System Message States</h2>
<div class="banner success">Success state keeps contrast and border semantics intact.</div>
<div class="banner warning">Warning state checks how the theme handles warm accents and muted text.</div>
<div class="banner error">Error state shows whether danger colors stay legible over each surface.</div>
</div>
<div class="theme-preview-card">
<h2>Form Controls</h2>
<form class="theme-preview-form">
<div>
<label for="preview-project">Project Name</label>
<input id="preview-project" name="project" type="text" placeholder="Starter backport playground" />
</div>
<div>
<label for="preview-owner">Owner</label>
<input id="preview-owner" name="owner" type="email" placeholder="team@example.com" />
</div>
<div>
<label for="preview-goal">Notes</label>
<textarea id="preview-goal" name="goal" rows="4" placeholder="Describe the kind of product this theme should support."></textarea>
</div>
<div class="theme-preview-actions">
<button type="button" class="btn">Primary Action</button>
<button type="button" class="btn btn-outline">Secondary Action</button>
</div>
</form>
</div>
<div class="theme-preview-card">
<h2>Dense Content Block</h2>
<table>
<thead>
<tr>
<th>Area</th>
<th>Expectation</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>Navigation</td>
<td>Should remain readable when menus get long.</td>
<td>Verified</td>
</tr>
<tr>
<td>Cards</td>
<td>Should keep spacing and hierarchy on both desktop and mobile.</td>
<td>Verified</td>
</tr>
<tr>
<td>Embeds</td>
<td>Should render cleanly inside the gallery iframe.</td>
<td>Verified</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
@@ -0,0 +1,180 @@
.theme-gallery-shell,
.theme-preview-shell {
display: grid;
gap: 1.5rem;
}
.theme-gallery-kicker,
.theme-preview-kicker {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 0.75rem;
}
.theme-gallery-hero h1,
.theme-preview-hero h1 {
margin-bottom: 0.75rem;
}
.theme-gallery-hero p,
.theme-preview-hero p {
max-width: 72ch;
color: var(--text-secondary);
}
.theme-gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1.25rem;
}
.theme-gallery-card,
.theme-preview-card,
.theme-preview-hero {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg, var(--radius));
padding: 1.25rem;
box-shadow: var(--shadow-sm);
}
.theme-gallery-card.is-active {
border-color: var(--primary);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 35%, transparent 65%), var(--shadow-md);
}
.theme-gallery-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.theme-gallery-head p {
margin: 0.4rem 0 0;
color: var(--text-secondary);
}
.theme-gallery-badge {
display: inline-flex;
align-items: center;
padding: 0.3rem 0.65rem;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 15%, transparent 85%);
color: var(--primary);
font-size: 0.78rem;
font-weight: 700;
white-space: nowrap;
}
.theme-gallery-actions,
.theme-preview-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
}
.theme-gallery-frame-wrap {
border: 1px solid var(--border);
border-radius: calc(var(--radius-lg, var(--radius)) - 4px);
overflow: hidden;
background: var(--bg-secondary);
}
.theme-gallery-frame-wrap iframe {
display: block;
width: 100%;
height: 430px;
border: 0;
background: #fff;
}
.theme-preview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
.theme-preview-stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.theme-preview-stat {
display: grid;
gap: 0.2rem;
padding: 0.85rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-elevated, var(--bg-secondary));
}
.theme-preview-stat span {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-muted);
}
.theme-preview-swatches {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.75rem;
}
.theme-preview-swatches span {
display: flex;
align-items: end;
min-height: 92px;
padding: 0.75rem;
border-radius: var(--radius);
color: #fff;
font-weight: 700;
box-shadow: inset 0 -20px 30px rgba(0, 0, 0, 0.12);
}
.theme-preview-form {
max-width: none;
display: grid;
gap: 0.85rem;
}
.embed-mode nav,
.embed-mode footer,
.embed-mode #theme-switcher,
.embed-mode .admin-toolbar {
display: none !important;
}
.embed-mode #content,
.embed-mode .admin-content {
margin-top: 0 !important;
min-height: 100vh !important;
padding-top: 1rem !important;
}
@media (max-width: 720px) {
.theme-gallery-grid,
.theme-preview-grid {
grid-template-columns: 1fr;
}
.theme-gallery-head {
flex-direction: column;
}
.theme-gallery-frame-wrap iframe {
height: 360px;
}
}
@@ -0,0 +1,35 @@
<?php
include_css('themes.css');
URL::$route['page-title'] = 'Themes';
$themeOptions = cfg('theme/options');
$currentTheme = (string)cfg('theme/key');
$previewRoute = 'theme-preview';
?>
<div class="theme-gallery-shell">
<section class="theme-gallery-hero card">
<span class="theme-gallery-kicker">Starter Theme Gallery</span>
<h1>Compare Themes Side by Side</h1>
<p>The gallery renders the same preview content in each theme family. This makes it easier to judge shell fit, typography, token balance, and embed behavior before carrying a theme into a downstream app.</p>
</section>
<div class="theme-gallery-grid">
<?php foreach($themeOptions as $themeKey => $themeInfo): ?>
<section class="theme-gallery-card<?= $themeKey === $currentTheme ? ' is-active' : '' ?>">
<div class="theme-gallery-head">
<div>
<h2><?= safe((string)$themeInfo['label']) ?></h2>
<p><?= safe((string)first($themeInfo['description'] ?? false, 'Reusable starter theme family.')) ?></p>
</div>
<?php if($themeKey === $currentTheme): ?><span class="theme-gallery-badge">Active</span><?php endif; ?>
</div>
<div class="theme-gallery-actions">
<a class="btn" href="<?= URL::Link($previewRoute, ['theme' => $themeKey]) ?>">Open Preview</a>
<a class="btn btn-secondary" href="<?= URL::Link('', ['theme' => $themeKey]) ?>">Open Home</a>
</div>
<div class="theme-gallery-frame-wrap">
<iframe title="<?= safe((string)$themeInfo['label']) ?> preview" src="<?= URL::Link($previewRoute, ['theme' => $themeKey, 'embed' => 1]) ?>"></iframe>
</div>
</section>
<?php endforeach; ?>
</div>
</div>
@@ -0,0 +1,182 @@
<?php
URL::$route['page-title'] = 'Workspace';
$section = trim((string)(URL::$route['param'] ?? 'overview'));
if ($section === '') $section = 'overview';
$sections = [
'overview' => [
'title' => 'Workspace overview',
'subtitle' => 'A generic app-shell pattern for tools, admin consoles, and internal products.',
'status' => ['label' => 'Ready', 'variant' => 'success'],
'description' => 'This slice comes from the uh-ai portal app: a reusable workspace shell with sidebar navigation, compact mobile controls, and semantic panel primitives.',
'highlights' => [
['title' => 'Shell layout', 'text' => 'Sidebar plus main panel, responsive overlay behavior, and a compact mobile header.'],
['title' => 'Semantic primitives', 'text' => 'Panels, section heads, status pills, list states, and empty-state placeholders.'],
['title' => 'Starter-friendly', 'text' => 'Backported against the starter theme variables instead of portal-specific branding tokens.'],
],
],
'projects' => [
'title' => 'Projects queue',
'subtitle' => 'Nested path fallback lets one controller serve multiple panes cleanly.',
'status' => ['label' => '3 active', 'variant' => 'info'],
'description' => 'This page is served from views/workspace/index.php, while the route remains /workspace/projects. That path fallback was backported from uh-ai as part of this slice.',
'highlights' => [
['title' => 'Content review', 'text' => 'Design a shared docs/workspace explorer for internal tools.'],
['title' => 'Component extraction', 'text' => 'Promote shell primitives into stable starter components.'],
['title' => 'Routing cleanup', 'text' => 'Use nested routes without multiplying single-file views.'],
],
],
'activity' => [
'title' => 'Recent activity',
'subtitle' => 'Sidebar-first tools often need a live or recent-events pane.',
'status' => ['label' => 'Monitoring', 'variant' => 'warn'],
'description' => 'The workspace shell is a better fit than the marketing-style home page when the app is navigation-heavy and stateful.',
'highlights' => [
['title' => 'Deploy preview', 'text' => 'Workspace layout validated on desktop and mobile widths.'],
['title' => 'Shell behavior', 'text' => 'Sidebar toggle is abstracted in js/u-workspace-shell.js.'],
['title' => 'Surface consistency', 'text' => 'All blocks inherit existing starter color and radius tokens.'],
],
],
];
if (!isset($sections[$section])) {
$section = 'overview';
}
$current = $sections[$section];
$navItems = [
['key' => 'overview', 'label' => 'Overview', 'icon' => 'fas fa-compass', 'meta' => 'Shell'],
['key' => 'projects', 'label' => 'Projects', 'icon' => 'fas fa-folder-tree', 'meta' => 'Routes'],
['key' => 'activity', 'label' => 'Activity', 'icon' => 'fas fa-wave-square', 'meta' => 'State'],
];
ob_start();
?>
<div class="ws-nav-group-label">Workspace areas</div>
<div class="ws-nav-list">
<?php foreach ($navItems as $item): ?>
<?php $active = $item['key'] === $section; ?>
<a class="ws-nav-item<?= $active ? ' is-active' : '' ?>" href="<?= URL::link('workspace/' . $item['key']) ?>">
<span class="ws-nav-item-inner">
<span class="ws-nav-icon"><i class="<?= asafe($item['icon']) ?>"></i></span>
<span class="ws-nav-item-text">
<span class="ws-nav-title"><?= safe($item['label']) ?></span>
<span class="ws-nav-meta"><?= safe($item['meta']) ?></span>
</span>
</span>
</a>
<?php endforeach; ?>
</div>
<div class="ws-demo-sidebar-copy">
<p>This sidebar and mobile shell pattern was extracted from the AI portal app and normalized against starter theme tokens.</p>
</div>
<?= component('components/workspace/list-state', [
'icon_class' => 'fas fa-circle-info',
'text' => 'Use /workspace/overview, /workspace/projects, or /workspace/activity',
]) ?>
<?php
$sidebarBody = ob_get_clean();
$sidebarTop = component('components/workspace/sidebar-toolbar', [
'action_html' => '<a class="ws-sidebar-action-btn" href="' . asafe(URL::link('workspace/overview')) . '"><i class="fas fa-grid-2"></i><span>Open shell</span></a>',
'search_input_id' => 'workspace-demo-search',
'search_input_name' => 'workspace_demo_search',
'search_placeholder' => 'Search workspace sections',
]);
$sidebar = component('components/workspace/sidebar-shell', [
'id' => 'workspace-demo-sidebar',
'top_html' => $sidebarTop,
'body_html' => $sidebarBody,
]);
$mobileBar = component('components/workspace/mobile-bar', [
'button_id' => 'workspace-demo-toggle',
'title' => 'Starter Workspace',
]);
$header = component('components/workspace/panel-header', [
'title' => $current['title'],
'subtitle' => $current['subtitle'],
'actions_html' => component('components/workspace/status-pill', [
'label' => $current['status']['label'],
'variant' => $current['status']['variant'],
]),
]);
$stats = [
['label' => 'Sidebar pattern', 'value' => 'Responsive', 'meta' => 'Overlay on mobile'],
['label' => 'Routing mode', 'value' => 'Nested', 'meta' => 'Parent-path fallback'],
['label' => 'Source app', 'value' => 'uh-ai', 'meta' => 'Portal shell'],
];
ob_start();
?>
<div class="ws-stat-grid">
<?php foreach ($stats as $stat): ?>
<div class="ws-stat-card">
<div class="ws-stat-card-label"><?= safe($stat['label']) ?></div>
<div class="ws-stat-card-value"><?= safe($stat['value']) ?></div>
<div class="ws-stat-card-meta"><?= safe($stat['meta']) ?></div>
</div>
<?php endforeach; ?>
</div>
<?php
$statsHtml = ob_get_clean();
ob_start();
?>
<div class="ws-detail-grid">
<?php foreach ($current['highlights'] as $highlight): ?>
<div class="ws-detail-card">
<h4><?= safe($highlight['title']) ?></h4>
<p><?= safe($highlight['text']) ?></p>
</div>
<?php endforeach; ?>
</div>
<?php
$detailHtml = ob_get_clean();
$mainBody =
component('components/workspace/section', [
'header_html' => component('components/workspace/section-head', ['title' => 'Why this belongs in the starter']),
'body_html' => '<p class="ws-section-copy">' . safe($current['description']) . '</p>' . $statsHtml,
]) .
component('components/workspace/section', [
'header_html' => component('components/workspace/section-head', ['title' => 'Starter demo content']),
'body_html' => $detailHtml . '<p class="ws-inline-note">Try visiting <strong>' . safe(URL::link('workspace/projects')) . '</strong> or <strong>' . safe(URL::link('workspace/activity')) . '</strong> to see the nested route fallback in action.</p>',
]);
if ($section === 'activity') {
$mainBody .= component('components/workspace/empty-state', [
'icon_class' => 'fas fa-clock-rotate-left',
'title' => 'No live stream wired yet',
'text' => 'The shell is generic. Add your own websocket, polling, or event-driven runtime behind it when a real product needs one.',
'action_html' => '<a class="ws-primary-btn" href="' . asafe(URL::link('dashboard')) . '">Open dashboard demo</a>',
]);
}
$main = $mobileBar . component('components/workspace/panel', [
'header_html' => $header,
'body_html' => $mainBody,
]);
echo component('components/workspace/app-frame', [
'id' => 'workspace-demo-shell',
'overlay_id' => 'workspace-demo-overlay',
'sidebar_html' => $sidebar,
'main_html' => $main,
]);
?>
<script>
(function () {
if (typeof UWorkspaceShell === 'undefined') return;
UWorkspaceShell.init({
sidebarId: 'workspace-demo-sidebar',
overlayId: 'workspace-demo-overlay',
toggleButtonId: 'workspace-demo-toggle'
});
}());
</script>