4 Commits
Author SHA1 Message Date
david raistrick 907484fc7f Character drag reorder via players array
Character list items draggable (ChevronsUpDown handle). Drop reorders
players array on campaign doc (single updateDoc). Array order = display
order.
2026-07-07 15:40:58 -04:00
david raistrick 9dbbb88730 Campaign drag reorder: fix sort in fetchDetails path
fetchDetails (db active path) sorted by createdAt, ignored order field.
Drag persisted but UI didn't re-sort. Now both paths sort by order
(fallback createdAt).
2026-07-07 15:37:04 -04:00
david raistrick 1078062b53 Campaign drag reorder via order field
Campaign cards draggable (ChevronsUpDown handle in header). Drop reorders,
batch updates order field on affected campaign docs. Sort by order (fallback
createdAt). New campaign gets order = max+1.
2026-07-07 15:24:12 -04:00
david raistrick 94126567e7 Encounter drag reorder via order field
Encounter cards draggable (ChevronsUpDown handle). Drop reorders, batch
updates order field on affected encounter docs. Sort by order (fallback
createdAt). New encounter gets order = max+1.
2026-07-07 15:18:08 -04:00
+114 -6
View File
@@ -682,6 +682,22 @@ function CharacterManager({ campaignId, campaignCharacters }) {
const [editingCharacter, setEditingCharacter] = useState(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [draggedCharId, setDraggedCharId] = useState(null);
const handleCharDragStart = (e, id) => { setDraggedCharId(id); e.dataTransfer.effectAllowed = 'move'; };
const handleCharDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; };
const handleCharDrop = async (e, targetId) => {
e.preventDefault();
if (!campaignId || !draggedCharId || draggedCharId === targetId) { setDraggedCharId(null); return; }
const reordered = [...campaignCharacters];
const fromIdx = reordered.findIndex(c => c.id === draggedCharId);
const toIdx = reordered.findIndex(c => c.id === targetId);
if (fromIdx === -1 || toIdx === -1) { setDraggedCharId(null); return; }
const [moved] = reordered.splice(fromIdx, 1);
reordered.splice(toIdx, 0, moved);
try { await storage.updateDoc(getPath.campaign(campaignId), { players: reordered }); } catch (err) {}
setDraggedCharId(null);
};
const [isOpen, setIsOpen] = useState(() => {
try { return localStorage.getItem('ttrpg.charactersCollapsed') !== 'true'; }
catch { return true; }
@@ -841,7 +857,17 @@ function CharacterManager({ campaignId, campaignCharacters }) {
<ul className="space-y-2">
{campaignCharacters.map(character => (
<li key={character.id} className="flex justify-between items-center p-3 bg-stone-800 rounded-md">
<li
key={character.id}
draggable
onDragStart={(e) => handleCharDragStart(e, character.id)}
onDragOver={handleCharDragOver}
onDrop={(e) => handleCharDrop(e, character.id)}
onDragEnd={() => setDraggedCharId(null)}
className={`flex justify-between items-center p-3 bg-stone-800 rounded-md cursor-grab ${draggedCharId === character.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`}
>
<div className="flex items-center flex-grow">
<ChevronsUpDown size={16} className="text-stone-400 flex-shrink-0 mr-2" title="Drag to reorder" />
{editingCharacter && editingCharacter.id === character.id ? (
<form
onSubmit={(e) => {
@@ -917,6 +943,7 @@ function CharacterManager({ campaignId, campaignCharacters }) {
</div>
</>
)}
</div>
</li>
))}
</ul>
@@ -2012,6 +2039,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
const [showCreateModal, setShowCreateModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [draggedEncounterId, setDraggedEncounterId] = useState(null);
const selectedEncounterIdRef = useRef(selectedEncounterId);
@@ -2019,6 +2047,45 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
if (encountersData) setEncounters(encountersData);
}, [encountersData]);
// Sort by order field (fallback createdAt). Drag reorder writes order.
const sortedEncounters = [...(encounters || [])].sort((a, b) => {
const ao = a.order ?? null, bo = b.order ?? null;
if (ao !== null && bo !== null) return ao - bo;
if (ao !== null) return -1;
if (bo !== null) return 1;
return (a.createdAt || '').localeCompare(b.createdAt || '');
});
const handleEncounterDragStart = (e, id) => {
setDraggedEncounterId(id);
e.dataTransfer.effectAllowed = 'move';
};
const handleEncounterDragOver = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleEncounterDrop = async (e, targetId) => {
e.preventDefault();
if (!db || !draggedEncounterId || draggedEncounterId === targetId) {
setDraggedEncounterId(null);
return;
}
const reordered = [...sortedEncounters];
const fromIdx = reordered.findIndex(x => x.id === draggedEncounterId);
const toIdx = reordered.findIndex(x => x.id === targetId);
if (fromIdx === -1 || toIdx === -1) { setDraggedEncounterId(null); return; }
const [moved] = reordered.splice(fromIdx, 1);
reordered.splice(toIdx, 0, moved);
// assign order = index, batch update
const ops = reordered.map((enc, i) => ({
type: 'update',
path: `${getPath.encounters(campaignId)}/${enc.id}`,
data: { order: i },
}));
try { await storage.batchWrite(ops); } catch (err) {}
setDraggedEncounterId(null);
};
useEffect(() => {
selectedEncounterIdRef.current = selectedEncounterId;
}, [selectedEncounterId]);
@@ -2063,6 +2130,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
isStarted: false,
isPaused: false,
ruleset: ruleset || '5e',
order: (encounters || []).reduce((max, e) => Math.max(max, e.order ?? -1), -1) + 1,
});
setShowCreateModal(false);
@@ -2169,7 +2237,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
)}
<div className="space-y-3">
{encounters?.map(encounter => {
{sortedEncounters.map(encounter => {
const isLive = activeDisplayInfo &&
activeDisplayInfo.activeCampaignId === campaignId &&
activeDisplayInfo.activeEncounterId === encounter.id;
@@ -2177,7 +2245,12 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
return (
<div
key={encounter.id}
className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? 'bg-amber-900 ring-2 ring-amber-500' : 'bg-stone-800 hover:bg-stone-700'} ${isLive ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : ''}`}
draggable
onDragStart={(e) => handleEncounterDragStart(e, encounter.id)}
onDragOver={handleEncounterDragOver}
onDrop={(e) => handleEncounterDrop(e, encounter.id)}
onDragEnd={() => setDraggedEncounterId(null)}
className={`p-3 rounded-md shadow transition-all ${selectedEncounterId === encounter.id ? 'bg-amber-900 ring-2 ring-amber-500' : 'bg-stone-800 hover:bg-stone-700'} ${isLive ? 'ring-2 ring-green-500 shadow-md shadow-green-500/30' : ''} ${draggedEncounterId === encounter.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} cursor-grab`}
>
<div className="flex justify-between items-center">
<div onClick={() => setSelectedEncounterId(encounter.id)} className="cursor-pointer flex-grow">
@@ -2192,6 +2265,11 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
)}
</div>
<div className="flex items-center space-x-2">
<ChevronsUpDown
size={18}
className="text-stone-400 flex-shrink-0 cursor-grab"
title="Drag to reorder"
/>
<button
onClick={() => handleTogglePlayerDisplay(encounter.id)}
className={`p-1 rounded transition-colors ${isLive ? 'bg-red-500 hover:bg-red-600 text-white' : 'text-amber-400 hover:text-amber-300 bg-stone-700 hover:bg-stone-600'}`}
@@ -2279,6 +2357,7 @@ function AdminView({ userId }) {
const { data: initialActiveInfo } = useFirestoreDocument(getPath.activeDisplay());
const [campaignsWithDetails, setCampaignsWithDetails] = useState([]);
const [draggedCampaignId, setDraggedCampaignId] = useState(null);
const [selectedCampaignId, setSelectedCampaignId] = useState(null);
const encounterStartedRef = useRef(false);
const encounterActiveRef = useRef(false);
@@ -2325,8 +2404,12 @@ function AdminView({ userId }) {
})
);
// newest first by createdAt (fallback to name for stable order).
// sort by order field (fallback createdAt)
detailedCampaigns.sort((a, b) => {
const ao = a.order ?? null, bo = b.order ?? null;
if (ao !== null && bo !== null) return ao - bo;
if (ao !== null) return -1;
if (bo !== null) return 1;
const at = a.createdAt || 0;
const bt = b.createdAt || 0;
if (at === bt) return (a.name || '').localeCompare(b.name || '');
@@ -2339,6 +2422,10 @@ function AdminView({ userId }) {
fetchDetails();
} else if (campaignsData) {
const sorted = [...campaignsData].sort((a, b) => {
const ao = a.order ?? null, bo = b.order ?? null;
if (ao !== null && bo !== null) return ao - bo;
if (ao !== null) return -1;
if (bo !== null) return 1;
const at = a.createdAt || 0;
const bt = b.createdAt || 0;
if (at === bt) return (a.name || '').localeCompare(b.name || '');
@@ -2382,6 +2469,7 @@ function AdminView({ userId }) {
createdAt: new Date().toISOString(),
players: [],
ruleset: ruleset || '5e',
order: (campaignsWithDetails || []).reduce((max, c) => Math.max(max, c.order ?? -1), -1) + 1,
});
setShowCreateModal(false);
@@ -2528,11 +2616,28 @@ function AdminView({ userId }) {
? { backgroundImage: `url(${campaign.playerDisplayBackgroundUrl})` }
: {};
const cardClasses = `h-40 flex flex-col justify-between rounded-lg shadow-md cursor-pointer transition-all relative overflow-hidden bg-cover bg-center ${selectedCampaignId === campaign.id ? 'ring-4 ring-amber-500' : ''} ${!campaign.playerDisplayBackgroundUrl ? 'bg-stone-800 hover:bg-stone-700' : 'hover:shadow-xl'}`;
const cardClasses = `h-40 flex flex-col justify-between rounded-lg shadow-md cursor-grab transition-all relative overflow-hidden bg-cover bg-center ${selectedCampaignId === campaign.id ? 'ring-4 ring-amber-500' : ''} ${!campaign.playerDisplayBackgroundUrl ? 'bg-stone-800 hover:bg-stone-700' : 'hover:shadow-xl'} ${draggedCampaignId === campaign.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`;
return (
<div
key={campaign.id}
draggable
onDragStart={(e) => { setDraggedCampaignId(campaign.id); e.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
onDrop={async (e) => {
e.preventDefault();
if (!db || !draggedCampaignId || draggedCampaignId === campaign.id) { setDraggedCampaignId(null); return; }
const reordered = [...campaignsWithDetails];
const fromIdx = reordered.findIndex(c => c.id === draggedCampaignId);
const toIdx = reordered.findIndex(c => c.id === campaign.id);
if (fromIdx === -1 || toIdx === -1) { setDraggedCampaignId(null); return; }
const [moved] = reordered.splice(fromIdx, 1);
reordered.splice(toIdx, 0, moved);
const ops = reordered.map((c, i) => ({ type: 'update', path: getPath.campaign(c.id), data: { order: i } }));
try { await storage.batchWrite(ops); } catch (err) {}
setDraggedCampaignId(null);
}}
onDragEnd={() => setDraggedCampaignId(null)}
onClick={() => {
if (encounterStartedRef.current) {
showToast('End or pause active encounter before switching campaigns.');
@@ -2548,7 +2653,10 @@ function AdminView({ userId }) {
className={`relative z-10 flex flex-col justify-between h-full ${campaign.playerDisplayBackgroundUrl ? 'bg-black bg-opacity-60 p-3' : 'p-4'}`}
>
<div>
<h3 className="text-xl font-semibold text-white">{campaign.name}</h3>
<div className="flex items-center gap-1">
<ChevronsUpDown size={14} className="text-white/60 flex-shrink-0" title="Drag to reorder" />
<h3 className="text-xl font-semibold text-white">{campaign.name}</h3>
</div>
<div className="text-xs text-stone-100 mt-1 space-x-3">
<span className="inline-flex items-center">
<Users size={12} className="mr-1" /> {campaign.characters?.length || 0} Characters