Init PercoVitrine — app de gestion de fiches produits

This commit is contained in:
perco
2026-06-27 10:34:47 +02:00
commit a1b3f6da98
16 changed files with 1158 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
data/
*.db
*.sqlite
+10
View File
@@ -0,0 +1,10 @@
Options -Indexes
RewriteEngine On
# Bloquer accès au dossier data
RewriteRule ^data/ - [F,L]
# Router tout vers le bon fichier PHP
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [QSA,L]
+14
View File
@@ -0,0 +1,14 @@
FROM php:8.3-apache
RUN docker-php-ext-install pdo pdo_sqlite && \
a2enmod rewrite
COPY . /var/www/html/
RUN chown -R www-data:www-data /var/www/html && \
echo "ServerName localhost" >> /etc/apache2/apache2.conf
COPY docker/apache.conf /etc/apache2/sites-available/000-default.conf
VOLUME /data
EXPOSE 80
+80
View File
@@ -0,0 +1,80 @@
<?php
require __DIR__ . '/includes/db.php';
require __DIR__ . '/includes/helpers.php';
$action = $_GET['action'] ?? '';
$method = $_SERVER['REQUEST_METHOD'];
/* ─── DELETE product ─────────────────────────────────────── */
if ($action === 'product' && $method === 'DELETE') {
$id = (int)($_GET['id'] ?? 0);
if (!$id) err('id manquant');
$medias = $pdo->prepare("SELECT filename FROM product_media WHERE product_id=?")->execute([$id]) ? $pdo->query("SELECT filename FROM product_media WHERE product_id=$id")->fetchAll() : [];
foreach ($medias as $m) { if ($m['filename']) @unlink($UPLOAD_DIR . $m['filename']); }
$pdo->prepare("DELETE FROM products WHERE id=?")->execute([$id]);
ok(null);
}
/* ─── DELETE media ───────────────────────────────────────── */
if ($action === 'media' && $method === 'DELETE') {
$id = (int)($_GET['id'] ?? 0);
if (!$id) err('id manquant');
$m = $pdo->prepare("SELECT filename FROM product_media WHERE id=?")->execute([$id])
? $pdo->query("SELECT filename FROM product_media WHERE id=$id")->fetch()
: null;
if ($m && $m['filename']) @unlink($UPLOAD_DIR . $m['filename']);
$pdo->prepare("DELETE FROM product_media WHERE id=?")->execute([$id]);
ok(null);
}
/* ─── PATCH media sort ───────────────────────────────────── */
if ($action === 'media_sort' && $method === 'POST') {
$body = json_decode(file_get_contents('php://input'), true);
$ids = $body['ids'] ?? [];
$stmt = $pdo->prepare("UPDATE product_media SET sort_order=? WHERE id=?");
foreach ($ids as $i => $id) $stmt->execute([$i, (int)$id]);
ok(null);
}
/* ─── GET product JSON ───────────────────────────────────── */
if ($action === 'product' && $method === 'GET') {
$id = (int)($_GET['id'] ?? 0);
if (!$id) err('id manquant');
$p = $pdo->prepare("SELECT * FROM products WHERE id=?")->execute([$id])
? $pdo->query("SELECT * FROM products WHERE id=$id")->fetch()
: null;
if (!$p) err('introuvable', 404);
$p['media'] = $pdo->query("SELECT * FROM product_media WHERE product_id={$id} ORDER BY sort_order")->fetchAll();
ok($p);
}
/* ─── POST upload media ──────────────────────────────────── */
if ($action === 'upload' && $method === 'POST') {
$pid = (int)($_POST['product_id'] ?? 0);
if (!$pid) err('product_id manquant');
if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
$orig = $_FILES['file']['name'];
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
$allowed = array_merge(imgExts(), vidExts());
if (!in_array($ext, $allowed)) err('format non supporté');
$fname = uniqid('m', true) . '.' . $ext;
if (!move_uploaded_file($_FILES['file']['tmp_name'], $UPLOAD_DIR . $fname)) err('échec upload');
$type = isImage($fname) ? 'image' : 'video';
$order = (int)$pdo->query("SELECT COALESCE(MAX(sort_order),0)+1 FROM product_media WHERE product_id=$pid")->fetchColumn();
$pdo->prepare("INSERT INTO product_media (product_id,type,filename,original_name,sort_order) VALUES(?,?,?,?,?)")
->execute([$pid, $type, $fname, $orig, $order]);
$newId = $pdo->lastInsertId();
ok(['id'=>$newId,'type'=>$type,'filename'=>$fname,'original_name'=>$orig,'sort_order'=>$order]);
}
// Video URL
if (!empty($_POST['video_url'])) {
$url = filter_var($_POST['video_url'], FILTER_SANITIZE_URL);
$order = (int)$pdo->query("SELECT COALESCE(MAX(sort_order),0)+1 FROM product_media WHERE product_id=$pid")->fetchColumn();
$pdo->prepare("INSERT INTO product_media (product_id,type,video_url,sort_order) VALUES(?,?,?,?)")
->execute([$pid, 'video', $url, $order]);
ok(['id'=>$pdo->lastInsertId(),'type'=>'video','video_url'=>$url,'sort_order'=>$order]);
}
err('aucun fichier');
}
err('action inconnue', 404);
+143
View File
@@ -0,0 +1,143 @@
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0d1117;--bg-card:#161b22;--bg-input:#0d1321;
--border:#30363d;--text:#f1f5f9;--muted:#8b949e;
--primary:#2DA3F2;--primary-dark:#1a8dd9;
--green:#22c55e;--red:#ef4444;--volt:#facc15;
--shadow:0 4px 24px rgba(0,0,0,.4);
}
body{font-family:"Outfit",system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;font-size:15px}
/* Layout */
.app-wrap{display:flex;min-height:100vh}
.sidebar{width:220px;background:var(--bg-card);border-right:1px solid var(--border);display:flex;flex-direction:column;position:fixed;top:0;left:0;bottom:0;z-index:100;overflow-y:auto}
.sidebar-brand{padding:1.25rem 1rem 1rem;font-size:1.1rem;font-weight:800;background:linear-gradient(135deg,var(--primary),var(--volt));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;border-bottom:1px solid var(--border);text-decoration:none;display:block}
.sidebar-nav{flex:1;padding:.5rem}
.sidebar-link{display:flex;align-items:center;gap:.6rem;padding:.5rem .75rem;color:var(--muted);text-decoration:none;border-radius:8px;font-size:.875rem;font-weight:500;margin:.1rem 0;transition:all .2s}
.sidebar-link i{width:16px;text-align:center;font-size:.8rem}
.sidebar-link:hover{background:rgba(45,163,242,.08);color:var(--primary)}
.sidebar-link.active{background:rgba(45,163,242,.14);color:var(--primary);border:1px solid rgba(45,163,242,.2)}
.sidebar-section{font-size:.62rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:.75rem .75rem .25rem;font-weight:700}
.main{margin-left:220px;flex:1;padding:2rem}
/* Cards */
.card{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:1.5rem}
.page-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:1.75rem;flex-wrap:wrap;gap:.75rem}
.page-title{font-size:1.4rem;font-weight:800}
.page-sub{font-size:.82rem;color:var(--muted);margin-top:.2rem}
/* Badges */
.badge{display:inline-flex;align-items:center;padding:.2rem .65rem;border-radius:20px;font-size:.72rem;font-weight:700;gap:.3rem}
.badge-muted{background:rgba(139,148,158,.1);color:var(--muted);border:1px solid rgba(139,148,158,.2)}
.badge-volt{background:rgba(250,204,21,.1);color:var(--volt);border:1px solid rgba(250,204,21,.2)}
.badge-green{background:rgba(34,197,94,.1);color:var(--green);border:1px solid rgba(34,197,94,.2)}
.badge-red{background:rgba(239,68,68,.1);color:var(--red);border:1px solid rgba(239,68,68,.2)}
.badge-blue{background:rgba(45,163,242,.1);color:var(--primary);border:1px solid rgba(45,163,242,.2)}
/* Buttons */
.btn{display:inline-flex;align-items:center;gap:.4rem;padding:.45rem 1rem;border-radius:8px;font-size:.875rem;font-weight:600;cursor:pointer;border:none;text-decoration:none;transition:all .2s;font-family:inherit}
.btn-primary{background:linear-gradient(135deg,var(--primary),var(--primary-dark));color:#fff}
.btn-primary:hover{opacity:.9;box-shadow:0 0 20px rgba(45,163,242,.3)}
.btn-secondary{background:transparent;color:var(--muted);border:1px solid var(--border)}
.btn-secondary:hover{color:var(--text);border-color:var(--primary)}
.btn-danger{background:rgba(239,68,68,.1);color:var(--red);border:1px solid rgba(239,68,68,.25)}
.btn-danger:hover{background:rgba(239,68,68,.2)}
.btn-success{background:rgba(34,197,94,.12);color:var(--green);border:1px solid rgba(34,197,94,.25)}
.btn-success:hover{background:rgba(34,197,94,.22)}
.btn-sm{padding:.3rem .7rem;font-size:.8rem}
.btn-icon{width:34px;height:34px;padding:0;justify-content:center;border-radius:8px}
/* Forms */
.form-group{margin-bottom:1.1rem}
label{display:block;font-size:.78rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.4rem}
input,select,textarea{background:var(--bg-input);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:.5rem .75rem;font-family:inherit;font-size:.875rem;width:100%;transition:border-color .2s,box-shadow .2s}
input:focus,select:focus,textarea:focus{border-color:var(--primary);outline:none;box-shadow:0 0 0 3px rgba(45,163,242,.1)}
input::placeholder,textarea::placeholder{color:var(--muted)}
select option{background:var(--bg-card)}
textarea{resize:vertical;min-height:140px;line-height:1.6}
.input-group{display:flex;gap:.5rem;align-items:center}
.input-prefix{background:var(--bg-card);border:1px solid var(--border);border-right:none;color:var(--muted);padding:.5rem .75rem;border-radius:8px 0 0 8px;font-size:.875rem;white-space:nowrap}
.input-group input{border-radius:0 8px 8px 0}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
.form-row-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem}
.form-hint{font-size:.74rem;color:var(--muted);margin-top:.3rem}
/* Product grid */
.products-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem;margin-top:1rem}
.product-card{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;overflow:hidden;text-decoration:none;color:inherit;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s,transform .15s}
.product-card:hover{border-color:rgba(45,163,242,.3);box-shadow:0 4px 24px rgba(45,163,242,.1);transform:translateY(-2px)}
.product-thumb{width:100%;aspect-ratio:1;object-fit:cover;background:var(--bg);display:block}
.product-thumb-placeholder{width:100%;aspect-ratio:1;background:var(--bg);display:flex;align-items:center;justify-content:center;color:var(--border);font-size:3rem}
.product-body{padding:1rem;flex:1;display:flex;flex-direction:column;gap:.4rem}
.product-title{font-weight:700;font-size:.95rem;color:var(--text)}
.product-meta{display:flex;justify-content:space-between;align-items:center;margin-top:auto;padding-top:.5rem}
.product-price{font-size:1rem;font-weight:800;color:var(--primary)}
.product-sku{font-size:.72rem;color:var(--muted)}
.product-cat{font-size:.72rem;color:var(--muted)}
.product-actions{display:flex;gap:.4rem;padding:.75rem 1rem;border-top:1px solid var(--border);background:rgba(0,0,0,.15)}
/* Media gallery */
.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:.6rem;margin-top:.6rem}
.media-item{position:relative;border-radius:8px;overflow:hidden;aspect-ratio:1;background:var(--bg);border:1px solid var(--border)}
.media-item img,.media-item video{width:100%;height:100%;object-fit:cover}
.media-item .del-btn{position:absolute;top:4px;right:4px;background:rgba(0,0,0,.7);color:#fff;border:none;border-radius:50%;width:22px;height:22px;font-size:.7rem;cursor:pointer;display:none;align-items:center;justify-content:center}
.media-item:hover .del-btn{display:flex}
.media-item .sort-handle{position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.7);color:#fff;border-radius:4px;padding:2px 5px;font-size:.65rem;cursor:grab}
.media-item.video-item::after{content:'▶';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:1.5rem;color:#fff;pointer-events:none;text-shadow:0 0 8px rgba(0,0,0,.8)}
.drop-zone{border:2px dashed var(--border);border-radius:10px;padding:2rem;text-align:center;color:var(--muted);cursor:pointer;transition:all .2s;position:relative}
.drop-zone.drag-over{border-color:var(--primary);background:rgba(45,163,242,.05);color:var(--primary)}
.drop-zone input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%}
.preview-strip{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.6rem}
.preview-item{width:72px;height:72px;border-radius:6px;overflow:hidden;border:1px solid var(--border);position:relative;flex-shrink:0}
.preview-item img,.preview-item video{width:100%;height:100%;object-fit:cover}
.preview-item .rm{position:absolute;top:-5px;right:-5px;background:var(--red);color:#fff;border:none;border-radius:50%;width:16px;height:16px;font-size:.6rem;cursor:pointer;display:flex;align-items:center;justify-content:center}
/* Detail view */
.product-detail-grid{display:grid;grid-template-columns:1fr 340px;gap:1.5rem;align-items:start}
.media-main img,.media-main video{width:100%;border-radius:10px;aspect-ratio:1;object-fit:cover}
.media-thumbs{display:flex;gap:.5rem;margin-top:.6rem;flex-wrap:wrap}
.media-thumb{width:64px;height:64px;border-radius:6px;overflow:hidden;border:2px solid transparent;cursor:pointer;flex-shrink:0}
.media-thumb.active{border-color:var(--primary)}
.media-thumb img,.media-thumb video{width:100%;height:100%;object-fit:cover}
.product-info-card{display:flex;flex-direction:column;gap:1rem}
.price-block{font-size:2rem;font-weight:800;color:var(--primary)}
.shipping-block{font-size:.85rem;color:var(--muted)}
.info-row{display:flex;justify-content:space-between;padding:.6rem 0;border-bottom:1px solid var(--border);font-size:.875rem}
.info-row:last-child{border:none}
.info-label{color:var(--muted);font-weight:600}
.desc-block{white-space:pre-wrap;line-height:1.7;font-size:.9rem;color:var(--text)}
/* Etsy panel */
.etsy-panel{background:linear-gradient(135deg,rgba(241,90,34,.05),rgba(241,90,34,.02));border:1px solid rgba(241,90,34,.2);border-radius:12px;padding:1.25rem}
.etsy-logo{font-size:1.1rem;font-weight:800;color:#f15a22}
.etsy-status-ok{color:var(--green);font-size:.85rem}
.etsy-status-ko{color:var(--muted);font-size:.85rem}
/* Filters */
.filters{display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem;align-items:center}
.filter-chip{padding:.3rem .75rem;border-radius:20px;font-size:.78rem;font-weight:600;cursor:pointer;border:1px solid var(--border);background:transparent;color:var(--muted);text-decoration:none;transition:all .2s}
.filter-chip:hover,.filter-chip.active{background:rgba(45,163,242,.12);color:var(--primary);border-color:rgba(45,163,242,.3)}
/* Table */
.table{width:100%;border-collapse:collapse}
.table th{text-align:left;padding:.6rem .75rem;font-size:.72rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);border-bottom:1px solid var(--border)}
.table td{padding:.75rem;border-bottom:1px solid rgba(48,54,61,.5);font-size:.875rem}
.table tr:last-child td{border:none}
.table tr:hover td{background:rgba(255,255,255,.02)}
/* Utils */
.flex{display:flex}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}
.items-center{align-items:center}.justify-between{justify-content:space-between}
.flex-wrap{flex-wrap:wrap}.flex-1{flex:1}.flex-col{flex-direction:column}
.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}
.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}
.text-muted{color:var(--muted)}.text-sm{font-size:.875rem}.text-xs{font-size:.75rem}
.font-bold{font-weight:700}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.empty-state{text-align:center;padding:4rem 2rem;color:var(--muted)}
.empty-state i{font-size:3rem;opacity:.2;margin-bottom:1rem;display:block}
.toast-wrap{position:fixed;bottom:1.5rem;right:1.5rem;z-index:9999;display:flex;flex-direction:column;gap:.5rem}
.toast{background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:.7rem 1rem;font-size:.875rem;min-width:200px;color:var(--text);box-shadow:var(--shadow);border-left:4px solid var(--primary);animation:slideIn .2s ease}
.toast.error{border-left-color:var(--red)}
@keyframes slideIn{from{opacity:0;transform:translateX(16px)}to{opacity:1;transform:none}}
.sep{border:none;border-top:1px solid var(--border);margin:1.25rem 0}
@media(max-width:768px){.sidebar{width:56px}.sidebar .sidebar-brand span,.sidebar .sidebar-section,.sidebar .sidebar-link span{display:none}.main{margin-left:56px;padding:1rem}.form-row,.form-row-3{grid-template-columns:1fr}.product-detail-grid{grid-template-columns:1fr}}
+119
View File
@@ -0,0 +1,119 @@
<?php
require __DIR__ . '/includes/db.php';
require __DIR__ . '/includes/helpers.php';
require __DIR__ . '/includes/layout.php';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['_action'] ?? '';
if ($action === 'add') {
$name = trim($_POST['name'] ?? '');
if (!$name) { $error = 'Le nom est obligatoire.'; }
else {
try { $pdo->prepare("INSERT INTO categories (name) VALUES(?)")->execute([$name]); }
catch (PDOException) { $error = 'Ce nom existe déjà.'; }
}
} elseif ($action === 'rename') {
$cid = (int)($_POST['id'] ?? 0);
$name = trim($_POST['name'] ?? '');
if ($cid && $name) {
try { $pdo->prepare("UPDATE categories SET name=? WHERE id=?")->execute([$name,$cid]); }
catch (PDOException) { $error = 'Ce nom existe déjà.'; }
}
} elseif ($action === 'delete') {
$cid = (int)($_POST['id'] ?? 0);
if ($cid) {
$pdo->prepare("UPDATE products SET category_id=NULL WHERE category_id=?")->execute([$cid]);
$pdo->prepare("DELETE FROM categories WHERE id=?")->execute([$cid]);
}
}
if (!$error) { header('Location: /categories.php'); exit; }
}
$cats = $pdo->query("SELECT c.*, COUNT(p.id) as nb FROM categories c LEFT JOIN products p ON p.category_id=c.id GROUP BY c.id ORDER BY c.name")->fetchAll();
layout_head('Catégories');
layout_sidebar('categories');
layout_main_open();
?>
<div class="page-header">
<div class="page-title"><i class="fas fa-tags" style="color:var(--primary);margin-right:.4rem"></i>Catégories</div>
<button onclick="document.getElementById('addModal').style.display='flex'" class="btn btn-primary"><i class="fas fa-plus"></i> Ajouter</button>
</div>
<?php if ($error): ?><div style="background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;color:var(--red)"><?= h($error) ?></div><?php endif ?>
<?php if (!$cats): ?>
<div class="empty-state"><i class="fas fa-tags"></i><p>Aucune catégorie.<br>Crée-en une pour organiser tes produits.</p></div>
<?php else: ?>
<div class="card" style="padding:0">
<table class="table">
<thead><tr><th>Nom</th><th style="width:100px;text-align:center">Produits</th><th style="width:120px"></th></tr></thead>
<tbody>
<?php foreach ($cats as $c): ?>
<tr>
<td><strong><?= h($c['name']) ?></strong></td>
<td style="text-align:center"><span class="badge badge-muted"><?= $c['nb'] ?></span></td>
<td style="text-align:right">
<div style="display:flex;gap:.4rem;justify-content:flex-end">
<button onclick="openRename(<?= $c['id'] ?>,'<?= h(addslashes($c['name'])) ?>')" class="btn btn-secondary btn-sm" title="Renommer"><i class="fas fa-pen"></i></button>
<?php if ($c['nb'] == 0): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Supprimer cette catégorie ?')">
<input type="hidden" name="_action" value="delete">
<input type="hidden" name="id" value="<?= $c['id'] ?>">
<button class="btn btn-danger btn-sm" title="Supprimer"><i class="fas fa-trash"></i></button>
</form>
<?php else: ?>
<button class="btn btn-danger btn-sm" disabled title="Catégorie utilisée" style="opacity:.35"><i class="fas fa-trash"></i></button>
<?php endif ?>
</div>
</td>
</tr>
<?php endforeach ?>
</tbody>
</table>
</div>
<?php endif ?>
<!-- Modal ajouter -->
<div id="addModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:999;align-items:center;justify-content:center">
<div class="card" style="width:360px">
<div style="font-weight:700;margin-bottom:1rem">Nouvelle catégorie</div>
<form method="post">
<input type="hidden" name="_action" value="add">
<div class="form-group"><label>Nom</label><input type="text" name="name" autofocus required></div>
<div style="display:flex;gap:.5rem">
<button type="submit" class="btn btn-primary" style="flex:1"><i class="fas fa-plus"></i> Ajouter</button>
<button type="button" onclick="document.getElementById('addModal').style.display='none'" class="btn btn-secondary">Annuler</button>
</div>
</form>
</div>
</div>
<!-- Modal renommer -->
<div id="renameModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:999;align-items:center;justify-content:center">
<div class="card" style="width:360px">
<div style="font-weight:700;margin-bottom:1rem">Renommer la catégorie</div>
<form method="post" id="renameForm">
<input type="hidden" name="_action" value="rename">
<input type="hidden" name="id" id="renameId">
<div class="form-group"><label>Nouveau nom</label><input type="text" name="name" id="renameName" required></div>
<div style="display:flex;gap:.5rem">
<button type="submit" class="btn btn-primary" style="flex:1"><i class="fas fa-check"></i> Enregistrer</button>
<button type="button" onclick="document.getElementById('renameModal').style.display='none'" class="btn btn-secondary">Annuler</button>
</div>
</form>
</div>
</div>
<script>
function openRename(id, name) {
document.getElementById('renameId').value = id;
document.getElementById('renameName').value = name;
document.getElementById('renameModal').style.display = 'flex';
setTimeout(()=>document.getElementById('renameName').focus(),50);
}
</script>
<?php layout_foot(); ?>
+24
View File
@@ -0,0 +1,24 @@
services:
percovitrine:
build: .
container_name: percovitrine
restart: unless-stopped
environment:
DB_PATH: /data/vitrine.db
UPLOAD_DIR: /data/uploads/
volumes:
- percovitrine_data:/data
labels:
- "traefik.enable=true"
- "traefik.http.routers.percovitrine.rule=Host(`vitrine.perco.home`)"
- "traefik.http.routers.percovitrine.entrypoints=web"
- "traefik.http.services.percovitrine.loadbalancer.server.port=80"
networks:
- traefik
volumes:
percovitrine_data:
networks:
traefik:
external: true
+16
View File
@@ -0,0 +1,16 @@
<VirtualHost *:80>
DocumentRoot /var/www/html
DirectoryIndex index.php
<Directory /var/www/html>
AllowOverride All
Require all granted
</Directory>
<Directory /var/www/html/data>
Require all denied
</Directory>
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>
+26
View File
@@ -0,0 +1,26 @@
<?php
require __DIR__ . '/includes/db.php';
$f = basename($_GET['f'] ?? '');
if (!$f) { http_response_code(400); exit; }
$path = $UPLOAD_DIR . $f;
if (!is_file($path)) { http_response_code(404); exit; }
$ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
$mime = match($ext) {
'jpg','jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'avif' => 'image/avif',
'mp4' => 'video/mp4',
'webm' => 'video/webm',
'mov' => 'video/quicktime',
default => 'application/octet-stream',
};
header('Content-Type: ' . $mime);
header('Cache-Control: public, max-age=86400');
header('Content-Length: ' . filesize($path));
readfile($path);
+244
View File
@@ -0,0 +1,244 @@
<?php
require __DIR__ . '/includes/db.php';
require __DIR__ . '/includes/helpers.php';
require __DIR__ . '/includes/layout.php';
$id = (int)($_GET['id'] ?? 0);
$product = null;
$medias = [];
if ($id) {
$product = $pdo->prepare("SELECT * FROM products WHERE id=?")->execute([$id])
? $pdo->query("SELECT * FROM products WHERE id=$id")->fetch()
: null;
if (!$product) { header('Location: /'); exit; }
$medias = $pdo->query("SELECT * FROM product_media WHERE product_id=$id ORDER BY sort_order")->fetchAll();
}
/* ─── Sauvegarde ─────────────────────────────────────────── */
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title'] ?? '');
$desc = trim($_POST['desc'] ?? '');
$price = (float)str_replace(',','.',($_POST['price'] ?? 0));
$shipping = (float)str_replace(',','.',($_POST['shipping'] ?? 0));
$sku = trim($_POST['sku'] ?? '');
$cat_id = (int)($_POST['category_id'] ?? 0) ?: null;
$status = $_POST['status'] ?? 'brouillon';
$tags = trim($_POST['tags'] ?? '');
if (!$title) { $error = 'Le titre est obligatoire.'; goto show; }
if ($id) {
$pdo->prepare("UPDATE products SET title=?,description=?,price=?,shipping=?,sku=?,category_id=?,status=?,tags=?,updated_at=CURRENT_TIMESTAMP WHERE id=?")
->execute([$title,$desc,$price,$shipping,$sku,$cat_id,$status,$tags,$id]);
} else {
$pdo->prepare("INSERT INTO products (title,description,price,shipping,sku,category_id,status,tags) VALUES(?,?,?,?,?,?,?,?)")
->execute([$title,$desc,$price,$shipping,$sku,$cat_id,$status,$tags]);
$id = (int)$pdo->lastInsertId();
}
// Uploads médias
if (!empty($_FILES['medias']['name'][0])) {
$count = count($_FILES['medias']['name']);
for ($i = 0; $i < $count; $i++) {
if ($_FILES['medias']['error'][$i] !== UPLOAD_ERR_OK) continue;
$orig = $_FILES['medias']['name'][$i];
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
if (!in_array($ext, array_merge(imgExts(), vidExts()))) continue;
$fname = uniqid('m', true) . '.' . $ext;
if (!move_uploaded_file($_FILES['medias']['tmp_name'][$i], $UPLOAD_DIR . $fname)) continue;
$type = isImage($fname) ? 'image' : 'video';
$order = (int)$pdo->query("SELECT COALESCE(MAX(sort_order),0)+1 FROM product_media WHERE product_id=$id")->fetchColumn();
$pdo->prepare("INSERT INTO product_media (product_id,type,filename,original_name,sort_order) VALUES(?,?,?,?,?)")
->execute([$id,$type,$fname,$orig,$order]);
}
}
// Suppression médias cochés
if (!empty($_POST['del_media'])) {
foreach ($_POST['del_media'] as $mid) {
$mid = (int)$mid;
$m = $pdo->query("SELECT filename FROM product_media WHERE id=$mid")->fetch();
if ($m && $m['filename']) @unlink($UPLOAD_DIR . $m['filename']);
$pdo->prepare("DELETE FROM product_media WHERE id=?")->execute([$mid]);
}
}
header('Location: /produit.php?id=' . $id);
exit;
}
show:
$cats = $pdo->query("SELECT * FROM categories ORDER BY name")->fetchAll();
$pageTitle = $id ? 'Modifier le produit' : 'Nouveau produit';
layout_head($pageTitle);
layout_sidebar($id ? '' : 'products');
layout_main_open();
?>
<div class="page-header">
<div>
<div class="page-title"><?= $id ? 'Modifier' : 'Nouveau produit' ?></div>
<?php if ($id): ?><div class="page-sub"><a href="/produit.php?id=<?= $id ?>" style="color:var(--muted);text-decoration:none">← Retour au produit</a></div><?php endif ?>
</div>
</div>
<?php if (!empty($error)): ?><div style="background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;color:var(--red)"><?= h($error) ?></div><?php endif ?>
<form method="post" enctype="multipart/form-data">
<div style="display:grid;grid-template-columns:1fr 360px;gap:1.5rem;align-items:start">
<!-- Colonne gauche -->
<div style="display:flex;flex-direction:column;gap:1rem">
<div class="card">
<div class="form-group">
<label>Titre *</label>
<input type="text" name="title" value="<?= h($product['title'] ?? '') ?>" placeholder="Nom du produit" required>
</div>
<div class="form-group" style="margin-bottom:0">
<label>Description</label>
<textarea name="desc" rows="8" placeholder="Décrivez votre produit (sera envoyé sur Etsy)…"><?= h($product['description'] ?? '') ?></textarea>
</div>
</div>
<div class="card">
<div style="font-size:.85rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem"><i class="fas fa-images" style="margin-right:.4rem"></i>Photos & vidéos</div>
<?php if ($medias): ?>
<div class="media-grid" id="mediaGrid" style="margin-bottom:1rem">
<?php foreach ($medias as $m): ?>
<div class="media-item<?= $m['type']==='video'?' video-item':'' ?>" data-id="<?= $m['id'] ?>">
<?php if ($m['filename']): ?>
<?php if ($m['type']==='image'): ?>
<img src="/file.php?f=<?= urlencode($m['filename']) ?>" alt="">
<?php else: ?>
<video src="/file.php?f=<?= urlencode($m['filename']) ?>" muted playsinline></video>
<?php endif ?>
<?php elseif ($m['video_url']): ?>
<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;padding:.5rem;font-size:.7rem;word-break:break-all;color:var(--muted)"><?= h($m['video_url']) ?></div>
<?php endif ?>
<span class="sort-handle" title="Drag pour réordonner"><i class="fas fa-grip-vertical"></i></span>
<label style="all:unset"><input type="checkbox" name="del_media[]" value="<?= $m['id'] ?>" style="position:absolute;top:4px;right:4px;cursor:pointer;width:16px;height:16px" title="Supprimer ce média"></label>
</div>
<?php endforeach ?>
</div>
<p class="form-hint" style="margin-bottom:.75rem"><i class="fas fa-square-check" style="margin-right:.3rem"></i>Cocher un média pour le supprimer à la sauvegarde</p>
<?php endif ?>
<div class="drop-zone" id="dropZone">
<input type="file" name="medias[]" multiple accept="image/*,video/mp4,video/webm,video/quicktime" id="fileInput">
<i class="fas fa-cloud-arrow-up" style="font-size:1.5rem;margin-bottom:.5rem;display:block"></i>
<strong>Cliquer ou glisser</strong> des images / vidéos<br>
<span style="font-size:.75rem">JPG, PNG, WEBP, AVIF, GIF, MP4, WEBM, MOV</span>
</div>
<div class="preview-strip" id="previewStrip"></div>
</div>
</div>
<!-- Colonne droite -->
<div style="display:flex;flex-direction:column;gap:1rem">
<div class="card">
<div style="font-size:.85rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem"><i class="fas fa-euro-sign" style="margin-right:.4rem"></i>Prix</div>
<div class="form-row">
<div class="form-group" style="margin-bottom:0">
<label>Prix de vente</label>
<div class="input-group">
<span class="input-prefix">€</span>
<input type="number" name="price" value="<?= h((string)($product['price'] ?? '0')) ?>" step="0.01" min="0" placeholder="0.00">
</div>
</div>
<div class="form-group" style="margin-bottom:0">
<label>Frais de port</label>
<div class="input-group">
<span class="input-prefix">€</span>
<input type="number" name="shipping" value="<?= h((string)($product['shipping'] ?? '0')) ?>" step="0.01" min="0" placeholder="0.00">
</div>
</div>
</div>
</div>
<div class="card">
<div style="font-size:.85rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem"><i class="fas fa-layer-group" style="margin-right:.4rem"></i>Organisation</div>
<div class="form-group">
<label>Statut</label>
<select name="status">
<?php foreach (['brouillon'=>'Brouillon','prêt'=>'Prêt à publier','publié'=>'Publié'] as $v=>$l): ?>
<option value="<?= $v ?>"<?= ($product['status']??'brouillon')===$v?' selected':'' ?>><?= $l ?></option>
<?php endforeach ?>
</select>
</div>
<div class="form-group">
<label>Catégorie</label>
<select name="category_id">
<option value="">— Aucune —</option>
<?php foreach ($cats as $c): ?>
<option value="<?= $c['id'] ?>"<?= ($product['category_id']??'')==$c['id']?' selected':'' ?>><?= h($c['name']) ?></option>
<?php endforeach ?>
</select>
<div class="form-hint">Gérer les catégories dans <a href="/categories.php" style="color:var(--primary)">Catégories</a></div>
</div>
<div class="form-group">
<label>Référence / SKU</label>
<input type="text" name="sku" value="<?= h($product['sku'] ?? '') ?>" placeholder="REF-001">
</div>
<div class="form-group" style="margin-bottom:0">
<label>Tags <span style="font-weight:400;text-transform:none">(séparés par des virgules)</span></label>
<input type="text" name="tags" value="<?= h($product['tags'] ?? '') ?>" placeholder="artisanat, fait main, cadeau…">
<div class="form-hint">Les tags seront utilisés lors de la publication Etsy</div>
</div>
</div>
<?php if ($id && ($product['etsy_listing_id'] ?? '')): ?>
<div class="etsy-panel">
<div class="etsy-logo"><i class="fab fa-etsy"></i> Etsy</div>
<div class="mt-2 etsy-status-ok"><i class="fas fa-check-circle"></i> Publié — ID <?= h($product['etsy_listing_id']) ?></div>
</div>
<?php endif ?>
<div style="display:flex;gap:.75rem">
<button type="submit" class="btn btn-primary" style="flex:1"><i class="fas fa-save"></i> Sauvegarder</button>
<a href="<?= $id ? '/produit.php?id='.$id : '/' ?>" class="btn btn-secondary"><i class="fas fa-xmark"></i></a>
</div>
</div>
</div><!-- /grid -->
</form>
<script>
// Preview médias avant upload
const input = document.getElementById('fileInput');
const strip = document.getElementById('previewStrip');
const drop = document.getElementById('dropZone');
let files = [];
function addFiles(newFiles) {
[...newFiles].forEach(f => {
files.push(f);
const div = document.createElement('div');
div.className = 'preview-item';
const rm = document.createElement('button');
rm.type = 'button'; rm.className = 'rm'; rm.innerHTML = '×';
rm.onclick = () => { files = files.filter(x=>x!==f); div.remove(); syncInput(); };
const tag = f.type.startsWith('image') ? document.createElement('img') : document.createElement('video');
tag.src = URL.createObjectURL(f);
if (!f.type.startsWith('image')) { tag.muted = true; tag.loop = true; tag.autoplay = true; }
div.appendChild(tag); div.appendChild(rm); strip.appendChild(div);
});
syncInput();
}
function syncInput() {
const dt = new DataTransfer();
files.forEach(f => dt.items.add(f));
input.files = dt.files;
}
input.addEventListener('change', () => { addFiles(input.files); });
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('drag-over'); });
drop.addEventListener('dragleave', () => drop.classList.remove('drag-over'));
drop.addEventListener('drop', e => { e.preventDefault(); drop.classList.remove('drag-over'); addFiles(e.dataTransfer.files); });
</script>
<?php layout_foot(); ?>
+51
View File
@@ -0,0 +1,51 @@
<?php
$DB_PATH = getenv('DB_PATH') ?: '/data/vitrine.db';
$UPLOAD_DIR = getenv('UPLOAD_DIR') ?: '/data/uploads/';
if (!is_dir($UPLOAD_DIR)) @mkdir($UPLOAD_DIR, 0755, true);
if (!is_dir(dirname($DB_PATH))) @mkdir(dirname($DB_PATH), 0755, true);
$pdo = new PDO('sqlite:' . $DB_PATH, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;");
$pdo->exec("
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT DEFAULT '',
price REAL DEFAULT 0,
shipping REAL DEFAULT 0,
sku TEXT DEFAULT '',
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
status TEXT DEFAULT 'brouillon' CHECK(status IN ('brouillon','prêt','publié')),
etsy_listing_id TEXT DEFAULT '',
tags TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS product_media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
type TEXT DEFAULT 'image' CHECK(type IN ('image','video')),
filename TEXT,
original_name TEXT,
video_url TEXT,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT DEFAULT ''
);
");
+50
View File
@@ -0,0 +1,50 @@
<?php
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }
function ok(mixed $data): never {
header('Content-Type: application/json');
echo json_encode(['ok' => true, 'data' => $data], JSON_UNESCAPED_UNICODE);
exit;
}
function err(string $msg, int $code = 400): never {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => $msg]);
exit;
}
function fmtPrice(float $p): string {
return number_format($p, 2, ',', ' ') . ' €';
}
function imgExts(): array { return ['jpg','jpeg','png','gif','webp','avif']; }
function vidExts(): array { return ['mp4','webm','mov']; }
function isImage(string $f): bool {
return in_array(strtolower(pathinfo($f, PATHINFO_EXTENSION)), imgExts());
}
function isVideo(string $f): bool {
return in_array(strtolower(pathinfo($f, PATHINFO_EXTENSION)), vidExts());
}
function handleUpload(string $field, string $dir): ?array {
if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) return null;
$orig = $_FILES[$field]['name'];
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
$ok = array_merge(imgExts(), vidExts());
if (!in_array($ext, $ok)) return null;
$fname = uniqid('m', true) . '.' . $ext;
if (!move_uploaded_file($_FILES[$field]['tmp_name'], $dir . $fname)) return null;
return ['filename' => $fname, 'original_name' => $orig, 'size' => filesize($dir . $fname)];
}
function statusBadge(string $s): string {
$map = [
'brouillon' => ['label' => 'Brouillon', 'class' => 'badge-muted'],
'prêt' => ['label' => 'Prêt', 'class' => 'badge-volt'],
'publié' => ['label' => 'Publié', 'class' => 'badge-green'],
];
$b = $map[$s] ?? ['label' => $s, 'class' => 'badge-muted'];
return '<span class="badge ' . $b['class'] . '">' . h($b['label']) . '</span>';
}
+37
View File
@@ -0,0 +1,37 @@
<?php
function layout_head(string $title): void {
echo '<!DOCTYPE html><html lang="fr"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>' . htmlspecialchars($title) . ' — PercoVitrine</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="stylesheet" href="/assets/style.css">
</head><body><div class="app-wrap">';
}
function layout_sidebar(string $active = ''): void {
$links = [
['href'=>'/','icon'=>'fa-store','label'=>'Produits','key'=>'products'],
['href'=>'/categories.php','icon'=>'fa-tags','label'=>'Catégories','key'=>'categories'],
['href'=>'/settings.php','icon'=>'fa-gear','label'=>'Réglages / Etsy','key'=>'settings'],
];
echo '<aside class="sidebar"><a href="/" class="sidebar-brand"><i class="fas fa-store"></i> <span>PercoVitrine</span></a><nav class="sidebar-nav">';
echo '<div class="sidebar-section">Menu</div>';
foreach ($links as $l) {
$cls = $active === $l['key'] ? ' active' : '';
echo '<a href="' . $l['href'] . '" class="sidebar-link' . $cls . '"><i class="fas ' . $l['icon'] . '"></i><span>' . $l['label'] . '</span></a>';
}
echo '</nav></aside>';
}
function layout_main_open(): void {
echo '<main class="main">';
}
function layout_foot(): void {
echo '</main></div><div id="toasts" class="toast-wrap"></div>
<script>
function toast(msg,type="ok"){const c=document.getElementById("toasts");const t=document.createElement("div");t.className="toast"+(type==="error"?" error":"");t.textContent=msg;c.appendChild(t);setTimeout(()=>t.remove(),3500);}
function confirm2(msg){return new Promise(r=>{if(confirm(msg))r(true);else r(false);})}
</script></body></html>';
}
+94
View File
@@ -0,0 +1,94 @@
<?php
require __DIR__ . '/includes/db.php';
require __DIR__ . '/includes/helpers.php';
require __DIR__ . '/includes/layout.php';
$status = $_GET['status'] ?? '';
$cat = $_GET['cat'] ?? '';
$q = trim($_GET['q'] ?? '');
$where = []; $params = [];
if ($status) { $where[] = 'p.status = ?'; $params[] = $status; }
if ($cat) { $where[] = 'p.category_id = ?'; $params[] = (int)$cat; }
if ($q) { $where[] = '(p.title LIKE ? OR p.sku LIKE ? OR p.tags LIKE ?)'; $params = array_merge($params, ["%$q%","%$q%","%$q%"]); }
$sql = "SELECT p.*, c.name as cat_name,
(SELECT filename FROM product_media WHERE product_id=p.id AND type='image' ORDER BY sort_order LIMIT 1) as thumb
FROM products p LEFT JOIN categories c ON c.id=p.category_id"
. ($where ? ' WHERE '.implode(' AND ',$where) : '')
. ' ORDER BY p.updated_at DESC';
$stmt = $pdo->prepare($sql); $stmt->execute($params);
$products = $stmt->fetchAll();
$cats = $pdo->query("SELECT * FROM categories ORDER BY name")->fetchAll();
$total = count($products);
layout_head('Produits');
layout_sidebar('products');
layout_main_open();
?>
<div class="page-header">
<div>
<div class="page-title"><i class="fas fa-store" style="color:var(--primary);margin-right:.4rem"></i>Mes produits</div>
<div class="page-sub"><?= $total ?> produit<?= $total!==1?'s':'' ?></div>
</div>
<a href="/form.php" class="btn btn-primary"><i class="fas fa-plus"></i> Nouveau produit</a>
</div>
<!-- Filtres -->
<div class="filters">
<form method="get" style="display:contents">
<input type="text" name="q" value="<?= h($q) ?>" placeholder="Rechercher…" style="width:200px;height:34px;padding:.3rem .7rem">
<?php if ($status||$cat) echo '<input type="hidden" name="status" value="'.h($status).'"><input type="hidden" name="cat" value="'.h($cat).'">'; ?>
</form>
<a href="/" class="filter-chip<?= !$status&&!$cat&&!$q?' active':'' ?>">Tous</a>
<a href="?status=brouillon<?= $q?"&q=".urlencode($q):'' ?>" class="filter-chip<?= $status==='brouillon'?' active':'' ?>">Brouillons</a>
<a href="?status=prêt<?= $q?"&q=".urlencode($q):'' ?>" class="filter-chip<?= $status==='prêt'?' active':'' ?>">Prêts</a>
<a href="?status=publié<?= $q?"&q=".urlencode($q):'' ?>" class="filter-chip<?= $status==='publié'?' active':'' ?>">Publiés</a>
<?php foreach ($cats as $c): ?>
<a href="?cat=<?= $c['id'] ?><?= $q?"&q=".urlencode($q):'' ?>" class="filter-chip<?= $cat==(string)$c['id']?' active':'' ?>"><?= h($c['name']) ?></a>
<?php endforeach ?>
</div>
<?php if (!$products): ?>
<div class="empty-state"><i class="fas fa-store"></i><p>Aucun produit pour l'instant.</p><a href="/form.php" class="btn btn-primary mt-3"><i class="fas fa-plus"></i> Créer mon premier produit</a></div>
<?php else: ?>
<div class="products-grid">
<?php foreach ($products as $p): ?>
<div class="product-card" style="cursor:default">
<?php if ($p['thumb']): ?>
<img src="/file.php?f=<?= urlencode($p['thumb']) ?>" alt="" class="product-thumb">
<?php else: ?>
<div class="product-thumb-placeholder"><i class="fas fa-image"></i></div>
<?php endif ?>
<div class="product-body">
<div class="product-title"><?= h($p['title']) ?></div>
<?php if ($p['cat_name']): ?><div class="product-cat"><i class="fas fa-tag" style="font-size:.65rem"></i> <?= h($p['cat_name']) ?></div><?php endif ?>
<?php if ($p['sku']): ?><div class="product-sku">Réf : <?= h($p['sku']) ?></div><?php endif ?>
<div class="product-meta">
<span class="product-price"><?= fmtPrice($p['price']) ?></span>
<?= statusBadge($p['status']) ?>
</div>
</div>
<div class="product-actions">
<a href="/produit.php?id=<?= $p['id'] ?>" class="btn btn-secondary btn-sm" title="Voir"><i class="fas fa-eye"></i></a>
<a href="/form.php?id=<?= $p['id'] ?>" class="btn btn-secondary btn-sm" title="Modifier"><i class="fas fa-pen"></i></a>
<button onclick="deleteProduct(<?= $p['id'] ?>,this)" class="btn btn-danger btn-sm" title="Supprimer"><i class="fas fa-trash"></i></button>
</div>
</div>
<?php endforeach ?>
</div>
<?php endif ?>
<script>
async function deleteProduct(id, btn) {
if (!confirm('Supprimer ce produit définitivement ?')) return;
const r = await fetch('/api.php?action=product&id=' + id, {method:'DELETE'});
const j = await r.json();
if (j.ok) { btn.closest('.product-card').remove(); toast('Produit supprimé'); }
else toast(j.error || 'Erreur', 'error');
}
</script>
<?php layout_foot(); ?>
+153
View File
@@ -0,0 +1,153 @@
<?php
require __DIR__ . '/includes/db.php';
require __DIR__ . '/includes/helpers.php';
require __DIR__ . '/includes/layout.php';
$id = (int)($_GET['id'] ?? 0);
if (!$id) { header('Location: /'); exit; }
$p = $pdo->query("SELECT p.*, c.name as cat_name FROM products p LEFT JOIN categories c ON c.id=p.category_id WHERE p.id=$id")->fetch();
if (!$p) { header('Location: /'); exit; }
$medias = $pdo->query("SELECT * FROM product_media WHERE product_id=$id ORDER BY sort_order")->fetchAll();
$images = array_filter($medias, fn($m) => $m['type']==='image' && $m['filename']);
$videos = array_filter($medias, fn($m) => $m['type']==='video');
$etsyKey = $pdo->query("SELECT value FROM settings WHERE key='etsy_api_key'")->fetchColumn();
layout_head(h($p['title']));
layout_sidebar('');
layout_main_open();
?>
<div class="page-header">
<div>
<a href="/" style="color:var(--muted);text-decoration:none;font-size:.85rem">← Tous les produits</a>
<div class="page-title" style="margin-top:.25rem"><?= h($p['title']) ?></div>
</div>
<div style="display:flex;gap:.5rem">
<a href="/form.php?id=<?= $id ?>" class="btn btn-secondary"><i class="fas fa-pen"></i> Modifier</a>
<?php if ($etsyKey && $p['status']==='prêt' && !$p['etsy_listing_id']): ?>
<button onclick="publishEtsy(<?= $id ?>)" class="btn btn-success"><i class="fab fa-etsy"></i> Publier sur Etsy</button>
<?php endif ?>
</div>
</div>
<div class="product-detail-grid">
<!-- Médias -->
<div>
<?php
$allMedia = array_merge(array_values($images), array_values($videos));
$first = $allMedia[0] ?? null;
?>
<div class="media-main" id="mediaMain">
<?php if ($first): ?>
<?php if ($first['type']==='image'): ?>
<img src="/file.php?f=<?= urlencode($first['filename']) ?>" alt="">
<?php else: ?>
<video src="<?= $first['filename'] ? '/file.php?f='.urlencode($first['filename']) : h($first['video_url']) ?>" controls style="width:100%;border-radius:10px;aspect-ratio:1;object-fit:cover"></video>
<?php endif ?>
<?php else: ?>
<div style="aspect-ratio:1;background:var(--bg-card);border-radius:10px;display:flex;align-items:center;justify-content:center;color:var(--border);font-size:4rem"><i class="fas fa-image"></i></div>
<?php endif ?>
</div>
<?php if (count($allMedia) > 1): ?>
<div class="media-thumbs">
<?php foreach ($allMedia as $i => $m): ?>
<div class="media-thumb<?= $i===0?' active':'' ?>" onclick="switchMedia(this,'<?= $m['type'] ?>','<?= $m['filename'] ? '/file.php?f='.urlencode($m['filename']) : h($m['video_url']) ?>')">
<?php if ($m['type']==='image'): ?>
<img src="/file.php?f=<?= urlencode($m['filename']) ?>" alt="">
<?php else: ?>
<div style="width:100%;height:100%;background:var(--bg);display:flex;align-items:center;justify-content:center;color:var(--muted)"><i class="fas fa-play"></i></div>
<?php endif ?>
</div>
<?php endforeach ?>
</div>
<?php endif ?>
<?php if ($p['description']): ?>
<div class="card" style="margin-top:1.25rem">
<div style="font-size:.75rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:.75rem">Description</div>
<div class="desc-block"><?= nl2br(h($p['description'])) ?></div>
</div>
<?php endif ?>
</div>
<!-- Infos produit -->
<div class="product-info-card">
<div class="card">
<div class="price-block"><?= fmtPrice($p['price']) ?></div>
<?php if ($p['shipping'] > 0): ?>
<div class="shipping-block" style="margin-top:.35rem"><i class="fas fa-truck" style="margin-right:.3rem"></i>Frais de port : <?= fmtPrice($p['shipping']) ?></div>
<?php else: ?>
<div class="shipping-block" style="margin-top:.35rem"><i class="fas fa-truck" style="margin-right:.3rem"></i>Livraison gratuite</div>
<?php endif ?>
<div style="margin-top:.75rem"><?= statusBadge($p['status']) ?></div>
</div>
<div class="card">
<?php if ($p['cat_name']): ?>
<div class="info-row"><span class="info-label">Catégorie</span><span><?= h($p['cat_name']) ?></span></div>
<?php endif ?>
<?php if ($p['sku']): ?>
<div class="info-row"><span class="info-label">Référence</span><code style="font-size:.8rem"><?= h($p['sku']) ?></code></div>
<?php endif ?>
<?php if ($p['tags']): ?>
<div class="info-row"><span class="info-label">Tags</span><span style="text-align:right;color:var(--muted);font-size:.8rem"><?= h($p['tags']) ?></span></div>
<?php endif ?>
<div class="info-row"><span class="info-label">Créé le</span><span style="font-size:.82rem;color:var(--muted)"><?= date('d/m/Y', strtotime($p['created_at'])) ?></span></div>
<div class="info-row"><span class="info-label">Modifié</span><span style="font-size:.82rem;color:var(--muted)"><?= date('d/m/Y H:i', strtotime($p['updated_at'])) ?></span></div>
<?php if ($p['etsy_listing_id']): ?>
<div class="info-row"><span class="info-label">Etsy ID</span><span style="font-size:.82rem;font-family:monospace"><?= h($p['etsy_listing_id']) ?></span></div>
<?php endif ?>
</div>
<?php if ($etsyKey): ?>
<div class="etsy-panel">
<div class="etsy-logo"><i class="fab fa-etsy"></i> Etsy</div>
<?php if ($p['etsy_listing_id']): ?>
<div class="mt-2 etsy-status-ok"><i class="fas fa-check-circle"></i> Publié sur Etsy (ID <?= h($p['etsy_listing_id']) ?>)</div>
<?php elseif ($p['status']==='prêt'): ?>
<div class="mt-2" style="font-size:.82rem;color:var(--muted)">Prêt à publier — clique sur "Publier sur Etsy"</div>
<?php else: ?>
<div class="mt-2 etsy-status-ko"><i class="fas fa-clock"></i> Statut "prêt" requis pour publier</div>
<?php endif ?>
</div>
<?php else: ?>
<div class="etsy-panel">
<div class="etsy-logo"><i class="fab fa-etsy"></i> Etsy</div>
<div class="mt-2 etsy-status-ko"><i class="fas fa-key"></i> Clé API non configurée — <a href="/settings.php" style="color:var(--primary)">Réglages</a></div>
</div>
<?php endif ?>
<div style="display:flex;gap:.5rem">
<a href="/form.php?id=<?= $id ?>" class="btn btn-secondary" style="flex:1"><i class="fas fa-pen"></i> Modifier</a>
<button onclick="if(confirm('Supprimer ce produit ?'))deleteProduct(<?= $id ?>)" class="btn btn-danger"><i class="fas fa-trash"></i></button>
</div>
</div>
</div>
<script>
function switchMedia(thumb, type, src) {
document.querySelectorAll('.media-thumb').forEach(t=>t.classList.remove('active'));
thumb.classList.add('active');
const main = document.getElementById('mediaMain');
if (type === 'image') {
main.innerHTML = `<img src="${src}" alt="" style="width:100%;border-radius:10px;aspect-ratio:1;object-fit:cover">`;
} else {
main.innerHTML = `<video src="${src}" controls style="width:100%;border-radius:10px;aspect-ratio:1;object-fit:cover"></video>`;
}
}
async function deleteProduct(id) {
const r = await fetch('/api.php?action=product&id='+id, {method:'DELETE'});
const j = await r.json();
if (j.ok) window.location='/';
else toast(j.error||'Erreur','error');
}
async function publishEtsy(id) {
toast('Publication Etsy pas encore disponible (API en cours d\'intégration)');
}
</script>
<?php layout_foot(); ?>
+94
View File
@@ -0,0 +1,94 @@
<?php
require __DIR__ . '/includes/db.php';
require __DIR__ . '/includes/helpers.php';
require __DIR__ . '/includes/layout.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$keys = ['etsy_api_key','etsy_api_secret','etsy_shop_id','currency'];
$stmt = $pdo->prepare("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)");
foreach ($keys as $k) {
$stmt->execute([$k, trim($_POST[$k] ?? '')]);
}
header('Location: /settings.php?saved=1');
exit;
}
function cfg(PDO $pdo, string $key): string {
$v = $pdo->prepare("SELECT value FROM settings WHERE key=?");
$v->execute([$key]);
return $v->fetchColumn() ?: '';
}
layout_head('Réglages');
layout_sidebar('settings');
layout_main_open();
?>
<div class="page-header">
<div class="page-title"><i class="fas fa-gear" style="color:var(--primary);margin-right:.4rem"></i>Réglages</div>
</div>
<?php if ($_GET['saved'] ?? false): ?>
<div style="background:rgba(34,197,94,.1);border:1px solid rgba(34,197,94,.3);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;color:var(--green)"><i class="fas fa-check-circle"></i> Réglages enregistrés.</div>
<?php endif ?>
<form method="post">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:start">
<div style="display:flex;flex-direction:column;gap:1rem">
<!-- Etsy -->
<div class="card">
<div class="etsy-logo mb-3"><i class="fab fa-etsy"></i> Intégration Etsy</div>
<p style="font-size:.83rem;color:var(--muted);margin-bottom:1rem;line-height:1.6">
Pour obtenir une clé API Etsy, va sur
<strong>etsy.com/developers</strong>, crée une application, et
copie la clé et le secret ci-dessous.<br>
L'intégration permettra de publier tes fiches directement depuis PercoVitrine.
</p>
<div class="form-group">
<label>API Key (Keystring)</label>
<input type="text" name="etsy_api_key" value="<?= h(cfg($pdo,'etsy_api_key')) ?>" placeholder="Coller la clé API…" autocomplete="off">
</div>
<div class="form-group">
<label>API Secret</label>
<input type="password" name="etsy_api_secret" value="<?= h(cfg($pdo,'etsy_api_secret')) ?>" placeholder="Coller le secret…" autocomplete="off">
<div class="form-hint">Stocké localement, jamais transmis sauf à Etsy</div>
</div>
<div class="form-group" style="margin-bottom:0">
<label>Shop ID</label>
<input type="text" name="etsy_shop_id" value="<?= h(cfg($pdo,'etsy_shop_id')) ?>" placeholder="Nom ou ID de ta boutique Etsy">
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:1rem">
<!-- Général -->
<div class="card">
<div style="font-size:.85rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.75rem"><i class="fas fa-sliders" style="margin-right:.4rem"></i>Général</div>
<div class="form-group" style="margin-bottom:0">
<label>Devise</label>
<select name="currency">
<?php foreach (['EUR'=>'Euro (€)','USD'=>'Dollar ($)','GBP'=>'Livre sterling (£)','CHF'=>'Franc suisse (CHF)'] as $v=>$l): ?>
<option value="<?= $v ?>"<?= cfg($pdo,'currency')===$v?' selected':'' ?>><?= $l ?></option>
<?php endforeach ?>
</select>
</div>
</div>
<div class="card" style="background:rgba(45,163,242,.04);border-color:rgba(45,163,242,.15)">
<div style="font-size:.85rem;font-weight:700;margin-bottom:.5rem"><i class="fas fa-circle-info" style="color:var(--primary);margin-right:.4rem"></i>Roadmap intégrations</div>
<ul style="font-size:.82rem;color:var(--muted);padding-left:1.2rem;line-height:2">
<li><span style="color:var(--volt)">●</span> Etsy — en cours d'intégration</li>
<li><span style="color:var(--border)">●</span> Vinted — prévu</li>
<li><span style="color:var(--border)">●</span> Leboncoin — prévu</li>
<li><span style="color:var(--border)">●</span> WooCommerce — prévu</li>
</ul>
</div>
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> Enregistrer</button>
</div>
</div>
</form>
<?php layout_foot(); ?>