209 lines
No EOL
7.5 KiB
TypeScript
209 lines
No EOL
7.5 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { cardsApi } from '@/api/cards'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Search, Check } from 'lucide-react'
|
|
|
|
interface PackCardsManagerProps {
|
|
currentCardIds: string[]
|
|
onCardsChange: (addIds: string[], removeIds: string[]) => void
|
|
disabled?: boolean
|
|
}
|
|
|
|
export function PackCardsManager({
|
|
currentCardIds,
|
|
onCardsChange,
|
|
disabled = false,
|
|
}: PackCardsManagerProps) {
|
|
const [search, setSearch] = useState('')
|
|
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set())
|
|
const [removedCards, setRemovedCards] = useState<Set<string>>(new Set())
|
|
|
|
// Load all cards with search
|
|
const { data: cardsData, isLoading } = useQuery({
|
|
queryKey: ['cards', 1, 100, search], // Limit to 100 (backend max)
|
|
queryFn: () => cardsApi.getCards({ page: 1, limit: 100, search }),
|
|
enabled: !disabled,
|
|
})
|
|
|
|
// Initialize selected cards from currentCardIds when it changes
|
|
useEffect(() => {
|
|
const initialSelected = new Set<string>(
|
|
currentCardIds.filter((id) => !removedCards.has(id.toString()))
|
|
)
|
|
setSelectedCards(initialSelected)
|
|
// Reset removed cards when currentCardIds changes (e.g., when opening dialog)
|
|
setRemovedCards(new Set())
|
|
}, [currentCardIds.join(',')]) // Use join to detect array changes
|
|
|
|
// Calculate which cards to add/remove when selection changes
|
|
useEffect(() => {
|
|
const currentlySelected = Array.from(selectedCards)
|
|
const removed = Array.from(removedCards)
|
|
|
|
// Cards to add: selected but not in currentCardIds and not removed
|
|
const toAdd = currentlySelected.filter(
|
|
(id) => !currentCardIds.includes(id) && !removed.includes(id)
|
|
)
|
|
// Cards to remove: in removedCards
|
|
const toRemove = removed.filter((id) => currentCardIds.includes(id))
|
|
|
|
if (toAdd.length > 0 || toRemove.length > 0) {
|
|
onCardsChange(toAdd, toRemove)
|
|
} else if (selectedCards.size > 0 || removedCards.size > 0) {
|
|
// Also notify if selection was cleared
|
|
onCardsChange([], [])
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedCards.size, removedCards.size, currentCardIds.join(',')])
|
|
|
|
const handleToggleCard = (cardId: string | number) => {
|
|
if (disabled) return
|
|
|
|
const cardIdStr = String(cardId)
|
|
const isCurrentlySelected = selectedCards.has(cardIdStr) && !removedCards.has(cardIdStr)
|
|
const isInCurrentPack = currentCardIds.includes(cardIdStr)
|
|
|
|
if (isCurrentlySelected) {
|
|
// Deselect card
|
|
const newSelected = new Set(selectedCards)
|
|
newSelected.delete(cardIdStr)
|
|
setSelectedCards(newSelected)
|
|
|
|
// If it was in current pack, mark as removed
|
|
if (isInCurrentPack) {
|
|
setRemovedCards((prev) => new Set([...prev, cardIdStr]))
|
|
}
|
|
} else {
|
|
// Select card
|
|
const newSelected = new Set(selectedCards)
|
|
newSelected.add(cardIdStr)
|
|
setSelectedCards(newSelected)
|
|
|
|
// If it was marked as removed, unmark it
|
|
if (removedCards.has(cardIdStr)) {
|
|
setRemovedCards((prev) => {
|
|
const newRemoved = new Set(prev)
|
|
newRemoved.delete(cardIdStr)
|
|
return newRemoved
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
const isCardSelected = (cardId: string | number) => {
|
|
const cardIdStr = String(cardId)
|
|
return selectedCards.has(cardIdStr) && !removedCards.has(cardIdStr)
|
|
}
|
|
|
|
const isCardInCurrentPack = (cardId: string | number) => {
|
|
return currentCardIds.includes(String(cardId))
|
|
}
|
|
|
|
const allCards = cardsData?.items || []
|
|
const filteredCards = search
|
|
? allCards.filter(
|
|
(card) =>
|
|
card.original?.toLowerCase().includes(search.toLowerCase()) ||
|
|
card.translation?.toLowerCase().includes(search.toLowerCase()) ||
|
|
card.mnemo?.toLowerCase().includes(search.toLowerCase())
|
|
)
|
|
: allCards
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>Manage Cards in Pack</Label>
|
|
<div className="flex items-center space-x-2">
|
|
<Search className="h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search cards by original, translation, or mnemo..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="max-w-sm"
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
Selected cards: {selectedCards.size - removedCards.size} / {allCards.length}
|
|
</p>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="text-center py-4">Loading cards...</div>
|
|
) : (
|
|
<div className="border rounded-lg max-h-[400px] overflow-y-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-12"></TableHead>
|
|
<TableHead>ID</TableHead>
|
|
<TableHead>Original</TableHead>
|
|
<TableHead>Translation</TableHead>
|
|
<TableHead>Mnemo</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredCards.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
|
No cards found
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
filteredCards.map((card) => {
|
|
const cardIdStr = card.id ? String(card.id) : `temp-${card.original}`
|
|
const selected = isCardSelected(cardIdStr)
|
|
const inCurrentPack = isCardInCurrentPack(cardIdStr)
|
|
const newlyRemoved = removedCards.has(cardIdStr)
|
|
|
|
return (
|
|
<TableRow
|
|
key={card.id || `temp-${card.original}`}
|
|
className={selected ? 'bg-muted/50' : ''}
|
|
onClick={() => handleToggleCard(cardIdStr)}
|
|
>
|
|
<TableCell>
|
|
{selected ? (
|
|
<Check className="h-4 w-4 text-primary" />
|
|
) : (
|
|
<div className="h-4 w-4 border rounded" />
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm">{card.id || '-'}</TableCell>
|
|
<TableCell className="font-medium">{card.original}</TableCell>
|
|
<TableCell>{card.translation}</TableCell>
|
|
<TableCell className="max-w-xs truncate">{card.mnemo}</TableCell>
|
|
<TableCell>
|
|
{newlyRemoved ? (
|
|
<Badge variant="destructive">Removed</Badge>
|
|
) : selected && inCurrentPack ? (
|
|
<Badge variant="default">In Pack</Badge>
|
|
) : selected ? (
|
|
<Badge variant="secondary">Selected</Badge>
|
|
) : inCurrentPack ? (
|
|
<Badge variant="outline">In Pack</Badge>
|
|
) : null}
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
})
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
} |