mirror of
https://github.com/JGH0/Todo-App-Backend.git
synced 2026-06-03 13:28:47 +02:00
Merge branch 'main' into APIhardening
This commit is contained in:
@@ -16,7 +16,7 @@ class App extends BaseConfig
|
||||
*
|
||||
* E.g., http://example.com/
|
||||
*/
|
||||
public string $baseURL = 'http://localhost:8080/';
|
||||
public string $baseURL = 'http://localhost/Todo-App-Backend/public/';
|
||||
|
||||
/**
|
||||
* Allowed Hostnames in the Site URL other than the hostname in the baseURL.
|
||||
|
||||
@@ -57,7 +57,7 @@ class Cors extends BaseConfig
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
|
||||
*/
|
||||
'supportsCredentials' => false,
|
||||
'supportsCredentials' => true,
|
||||
|
||||
/**
|
||||
* Set headers to allow.
|
||||
|
||||
@@ -6,6 +6,9 @@ use CodeIgniter\Router\RouteCollection;
|
||||
* @var RouteCollection $routes
|
||||
*/
|
||||
$routes->get('/', 'Home::index');
|
||||
$routes->get('/themes', 'ThemeStore::index');
|
||||
$routes->post('/themes/upload', 'ThemeStore::upload');
|
||||
$routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1');
|
||||
|
||||
// ============================================================================
|
||||
// API Routes - Version 1.0
|
||||
@@ -91,3 +94,20 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\V1', 'filter' => [
|
||||
$routes->put('user/themes/(:segment)', 'UserThemeController::update/$1');
|
||||
$routes->delete('user/themes/(:segment)', 'UserThemeController::delete/$1');
|
||||
});
|
||||
$routes->get('/themes', 'ThemeStore::index');
|
||||
$routes->options('/themes', static function () {
|
||||
header('Access-Control-Allow-Origin: http://localhost:5173');
|
||||
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Accept, Fetch');
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
return response()->setStatusCode(204);
|
||||
});
|
||||
$routes->post('/themes/upload', 'ThemeStore::upload');
|
||||
$routes->options('/themes/upload', static function () {
|
||||
header('Access-Control-Allow-Origin: http://localhost:5173');
|
||||
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Accept, Fetch');
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
return response()->setStatusCode(204);
|
||||
});
|
||||
$routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1');
|
||||
|
||||
266
app/Controllers/ThemeStore.php
Normal file
266
app/Controllers/ThemeStore.php
Normal file
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\MarketplaceThemeModel;
|
||||
use App\Models\UserThemeModel;
|
||||
use CodeIgniter\HTTP\Response;
|
||||
|
||||
class ThemeStore extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$model = new MarketplaceThemeModel();
|
||||
$themes = $model->where('is_published', 1)->findAll();
|
||||
|
||||
foreach ($themes as &$theme) {
|
||||
$meta = json_decode($theme['metadata'] ?? '{}', true);
|
||||
$theme['colors'] = $meta['colors'] ?? [];
|
||||
$theme['tags'] = $meta['tags'] ?? [];
|
||||
$theme['vars'] = $meta['vars'] ?? [];
|
||||
|
||||
// Provide a preview array compatible with the frontend
|
||||
$theme['preview'] = !empty($theme['colors']) ? array_values($theme['colors']) : ['#ffffff', '#f0f0f0', '#007acc'];
|
||||
}
|
||||
|
||||
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch') || str_contains($this->request->getHeaderLine('Accept'), 'application/json')) {
|
||||
header('Access-Control-Allow-Origin: http://localhost:5173');
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
return $this->response->setJSON($themes);
|
||||
}
|
||||
|
||||
return view('theme_store', [
|
||||
'themes' => $themes,
|
||||
'flash_success' => session()->getFlashdata('success'),
|
||||
'flash_error' => session()->getFlashdata('error'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function upload(): Response
|
||||
{
|
||||
header('Access-Control-Allow-Origin: http://localhost:5173');
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
|
||||
$file = $this->request->getFile('theme_css');
|
||||
$displayName = trim($this->request->getPost('display_name') ?? '');
|
||||
$description = trim($this->request->getPost('description') ?? '');
|
||||
|
||||
if ($displayName === '') {
|
||||
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch')) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['error' => 'Display name is required.']);
|
||||
}
|
||||
return redirect()->to('themes')->with('error', 'Display name is required.');
|
||||
}
|
||||
|
||||
if (! $file || ! $file->isValid() || $file->hasMoved()) {
|
||||
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch')) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['error' => 'Please upload a valid CSS file.']);
|
||||
}
|
||||
return redirect()->to('themes')->with('error', 'Please upload a valid CSS file.');
|
||||
}
|
||||
|
||||
if (strtolower($file->getExtension()) !== 'css') {
|
||||
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch')) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['error' => 'Only .css files are allowed.']);
|
||||
}
|
||||
return redirect()->to('themes')->with('error', 'Only .css files are allowed.');
|
||||
}
|
||||
|
||||
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $displayName));
|
||||
$slug = trim($slug, '-');
|
||||
$filename = $slug . '-' . substr(bin2hex(random_bytes(3)), 0, 6) . '.css';
|
||||
|
||||
$file->move(FCPATH . 'themes', $filename, true);
|
||||
|
||||
// Extract CSS variables and colors from the uploaded file
|
||||
$cssContent = file_get_contents(FCPATH . 'themes/' . $filename);
|
||||
preg_match_all('/(--[a-zA-Z0-9-]+)\s*:\s*([^;]+);/', $cssContent, $matches);
|
||||
|
||||
$vars = [];
|
||||
if (!empty($matches[1])) {
|
||||
foreach ($matches[1] as $index => $key) {
|
||||
$vars[$key] = trim($matches[2][$index]);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to generate 3-color preview based on standard variables
|
||||
$colors = [];
|
||||
if (isset($vars['--bg'])) $colors['bg'] = $vars['--bg'];
|
||||
if (isset($vars['--surface'])) $colors['surface'] = $vars['--surface'];
|
||||
if (isset($vars['--accent'])) $colors['accent'] = $vars['--accent'];
|
||||
|
||||
$model = new MarketplaceThemeModel();
|
||||
$model->insert([
|
||||
'id' => $this->uuid4(),
|
||||
'name' => $slug,
|
||||
'display_name' => $displayName,
|
||||
'description' => $description ?: 'Custom community theme.',
|
||||
'author' => 'Community',
|
||||
'version' => '1.0.0',
|
||||
'thumbnail_url' => null,
|
||||
'download_url' => '/themes/' . $filename,
|
||||
'price' => 0,
|
||||
'is_published' => true,
|
||||
'metadata' => json_encode([
|
||||
'tags' => ['custom', 'community'],
|
||||
'colors' => $colors,
|
||||
'vars' => $vars
|
||||
]),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
if ($this->request->isAJAX() || $this->request->hasHeader('Fetch')) {
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'message' => '"' . esc($displayName) . '" uploaded successfully!'
|
||||
]);
|
||||
}
|
||||
return redirect()->to('themes')->with('success', '"' . esc($displayName) . '" uploaded successfully!');
|
||||
}
|
||||
|
||||
public function preview(string $id): Response
|
||||
{
|
||||
$model = new MarketplaceThemeModel();
|
||||
$theme = $model->find($id);
|
||||
|
||||
if (! $theme) {
|
||||
return $this->response->setStatusCode(404)->setBody('<p style="font-family:sans-serif;padding:2rem">Theme not found.</p>');
|
||||
}
|
||||
|
||||
$distIndex = '/home/came/Nextcloud/arch-work/Projects/Todo-App/dist/index.html';
|
||||
|
||||
if (! file_exists($distIndex)) {
|
||||
return $this->response->setBody(
|
||||
'<html><body style="font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f0f17;color:#94a3b8">'
|
||||
. '<div style="text-align:center"><p>Todo app dist not found.</p></div></body></html>'
|
||||
);
|
||||
}
|
||||
|
||||
$todoHtml = file_get_contents($distIndex);
|
||||
|
||||
// Rewrite asset paths from /assets/ to the public symlink so Apache serves them
|
||||
$assetBase = rtrim(base_url('todo-preview'), '/');
|
||||
$todoHtml = str_replace('="/assets/', '="' . $assetBase . '/assets/', $todoHtml);
|
||||
|
||||
// Build CSS variable overrides from the stored vars map
|
||||
$meta = json_decode($theme['metadata'] ?? '{}', true);
|
||||
$vars = $meta['vars'] ?? [];
|
||||
|
||||
$cssVars = ":root {\n";
|
||||
foreach ($vars as $prop => $value) {
|
||||
$cssVars .= " {$prop}: {$value};\n";
|
||||
}
|
||||
$cssVars .= "}\n";
|
||||
|
||||
// Also inject any raw CSS from the downloaded file (for custom/uploaded themes)
|
||||
$cssPath = FCPATH . ltrim($theme['download_url'], '/');
|
||||
$rawCss = file_exists($cssPath) ? file_get_contents($cssPath) : '';
|
||||
|
||||
$styleTag = "<style>\n/* Theme Store: {$theme['display_name']} */\n{$cssVars}\n{$rawCss}\n</style>";
|
||||
|
||||
$todoHtml = str_replace('</head>', $styleTag . "\n</head>", $todoHtml);
|
||||
|
||||
return $this->response
|
||||
->setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
->setBody($todoHtml);
|
||||
}
|
||||
|
||||
public function install(string $id): Response
|
||||
{
|
||||
$model = new MarketplaceThemeModel();
|
||||
$theme = $model->find($id);
|
||||
|
||||
if (! $theme) {
|
||||
return $this->response->setStatusCode(404)->setJSON(['error' => 'Theme not found in the marketplace.']);
|
||||
}
|
||||
|
||||
// Using session user_id or a default placeholder since standard auth might be configured separately
|
||||
$userId = session()->get('user_id') ?? 'default-user-id';
|
||||
|
||||
$userThemeModel = new UserThemeModel();
|
||||
|
||||
if (! $userThemeModel->isInstalled($userId, $id)) {
|
||||
$userThemeModel->installTheme($userId, $id);
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'success' => true,
|
||||
'message' => '"' . esc($theme['display_name']) . '" has been installed to your account.'
|
||||
]);
|
||||
}
|
||||
|
||||
public function activate(string $id): Response
|
||||
{
|
||||
$userId = session()->get('user_id') ?? 'default-user-id';
|
||||
|
||||
$userThemeModel = new UserThemeModel();
|
||||
|
||||
if (! $userThemeModel->isInstalled($userId, $id)) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['error' => 'Theme must be installed before it can be activated.']);
|
||||
}
|
||||
|
||||
$userThemeModel->setActiveTheme($userId, $id);
|
||||
|
||||
return $this->response->setJSON(['success' => true, 'message' => 'Theme activated successfully.']);
|
||||
}
|
||||
|
||||
public function uninstall(string $id): Response
|
||||
{
|
||||
$userId = session()->get('user_id') ?? 'default-user-id';
|
||||
$userThemeModel = new UserThemeModel();
|
||||
|
||||
$userThemeModel->uninstallTheme($userId, $id);
|
||||
|
||||
return $this->response->setJSON(['success' => true, 'message' => 'Theme successfully uninstalled.']);
|
||||
}
|
||||
|
||||
public function myThemes(): Response
|
||||
{
|
||||
$userId = session()->get('user_id') ?? 'default-user-id';
|
||||
$userThemeModel = new UserThemeModel();
|
||||
|
||||
return $this->response->setJSON(['success' => true, 'data' => $userThemeModel->getUserThemes($userId)]);
|
||||
}
|
||||
|
||||
public function serveCss(string $filename): Response
|
||||
{
|
||||
// Ensure it's just a file name (prevent directory traversal)
|
||||
$filename = basename($filename);
|
||||
$cssPath = FCPATH . 'themes/' . $filename;
|
||||
|
||||
// If the file actually exists on disk (e.g. newly uploaded themes)
|
||||
if (file_exists($cssPath)) {
|
||||
$css = file_get_contents($cssPath);
|
||||
return $this->response->setContentType('text/css')->setBody($css);
|
||||
}
|
||||
|
||||
// Generate dynamically for seeded themes that don't have a physical file
|
||||
$model = new MarketplaceThemeModel();
|
||||
$name = preg_replace('/\.css$/i', '', $filename);
|
||||
$theme = $model->where('name', $name)->first();
|
||||
|
||||
if (! $theme) {
|
||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
$meta = json_decode($theme['metadata'] ?? '{}', true);
|
||||
$vars = $meta['vars'] ?? [];
|
||||
|
||||
$css = "/* Theme: {$theme['display_name']} */\n:root {\n";
|
||||
foreach ($vars as $prop => $value) {
|
||||
$css .= " {$prop}: {$value};\n";
|
||||
}
|
||||
$css .= "}\n";
|
||||
|
||||
return $this->response->setContentType('text/css')->setBody($css);
|
||||
}
|
||||
|
||||
private function uuid4(): string
|
||||
{
|
||||
$data = random_bytes(16);
|
||||
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
|
||||
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||
}
|
||||
}
|
||||
@@ -8,34 +8,296 @@ class MarketplaceThemesSeeder extends Seeder
|
||||
{
|
||||
public function run()
|
||||
{
|
||||
$this->db->query('SET FOREIGN_KEY_CHECKS=0');
|
||||
$this->db->table('marketplace_themes')->truncate();
|
||||
$this->db->query('SET FOREIGN_KEY_CHECKS=1');
|
||||
|
||||
$data = [
|
||||
[
|
||||
'id' => '550e8400-e29b-41d4-a716-446655440010',
|
||||
'name' => 'default-light',
|
||||
'display_name' => 'Default Light',
|
||||
'description' => 'Clean and simple light theme',
|
||||
'author' => 'System',
|
||||
'version' => '1.0.0',
|
||||
'id' => '550e8400-e29b-41d4-a716-446655440010',
|
||||
'name' => 'ocean-breeze',
|
||||
'display_name' => 'Ocean Breeze',
|
||||
'description' => 'A refreshing light theme inspired by the open sea. Soft teals and ocean blues create a calm, productive workspace that\'s easy on the eyes during long work sessions.',
|
||||
'author' => 'ThemeForge',
|
||||
'version' => '1.2.0',
|
||||
'thumbnail_url' => null,
|
||||
'download_url' => '/themes/default-light.zip',
|
||||
'price' => 0,
|
||||
'download_url' => '/themes/ocean-breeze.css',
|
||||
'price' => 0,
|
||||
'is_published' => true,
|
||||
'metadata' => json_encode(['tags' => ['light', 'clean']]),
|
||||
'metadata' => json_encode([
|
||||
'tags' => ['light', 'blue', 'calm', 'minimal'],
|
||||
'colors' => [
|
||||
'Primary' => '#0077B6',
|
||||
'Secondary' => '#00B4D8',
|
||||
'Background' => '#E0F4FF',
|
||||
'Surface' => '#FFFFFF',
|
||||
'Text' => '#1A2B3C',
|
||||
'Accent' => '#48CAE4',
|
||||
],
|
||||
'vars' => [
|
||||
'--bg' => '#E0F4FF',
|
||||
'--surface' => '#FFFFFF',
|
||||
'--surface-strong' => '#FFFFFF',
|
||||
'--surface-muted' => '#F0F9FF',
|
||||
'--border' => '#BAE0F2',
|
||||
'--line' => '#90C8E0',
|
||||
'--text' => '#1A2B3C',
|
||||
'--text-muted' => '#4A6B7A',
|
||||
'--text-strong' => '#0D1B26',
|
||||
'--accent' => '#0077B6',
|
||||
'--accent-text' => '#FFFFFF',
|
||||
'--accent-soft' => '#CCE9F5',
|
||||
'--sidebar-bg' => '#FFFFFF',
|
||||
'--sidebar-border' => '#BAE0F2',
|
||||
'--sidebar-text' => '#1A2B3C',
|
||||
'--sidebar-text-muted' => '#4A6B7A',
|
||||
'--input-bg' => '#FFFFFF',
|
||||
'--input-border' => '#BAE0F2',
|
||||
'--modal-bg' => '#FFFFFF',
|
||||
'--chip' => '#C8E8F0',
|
||||
'--success' => '#D4F0E4',
|
||||
],
|
||||
]),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
[
|
||||
'id' => '550e8400-e29b-41d4-a716-446655440011',
|
||||
'name' => 'default-dark',
|
||||
'display_name' => 'Default Dark',
|
||||
'description' => 'Dark theme for night owls',
|
||||
'author' => 'System',
|
||||
'version' => '1.0.0',
|
||||
'id' => '550e8400-e29b-41d4-a716-446655440011',
|
||||
'name' => 'midnight-void',
|
||||
'display_name' => 'Midnight Void',
|
||||
'description' => 'Deep space dark theme for night owls and late-night coders. Rich dark purples and blues with vibrant neon accents give this theme a premium, modern feel.',
|
||||
'author' => 'ThemeForge',
|
||||
'version' => '2.0.1',
|
||||
'thumbnail_url' => null,
|
||||
'download_url' => '/themes/default-dark.zip',
|
||||
'price' => 0,
|
||||
'download_url' => '/themes/midnight-void.css',
|
||||
'price' => 0,
|
||||
'is_published' => true,
|
||||
'metadata' => json_encode(['tags' => ['dark', 'night']]),
|
||||
'metadata' => json_encode([
|
||||
'tags' => ['dark', 'purple', 'neon', 'night'],
|
||||
'colors' => [
|
||||
'Primary' => '#7C3AED',
|
||||
'Secondary' => '#A78BFA',
|
||||
'Background' => '#0D0D1A',
|
||||
'Surface' => '#1A1A2E',
|
||||
'Text' => '#E2E8F0',
|
||||
'Accent' => '#F472B6',
|
||||
],
|
||||
'vars' => [
|
||||
'--bg' => '#0D0D1A',
|
||||
'--surface' => '#1A1A2E',
|
||||
'--surface-strong' => '#222234',
|
||||
'--surface-muted' => '#121220',
|
||||
'--border' => '#2A2A44',
|
||||
'--line' => '#333350',
|
||||
'--text' => '#E2E8F0',
|
||||
'--text-muted' => '#94A3B8',
|
||||
'--text-strong' => '#F1F5F9',
|
||||
'--accent' => '#7C3AED',
|
||||
'--accent-text' => '#FFFFFF',
|
||||
'--accent-soft' => '#2D1A5E',
|
||||
'--sidebar-bg' => '#16162A',
|
||||
'--sidebar-border' => '#2A2A44',
|
||||
'--sidebar-text' => '#E2E8F0',
|
||||
'--sidebar-text-muted' => '#94A3B8',
|
||||
'--input-bg' => '#0D0D1A',
|
||||
'--input-border' => '#2A2A44',
|
||||
'--modal-bg' => '#1A1A2E',
|
||||
'--chip' => '#2A2A44',
|
||||
'--success' => '#0D2A1A',
|
||||
],
|
||||
]),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
[
|
||||
'id' => '550e8400-e29b-41d4-a716-446655440012',
|
||||
'name' => 'forest-grove',
|
||||
'display_name' => 'Forest Grove',
|
||||
'description' => 'Earthy greens and warm neutrals bring the tranquility of a woodland retreat to your workspace. A grounding, nature-inspired theme designed for focused productivity.',
|
||||
'author' => 'NaturePalette',
|
||||
'version' => '1.0.5',
|
||||
'thumbnail_url' => null,
|
||||
'download_url' => '/themes/forest-grove.css',
|
||||
'price' => 0,
|
||||
'is_published' => true,
|
||||
'metadata' => json_encode([
|
||||
'tags' => ['light', 'green', 'earthy', 'nature'],
|
||||
'colors' => [
|
||||
'Primary' => '#2D6A4F',
|
||||
'Secondary' => '#52B788',
|
||||
'Background' => '#F0F7EE',
|
||||
'Surface' => '#FFFFFF',
|
||||
'Text' => '#1B2E22',
|
||||
'Accent' => '#B7E4C7',
|
||||
],
|
||||
'vars' => [
|
||||
'--bg' => '#F0F7EE',
|
||||
'--surface' => '#FFFFFF',
|
||||
'--surface-strong' => '#FFFFFF',
|
||||
'--surface-muted' => '#F5FAF4',
|
||||
'--border' => '#C0DACB',
|
||||
'--line' => '#A0C4B0',
|
||||
'--text' => '#1B2E22',
|
||||
'--text-muted' => '#527A62',
|
||||
'--text-strong' => '#0D1F14',
|
||||
'--accent' => '#2D6A4F',
|
||||
'--accent-text' => '#FFFFFF',
|
||||
'--accent-soft' => '#C0E8D4',
|
||||
'--sidebar-bg' => '#FFFFFF',
|
||||
'--sidebar-border' => '#C0DACB',
|
||||
'--sidebar-text' => '#1B2E22',
|
||||
'--sidebar-text-muted' => '#527A62',
|
||||
'--input-bg' => '#FFFFFF',
|
||||
'--input-border' => '#C0DACB',
|
||||
'--modal-bg' => '#FFFFFF',
|
||||
'--chip' => '#B8E0C8',
|
||||
'--success' => '#CCF0DC',
|
||||
],
|
||||
]),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
[
|
||||
'id' => '550e8400-e29b-41d4-a716-446655440013',
|
||||
'name' => 'sunset-ember',
|
||||
'display_name' => 'Sunset Ember',
|
||||
'description' => 'Warm oranges, deep reds, and golden highlights capture the magic of a perfect sunset. This vibrant theme adds energy and warmth to every interaction.',
|
||||
'author' => 'ChromaCraft',
|
||||
'version' => '1.1.2',
|
||||
'thumbnail_url' => null,
|
||||
'download_url' => '/themes/sunset-ember.css',
|
||||
'price' => 0,
|
||||
'is_published' => true,
|
||||
'metadata' => json_encode([
|
||||
'tags' => ['warm', 'orange', 'vibrant', 'sunset'],
|
||||
'colors' => [
|
||||
'Primary' => '#D62828',
|
||||
'Secondary' => '#F77F00',
|
||||
'Background' => '#FFF5E4',
|
||||
'Surface' => '#FFFFFF',
|
||||
'Text' => '#2D1B00',
|
||||
'Accent' => '#FCBF49',
|
||||
],
|
||||
'vars' => [
|
||||
'--bg' => '#FFF5E4',
|
||||
'--surface' => '#FFFFFF',
|
||||
'--surface-strong' => '#FFFFFF',
|
||||
'--surface-muted' => '#FFF8F0',
|
||||
'--border' => '#F0D0A8',
|
||||
'--line' => '#E0B880',
|
||||
'--text' => '#2D1B00',
|
||||
'--text-muted' => '#8A6040',
|
||||
'--text-strong' => '#1A0A00',
|
||||
'--accent' => '#D62828',
|
||||
'--accent-text' => '#FFFFFF',
|
||||
'--accent-soft' => '#FFE0CC',
|
||||
'--sidebar-bg' => '#FFFFFF',
|
||||
'--sidebar-border' => '#F0D0A8',
|
||||
'--sidebar-text' => '#2D1B00',
|
||||
'--sidebar-text-muted' => '#8A6040',
|
||||
'--input-bg' => '#FFFFFF',
|
||||
'--input-border' => '#F0D0A8',
|
||||
'--modal-bg' => '#FFFFFF',
|
||||
'--chip' => '#F8D8B0',
|
||||
'--success' => '#DDFADC',
|
||||
],
|
||||
]),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
[
|
||||
'id' => '550e8400-e29b-41d4-a716-446655440014',
|
||||
'name' => 'arctic-frost',
|
||||
'display_name' => 'Arctic Frost',
|
||||
'description' => 'Ultra-clean whites and icy blues inspired by frozen tundras. A minimalist theme that maximises clarity and focus with crisp contrast and breathable spacing.',
|
||||
'author' => 'MinimalStudio',
|
||||
'version' => '3.0.0',
|
||||
'thumbnail_url' => null,
|
||||
'download_url' => '/themes/arctic-frost.css',
|
||||
'price' => 0,
|
||||
'is_published' => true,
|
||||
'metadata' => json_encode([
|
||||
'tags' => ['light', 'minimal', 'clean', 'ice'],
|
||||
'colors' => [
|
||||
'Primary' => '#2176AE',
|
||||
'Secondary' => '#57C4E5',
|
||||
'Background' => '#F8FBFF',
|
||||
'Surface' => '#FFFFFF',
|
||||
'Text' => '#1C2B3A',
|
||||
'Accent' => '#A8DADC',
|
||||
],
|
||||
'vars' => [
|
||||
'--bg' => '#F8FBFF',
|
||||
'--surface' => '#FFFFFF',
|
||||
'--surface-strong' => '#FFFFFF',
|
||||
'--surface-muted' => '#F0F5FC',
|
||||
'--border' => '#C0D4E8',
|
||||
'--line' => '#A0BCDA',
|
||||
'--text' => '#1C2B3A',
|
||||
'--text-muted' => '#4E6478',
|
||||
'--text-strong' => '#0D1B2A',
|
||||
'--accent' => '#2176AE',
|
||||
'--accent-text' => '#FFFFFF',
|
||||
'--accent-soft' => '#CCE0F0',
|
||||
'--sidebar-bg' => '#FFFFFF',
|
||||
'--sidebar-border' => '#C0D4E8',
|
||||
'--sidebar-text' => '#1C2B3A',
|
||||
'--sidebar-text-muted' => '#4E6478',
|
||||
'--input-bg' => '#FFFFFF',
|
||||
'--input-border' => '#C0D4E8',
|
||||
'--modal-bg' => '#FFFFFF',
|
||||
'--chip' => '#B8D4E8',
|
||||
'--success' => '#D4F0E4',
|
||||
],
|
||||
]),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
[
|
||||
'id' => '550e8400-e29b-41d4-a716-446655440015',
|
||||
'name' => 'obsidian-rose',
|
||||
'display_name' => 'Obsidian Rose',
|
||||
'description' => 'A sophisticated dark theme blending deep charcoal blacks with rose gold accents. Elegant and bold, this theme is built for those who want style without sacrificing readability.',
|
||||
'author' => 'ChromaCraft',
|
||||
'version' => '1.3.0',
|
||||
'thumbnail_url' => null,
|
||||
'download_url' => '/themes/obsidian-rose.css',
|
||||
'price' => 0,
|
||||
'is_published' => true,
|
||||
'metadata' => json_encode([
|
||||
'tags' => ['dark', 'elegant', 'rose', 'premium'],
|
||||
'colors' => [
|
||||
'Primary' => '#C9184A',
|
||||
'Secondary' => '#FF4D6D',
|
||||
'Background' => '#0A0A0F',
|
||||
'Surface' => '#1C1C28',
|
||||
'Text' => '#F1E3E4',
|
||||
'Accent' => '#B5838D',
|
||||
],
|
||||
'vars' => [
|
||||
'--bg' => '#0A0A0F',
|
||||
'--surface' => '#1C1C28',
|
||||
'--surface-strong' => '#242430',
|
||||
'--surface-muted' => '#14141E',
|
||||
'--border' => '#2A2A38',
|
||||
'--line' => '#383848',
|
||||
'--text' => '#F1E3E4',
|
||||
'--text-muted' => '#B5939A',
|
||||
'--text-strong' => '#FAF0F1',
|
||||
'--accent' => '#C9184A',
|
||||
'--accent-text' => '#FFFFFF',
|
||||
'--accent-soft' => '#3D0A1A',
|
||||
'--sidebar-bg' => '#161620',
|
||||
'--sidebar-border' => '#2A2A38',
|
||||
'--sidebar-text' => '#F1E3E4',
|
||||
'--sidebar-text-muted' => '#B5939A',
|
||||
'--input-bg' => '#0A0A0F',
|
||||
'--input-border' => '#2A2A38',
|
||||
'--modal-bg' => '#1C1C28',
|
||||
'--chip' => '#2A2030',
|
||||
'--success' => '#0A2016',
|
||||
],
|
||||
]),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
],
|
||||
|
||||
861
app/Views/theme_store.php
Normal file
861
app/Views/theme_store.php
Normal file
@@ -0,0 +1,861 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Theme Store</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0f0f17;
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
header {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
padding: 60px 24px 50px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at 50% 0%, rgba(124,58,237,0.15) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
header h1 {
|
||||
font-size: clamp(2rem, 5vw, 3.2rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
background: linear-gradient(135deg, #a78bfa, #60a5fa, #f472b6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
position: relative;
|
||||
}
|
||||
header p {
|
||||
margin-top: 12px;
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
position: relative;
|
||||
}
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
margin-top: 20px;
|
||||
position: relative;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.header-badge {
|
||||
background: rgba(124,58,237,0.2);
|
||||
border: 1px solid rgba(124,58,237,0.4);
|
||||
color: #a78bfa;
|
||||
padding: 4px 14px;
|
||||
border-radius: 100px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.btn-upload-header {
|
||||
background: linear-gradient(135deg, #7c3aed, #6d28d9);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
border-radius: 100px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.btn-upload-header:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||
|
||||
/* ── Flash messages ── */
|
||||
.flash {
|
||||
max-width: 680px;
|
||||
margin: 24px auto 0;
|
||||
padding: 12px 18px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.flash-success { background: rgba(52,211,153,0.12); border: 1px solid rgba(52,211,153,0.3); color: #34d399; }
|
||||
.flash-error { background: rgba(248,113,113,0.12); border: 1px solid rgba(248,113,113,0.3); color: #f87171; }
|
||||
|
||||
/* ── Grid ── */
|
||||
.grid-wrapper {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 24px 80px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
.card {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 48px rgba(0,0,0,0.5);
|
||||
border-color: rgba(167,139,250,0.3);
|
||||
}
|
||||
.card-preview {
|
||||
height: 90px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-preview .swatch { flex: 1; transition: flex 0.3s ease; }
|
||||
.card:hover .card-preview .swatch { flex: 1.4; }
|
||||
|
||||
.card-body { padding: 22px; }
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-name { font-size: 1.15rem; font-weight: 700; color: #f1f5f9; }
|
||||
.card-version {
|
||||
font-size: 0.75rem; color: #64748b;
|
||||
background: rgba(255,255,255,0.05);
|
||||
padding: 2px 8px; border-radius: 100px;
|
||||
}
|
||||
.card-author { font-size: 0.8rem; color: #94a3b8; margin-bottom: 10px; }
|
||||
.card-desc {
|
||||
font-size: 0.88rem; color: #94a3b8; line-height: 1.55;
|
||||
margin-bottom: 14px;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.card-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; }
|
||||
.tag {
|
||||
background: rgba(167,139,250,0.1);
|
||||
border: 1px solid rgba(167,139,250,0.25);
|
||||
color: #a78bfa;
|
||||
font-size: 0.72rem; padding: 2px 9px; border-radius: 100px; font-weight: 500;
|
||||
}
|
||||
.card-actions { display: flex; gap: 10px; }
|
||||
.btn {
|
||||
flex: 1; padding: 10px 0; border-radius: 8px; border: none;
|
||||
cursor: pointer; font-size: 0.85rem; font-weight: 600;
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.btn:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||
.btn-download {
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||
color: #fff; text-decoration: none;
|
||||
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
}
|
||||
.btn-details {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.btn-details:hover { background: rgba(255,255,255,0.1); }
|
||||
|
||||
/* ── Backdrop shared ── */
|
||||
.modal-backdrop {
|
||||
display: none;
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.8);
|
||||
backdrop-filter: blur(6px);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.modal-backdrop.open { display: flex; }
|
||||
|
||||
/* ── Details Modal ── */
|
||||
.details-modal {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 20px;
|
||||
width: 95vw;
|
||||
max-width: 1100px;
|
||||
height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: popIn 0.22s cubic-bezier(0.34,1.56,0.64,1);
|
||||
overflow: hidden;
|
||||
}
|
||||
@keyframes popIn {
|
||||
from { opacity: 0; transform: scale(0.92) translateY(20px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
/* colour stripe at top */
|
||||
.modal-preview-stripe {
|
||||
height: 80px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modal-preview-stripe .swatch { flex: 1; }
|
||||
|
||||
/* tab bar */
|
||||
.modal-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
background: rgba(255,255,255,0.02);
|
||||
flex-shrink: 0;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.modal-tab {
|
||||
padding: 12px 20px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
background: none;
|
||||
border-top: none; border-left: none; border-right: none;
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.modal-tab:hover { color: #94a3b8; }
|
||||
.modal-tab.active { color: #a78bfa; border-bottom-color: #7c3aed; }
|
||||
|
||||
/* tab panels */
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.tab-panel {
|
||||
display: none;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
/* details panel */
|
||||
.details-panel { padding: 28px; }
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.modal-title { font-size: 1.4rem; font-weight: 800; color: #f1f5f9; }
|
||||
.modal-close {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: #94a3b8;
|
||||
border-radius: 8px; width: 34px; height: 34px;
|
||||
cursor: pointer; font-size: 1.1rem;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s; flex-shrink: 0;
|
||||
}
|
||||
.modal-close:hover { background: rgba(255,255,255,0.12); color: #fff; }
|
||||
.modal-meta { font-size: 0.82rem; color: #64748b; margin-bottom: 16px; }
|
||||
.modal-desc { font-size: 0.92rem; color: #94a3b8; line-height: 1.65; margin-bottom: 22px; }
|
||||
.section-label {
|
||||
font-size: 0.75rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
color: #64748b; margin-bottom: 10px;
|
||||
}
|
||||
.colors-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.color-chip { border-radius: 10px; overflow: hidden; border: 1px solid rgba(255,255,255,0.07); }
|
||||
.color-chip-swatch { height: 48px; }
|
||||
.color-chip-info { padding: 6px 8px; background: rgba(255,255,255,0.03); }
|
||||
.color-chip-name { font-size: 0.7rem; color: #94a3b8; display: block; }
|
||||
.color-chip-hex { font-size: 0.75rem; font-weight: 600; color: #e2e8f0; font-family: 'SF Mono', monospace; }
|
||||
.modal-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 24px; }
|
||||
.modal-actions { display: flex; gap: 10px; }
|
||||
.btn-download-lg {
|
||||
flex: 1; padding: 13px; font-size: 0.95rem; border-radius: 10px;
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||
color: #fff; font-weight: 700; text-decoration: none;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
border: none; cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.btn-download-lg:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||
|
||||
/* preview panel */
|
||||
.preview-panel {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
.preview-panel.active { display: flex; }
|
||||
.preview-toolbar {
|
||||
padding: 10px 16px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.preview-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.preview-url {
|
||||
flex: 1;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 6px;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.78rem;
|
||||
color: #64748b;
|
||||
font-family: monospace;
|
||||
}
|
||||
.preview-iframe-wrap {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.preview-iframe-wrap iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
.preview-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0f0f17;
|
||||
color: #94a3b8;
|
||||
gap: 14px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.spinner {
|
||||
width: 32px; height: 32px;
|
||||
border: 3px solid rgba(167,139,250,0.2);
|
||||
border-top-color: #7c3aed;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Upload Modal ── */
|
||||
.upload-modal {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 20px;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
animation: popIn 0.22s cubic-bezier(0.34,1.56,0.64,1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.upload-header {
|
||||
padding: 24px 28px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.upload-title { font-size: 1.25rem; font-weight: 700; color: #f1f5f9; }
|
||||
.upload-body { padding: 0 28px 28px; }
|
||||
.field { margin-bottom: 18px; }
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.field input[type="text"],
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.field input[type="text"]:focus,
|
||||
.field textarea:focus {
|
||||
border-color: rgba(124,58,237,0.5);
|
||||
}
|
||||
.field textarea { resize: vertical; min-height: 80px; }
|
||||
.file-drop {
|
||||
border: 2px dashed rgba(124,58,237,0.3);
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
position: relative;
|
||||
color: #64748b;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.file-drop:hover,
|
||||
.file-drop.drag-over {
|
||||
border-color: rgba(124,58,237,0.7);
|
||||
background: rgba(124,58,237,0.05);
|
||||
}
|
||||
.file-drop input[type="file"] {
|
||||
position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%;
|
||||
}
|
||||
.file-drop-icon { font-size: 1.8rem; margin-bottom: 8px; display: block; }
|
||||
.file-drop strong { color: #a78bfa; }
|
||||
.file-name-preview {
|
||||
margin-top: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: #34d399;
|
||||
display: none;
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
background: linear-gradient(135deg, #7c3aed, #6d28d9);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.btn-submit:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||
|
||||
/* ── Footer ── */
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 32px 24px;
|
||||
color: #334155;
|
||||
font-size: 0.82rem;
|
||||
border-top: 1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(167,139,250,0.3); border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>Theme Store</h1>
|
||||
<p>Beautiful, ready-to-use themes for your application</p>
|
||||
<div class="header-row">
|
||||
<span class="header-badge"><?= count($themes) ?> free themes</span>
|
||||
<button class="btn-upload-header" onclick="openUploadModal()">
|
||||
<span>+</span> Upload Theme
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<?php if ($flash_success): ?>
|
||||
<div style="max-width:1200px;margin:0 auto;padding:0 24px">
|
||||
<div class="flash flash-success">✓ <?= esc($flash_success) ?></div>
|
||||
</div>
|
||||
<?php elseif ($flash_error): ?>
|
||||
<div style="max-width:1200px;margin:0 auto;padding:0 24px">
|
||||
<div class="flash flash-error">⚠ <?= esc($flash_error) ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="grid-wrapper">
|
||||
<div class="grid">
|
||||
<?php foreach ($themes as $theme): ?>
|
||||
<div class="card">
|
||||
<div class="card-preview">
|
||||
<?php foreach (array_values($theme['colors']) as $hex): ?>
|
||||
<div class="swatch" style="background:<?= esc($hex) ?>"></div>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($theme['colors'])): ?>
|
||||
<div class="swatch" style="background:linear-gradient(135deg,#7c3aed,#f472b6)"></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-meta">
|
||||
<span class="card-name"><?= esc($theme['display_name']) ?></span>
|
||||
<span class="card-version">v<?= esc($theme['version']) ?></span>
|
||||
</div>
|
||||
<div class="card-author">by <?= esc($theme['author']) ?></div>
|
||||
<p class="card-desc"><?= esc($theme['description']) ?></p>
|
||||
<div class="card-tags">
|
||||
<?php foreach ($theme['tags'] as $tag): ?>
|
||||
<span class="tag"><?= esc($tag) ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-download" onclick="installTheme(<?= htmlspecialchars(json_encode([
|
||||
'id' => $theme['id'],
|
||||
'display_name' => $theme['display_name'],
|
||||
'description' => $theme['description'],
|
||||
'author' => $theme['author'],
|
||||
'version' => $theme['version'],
|
||||
'download_url' => $theme['download_url'],
|
||||
'colors' => $theme['colors'],
|
||||
'tags' => $theme['tags'],
|
||||
'vars' => $theme['vars'],
|
||||
]), ENT_QUOTES, 'UTF-8') ?>)">
|
||||
⇓ Install Theme
|
||||
</button>
|
||||
<button class="btn btn-details"
|
||||
onclick="openDetailsModal(<?= htmlspecialchars(json_encode([
|
||||
'id' => $theme['id'],
|
||||
'display_name' => $theme['display_name'],
|
||||
'description' => $theme['description'],
|
||||
'author' => $theme['author'],
|
||||
'version' => $theme['version'],
|
||||
'download_url' => $theme['download_url'],
|
||||
'colors' => $theme['colors'],
|
||||
'tags' => $theme['tags'],
|
||||
'vars' => $theme['vars'],
|
||||
]), ENT_QUOTES, 'UTF-8') ?>)">
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Details Modal ── -->
|
||||
<div class="modal-backdrop" id="details-backdrop" onclick="closeDetailsOnBackdrop(event)">
|
||||
<div class="details-modal" id="details-modal">
|
||||
|
||||
<div class="modal-preview-stripe" id="dm-stripe"></div>
|
||||
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab active" id="tab-details" onclick="switchTab('details')">
|
||||
✎ Details
|
||||
</button>
|
||||
<button class="modal-tab" id="tab-preview" onclick="switchTab('preview')">
|
||||
▶ Live Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
<!-- Details panel -->
|
||||
<div class="tab-panel active" id="panel-details">
|
||||
<div class="details-panel">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="dm-title"></span>
|
||||
<button class="modal-close" onclick="closeDetailsModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-meta" id="dm-meta"></div>
|
||||
<p class="modal-desc" id="dm-desc"></p>
|
||||
|
||||
<div id="dm-colors-section">
|
||||
<div class="section-label">Colour Palette</div>
|
||||
<div class="colors-grid" id="dm-colors"></div>
|
||||
</div>
|
||||
|
||||
<div class="section-label" style="margin-bottom:10px">Tags</div>
|
||||
<div class="modal-tags" id="dm-tags"></div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn-download-lg" id="dm-download" onclick="installCurrentTheme()">
|
||||
⇓ Install Theme
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview panel -->
|
||||
<div class="tab-panel" id="panel-preview" style="height:100%">
|
||||
<div class="preview-panel" id="preview-panel-inner">
|
||||
<div class="preview-toolbar">
|
||||
<div class="preview-dot" style="background:#ff5f57"></div>
|
||||
<div class="preview-dot" style="background:#febc2e"></div>
|
||||
<div class="preview-dot" style="background:#28c840"></div>
|
||||
<div class="preview-url" id="preview-url">localhost:5173 — with theme applied</div>
|
||||
</div>
|
||||
<div class="preview-iframe-wrap">
|
||||
<div class="preview-loading" id="preview-loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading preview…</span>
|
||||
</div>
|
||||
<iframe id="preview-iframe" title="Theme Preview" onload="iframeLoaded()"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- .modal-body -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Upload Modal ── -->
|
||||
<div class="modal-backdrop" id="upload-backdrop" onclick="closeUploadOnBackdrop(event)">
|
||||
<div class="upload-modal">
|
||||
<div class="upload-header">
|
||||
<span class="upload-title">Upload Custom Theme</span>
|
||||
<button class="modal-close" onclick="closeUploadModal()">✕</button>
|
||||
</div>
|
||||
<div class="upload-body">
|
||||
<form method="post" action="<?= site_url('themes/upload') ?>" enctype="multipart/form-data">
|
||||
|
||||
<div class="field">
|
||||
<label>Display Name *</label>
|
||||
<input type="text" name="display_name" placeholder="e.g. Neon Sunset" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Description</label>
|
||||
<textarea name="description" placeholder="Describe your theme's mood and colours…"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>CSS File *</label>
|
||||
<div class="file-drop" id="file-drop">
|
||||
<input type="file" name="theme_css" accept=".css" required onchange="previewFileName(this)">
|
||||
<span class="file-drop-icon">💾</span>
|
||||
<div>Drop your <strong>.css</strong> file here<br>or <strong>click to browse</strong></div>
|
||||
<div class="file-name-preview" id="file-name-preview"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-submit">↑ Upload Theme</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
© <?= date('Y') ?> Theme Store — All themes are free to use
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
let currentThemeId = null;
|
||||
let currentThemeVars = {};
|
||||
let currentThemeData = null;
|
||||
let previewLoaded = false;
|
||||
|
||||
/* ── Theme Installation ── */
|
||||
function installTheme(theme) {
|
||||
// Convert theme data to match Todo-App format
|
||||
const themeData = {
|
||||
name: theme.display_name,
|
||||
description: theme.description,
|
||||
preview: Object.values(theme.colors || {}).length ? Object.values(theme.colors) : ['#ffffff', '#f0f0f0', '#007acc'],
|
||||
vars: theme.vars || {},
|
||||
source: 'theme-store'
|
||||
};
|
||||
|
||||
console.log('Installing theme:', themeData);
|
||||
|
||||
// Send install message to parent Todo-App
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({
|
||||
type: 'THEME_DOWNLOAD_REQUEST',
|
||||
data: themeData
|
||||
}, '*');
|
||||
|
||||
// Show success feedback
|
||||
showInstallFeedback(theme.display_name);
|
||||
} else {
|
||||
// Fallback: redirect to the frontend with the theme data
|
||||
const installUrl = "http://localhost:5173/#theme-install:" + encodeURIComponent(JSON.stringify(themeData));
|
||||
window.location.href = installUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function installCurrentTheme() {
|
||||
if (currentThemeData) {
|
||||
installTheme(currentThemeData);
|
||||
}
|
||||
}
|
||||
|
||||
function showInstallFeedback(themeName) {
|
||||
// Create a temporary success message
|
||||
const feedback = document.createElement('div');
|
||||
feedback.className = 'install-feedback';
|
||||
feedback.innerHTML = `✅ "${themeName}" is being installed...`;
|
||||
feedback.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3);
|
||||
animation: slideIn 0.3s ease-out;
|
||||
`;
|
||||
|
||||
document.body.appendChild(feedback);
|
||||
|
||||
// Remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
feedback.style.animation = 'slideOut 0.3s ease-out';
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(feedback);
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Add animations
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes slideOut {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
/* ── Details modal ── */
|
||||
function openDetailsModal(theme) {
|
||||
currentThemeId = theme.id;
|
||||
currentThemeVars = theme.vars || {};
|
||||
currentThemeData = theme; // Store full theme data for installation
|
||||
previewLoaded = false;
|
||||
|
||||
const colors = theme.colors || {};
|
||||
const tags = theme.tags || [];
|
||||
|
||||
document.getElementById('dm-title').textContent = theme.display_name;
|
||||
document.getElementById('dm-meta').textContent = 'by ' + theme.author + ' · v' + theme.version;
|
||||
document.getElementById('dm-desc').textContent = theme.description;
|
||||
document.getElementById('preview-url').textContent = 'localhost — ' + theme.display_name + ' applied';
|
||||
|
||||
// stripe
|
||||
const stripe = document.getElementById('dm-stripe');
|
||||
const colorValues = Object.values(colors);
|
||||
stripe.innerHTML = colorValues.length
|
||||
? colorValues.map(c => `<div class="swatch" style="background:${c}"></div>`).join('')
|
||||
: '<div class="swatch" style="background:linear-gradient(135deg,#7c3aed,#f472b6)"></div>';
|
||||
|
||||
// colour chips
|
||||
const colorsSection = document.getElementById('dm-colors-section');
|
||||
const grid = document.getElementById('dm-colors');
|
||||
if (Object.keys(colors).length) {
|
||||
colorsSection.style.display = '';
|
||||
grid.innerHTML = Object.entries(colors).map(([name, hex]) => `
|
||||
<div class="color-chip">
|
||||
<div class="color-chip-swatch" style="background:${hex}"></div>
|
||||
<div class="color-chip-info">
|
||||
<span class="color-chip-name">${name}</span>
|
||||
<span class="color-chip-hex">${hex}</span>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
} else {
|
||||
colorsSection.style.display = 'none';
|
||||
}
|
||||
|
||||
// tags
|
||||
document.getElementById('dm-tags').innerHTML =
|
||||
tags.length ? tags.map(t => `<span class="tag">${t}</span>`).join('') : '<span style="color:#475569;font-size:0.82rem">No tags</span>';
|
||||
|
||||
// reset to details tab
|
||||
switchTab('details');
|
||||
|
||||
document.getElementById('details-backdrop').classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeDetailsModal() {
|
||||
document.getElementById('details-backdrop').classList.remove('open');
|
||||
document.getElementById('preview-iframe').src = '';
|
||||
document.getElementById('preview-loading').style.display = 'flex';
|
||||
currentThemeVars = {};
|
||||
previewLoaded = false;
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function closeDetailsOnBackdrop(e) {
|
||||
if (e.target === document.getElementById('details-backdrop')) closeDetailsModal();
|
||||
}
|
||||
|
||||
/* ── Tabs ── */
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.modal-tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||
|
||||
document.getElementById('tab-' + name).classList.add('active');
|
||||
document.getElementById('panel-' + name).classList.add('active');
|
||||
|
||||
if (name === 'preview') {
|
||||
document.getElementById('preview-panel-inner').classList.add('active');
|
||||
if (!previewLoaded && currentThemeId) {
|
||||
document.getElementById('preview-loading').style.display = 'flex';
|
||||
const themeData = {
|
||||
name: currentThemeData.display_name,
|
||||
vars: currentThemeVars
|
||||
};
|
||||
const encoded = btoa(JSON.stringify(themeData));
|
||||
document.getElementById('preview-iframe').src = 'http://localhost:5173/?__theme_preview=' + encoded + '&__preview_mode=true';
|
||||
}
|
||||
} else {
|
||||
document.getElementById('preview-panel-inner').classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function iframeLoaded() {
|
||||
previewLoaded = true;
|
||||
document.getElementById('preview-loading').style.display = 'none';
|
||||
}
|
||||
|
||||
/* ── Upload modal ── */
|
||||
function openUploadModal() {
|
||||
document.getElementById('upload-backdrop').classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
function closeUploadModal() {
|
||||
document.getElementById('upload-backdrop').classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
function closeUploadOnBackdrop(e) {
|
||||
if (e.target === document.getElementById('upload-backdrop')) closeUploadModal();
|
||||
}
|
||||
|
||||
function previewFileName(input) {
|
||||
const el = document.getElementById('file-name-preview');
|
||||
if (input.files && input.files[0]) {
|
||||
el.textContent = '✓ ' + input.files[0].name;
|
||||
el.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Drag-over styling
|
||||
const drop = document.getElementById('file-drop');
|
||||
drop.addEventListener('dragover', () => drop.classList.add('drag-over'));
|
||||
drop.addEventListener('dragleave', () => drop.classList.remove('drag-over'));
|
||||
drop.addEventListener('drop', () => drop.classList.remove('drag-over'));
|
||||
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeDetailsModal(); closeUploadModal(); } });
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user