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
+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>';
}