This commit is contained in:
Hymmel 2025-10-09 11:03:23 +02:00
parent 1fed6042ca
commit 81fbb08041
4 changed files with 255 additions and 53 deletions

View file

@ -23,7 +23,7 @@ Then visit:
- Frontend UI: http://localhost:3000 - Frontend UI: http://localhost:3000
- Backend API (optional): http://localhost:4000 - Backend API (optional): http://localhost:4000
Default environment variables ship in `backend/.env.example`. Copy it (e.g. to `backend/.env`) and update `JWT_SECRET`, `FRONTEND_ORIGIN`, and `COOKIE_SECURE` before deploying for real. Default environment variables ship in `backend/.env.example`. Copy it (e.g. to `backend/.env`) and update `JWT_SECRET`, `FRONTEND_ORIGINS`, and `COOKIE_SECURE` before deploying for real.
**Persistent data** **Persistent data**
@ -48,7 +48,7 @@ Environment variables (see `.env.example`):
|-------------------|----------------------------------------------| |-------------------|----------------------------------------------|
| `DATABASE_URL` | Prisma connection string (PostgreSQL) | | `DATABASE_URL` | Prisma connection string (PostgreSQL) |
| `JWT_SECRET` | Secret for signing JWT auth cookies | | `JWT_SECRET` | Secret for signing JWT auth cookies |
| `FRONTEND_ORIGIN` | CORS allowlist (e.g. `http://localhost:3100`) | | `FRONTEND_ORIGINS` | Comma-delimited list of allowed frontend origins |
| `COOKIE_SECURE` | Set to `true` when serving over HTTPS | | `COOKIE_SECURE` | Set to `true` when serving over HTTPS |
## Frontend details (`frontend/`) ## Frontend details (`frontend/`)

View file

@ -1,4 +1,4 @@
DATABASE_URL="file:./dev.db" DATABASE_URL="postgresql://basenoter:basenoter@localhost:5432/basenoter?schema=public"
JWT_SECRET="change-me" JWT_SECRET="change-me"
FRONTEND_ORIGIN="http://localhost:3100" FRONTEND_ORIGINS="http://localhost:3100,http://localhost:3000"
COOKIE_SECURE="false" COOKIE_SECURE="false"

View file

@ -19,7 +19,41 @@ const __dirname = path.dirname(__filename);
const uploadDir = path.join(__dirname, '..', 'uploads'); const uploadDir = path.join(__dirname, '..', 'uploads');
const fsPromises = fs.promises; const fsPromises = fs.promises;
const jwtSecret = process.env.JWT_SECRET || 'super-secret-key'; const jwtSecret = process.env.JWT_SECRET || 'super-secret-key';
const frontendOrigin = process.env.FRONTEND_ORIGIN || 'http://localhost:3100'; const defaultFrontendOrigins = ['http://localhost:3100', 'http://localhost:3000'];
const configuredOrigins = process.env.FRONTEND_ORIGINS || process.env.FRONTEND_ORIGIN || '';
function normalizeOrigin(origin) {
try {
const url = new URL(origin);
url.pathname = '';
url.search = '';
url.hash = '';
return url.toString().replace(/\/$/, '');
} catch (_error) {
return origin.trim().replace(/\/$/, '');
}
}
const allowedOrigins = new Set(
[...configuredOrigins.split(',').map((origin) => origin.trim()), ...defaultFrontendOrigins]
.filter(Boolean)
.map(normalizeOrigin)
);
const corsOptions = {
origin(origin, callback) {
if (!origin) {
return callback(null, true);
}
const normalized = normalizeOrigin(origin);
if (allowedOrigins.has(normalized)) {
return callback(null, true);
}
return callback(new Error(`Origin ${origin} not allowed by CORS`));
},
credentials: true,
};
const port = process.env.PORT || 4000; const port = process.env.PORT || 4000;
const host = process.env.HOST || '0.0.0.0'; const host = process.env.HOST || '0.0.0.0';
@ -38,12 +72,8 @@ const storage = multer.diskStorage({
const upload = multer({ storage }); const upload = multer({ storage });
app.use( app.use(cors(corsOptions));
cors({ app.options('*', cors(corsOptions));
origin: frontendOrigin,
credentials: true,
})
);
app.use(express.json()); app.use(express.json());
app.use(cookieParser()); app.use(cookieParser());
app.use('/uploads', express.static(uploadDir)); app.use('/uploads', express.static(uploadDir));
@ -813,6 +843,10 @@ app.get('/profiles/:username', requireAuth, async (req, res) => {
} }
}); });
app.use((_req, res) => {
res.status(404).json({ error: 'Not found' });
});
function summarizeDefenses(defenses) { function summarizeDefenses(defenses) {
if (!defenses.length) { if (!defenses.length) {
return { count: 0, averageStars: 0, averagePercent: 0, averageTrophies: 0 }; return { count: 0, averageStars: 0, averagePercent: 0, averageTrophies: 0 };

View file

@ -41,8 +41,6 @@ const API = {
profileDetail: (username: string) => `${API_BASE}/profiles/${encodeURIComponent(username)}`, profileDetail: (username: string) => `${API_BASE}/profiles/${encodeURIComponent(username)}`,
}; };
const PROFILE_DEFENSE_PREVIEW_LIMIT = 5;
type User = { type User = {
id: string; id: string;
username: string; username: string;
@ -107,6 +105,11 @@ type ProfileBase = {
defenses: ProfileDefense[]; defenses: ProfileDefense[];
}; };
type ProfileCategorySummary = Summary & {
armyCategoryId: string;
name: string;
};
type ProfileDetail = { type ProfileDetail = {
profile: { profile: {
id: string; id: string;
@ -187,7 +190,9 @@ const initialErrors: ErrorState = {
export default function Page() { export default function Page() {
const [authTab, setAuthTab] = useState<'login' | 'signup'>('login'); const [authTab, setAuthTab] = useState<'login' | 'signup'>('login');
const [view, setView] = useState<'dashboard' | 'forms' | 'baseDetail' | 'categoryDetail'>('dashboard'); const [view, setView] = useState<'dashboard' | 'forms' | 'baseDetail' | 'categoryDetail' | 'profileBaseDetail'>(
'dashboard'
);
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [categories, setCategories] = useState<ArmyCategory[]>([]); const [categories, setCategories] = useState<ArmyCategory[]>([]);
const [bases, setBases] = useState<BaseItem[]>([]); const [bases, setBases] = useState<BaseItem[]>([]);
@ -205,6 +210,7 @@ export default function Page() {
const [profileDetail, setProfileDetail] = useState<ProfileDetail | null>(null); const [profileDetail, setProfileDetail] = useState<ProfileDetail | null>(null);
const [profileError, setProfileError] = useState(''); const [profileError, setProfileError] = useState('');
const [profileLoading, setProfileLoading] = useState(false); const [profileLoading, setProfileLoading] = useState(false);
const [profileSelectedBase, setProfileSelectedBase] = useState<ProfileBase | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
@ -268,6 +274,27 @@ export default function Page() {
return defenses.find((defense) => defense.id === editingDefenseId) ?? null; return defenses.find((defense) => defense.id === editingDefenseId) ?? null;
}, [editingDefenseId, defenses]); }, [editingDefenseId, defenses]);
const profileSelectedBaseCategories = useMemo(() => {
if (!profileSelectedBase) {
return [] as ProfileCategorySummary[];
}
const buckets = new Map<string, { name: string; items: ProfileDefense[] }>();
profileSelectedBase.defenses.forEach((defense) => {
const key = defense.armyCategoryId || defense.armyCategoryName;
if (!buckets.has(key)) {
buckets.set(key, { name: defense.armyCategoryName || 'Unknown Army', items: [] });
}
buckets.get(key)!.items.push(defense);
});
const summaries: ProfileCategorySummary[] = Array.from(buckets.entries()).map(([armyCategoryId, bucket]) => ({
armyCategoryId,
name: bucket.name,
...summarizeProfileDefenses(bucket.items),
}));
summaries.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
return summaries;
}, [profileSelectedBase]);
async function handleLogin(event: FormEvent<HTMLFormElement>) { async function handleLogin(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
@ -498,6 +525,7 @@ export default function Page() {
if (!term) { if (!term) {
setProfileResults([]); setProfileResults([]);
setProfileDetail(null); setProfileDetail(null);
setProfileSelectedBase(null);
return; return;
} }
try { try {
@ -505,9 +533,11 @@ export default function Page() {
const data = await request('GET', `${API.profiles}?search=${encodeURIComponent(term)}`); const data = await request('GET', `${API.profiles}?search=${encodeURIComponent(term)}`);
setProfileResults(data.profiles || []); setProfileResults(data.profiles || []);
setProfileDetail(null); setProfileDetail(null);
setProfileSelectedBase(null);
} catch (error: any) { } catch (error: any) {
setProfileResults([]); setProfileResults([]);
setProfileDetail(null); setProfileDetail(null);
setProfileSelectedBase(null);
setProfileError(error.message); setProfileError(error.message);
} finally { } finally {
setProfileLoading(false); setProfileLoading(false);
@ -518,10 +548,13 @@ export default function Page() {
try { try {
setProfileLoading(true); setProfileLoading(true);
setProfileError(''); setProfileError('');
setProfileSelectedBase(null);
const data: ProfileDetail = await request('GET', API.profileDetail(username)); const data: ProfileDetail = await request('GET', API.profileDetail(username));
setProfileDetail(data); setProfileDetail(data);
setView('dashboard');
} catch (error: any) { } catch (error: any) {
setProfileDetail(null); setProfileDetail(null);
setProfileSelectedBase(null);
setProfileError(error.message); setProfileError(error.message);
} finally { } finally {
setProfileLoading(false); setProfileLoading(false);
@ -530,6 +563,23 @@ export default function Page() {
function clearProfileDetail() { function clearProfileDetail() {
setProfileDetail(null); setProfileDetail(null);
setProfileSelectedBase(null);
setView('dashboard');
}
function openProfileBaseDetail(base: ProfileBase) {
setProfileSelectedBase(base);
setView('profileBaseDetail');
}
function closeProfileBaseDetail() {
setProfileSelectedBase(null);
setView('dashboard');
}
function goToDashboard() {
setView('dashboard');
setProfileSelectedBase(null);
} }
async function refreshOwnProfileDetail() { async function refreshOwnProfileDetail() {
@ -556,6 +606,7 @@ export default function Page() {
setEditImageMode('keep'); setEditImageMode('keep');
setProfileResults([]); setProfileResults([]);
setProfileDetail(null); setProfileDetail(null);
setProfileSelectedBase(null);
setProfileSearchTerm(''); setProfileSearchTerm('');
setProfileError(''); setProfileError('');
setProfileLoading(false); setProfileLoading(false);
@ -583,10 +634,26 @@ export default function Page() {
return map; return map;
}, [categories]); }, [categories]);
function formatTrophies(value: number) { function formatTrophies(value: number) {
const sign = value > 0 ? '+' : ''; const sign = value > 0 ? '+' : '';
return `${sign}${value} trophies`; return `${sign}${value} trophies`;
}
function summarizeProfileDefenses(defenses: ProfileDefense[]): Summary {
if (!defenses.length) {
return { count: 0, averageStars: 0, averagePercent: 0, averageTrophies: 0 };
} }
const totalStars = defenses.reduce((sum, defense) => sum + Number(defense.stars || 0), 0);
const totalPercent = defenses.reduce((sum, defense) => sum + Number(defense.percent || 0), 0);
const totalTrophies = defenses.reduce((sum, defense) => sum + Number(defense.trophies || 0), 0);
const count = defenses.length;
return {
count,
averageStars: Number((totalStars / count).toFixed(2)),
averagePercent: Number((totalPercent / count).toFixed(2)),
averageTrophies: Number((totalTrophies / count).toFixed(2)),
};
}
if (loading) { if (loading) {
return ( return (
@ -673,7 +740,7 @@ export default function Page() {
id="dashboard-button" id="dashboard-button"
className={`ghost ${view === 'dashboard' ? 'hidden' : ''}`} className={`ghost ${view === 'dashboard' ? 'hidden' : ''}`}
type="button" type="button"
onClick={() => setView('dashboard')} onClick={goToDashboard}
> >
Dashboard Dashboard
</button> </button>
@ -808,19 +875,19 @@ export default function Page() {
<h3>Results</h3> <h3>Results</h3>
<ul className="list compact"> <ul className="list compact">
{profileResults.length ? ( {profileResults.length ? (
profileResults.map((profile) => ( profileResults.map((profile) => (
<li key={profile.id} className="list-item"> <li key={profile.id} className="list-item">
<div className="defense-header"> <div className="defense-header">
<strong>{profile.username}</strong> <strong>{profile.username}</strong>
<button <button
type="button" type="button"
className="ghost small" className="ghost small"
onClick={() => loadProfile(profile.username)} onClick={() => loadProfile(profile.username)}
disabled={profileLoading} disabled={profileLoading}
> >
View View
</button> </button>
</div> </div>
<div className="defense-meta"> <div className="defense-meta">
<span>{profile.publicBaseCount} public bases</span> <span>{profile.publicBaseCount} public bases</span>
<span>{profile.publicDefenseCount} public attacks</span> <span>{profile.publicDefenseCount} public attacks</span>
@ -856,38 +923,20 @@ export default function Page() {
<ul className="list compact"> <ul className="list compact">
{profileDetail.bases.length ? ( {profileDetail.bases.length ? (
profileDetail.bases.map((base) => ( profileDetail.bases.map((base) => (
<li key={base.id} className="list-item"> <li key={base.id} className="list-item clickable" onClick={() => openProfileBaseDetail(base)}>
<div className="defense-header"> <div className="defense-header">
<div> <div>
<strong>{base.title}</strong>{' '} <strong>{base.title}</strong>{' '}
{base.isPrivate ? <span className="badge muted">Private</span> : null} {base.isPrivate ? <span className="badge muted">Private</span> : null}
</div> </div>
<span className="badge">{base.summary.count} attacks</span>
</div> </div>
<div className="defense-meta"> <div className="defense-meta">
<span>{base.summary.count} attacks</span>
<span>{base.summary.averageStars} avg</span> <span>{base.summary.averageStars} avg</span>
<span>{base.summary.averagePercent}% avg</span> <span>{base.summary.averagePercent}% avg</span>
<span>{formatTrophies(base.summary.averageTrophies)}</span>
</div> </div>
{base.defenses.length ? ( <p className="muted">Click to open full details.</p>
<ul className="list compact">
{base.defenses.slice(0, PROFILE_DEFENSE_PREVIEW_LIMIT).map((defense) => (
<li key={defense.id} className="defense-meta">
<span>{defense.armyCategoryName}</span>
<span>{defense.stars}</span>
<span>{defense.percent}%</span>
<span>{formatTrophies(defense.trophies)}</span>
<span>{new Date(defense.createdAt).toLocaleString()}</span>
</li>
))}
{base.defenses.length > PROFILE_DEFENSE_PREVIEW_LIMIT ? (
<li className="muted">
Showing {PROFILE_DEFENSE_PREVIEW_LIMIT} of {base.defenses.length} attacks.
</li>
) : null}
</ul>
) : (
<p className="muted">No public attacks yet.</p>
)}
</li> </li>
)) ))
) : ( ) : (
@ -1465,6 +1514,125 @@ export default function Page() {
</div> </div>
</section> </section>
<section
id="profile-base-detail-view"
className={`view-section ${view !== 'profileBaseDetail' ? 'hidden' : ''}`}
>
{profileSelectedBase ? (
<>
<div className="card">
<div className="detail-header">
<button className="ghost" type="button" onClick={closeProfileBaseDetail}>
Back
</button>
<span className="detail-created">
{`Created ${new Date(profileSelectedBase.createdAt).toLocaleString()}`}
</span>
</div>
<h2>
{profileSelectedBase.title}{' '}
{profileSelectedBase.isPrivate ? <span className="badge muted">Private</span> : null}
</h2>
<p className={profileSelectedBase.description ? '' : 'muted'}>
{profileSelectedBase.description || 'No description yet.'}
</p>
<div className="defense-meta">
<span>
Shared by <strong>{profileDetail?.profile.username || 'Unknown player'}</strong>
</span>
</div>
<div className={`detail-links ${profileSelectedBase.url ? '' : 'hidden'}`}>
{profileSelectedBase.url && (
<a href={profileSelectedBase.url} target="_blank" rel="noopener noreferrer">
Planning Link
</a>
)}
</div>
<div className={`detail-image ${profileSelectedBase.imageUrl ? '' : 'hidden'}`}>
{profileSelectedBase.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={profileSelectedBase.imageUrl}
alt={`Preview of ${profileSelectedBase.title}`}
/>
)}
</div>
</div>
<div className="card">
<h3>Base Averages</h3>
<div className="stat">
{profileSelectedBase.summary.count ? (
<>
<strong>{profileSelectedBase.summary.averageStars}</strong> average {' '}
<strong>{profileSelectedBase.summary.averagePercent}%</strong> destruction
<br />
<span className="badge">
{formatTrophies(profileSelectedBase.summary.averageTrophies)}
</span>{' '}
<span className="badge">{profileSelectedBase.summary.count} defenses</span>
</>
) : (
'No defenses logged yet.'
)}
</div>
</div>
<div className="card">
<h3>Army Categories vs This Base</h3>
<ul className="list">
{profileSelectedBaseCategories.length ? (
profileSelectedBaseCategories.map((category) => (
<li key={category.armyCategoryId} className="list-item">
<div className="defense-header">
<strong>{category.name}</strong>
<span className="badge">{category.count} attacks</span>
</div>
<div className="defense-meta">
<span>{category.averageStars} avg</span>
<span>{category.averagePercent}% avg</span>
<span>{formatTrophies(category.averageTrophies)} avg</span>
</div>
</li>
))
) : (
<li>No attacks recorded for this base yet.</li>
)}
</ul>
</div>
<div className="card">
<h3>Defenses</h3>
<ul className="list">
{profileSelectedBase.defenses.length ? (
profileSelectedBase.defenses.map((defense) => (
<li key={defense.id} className="list-item">
<div className="defense-header">
<strong>{defense.armyCategoryName || 'Unknown Army'}</strong>
<div>
<strong>{defense.stars}</strong> {defense.percent}% {formatTrophies(defense.trophies)}
</div>
</div>
<div className="defense-meta">
<span>{new Date(defense.createdAt).toLocaleString()}</span>
</div>
</li>
))
) : (
<li>No defenses recorded yet.</li>
)}
</ul>
</div>
</>
) : (
<div className="card">
<div className="detail-header">
<button className="ghost" type="button" onClick={closeProfileBaseDetail}>
Back
</button>
</div>
<p className="muted">Select a base from a profile search to view its details.</p>
</div>
)}
</section>
<section id="category-detail-view" className={`view-section ${view !== 'categoryDetail' ? 'hidden' : ''}`}> <section id="category-detail-view" className={`view-section ${view !== 'categoryDetail' ? 'hidden' : ''}`}>
<div className="card"> <div className="card">
<div className="detail-header"> <div className="detail-header">