Add marketplace

This commit is contained in:
Cametendo
2026-05-06 14:17:25 +02:00
parent deba81fadb
commit af21317040
20 changed files with 1286 additions and 25 deletions

2
.gitignore vendored
View File

@@ -126,3 +126,5 @@ _modules/*
/phpunit*.xml
.env
env
.claude/
.claude/*

View File

@@ -6,3 +6,9 @@ use CodeIgniter\Router\RouteCollection;
* @var RouteCollection $routes
*/
$routes->get('/', 'Home::index');
<<<<<<< Updated upstream
=======
$routes->get('/themes', 'ThemeStore::index');
$routes->post('/themes/upload', 'ThemeStore::upload');
$routes->get('/themes/preview/(:segment)', 'ThemeStore::preview/$1');
>>>>>>> Stashed changes

View File

@@ -0,0 +1,127 @@
<?php
namespace App\Controllers;
use App\Models\MarketplaceThemeModel;
use CodeIgniter\HTTP\Response;
class ThemeStore extends BaseController
{
public function index(): string
{
$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'] ?? [];
}
return view('theme_store', [
'themes' => $themes,
'flash_success' => session()->getFlashdata('success'),
'flash_error' => session()->getFlashdata('error'),
]);
}
public function upload(): Response
{
$file = $this->request->getFile('theme_css');
$displayName = trim($this->request->getPost('display_name') ?? '');
$description = trim($this->request->getPost('description') ?? '');
if ($displayName === '') {
return redirect()->to('/themes')->with('error', 'Display name is required.');
}
if (! $file || ! $file->isValid() || $file->hasMoved()) {
return redirect()->to('/themes')->with('error', 'Please upload a valid CSS file.');
}
if (strtolower($file->getExtension()) !== 'css') {
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);
$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' => []]),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
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);
}
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));
}
}

View File

@@ -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'),
],

767
app/Views/theme_store.php Normal file
View File

@@ -0,0 +1,767 @@
<!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, #7c3aed, #6d28d9);
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, #7c3aed, #6d28d9);
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>&#x2B;</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">&#10003; <?= 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">&#9888; <?= 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">
<a class="btn btn-download" href="<?= esc($theme['download_url']) ?>" download>
&#8659; Download
</a>
<button class="btn btn-details"
onclick='openDetailsModal(<?= 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"],
]) ?>)'>
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')">
&#9998; Details
</button>
<button class="modal-tab" id="tab-preview" onclick="switchTab('preview')">
&#9654; 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()">&#x2715;</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">
<a class="btn-download-lg" id="dm-download" href="#" download>
&#8659; Download Theme
</a>
</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&hellip;</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()">&#x2715;</button>
</div>
<div class="upload-body">
<form method="post" action="/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">&#128190;</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">&#x2191; Upload Theme</button>
</form>
</div>
</div>
</div>
<footer>
&copy; <?= date('Y') ?> Theme Store &mdash; All themes are free to use
</footer>
<script>
let currentThemeId = null;
let currentThemeVars = {};
let previewLoaded = false;
/* ── Details modal ── */
function openDetailsModal(theme) {
currentThemeId = theme.id;
currentThemeVars = theme.vars || {};
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('dm-download').href = theme.download_url;
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 encoded = btoa(JSON.stringify(currentThemeVars));
document.getElementById('preview-iframe').src = 'http://localhost:5173/?__theme=' + encoded;
}
} 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>

View File

@@ -30,13 +30,13 @@
# DATABASE
#--------------------------------------------------------------------
# database.default.hostname = localhost
# database.default.database = ci4
# database.default.username = root
# database.default.password = root
# database.default.DBDriver = MySQLi
database.default.hostname = localhost
database.default.database = ci4
database.default.username = root
database.default.password = root
database.default.DBDriver = MySQLi
# database.default.DBPrefix =
# database.default.port = 3306
database.default.port = 3306
# If you use MySQLi as tests, first update the values of Config\Database::$tests.
# database.tests.hostname = localhost

View File

@@ -0,0 +1,16 @@
/* Arctic Frost Theme — MinimalStudio v3.0.0 */
:root {
--color-primary: #2176AE;
--color-secondary: #57C4E5;
--color-background: #F8FBFF;
--color-surface: #FFFFFF;
--color-text: #1C2B3A;
--color-accent: #A8DADC;
}
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
a, .link { color: var(--color-primary); }
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
.btn-primary:hover { background: var(--color-secondary); }
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 1px 6px rgba(33,118,174,0.08); padding: 20px; border: 1px solid #DDE8F0; }
.tag { background: var(--color-accent); color: var(--color-text); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }

View File

@@ -0,0 +1,16 @@
/* Forest Grove Theme — NaturePalette v1.0.5 */
:root {
--color-primary: #2D6A4F;
--color-secondary: #52B788;
--color-background: #F0F7EE;
--color-surface: #FFFFFF;
--color-text: #1B2E22;
--color-accent: #B7E4C7;
}
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
a, .link { color: var(--color-primary); }
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
.btn-primary:hover { background: var(--color-secondary); }
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 8px rgba(45,106,79,0.12); padding: 20px; }
.tag { background: var(--color-accent); color: var(--color-text); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }

View File

@@ -0,0 +1,16 @@
/* Midnight Void Theme — ThemeForge v2.0.1 */
:root {
--color-primary: #7C3AED;
--color-secondary: #A78BFA;
--color-background: #0D0D1A;
--color-surface: #1A1A2E;
--color-text: #E2E8F0;
--color-accent: #F472B6;
}
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
a, .link { color: var(--color-secondary); }
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
.btn-primary:hover { background: var(--color-accent); }
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 16px rgba(124,58,237,0.25); padding: 20px; }
.tag { background: var(--color-accent); color: #0D0D1A; border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }

View File

@@ -0,0 +1,16 @@
/* Obsidian Rose Theme — ChromaCraft v1.3.0 */
:root {
--color-primary: #C9184A;
--color-secondary: #FF4D6D;
--color-background: #0A0A0F;
--color-surface: #1C1C28;
--color-text: #F1E3E4;
--color-accent: #B5838D;
}
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
a, .link { color: var(--color-secondary); }
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
.btn-primary:hover { background: var(--color-secondary); }
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 16px rgba(201,24,74,0.2); padding: 20px; }
.tag { background: var(--color-accent); color: #0A0A0F; border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }

View File

@@ -0,0 +1,16 @@
/* Ocean Breeze Theme — ThemeForge v1.2.0 */
:root {
--color-primary: #0077B6;
--color-secondary: #00B4D8;
--color-background: #E0F4FF;
--color-surface: #FFFFFF;
--color-text: #1A2B3C;
--color-accent: #48CAE4;
}
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
a, .link { color: var(--color-primary); }
.btn-primary { background: var(--color-primary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
.btn-primary:hover { background: var(--color-secondary); }
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 8px rgba(0,119,182,0.1); padding: 20px; }
.tag { background: var(--color-accent); color: var(--color-text); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }

View File

@@ -0,0 +1,16 @@
/* Sunset Ember Theme — ChromaCraft v1.1.2 */
:root {
--color-primary: #D62828;
--color-secondary: #F77F00;
--color-background: #FFF5E4;
--color-surface: #FFFFFF;
--color-text: #2D1B00;
--color-accent: #FCBF49;
}
body { background-color: var(--color-background); color: var(--color-text); font-family: system-ui, sans-serif; }
a, .link { color: var(--color-primary); }
.btn-primary { background: var(--color-secondary); color: #fff; border: none; border-radius: 6px; padding: 8px 18px; cursor: pointer; }
.btn-primary:hover { background: var(--color-primary); }
.card { background: var(--color-surface); border-radius: 10px; box-shadow: 0 2px 8px rgba(214,40,40,0.1); padding: 20px; }
.tag { background: var(--color-accent); color: var(--color-text); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; }

1
public/todo-preview Symbolic link
View File

@@ -0,0 +1 @@
/home/came/Nextcloud/arch-work/Projects/Todo-App/dist

0
writable/.htaccess Normal file → Executable file
View File

0
writable/cache/index.html vendored Normal file → Executable file
View File

0
writable/debugbar/index.html Normal file → Executable file
View File

0
writable/index.html Normal file → Executable file
View File

0
writable/logs/index.html Normal file → Executable file
View File

0
writable/session/index.html Normal file → Executable file
View File

0
writable/uploads/index.html Normal file → Executable file
View File