Working marketplace

This commit is contained in:
Cametendo
2026-05-13 15:29:47 +02:00
parent f27498dc26
commit caf81ea4e2
14 changed files with 577 additions and 32 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -90,5 +90,19 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\V1', 'filter' => [
$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');

View File

@@ -3,11 +3,12 @@
namespace App\Controllers;
use App\Models\MarketplaceThemeModel;
use App\Models\UserThemeModel;
use CodeIgniter\HTTP\Response;
class ThemeStore extends BaseController
{
public function index(): string
public function index()
{
$model = new MarketplaceThemeModel();
$themes = $model->where('is_published', 1)->findAll();
@@ -17,6 +18,15 @@ class ThemeStore extends BaseController
$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', [
@@ -28,20 +38,32 @@ class ThemeStore extends BaseController
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 === '') {
return redirect()->to('/themes')->with('error', 'Display name is required.');
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()) {
return redirect()->to('/themes')->with('error', 'Please upload a valid CSS file.');
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') {
return redirect()->to('/themes')->with('error', 'Only .css files are allowed.');
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));
@@ -50,6 +72,23 @@ class ThemeStore extends BaseController
$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(),
@@ -62,12 +101,22 @@ class ThemeStore extends BaseController
'download_url' => '/themes/' . $filename,
'price' => 0,
'is_published' => true,
'metadata' => json_encode(['tags' => ['custom', 'community'], 'colors' => []]),
'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'),
]);
return redirect()->to('/themes')->with('success', '"' . esc($displayName) . '" uploaded successfully!');
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
@@ -117,6 +166,96 @@ class ThemeStore extends BaseController
->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);

View File

@@ -164,7 +164,7 @@
}
.btn:hover { opacity: 0.88; transform: translateY(-1px); }
.btn-download {
background: linear-gradient(135deg, #7c3aed, #6d28d9);
background: linear-gradient(135deg, #22c55e, #16a34a);
color: #fff; text-decoration: none;
display: flex; align-items: center; justify-content: center; gap: 6px;
}
@@ -292,7 +292,7 @@
.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);
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;
@@ -510,21 +510,31 @@
<?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-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') ?>)">
&#8659; Install Theme
</button>
<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"],
]) ?>)'>
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>
@@ -570,9 +580,9 @@
<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>
<button class="btn-download-lg" id="dm-download" onclick="installCurrentTheme()">
&#8659; Install Theme
</button>
</div>
</div>
</div>
@@ -608,7 +618,7 @@
<button class="modal-close" onclick="closeUploadModal()">&#x2715;</button>
</div>
<div class="upload-body">
<form method="post" action="/themes/upload" enctype="multipart/form-data">
<form method="post" action="<?= site_url('themes/upload') ?>" enctype="multipart/form-data">
<div class="field">
<label>Display Name *</label>
@@ -643,12 +653,93 @@
<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 || {};
@@ -657,7 +748,6 @@
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
@@ -720,8 +810,12 @@
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;
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');

View File

@@ -30,7 +30,6 @@ Options -Indexes
# such as an image or css document, if this isn't true it sends the
# request to the front controller, index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^([\s\S]*)$ index.php/$1 [L,NC,QSA]
# Ensure Authorization header is passed along

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,37 @@
/* @todo-theme-meta
{
"name": "Extract Test Theme",
"id": "custom-1778676985034",
"group": "Custom",
"preview": [
"#f5f5f5",
"#ffffff",
"#274f69"
],
"hasWallpaper": false
}
*/
:root {
--bg: #f5f5f5;
--surface: #ffffff;
--surface-strong: #ffffff;
--surface-muted: #fafafa;
--border: #d0d0d0;
--line: #b9b9b5;
--text: #1f1f1f;
--text-muted: #686866;
--text-strong: #111111;
--accent: #274f69;
--accent-text: #ffffff;
--accent-soft: #d6e4ec;
--sidebar-bg: #ffffff;
--sidebar-border: #d0d0d0;
--sidebar-text: #222222;
--sidebar-text-muted: #686866;
--input-bg: #ffffff;
--input-border: #cfcfcf;
--modal-bg: #ffffff;
--chip: #d8d8d8;
--success: #dff7e7;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,37 @@
/* @todo-theme-meta
{
"name": "Red Extract Theme",
"id": "custom-1778677606575",
"group": "Custom",
"preview": [
"#f5f5f5",
"#ffffff",
"#274f69"
],
"hasWallpaper": false
}
*/
:root {
--bg: #f5f5f5;
--surface: #ffffff;
--surface-strong: #ffffff;
--surface-muted: #fafafa;
--border: #d0d0d0;
--line: #b9b9b5;
--text: #1f1f1f;
--text-muted: #686866;
--text-strong: #111111;
--accent: #274f69;
--accent-text: #ffffff;
--accent-soft: #d6e4ec;
--sidebar-bg: #ffffff;
--sidebar-border: #d0d0d0;
--sidebar-text: #222222;
--sidebar-text-muted: #686866;
--input-bg: #ffffff;
--input-border: #cfcfcf;
--modal-bg: #ffffff;
--chip: #d8d8d8;
--success: #dff7e7;
}

View File

@@ -0,0 +1,37 @@
/* @todo-theme-meta
{
"name": "Test Theme",
"id": "custom-1778671955013",
"group": "Custom",
"preview": [
"#f5f5f5",
"#ffffff",
"#274f69"
],
"hasWallpaper": false
}
*/
:root {
--bg: #f5f5f5;
--surface: #ffffff;
--surface-strong: #ffffff;
--surface-muted: #fafafa;
--border: #d0d0d0;
--line: #b9b9b5;
--text: #1f1f1f;
--text-muted: #686866;
--text-strong: #111111;
--accent: #274f69;
--accent-text: #ffffff;
--accent-soft: #d6e4ec;
--sidebar-bg: #ffffff;
--sidebar-border: #d0d0d0;
--sidebar-text: #222222;
--sidebar-text-muted: #686866;
--input-bg: #ffffff;
--input-border: #cfcfcf;
--modal-bg: #ffffff;
--chip: #d8d8d8;
--success: #dff7e7;
}

View File

@@ -0,0 +1,37 @@
/* @todo-theme-meta
{
"name": "Test Theme",
"id": "custom-1778671955013",
"group": "Custom",
"preview": [
"#f5f5f5",
"#ffffff",
"#274f69"
],
"hasWallpaper": false
}
*/
:root {
--bg: #f5f5f5;
--surface: #ffffff;
--surface-strong: #ffffff;
--surface-muted: #fafafa;
--border: #d0d0d0;
--line: #b9b9b5;
--text: #1f1f1f;
--text-muted: #686866;
--text-strong: #111111;
--accent: #274f69;
--accent-text: #ffffff;
--accent-soft: #d6e4ec;
--sidebar-bg: #ffffff;
--sidebar-border: #d0d0d0;
--sidebar-text: #222222;
--sidebar-text-muted: #686866;
--input-bg: #ffffff;
--input-border: #cfcfcf;
--modal-bg: #ffffff;
--chip: #d8d8d8;
--success: #dff7e7;
}

View File

@@ -0,0 +1,37 @@
/* @todo-theme-meta
{
"name": "THemestore theme by Came",
"id": "custom-1778674717129",
"group": "Custom",
"preview": [
"#17acde",
"#222020",
"#00bbff"
],
"hasWallpaper": false
}
*/
:root {
--bg: #17acde;
--surface: #222020;
--surface-strong: #2bc582;
--surface-muted: #38363a;
--border: #000000;
--line: #ffffff;
--text: #d12e57;
--text-muted: #2f84c1;
--text-strong: #ff0000;
--accent: #00bbff;
--accent-text: #570000;
--accent-soft: #005f85;
--sidebar-bg: #004370;
--sidebar-border: #2a5070;
--sidebar-text: #d8f0ff;
--sidebar-text-muted: #7ab0d0;
--input-bg: #ffffff;
--input-border: #a0cce0;
--modal-bg: #ffffff;
--chip: #b0d8ec;
--success: #d0f0e0;
}