Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 3 additions & 11 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,11 @@ RUN --mount=type=cache,target=/var/cache/apk \
apk add --update-cache --cache-dir /var/cache/apk python3 make g++

COPY package.json package-lock.json ./
# sharp prebuilds need x86-64-v2 (SSE4.2+). Hosts like Intel Atom N2800 SIGILL
# in libvips. Keep @img/sharp-wasm32 (declared dep) and strip native @img binaries
# so sharp cannot load them. Do NOT use `npm install --cpu=wasm32` (prunes lightningcss).
# Strip sharp if pulled in as an optional transitive of Next.js — native/WASM
# sharp crashes on CPUs without x86-64-v2 / Wasm SIMD (e.g. Intel Atom N2800).
RUN --mount=type=cache,target=/root/.npm \
HUSKY=0 npm ci --prefer-offline --no-audit \
&& find node_modules/@img -mindepth 1 -maxdepth 1 -type d \( \
-name 'sharp-linux*' -o -name 'sharp-libvips-*' \
\) -exec rm -rf {} + \
&& test -f node_modules/@img/sharp-wasm32/lib/sharp-wasm32-0.35.4.node.wasm
&& rm -rf node_modules/sharp node_modules/@img

# ── Étape 2 : build ───────────────────────────────────────────────────────────
FROM deps AS builder
Expand Down Expand Up @@ -46,12 +42,8 @@ RUN addgroup --system --gid 1111 hobbyhoops \
COPY --from=builder /app/public ./public
COPY --from=builder --chown=hobbyhoops:hobbyhoops /app/.next/standalone ./
COPY --from=builder --chown=hobbyhoops:hobbyhoops /app/.next/static ./.next/static
# Standalone tracing may omit dynamically loaded .wasm; force-copy sharp WASM runtime.
COPY --from=builder --chown=hobbyhoops:hobbyhoops /app/node_modules/@img/sharp-wasm32 ./node_modules/@img/sharp-wasm32
COPY --from=builder --chown=hobbyhoops:hobbyhoops /app/node_modules/@emnapi ./node_modules/@emnapi

RUN mkdir -p /app/data && chown hobbyhoops:hobbyhoops /app/data
RUN test -f /app/node_modules/@img/sharp-wasm32/lib/sharp-wasm32-0.35.4.node.wasm

COPY --chmod=755 scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
COPY --chmod=755 scripts/docker-ensure-db.mjs /app/scripts/docker-ensure-db.mjs
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ npm run clean # removes .next, TypeScript caches, etc.
Everything is stored in **`data/`** (gitignored), mounted as `/app/data` in Docker:

- **`data/hobbyhoops.db`** — SQLite (collection, references, accounts, sessions, rate limiting) via `better-sqlite3`
- **`data/card-photos/`** — card front/back images (WebP), uploaded from Admin / receive flow
- **`data/card-photos/`** — card front/back images (JPEG / PNG / GIF / WebP as uploaded), from Admin / receive flow

This file and its WAL journals are **local** and must **never** be committed. Card photos live next to the database on the same volume — back up `data/` as a whole if you want images included.

Expand Down Expand Up @@ -115,7 +115,7 @@ This complements the SQLite backup (`hobbyhoops.db`): CSV is ideal for spreadshe

The image starts with an **empty collection**: only a writable `data/` directory is required (the SQLite database is created there automatically). The container runs as the **`hobbyhoops`** system user (UID/GID **1111**). Mount `data/` at `/app/data` and make it writable by that user.

Card-photo processing uses **sharp via WebAssembly** in the Docker image (not the native libvips prebuild). Native sharp/libvips binaries require **x86-64-v2** (SSE4.2+) and crash with `SIGILL` / exit `132` on older CPUs such as Intel Atom N2800.
Card photos are stored **as uploaded** (JPEG / PNG / GIF / WebP) with no native image library. The app deliberately avoids `sharp` so it runs on older CPUs such as Intel Atom N2800 (no x86-64-v2 / Wasm SIMD).

The application listens on **`127.0.0.1:3000`** (not exposed on all interfaces). In production, place a reverse proxy (e.g. Apache) in front of the host and proxy to that address.

Expand Down
4 changes: 2 additions & 2 deletions docs/csv-import-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ id,player,team,year,brand,set,variation,autograph,memorabilia,rookie,tradable,wn
| `opening_date` | Non | Date d’ajout à la collection | `15/03/2024` |
| `protection` | Non | Protection (toploader, etc.) | `Penny sleeve` |
| `storage` | Non | Lieu de rangement | `Boîte A` |
| `photo_front` | Non | Recto : chemin local `/api/card-photos/…` ou URL HTTPS / data URI | `/api/card-photos/card-0001/front.webp` |
| `photo_back` | Non | Verso : idem | `/api/card-photos/card-0001/back.webp` |
| `photo_front` | Non | Recto : chemin local `/api/card-photos/…` ou URL HTTPS / data URI | `/api/card-photos/card-0001/front.jpg` |
| `photo_back` | Non | Verso : idem | `/api/card-photos/card-0001/back.png` |

### Alias d’en-tête (français)

Expand Down
10 changes: 1 addition & 9 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,7 @@ const projectRoot = path.dirname(fileURLToPath(import.meta.url));
const nextConfig: NextConfig = {
output: "standalone",
poweredByHeader: false,
serverExternalPackages: ["better-sqlite3", "sharp"],
// Standalone tracing often omits sharp's dynamically loaded .wasm / natives.
outputFileTracingIncludes: {
"/**": [
"./node_modules/sharp/**/*",
"./node_modules/@img/sharp-wasm32/**/*",
"./node_modules/@emnapi/**/*",
],
},
serverExternalPackages: ["better-sqlite3"],
turbopack: {
root: projectRoot,
},
Expand Down
8 changes: 6 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 2 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,18 @@
},
"dependencies": {
"@base-ui/react": "^1.8.0",
"@img/sharp-wasm32": "0.35.4",
"@tanstack/react-table": "^9.1.2",
"better-sqlite3": "^13.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"csv-parse": "^7.0.2",
"next": "16.3.4",
"lucide-react": "^1.42.0",
"next": "16.3.4",
"pino": "^10.3.1",
"react": "19.2.8",
"react-dom": "19.2.8",
"recharts": "^3.10.1",
"server-only": "^0.0.1",
"sharp": "0.35.4",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.5.4"
Expand Down Expand Up @@ -74,7 +72,6 @@
},
"overrides": {
"nanoid": "^3.3.18",
"postcss": "8.5.23",
"sharp": "0.35.4"
"postcss": "8.5.23"
}
}
16 changes: 14 additions & 2 deletions scripts/generate-pwa-icons.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* Generates raster PNG icons required for PWA installation on Android and iOS.
* Uses sharp (bundled with Next.js) to rasterise src/app/icon.svg.
* Requires a one-off `sharp` install on a modern CPU (not shipped in production):
* npm install --no-save sharp && npm run generate:icons
*
* Outputs:
* public/icons/icon-192x192.png – standard manifest icon
Expand All @@ -9,13 +10,24 @@
* public/icons/apple-touch-icon.png – 180×180 for iOS
*/

import sharp from "sharp";
import { mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { getLogger } from "./lib/logger.mjs";

const log = getLogger("generate-pwa-icons");

let sharp;
try {
sharp = (await import("sharp")).default;
} catch {
log.error({
msg: "sharp is not installed — required only for this maintainer script",
hint: "npm install --no-save sharp && npm run generate:icons",
});
process.exit(1);
}

const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const iconSvg = join(root, "src/app/icon.svg");
const outputDir = join(root, "public/icons");
Expand Down
35 changes: 31 additions & 4 deletions src/lib/card-photo-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,18 @@ export type CardPhotoSide = (typeof CARD_PHOTO_SIDES)[number];
/** Limite par fichier (photos téléphone / scanner). */
export const CARD_PHOTO_MAX_BYTES = 15 * 1024 * 1024;

export const CARD_PHOTO_OUTPUT_EXT = ".webp";
/** Formats stockés tels quels (pas de ré-encodage — compatible CPU sans SSE4.2 / Wasm SIMD). */
export const STOREABLE_CARD_PHOTO_MIME = {
"image/jpeg": { ext: ".jpg", contentType: "image/jpeg" },
"image/png": { ext: ".png", contentType: "image/png" },
"image/gif": { ext: ".gif", contentType: "image/gif" },
"image/webp": { ext: ".webp", contentType: "image/webp" },
} as const;

export type StoreableCardPhotoMime = keyof typeof STOREABLE_CARD_PHOTO_MIME;

const LOCAL_PHOTO_PATH_RE =
/^\/api\/card-photos\/[^/]+\/(front|back)\.(webp|jpe?g|png|gif)$/i;

/** MIME acceptés à l’upload (formats image courants ; SVG exclu). */
export const ACCEPTED_IMAGE_MIME_TYPES = [
Expand All @@ -26,11 +37,16 @@ export function isCardPhotoSide(value: string): value is CardPhotoSide {
}

export function isLocalCardPhotoUrl(value: string): boolean {
return /^\/api\/card-photos\/[^/]+\/(front|back)\.webp$/i.test(value);
return LOCAL_PHOTO_PATH_RE.test(value);
}

export function publicCardPhotoUrl(cardId: string, side: CardPhotoSide): string {
return `/api/card-photos/${encodeURIComponent(cardId)}/${side}${CARD_PHOTO_OUTPUT_EXT}`;
export function publicCardPhotoUrl(
cardId: string,
side: CardPhotoSide,
ext: string
): string {
const normalized = ext.startsWith(".") ? ext.toLowerCase() : `.${ext.toLowerCase()}`;
return `/api/card-photos/${encodeURIComponent(cardId)}/${side}${normalized}`;
}

export function cardPhotoFileInputAccept(): string {
Expand All @@ -51,3 +67,14 @@ export function cardPhotoFileInputAccept(): string {
".ico",
].join(",");
}

export function contentTypeForCardPhotoFilename(filename: string): string | null {
const ext = filename.includes(".")
? `.${filename.split(".").pop()!.toLowerCase()}`
: "";
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
if (ext === ".png") return "image/png";
if (ext === ".gif") return "image/gif";
if (ext === ".webp") return "image/webp";
return null;
}
10 changes: 5 additions & 5 deletions src/lib/card-photo-storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe("card-photo-storage", () => {
}
});

it("saves a webp under data/card-photos and reads it back", async () => {
it("saves a png under data/card-photos and reads it back", async () => {
const saved = await saveCardPhoto({
cardId: "card-0001",
side: "front",
Expand All @@ -51,15 +51,15 @@ describe("card-photo-storage", () => {
expect(saved.ok).toBe(true);
if (!saved.ok) return;

expect(saved.url).toBe("/api/card-photos/card-0001/front.webp");
expect(saved.url).toBe("/api/card-photos/card-0001/front.png");
expect(
fs.existsSync(path.join(getCardPhotosRoot(), "card-0001", "front.webp"))
fs.existsSync(path.join(getCardPhotosRoot(), "card-0001", "front.png"))
).toBe(true);

const read = readCardPhotoFile("card-0001", "front.webp");
const read = readCardPhotoFile("card-0001", "front.png");
expect(read.ok).toBe(true);
if (!read.ok) return;
expect(read.contentType).toBe("image/webp");
expect(read.contentType).toBe("image/png");
expect(read.buffer.length).toBeGreaterThan(0);
});

Expand Down
Loading