dev #1
3 changed files with 166 additions and 36 deletions
|
|
@ -39,6 +39,16 @@ model Base {
|
|||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
defenses Defense[]
|
||||
trophyResets TrophyReset[]
|
||||
}
|
||||
|
||||
model TrophyReset {
|
||||
id String @id @default(cuid())
|
||||
trophiesAtStart Int
|
||||
trophiesLost Int
|
||||
createdAt DateTime @default(now())
|
||||
base Base @relation(fields: [baseId], references: [id])
|
||||
baseId String
|
||||
}
|
||||
|
||||
model Defense {
|
||||
|
|
@ -49,6 +59,6 @@ model Defense {
|
|||
createdAt DateTime @default(now())
|
||||
base Base @relation(fields: [baseId], references: [id])
|
||||
baseId String
|
||||
armyCategory ArmyCategory @relation(fields: [armyCategoryId], references: [id])
|
||||
armyCategoryId String
|
||||
armyCategory ArmyCategory? @relation(fields: [armyCategoryId], references: [id])
|
||||
armyCategoryId String?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -480,6 +480,41 @@ app.delete('/bases/:baseId', requireAuth, async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
app.post('/bases/:baseId/trophy-resets', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { baseId } = req.params;
|
||||
const { trophiesAtStart, trophiesLost } = req.body || {};
|
||||
|
||||
const parsedTrophiesAtStart = Number(trophiesAtStart);
|
||||
const parsedTrophiesLost = Number(trophiesLost);
|
||||
|
||||
if (!Number.isFinite(parsedTrophiesAtStart) || parsedTrophiesAtStart < 0) {
|
||||
return res.status(400).json({ error: 'Trophies at start must be a positive number' });
|
||||
}
|
||||
if (!Number.isFinite(parsedTrophiesLost)) {
|
||||
return res.status(400).json({ error: 'Trophies lost must be a number' });
|
||||
}
|
||||
|
||||
const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } });
|
||||
if (!base) {
|
||||
return res.status(404).json({ error: 'Base not found' });
|
||||
}
|
||||
|
||||
await prisma.trophyReset.create({
|
||||
data: {
|
||||
baseId: base.id,
|
||||
trophiesAtStart: parsedTrophiesAtStart,
|
||||
trophiesLost: parsedTrophiesLost,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(201).json({ message: 'Trophy reset logged' });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/bases/:baseId/defenses', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { baseId } = req.params;
|
||||
|
|
@ -489,9 +524,6 @@ app.post('/bases/:baseId/defenses', requireAuth, async (req, res) => {
|
|||
const parsedPercent = Number(percent);
|
||||
const parsedTrophies = Number(trophies ?? 0);
|
||||
|
||||
if (!armyCategoryId) {
|
||||
return res.status(400).json({ error: 'Army category is required' });
|
||||
}
|
||||
if (!Number.isFinite(parsedStars) || parsedStars < 0 || parsedStars > 3) {
|
||||
return res.status(400).json({ error: 'Stars must be between 0 and 3' });
|
||||
}
|
||||
|
|
@ -502,22 +534,25 @@ app.post('/bases/:baseId/defenses', requireAuth, async (req, res) => {
|
|||
return res.status(400).json({ error: 'Trophies must be between -200 and 200' });
|
||||
}
|
||||
|
||||
const [base, category] = await Promise.all([
|
||||
prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } }),
|
||||
prisma.armyCategory.findFirst({ where: { id: armyCategoryId, userId: req.user.id } }),
|
||||
]);
|
||||
|
||||
const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } });
|
||||
if (!base) {
|
||||
return res.status(404).json({ error: 'Base not found' });
|
||||
}
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Army category not found' });
|
||||
|
||||
let category = null;
|
||||
if (armyCategoryId) {
|
||||
category = await prisma.armyCategory.findFirst({
|
||||
where: { id: armyCategoryId, userId: req.user.id },
|
||||
});
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Army category not found' });
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.defense.create({
|
||||
data: {
|
||||
baseId: base.id,
|
||||
armyCategoryId: category.id,
|
||||
armyCategoryId: category ? category.id : null,
|
||||
stars: parsedStars,
|
||||
percent: parsedPercent,
|
||||
trophies: parsedTrophies,
|
||||
|
|
@ -539,9 +574,6 @@ app.put('/defenses/:defenseId', requireAuth, async (req, res) => {
|
|||
if (!baseId) {
|
||||
return res.status(400).json({ error: 'Base is required' });
|
||||
}
|
||||
if (!armyCategoryId) {
|
||||
return res.status(400).json({ error: 'Army category is required' });
|
||||
}
|
||||
|
||||
const parsedStars = Number(stars);
|
||||
const parsedPercent = Number(percent);
|
||||
|
|
@ -557,29 +589,34 @@ app.put('/defenses/:defenseId', requireAuth, async (req, res) => {
|
|||
return res.status(400).json({ error: 'Trophies must be between -200 and 200' });
|
||||
}
|
||||
|
||||
const [defense, base, category] = await Promise.all([
|
||||
prisma.defense.findFirst({
|
||||
where: { id: defenseId, base: { userId: req.user.id } },
|
||||
}),
|
||||
prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } }),
|
||||
prisma.armyCategory.findFirst({ where: { id: armyCategoryId, userId: req.user.id } }),
|
||||
]);
|
||||
const defense = await prisma.defense.findFirst({
|
||||
where: { id: defenseId, base: { userId: req.user.id } },
|
||||
});
|
||||
|
||||
if (!defense) {
|
||||
return res.status(404).json({ error: 'Defense not found' });
|
||||
}
|
||||
|
||||
const base = await prisma.base.findFirst({ where: { id: baseId, userId: req.user.id } });
|
||||
if (!base) {
|
||||
return res.status(404).json({ error: 'Base not found' });
|
||||
}
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Army category not found' });
|
||||
|
||||
let category = null;
|
||||
if (armyCategoryId) {
|
||||
category = await prisma.armyCategory.findFirst({
|
||||
where: { id: armyCategoryId, userId: req.user.id },
|
||||
});
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Army category not found' });
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.defense.update({
|
||||
where: { id: defense.id },
|
||||
data: {
|
||||
baseId: base.id,
|
||||
armyCategoryId: category.id,
|
||||
armyCategoryId: armyCategoryId ? category.id : null,
|
||||
stars: parsedStars,
|
||||
percent: parsedPercent,
|
||||
trophies: parsedTrophies,
|
||||
|
|
@ -628,10 +665,16 @@ app.get('/defenses', requireAuth, async (req, res) => {
|
|||
const categoryLookup = new Map(
|
||||
user.armyCategories.map((category) => [category.id, category.name])
|
||||
);
|
||||
categoryLookup.set(null, '(No category)');
|
||||
|
||||
const defenses = [];
|
||||
const baseBuckets = new Map();
|
||||
const categoryBuckets = new Map();
|
||||
categoryBuckets.set(null, {
|
||||
name: '(No category)',
|
||||
items: [],
|
||||
bases: new Map(),
|
||||
});
|
||||
|
||||
user.bases.forEach((base) => {
|
||||
baseBuckets.set(base.id, {
|
||||
|
|
@ -813,6 +856,9 @@ app.get('/profiles/:username', requireAuth, async (req, res) => {
|
|||
},
|
||||
},
|
||||
},
|
||||
trophyResets: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -824,7 +870,13 @@ app.get('/profiles/:username', requireAuth, async (req, res) => {
|
|||
trophies: defense.trophies,
|
||||
createdAt: defense.createdAt,
|
||||
armyCategoryId: defense.armyCategoryId,
|
||||
armyCategoryName: defense.armyCategory?.name || 'Unknown Army',
|
||||
armyCategoryName: defense.armyCategory?.name || '(No category)',
|
||||
}));
|
||||
const trophyResets = base.trophyResets.map((reset) => ({
|
||||
id: reset.id,
|
||||
trophiesAtStart: reset.trophiesAtStart,
|
||||
trophiesLost: reset.trophiesLost,
|
||||
createdAt: reset.createdAt,
|
||||
}));
|
||||
return {
|
||||
id: base.id,
|
||||
|
|
@ -836,6 +888,7 @@ app.get('/profiles/:username', requireAuth, async (req, res) => {
|
|||
createdAt: base.createdAt,
|
||||
summary: summarizeDefenses(defenses),
|
||||
defenses,
|
||||
trophyResets,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const API = {
|
|||
deleteCategory: (categoryId: string) => `${API_BASE}/army-categories/${categoryId}`,
|
||||
updateBase: (baseId: string) => `${API_BASE}/bases/${baseId}`,
|
||||
deleteBase: (baseId: string) => `${API_BASE}/bases/${baseId}`,
|
||||
addTrophyReset: (baseId: string) => `${API_BASE}/bases/${baseId}/trophy-resets`,
|
||||
updateDefense: (defenseId: string) => `${API_BASE}/defenses/${defenseId}`,
|
||||
deleteDefense: (defenseId: string) => `${API_BASE}/defenses/${defenseId}`,
|
||||
profiles: `${API_BASE}/profiles`,
|
||||
|
|
@ -104,6 +105,13 @@ type ProfileDefense = {
|
|||
armyCategoryName: string;
|
||||
};
|
||||
|
||||
type TrophyReset = {
|
||||
id: string;
|
||||
trophiesAtStart: number;
|
||||
trophiesLost: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type ProfileBase = {
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
@ -114,6 +122,7 @@ type ProfileBase = {
|
|||
createdAt: string;
|
||||
summary: Summary;
|
||||
defenses: ProfileDefense[];
|
||||
trophyResets: TrophyReset[];
|
||||
};
|
||||
|
||||
type ProfileCategorySummary = Summary & {
|
||||
|
|
@ -165,6 +174,7 @@ type ErrorState = {
|
|||
category: string;
|
||||
base: string;
|
||||
defense: string;
|
||||
trophyReset: string;
|
||||
};
|
||||
|
||||
async function request(method: string, url: string, body?: any, options?: { isForm?: boolean }) {
|
||||
|
|
@ -197,6 +207,7 @@ const initialErrors: ErrorState = {
|
|||
category: '',
|
||||
base: '',
|
||||
defense: '',
|
||||
trophyReset: '',
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
|
|
@ -300,7 +311,7 @@ export default function Page() {
|
|||
profileSelectedBase.defenses.forEach((defense) => {
|
||||
const key = defense.armyCategoryId || defense.armyCategoryName;
|
||||
if (!buckets.has(key)) {
|
||||
buckets.set(key, { name: defense.armyCategoryName || 'Unknown Army', items: [] });
|
||||
buckets.set(key, { name: defense.armyCategoryName || '(No category)', items: [] });
|
||||
}
|
||||
buckets.get(key)!.items.push(defense);
|
||||
});
|
||||
|
|
@ -615,6 +626,24 @@ export default function Page() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleTrophyResetSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!profileSelectedBase) {
|
||||
return;
|
||||
}
|
||||
const form = event.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
const payload = Object.fromEntries(formData.entries());
|
||||
try {
|
||||
setErrors((prev) => ({ ...prev, trophyReset: '' }));
|
||||
await request('POST', API.addTrophyReset(profileSelectedBase.id), payload);
|
||||
form.reset();
|
||||
await refreshOwnProfileDetail();
|
||||
} catch (error: any) {
|
||||
setErrors((prev) => ({ ...prev, trophyReset: error.message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await request('POST', API.logout);
|
||||
|
|
@ -985,7 +1014,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
|||
{defenses.length ? (
|
||||
defenses.map((defense) => {
|
||||
const date = new Date(defense.createdAt);
|
||||
const categoryName = categoryNameMap.get(defense.armyCategoryId) || 'Unknown Army';
|
||||
const categoryName = categoryNameMap.get(defense.armyCategoryId) || '(No category)';
|
||||
return (
|
||||
<li key={defense.id} className="list-item">
|
||||
<div className="defense-header">
|
||||
|
|
@ -1263,9 +1292,9 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
|||
</label>
|
||||
<label>
|
||||
Army Category
|
||||
<select name="armyCategoryId" required defaultValue="">
|
||||
<option value="" disabled>
|
||||
{categories.length ? 'Select an army category' : 'Add an army category first'}
|
||||
<select name="armyCategoryId" defaultValue="">
|
||||
<option value="">
|
||||
{categories.length ? '(No category)' : 'Add an army category first'}
|
||||
</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
|
|
@ -1308,7 +1337,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
|||
{defenses.length ? (
|
||||
defenses.slice(0, 10).map((defense) => {
|
||||
const categoryName =
|
||||
categoryNameMap.get(defense.armyCategoryId) || defense.categoryName || 'Unknown Army';
|
||||
categoryNameMap.get(defense.armyCategoryId) || defense.categoryName || '(No category)';
|
||||
return (
|
||||
<li key={defense.id} className="list-item">
|
||||
<div className="defense-header">
|
||||
|
|
@ -1370,7 +1399,8 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
|||
</label>
|
||||
<label>
|
||||
Army Category
|
||||
<select name="armyCategoryId" required defaultValue={defenseBeingEdited.armyCategoryId}>
|
||||
<select name="armyCategoryId" defaultValue={defenseBeingEdited.armyCategoryId || ''}>
|
||||
<option value="">(No category)</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
|
|
@ -1520,7 +1550,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
|||
.filter((defense) => defense.baseId === selectedBaseId)
|
||||
.map((defense) => {
|
||||
const date = new Date(defense.createdAt);
|
||||
const categoryName = categoryNameMap.get(defense.armyCategoryId) || 'Unknown Army';
|
||||
const categoryName = categoryNameMap.get(defense.armyCategoryId) || '(No category)';
|
||||
return (
|
||||
<li key={defense.id} className="list-item">
|
||||
<div className="defense-header">
|
||||
|
|
@ -1626,6 +1656,43 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
|||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3>Legend League Day Reset</h3>
|
||||
<form className="form compact" onSubmit={handleTrophyResetSubmit}>
|
||||
<label>
|
||||
Trophies at Start
|
||||
<input type="number" name="trophiesAtStart" min={0} step={1} required className="styled-number" />
|
||||
</label>
|
||||
<label>
|
||||
Trophies Lost
|
||||
<input type="number" name="trophiesLost" step={1} required className="styled-number" />
|
||||
</label>
|
||||
<button type="submit" className="primary">
|
||||
Add Reset
|
||||
</button>
|
||||
<p className="form-error">{errors.trophyReset}</p>
|
||||
</form>
|
||||
<div className="subsection">
|
||||
<h4>Recent Resets</h4>
|
||||
<ul className="list compact">
|
||||
{profileSelectedBase.trophyResets.length ? (
|
||||
profileSelectedBase.trophyResets.map((reset) => (
|
||||
<li key={reset.id} className="list-item">
|
||||
<div className="defense-header">
|
||||
<span>{new Date(reset.createdAt).toLocaleDateString()}</span>
|
||||
<div className="defense-meta">
|
||||
<span>{reset.trophiesAtStart} trophies at start</span>
|
||||
<span>{formatTrophies(reset.trophiesLost)} lost</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li>No resets logged yet.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3>Defenses</h3>
|
||||
<ul className="list">
|
||||
|
|
@ -1633,7 +1700,7 @@ function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
|
|||
profileSelectedBase.defenses.map((defense) => (
|
||||
<li key={defense.id} className="list-item">
|
||||
<div className="defense-header">
|
||||
<strong>{defense.armyCategoryName || 'Unknown Army'}</strong>
|
||||
<strong>{defense.armyCategoryName || '(No category)'}</strong>
|
||||
<div>
|
||||
<strong>{defense.stars}★</strong> • {defense.percent}% • {formatTrophies(defense.trophies)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue