Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f04e3497f4 | ||
|
|
0ddf9846a2 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.2",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
@@ -20,6 +20,7 @@
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "^7.10.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"compression": "^1.8.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^18.0.6",
|
||||
"express": "^5.2.1",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- CreateIndex: the columns every list is looked up by. Postgres does not
|
||||
-- index a foreign key on its own.
|
||||
CREATE INDEX "locations_campaignId_idx" ON "locations"("campaignId");
|
||||
CREATE INDEX "campaign_members_userId_idx" ON "campaign_members"("userId");
|
||||
CREATE INDEX "characters_campaignId_idx" ON "characters"("campaignId");
|
||||
CREATE INDEX "characters_userId_idx" ON "characters"("userId");
|
||||
CREATE INDEX "weapons_campaignId_idx" ON "weapons"("campaignId");
|
||||
CREATE INDEX "items_campaignId_idx" ON "items"("campaignId");
|
||||
CREATE INDEX "spells_campaignId_idx" ON "spells"("campaignId");
|
||||
CREATE INDEX "monsters_campaignId_idx" ON "monsters"("campaignId");
|
||||
CREATE INDEX "npcs_campaignId_idx" ON "npcs"("campaignId");
|
||||
CREATE INDEX "encounters_campaignId_idx" ON "encounters"("campaignId");
|
||||
CREATE INDEX "encounter_rewards_encounterId_idx" ON "encounter_rewards"("encounterId");
|
||||
CREATE INDEX "sale_offers_campaignId_idx" ON "sale_offers"("campaignId");
|
||||
CREATE INDEX "sale_offers_npcId_idx" ON "sale_offers"("npcId");
|
||||
CREATE INDEX "sale_offers_encounterId_idx" ON "sale_offers"("encounterId");
|
||||
CREATE INDEX "encounter_participants_encounterId_idx" ON "encounter_participants"("encounterId");
|
||||
CREATE INDEX "encounter_participants_characterId_idx" ON "encounter_participants"("characterId");
|
||||
CREATE INDEX "notes_campaignId_idx" ON "notes"("campaignId");
|
||||
CREATE INDEX "notes_characterId_idx" ON "notes"("characterId");
|
||||
@@ -213,6 +213,7 @@ model Location {
|
||||
encounters Encounter[]
|
||||
visits CharacterLocationVisit[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("locations")
|
||||
}
|
||||
|
||||
@@ -243,6 +244,7 @@ model CampaignMember {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([campaignId, userId])
|
||||
@@index([userId])
|
||||
@@map("campaign_members")
|
||||
}
|
||||
|
||||
@@ -323,6 +325,8 @@ model Character {
|
||||
encounterEntries EncounterParticipant[]
|
||||
locationVisits CharacterLocationVisit[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@index([userId])
|
||||
@@map("characters")
|
||||
}
|
||||
|
||||
@@ -386,6 +390,7 @@ model Weapon {
|
||||
rewards EncounterReward[]
|
||||
offers SaleOffer[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("weapons")
|
||||
}
|
||||
|
||||
@@ -425,6 +430,7 @@ model Item {
|
||||
rewards EncounterReward[]
|
||||
offers SaleOffer[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("items")
|
||||
}
|
||||
|
||||
@@ -486,6 +492,7 @@ model Spell {
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
known CharacterSpell[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("spells")
|
||||
}
|
||||
|
||||
@@ -528,6 +535,7 @@ model Monster {
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
encounterAppearances EncounterParticipant[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("monsters")
|
||||
}
|
||||
|
||||
@@ -555,6 +563,7 @@ model Npc {
|
||||
encounterAppearances EncounterParticipant[]
|
||||
wares SaleOffer[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("npcs")
|
||||
}
|
||||
|
||||
@@ -590,6 +599,7 @@ model Encounter {
|
||||
rewards EncounterReward[]
|
||||
wares SaleOffer[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("encounters")
|
||||
}
|
||||
|
||||
@@ -615,6 +625,7 @@ model EncounterReward {
|
||||
weapon Weapon? @relation(fields: [weaponId], references: [id], onDelete: Cascade)
|
||||
item Item? @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([encounterId])
|
||||
@@map("encounter_rewards")
|
||||
}
|
||||
|
||||
@@ -642,6 +653,9 @@ model SaleOffer {
|
||||
weapon Weapon? @relation(fields: [weaponId], references: [id], onDelete: Cascade)
|
||||
item Item? @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([campaignId])
|
||||
@@index([npcId])
|
||||
@@index([encounterId])
|
||||
@@map("sale_offers")
|
||||
}
|
||||
|
||||
@@ -672,6 +686,8 @@ model EncounterParticipant {
|
||||
monster Monster? @relation(fields: [monsterId], references: [id], onDelete: SetNull)
|
||||
npc Npc? @relation(fields: [npcId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([encounterId])
|
||||
@@index([characterId])
|
||||
@@map("encounter_participants")
|
||||
}
|
||||
|
||||
@@ -692,5 +708,7 @@ model Note {
|
||||
character Character? @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
author User @relation(fields: [authorId], references: [id])
|
||||
|
||||
@@index([campaignId])
|
||||
@@index([characterId])
|
||||
@@map("notes")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const compression = require('compression');
|
||||
|
||||
const authRoutes = require('./routes/auth.routes');
|
||||
const campaignsRoutes = require('./routes/campaigns.routes');
|
||||
@@ -11,6 +12,9 @@ function createApp() {
|
||||
const app = express();
|
||||
|
||||
app.use(cors({ origin: process.env.CORS_ORIGIN || 'http://localhost:5173' }));
|
||||
// Nothing in front of the API compresses for it (frp only tunnels), so the
|
||||
// JSON is gzipped here.
|
||||
app.use(compression());
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/health', (req, res) => res.json({ ok: true }));
|
||||
|
||||
@@ -12,11 +12,18 @@ function requireCampaignMember() {
|
||||
return async (req, res, next) => {
|
||||
const campaignId = req.params.campaignId || req.params.id;
|
||||
try {
|
||||
const campaign = await prisma.campaign.findUnique({ where: { id: campaignId } });
|
||||
if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
|
||||
|
||||
const membership = await loadCampaignMembership(req.user.id, campaignId);
|
||||
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
|
||||
// One query for a member, which is nearly every request. Only when it
|
||||
// finds nothing is the campaign looked up, to tell a 404 from a 403.
|
||||
const found = await prisma.campaignMember.findUnique({
|
||||
where: { campaignId_userId: { campaignId, userId: req.user.id } },
|
||||
include: { campaign: true },
|
||||
});
|
||||
if (!found) {
|
||||
const exists = await prisma.campaign.findUnique({ where: { id: campaignId }, select: { id: true } });
|
||||
if (!exists) return res.status(404).json({ error: 'Campaign not found' });
|
||||
return res.status(403).json({ error: 'Not a member of this campaign' });
|
||||
}
|
||||
const { campaign, ...membership } = found;
|
||||
|
||||
req.campaign = campaign;
|
||||
req.membership = membership;
|
||||
|
||||
@@ -1442,6 +1442,7 @@ describe('default content library', () => {
|
||||
expect((await request(app).get(`/campaigns/${campaign.id}/library`).set(playerAuth)).status).toBe(403);
|
||||
const listRes = await request(app).get(`/campaigns/${campaign.id}/library`).set(dmAuth);
|
||||
expect(listRes.status).toBe(200);
|
||||
expect(listRes.headers['content-encoding']).toBe('gzip'); // a big answer goes out compressed
|
||||
expect(listRes.body.weapons.length).toBeGreaterThan(20);
|
||||
expect(listRes.body.spells.length).toBeGreaterThan(50);
|
||||
expect(listRes.body.monsters.length).toBeGreaterThan(20);
|
||||
@@ -1454,6 +1455,12 @@ describe('default content library', () => {
|
||||
expect(addRes.body.weapon.name).toBe('Longsword');
|
||||
expect(addRes.body.weapon.campaignId).toBe(campaign.id);
|
||||
|
||||
// Each library entry goes in once, until its copy is renamed or deleted.
|
||||
const addLongsword = () => request(app).post(`/campaigns/${campaign.id}/weapons/from-library`).set(dmAuth).send({ key: 'longsword' });
|
||||
expect((await addLongsword()).status).toBe(409);
|
||||
await request(app).delete(`/campaigns/${campaign.id}/weapons/${addRes.body.weapon.id}`).set(dmAuth);
|
||||
expect((await addLongsword()).status).toBe(201);
|
||||
|
||||
// Library spells show their level and default damage before they are added,
|
||||
// and carry both into the campaign.
|
||||
const byKey = Object.fromEntries(listRes.body.spells.map((s) => [s.key, s]));
|
||||
|
||||
@@ -948,6 +948,14 @@ router.delete('/:id/wares/:wareId', requireCampaignMember(), requireDM(), async
|
||||
// backend/src/game/data/{weapons,spells,monsters}Library.js for the sets.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A library entry goes into a campaign once: it is taken for as long as the
|
||||
// pool holds a row by its name, and free again when that row is renamed or
|
||||
// deleted.
|
||||
async function inPool(model, campaignId, name) {
|
||||
const row = await prisma[model].findFirst({ where: { campaignId, name: { equals: name, mode: 'insensitive' } }, select: { id: true } });
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
router.get('/:id/library', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
res.json({ weapons: WEAPON_LIBRARY, items: ITEM_LIBRARY, spells: SPELL_LIBRARY, monsters: MONSTER_LIBRARY });
|
||||
@@ -962,6 +970,9 @@ router.post('/:id/weapons/from-library', requireCampaignMember(), requireDM(), a
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const template = WEAPON_LIBRARY.find((w) => w.key === parsed.data.key);
|
||||
if (!template) return res.status(404).json({ error: 'Unknown library weapon.' });
|
||||
if (await inPool('weapon', req.campaign.id, template.name)) {
|
||||
return res.status(409).json({ error: `${template.name} is already in this campaign.` });
|
||||
}
|
||||
const { key, category, ...data } = template;
|
||||
const weapon = await prisma.weapon.create({ data: { ...data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ weapon });
|
||||
@@ -976,6 +987,9 @@ router.post('/:id/items/from-library', requireCampaignMember(), requireDM(), asy
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const template = ITEM_LIBRARY.find((i) => i.key === parsed.data.key);
|
||||
if (!template) return res.status(404).json({ error: 'Unknown library item.' });
|
||||
if (await inPool('item', req.campaign.id, template.name)) {
|
||||
return res.status(409).json({ error: `${template.name} is already in this campaign.` });
|
||||
}
|
||||
const { key, category, ...data } = template;
|
||||
const item = await prisma.item.create({ data: { ...data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ item });
|
||||
@@ -990,6 +1004,9 @@ router.post('/:id/spells/from-library', requireCampaignMember(), requireDM(), as
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const template = SPELL_LIBRARY.find((s) => s.key === parsed.data.key);
|
||||
if (!template) return res.status(404).json({ error: 'Unknown library spell.' });
|
||||
if (await inPool('spell', req.campaign.id, template.name)) {
|
||||
return res.status(409).json({ error: `${template.name} is already in this campaign.` });
|
||||
}
|
||||
const { key, classes, ...data } = template;
|
||||
const spell = await prisma.spell.create({ data: { ...data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ spell });
|
||||
@@ -1004,6 +1021,9 @@ router.post('/:id/monsters/from-library', requireCampaignMember(), requireDM(),
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const template = MONSTER_LIBRARY.find((m) => m.key === parsed.data.key);
|
||||
if (!template) return res.status(404).json({ error: 'Unknown library monster.' });
|
||||
if (await inPool('monster', req.campaign.id, template.name)) {
|
||||
return res.status(409).json({ error: `${template.name} is already in this campaign.` });
|
||||
}
|
||||
const { key, ...data } = template;
|
||||
const monster = await prisma.monster.create({ data: { ...data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ monster });
|
||||
|
||||
+3
-1
@@ -53,7 +53,9 @@ The pool is the "Items Pool" card on the campaign page (DM only).
|
||||
worn gear such as a Ring of Protection, and common supplies. Each is copied
|
||||
into the pool with its effects already set (Chain Mail: +6 AC, −10 Speed,
|
||||
and a note about Stealth), at starting level 1 and not ticked as a starting
|
||||
option. The copy is yours to change. The full list is in section 10.
|
||||
option. The copy is yours to change. The full list is in section 10. Each
|
||||
can be added once: an item the pool already has by that name is left out of
|
||||
the library list, and comes back if the pool's item is renamed or deleted.
|
||||
- **Effects:** the "Effects" button on an item's row. The row shows what the
|
||||
item does under its name.
|
||||
- **Changing:** starting level, growth (when the item has dice) and the
|
||||
|
||||
@@ -57,6 +57,9 @@ The list is the "Monsters" card on the campaign page (DM only).
|
||||
scores, special trait and notes. Only name, AC and HP must be typed; speed
|
||||
starts at 30, attack bonus at 0 and each ability score at 10.
|
||||
- **Adding from the bestiary:** "📚 From Library" copies a complete stat block.
|
||||
Each monster can be added once: one the campaign already has by that name is
|
||||
left out of the list, and comes back if the campaign's monster is renamed or
|
||||
deleted.
|
||||
- **Changing:** "Edit" on a monster's row opens the same form filled in.
|
||||
Saving changes the template, and copies already in encounters show the new
|
||||
stat block straight away; their current HP is left alone.
|
||||
|
||||
@@ -107,6 +107,10 @@ one shown.
|
||||
A spell added from the library is **not** a starting option until ticked. The
|
||||
library has nothing above level 5; add higher spells by hand.
|
||||
|
||||
Each library spell can be added once. A spell the list already has by that
|
||||
name is left out of the library list, and comes back if the campaign's spell
|
||||
is renamed or deleted.
|
||||
|
||||
## 4. Granting spells to characters
|
||||
|
||||
On a character's sheet, the DM picks a spell from the list and clicks Grant.
|
||||
|
||||
@@ -86,6 +86,10 @@ growth, and **not** ticked as a starting option. Change those in the table
|
||||
afterwards. Every library weapon is listed in
|
||||
[section 12](#12-the-complete-weapon-library).
|
||||
|
||||
Each library weapon can be added once. A weapon the pool already has by that
|
||||
name is left out of the library list, and comes back if the pool's weapon is
|
||||
renamed or deleted.
|
||||
|
||||
## 4. Giving weapons to characters
|
||||
|
||||
On a character's sheet, the DM picks a weapon from the pool and clicks Give.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -18,10 +18,12 @@
|
||||
"socket.io-client": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"compression": "^1.8.2",
|
||||
"oxlint": "^1.81.0",
|
||||
"typescript": "~7.0.2",
|
||||
"vite": "^8.3.0"
|
||||
|
||||
+30
-24
@@ -1,35 +1,41 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { Layout } from './components/Layout';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { RegisterPage } from './pages/RegisterPage';
|
||||
import { CampaignListPage } from './pages/CampaignListPage';
|
||||
import { CampaignHomePage } from './pages/CampaignHomePage';
|
||||
import { PartyOverviewPage } from './pages/PartyOverviewPage';
|
||||
import { CharacterSheetPage } from './pages/CharacterSheetPage';
|
||||
import { CharacterBuilderPage } from './pages/CharacterBuilderPage';
|
||||
import { EncounterPage } from './pages/EncounterPage';
|
||||
import { AccountPage } from './pages/AccountPage';
|
||||
|
||||
// Each page is its own file to download, fetched the first time it is opened,
|
||||
// so signing in does not wait on the encounter tracker or the character builder.
|
||||
const RegisterPage = lazy(() => import('./pages/RegisterPage').then((m) => ({ default: m.RegisterPage })));
|
||||
const CampaignListPage = lazy(() => import('./pages/CampaignListPage').then((m) => ({ default: m.CampaignListPage })));
|
||||
const CampaignHomePage = lazy(() => import('./pages/CampaignHomePage').then((m) => ({ default: m.CampaignHomePage })));
|
||||
const PartyOverviewPage = lazy(() => import('./pages/PartyOverviewPage').then((m) => ({ default: m.PartyOverviewPage })));
|
||||
const CharacterSheetPage = lazy(() => import('./pages/CharacterSheetPage').then((m) => ({ default: m.CharacterSheetPage })));
|
||||
const CharacterBuilderPage = lazy(() => import('./pages/CharacterBuilderPage').then((m) => ({ default: m.CharacterBuilderPage })));
|
||||
const EncounterPage = lazy(() => import('./pages/EncounterPage').then((m) => ({ default: m.EncounterPage })));
|
||||
const AccountPage = lazy(() => import('./pages/AccountPage').then((m) => ({ default: m.AccountPage })));
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Navigate to="/campaigns" replace />} />
|
||||
<Route path="/campaigns" element={<CampaignListPage />} />
|
||||
<Route path="/campaigns/:campaignId" element={<CampaignHomePage />} />
|
||||
<Route path="/campaigns/:campaignId/party" element={<PartyOverviewPage />} />
|
||||
<Route path="/characters/:characterId" element={<CharacterSheetPage />} />
|
||||
<Route path="/characters/:characterId/build" element={<CharacterBuilderPage />} />
|
||||
<Route path="/encounters/:encounterId" element={<EncounterPage />} />
|
||||
<Route path="/account" element={<AccountPage />} />
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Navigate to="/campaigns" replace />} />
|
||||
<Route path="/campaigns" element={<CampaignListPage />} />
|
||||
<Route path="/campaigns/:campaignId" element={<CampaignHomePage />} />
|
||||
<Route path="/campaigns/:campaignId/party" element={<PartyOverviewPage />} />
|
||||
<Route path="/characters/:characterId" element={<CharacterSheetPage />} />
|
||||
<Route path="/characters/:characterId/build" element={<CharacterBuilderPage />} />
|
||||
<Route path="/encounters/:encounterId" element={<EncounterPage />} />
|
||||
<Route path="/account" element={<AccountPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+29
-20
@@ -735,12 +735,21 @@ export function useEncounter(encounterId: string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
// Every change to an encounter is answered with the encounter as this viewer
|
||||
// sees it, so the cache takes that as it is: no second request for what just
|
||||
// came back.
|
||||
function keepEncounter(qc: QueryClient, encounterId: string, data: unknown) {
|
||||
const encounter = (data as { encounter?: Encounter } | undefined)?.encounter;
|
||||
if (encounter) qc.setQueryData(['encounter', encounterId], encounter);
|
||||
else qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
||||
}
|
||||
|
||||
export function useAddParticipant(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { characterId?: string; monsterId?: string; npcId?: string; displayName?: string; initiativeRoll?: number }) =>
|
||||
(await apiClient.post(`/encounters/${encounterId}/participants`, data)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -749,7 +758,7 @@ export function usePatchParticipant(encounterId: string) {
|
||||
return useMutation({
|
||||
mutationFn: async ({ participantId, ...data }: Partial<EncounterParticipant> & { participantId: string }) =>
|
||||
(await apiClient.patch(`/encounters/${encounterId}/participants/${participantId}`, data)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -757,8 +766,8 @@ export function useRemoveParticipant(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (participantId: string) =>
|
||||
apiClient.delete(`/encounters/${encounterId}/participants/${participantId}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
(await apiClient.delete(`/encounters/${encounterId}/participants/${participantId}`)).data,
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -766,7 +775,7 @@ export function useAdvanceRound(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => (await apiClient.post(`/encounters/${encounterId}/advance-round`)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -781,8 +790,8 @@ export function usePatchEncounter(encounterId: string) {
|
||||
dmNotes?: string | null;
|
||||
locationId?: string | null;
|
||||
}) => (await apiClient.patch(`/encounters/${encounterId}`, data)).data,
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
||||
onSuccess: (data, variables) => {
|
||||
keepEncounter(qc, encounterId, data);
|
||||
// The campaign page lists encounters by name, mode and status, and an
|
||||
// encounter in progress carries its characters to its location.
|
||||
if (variables.status || variables.mode || variables.name || variables.locationId !== undefined) {
|
||||
@@ -800,7 +809,7 @@ export function useSetTargets(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (participantIds: string[]) => (await apiClient.put(`/encounters/${encounterId}/targets`, { participantIds })).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -810,8 +819,8 @@ export function useReviveParticipant(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (participantId: string) => (await apiClient.post(`/encounters/${encounterId}/participants/${participantId}/revive`)).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
||||
onSuccess: (data) => {
|
||||
keepEncounter(qc, encounterId, data);
|
||||
qc.invalidateQueries({ queryKey: ['character'] }); // the revived character's sheet
|
||||
},
|
||||
});
|
||||
@@ -821,7 +830,7 @@ export function useNextTurn(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => (await apiClient.post(`/encounters/${encounterId}/next-turn`)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -838,15 +847,15 @@ export function useAddReward(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: NewReward) => (await apiClient.post(`/encounters/${encounterId}/rewards`, data)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteReward(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (rewardId: string) => apiClient.delete(`/encounters/${encounterId}/rewards/${rewardId}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
mutationFn: async (rewardId: string) => (await apiClient.delete(`/encounters/${encounterId}/rewards/${rewardId}`)).data,
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -857,8 +866,8 @@ export function useAwardReward(encounterId: string) {
|
||||
return useMutation({
|
||||
mutationFn: async ({ rewardId, ...target }: { rewardId: string } & ({ characterId: string } | { split: true })) =>
|
||||
(await apiClient.post(`/encounters/${encounterId}/rewards/${rewardId}/award`, target)).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
||||
onSuccess: (data) => {
|
||||
keepEncounter(qc, encounterId, data);
|
||||
qc.invalidateQueries({ queryKey: ['character'] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign'] });
|
||||
},
|
||||
@@ -927,8 +936,8 @@ export function useDeleteWare(campaignId: string) {
|
||||
}
|
||||
|
||||
// Both change what a character holds, so the sheet and the party list refresh too.
|
||||
function invalidateAfterTrade(qc: QueryClient, encounterId: string) {
|
||||
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
||||
function invalidateAfterTrade(qc: QueryClient, encounterId: string, data: unknown) {
|
||||
keepEncounter(qc, encounterId, data);
|
||||
qc.invalidateQueries({ queryKey: ['character'] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign'] });
|
||||
}
|
||||
@@ -939,7 +948,7 @@ export function useExchangeWithNpc(encounterId: string) {
|
||||
return useMutation({
|
||||
mutationFn: async (data: { characterId: string; npcId: string; from: 'GOLD' | 'CASH'; amount: number }) =>
|
||||
(await apiClient.post<{ received: number; currency: Currency }>(`/encounters/${encounterId}/exchange`, data)).data,
|
||||
onSuccess: () => invalidateAfterTrade(qc, encounterId),
|
||||
onSuccess: (data) => invalidateAfterTrade(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -948,7 +957,7 @@ export function useBuyWare(encounterId: string) {
|
||||
return useMutation({
|
||||
mutationFn: async ({ wareId, ...data }: { wareId: string; characterId: string; quantity?: number }) =>
|
||||
(await apiClient.post(`/encounters/${encounterId}/wares/${wareId}/buy`, data)).data,
|
||||
onSuccess: () => invalidateAfterTrade(qc, encounterId),
|
||||
onSuccess: (data) => invalidateAfterTrade(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Suspense } from 'react';
|
||||
import { Link, NavLink, Outlet, useMatch } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
|
||||
@@ -57,7 +58,10 @@ export function Layout() {
|
||||
)}
|
||||
</header>
|
||||
<main className="page">
|
||||
<Outlet />
|
||||
{/* The bars stay put while a page that has not been opened before downloads. */}
|
||||
<Suspense fallback={null}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
{user && (
|
||||
<nav className="bottom-nav" aria-label="Main">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useQueryClient, type QueryKey } from '@tanstack/react-query';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { API_URL } from '../api/client';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
@@ -13,6 +13,10 @@ function getSocket(token: string): Socket {
|
||||
return sharedSocket;
|
||||
}
|
||||
|
||||
// One change on the server often sends two or three events together. They are
|
||||
// gathered for this long and answered with one refetch per query.
|
||||
const FLUSH_MS = 50;
|
||||
|
||||
// Joins the Socket.io room for a campaign and keeps React Query's cache in
|
||||
// sync with server-pushed events (HP/condition changes, encounter rounds,
|
||||
// new characters) so every open tab in the campaign updates live.
|
||||
@@ -25,27 +29,44 @@ export function useCampaignSocket(campaignId: string | undefined) {
|
||||
const socket = getSocket(token);
|
||||
socket.emit('campaign:join', campaignId);
|
||||
|
||||
const onCharacterUpdated = () => {
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
|
||||
qc.invalidateQueries({ queryKey: ['character'] });
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet HP
|
||||
const pending = new Map<string, QueryKey>();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const flush = () => {
|
||||
timer = undefined;
|
||||
const keys = [...pending.values()];
|
||||
pending.clear();
|
||||
// A wider key covers a narrower one waiting beside it: ['encounter'] takes in ['encounter', id].
|
||||
const covered = (key: QueryKey) => keys.some((other) => other.length < key.length && other.every((part, i) => part === key[i]));
|
||||
for (const queryKey of keys) if (!covered(queryKey)) qc.invalidateQueries({ queryKey });
|
||||
};
|
||||
const invalidate = (queryKey: QueryKey) => {
|
||||
pending.set(JSON.stringify(queryKey), queryKey);
|
||||
timer ??= setTimeout(flush, FLUSH_MS);
|
||||
};
|
||||
// An event names what changed when it is one thing, and nothing when it may be many.
|
||||
const one = (root: string, id?: string) => invalidate(id ? [root, id] : [root]);
|
||||
|
||||
const onCharacterUpdated = ({ characterId }: { characterId?: string } = {}) => {
|
||||
invalidate(['campaign', campaignId, 'characters']);
|
||||
one('character', characterId);
|
||||
invalidate(['encounter']); // a character combatant shows the sheet HP
|
||||
// A character arriving somewhere new adds it to the places their player can see.
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'locations'] });
|
||||
invalidate(['campaign', campaignId, 'locations']);
|
||||
};
|
||||
const onCharacterCreated = () => {
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
|
||||
invalidate(['campaign', campaignId, 'characters']);
|
||||
};
|
||||
const onParticipantUpdated = () => {
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] });
|
||||
const onParticipantUpdated = ({ encounterId }: { encounterId?: string } = {}) => {
|
||||
one('encounter', encounterId);
|
||||
// A player's list only holds the encounters their characters are in.
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] });
|
||||
invalidate(['campaign', campaignId, 'encounters']);
|
||||
};
|
||||
const onRoundAdvanced = () => {
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] });
|
||||
const onRoundAdvanced = ({ encounterId }: { encounterId?: string } = {}) => {
|
||||
one('encounter', encounterId);
|
||||
};
|
||||
const onCampaignDeleted = () => {
|
||||
qc.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId] });
|
||||
invalidate(['campaigns']);
|
||||
invalidate(['campaign', campaignId]);
|
||||
};
|
||||
|
||||
socket.on('character:updated', onCharacterUpdated);
|
||||
@@ -55,6 +76,10 @@ export function useCampaignSocket(campaignId: string | undefined) {
|
||||
socket.on('campaign:deleted', onCampaignDeleted);
|
||||
|
||||
return () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
flush();
|
||||
}
|
||||
socket.emit('campaign:leave', campaignId);
|
||||
socket.off('character:updated', onCharacterUpdated);
|
||||
socket.off('character:created', onCharacterCreated);
|
||||
|
||||
@@ -503,6 +503,7 @@ function NpcTrade({
|
||||
|
||||
function LibraryPicker<T extends { key: string; name: string }>({
|
||||
items,
|
||||
taken,
|
||||
onAdd,
|
||||
adding,
|
||||
renderMeta,
|
||||
@@ -510,6 +511,9 @@ function LibraryPicker<T extends { key: string; name: string }>({
|
||||
placeholder,
|
||||
}: {
|
||||
items: T[];
|
||||
// What the campaign already holds: a library entry by one of these names is
|
||||
// left out, since each can be added once.
|
||||
taken: { name: string }[] | undefined;
|
||||
onAdd: (key: string) => void;
|
||||
adding: boolean;
|
||||
renderMeta: (item: T) => string;
|
||||
@@ -518,7 +522,9 @@ function LibraryPicker<T extends { key: string; name: string }>({
|
||||
placeholder: string;
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const filtered = items.filter((i) => i.name.toLowerCase().includes(query.toLowerCase()));
|
||||
const takenNames = new Set((taken ?? []).map((t) => t.name.toLowerCase()));
|
||||
const available = items.filter((i) => !takenNames.has(i.name.toLowerCase()));
|
||||
const filtered = available.filter((i) => i.name.toLowerCase().includes(query.toLowerCase()));
|
||||
const { pageItems, page, pageCount, prevPage, nextPage } = usePagination(filtered, 6);
|
||||
|
||||
return (
|
||||
@@ -537,7 +543,11 @@ function LibraryPicker<T extends { key: string; name: string }>({
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No matches.</p>}
|
||||
{filtered.length === 0 && (
|
||||
<p style={{ color: 'var(--text-muted)' }}>
|
||||
{available.length === 0 ? 'Everything in the library is already in this campaign.' : 'No matches.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} />
|
||||
</div>
|
||||
@@ -560,7 +570,6 @@ export function CampaignHomePage() {
|
||||
const { data: npcs } = useCampaignNpcs(campaignId, data?.campaign.role === 'DM');
|
||||
const { data: notes } = useCampaignNotes(campaignId);
|
||||
const { data: locations } = useCampaignLocations(campaignId);
|
||||
const { data: library } = useCampaignLibrary(campaignId, data?.campaign.role === 'DM');
|
||||
|
||||
const createCharacter = useCreateCharacter(campaignId);
|
||||
const deleteCharacter = useDeleteCharacter(campaignId);
|
||||
@@ -619,6 +628,9 @@ export function CampaignHomePage() {
|
||||
const [showItemLibrary, setShowItemLibrary] = useState(false);
|
||||
const [showSpellLibrary, setShowSpellLibrary] = useState(false);
|
||||
const [showMonsterLibrary, setShowMonsterLibrary] = useState(false);
|
||||
// The library is a large download, so it waits until one of its pickers is opened.
|
||||
const libraryOpen = showWeaponLibrary || showItemLibrary || showSpellLibrary || showMonsterLibrary;
|
||||
const { data: library } = useCampaignLibrary(campaignId, data?.campaign.role === 'DM' && libraryOpen);
|
||||
|
||||
const weaponsPage = usePagination(weapons, 8);
|
||||
const spellsPage = usePagination(spells, 8);
|
||||
@@ -1262,6 +1274,7 @@ export function CampaignHomePage() {
|
||||
{showWeaponLibrary && library && (
|
||||
<LibraryPicker<LibraryWeapon>
|
||||
items={library.weapons}
|
||||
taken={weapons}
|
||||
adding={addWeaponFromLibrary.isPending}
|
||||
onAdd={(key) => addWeaponFromLibrary.mutate(key)}
|
||||
placeholder="Search the weapon library…"
|
||||
@@ -1378,6 +1391,7 @@ export function CampaignHomePage() {
|
||||
{showItemLibrary && library && (
|
||||
<LibraryPicker<LibraryItem>
|
||||
items={library.items}
|
||||
taken={items}
|
||||
adding={addItemFromLibrary.isPending}
|
||||
onAdd={(key) => addItemFromLibrary.mutate(key)}
|
||||
placeholder="Search the item library: armor, shields, worn gear, supplies…"
|
||||
@@ -1496,6 +1510,7 @@ export function CampaignHomePage() {
|
||||
{showSpellLibrary && library && (
|
||||
<LibraryPicker<LibrarySpell>
|
||||
items={library.spells}
|
||||
taken={spells}
|
||||
adding={addSpellFromLibrary.isPending}
|
||||
onAdd={(key) => addSpellFromLibrary.mutate(key)}
|
||||
placeholder="Search the spell library…"
|
||||
@@ -1615,6 +1630,7 @@ export function CampaignHomePage() {
|
||||
{showMonsterLibrary && library && (
|
||||
<LibraryPicker<LibraryMonster>
|
||||
items={library.monsters}
|
||||
taken={monsters}
|
||||
adding={addMonsterFromLibrary.isPending}
|
||||
onAdd={(key) => addMonsterFromLibrary.mutate(key)}
|
||||
placeholder="Search the monster library…"
|
||||
|
||||
+13
-2
@@ -1,9 +1,20 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
import compression from 'compression'
|
||||
import { defineConfig, type Connect, type Plugin } from 'vite'
|
||||
|
||||
// `vite preview` is what serves the built site, and nothing in front of it
|
||||
// compresses (frp only tunnels), so the files are gzipped here.
|
||||
const gzipPreview: Plugin = {
|
||||
name: 'gzip-preview',
|
||||
configurePreviewServer(server) {
|
||||
// Typed for Express, and at home in any Node server.
|
||||
server.middlewares.use(compression() as Connect.NextHandleFunction)
|
||||
},
|
||||
}
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), gzipPreview],
|
||||
|
||||
preview: {
|
||||
host: true,
|
||||
|
||||
Generated
+184
-4
@@ -1,23 +1,24 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.2",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.2",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "^7.10.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"compression": "^1.8.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^18.0.6",
|
||||
"express": "^5.2.1",
|
||||
@@ -32,7 +33,7 @@
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.2",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"axios": "^1.20.0",
|
||||
@@ -42,10 +43,12 @@
|
||||
"socket.io-client": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"compression": "^1.8.2",
|
||||
"oxlint": "^1.81.0",
|
||||
"typescript": "~7.0.2",
|
||||
"vite": "^8.3.0"
|
||||
@@ -2740,6 +2743,38 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/compression": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
|
||||
"integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
@@ -2838,6 +2873,31 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
"@types/serve-static": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express-serve-static-core": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz",
|
||||
"integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
"@types/range-parser": "*",
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
@@ -2845,6 +2905,13 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
@@ -2899,6 +2966,20 @@
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||
@@ -2919,6 +3000,27 @@
|
||||
"@types/react": "^19.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
||||
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stack-utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
|
||||
@@ -4449,6 +4551,65 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/compressible": {
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": ">= 1.43.0 < 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/compression": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
|
||||
"integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"compressible": "~2.0.18",
|
||||
"debug": "2.6.9",
|
||||
"destroy": "1.2.0",
|
||||
"negotiator": "~0.6.4",
|
||||
"on-headers": "~1.1.0",
|
||||
"safe-buffer": "5.2.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/compression/node_modules/negotiator": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
||||
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/confbox": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||
@@ -4791,6 +4952,16 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -7535,6 +7706,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/on-headers": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.2",
|
||||
"private": true,
|
||||
"description": "D&D 5e campaign manager — monorepo root",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user