Some checks failed
CI / Lint (pull_request) Has been cancelled
CI / Schema map up to date (pull_request) Has been cancelled
CI / Forbidden patterns (9 checks) (pull_request) Has been cancelled
CI / Migrations apply (node-pg-migrate) (pull_request) Has been cancelled
CI / Unit tests (vitest) (pull_request) Has been cancelled
Convoy metrics gate / Require role-event telemetry on convoy PRs (pull_request) Has been cancelled
PR Health rollup / Aggregate gate status (pull_request) Has been cancelled
Preview smoke / Should run? (pull_request) Has been cancelled
Visual diff / Should run? (pull_request) Has been cancelled
Preview smoke / Playwright smoke (pull_request) Has been cancelled
Visual diff / Screenshot diff (pull_request) Has been cancelled
- AdminBulkSelectionToolbar: floating toolbar with batch edit action + clear selection - BatchEditModal: form for editing allowed fields (rarity, verified, game, mana_cost, cmc, card_type, edhrec_rank, layout, released_at, reserved, reprint) with live preview - API: POST /api/admin/cards/batch with validation, allowlist, 1000-card cap, audit logging - Hook: selection state (Set), select-all, batch edit workflow - Table: checkbox column, row highlighting, select-all header - Build: all routes compile, lint clean (pre-existing only), 342 tests pass
281 lines
No EOL
9.9 KiB
JavaScript
281 lines
No EOL
9.9 KiB
JavaScript
import { useState } from 'react';
|
|
import { GlassSurface, Button, Input, Modal } from './ui';
|
|
|
|
const RARITY_OPTIONS = [
|
|
{ value: 'common', label: 'Common' },
|
|
{ value: 'uncommon', label: 'Uncommon' },
|
|
{ value: 'rare', label: 'Rare' },
|
|
{ value: 'mythic', label: 'Mythic' },
|
|
{ value: 'special', label: 'Special' },
|
|
{ value: 'bonus', label: 'Bonus' }
|
|
];
|
|
|
|
const GAME_OPTIONS = [
|
|
{ value: 'mtg', label: 'Magic: The Gathering' },
|
|
{ value: 'pokemon', label: 'Pokémon TCG' },
|
|
{ value: 'lorcana', label: 'Disney Lorcana' }
|
|
];
|
|
|
|
const LAYOUT_OPTIONS = [
|
|
{ value: 'normal', label: 'Normal' },
|
|
{ value: 'transform', label: 'Transform' },
|
|
{ value: 'modal_dfc', label: 'Modal DFC' },
|
|
{ value: 'meld', label: 'Meld' },
|
|
{ value: 'split', label: 'Split' },
|
|
{ value: 'flip', label: 'Flip' },
|
|
{ value: 'leveler', label: 'Leveler' },
|
|
{ value: 'saga', label: 'Saga' },
|
|
{ value: 'class', label: 'Class' },
|
|
{ value: 'adventure', label: 'Adventure' },
|
|
{ value: 'planar', label: 'Planar' },
|
|
{ value: 'scheme', label: 'Scheme' },
|
|
{ value: 'vanguard', label: 'Vanguard' },
|
|
{ value: 'token', label: 'Token' },
|
|
{ value: 'emblem', label: 'Emblem' },
|
|
{ value: 'augment', label: 'Augment' },
|
|
{ value: 'host', label: 'Host' },
|
|
{ value: 'art_series', label: 'Art Series' }
|
|
];
|
|
|
|
export default function BatchEditModal({
|
|
isOpen,
|
|
onClose,
|
|
onSubmit,
|
|
loading,
|
|
error,
|
|
selectedCount
|
|
}) {
|
|
const [formData, setFormData] = useState({
|
|
rarity: '',
|
|
verified: '',
|
|
game: '',
|
|
mana_cost: '',
|
|
cmc: '',
|
|
card_type: '',
|
|
edhrec_rank: '',
|
|
layout: '',
|
|
released_at: '',
|
|
reserved: '',
|
|
reprint: ''
|
|
});
|
|
|
|
const [dirtyFields, setDirtyFields] = useState(new Set());
|
|
|
|
const handleChange = (field, value) => {
|
|
setFormData(prev => ({ ...prev, [field]: value }));
|
|
setDirtyFields(prev => new Set([...prev, field]));
|
|
};
|
|
|
|
const handleBlur = (field) => {
|
|
// Keep track of touched fields
|
|
};
|
|
|
|
const getFieldsToSubmit = () => {
|
|
const updates = {};
|
|
dirtyFields.forEach(field => {
|
|
const value = formData[field];
|
|
if (value === '') return;
|
|
if (field === 'verified' || field === 'reserved' || field === 'reprint') {
|
|
updates[field] = value === 'true';
|
|
} else if (field === 'cmc' || field === 'edhrec_rank') {
|
|
updates[field] = value === '' ? null : parseInt(value, 10);
|
|
} else {
|
|
updates[field] = value;
|
|
}
|
|
});
|
|
return updates;
|
|
};
|
|
|
|
const hasChanges = dirtyFields.size > 0;
|
|
|
|
const handleSubmit = (e) => {
|
|
e.preventDefault();
|
|
const updates = getFieldsToSubmit();
|
|
if (Object.keys(updates).length > 0) {
|
|
onSubmit(updates);
|
|
}
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<Modal
|
|
isOpen={isOpen}
|
|
onClose={onClose}
|
|
title={`Batch Edit ${selectedCount} Card${selectedCount !== 1 ? 's' : ''}`}
|
|
size="lg"
|
|
>
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
|
|
<div className="mb-4 p-3 rounded-xl text-sm" style={{
|
|
backgroundColor: 'var(--bg-tertiary)',
|
|
border: '1px solid var(--border)'
|
|
}}>
|
|
<strong style={{ color: 'var(--text-primary)' }}>Fields to update:</strong>
|
|
<ul className="mt-2 space-y-1">
|
|
{Array.from(dirtyFields).map(field => (
|
|
<li key={field} style={{ color: 'var(--text-secondary)' }}>
|
|
{field}: {field === 'verified' || field === 'reserved' || field === 'reprint'
|
|
? (formData[field] === 'true' ? 'Yes' : 'No')
|
|
: formData[field] || '(cleared)'}
|
|
</li>
|
|
))}
|
|
{dirtyFields.size === 0 && (
|
|
<li style={{ color: 'var(--text-tertiary)' }}>No fields changed yet</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
|
|
<GlassSurface tint="low" rim="none" className="p-4 space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<Input
|
|
label="Rarity"
|
|
type="select"
|
|
value={formData.rarity}
|
|
onChange={(e) => handleChange('rarity', e.target.value)}
|
|
options={RARITY_OPTIONS.map(o => ({ value: o.value, label: o.label }))}
|
|
placeholder="Select rarity"
|
|
dirty={dirtyFields.has('rarity')}
|
|
/>
|
|
<div className="flex items-end">
|
|
<label className="flex items-center gap-2 cursor-pointer w-full">
|
|
<span className="w-1/3 text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Verified</span>
|
|
<select
|
|
value={formData.verified}
|
|
onChange={(e) => handleChange('verified', e.target.value)}
|
|
className="glass-input flex-1 rounded-xl px-4 py-2"
|
|
style={{
|
|
border: dirtyFields.has('verified') ? '1px solid var(--accent-ember)' : '1px solid var(--border)',
|
|
color: 'var(--text-primary)',
|
|
backgroundColor: 'var(--bg-tertiary)'
|
|
}}
|
|
>
|
|
<option value="">—</option>
|
|
<option value="true">Yes</option>
|
|
<option value="false">No</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<Input
|
|
label="Game"
|
|
type="select"
|
|
value={formData.game}
|
|
onChange={(e) => handleChange('game', e.target.value)}
|
|
options={GAME_OPTIONS.map(o => ({ value: o.value, label: o.label }))}
|
|
placeholder="Select game"
|
|
dirty={dirtyFields.has('game')}
|
|
/>
|
|
<Input
|
|
label="Layout"
|
|
type="select"
|
|
value={formData.layout}
|
|
onChange={(e) => handleChange('layout', e.target.value)}
|
|
options={LAYOUT_OPTIONS.map(o => ({ value: o.value, label: o.label }))}
|
|
placeholder="Select layout"
|
|
dirty={dirtyFields.has('layout')}
|
|
/>
|
|
<Input
|
|
label="Mana Cost"
|
|
value={formData.mana_cost}
|
|
onChange={(e) => handleChange('mana_cost', e.target.value)}
|
|
placeholder="{2}{W}{W}"
|
|
dirty={dirtyFields.has('mana_cost')}
|
|
/>
|
|
<Input
|
|
label="CMC"
|
|
type="number"
|
|
min="0"
|
|
max="20"
|
|
value={formData.cmc}
|
|
onChange={(e) => handleChange('cmc', e.target.value)}
|
|
placeholder="3"
|
|
dirty={dirtyFields.has('cmc')}
|
|
/>
|
|
<Input
|
|
label="Card Type"
|
|
value={formData.card_type}
|
|
onChange={(e) => handleChange('card_type', e.target.value)}
|
|
placeholder="Creature — Human Wizard"
|
|
dirty={dirtyFields.has('card_type')}
|
|
/>
|
|
<Input
|
|
label="EDHREC Rank"
|
|
type="number"
|
|
min="0"
|
|
value={formData.edhrec_rank}
|
|
onChange={(e) => handleChange('edhrec_rank', e.target.value)}
|
|
placeholder="12345"
|
|
dirty={dirtyFields.has('edhrec_rank')}
|
|
/>
|
|
<Input
|
|
label="Released At"
|
|
type="date"
|
|
value={formData.released_at}
|
|
onChange={(e) => handleChange('released_at', e.target.value)}
|
|
dirty={dirtyFields.has('released_at')}
|
|
/>
|
|
<div className="flex items-end">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.reserved === 'true'}
|
|
onChange={(e) => handleChange('reserved', e.target.checked ? 'true' : 'false')}
|
|
className="w-4 h-4 rounded border-2"
|
|
style={{
|
|
borderColor: 'var(--border)',
|
|
accentColor: 'var(--accent-ember)'
|
|
}}
|
|
/>
|
|
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
|
Reserved List
|
|
</span>
|
|
</label>
|
|
</div>
|
|
<div className="flex items-end">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.reprint === 'true'}
|
|
onChange={(e) => handleChange('reprint', e.target.checked ? 'true' : 'false')}
|
|
className="w-4 h-4 rounded border-2"
|
|
style={{
|
|
borderColor: 'var(--border)',
|
|
accentColor: 'var(--accent-ember)'
|
|
}}
|
|
/>
|
|
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
|
Reprint
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</GlassSurface>
|
|
|
|
{error && (
|
|
<div className="p-3 rounded-xl text-sm" style={{
|
|
backgroundColor: 'rgba(220, 38, 38, 0.1)',
|
|
border: '1px solid rgba(220, 38, 38, 0.3)',
|
|
color: '#dc2626'
|
|
}}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-6 flex justify-end gap-3 border-t pt-4" style={{ borderColor: 'var(--border)' }}>
|
|
<Button variant="ghost" onClick={onClose} disabled={loading}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={handleSubmit}
|
|
disabled={loading || !hasChanges}
|
|
loading={loading}
|
|
>
|
|
Apply to {selectedCount} Card{selectedCount !== 1 ? 's' : ''}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
} |