Skip to content

Commit ad8aae0

Browse files
committed
Add product uploads and admin refinements
1 parent 12c02ab commit ad8aae0

10 files changed

Lines changed: 236 additions & 8 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: Deploy
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
workflow_dispatch:
8+
9+
concurrency:
10+
group: deploy-production
11+
cancel-in-progress: true
12+
13+
jobs:
14+
deploy:
15+
runs-on: ubuntu-latest
16+
timeout-minutes: 10
17+
18+
steps:
19+
- name: Checkout
20+
uses: actions/checkout@v4
21+
22+
- name: Configure SSH
23+
run: |
24+
mkdir -p ~/.ssh
25+
printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
26+
chmod 600 ~/.ssh/deploy_key
27+
ssh-keyscan -p "${{ secrets.DEPLOY_PORT || 22 }}" -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
28+
29+
- name: Upload application files
30+
run: |
31+
rsync -az --delete \
32+
--exclude '.git/' \
33+
--exclude '.github/' \
34+
--exclude '.env' \
35+
--exclude 'node_modules/' \
36+
--exclude 'storage/' \
37+
--exclude 'public/uploads/' \
38+
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.DEPLOY_PORT || 22 }}" \
39+
./ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}/"
40+
41+
- name: Install dependencies and restart
42+
run: |
43+
ssh -i ~/.ssh/deploy_key -p "${{ secrets.DEPLOY_PORT || 22 }}" "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" <<'EOF'
44+
set -euo pipefail
45+
cd "${{ secrets.DEPLOY_PATH }}"
46+
mkdir -p storage public/uploads/products
47+
npm ci --omit=dev
48+
sudo systemctl restart shopsite
49+
sleep 2
50+
curl -fsS http://127.0.0.1:3001/ >/dev/null
51+
EOF

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
node_modules/
22
.env
3+
.codex
34
storage/*.db
45
storage/*.db-*
6+
public/uploads/
57
*.log

package-lock.json

Lines changed: 72 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"ejs": "^3.1.10",
1515
"express": "^4.21.2",
1616
"express-session": "^1.18.1",
17+
"multer": "^2.1.1",
1718
"nodemailer": "^8.0.5",
1819
"stripe": "^17.6.0"
1920
}

public/scripts/main.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ async function ensureStripeClient() {
382382
throw new Error("Stripe n'a pas pu se charger.");
383383
}
384384

385-
stripeClient = window.Stripe(getStripeKey());
385+
stripeClient = window.Stripe(getStripeKey(), { locale: "fr" });
386386
return stripeClient;
387387
}
388388

public/styles/main.css

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,10 @@ button,
654654
align-items: center;
655655
}
656656

657+
.summary-card > .button-block {
658+
margin-top: 1rem;
659+
}
660+
657661
.product-meta {
658662
margin-bottom: 0.9rem;
659663
}
@@ -965,6 +969,10 @@ button,
965969
margin: 0.05rem 0 0;
966970
}
967971

972+
.product-admin-actions {
973+
margin: -0.1rem 0 0.25rem;
974+
}
975+
968976
.price-large {
969977
margin: -0.15rem 0 0;
970978
font-size: 2rem;
@@ -1454,7 +1462,7 @@ button,
14541462
.cart-item-actions {
14551463
flex-direction: column;
14561464
align-items: stretch;
1457-
gap: 0.65rem;
1465+
gap: 0.45rem;
14581466
}
14591467

14601468
.cart-item-actions form {

server.js

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const path = require("path");
44
const crypto = require("crypto");
55
const express = require("express");
66
const session = require("express-session");
7+
const multer = require("multer");
78
const nodemailer = require("nodemailer");
89
const Stripe = require("stripe");
910
const { verifyPassword } = require("./lib/auth");
@@ -50,6 +51,7 @@ const env = process.env;
5051
const app = express();
5152
const databasePath = path.join(__dirname, "storage", "shop.db");
5253
const db = initializeDatabase(databasePath, env);
54+
const productUploadDir = path.join(__dirname, "public", "uploads", "products");
5355
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null;
5456
const stripePublishableKey = env.STRIPE_PUBLISHABLE_KEY || "";
5557
const swissBitcoinPayApiUrl = (env.SWISS_BITCOIN_PAY_API_URL || "https://api.swiss-bitcoin-pay.ch").replace(/\/$/, "");
@@ -69,6 +71,35 @@ app.set("view engine", "ejs");
6971
app.set("views", path.join(__dirname, "views"));
7072
app.use("/static", express.static(path.join(__dirname, "public")));
7173

74+
const productImageUpload = multer({
75+
storage: multer.diskStorage({
76+
destination: (req, file, callback) => {
77+
callback(null, productUploadDir);
78+
},
79+
filename: (req, file, callback) => {
80+
const extensionByMimeType = {
81+
"image/jpeg": ".jpg",
82+
"image/png": ".png",
83+
"image/webp": ".webp",
84+
"image/gif": ".gif",
85+
};
86+
const extension = extensionByMimeType[file.mimetype] || ".img";
87+
callback(null, `${Date.now()}-${crypto.randomBytes(8).toString("hex")}${extension}`);
88+
},
89+
}),
90+
limits: {
91+
fileSize: 8 * 1024 * 1024,
92+
files: 13,
93+
},
94+
fileFilter: (req, file, callback) => {
95+
if (!["image/jpeg", "image/png", "image/webp", "image/gif"].includes(file.mimetype)) {
96+
return callback(new Error("Seules les images JPG, PNG, WebP ou GIF peuvent être importées."));
97+
}
98+
99+
callback(null, true);
100+
},
101+
});
102+
72103
function formatMoney(cents, currency = "CHF") {
73104
return new Intl.NumberFormat("fr-CH", {
74105
style: "currency",
@@ -397,6 +428,53 @@ function absoluteUrl(req, value) {
397428
return `${origin}${input.startsWith("/") ? "" : "/"}${input}`;
398429
}
399430

431+
function productUploadUrl(file) {
432+
if (!file?.filename) {
433+
return "";
434+
}
435+
436+
return `/static/uploads/products/${file.filename}`;
437+
}
438+
439+
function withProductUploads(req, res, next) {
440+
fs.mkdirSync(productUploadDir, { recursive: true });
441+
442+
productImageUpload.fields([
443+
{ name: "image_file", maxCount: 1 },
444+
{ name: "gallery_files", maxCount: 12 },
445+
])(req, res, (error) => {
446+
if (error) {
447+
setFlash(req, "error", error.message || "L'import des images a échoué.");
448+
return saveSessionAndRedirect(req, res, req.originalUrl);
449+
}
450+
451+
return next();
452+
});
453+
}
454+
455+
function productInputWithUploads(req) {
456+
const input = { ...req.body };
457+
const primaryUpload = productUploadUrl(req.files?.image_file?.[0]);
458+
const galleryUploads = (req.files?.gallery_files || []).map(productUploadUrl).filter(Boolean);
459+
const existingGalleryUrls = String(input.image_gallery_urls || "").trim();
460+
461+
if (primaryUpload) {
462+
input.image_url = primaryUpload;
463+
}
464+
465+
if (!input.image_url && galleryUploads.length) {
466+
input.image_url = galleryUploads.shift();
467+
}
468+
469+
if (galleryUploads.length) {
470+
input.image_gallery_urls = [existingGalleryUrls, ...galleryUploads]
471+
.filter(Boolean)
472+
.join("\n");
473+
}
474+
475+
return input;
476+
}
477+
400478
function setPublicApiHeaders(res) {
401479
res.set("Access-Control-Allow-Origin", "*");
402480
res.set("Access-Control-Allow-Methods", "GET, OPTIONS");
@@ -2780,8 +2858,8 @@ app.get("/admin/products/new", requireAdmin, (req, res) => {
27802858
});
27812859
});
27822860

2783-
app.post("/admin/products/new", requireAdmin, (req, res) => {
2784-
createProduct(db, req.body);
2861+
app.post("/admin/products/new", requireAdmin, withProductUploads, (req, res) => {
2862+
createProduct(db, productInputWithUploads(req));
27852863
setFlash(req, "success", "Produit créé.");
27862864
saveSessionAndRedirect(req, res, "/admin");
27872865
});
@@ -2799,8 +2877,8 @@ app.get("/admin/products/:id/edit", requireAdmin, (req, res) => {
27992877
});
28002878
});
28012879

2802-
app.post("/admin/products/:id/edit", requireAdmin, (req, res) => {
2803-
const product = updateProduct(db, Number.parseInt(req.params.id, 10), req.body);
2880+
app.post("/admin/products/:id/edit", requireAdmin, withProductUploads, (req, res) => {
2881+
const product = updateProduct(db, Number.parseInt(req.params.id, 10), productInputWithUploads(req));
28042882
if (!product) {
28052883
return res.status(404).render("not-found", { title: "Produit introuvable" });
28062884
}

views/admin/product-form.ejs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
<a href="/admin" class="button button-secondary">Retour</a>
1313
</div>
1414

15-
<form action="<%= formAction %>" method="post" class="admin-form-grid">
15+
<form action="<%= formAction %>" method="post" class="admin-form-grid" enctype="multipart/form-data">
1616
<label>
1717
Nom du produit
1818
<input type="text" name="name" value="<%= product?.name || '' %>" required>
@@ -33,12 +33,24 @@
3333
<input type="url" name="image_url" value="<%= product?.image_url || '' %>">
3434
</label>
3535

36+
<label>
37+
Importer l'image principale
38+
<input type="file" name="image_file" accept="image/*">
39+
<small>Remplace l'URL ci-dessus si une image est sélectionnée.</small>
40+
</label>
41+
3642
<label class="full-span">
3743
Galerie photos
3844
<textarea name="image_gallery_urls" rows="5" placeholder="https://.../photo-2.jpg&#10;https://.../photo-3.jpg"><%= product?.image_gallery_text || '' %></textarea>
3945
<small>Une URL par ligne. L'image principale ci-dessus reste la photo d'ouverture.</small>
4046
</label>
4147
48+
<label class="full-span">
49+
Importer des photos de galerie
50+
<input type="file" name="gallery_files" accept="image/*" multiple>
51+
<small>Les images importées seront ajoutées à la galerie existante.</small>
52+
</label>
53+
4254
<label class="full-span">
4355
Résumé court
4456
<input type="text" name="short_description" value="<%= product?.short_description || '' %>" required>

views/checkout.ejs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,6 @@
196196
<div class="stripe-card-panel">
197197
<div class="stripe-card-copy">
198198
<strong>Paiement sécurisé par Stripe</strong>
199-
<p>Saisissez votre carte directement ici, sans quitter la page.</p>
200199
</div>
201200
<div id="stripe-payment-message" class="stripe-payment-message" hidden></div>
202201
<div id="stripe-payment-element" class="stripe-payment-element">

0 commit comments

Comments
 (0)