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:
parent
5a8e3688ee
commit
1311f699f9
2 changed files with 317 additions and 26 deletions
|
|
@ -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
|
||||
const stats = useMemo(() => {
|
||||
if (!stash) return { total: 0, byCategory: {}, totalValue: 0 };
|
||||
|
|
@ -337,6 +389,9 @@ export function useStash(options?: { enabled?: boolean }) {
|
|||
updateItem,
|
||||
removeItem,
|
||||
importItems,
|
||||
bulkSetVisibility,
|
||||
bulkSetForSale,
|
||||
bulkRemove,
|
||||
isEncryptionAvailable: hasNip44,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,13 +54,18 @@ import {
|
|||
MapPin,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Check,
|
||||
Download,
|
||||
Upload,
|
||||
HardDrive,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
const RARITY_COLORS = {
|
||||
common: 'from-gray-500 to-gray-600',
|
||||
|
|
@ -85,8 +90,9 @@ const RARITY_BORDER = {
|
|||
};
|
||||
|
||||
// ─── 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;
|
||||
selectable?: boolean; selected?: boolean; onToggleSelect?: () => void;
|
||||
}) {
|
||||
const category = STASH_CATEGORIES[item.category];
|
||||
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);
|
||||
|
||||
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]',
|
||||
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])} />
|
||||
{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">
|
||||
{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>
|
||||
|
|
@ -143,17 +158,21 @@ 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>}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<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" />
|
||||
</button>
|
||||
{canListOnMarketplace && onListOnMarketplace && (
|
||||
<button onClick={(e) => { e.stopPropagation(); onListOnMarketplace(); }} className="w-7 h-7 rounded-lg bg-[#ff6600]/20 hover:bg-[#ff6600]/40 flex items-center justify-center transition-colors touch-manipulation active:scale-90">
|
||||
<Store className="h-3.5 w-3.5 text-[#ff6600]" />
|
||||
</button>
|
||||
{!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">
|
||||
<Edit3 className="h-3.5 w-3.5 text-gray-300" />
|
||||
</button>
|
||||
{canListOnMarketplace && onListOnMarketplace && (
|
||||
<button onClick={(e) => { e.stopPropagation(); onListOnMarketplace(); }} className="w-7 h-7 rounded-lg bg-[#ff6600]/20 hover:bg-[#ff6600]/40 flex items-center justify-center transition-colors touch-manipulation active:scale-90">
|
||||
<Store className="h-3.5 w-3.5 text-[#ff6600]" />
|
||||
</button>
|
||||
)}
|
||||
<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" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -161,8 +180,9 @@ function StashListRow({ item, onEdit, onDelete, onListOnMarketplace, isWTB = fal
|
|||
}
|
||||
|
||||
// ─── 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;
|
||||
selectable?: boolean; selected?: boolean; onToggleSelect?: () => void;
|
||||
}) {
|
||||
const category = STASH_CATEGORIES[item.category];
|
||||
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);
|
||||
|
||||
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])}>
|
||||
{rarity !== 'common' && <div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-shimmer" />}
|
||||
</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>
|
||||
</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">
|
||||
<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
|
||||
|
|
@ -605,7 +634,7 @@ export default function StashPage() {
|
|||
|
||||
const navigate = useNavigate();
|
||||
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 { toast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -757,9 +786,79 @@ export default function StashPage() {
|
|||
});
|
||||
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 [filterCategory, setFilterCategory] = 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');
|
||||
// Grid default on desktop, list on mobile
|
||||
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 displayItems = activeTab === 'stash' ? stashItems : wishlistItems;
|
||||
|
||||
const filteredItems = displayItems.filter(item => {
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
if (!item.title.toLowerCase().includes(q) && !item.brand?.toLowerCase().includes(q) && !item.description?.toLowerCase().includes(q)) return false;
|
||||
}
|
||||
if (filterCategory !== 'all' && item.category !== filterCategory) return false;
|
||||
if (filterLocation !== 'all' && item.storageLocation !== filterLocation) return false;
|
||||
return true;
|
||||
});
|
||||
const filteredItems = displayItems
|
||||
.filter((item) => {
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
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 (filterLocation !== 'all' && item.storageLocation !== filterLocation) return false;
|
||||
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 activeCategoryChips = Object.entries(STASH_CATEGORIES).filter(([key]) => displayItems.some(i => i.category === key));
|
||||
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 ──
|
||||
if (!user) {
|
||||
return (
|
||||
|
|
@ -887,6 +1035,22 @@ export default function StashPage() {
|
|||
</div>
|
||||
</div>
|
||||
<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 */}
|
||||
{stats.total > 0 && (
|
||||
<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>)}
|
||||
</SelectContent>
|
||||
</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 && (
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto scrollbar-none">
|
||||
{activeCategoryChips.map(([key, cat]) => {
|
||||
|
|
@ -1024,6 +1197,7 @@ export default function StashPage() {
|
|||
<div className="space-y-2">
|
||||
{filteredItems.map((item) => (
|
||||
<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)} />
|
||||
))}
|
||||
</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">
|
||||
{filteredItems.map((item) => (
|
||||
<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)} />
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -1179,6 +1354,9 @@ export default function StashPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Public stash preview */}
|
||||
{publicCard}
|
||||
|
||||
{/* Backup & transfer */}
|
||||
{backupCard}
|
||||
|
||||
|
|
@ -1220,9 +1398,67 @@ export default function StashPage() {
|
|||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* ── 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't be undone here.
|
||||
Export a backup first if you'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 */}
|
||||
<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' }}>
|
||||
<AddItemDialog onAdd={handleAddItem} mode="wtb"
|
||||
|
|
|
|||
Loading…
Reference in a new issue