From a1b3f6da986ad1a2a7cefcd5dea6f35531367728 Mon Sep 17 00:00:00 2001 From: perco Date: Sat, 27 Jun 2026 10:34:47 +0200 Subject: [PATCH] =?UTF-8?q?Init=20PercoVitrine=20=E2=80=94=20app=20de=20ge?= =?UTF-8?q?stion=20de=20fiches=20produits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + .htaccess | 10 ++ Dockerfile | 14 +++ api.php | 80 ++++++++++++++ assets/style.css | 143 +++++++++++++++++++++++++ categories.php | 119 +++++++++++++++++++++ docker-compose.yml | 24 +++++ docker/apache.conf | 16 +++ file.php | 26 +++++ form.php | 244 +++++++++++++++++++++++++++++++++++++++++++ includes/db.php | 51 +++++++++ includes/helpers.php | 50 +++++++++ includes/layout.php | 37 +++++++ index.php | 94 +++++++++++++++++ produit.php | 153 +++++++++++++++++++++++++++ settings.php | 94 +++++++++++++++++ 16 files changed, 1158 insertions(+) create mode 100644 .gitignore create mode 100644 .htaccess create mode 100644 Dockerfile create mode 100644 api.php create mode 100644 assets/style.css create mode 100644 categories.php create mode 100644 docker-compose.yml create mode 100644 docker/apache.conf create mode 100644 file.php create mode 100644 form.php create mode 100644 includes/db.php create mode 100644 includes/helpers.php create mode 100644 includes/layout.php create mode 100644 index.php create mode 100644 produit.php create mode 100644 settings.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8c68de6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +data/ +*.db +*.sqlite diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..cd0a0e7 --- /dev/null +++ b/.htaccess @@ -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] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4196178 --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/api.php b/api.php new file mode 100644 index 0000000..d6aba89 --- /dev/null +++ b/api.php @@ -0,0 +1,80 @@ +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); diff --git a/assets/style.css b/assets/style.css new file mode 100644 index 0000000..f8d8e1e --- /dev/null +++ b/assets/style.css @@ -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}} diff --git a/categories.php b/categories.php new file mode 100644 index 0000000..ce724ce --- /dev/null +++ b/categories.php @@ -0,0 +1,119 @@ +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(); +?> + + + +
+ + +

Aucune catégorie.
Crée-en une pour organiser tes produits.

+ +
+ + + + + + + + + + + +
NomProduits
+
+ + +
+ + + +
+ + + +
+
+
+ + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3e1da5d --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docker/apache.conf b/docker/apache.conf new file mode 100644 index 0000000..93d3c0b --- /dev/null +++ b/docker/apache.conf @@ -0,0 +1,16 @@ + + DocumentRoot /var/www/html + DirectoryIndex index.php + + + AllowOverride All + Require all granted + + + + Require all denied + + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + diff --git a/file.php b/file.php new file mode 100644 index 0000000..b6e1470 --- /dev/null +++ b/file.php @@ -0,0 +1,26 @@ + '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); diff --git a/form.php b/form.php new file mode 100644 index 0000000..b2c2d62 --- /dev/null +++ b/form.php @@ -0,0 +1,244 @@ +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(); +?> + + + +
+ +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
Photos & vidéos
+ + +
+ +
+ + + + + + + +
+ + + +
+ +
+

Cocher un média pour le supprimer à la sauvegarde

+ + +
+ + + Cliquer ou glisser des images / vidéos
+ JPG, PNG, WEBP, AVIF, GIF, MP4, WEBM, MOV +
+
+
+ +
+ + +
+ +
+
Prix
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ +
+
Organisation
+
+ + +
+
+ + +
Gérer les catégories dans Catégories
+
+
+ + +
+
+ + +
Les tags seront utilisés lors de la publication Etsy
+
+
+ + +
+ +
Publié — ID
+
+ + +
+ + +
+ +
+
+ +
+ + + diff --git a/includes/db.php b/includes/db.php new file mode 100644 index 0000000..c716c4a --- /dev/null +++ b/includes/db.php @@ -0,0 +1,51 @@ + 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 '' +); +"); diff --git a/includes/helpers.php b/includes/helpers.php new file mode 100644 index 0000000..66a14d1 --- /dev/null +++ b/includes/helpers.php @@ -0,0 +1,50 @@ + 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 '' . h($b['label']) . ''; +} diff --git a/includes/layout.php b/includes/layout.php new file mode 100644 index 0000000..652befa --- /dev/null +++ b/includes/layout.php @@ -0,0 +1,37 @@ + +' . htmlspecialchars($title) . ' — PercoVitrine + + + + +
'; +} + +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 ''; +} + +function layout_main_open(): void { + echo '
'; +} + +function layout_foot(): void { + echo '
+'; +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..d31349f --- /dev/null +++ b/index.php @@ -0,0 +1,94 @@ +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(); +?> + + + + +
+
+ + '; ?> +
+ Tous + Brouillons + Prêts + Publiés + + + +
+ + +

Aucun produit pour l'instant.

Créer mon premier produit
+ +
+ +
+ + + +
+ +
+
+
+
Réf :
+
+ + +
+
+
+ + + +
+
+ +
+ + + + + diff --git a/produit.php b/produit.php new file mode 100644 index 0000000..9641d02 --- /dev/null +++ b/produit.php @@ -0,0 +1,153 @@ +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(); +?> + + + +
+ + +
+ +
+ + + + + + + +
+ +
+ 1): ?> +
+ $m): ?> +
+ + + +
+ +
+ +
+ + + +
+
Description
+
+
+ +
+ + +
+
+
+ 0): ?> +
Frais de port :
+ +
Livraison gratuite
+ +
+
+ +
+ +
Catégorie
+ + +
Référence
+ + +
Tags
+ +
Créé le
+
Modifié
+ +
Etsy ID
+ +
+ + +
+ + +
Publié sur Etsy (ID )
+ +
Prêt à publier — clique sur "Publier sur Etsy"
+ +
Statut "prêt" requis pour publier
+ +
+ +
+ +
Clé API non configurée — Réglages
+
+ + +
+ Modifier + +
+
+ +
+ + + diff --git a/settings.php b/settings.php new file mode 100644 index 0000000..0a7db08 --- /dev/null +++ b/settings.php @@ -0,0 +1,94 @@ +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(); +?> + + + + +
Réglages enregistrés.
+ + +
+ +
+ +
+ +
+ +

+ Pour obtenir une clé API Etsy, va sur + etsy.com/developers, crée une application, et + copie la clé et le secret ci-dessous.
+ L'intégration permettra de publier tes fiches directement depuis PercoVitrine. +

+
+ + +
+
+ + +
Stocké localement, jamais transmis sauf à Etsy
+
+
+ + +
+
+
+ +
+ +
+
Général
+
+ + +
+
+ +
+
Roadmap intégrations
+
    +
  • Etsy — en cours d'intégration
  • +
  • Vinted — prévu
  • +
  • Leboncoin — prévu
  • +
  • WooCommerce — prévu
  • +
+
+ + +
+ +
+
+