[Feature] Spoolbuddy spool detail card and UI improvements (#866)

[Feature] Spoolbuddy spool detail card and UI improvements (#866)
This commit is contained in:
Keybored
2026-04-04 15:34:09 +02:00
committed by GitHub
parent 2d5dd1b31f
commit 4a2a0b1a10
4 changed files with 400 additions and 154 deletions
@@ -0,0 +1,283 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Check, AlertTriangle, RefreshCw } from 'lucide-react';
import type { InventorySpool } from '../../api/client';
import { spoolbuddyApi, api } from '../../api/client';
import { SpoolIcon } from './SpoolIcon';
const DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';
function getDefaultCoreWeight(): number {
try {
const stored = localStorage.getItem(DEFAULT_CORE_WEIGHT_KEY);
if (stored) {
const weight = parseInt(stored, 10);
if (weight >= 0 && weight <= 500) return weight;
}
} catch {
// Ignore errors
}
return 250;
}
interface InventorySpoolInfoCardProps {
spool: InventorySpool;
liveScaleWeight: number | null;
persistedGrossWeight?: number | null;
onClose?: () => void;
onSyncWeight?: () => void;
onAssignToAms?: () => void;
className?: string;
}
export function InventorySpoolInfoCard({
spool,
liveScaleWeight,
persistedGrossWeight,
onClose,
onSyncWeight,
onAssignToAms,
className,
}: InventorySpoolInfoCardProps) {
const { t } = useTranslation();
const [syncing, setSyncing] = useState(false);
const [synced, setSynced] = useState(false);
const [syncedGrossWeight, setSyncedGrossWeight] = useState<number | null>(null);
// Fetch k_profiles if not already present in the spool object
const { data: fetchedKProfiles } = useQuery({
queryKey: ['spool-k-profiles', spool.id],
queryFn: () => api.getSpoolKProfiles(spool.id),
// Inventory list payloads may omit k_profiles, so lazily fetch when missing.
enabled: !spool.k_profiles || spool.k_profiles.length === 0,
staleTime: 5 * 60 * 1000,
});
// Use fetched k_profiles if available, otherwise use the ones from the spool object
const kProfiles = (spool.k_profiles && spool.k_profiles.length > 0) ? spool.k_profiles : fetchedKProfiles;
const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
const coreWeight = (spool.core_weight && spool.core_weight > 0)
? spool.core_weight
: getDefaultCoreWeight();
const grossWeightFromScale = liveScaleWeight !== null
? Math.round(Math.max(0, liveScaleWeight))
: null;
// Inventory scenario: prefer the most recently synced value in this modal session.
const displayedGrossWeight = syncedGrossWeight ?? (
persistedGrossWeight !== undefined
? (persistedGrossWeight !== null ? Math.round(Math.max(0, persistedGrossWeight)) : null)
: grossWeightFromScale
);
const inventoryRemaining = Math.round(Math.max(0,
(spool.label_weight || 0) - (spool.weight_used || 0)
));
// Use live scale for remaining/fill only when scale has a meaningful reading.
const minDynamicScaleReading = 10;
const useDynamicRemaining = grossWeightFromScale !== null
&& grossWeightFromScale >= minDynamicScaleReading;
const remaining = useDynamicRemaining
? Math.round(Math.max(0, grossWeightFromScale - coreWeight))
: inventoryRemaining;
const labelWeight = Math.round(spool.label_weight || 1000);
const fillPercent = labelWeight > 0 ? Math.min(100, Math.round((remaining / labelWeight) * 100)) : null;
const fillColor = fillPercent !== null
? (fillPercent > 50 ? '#22c55e' : fillPercent > 20 ? '#eab308' : '#ef4444')
: '#808080';
const netWeight = Math.max(0,
(spool.label_weight || 0) - (spool.weight_used || 0)
);
const calculatedWeight = netWeight + coreWeight;
const difference = grossWeightFromScale !== null ? grossWeightFromScale - calculatedWeight : null;
const isMatch = difference !== null ? Math.abs(difference) <= 50 : null;
// Inventory fallback so gross is always populated across spools.
const inventoryDerivedGrossWeight = Math.round(calculatedWeight);
const resolvedGrossWeight = displayedGrossWeight ?? inventoryDerivedGrossWeight;
const nozzleTempRange = (spool.nozzle_temp_min != null && spool.nozzle_temp_max != null)
? `${spool.nozzle_temp_min}-${spool.nozzle_temp_max}\u00B0C`
: null;
const slicerPreset = spool.slicer_filament_name || spool.slicer_filament || null;
const note = spool.note?.trim() || null;
const kFactorSummary = (kProfiles && kProfiles.length > 0)
? Array.from(new Set(kProfiles.map(kp => kp.k_value.toFixed(3)))).join(', ')
: null;
const handleSyncWeight = async () => {
if (liveScaleWeight === null) return;
const roundedLiveWeight = Math.round(Math.max(0, liveScaleWeight));
setSyncing(true);
try {
await spoolbuddyApi.updateSpoolWeight(spool.id, roundedLiveWeight);
setSyncedGrossWeight(roundedLiveWeight);
setSynced(true);
onSyncWeight?.();
setTimeout(() => setSynced(false), 3000);
} catch (e) {
console.error('Failed to sync weight:', e);
} finally {
setSyncing(false);
}
};
return (
<div className={`flex flex-col items-center space-y-4 max-w-md ${className ?? ''}`}>
<div className="flex items-start gap-5">
<div className="relative shrink-0">
<SpoolIcon color={colorHex} isEmpty={false} size={100} />
{fillPercent !== null && (
<div
className="absolute -bottom-2 -right-2 px-2 py-0.5 rounded-full text-xs font-bold text-white shadow-lg"
style={{ backgroundColor: fillColor }}
>
{fillPercent}%
</div>
)}
</div>
<div className="flex-1 min-w-0 pt-1">
<h3 className="text-lg font-semibold text-zinc-100">
{spool.color_name || 'Unknown color'}
</h3>
<p className="text-sm text-zinc-400">
{spool.brand} &bull; {spool.material}
{spool.subtype && ` ${spool.subtype}`}
</p>
<div className="mt-3">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold font-mono text-zinc-100">{remaining}g</span>
<span className="text-sm text-zinc-500">/ {labelWeight}g</span>
</div>
<p className="text-xs text-zinc-500 mt-0.5">{t('spoolbuddy.spool.remaining', 'Remaining')}</p>
<div className="mt-2 max-w-xs">
<div className="h-2 bg-zinc-700 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${fillPercent ?? 0}%`, backgroundColor: fillColor }}
/>
</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm bg-zinc-800 rounded-lg p-4 w-full">
<div className="flex justify-between">
<span className="text-zinc-500">{t('spoolbuddy.dashboard.grossWeight', 'Gross weight')}</span>
<span className="font-mono text-zinc-300">{resolvedGrossWeight}g</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">{t('spoolbuddy.spool.coreWeight', 'Core')}</span>
<span className="font-mono text-zinc-300">{coreWeight}g</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">{t('spoolbuddy.dashboard.spoolSize', 'Spool size')}</span>
<span className="font-mono text-zinc-300">{labelWeight}g</span>
</div>
<div className="flex justify-between items-center">
<span className="text-zinc-500">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>
{grossWeightFromScale !== null ? (
<span className={`flex items-center gap-1 font-mono ${isMatch ? 'text-green-500' : 'text-yellow-500'}`}>
{grossWeightFromScale}g
{isMatch ? (
<Check className="w-3.5 h-3.5" />
) : (
<>
<AlertTriangle className="w-3.5 h-3.5" />
<button
onClick={handleSyncWeight}
className="p-1 hover:bg-green-500/20 rounded transition-colors text-green-500"
title={t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
>
<RefreshCw className="w-4 h-4" />
</button>
</>
)}
</span>
) : (
<span className="text-zinc-500">{'\u2014'}</span>
)}
</div>
<div className="flex justify-between items-center">
<span className="text-zinc-500">{t('spoolbuddy.dashboard.tagId', 'Tag')}</span>
<span className="font-mono text-xs text-zinc-400 truncate max-w-[120px]" title={spool.tag_uid || ''}>
{spool.tag_uid ? spool.tag_uid.slice(-8) : '\u2014'}
</span>
</div>
{nozzleTempRange && (
<div className="flex justify-between items-center">
<span className="text-zinc-500">{t('spoolbuddy.inventory.nozzleTemp', 'Nozzle')}</span>
<span className="font-mono text-zinc-300">{nozzleTempRange}</span>
</div>
)}
{spool.cost_per_kg != null && spool.cost_per_kg > 0 && (
<div className="flex justify-between items-center">
<span className="text-zinc-500">{t('spoolbuddy.inventory.costPerKg', 'Cost/kg')}</span>
<span className="font-mono text-zinc-300">{spool.cost_per_kg.toFixed(2)}/kg</span>
</div>
)}
{kFactorSummary && (
<div className="flex justify-between items-center">
<span className="text-zinc-500">{t('spoolbuddy.inventory.kProfiles', 'K-Profile')}</span>
<span className="font-mono text-zinc-300 truncate max-w-[220px] text-right" title={kFactorSummary}>{kFactorSummary}</span>
</div>
)}
{slicerPreset && (
<div className="min-w-0">
<p className="text-xs text-zinc-500 mb-1">{t('spoolbuddy.inventory.slicerFilament', 'Slicer Filament')}</p>
<p className="text-sm text-zinc-300 whitespace-pre-wrap break-words">{slicerPreset}</p>
</div>
)}
{note && (
<div className="col-span-2">
<p className="text-xs text-zinc-500 mb-1">{t('spoolbuddy.inventory.note', 'Note')}</p>
<p className="text-sm leading-5 text-zinc-300 whitespace-pre-wrap break-words max-h-[3.75rem] overflow-y-auto pr-1">{note}</p>
</div>
)}
</div>
<div className="flex gap-2 justify-center">
{onAssignToAms && (
<button
onClick={onAssignToAms}
className="px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]"
>
{t('spoolbuddy.modal.assignToAms', 'Assign to AMS')}
</button>
)}
<button
onClick={handleSyncWeight}
disabled={liveScaleWeight === null || syncing}
className={`px-5 py-2.5 rounded-lg text-sm font-medium transition-colors min-h-[44px] ${
synced
? 'bg-green-600/20 text-green-400'
: onAssignToAms
? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 disabled:opacity-40 disabled:cursor-not-allowed'
: 'bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed'
}`}
>
{syncing ? '...' : synced ? t('spoolbuddy.dashboard.weightSynced', 'Synced!') : t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
</button>
{onClose && (
<button
onClick={onClose}
className="px-5 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
>
{t('spoolbuddy.dashboard.close', 'Close')}
</button>
)}
</div>
</div>
);
}
+37
View File
@@ -387,6 +387,43 @@ body {
animation: slide-in-left 0.3s ease-out;
}
/* SpoolBuddy idle scan animation: transform/opacity only for low-power devices */
@keyframes spoolbuddy-optimized-ping {
0% {
transform: scale(0.8);
opacity: 0.8;
}
80%,
100% {
transform: scale(2.2);
opacity: 0;
}
}
.spoolbuddy-optimized-ping {
will-change: auto;
contain: layout;
animation: spoolbuddy-optimized-ping 3.5s cubic-bezier(0, 0, 0.2, 1) infinite;
}
.spoolbuddy-spool-glow {
transform: scale(2.0);
will-change: auto;
transition: background 140ms linear, opacity 140ms linear;
}
@media (prefers-reduced-motion: reduce) {
.spoolbuddy-optimized-ping {
animation: none;
opacity: 0.25;
transform: scale(1.2);
}
.spoolbuddy-spool-glow {
transition: none;
}
}
/* Card shadows - uses theme-specific shadow */
.card-shadow {
box-shadow: var(--card-shadow);
@@ -46,16 +46,36 @@ function IdleSpool() {
return (
<div className="flex flex-col items-center text-center">
{/* Spool with single subtle NFC ring */}
{/* Animated spool with optimized NFC waves */}
<div className="relative mb-6 flex items-center justify-center" style={{ width: 160, height: 160 }}>
{/* Static NFC wave rings */}
<div className="absolute w-24 h-24 rounded-full border-2 border-green-500/30" />
<div className="absolute w-32 h-32 rounded-full border border-green-500/20" />
<div className="absolute w-40 h-40 rounded-full border border-green-500/10" />
{/* NFC wave rings: transform + opacity only for Pi-friendly rendering */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
{[0, 1].map((i) => (
<div
key={i}
className="absolute rounded-full border spoolbuddy-optimized-ping"
style={{
width: 80,
height: 80,
borderColor: `${currentColor}4D`,
transition: 'border-color 140ms linear',
animationDelay: `${i * 0.8}s`,
}}
/>
))}
</div>
{/* Spool icon with slow color transition */}
<div className="relative transition-colors duration-[2000ms]">
<SpoolIcon color={currentColor} isEmpty={false} size={100} />
{/* Spool icon with lightweight radial glow */}
<div className="relative overflow-hidden rounded-full">
<div
className="absolute inset-0 rounded-full opacity-30 spoolbuddy-spool-glow"
style={{
background: `radial-gradient(circle, ${currentColor} 0%, transparent 70%)`,
}}
/>
<div className="relative" style={{ transition: 'opacity 140ms linear' }}>
<SpoolIcon color={currentColor} isEmpty={false} size={100} />
</div>
</div>
</div>
@@ -1,11 +1,15 @@
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { Search, X, Package } from 'lucide-react';
import { api } from '../../api/client';
import type { InventorySpool, SpoolAssignment } from '../../api/client';
import { resolveSpoolColorName } from '../../utils/colors';
import { formatSlotLabel } from '../../utils/amsHelpers';
import { InventorySpoolInfoCard } from '../../components/spoolbuddy/InventorySpoolInfoCard';
import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
type FilterMode = 'all' | 'in_ams' | string; // string = material name
@@ -49,10 +53,12 @@ function SpoolCircle({ color, size = 56 }: { color: string; size?: number }) {
}
export function SpoolBuddyInventoryPage() {
const { sbState, selectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState('');
const [filterMode, setFilterMode] = useState<FilterMode>('all');
const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
const [showAssignAmsModal, setShowAssignAmsModal] = useState(false);
const { data: spoolmanSettings } = useQuery({
queryKey: ['spoolman-settings'],
@@ -60,7 +66,7 @@ export function SpoolBuddyInventoryPage() {
staleTime: 5 * 60 * 1000,
});
const { data: spools = [], isLoading } = useQuery({
const { data: spools = [], isLoading, refetch: refetchSpools } = useQuery({
queryKey: ['inventory-spools'],
queryFn: () => api.getSpools(false),
refetchInterval: 30000,
@@ -220,12 +226,29 @@ export function SpoolBuddyInventoryPage() {
{selectedSpoolId != null && (() => {
const liveSpool = spools.find(s => s.id === selectedSpoolId);
if (!liveSpool) return null;
const handleCloseDetail = () => {
setSelectedSpoolId(null);
setShowAssignAmsModal(false);
};
return (
<SpoolDetailModal
spool={liveSpool}
assignment={assignmentMap[liveSpool.id]}
onClose={() => setSelectedSpoolId(null)}
/>
<>
<SpoolDetailModal
spool={liveSpool}
assignment={assignmentMap[liveSpool.id]}
sbState={sbState}
onSyncWeight={() => {
void refetchSpools();
}}
onAssignToAms={() => setShowAssignAmsModal(true)}
onClose={handleCloseDetail}
/>
<AssignToAmsModal
isOpen={showAssignAmsModal}
onClose={() => setShowAssignAmsModal(false)}
spool={liveSpool}
printerId={selectedPrinterId}
/>
</>
);
})()}
</div>
@@ -314,168 +337,51 @@ function CatalogCard({ spool, assignment, onClick }: {
}
/* Detail bottom sheet */
function SpoolDetailModal({ spool, assignment, onClose }: {
function SpoolDetailModal({ spool, assignment, sbState, onSyncWeight, onAssignToAms, onClose }: {
spool: InventorySpool;
assignment?: SpoolAssignment;
sbState: SpoolBuddyOutletContext['sbState'];
onSyncWeight: () => void;
onAssignToAms: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const color = spoolColor(spool);
const pct = spoolPct(spool);
const remaining = spoolRemaining(spool);
const colorName = resolveSpoolColorName(spool.color_name, spool.rgba);
const useLiveScaleWeight = sbState.deviceOnline && sbState.weight !== null;
const modalScaleWeight = useLiveScaleWeight
? Math.round(sbState.weight as number)
: null;
const persistedGrossWeight = spool.last_scale_weight != null ? Math.round(spool.last_scale_weight) : null;
return (
<div className="fixed inset-0 z-50" onClick={onClose}>
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" onClick={onClose}>
<div
className="h-full w-full bg-bambu-dark overflow-y-auto"
className="w-full max-w-md bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-2xl p-4 overflow-y-auto max-h-[90vh]"
onClick={e => e.stopPropagation()}
>
{/* Header with spool icon */}
<div className="flex items-center gap-4 p-4 pb-3">
<SpoolCircle color={color} size={72} />
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold text-white">
{spoolDisplayName(spool)}
</h2>
{spool.brand && (
<p className="text-sm text-white/50">{spool.brand}</p>
)}
<div className="flex items-center gap-1.5 mt-1">
<span
className="w-3 h-3 rounded-full border border-white/10"
style={{ backgroundColor: color }}
/>
<span className="text-sm text-white/60">
{colorName || '-'}
</span>
</div>
</div>
</div>
<div className="px-4 pb-4 space-y-4">
{/* Remaining bar */}
<div>
<div className="flex justify-between text-xs text-white/50 mb-1.5">
<span>{t('spoolbuddy.inventory.remaining', 'Remaining')}</span>
<span>{Math.round(remaining)}g ({Math.round(pct)}%)</span>
</div>
<div className="h-3 bg-bambu-dark-secondary rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${pct > 50 ? 'bg-bambu-green' : pct > 20 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
</div>
{/* AMS location */}
<div className="space-y-3">
{assignment && (
<div className="flex items-center gap-2">
<div className="flex items-center justify-center gap-2">
<span className="px-2.5 py-1 rounded-md text-xs font-bold bg-bambu-green/20 text-bambu-green">
{assignmentLabel(assignment)}
</span>
{assignment.printer_name && (
<span className="text-xs text-white/40">{assignment.printer_name}</span>
<span className="text-xs text-zinc-400">{assignment.printer_name}</span>
)}
</div>
)}
{/* Detail grid */}
<div className="grid grid-cols-2 gap-2.5">
<DetailItem
label={t('spoolbuddy.inventory.labelWeight', 'Label Weight')}
value={`${spool.label_weight}g`}
<div className="flex justify-center">
<InventorySpoolInfoCard
spool={spool}
liveScaleWeight={modalScaleWeight}
persistedGrossWeight={persistedGrossWeight}
onSyncWeight={onSyncWeight}
onAssignToAms={onAssignToAms}
onClose={onClose}
className="max-w-md"
/>
<DetailItem
label={t('spoolbuddy.inventory.weightUsed', 'Used')}
value={spool.weight_used > 0 ? `${Math.round(spool.weight_used)}g` : '-'}
/>
<DetailItem
label={t('spoolbuddy.inventory.coreWeight', 'Core Weight')}
value={spool.core_weight > 0 ? `${spool.core_weight}g` : '-'}
/>
<DetailItem
label={t('spoolbuddy.inventory.grossWeight', 'Gross Weight')}
value={`${spool.label_weight + spool.core_weight}g`}
/>
{spool.nozzle_temp_min != null && spool.nozzle_temp_max != null && (
<DetailItem
label={t('spoolbuddy.inventory.nozzleTemp', 'Nozzle Temp')}
value={`${spool.nozzle_temp_min}-${spool.nozzle_temp_max}°C`}
/>
)}
{spool.cost_per_kg != null && spool.cost_per_kg > 0 && (
<DetailItem
label={t('spoolbuddy.inventory.costPerKg', 'Cost/kg')}
value={`${spool.cost_per_kg.toFixed(2)}/kg`}
/>
)}
{spool.last_scale_weight != null && (
<DetailItem
label={t('spoolbuddy.inventory.lastScaleWeight', 'Scale Weight')}
value={`${Math.round(spool.last_scale_weight)}g`}
/>
)}
{spool.tag_uid && (
<DetailItem
label={t('spoolbuddy.inventory.tagId', 'Tag')}
value={spool.tag_uid}
mono
/>
)}
{(spool.slicer_filament_name || spool.slicer_filament) && (
<DetailItem
label={t('spoolbuddy.inventory.slicerFilament', 'Slicer Filament')}
value={spool.slicer_filament_name || spool.slicer_filament || ''}
/>
)}
</div>
{/* K-Profiles */}
{spool.k_profiles && spool.k_profiles.length > 0 && (
<div>
<p className="text-xs text-white/40 mb-1.5">{t('spoolbuddy.inventory.kProfiles', 'PA K-Profiles')}</p>
<div className="space-y-1">
{spool.k_profiles.map(kp => (
<div key={kp.id} className="flex items-center justify-between bg-bambu-dark-secondary rounded-lg px-3 py-2">
<span className="text-sm text-white/70 truncate">
{kp.name || `${kp.nozzle_diameter}mm ${kp.nozzle_type || ''}`}
</span>
<span className="text-sm font-mono text-bambu-green shrink-0 ml-2">
{kp.k_value.toFixed(3)}
</span>
</div>
))}
</div>
</div>
)}
{/* Note */}
{spool.note && (
<div className="bg-bambu-dark-secondary rounded-lg p-3">
<p className="text-xs text-white/40 mb-1">{t('spoolbuddy.inventory.note', 'Note')}</p>
<p className="text-sm text-white/70">{spool.note}</p>
</div>
)}
{/* Close button */}
<button
onClick={onClose}
className="w-full py-3 rounded-xl bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-white/60 hover:text-white text-sm font-medium transition-colors"
>
{t('spoolbuddy.inventory.close', 'Close')}
</button>
</div>
</div>
</div>
);
}
function DetailItem({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="bg-bambu-dark-secondary rounded-lg px-3 py-2">
<p className="text-[10px] text-white/40 uppercase tracking-wide">{label}</p>
<p className={`text-sm text-white mt-0.5 truncate ${mono ? 'font-mono text-xs' : ''}`}>{value}</p>
</div>
);
}