Compare commits
4
Commits
f3ed971592
...
907484fc7f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
907484fc7f | ||
|
|
9dbbb88730 | ||
|
|
1078062b53 | ||
|
|
94126567e7 |
+114
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user