Files
home_hub/design_system/package-smartphone/components/mobile-apps.jsx
T
gilles 4518ed8311 chore(design): ajout du package design system smartphone
Contient les tokens, composants et exemples adaptés au mobile,
à utiliser comme référence lors du développement des vues smartphone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 08:53:36 +02:00

660 lines
27 KiB
React

/* ============================================================
mobile-apps.jsx
Composants pour patterns d'app courants : avatar+menu,
onboarding, chat, calendrier, maps, recherche+filtres,
scanner QR, caméra, gestion fichiers.
============================================================ */
const { useState: uA, useRef: rA, useEffect: eA } = React;
/* ============================================================
Avatar — bouton rond utilisateur (initiales ou icône)
Nom système : Avatar
============================================================ */
function Avatar({ name = 'M', color = 'var(--accent)', size = 36, onClick, active }) {
const initials = name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
return (
<button onClick={onClick} className="touch-press" style={{
width: size, height: size, borderRadius: '50%',
background: `linear-gradient(135deg, ${color}, color-mix(in oklch, ${color} 60%, black))`,
color: 'var(--bg-1)',
border: active ? '2px solid var(--accent)' : 'none',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'var(--font-ui)', fontSize: size * 0.4, fontWeight: 700,
cursor: 'pointer',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.25), 0 2px 6px rgba(0,0,0,0.3)',
WebkitTapHighlightColor: 'transparent',
}}>{initials}</button>
);
}
/* ============================================================
AvatarMenu — popup descendant depuis l'avatar
Nom système : AvatarMenu
Items : [{icon, label, onClick, danger}]
============================================================ */
function AvatarMenu({ open, onClose, name, email, items = [] }) {
if (!open) return null;
return (
<div onClick={onClose} style={{
position: 'absolute', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.35)',
animation: 'fade-in .15s',
}}>
<style>{`
@keyframes fade-in { from { opacity: 0 } to { opacity: 1 } }
@keyframes drop-in { from { opacity: 0; transform: translateY(-8px) scale(.95) } to { opacity: 1; transform: translateY(0) scale(1) } }
`}</style>
<div onClick={(e) => e.stopPropagation()} style={{
position: 'absolute', top: 56, right: 12,
width: 240,
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 14,
overflow: 'hidden',
boxShadow: '0 14px 32px rgba(0,0,0,0.5)',
animation: 'drop-in .2s cubic-bezier(.3,.7,.3,1.2)',
transformOrigin: 'top right',
}}>
<div style={{
padding: '14px 14px 12px',
display: 'flex', alignItems: 'center', gap: 10,
borderBottom: '1px solid var(--border-1)',
background: 'var(--bg-2)',
}}>
<Avatar name={name} size={36} />
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 700 }}>{name}</div>
{email && <div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)' }}>{email}</div>}
</div>
</div>
{items.map((it, i) => (
<button key={i} onClick={() => { onClose(); it.onClick && it.onClick(); }}
className="touch-press" style={{
width: '100%', minHeight: 44,
padding: '10px 14px',
background: 'transparent', border: 'none',
borderTop: i > 0 ? '1px solid var(--border-1)' : 'none',
color: it.danger ? 'var(--err)' : 'var(--ink-1)',
display: 'flex', alignItems: 'center', gap: 10,
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 500,
cursor: 'pointer', textAlign: 'left',
WebkitTapHighlightColor: 'transparent',
}}>
<Icon name={it.icon} size={15} style={{ color: it.danger ? 'var(--err)' : 'var(--accent)' }} />
<span style={{ flex: 1 }}>{it.label}</span>
{!it.danger && <Icon name="chevR" size={12} style={{ color: 'var(--ink-3)' }} />}
</button>
))}
</div>
</div>
);
}
/* ============================================================
OnboardingSlider — slides + dots + boutons suivant/passer
Nom système : OnboardingSlider
Cas : présentation d'une nouvelle app à l'utilisateur.
slides : [{icon, color, title, desc}]
============================================================ */
function OnboardingSlider({ slides, onFinish }) {
const [i, setI] = uA(0);
const isLast = i === slides.length - 1;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{
padding: '14px 20px',
display: 'flex', justifyContent: 'flex-end',
}}>
<button onClick={onFinish} style={{
padding: '6px 12px', background: 'transparent', border: 'none',
color: 'var(--ink-3)', fontFamily: 'var(--font-ui)',
fontWeight: 600, fontSize: 14, cursor: 'pointer',
WebkitTapHighlightColor: 'transparent',
}}>Passer</button>
</div>
<div style={{
flex: 1, padding: '0 32px',
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
textAlign: 'center',
}}>
<div style={{
width: 110, height: 110, borderRadius: 28,
background: `linear-gradient(135deg, ${slides[i].color}, color-mix(in oklch, ${slides[i].color} 60%, black))`,
color: 'var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginBottom: 28,
boxShadow: `inset 0 2px 0 rgba(255,255,255,0.2), 0 12px 28px rgba(0,0,0,0.4)`,
animation: 'pop-in .35s cubic-bezier(.3,.7,.3,1.3)',
}}>
<style>{`@keyframes pop-in { from { transform: scale(.7); opacity: 0 } }`}</style>
<Icon name={slides[i].icon} size={56} />
</div>
<div style={{ fontSize: 26, fontWeight: 700, marginBottom: 12 }}>{slides[i].title}</div>
<div style={{ fontSize: 15, color: 'var(--ink-3)', lineHeight: 1.5, maxWidth: 280 }}>{slides[i].desc}</div>
</div>
<div style={{ padding: '20px 24px 30px' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginBottom: 22 }}>
{slides.map((_, j) => (
<span key={j} onClick={() => setI(j)} style={{
width: i === j ? 24 : 8, height: 8, borderRadius: 4,
background: i === j ? 'var(--accent)' : 'var(--border-3)',
transition: 'width .25s, background .2s',
cursor: 'pointer',
}} />
))}
</div>
<PrimaryButton icon={isLast ? 'play' : 'chevR'}
onClick={() => isLast ? onFinish() : setI(i + 1)}>
{isLast ? 'Commencer' : 'Suivant'}
</PrimaryButton>
</div>
</div>
);
}
/* ============================================================
ChatBubble — bulle de message (envoyé/reçu)
Nom système : ChatBubble
============================================================ */
function ChatBubble({ text, time, me, status }) {
return (
<div style={{
display: 'flex',
justifyContent: me ? 'flex-end' : 'flex-start',
padding: '4px 14px',
}}>
<div style={{
maxWidth: '78%',
padding: '8px 12px',
background: me ? 'var(--accent)' : 'var(--bg-3)',
color: me ? 'var(--bg-1)' : 'var(--ink-1)',
borderRadius: me ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
fontSize: 14, lineHeight: 1.4,
boxShadow: me ? '0 2px 6px var(--accent-glow)' : 'var(--shadow-1)',
border: me ? 'none' : '1px solid var(--border-2)',
}}>
<div>{text}</div>
<div style={{
fontSize: 10,
color: me ? 'rgba(0,0,0,0.55)' : 'var(--ink-3)',
marginTop: 4, textAlign: 'right',
fontFamily: 'var(--font-mono)',
display: 'inline-flex', alignItems: 'center', gap: 4,
float: 'right',
}}>
{time}
{me && status === 'sent' && <span></span>}
{me && status === 'read' && <span></span>}
</div>
</div>
</div>
);
}
/* ============================================================
ChatComposer — barre d'envoi en bas (input + + + send)
Nom système : ChatComposer
============================================================ */
function ChatComposer({ onSend }) {
const [v, setV] = uA('');
return (
<div style={{
padding: '8px 10px 18px',
display: 'flex', alignItems: 'flex-end', gap: 8,
borderTop: '1px solid var(--border-2)',
background: 'var(--surf-glass-strong)',
backdropFilter: 'blur(14px)',
}}>
<IconButton icon="plus" label="Joindre" size={36} />
<div style={{
flex: 1, minHeight: 36,
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 12px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 18,
}}>
<input type="text" value={v} onChange={(e) => setV(e.target.value)}
placeholder="Message…"
style={{
flex: 1, minWidth: 0,
background: 'transparent', border: 'none', outline: 'none',
color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 14,
}} />
</div>
{v ? (
<button onClick={() => { onSend && onSend(v); setV(''); }}
className="touch-press" style={{
width: 36, height: 36, borderRadius: '50%',
background: 'var(--accent)', color: 'var(--bg-1)',
border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', boxShadow: '0 2px 6px var(--accent-glow)',
WebkitTapHighlightColor: 'transparent',
}}><Icon name="chevR" size={16} /></button>
) : (
<IconButton icon="terminal" label="Audio" size={36} />
)}
</div>
);
}
/* ============================================================
CalendarMonth — vue mois avec points sous les jours marqués
Nom système : CalendarMonth
Props : year, month (0-11), selected (Date), onSelect, events (Set de jours)
============================================================ */
function CalendarMonth({ year, month, selected, onSelect, events = new Set() }) {
const today = new Date();
const first = new Date(year, month, 1);
const last = new Date(year, month + 1, 0);
const startDay = (first.getDay() + 6) % 7; // lundi = 0
const days = last.getDate();
const cells = [];
for (let i = 0; i < startDay; i++) cells.push(null);
for (let d = 1; d <= days; d++) cells.push(d);
const monthName = first.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
return (
<div>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 14px 12px',
}}>
<IconButton icon="chevL" label="Mois précédent" size={32} />
<div style={{ fontSize: 16, fontWeight: 700, textTransform: 'capitalize' }}>{monthName}</div>
<IconButton icon="chevR" label="Mois suivant" size={32} />
</div>
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4,
padding: '0 8px',
}}>
{['L', 'M', 'M', 'J', 'V', 'S', 'D'].map((d, i) => (
<div key={i} style={{
textAlign: 'center', fontSize: 10,
color: 'var(--ink-3)', fontFamily: 'var(--font-mono)',
fontWeight: 700, padding: '4px 0',
letterSpacing: '0.08em',
}}>{d}</div>
))}
{cells.map((d, i) => {
const isToday = d === today.getDate() && month === today.getMonth() && year === today.getFullYear();
const isSel = selected && d === selected.getDate() && month === selected.getMonth() && year === selected.getFullYear();
const hasEvent = d && events.has(d);
return (
<button key={i} onClick={() => d && onSelect && onSelect(new Date(year, month, d))}
disabled={!d}
className="touch-press"
style={{
aspectRatio: '1',
background: isSel ? 'var(--accent)' : isToday ? 'var(--accent-tint)' : 'transparent',
color: isSel ? 'var(--bg-1)' : isToday ? 'var(--accent)' : (d ? 'var(--ink-1)' : 'transparent'),
border: 'none', borderRadius: 8,
fontFamily: 'var(--font-mono)', fontSize: 13,
fontWeight: isSel || isToday ? 700 : 500,
cursor: d ? 'pointer' : 'default',
position: 'relative',
WebkitTapHighlightColor: 'transparent',
}}>
{d}
{hasEvent && (
<span style={{
position: 'absolute', bottom: 4, left: '50%',
transform: 'translateX(-50%)',
width: 4, height: 4, borderRadius: '50%',
background: isSel ? 'var(--bg-1)' : 'var(--accent)',
}}/>
)}
</button>
);
})}
</div>
</div>
);
}
/* ============================================================
MapView — placeholder visuel d'une carte avec pins
Nom système : MapView
============================================================ */
function MapView({ pins = [] }) {
return (
<div style={{
position: 'relative',
height: '100%', width: '100%',
background: 'var(--bg-2)',
overflow: 'hidden',
}}>
{/* fond carte stylisé */}
<svg width="100%" height="100%" viewBox="0 0 400 600" preserveAspectRatio="xMidYMid slice" style={{ position: 'absolute', inset: 0 }}>
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="var(--border-1)" strokeWidth="0.5"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)"/>
{/* routes */}
<path d="M 0 200 Q 200 150 400 250" stroke="var(--ink-4)" strokeWidth="6" fill="none" opacity="0.3"/>
<path d="M 100 0 Q 150 200 200 400 T 250 600" stroke="var(--ink-4)" strokeWidth="6" fill="none" opacity="0.3"/>
<path d="M 200 100 L 350 500" stroke="var(--ink-4)" strokeWidth="4" fill="none" opacity="0.25"/>
{/* zones */}
<path d="M 0 0 L 150 0 L 100 120 L 0 100 Z" fill="var(--bg-3)" opacity="0.5"/>
<path d="M 280 350 L 400 380 L 400 550 L 320 600 L 250 500 Z" fill="var(--bg-3)" opacity="0.4"/>
<circle cx="240" cy="380" r="60" fill="var(--ok)" opacity="0.12"/>
{/* fleuve */}
<path d="M 0 450 Q 100 420 200 460 T 400 440" stroke="var(--info)" strokeWidth="10" fill="none" opacity="0.4"/>
</svg>
{/* pins */}
{pins.map((p, i) => (
<div key={i} style={{
position: 'absolute', left: `${p.x}%`, top: `${p.y}%`,
transform: 'translate(-50%, -100%)',
pointerEvents: 'none',
}}>
<div style={{
width: 28, height: 28, borderRadius: '50% 50% 50% 0',
background: p.color || 'var(--accent)',
transform: 'rotate(-45deg)',
border: '2px solid var(--bg-1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 10px rgba(0,0,0,0.5)',
}}>
<Icon name={p.icon || 'grid'} size={12} style={{ color: 'var(--bg-1)', transform: 'rotate(45deg)' }}/>
</div>
{p.label && (
<div style={{
position: 'absolute', top: -28, left: '50%',
transform: 'translateX(-50%)',
padding: '3px 8px',
background: 'var(--bg-3)',
border: '1px solid var(--border-2)',
borderRadius: 6,
fontFamily: 'var(--font-mono)', fontSize: 10,
color: 'var(--ink-1)',
whiteSpace: 'nowrap',
boxShadow: 'var(--shadow-2)',
}}>{p.label}</div>
)}
</div>
))}
</div>
);
}
/* ============================================================
FilterChips — barre de chips de filtre
Nom système : FilterChips
============================================================ */
function FilterChips({ value = [], onChange, options }) {
const toggle = (v) => {
if (value.includes(v)) onChange(value.filter((x) => x !== v));
else onChange([...value, v]);
};
return (
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', padding: '4px 0', WebkitOverflowScrolling: 'touch' }}>
{options.map((o) => {
const v = typeof o === 'string' ? o : o.value;
const l = typeof o === 'string' ? o : o.label;
const ic = typeof o === 'object' ? o.icon : null;
const active = value.includes(v);
return (
<button key={v} onClick={() => toggle(v)} className="touch-press" style={{
flex: '0 0 auto',
padding: '6px 12px',
background: active ? 'var(--accent)' : 'var(--bg-3)',
color: active ? 'var(--bg-1)' : 'var(--ink-2)',
border: `1px solid ${active ? 'var(--accent)' : 'var(--border-2)'}`,
borderRadius: 999,
display: 'inline-flex', alignItems: 'center', gap: 6,
cursor: 'pointer',
fontFamily: 'var(--font-ui)', fontSize: 12, fontWeight: 600,
WebkitTapHighlightColor: 'transparent',
}}>
{ic && <Icon name={ic} size={12} />}
{l}
</button>
);
})}
</div>
);
}
/* ============================================================
QrScannerView — viseur scanner code-barres / QR
Nom système : QrScannerView
============================================================ */
function QrScannerView({ onCapture }) {
return (
<div style={{
position: 'relative', width: '100%', height: '100%',
background: '#000',
overflow: 'hidden',
}}>
{/* fake camera feed = grain animé */}
<div style={{
position: 'absolute', inset: 0,
background: `
radial-gradient(ellipse at 30% 40%, rgba(80,60,40,0.4), transparent 60%),
radial-gradient(ellipse at 70% 60%, rgba(40,40,60,0.5), transparent 50%),
#15110c
`,
}}/>
{/* visée centrale */}
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: 220, height: 220,
}}>
{/* 4 coins */}
{[
{ top: 0, left: 0, br: '4px 0 0 0' },
{ top: 0, right: 0, br: '0 4px 0 0' },
{ bottom: 0, left: 0, br: '0 0 0 4px' },
{ bottom: 0, right: 0, br: '0 0 4px 0' },
].map((c, i) => (
<div key={i} style={{
position: 'absolute', ...c, width: 28, height: 28,
borderTop: c.top !== undefined ? '3px solid var(--accent)' : 'none',
borderBottom: c.bottom !== undefined ? '3px solid var(--accent)' : 'none',
borderLeft: c.left !== undefined ? '3px solid var(--accent)' : 'none',
borderRight: c.right !== undefined ? '3px solid var(--accent)' : 'none',
borderRadius: c.br,
}}/>
))}
{/* ligne scan animée */}
<div style={{
position: 'absolute', left: 6, right: 6, height: 2,
background: 'linear-gradient(90deg, transparent, var(--accent), transparent)',
boxShadow: '0 0 12px var(--accent), 0 0 20px var(--accent)',
animation: 'qr-scan 2.4s ease-in-out infinite',
}}/>
<style>{`@keyframes qr-scan {
0%, 100% { top: 6px; opacity: 1 }
50% { top: calc(100% - 8px); opacity: 0.7 }
}`}</style>
</div>
{/* overlay assombri hors visée */}
<div style={{
position: 'absolute', inset: 0,
boxShadow: '0 0 0 9999px rgba(0,0,0,0.55) inset',
clipPath: 'polygon(0% 0%, 0% 100%, 100% 100%, 100% 0%, calc(50% + 110px) 0%, calc(50% + 110px) calc(50% + 110px), calc(50% - 110px) calc(50% + 110px), calc(50% - 110px) calc(50% - 110px), calc(50% + 110px) calc(50% - 110px), calc(50% + 110px) 0%)',
pointerEvents: 'none',
}}/>
{/* texte */}
<div style={{
position: 'absolute', top: 'calc(50% + 140px)', left: 0, right: 0,
textAlign: 'center', color: 'var(--ink-1)',
fontFamily: 'var(--font-ui)', fontSize: 14, fontWeight: 600,
}}>Pointe vers un QR code ou code-barres</div>
{/* boutons bas */}
<div style={{
position: 'absolute', bottom: 28, left: 0, right: 0,
display: 'flex', justifyContent: 'space-around', alignItems: 'center',
}}>
<IconButton icon="folder" label="Galerie" size={44} />
<button onClick={() => onCapture && onCapture('demo')} className="touch-press" style={{
width: 70, height: 70, borderRadius: '50%',
background: 'var(--accent)', border: '4px solid #fff',
color: 'var(--bg-1)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 12px var(--accent-glow)',
WebkitTapHighlightColor: 'transparent',
}}><Icon name="grid" size={26} /></button>
<IconButton icon="moon" label="Flash" size={44} />
</div>
</div>
);
}
/* ============================================================
CameraView — viseur appareil photo avec shutter rond
Nom système : CameraView
============================================================ */
function CameraView({ onShoot }) {
return (
<div style={{
position: 'relative', width: '100%', height: '100%',
background: '#000', overflow: 'hidden',
}}>
{/* fake scene */}
<div style={{
position: 'absolute', inset: 0,
background: `
linear-gradient(180deg, #4a2e1a 0%, #6b4423 30%, #2a1f15 70%, #15110c 100%),
radial-gradient(circle at 50% 30%, rgba(254,128,25,0.3), transparent 50%)
`,
backgroundBlendMode: 'overlay',
}}/>
{/* règle des tiers */}
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
{[33.33, 66.66].map((p) => (
<React.Fragment key={p}>
<div style={{ position:'absolute', left:0, right:0, top:`${p}%`, height:1, background:'rgba(255,255,255,0.2)' }}/>
<div style={{ position:'absolute', top:0, bottom:0, left:`${p}%`, width:1, background:'rgba(255,255,255,0.2)' }}/>
</React.Fragment>
))}
</div>
{/* top bar */}
<div style={{
position: 'absolute', top: 20, left: 0, right: 0,
display: 'flex', justifyContent: 'space-around',
padding: '0 16px',
}}>
{[
{ icon: 'moon', label: 'Flash' },
{ icon: 'clock', label: 'Minuteur' },
{ icon: 'grid', label: 'Grille' },
].map((b) => (
<IconButton key={b.label} icon={b.icon} label={b.label} size={36} />
))}
</div>
{/* mode chips */}
<div style={{
position: 'absolute', bottom: 130, left: 0, right: 0,
display: 'flex', justifyContent: 'center', gap: 20,
color: 'var(--ink-2)', fontFamily: 'var(--font-mono)', fontSize: 12,
letterSpacing: '0.08em', textTransform: 'uppercase',
}}>
<span style={{ opacity: 0.5 }}>Vidéo</span>
<span style={{ color: 'var(--accent)', fontWeight: 700 }}>Photo</span>
<span style={{ opacity: 0.5 }}>Portrait</span>
</div>
{/* bottom controls */}
<div style={{
position: 'absolute', bottom: 28, left: 0, right: 0,
display: 'flex', justifyContent: 'space-around', alignItems: 'center',
}}>
<div style={{
width: 50, height: 50, borderRadius: 10,
background: 'linear-gradient(135deg, #6b4423, #2a1f15)',
border: '2px solid #fff',
}}/>
<button onClick={() => onShoot && onShoot()} className="touch-press" style={{
width: 76, height: 76, borderRadius: '50%',
background: '#fff', border: '4px solid rgba(255,255,255,0.4)',
cursor: 'pointer',
boxShadow: '0 0 0 4px rgba(0,0,0,0.4)',
WebkitTapHighlightColor: 'transparent',
}}/>
<IconButton icon="refresh" label="Caméra avant" size={44} />
</div>
</div>
);
}
/* ============================================================
FileExplorer — liste fichiers/dossiers
Nom système : FileExplorer
============================================================ */
function FileExplorer({ items, onOpen, onAction }) {
const sizeFmt = (b) => {
if (b == null) return '';
if (b < 1024) return `${b} o`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} Ko`;
if (b < 1024 ** 3) return `${(b / 1024 / 1024).toFixed(1)} Mo`;
return `${(b / 1024 / 1024 / 1024).toFixed(1)} Go`;
};
const typeIcon = (t) => ({
folder: 'folder', image: 'grid', video: 'play', audio: 'terminal',
pdf: 'list', code: 'terminal', archive: 'download', file: 'list',
})[t] || 'list';
const typeColor = (t) => ({
folder: 'var(--accent)', image: 'var(--blue)', video: 'var(--purple)',
audio: 'var(--ok)', pdf: 'var(--err)', code: 'var(--info)', archive: 'var(--warn)',
})[t] || 'var(--ink-3)';
return (
<div>
{items.map((it) => (
<SwipeableRow key={it.name}
onTap={() => onOpen && onOpen(it)}
leftActions={[
{ label: 'Suppr.', icon: 'close', color: 'var(--err)',
onClick: () => onAction && onAction('delete', it) },
]}
rightActions={[
{ label: 'Renom.', icon: 'cog', color: 'var(--info)',
onClick: () => onAction && onAction('rename', it) },
{ label: 'Partag.', icon: 'download', color: 'var(--accent)',
onClick: () => onAction && onAction('share', it) },
]}>
<div style={{
padding: '12px 14px',
display: 'flex', alignItems: 'center', gap: 12,
borderBottom: '1px solid var(--border-1)',
background: 'var(--bg-3)',
}}>
<span style={{
width: 38, height: 38, borderRadius: 8,
background: 'var(--bg-1)',
border: `1px solid ${typeColor(it.type)}`,
color: typeColor(it.type),
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>
<Icon name={typeIcon(it.type)} size={17} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--ink-1)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.name}</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', fontFamily: 'var(--font-mono)', marginTop: 2 }}>
{it.date || ''} {it.size != null && `· ${sizeFmt(it.size)}`}
</div>
</div>
{it.type === 'folder' && <Icon name="chevR" size={13} style={{ color: 'var(--ink-3)' }}/>}
</div>
</SwipeableRow>
))}
</div>
);
}
Object.assign(window, {
Avatar, AvatarMenu,
OnboardingSlider,
ChatBubble, ChatComposer,
CalendarMonth,
MapView,
FilterChips,
QrScannerView, CameraView,
FileExplorer,
});