feat(stash): sort + size search, public-stash preview, bulk actions

- Filter bar gains a Sort select (Newest / Value / Condition / A-Z) and search
  now matches size too.
- 'Public stash' card lists which items are public with a link to your profile
  ('see what others see').
- Bulk actions: a header Select toggle puts rows/cards into multi-select
  (checkbox + click-to-toggle, per-item actions hidden) with a fixed action bar
  -- Public / Private / For sale / Remove -- backed by new bulkSetVisibility /
  bulkSetForSale / bulkRemove mutations that each do ONE re-encrypt, plus a
  bulk-delete confirm. Showcase sync/unpublish runs per affected item.
This commit is contained in:
SkateSpots 2026-09-29 22:31:14 +07:00
parent 5a8e3688ee
commit 1311f699f9
2 changed files with 317 additions and 26 deletions

View file

@ -308,6 +308,58 @@ export function useStash(options?: { enabled?: boolean }) {
}, },
}); });
// Bulk actions — apply to many items in ONE re-encrypt + publish.
const bulkSetVisibility = useMutation({
mutationFn: async ({ ids, visibility }: { ids: string[]; visibility: StashItem['visibility'] }) => {
const current = await fetchLatestStash();
const now = Math.floor(Date.now() / 1000);
const changed: StashItem[] = [];
const next = current.items.map((i) => {
if (!ids.includes(i.id)) return i;
const updated = { ...i, visibility };
changed.push(updated);
return updated;
});
await saveStash({ items: next, updatedAt: now });
return { changed };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] });
},
});
const bulkSetForSale = useMutation({
mutationFn: async ({ ids, forSale }: { ids: string[]; forSale: boolean }) => {
const current = await fetchLatestStash();
const now = Math.floor(Date.now() / 1000);
const changed: StashItem[] = [];
const next = current.items.map((i) => {
if (!ids.includes(i.id)) return i;
const updated = { ...i, willingToSell: forSale };
changed.push(updated);
return updated;
});
await saveStash({ items: next, updatedAt: now });
return { changed };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] });
},
});
const bulkRemove = useMutation({
mutationFn: async (ids: string[]) => {
const current = await fetchLatestStash();
const now = Math.floor(Date.now() / 1000);
const removed = current.items.filter((i) => ids.includes(i.id));
await saveStash({ items: current.items.filter((i) => !ids.includes(i.id)), updatedAt: now });
return { removed };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] });
},
});
// Stats for RPG display // Stats for RPG display
const stats = useMemo(() => { const stats = useMemo(() => {
if (!stash) return { total: 0, byCategory: {}, totalValue: 0 }; if (!stash) return { total: 0, byCategory: {}, totalValue: 0 };
@ -337,6 +389,9 @@ export function useStash(options?: { enabled?: boolean }) {
updateItem, updateItem,
removeItem, removeItem,
importItems, importItems,
bulkSetVisibility,
bulkSetForSale,
bulkRemove,
isEncryptionAvailable: hasNip44, isEncryptionAvailable: hasNip44,
}; };
} }

View file

@ -54,13 +54,18 @@ import {
MapPin, MapPin,
LayoutGrid, LayoutGrid,
List, List,
Check,
Download, Download,
Upload, Upload,
HardDrive, HardDrive,
} from 'lucide-react'; } from 'lucide-react';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { nip19 } from 'nostr-tools';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/** Sort order for the "Condition" option (best → worst). */
const CONDITION_SORT_RANK: Record<string, number> = { new: 0, like_new: 1, good: 2, well_used: 3 };
// ─── Rarity helpers ────────────────────────────────────────────────────────── // ─── Rarity helpers ──────────────────────────────────────────────────────────
const RARITY_COLORS = { const RARITY_COLORS = {
common: 'from-gray-500 to-gray-600', common: 'from-gray-500 to-gray-600',
@ -85,8 +90,9 @@ const RARITY_BORDER = {
}; };
// ─── List Row — mobile default ──────────────────────────────────────────────── // ─── List Row — mobile default ────────────────────────────────────────────────
function StashListRow({ item, onEdit, onDelete, onListOnMarketplace, isWTB = false }: { function StashListRow({ item, onEdit, onDelete, onListOnMarketplace, isWTB = false, selectable = false, selected = false, onToggleSelect }: {
item: StashItem; onEdit: () => void; onDelete: () => void; onListOnMarketplace?: () => void; isWTB?: boolean; item: StashItem; onEdit: () => void; onDelete: () => void; onListOnMarketplace?: () => void; isWTB?: boolean;
selectable?: boolean; selected?: boolean; onToggleSelect?: () => void;
}) { }) {
const category = STASH_CATEGORIES[item.category]; const category = STASH_CATEGORIES[item.category];
const condition = CONDITION_LABELS[item.condition]; const condition = CONDITION_LABELS[item.condition];
@ -96,11 +102,20 @@ function StashListRow({ item, onEdit, onDelete, onListOnMarketplace, isWTB = fal
const canListOnMarketplace = !isWTB && (item.willingToSell || item.willingToTrade); const canListOnMarketplace = !isWTB && (item.willingToSell || item.willingToTrade);
return ( return (
<div className={cn( <div
onClick={selectable ? onToggleSelect : undefined}
className={cn(
'relative flex items-center gap-3 p-3 rounded-xl border-2 bg-black/60 transition-all active:scale-[0.99]', 'relative flex items-center gap-3 p-3 rounded-xl border-2 bg-black/60 transition-all active:scale-[0.99]',
RARITY_BORDER[rarity], RARITY_GLOW[rarity], RARITY_BORDER[rarity], RARITY_GLOW[rarity],
selectable && 'cursor-pointer',
selected && 'ring-2 ring-[#ccff00] border-[#ccff00]/60',
)}> )}>
<div className={cn('absolute left-0 top-0 bottom-0 w-1 rounded-l-xl bg-gradient-to-b', RARITY_COLORS[rarity])} /> <div className={cn('absolute left-0 top-0 bottom-0 w-1 rounded-l-xl bg-gradient-to-b', RARITY_COLORS[rarity])} />
{selectable && (
<span className={cn('flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full border-2', selected ? 'border-[#ccff00] bg-[#ccff00] text-black' : 'border-white/30')}>
{selected && <Check className="h-3 w-3" />}
</span>
)}
<div className="w-14 h-14 flex-shrink-0 rounded-lg overflow-hidden bg-black/50 border border-white/10 flex items-center justify-center ml-1"> <div className="w-14 h-14 flex-shrink-0 rounded-lg overflow-hidden bg-black/50 border border-white/10 flex items-center justify-center ml-1">
{item.images?.[0] ? <ImageWithFallback src={item.images[0]} alt={item.title} className="w-full h-full object-cover" fallback={<span className="text-2xl">{category.emoji}</span>} /> : <span className="text-2xl">{category.emoji}</span>} {item.images?.[0] ? <ImageWithFallback src={item.images[0]} alt={item.title} className="w-full h-full object-cover" fallback={<span className="text-2xl">{category.emoji}</span>} /> : <span className="text-2xl">{category.emoji}</span>}
</div> </div>
@ -143,6 +158,8 @@ function StashListRow({ item, onEdit, onDelete, onListOnMarketplace, isWTB = fal
{item.willingToSell && !isWTB && <span className="text-[9px] font-bold text-green-400 border border-green-500/40 rounded px-1 py-0">⚡</span>} {item.willingToSell && !isWTB && <span className="text-[9px] font-bold text-green-400 border border-green-500/40 rounded px-1 py-0">⚡</span>}
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
{!selectable && (
<>
<button onClick={(e) => { e.stopPropagation(); onEdit(); }} className="w-7 h-7 rounded-lg bg-white/5 hover:bg-white/15 flex items-center justify-center transition-colors touch-manipulation active:scale-90"> <button onClick={(e) => { e.stopPropagation(); onEdit(); }} className="w-7 h-7 rounded-lg bg-white/5 hover:bg-white/15 flex items-center justify-center transition-colors touch-manipulation active:scale-90">
<Edit3 className="h-3.5 w-3.5 text-gray-300" /> <Edit3 className="h-3.5 w-3.5 text-gray-300" />
</button> </button>
@ -154,6 +171,8 @@ function StashListRow({ item, onEdit, onDelete, onListOnMarketplace, isWTB = fal
<button onClick={(e) => { e.stopPropagation(); onDelete(); }} className="w-7 h-7 rounded-lg bg-red-500/10 hover:bg-red-500/30 flex items-center justify-center transition-colors touch-manipulation active:scale-90"> <button onClick={(e) => { e.stopPropagation(); onDelete(); }} className="w-7 h-7 rounded-lg bg-red-500/10 hover:bg-red-500/30 flex items-center justify-center transition-colors touch-manipulation active:scale-90">
<Trash2 className="h-3.5 w-3.5 text-red-400" /> <Trash2 className="h-3.5 w-3.5 text-red-400" />
</button> </button>
</>
)}
</div> </div>
</div> </div>
</div> </div>
@ -161,8 +180,9 @@ function StashListRow({ item, onEdit, onDelete, onListOnMarketplace, isWTB = fal
} }
// ─── Grid Card — desktop default ────────────────────────────────────────────── // ─── Grid Card — desktop default ──────────────────────────────────────────────
function StashItemCard({ item, onEdit, onDelete, onListOnMarketplace, isWTB = false }: { function StashItemCard({ item, onEdit, onDelete, onListOnMarketplace, isWTB = false, selectable = false, selected = false, onToggleSelect }: {
item: StashItem; onEdit: () => void; onDelete: () => void; onListOnMarketplace?: () => void; isWTB?: boolean; item: StashItem; onEdit: () => void; onDelete: () => void; onListOnMarketplace?: () => void; isWTB?: boolean;
selectable?: boolean; selected?: boolean; onToggleSelect?: () => void;
}) { }) {
const category = STASH_CATEGORIES[item.category]; const category = STASH_CATEGORIES[item.category];
const condition = CONDITION_LABELS[item.condition]; const condition = CONDITION_LABELS[item.condition];
@ -172,7 +192,9 @@ function StashItemCard({ item, onEdit, onDelete, onListOnMarketplace, isWTB = fa
const canListOnMarketplace = !isWTB && (item.willingToSell || item.willingToTrade); const canListOnMarketplace = !isWTB && (item.willingToSell || item.willingToTrade);
return ( return (
<Card className={cn('relative overflow-hidden transition-all duration-300 hover:scale-[1.02] hover:-translate-y-0.5 cursor-pointer group bg-gradient-to-br from-black/90 to-black/70 border-2', RARITY_GLOW[rarity], RARITY_BORDER[rarity], isWTB && 'ring-2 ring-cyan-500/30')}> <Card
onClick={selectable ? onToggleSelect : undefined}
className={cn('relative overflow-hidden transition-all duration-300 hover:scale-[1.02] hover:-translate-y-0.5 cursor-pointer group bg-gradient-to-br from-black/90 to-black/70 border-2', RARITY_GLOW[rarity], RARITY_BORDER[rarity], isWTB && 'ring-2 ring-cyan-500/30', selected && 'ring-2 ring-[#ccff00] border-[#ccff00]/60')}>
<div className={cn('absolute top-0 left-0 right-0 h-1.5 bg-gradient-to-r', RARITY_COLORS[rarity])}> <div className={cn('absolute top-0 left-0 right-0 h-1.5 bg-gradient-to-r', RARITY_COLORS[rarity])}>
{rarity !== 'common' && <div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-shimmer" />} {rarity !== 'common' && <div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-shimmer" />}
</div> </div>
@ -197,7 +219,14 @@ function StashItemCard({ item, onEdit, onDelete, onListOnMarketplace, isWTB = fa
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{category.label}</span> <span className="text-[10px] uppercase tracking-wider text-muted-foreground">{category.label}</span>
</div> </div>
)} )}
<div className="absolute inset-0 bg-black/85 transition-all duration-200 flex flex-col items-center justify-center gap-2 backdrop-blur-sm opacity-100 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100"> {selectable && (
<div className="absolute bottom-2 left-2 z-20">
<span className={cn('flex h-6 w-6 items-center justify-center rounded-full border-2', selected ? 'border-[#ccff00] bg-[#ccff00] text-black' : 'border-white/50 bg-black/50')}>
{selected && <Check className="h-3.5 w-3.5" />}
</span>
</div>
)}
<div className={cn('absolute inset-0 bg-black/85 transition-all duration-200 flex flex-col items-center justify-center gap-2 backdrop-blur-sm', selectable ? 'hidden' : 'opacity-100 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100')}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="h-8 border-white/30" onClick={(e) => { e.stopPropagation(); onEdit(); }}> <Button size="sm" variant="outline" className="h-8 border-white/30" onClick={(e) => { e.stopPropagation(); onEdit(); }}>
<Edit3 className="h-3.5 w-3.5 mr-1" />Edit <Edit3 className="h-3.5 w-3.5 mr-1" />Edit
@ -605,7 +634,7 @@ export default function StashPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { items, stats, isLoading, addItem, updateItem, removeItem, importItems, isEncryptionAvailable } = useStash(); const { items, stats, isLoading, addItem, updateItem, removeItem, importItems, bulkSetVisibility, bulkSetForSale, bulkRemove, isEncryptionAvailable } = useStash();
const { syncItem: syncShowcase, unpublishItem: unpublishShowcase } = useStashShowcaseSync(); const { syncItem: syncShowcase, unpublishItem: unpublishShowcase } = useStashShowcaseSync();
const { toast } = useToast(); const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -757,9 +786,79 @@ export default function StashPage() {
}); });
setPendingDeleteId(null); setPendingDeleteId(null);
}; };
// ── Bulk selection (multi-select delete / visibility / for-sale) ──────────
const [selecting, setSelecting] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const toggleSelect = (id: string) =>
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const exitSelecting = () => {
setSelecting(false);
setSelectedIds(new Set());
};
const handleBulkVisibility = (visibility: StashItem['visibility']) => {
const ids = [...selectedIds];
if (ids.length === 0) return;
bulkSetVisibility.mutate(
{ ids, visibility },
{
onSuccess: ({ changed }) => {
// Keep the public showcase in step with the new visibility.
for (const it of changed) {
if (it.visibility === 'public' && it.itemType !== 'wtb') syncShowcase(it).catch(() => {});
else unpublishShowcase(it.id).catch(() => {});
}
toast({ title: `Updated ${changed.length} item${changed.length === 1 ? '' : 's'}` });
exitSelecting();
},
},
);
};
const handleBulkForSale = (forSale: boolean) => {
const ids = [...selectedIds];
if (ids.length === 0) return;
bulkSetForSale.mutate(
{ ids, forSale },
{
onSuccess: ({ changed }) => {
for (const it of changed) {
if (it.visibility === 'public' && it.itemType !== 'wtb') syncShowcase(it).catch(() => {});
}
toast({ title: forSale ? `Marked ${changed.length} for sale` : `Unmarked ${changed.length}` });
exitSelecting();
},
},
);
};
const handleBulkDelete = () => {
const ids = [...selectedIds];
if (ids.length === 0) return;
bulkRemove.mutate(ids, {
onSuccess: ({ removed }) => {
for (const it of removed) {
if (it.visibility === 'public') unpublishShowcase(it.id).catch(() => {});
}
toast({ title: `Removed ${removed.length} item${removed.length === 1 ? '' : 's'}` });
setBulkDeleteOpen(false);
exitSelecting();
},
});
};
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [filterCategory, setFilterCategory] = useState<string>('all'); const [filterCategory, setFilterCategory] = useState<string>('all');
const [filterLocation, setFilterLocation] = useState<string>('all'); const [filterLocation, setFilterLocation] = useState<string>('all');
const [filterSort, setFilterSort] = useState<'recent' | 'value' | 'condition' | 'az'>('recent');
const [activeTab, setActiveTab] = useState<'stash' | 'wishlist'>('stash'); const [activeTab, setActiveTab] = useState<'stash' | 'wishlist'>('stash');
// Grid default on desktop, list on mobile // Grid default on desktop, list on mobile
const [viewMode, setViewMode] = useState<'list' | 'grid'>(() => const [viewMode, setViewMode] = useState<'list' | 'grid'>(() =>
@ -791,20 +890,69 @@ export default function StashPage() {
const wishlistItems = useMemo(() => items.filter(i => i.itemType === 'wtb'), [items]); const wishlistItems = useMemo(() => items.filter(i => i.itemType === 'wtb'), [items]);
const displayItems = activeTab === 'stash' ? stashItems : wishlistItems; const displayItems = activeTab === 'stash' ? stashItems : wishlistItems;
const filteredItems = displayItems.filter(item => { const filteredItems = displayItems
.filter((item) => {
if (searchQuery) { if (searchQuery) {
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
if (!item.title.toLowerCase().includes(q) && !item.brand?.toLowerCase().includes(q) && !item.description?.toLowerCase().includes(q)) return false; if (
!item.title.toLowerCase().includes(q) &&
!item.brand?.toLowerCase().includes(q) &&
!item.description?.toLowerCase().includes(q) &&
!item.size?.toLowerCase().includes(q)
) {
return false;
}
} }
if (filterCategory !== 'all' && item.category !== filterCategory) return false; if (filterCategory !== 'all' && item.category !== filterCategory) return false;
if (filterLocation !== 'all' && item.storageLocation !== filterLocation) return false; if (filterLocation !== 'all' && item.storageLocation !== filterLocation) return false;
return true; return true;
})
.sort((a, b) => {
switch (filterSort) {
case 'value':
return (b.minPrice ?? b.maxPrice ?? 0) - (a.minPrice ?? a.maxPrice ?? 0);
case 'condition':
return (CONDITION_SORT_RANK[a.condition] ?? 9) - (CONDITION_SORT_RANK[b.condition] ?? 9);
case 'az':
return a.title.localeCompare(b.title);
case 'recent':
default:
return b.addedAt - a.addedAt;
}
}); });
const hasForSale = stashItems.filter(i => i.willingToSell || i.willingToTrade).length; const hasForSale = stashItems.filter(i => i.willingToSell || i.willingToTrade).length;
const activeCategoryChips = Object.entries(STASH_CATEGORIES).filter(([key]) => displayItems.some(i => i.category === key)); const activeCategoryChips = Object.entries(STASH_CATEGORIES).filter(([key]) => displayItems.some(i => i.category === key));
const showCategoryChips = activeCategoryChips.length > 1; const showCategoryChips = activeCategoryChips.length > 1;
// Public items are published as separate NIP-99 listings — show which ones are
// visible to others, with a link to the real profile view.
const publicItems = stashItems.filter((i) => i.visibility === 'public');
const profileHref = user?.pubkey ? `/${nip19.npubEncode(user.pubkey)}` : '/';
const publicCard = publicItems.length > 0 && (
<div className="space-y-3 rounded-2xl border border-emerald-500/20 bg-gradient-to-br from-emerald-500/8 to-transparent p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-emerald-400" />
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-300">Public stash</span>
</div>
<span className="text-2xl font-black text-emerald-400">{publicItems.length}</span>
</div>
<div className="flex flex-wrap gap-1">
{publicItems.slice(0, 4).map((i) => (
<span key={i.id} className="max-w-[9rem] truncate rounded-full bg-white/6 px-2 py-0.5 text-[10px] text-gray-300">{i.title}</span>
))}
{publicItems.length > 4 && <span className="text-[10px] text-gray-500">+{publicItems.length - 4}</span>}
</div>
<p className="text-[11px] leading-snug text-gray-500">Anyone can see these on your profile.</p>
<Link to={profileHref} className="block">
<Button size="sm" variant="outline" className="w-full gap-1.5 border-emerald-500/30 text-emerald-300 hover:bg-emerald-500/10">
<Globe className="h-3.5 w-3.5" /> See what others see
</Button>
</Link>
</div>
);
// ── Not logged in ── // ── Not logged in ──
if (!user) { if (!user) {
return ( return (
@ -887,6 +1035,22 @@ export default function StashPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
{/* Bulk select */}
{stats.total > 0 && (
<button
type="button"
onClick={() => (selecting ? exitSelecting() : setSelecting(true))}
className={cn(
'flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-bold transition-colors',
selecting
? 'border-[#ccff00]/50 bg-[#ccff00]/15 text-[#ccff00]'
: 'border-white/10 text-gray-400 hover:text-white',
)}
>
{selecting ? <X className="h-3.5 w-3.5" /> : <Check className="h-3.5 w-3.5" />}
{selecting ? 'Cancel' : 'Select'}
</button>
)}
{/* View toggle — only useful when there are items to view */} {/* View toggle — only useful when there are items to view */}
{stats.total > 0 && ( {stats.total > 0 && (
<div className="flex items-center rounded-lg border border-white/10 overflow-hidden"> <div className="flex items-center rounded-lg border border-white/10 overflow-hidden">
@ -933,6 +1097,15 @@ export default function StashPage() {
{Object.entries(STASH_CATEGORIES).map(([key, cat]) => <SelectItem key={key} value={key}>{cat.emoji} {cat.label}</SelectItem>)} {Object.entries(STASH_CATEGORIES).map(([key, cat]) => <SelectItem key={key} value={key}>{cat.emoji} {cat.label}</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={filterSort} onValueChange={(v) => setFilterSort(v as typeof filterSort)}>
<SelectTrigger className="w-[110px] h-8 bg-white/4 border-white/8 text-sm flex-shrink-0" aria-label="Sort"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="recent">Newest</SelectItem>
<SelectItem value="value">Value</SelectItem>
<SelectItem value="condition">Condition</SelectItem>
<SelectItem value="az">A–Z</SelectItem>
</SelectContent>
</Select>
{showCategoryChips && ( {showCategoryChips && (
<div className="flex items-center gap-1.5 overflow-x-auto scrollbar-none"> <div className="flex items-center gap-1.5 overflow-x-auto scrollbar-none">
{activeCategoryChips.map(([key, cat]) => { {activeCategoryChips.map(([key, cat]) => {
@ -1024,6 +1197,7 @@ export default function StashPage() {
<div className="space-y-2"> <div className="space-y-2">
{filteredItems.map((item) => ( {filteredItems.map((item) => (
<StashListRow key={item.id} item={item} isWTB={item.itemType === 'wtb'} <StashListRow key={item.id} item={item} isWTB={item.itemType === 'wtb'}
selectable={selecting} selected={selectedIds.has(item.id)} onToggleSelect={() => toggleSelect(item.id)}
onEdit={() => setEditingItem(item)} onDelete={() => handleRemoveItem(item.id)} onListOnMarketplace={() => setListingItem(item)} /> onEdit={() => setEditingItem(item)} onDelete={() => handleRemoveItem(item.id)} onListOnMarketplace={() => setListingItem(item)} />
))} ))}
</div> </div>
@ -1031,6 +1205,7 @@ export default function StashPage() {
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredItems.map((item) => ( {filteredItems.map((item) => (
<StashItemCard key={item.id} item={item} isWTB={item.itemType === 'wtb'} <StashItemCard key={item.id} item={item} isWTB={item.itemType === 'wtb'}
selectable={selecting} selected={selectedIds.has(item.id)} onToggleSelect={() => toggleSelect(item.id)}
onEdit={() => setEditingItem(item)} onDelete={() => handleRemoveItem(item.id)} onListOnMarketplace={() => setListingItem(item)} /> onEdit={() => setEditingItem(item)} onDelete={() => handleRemoveItem(item.id)} onListOnMarketplace={() => setListingItem(item)} />
))} ))}
</div> </div>
@ -1179,6 +1354,9 @@ export default function StashPage() {
</div> </div>
)} )}
{/* Public stash preview */}
{publicCard}
{/* Backup & transfer */} {/* Backup & transfer */}
{backupCard} {backupCard}
@ -1220,8 +1398,66 @@ export default function StashPage() {
)} )}
{/* Backup & transfer — mobile (the sidebar is desktop-only). */} {/* Backup & transfer — mobile (the sidebar is desktop-only). */}
<div className="lg:hidden mt-4">{backupCard}</div> <div className="mt-4 space-y-4 lg:hidden">
{publicCard}
{backupCard}
</div> </div>
</div>
{/* ── Bulk action bar ── */}
{selecting && (
<div
className="fixed inset-x-0 z-40 border-t border-white/10 bg-black/95 px-3 py-2.5 backdrop-blur-md"
style={{ bottom: 'calc(3.5rem + env(safe-area-inset-bottom, 0px))' }}
>
<div className="mx-auto flex max-w-7xl flex-wrap items-center gap-2">
<span className="text-sm font-bold text-white">{selectedIds.size} selected</span>
<button
type="button"
onClick={() => setSelectedIds(new Set(filteredItems.map((i) => i.id)))}
className="text-xs font-bold text-[#ccff00] hover:underline"
>
Select all
</button>
<div className="ml-auto flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" className="h-8 gap-1.5 border-white/15" disabled={selectedIds.size === 0} onClick={() => handleBulkVisibility('public')}>
<Globe className="h-3.5 w-3.5" /> Public
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1.5 border-white/15" disabled={selectedIds.size === 0} onClick={() => handleBulkVisibility('private')}>
<Lock className="h-3.5 w-3.5" /> Private
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1.5 border-green-500/30 text-green-400 hover:bg-green-500/10" disabled={selectedIds.size === 0} onClick={() => handleBulkForSale(true)}>
<Zap className="h-3.5 w-3.5" /> For sale
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1.5 border-red-500/30 text-red-400 hover:bg-red-500/10" disabled={selectedIds.size === 0} onClick={() => setBulkDeleteOpen(true)}>
<Trash2 className="h-3.5 w-3.5" /> Remove
</Button>
<Button size="sm" variant="ghost" className="h-8" onClick={exitSelecting}>Done</Button>
</div>
</div>
</div>
)}
{/* Bulk delete confirmation */}
<AlertDialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Remove {selectedIds.size} item{selectedIds.size === 1 ? '' : 's'}?
</AlertDialogTitle>
<AlertDialogDescription>
This rewrites your encrypted stash in one signature and can&apos;t be undone here.
Export a backup first if you&apos;re not sure.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleBulkDelete} className="bg-red-500 text-white hover:bg-red-600">
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Mobile FABs */} {/* Mobile FABs */}
<div className="fixed md:hidden z-40 flex flex-col items-end gap-2" style={{ bottom: 'calc(3.5rem + env(safe-area-inset-bottom, 0px) + 0.75rem)', right: '0.75rem' }}> <div className="fixed md:hidden z-40 flex flex-col items-end gap-2" style={{ bottom: 'calc(3.5rem + env(safe-area-inset-bottom, 0px) + 0.75rem)', right: '0.75rem' }}>