mirror of
https://github.com/JGH0/Todo-App-Backend.git
synced 2026-06-03 13:28:47 +02:00
Working marketplace
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.
|
||||
|
||||
@@ -34,7 +34,7 @@ class Cors extends BaseConfig
|
||||
* - ['http://localhost:8080']
|
||||
* - ['https://www.example.com']
|
||||
*/
|
||||
'allowedOrigins' => [],
|
||||
'allowedOrigins' => ['http://localhost:5173', 'http://127.0.0.1:5173'],
|
||||
|
||||
/**
|
||||
* Origin regex patterns for the `Access-Control-Allow-Origin` header.
|
||||
@@ -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.
|
||||
@@ -68,7 +68,7 @@ class Cors extends BaseConfig
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers
|
||||
*/
|
||||
'allowedHeaders' => [],
|
||||
'allowedHeaders' => ['*'],
|
||||
|
||||
/**
|
||||
* Set headers to expose.
|
||||
@@ -93,7 +93,7 @@ class Cors extends BaseConfig
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
|
||||
*/
|
||||
'allowedMethods' => [],
|
||||
'allowedMethods' => ['*'],
|
||||
|
||||
/**
|
||||
* Set how many seconds the results of a preflight request can be cached.
|
||||
|
||||
@@ -7,5 +7,24 @@ use CodeIgniter\Router\RouteCollection;
|
||||
*/
|
||||
$routes->get('/', 'Home::index');
|
||||
$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');
|
||||
$routes->post('/themes/install/(:segment)', 'ThemeStore::install/$1');
|
||||
$routes->post('/themes/activate/(:segment)', 'ThemeStore::activate/$1');
|
||||
$routes->delete('/themes/uninstall/(:segment)', 'ThemeStore::uninstall/$1');
|
||||
$routes->get('/themes/my-themes', 'ThemeStore::myThemes');
|
||||
$routes->get('/themes/(:segment)', 'ThemeStore::serveCss/$1');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
⇓ 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') ?>)">
|
||||
⇓ 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>
|
||||
⇓ Download Theme
|
||||
</a>
|
||||
<button class="btn-download-lg" id="dm-download" onclick="installCurrentTheme()">
|
||||
⇓ Install Theme
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -608,7 +618,7 @@
|
||||
<button class="modal-close" onclick="closeUploadModal()">✕</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');
|
||||
|
||||
Reference in New Issue
Block a user