before claude

This commit is contained in:
Gilles Soulier
2026-01-18 06:26:17 +01:00
parent dc19315e5d
commit 740c3d7516
60 changed files with 3815 additions and 354 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

5
webui/dist/assets/index-BURbFjJa.css vendored Normal file

File diff suppressed because one or more lines are too long

18
webui/dist/assets/index-ZvFbjZEA.js vendored Normal file

File diff suppressed because one or more lines are too long

5
webui/dist/favicon.svg vendored Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#3c3836" />
<circle cx="32" cy="32" r="18" fill="#fe8019" />
<path d="M18 34c6-6 22-6 28 0" fill="none" stroke="#282828" stroke-width="4" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 281 B

14
webui/dist/index.html vendored Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PriceWatch Web UI</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script type="module" crossorigin src="/assets/index-ZvFbjZEA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BURbFjJa.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 273 B

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 273 B

After

Width:  |  Height:  |  Size: 86 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 273 B

After

Width:  |  Height:  |  Size: 48 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 273 B

After

Width:  |  Height:  |  Size: 181 KiB

View File

@@ -0,0 +1,144 @@
<script setup lang="ts">
const props = defineProps<{
productId: number
compareIds: number[]
showSecondary?: boolean
}>()
const emit = defineEmits<{
(e: 'refresh'): void
(e: 'compare'): void
(e: 'edit'): void
(e: 'delete'): void
(e: 'open'): void
}>()
function handleRefresh(event: Event) {
event.stopPropagation()
emit('refresh')
}
function handleCompare(event: Event) {
event.stopPropagation()
emit('compare')
}
function handleEdit(event: Event) {
event.stopPropagation()
emit('edit')
}
function handleDelete(event: Event) {
event.stopPropagation()
emit('delete')
}
function handleOpen(event: Event) {
event.stopPropagation()
emit('open')
}
</script>
<template>
<div class="card-actions">
<button
class="card-actions__btn card-actions__btn--primary"
title="Rafraichir"
aria-label="Rafraichir le produit"
@click="handleRefresh"
>
<i class="fa-solid fa-rotate"></i>
</button>
<button
class="card-actions__btn"
title="Modifier"
aria-label="Modifier le produit"
@click="handleEdit"
>
<i class="fa-solid fa-pen"></i>
</button>
<button
class="card-actions__btn"
title="Supprimer"
aria-label="Supprimer le produit"
@click="handleDelete"
>
<i class="fa-solid fa-trash"></i>
</button>
<button
class="card-actions__btn"
title="Ouvrir"
aria-label="Ouvrir dans un nouvel onglet"
@click="handleOpen"
>
<i class="fa-solid fa-up-right-from-square"></i>
</button>
<button
v-if="props.showSecondary"
class="card-actions__btn"
:class="{ 'card-actions__btn--active': compareIds.includes(productId) }"
title="Comparer"
aria-label="Comparer le produit"
@click="handleCompare"
>
<i class="fa-solid" :class="compareIds.includes(productId) ? 'fa-square-check' : 'fa-code-compare'"></i>
</button>
</div>
</template>
<style scoped>
.card-actions {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 0 2px;
margin-top: auto;
}
.card-actions__btn {
width: 30px;
height: 30px;
border-radius: 9px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
font-size: 0.8rem;
}
.card-actions__btn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text);
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-1px);
}
.card-actions__btn--primary {
background: rgba(254, 128, 25, 0.18);
color: var(--accent);
border-color: rgba(254, 128, 25, 0.4);
}
.card-actions__btn--primary:hover {
transform: translateY(-1px);
box-shadow: 0 6px 14px rgba(254, 128, 25, 0.25);
}
.card-actions__btn--active {
background: rgba(184, 187, 38, 0.2);
color: var(--success);
border-color: rgba(184, 187, 38, 0.45);
}
.card-actions__btn--active:hover {
background: var(--success);
}
</style>

View File

@@ -78,11 +78,9 @@ const yBounds = computed(() => {
const values = validPoints.value.map((item) => item.v);
const rawMin = Math.min(...values);
const rawMax = Math.max(...values);
const delta = Math.max(rawMax - rawMin, 1);
const pad = delta * 0.05;
return {
min: rawMin - pad,
max: rawMax + pad,
min: rawMin,
max: rawMax,
};
});
@@ -132,21 +130,14 @@ const chartPoints = computed(() => {
const hasPoints = computed(() => chartPoints.value.length > 0);
const linePoints = computed(() => {
if (!chartPoints.value.length) {
if (chartPoints.value.length <= 1) {
return [];
}
if (chartPoints.value.length === 1) {
const point = chartPoints.value[0];
const endX = margins.left + chartDimensions.value.width;
return [
{ x: margins.left, y: point.y },
{ x: endX, y: point.y },
];
}
return chartPoints.value;
});
const polylinePoints = computed(() => linePoints.value.map((point) => `${point.x},${point.y}`).join(" "));
const showLine = computed(() => linePoints.value.length > 1);
const yTickValues = computed(() => {
const count = Math.max(2, props.yTicks);
@@ -256,6 +247,7 @@ const placeholderLabel = computed(() => "");
</text>
</g>
<polyline
v-if="showLine"
:points="polylinePoints"
stroke="currentColor"
fill="none"

View File

@@ -0,0 +1,319 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
price: number | null
currency: string
msrp?: number | null
discountAmount?: number | null
discountPercent?: number | null
discountText?: string | null
deltaLabel?: string | null
deltaLabelTitle?: string | null
stockStatus: string
stockText?: string | null
inStock?: boolean | null
reference?: string | null
url?: string | null
ratingValue?: number | null
ratingCount?: number | null
amazonChoice?: boolean | null
compact?: boolean
}>()
const formatPrice = (value: number | null, currency: string): string => {
if (value === null || value === undefined || !Number.isFinite(value)) {
return '—'
}
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: currency || 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value)
}
const formatShortPrice = (value: number | null, currency: string): string => {
if (value === null || value === undefined || !Number.isFinite(value)) {
return '—'
}
const numeric = Number(value)
const rounded = Math.round(numeric * 100) / 100
const centsValue = Math.round(rounded * 100) - Math.floor(rounded) * 100
const hasCents = centsValue !== 0
if ((currency || 'EUR') === 'EUR') {
const euros = Math.floor(rounded)
const cents = String(Math.abs(centsValue)).padStart(2, '0')
return hasCents ? `${euros}${cents}` : `${euros}`
}
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: currency || 'EUR',
minimumFractionDigits: hasCents ? 2 : 0,
maximumFractionDigits: hasCents ? 2 : 0,
}).format(rounded)
}
type PriceParts = {
euros: string
cents: string | null
symbol: string
raw: string | null
}
const formatPriceParts = (value: number | null, currency: string): PriceParts => {
if (value === null || value === undefined || !Number.isFinite(value)) {
return { euros: '', cents: null, symbol: '', raw: '—' }
}
const numeric = Number(value)
const rounded = Math.round(numeric * 100) / 100
const centsValue = Math.round(rounded * 100) - Math.floor(rounded) * 100
const hasCents = centsValue !== 0
if ((currency || 'EUR') !== 'EUR') {
return { euros: '', cents: null, symbol: '', raw: formatShortPrice(rounded, currency) }
}
return {
euros: String(Math.floor(rounded)),
cents: hasCents ? String(Math.abs(centsValue)).padStart(2, '0') : null,
symbol: '€',
raw: null,
}
}
const formattedPrice = computed(() => formatPriceParts(props.price, props.currency))
const formattedMsrp = computed(() => (props.msrp ? formatPriceParts(props.msrp, props.currency) : null))
const discountDisplay = computed(() => {
if (props.discountText?.trim()) {
return props.discountText.trim()
}
if (props.discountAmount === null || props.discountAmount === undefined ||
props.discountPercent === null || props.discountPercent === undefined) {
return null
}
const amount = formatShortPrice(props.discountAmount, props.currency)
const percent = Math.round(props.discountPercent)
return `-${percent}% (${amount})`
})
const stockLabel = computed(() => {
if (props.stockText?.trim()) {
return props.stockText.trim()
}
const map: Record<string, string> = {
in_stock: 'En stock',
out_of_stock: 'Rupture',
unknown: 'Inconnu',
error: 'Erreur',
}
return map[props.stockStatus] || props.stockStatus
})
const stockClass = computed(() => {
if (props.inStock === true) return 'text-[var(--success)]'
if (props.inStock === false) return 'text-[var(--danger)]'
if (props.stockStatus === 'in_stock') return 'text-[var(--success)]'
if (props.stockStatus === 'out_of_stock') return 'text-[var(--danger)]'
return 'text-[var(--muted)]'
})
const deltaDisplay = computed(() => {
if (props.deltaLabel && props.deltaLabel.trim()) {
return props.deltaLabel
}
return '—'
})
const deltaTitle = computed(() => {
if (props.deltaLabelTitle && props.deltaLabelTitle.trim()) {
return props.deltaLabelTitle
}
return 'Evol.'
})
const ratingDisplay = computed(() => {
if (props.ratingValue === null || props.ratingValue === undefined) {
return '—'
}
const value = props.ratingValue.toFixed(1).replace('.', ',')
if (props.ratingCount === null || props.ratingCount === undefined) {
return value
}
const count = new Intl.NumberFormat('fr-FR').format(props.ratingCount)
return `${value} (${count})`
})
const amazonChoiceDisplay = computed(() => {
if (props.amazonChoice === true) return 'Oui'
if (props.amazonChoice === false) return '—'
return '—'
})
</script>
<template>
<div class="price-block" :class="{ 'price-block--compact': compact }">
<div class="price-block__row price-block__row--main">
<span class="price-block__label">Actuel</span>
<span class="price-block__current">
<template v-if="formattedPrice.raw">{{ formattedPrice.raw }}</template>
<template v-else>
<span class="price-block__euros">{{ formattedPrice.euros }}</span>
<sup class="price-block__currency">{{ formattedPrice.symbol }}</sup>
<span v-if="formattedPrice.cents" class="price-block__cents">{{ formattedPrice.cents }}</span>
</template>
</span>
</div>
<div class="price-block__row">
<span class="price-block__label">Prix conseillé</span>
<span class="price-block__msrp">
<template v-if="!formattedMsrp"></template>
<template v-else-if="formattedMsrp.raw">{{ formattedMsrp.raw }}</template>
<template v-else>
<span class="price-block__euros">{{ formattedMsrp.euros }}</span>
<sup class="price-block__currency">{{ formattedMsrp.symbol }}</sup>
<span v-if="formattedMsrp.cents" class="price-block__cents">{{ formattedMsrp.cents }}</span>
</template>
</span>
</div>
<div class="price-block__row">
<span class="price-block__label">{{ deltaTitle }}</span>
<span>{{ deltaDisplay }}</span>
</div>
<div v-if="discountDisplay" class="price-block__row price-block__discount">
<span class="price-block__label">Réduction</span>
<span>{{ discountDisplay }}</span>
</div>
<div class="price-block__row price-block__stock" :class="stockClass">
<span class="price-block__label">Stock</span>
<span>{{ stockLabel }}</span>
</div>
<div class="price-block__row">
<span class="price-block__label">Note</span>
<span>{{ ratingDisplay }}</span>
</div>
<div class="price-block__row">
<span class="price-block__label">Choix Amazon</span>
<span>{{ amazonChoiceDisplay }}</span>
</div>
<div class="price-block__meta">
<span v-if="reference" class="price-block__ref">Ref: {{ reference }}</span>
<a
v-if="url"
class="price-block__link"
:href="url"
target="_blank"
rel="noreferrer"
@click.stop
>
Lien produit
</a>
</div>
</div>
</template>
<style scoped>
.price-block {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 100px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: var(--surface-2);
}
.price-block--compact {
gap: 4px;
}
.price-block__row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 0.75rem;
color: var(--text);
}
.price-block__row--main {
font-size: 0.8rem;
align-items: baseline;
}
.price-block__label {
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.3px;
font-size: 0.6rem;
min-width: 82px;
}
.price-block__current {
font-size: 1.05rem;
font-weight: 700;
}
.price-block__euros {
font-variant-numeric: tabular-nums;
}
.price-block__currency {
font-size: 0.65em;
vertical-align: super;
margin-left: 1px;
margin-right: 1px;
}
.price-block__cents {
font-size: 0.85em;
}
.price-block__msrp {
color: var(--muted);
text-decoration: line-through;
}
.price-block__discount {
font-weight: 600;
color: var(--success);
}
.price-block__stock {
font-weight: 600;
}
.price-block__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 6px;
font-size: 0.65rem;
}
.price-block__ref {
color: var(--muted);
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.price-block__link {
color: var(--accent);
text-decoration: none;
font-weight: 600;
}
.price-block__link:hover {
text-decoration: underline;
}
</style>

View File

@@ -119,7 +119,7 @@ const emit = defineEmits<{
}>();
const popupStyle = computed(() => ({
position: "fixed",
position: "fixed" as const,
top: `${props.position.top}px`,
left: `${props.position.left}px`,
width: "280px",

View File

@@ -0,0 +1,600 @@
<script setup lang="ts">
import { computed } from 'vue'
import MiniLineChart from './MiniLineChart.vue'
import PriceBlock from './PriceBlock.vue'
import CardActions from './CardActions.vue'
interface HistoryPoint {
t: number
v: number
}
interface HistorySnapshot {
points: HistoryPoint[]
min: number | null
max: number | null
delta: number | null
trendIcon: string
trendLabel: string
trendDeltaLabel: string
trendColor: string
lastTimestamp: number | null
}
interface Product {
id: number
storeId: string
title: string
url: string
price: number | null
currency: string
msrp: number | null
stockStatus: string
stockText?: string | null
inStock?: boolean | null
updatedAt: string
delta: number
discountAmount: number | null
discountPercent: number | null
discountText?: string | null
imageWebp?: string
imageJpg?: string
reference?: string
category?: string
type?: string
notes?: string
analysis?: string
ratingValue?: number | null
ratingCount?: number | null
amazonChoice?: boolean | null
}
const props = defineProps<{
product: Product
historyData: HistorySnapshot | null
compareIds: number[]
storeLogo?: string
storeLabel: string
storeInitials: string
chartPeriodLabel: string
imageMode: string
placeholderImage: string
}>()
const emit = defineEmits<{
(e: 'click'): void
(e: 'refresh'): void
(e: 'compare'): void
(e: 'edit'): void
(e: 'delete'): void
(e: 'open'): void
(e: 'hover', event: MouseEvent | FocusEvent): void
(e: 'leave'): void
}>()
const formatShortPrice = (value: number | null, currency: string): string => {
if (value === null || value === undefined || !Number.isFinite(value)) {
return '—'
}
const numeric = Number(value)
const rounded = Math.round(numeric * 100) / 100
const centsValue = Math.round(rounded * 100) - Math.floor(rounded) * 100
const hasCents = centsValue !== 0
if ((currency || 'EUR') === 'EUR') {
const euros = Math.floor(rounded)
const cents = String(Math.abs(centsValue)).padStart(2, '0')
return hasCents ? `${euros}${cents}` : `${euros}`
}
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: currency || 'EUR',
minimumFractionDigits: hasCents ? 2 : 0,
maximumFractionDigits: hasCents ? 2 : 0,
}).format(rounded)
}
const formatHistoryDateLabel = (value: number | string): string => {
let timestamp: number | null = null
if (typeof value === 'number') {
timestamp = value
} else if (typeof value === 'string') {
const parsed = Date.parse(value)
if (!Number.isNaN(parsed)) {
timestamp = parsed
}
}
if (timestamp === null) {
return typeof value === 'string' ? value : ''
}
const diffMs = Math.max(0, Date.now() - timestamp)
const hours = Math.max(0, Math.round(diffMs / 3_600_000))
if (hours < 36) {
return hours === 0 ? '0h' : `-${hours}h`
}
const days = Math.max(1, Math.round(hours / 24))
return `-${days}j`
}
const formatRelativeTimeAgo = (timestamp: number | null): string => {
if (timestamp === null || !Number.isFinite(timestamp)) {
return 'a l instant'
}
const diff = Date.now() - timestamp
if (diff < 60_000) {
return 'a l instant'
}
const minutes = Math.floor(diff / 60_000)
if (minutes < 60) {
return `il y a ${minutes} min`
}
const hours = Math.floor(diff / 3_600_000)
if (hours < 24) {
return `il y a ${hours} h`
}
const days = Math.floor(diff / 86_400_000)
return `il y a ${days} j`
}
const cardClasses = computed(() => ({
'product-card': true,
'product-card--accent': props.product.delta < 0,
}))
const imageUrl = computed(() => {
return props.product.imageJpg || props.product.imageWebp || props.placeholderImage
})
const hasImage = computed(() => {
return Boolean(props.product.imageWebp || props.product.imageJpg)
})
const lastUpdateLabel = computed(() => {
const timestamp = props.historyData?.lastTimestamp
if (timestamp) {
return formatRelativeTimeAgo(timestamp)
}
if (props.product.updatedAt) {
const parsed = Date.parse(props.product.updatedAt)
return Number.isNaN(parsed) ? props.product.updatedAt : formatRelativeTimeAgo(parsed)
}
return '—'
})
const historyDeltaLabel = computed(() => {
const value = props.historyData?.delta
if (value === null || value === undefined || !Number.isFinite(value)) {
return '—'
}
const numeric = Number(value)
const sign = numeric >= 0 ? '+' : ''
return `${sign}${numeric.toFixed(1)}%`
})
function handleClick() {
emit('click')
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
emit('click')
}
}
function handleHover(event: MouseEvent | FocusEvent) {
emit('hover', event)
}
function handleLeave() {
emit('leave')
}
</script>
<template>
<article
:class="cardClasses"
role="button"
tabindex="0"
@click="handleClick"
@keydown="handleKeydown"
@mouseenter="handleHover"
@mouseleave="handleLeave"
@focusin="handleHover"
@focusout="handleLeave"
>
<div class="product-card__top">
<div class="product-card__identity">
<div class="product-card__store-icon">
<img v-if="storeLogo" :src="storeLogo" alt="" />
<span v-else class="product-card__store-initials">{{ storeInitials }}</span>
</div>
<div class="product-card__identity-text">
<h3 class="product-card__title" :title="product.title">
{{ product.title }}
</h3>
<div class="product-card__store-name">{{ storeLabel }}</div>
</div>
</div>
<div class="product-card__reference">
{{ product.reference || '—' }}
</div>
</div>
<div class="product-card__layout">
<!-- Colonne gauche -->
<div class="product-card__left">
<div class="product-card__thumbnail">
<picture v-if="hasImage">
<source v-if="product.imageWebp" :srcset="product.imageWebp" type="image/webp" />
<source v-if="product.imageJpg" :srcset="product.imageJpg" type="image/jpeg" />
<img
:src="imageUrl"
class="product-card__image product-card__image--contain"
alt="Image produit"
loading="lazy"
/>
</picture>
<img
v-else
:src="placeholderImage"
class="product-card__image product-card__image--contain"
alt="Image indisponible"
loading="lazy"
/>
</div>
</div>
<!-- Zone centrale -->
<div class="product-card__main">
<PriceBlock
:price="product.price"
:currency="product.currency"
:msrp="product.msrp"
:discount-amount="product.discountAmount"
:discount-percent="product.discountPercent"
:discount-text="product.discountText"
:delta-label="historyDeltaLabel"
:delta-label-title="`Evol. ${chartPeriodLabel}`"
:stock-status="product.stockStatus"
:stock-text="product.stockText"
:in-stock="product.inStock"
:reference="product.reference"
:url="product.url"
:rating-value="product.ratingValue"
:rating-count="product.ratingCount"
:amazon-choice="product.amazonChoice"
/>
</div>
<!-- Zone basse -->
<div class="product-card__history-zone">
<div class="product-card__chart-container" :style="{ color: historyData?.trendColor || 'var(--muted)' }">
<MiniLineChart
v-if="historyData && historyData.points.length > 0"
:points="historyData.points"
:height="140"
:formatY="(value: number) => formatShortPrice(value, product.currency)"
:formatX="formatHistoryDateLabel"
:yTicks="3"
:xTicks="3"
/>
<div v-else class="product-card__no-history">
Pas d'historique
</div>
</div>
<div class="product-card__history-stats">
<div class="product-card__stat">
<span class="product-card__stat-label">Min</span>
<span class="product-card__stat-value">
{{ historyData && historyData.min !== null ? formatShortPrice(historyData.min, product.currency) : '' }}
</span>
</div>
<div class="product-card__stat">
<span class="product-card__stat-label">Max</span>
<span class="product-card__stat-value">
{{ historyData && historyData.max !== null ? formatShortPrice(historyData.max, product.currency) : '' }}
</span>
</div>
<div class="product-card__stat">
<span class="product-card__stat-label">Tendance</span>
<span
class="product-card__stat-value product-card__trend"
:style="{ color: historyData?.trendColor || 'var(--muted)' }"
>
{{ historyData?.trendIcon || '' }} {{ historyData?.trendLabel || '' }}
<span class="product-card__trend-delta">{{ historyData?.trendDeltaLabel || '' }}</span>
</span>
</div>
<div class="product-card__stat product-card__stat--update">
<span class="product-card__stat-label">Dernier scrap</span>
<span class="product-card__stat-value">
{{ lastUpdateLabel }}
</span>
</div>
</div>
</div>
</div>
<div class="product-card__footer">
<div class="product-card__meta">
<span>Categorie: {{ product.category || '' }}</span>
<span>Type: {{ product.type || '' }}</span>
</div>
<CardActions
:product-id="product.id"
:compare-ids="compareIds"
:show-secondary="false"
@refresh="emit('refresh')"
@compare="emit('compare')"
@edit="emit('edit')"
@delete="emit('delete')"
@open="emit('open')"
/>
</div>
</article>
</template>
<style scoped>
.product-card {
background: var(--surface);
border-radius: var(--radius);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 14px 26px var(--shadow);
display: flex;
flex-direction: column;
padding: 16px;
position: relative;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.product-card:hover,
.product-card:focus-within {
transform: translateY(-2px);
box-shadow: 0 16px 32px var(--shadow);
}
.product-card--accent {
border-color: rgba(254, 128, 25, 0.5);
box-shadow: 0 10px 30px rgba(254, 128, 25, 0.15);
}
/* Header: Identity */
.product-card__layout {
display: grid;
grid-template-columns: minmax(0, 220px) minmax(0, 1fr);
gap: 16px;
}
.product-card__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.product-card__reference {
font-size: 0.7rem;
font-family: var(--font-mono);
color: var(--muted);
text-align: right;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-card__left {
display: flex;
flex-direction: column;
gap: 12px;
}
.product-card__identity {
display: flex;
align-items: flex-start;
gap: 10px;
}
.product-card__store-icon {
width: 38px;
height: 38px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: var(--surface-2);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.product-card__store-icon img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 2px;
}
.product-card__store-initials {
font-size: 0.65rem;
font-weight: 600;
color: var(--muted);
}
.product-card__identity-text {
flex: 1;
min-width: 0;
}
.product-card__title {
font-size: 0.92rem;
font-weight: 600;
line-height: 1.3;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.product-card__store-name {
font-size: 0.7rem;
color: var(--muted);
margin-top: 2px;
}
/* Thumbnail */
.product-card__thumbnail {
width: 100%;
height: var(--pw-card-media-height, 140px);
border-radius: 10px;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.04), rgba(0, 0, 0, 0.15));
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.06);
}
.product-card__image {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
}
.product-card__image--contain {
object-fit: contain;
}
.product-card__image--cover {
object-fit: cover;
width: 100%;
height: 100%;
}
/* Main Zone */
.product-card__main {
display: flex;
flex-direction: column;
gap: 12px;
min-height: var(--pw-card-media-height, 140px);
align-items: flex-start;
}
.product-card__main :deep(.price-block) {
max-width: 260px;
width: 100%;
}
/* History Zone */
.product-card__history-zone {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 14px;
margin-top: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.product-card__chart-container {
height: 140px;
border-radius: 10px;
padding: 8px;
background: var(--surface-2);
border: 1px solid rgba(255, 255, 255, 0.04);
overflow: hidden;
}
.product-card__no-history {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: var(--muted);
opacity: 0.6;
}
.product-card__history-stats {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 0.7rem;
flex-wrap: wrap;
}
.product-card__stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.product-card__stat-label {
color: var(--muted);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.product-card__stat-value {
font-weight: 600;
font-family: var(--font-mono);
}
.product-card__trend {
display: flex;
align-items: center;
gap: 4px;
}
.product-card__trend-delta {
font-size: 0.65rem;
opacity: 0.8;
}
.product-card__stat--update {
min-width: 140px;
}
.product-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 8px;
margin-top: 10px;
}
.product-card__meta {
font-size: 0.7rem;
color: var(--muted);
display: flex;
gap: 12px;
}
@media (max-width: 900px) {
.product-card__layout {
grid-template-columns: 1fr;
}
.product-card__top {
flex-direction: column;
}
.product-card__reference {
text-align: left;
}
.product-card__footer {
flex-direction: column;
align-items: flex-start;
}
.product-card__history-zone {
margin-top: 0;
}
}
</style>

View File

@@ -0,0 +1,200 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{
notes?: string | null
analysis?: string | null
editable?: boolean
}>()
const emit = defineEmits<{
(e: 'update:notes', value: string): void
}>()
const isEditing = ref(false)
const editedNotes = ref(props.notes || '')
const hasContent = computed(() => {
return Boolean(props.notes?.trim() || props.analysis?.trim())
})
const displayText = computed(() => {
if (props.notes?.trim()) {
return props.notes
}
if (props.analysis?.trim()) {
return props.analysis
}
return 'Aucune note'
})
function startEdit() {
if (!props.editable) return
editedNotes.value = props.notes || ''
isEditing.value = true
}
function saveEdit() {
emit('update:notes', editedNotes.value)
isEditing.value = false
}
function cancelEdit() {
editedNotes.value = props.notes || ''
isEditing.value = false
}
</script>
<template>
<div class="product-summary" :class="{ 'product-summary--empty': !hasContent }">
<div class="product-summary__header">
<span class="product-summary__label">Résumé</span>
<button
v-if="editable && !isEditing"
class="product-summary__edit-btn"
title="Modifier"
@click="startEdit"
>
<i class="fa-solid fa-pen"></i>
</button>
</div>
<div v-if="isEditing" class="product-summary__edit">
<textarea
v-model="editedNotes"
class="product-summary__textarea"
placeholder="Ajouter des notes..."
rows="3"
></textarea>
<div class="product-summary__edit-actions">
<button class="product-summary__action-btn" @click="saveEdit">
<i class="fa-solid fa-check"></i>
</button>
<button class="product-summary__action-btn product-summary__action-btn--cancel" @click="cancelEdit">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
<div v-else class="product-summary__content">
{{ displayText }}
</div>
</div>
</template>
<style scoped>
.product-summary {
background: rgba(0, 0, 0, 0.18);
border-radius: 12px;
padding: 10px 12px;
border: 1px dashed rgba(255, 255, 255, 0.14);
}
.product-summary--empty {
opacity: 0.7;
}
.product-summary__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.product-summary__label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--muted);
}
.product-summary__edit-btn {
width: 22px;
height: 22px;
border-radius: 6px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
font-size: 0.65rem;
}
.product-summary__edit-btn:hover {
background: var(--accent);
color: #1b1b1b;
border-color: var(--accent);
}
.product-summary__content {
font-size: 0.8rem;
line-height: 1.4;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
}
.product-summary__edit {
display: flex;
flex-direction: column;
gap: 8px;
}
.product-summary__textarea {
width: 100%;
background: var(--surface);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 8px;
color: var(--text);
font-size: 0.8rem;
font-family: inherit;
resize: vertical;
min-height: 60px;
}
.product-summary__textarea:focus {
outline: 2px solid rgba(254, 128, 25, 0.4);
}
.product-summary__edit-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.product-summary__action-btn {
width: 26px;
height: 26px;
border-radius: 6px;
background: var(--accent);
border: none;
color: #1b1b1b;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
font-size: 0.7rem;
}
.product-summary__action-btn:hover {
transform: translateY(-1px);
}
.product-summary__action-btn--cancel {
background: var(--surface);
color: var(--muted);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.product-summary__action-btn--cancel:hover {
background: var(--danger);
color: white;
border-color: var(--danger);
}
</style>

View File

@@ -7,7 +7,8 @@
--pw-store-icon: 40px;
--pw-card-height-factor: 1;
--pw-card-mobile-height-factor: 1;
--pw-card-media-height: 160px;
--pw-card-media-height: 140px;
--pw-card-columns: 3;
}
.app-root {
@@ -138,14 +139,12 @@
background: var(--surface);
border-radius: var(--radius);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 16px 32px var(--shadow);
box-shadow: 0 12px 24px var(--shadow);
display: flex;
flex-direction: column;
gap: 12px;
min-height: calc(470px * var(--pw-card-height-factor, 1));
padding: 24px;
padding: 16px;
position: relative;
padding-bottom: 90px;
}
.card-thumbnail {
@@ -403,6 +402,25 @@
box-shadow: 0 10px 30px rgba(254, 128, 25, 0.2);
}
/* Stock status colors */
.status-in_stock,
.status-in-stock {
color: var(--success);
}
.status-out_of_stock,
.status-out-of-stock {
color: var(--danger);
}
.status-unknown {
color: var(--muted);
}
.status-error {
color: var(--danger);
}
.density-dense .card {
padding: 12px;
}
@@ -616,6 +634,36 @@
min-width: 280px;
}
.add-product-modal {
display: flex;
flex-direction: column;
max-height: 80vh;
overflow: hidden;
}
.add-product-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.add-product-modal__body {
padding: 16px 24px;
overflow-y: auto;
}
.add-product-modal__footer {
display: flex;
gap: 12px;
padding: 12px 24px 18px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: var(--surface);
position: sticky;
bottom: 0;
}
.image-toggle {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
@@ -636,6 +684,44 @@
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
}
.add-product-carousel {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(88px, 1fr);
gap: 8px;
overflow-x: auto;
padding-bottom: 6px;
scroll-snap-type: x mandatory;
}
.add-product-thumb {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: transparent;
padding: 4px;
scroll-snap-align: start;
cursor: pointer;
transition: border 0.15s ease, transform 0.15s ease;
}
.add-product-thumb:hover {
border-color: rgba(254, 128, 25, 0.6);
transform: translateY(-1px);
}
.add-product-thumb.selected {
border-color: rgba(254, 128, 25, 0.9);
box-shadow: 0 6px 16px rgba(254, 128, 25, 0.2);
}
.add-product-thumb__image {
width: 100%;
height: 72px;
object-fit: cover;
border-radius: 8px;
display: block;
}
.log-status-panel {
border-color: rgba(255, 255, 255, 0.1);
}
@@ -777,8 +863,8 @@
.product-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 32px;
grid-template-columns: repeat(var(--pw-card-columns, 3), minmax(0, 1fr));
gap: 24px;
}
@media (max-width: 1024px) {
@@ -790,6 +876,18 @@
}
}
@media (max-width: 1200px) {
.product-grid {
--pw-card-columns: min(var(--pw-card-columns, 3), 3);
}
}
@media (max-width: 900px) {
.product-grid {
--pw-card-columns: min(var(--pw-card-columns, 3), 2);
}
}
@media (max-width: 640px) {
.app-header .toolbar-text {
display: none;
@@ -800,6 +898,7 @@
}
.product-grid {
grid-template-columns: 1fr;
--pw-card-columns: 1;
}
.card {
min-height: calc(470px * var(--pw-card-mobile-height-factor, 1));

View File

@@ -18,12 +18,23 @@ export interface Product {
id: number
source: string
reference: string
asin: string | null
url: string
title: string | null
category: string | null
type: string | null
description: string | null
currency: string | null
msrp: number | null
rating_value: number | null
rating_count: number | null
amazon_choice: boolean | null
amazon_choice_label: string | null
discount_text: string | null
stock_text: string | null
in_stock: boolean | null
model_number: string | null
model_name: string | null
first_seen_at: string
last_updated_at: string
latest_price: number | null
@@ -31,6 +42,8 @@ export interface Product {
latest_stock_status: StockStatus | null
latest_fetched_at: string | null
images: string[]
main_image: string | null
gallery_images: string[]
specs: Record<string, string>
discount_amount: number | null
discount_percent: number | null
@@ -43,6 +56,7 @@ export interface ProductCreate {
url: string
title?: string | null
category?: string | null
type?: string | null
description?: string | null
currency?: string | null
msrp?: number | null
@@ -52,6 +66,7 @@ export interface ProductUpdate {
url?: string | null
title?: string | null
category?: string | null
type?: string | null
description?: string | null
currency?: string | null
msrp?: number | null
@@ -188,10 +203,23 @@ export interface ProductSnapshot {
currency: string | null
shipping_cost: number | null
stock_status: StockStatus | null
stock_text: string | null
in_stock: boolean | null
reference: string | null
asin: string | null
category: string | null
type: string | null
description: string | null
rating_value: number | null
rating_count: number | null
amazon_choice: boolean | null
amazon_choice_label: string | null
discount_text: string | null
model_number: string | null
model_name: string | null
images: string[]
main_image: string | null
gallery_images: string[]
specs: Record<string, string>
msrp: number | null
debug: DebugInfo

View File

@@ -46,6 +46,7 @@ export interface FilterChip {
// === Settings ===
export interface AppSettings {
cardRatio: number
cardColumns: number
imageHeight: number
imageMode: ImageMode
fontSize: number
@@ -87,12 +88,12 @@ export interface ProductMeta {
export type ProductMetaMap = Record<number, ProductMeta>
// === Scrape Log ===
export type LogLevel = 'debug' | 'info' | 'success' | 'warning' | 'error'
export type ScrapeLogLevel = 'debug' | 'info' | 'success' | 'warning' | 'error'
export interface ScrapeLogEntry {
id: number
time: string
level: LogLevel
level: ScrapeLogLevel
text: string
}
@@ -152,7 +153,7 @@ export type LogTab = 'frontend' | 'backend' | 'uvicorn'
export interface FrontendLog {
id: number
time: string
level: LogLevel
level: ScrapeLogLevel
message: string
}
@@ -164,6 +165,9 @@ export interface CardRatioPreset {
// === Constants (exportés pour réutilisation) ===
export const DEFAULT_CARD_RATIO = 1
export const DEFAULT_CARD_COLUMNS = 3
export const MIN_CARD_COLUMNS = 1
export const MAX_CARD_COLUMNS = 6
export const DEFAULT_IMAGE_HEIGHT = 160
export const CARD_HISTORY_LIMIT = 12
export const DEFAULT_LOG_DURATION = 2500
@@ -178,7 +182,7 @@ export const CARD_RATIO_PRESETS: CardRatioPreset[] = [
export const IMAGE_MODES: ImageMode[] = ['contain', 'cover']
export const LOG_ICONS: Record<LogLevel, string> = {
export const LOG_ICONS: Record<ScrapeLogLevel, string> = {
debug: '🔍',
info: '',
success: '✅',

View File

@@ -1,9 +1,9 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "ES2021",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"strict": true,
"noEmit": true,