Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b3d121151 | ||
|
|
f04e3497f4 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.12.1",
|
||||
"version": "1.12.3",
|
||||
"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")
|
||||
}
|
||||
|
||||
+8
-1
@@ -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');
|
||||
@@ -10,7 +11,13 @@ const notesRoutes = require('./routes/notes.routes');
|
||||
function createApp() {
|
||||
const app = express();
|
||||
|
||||
app.use(cors({ origin: process.env.CORS_ORIGIN || 'http://localhost:5173' }));
|
||||
// The site and the API are on different hosts, so a browser asks leave
|
||||
// before each kind of request. maxAge lets it remember the answer (browsers
|
||||
// cap it at two hours) and not ask again every time.
|
||||
app.use(cors({ origin: process.env.CORS_ORIGIN || 'http://localhost:5173', maxAge: 7200 }));
|
||||
// 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);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.12.1",
|
||||
"version": "1.12.3",
|
||||
"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';
|
||||
@@ -8,11 +8,18 @@ let sharedSocket: Socket | null = null;
|
||||
|
||||
function getSocket(token: string): Socket {
|
||||
if (!sharedSocket) {
|
||||
sharedSocket = io(API_URL, { auth: { token }, transports: ['websocket', 'polling'] });
|
||||
// Starts on plain requests and moves up to a websocket when one gets
|
||||
// through. Asking for the websocket first leaves no way back: behind a
|
||||
// proxy that does not pass the upgrade, nothing live arrives at all.
|
||||
sharedSocket = io(API_URL, { auth: { token } });
|
||||
}
|
||||
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 +32,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 +79,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);
|
||||
|
||||
@@ -570,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);
|
||||
@@ -629,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);
|
||||
|
||||
+26
-2
@@ -1,9 +1,33 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
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, so the two things a web
|
||||
// server would be set up to do are done here.
|
||||
const servePreview: Plugin = {
|
||||
name: 'serve-preview',
|
||||
configurePreviewServer(server) {
|
||||
const assets = join(server.config.root, server.config.build.outDir)
|
||||
// Typed for Express, and at home in any Node server.
|
||||
server.middlewares.use(compression() as Connect.NextHandleFunction)
|
||||
// A built file carries a hash of its contents in its name, so a browser
|
||||
// can keep it for good and never ask again. Only for files that are
|
||||
// there: a name from an older build must not be remembered as missing.
|
||||
server.middlewares.use((req, res, next) => {
|
||||
const path = (req.url ?? '').split('?')[0]
|
||||
if (path.startsWith('/assets/') && !path.includes('..') && existsSync(join(assets, path))) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
}
|
||||
next()
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), servePreview],
|
||||
|
||||
preview: {
|
||||
host: true,
|
||||
|
||||
Generated
+184
-4
@@ -1,23 +1,24 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.12.1",
|
||||
"version": "1.12.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.12.1",
|
||||
"version": "1.12.3",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"version": "1.12.1",
|
||||
"version": "1.12.3",
|
||||
"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.1",
|
||||
"version": "1.12.3",
|
||||
"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.1",
|
||||
"version": "1.12.3",
|
||||
"private": true,
|
||||
"description": "D&D 5e campaign manager — monorepo root",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user