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
- 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**
@ -48,7 +48,7 @@ Environment variables (see `.env.example`):
|-------------------|----------------------------------------------|
| `DATABASE_URL` | Prisma connection string (PostgreSQL) |
| `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 |
## 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"
FRONTEND_ORIGIN="http://localhost:3100"
FRONTEND_ORIGINS="http://localhost:3100,http://localhost:3000"
COOKIE_SECURE="false"

View file

@ -19,7 +19,41 @@ const __dirname = path.dirname(__filename);
const uploadDir = path.join(__dirname, '..', 'uploads');
const fsPromises = fs.promises;
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 host = process.env.HOST || '0.0.0.0';
@ -38,12 +72,8 @@ const storage = multer.diskStorage({
const upload = multer({ storage });
app.use(
cors({
origin: frontendOrigin,
credentials: true,
})
);
app.use(cors(corsOptions));
app.options('*', cors(corsOptions));
app.use(express.json());
app.use(cookieParser());
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) {
if (!defenses.length) {
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)}`,
};
const PROFILE_DEFENSE_PREVIEW_LIMIT = 5;
type User = {
id: string;
username: string;
@ -107,6 +105,11 @@ type ProfileBase = {
defenses: ProfileDefense[];
};
type ProfileCategorySummary = Summary & {
armyCategoryId: string;
name: string;
};
type ProfileDetail = {
profile: {
id: string;
@ -187,7 +190,9 @@ const initialErrors: ErrorState = {
export default function Page() {
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 [categories, setCategories] = useState<ArmyCategory[]>([]);
const [bases, setBases] = useState<BaseItem[]>([]);
@ -205,6 +210,7 @@ export default function Page() {
const [profileDetail, setProfileDetail] = useState<ProfileDetail | null>(null);
const [profileError, setProfileError] = useState('');
const [profileLoading, setProfileLoading] = useState(false);
const [profileSelectedBase, setProfileSelectedBase] = useState<ProfileBase | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
@ -268,6 +274,27 @@ export default function Page() {
return defenses.find((defense) => defense.id === editingDefenseId) ?? null;
}, [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>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
@ -498,6 +525,7 @@ export default function Page() {
if (!term) {
setProfileResults([]);
setProfileDetail(null);
setProfileSelectedBase(null);
return;
}
try {
@ -505,9 +533,11 @@ export default function Page() {
const data = await request('GET', `${API.profiles}?search=${encodeURIComponent(term)}`);
setProfileResults(data.profiles || []);
setProfileDetail(null);
setProfileSelectedBase(null);
} catch (error: any) {
setProfileResults([]);
setProfileDetail(null);
setProfileSelectedBase(null);
setProfileError(error.message);
} finally {
setProfileLoading(false);
@ -518,10 +548,13 @@ export default function Page() {
try {
setProfileLoading(true);
setProfileError('');
setProfileSelectedBase(null);
const data: ProfileDetail = await request('GET', API.profileDetail(username));
setProfileDetail(data);
setView('dashboard');
} catch (error: any) {
setProfileDetail(null);
setProfileSelectedBase(null);
setProfileError(error.message);
} finally {
setProfileLoading(false);
@ -530,6 +563,23 @@ export default function Page() {
function clearProfileDetail() {
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() {
@ -556,6 +606,7 @@ export default function Page() {
setEditImageMode('keep');
setProfileResults([]);
setProfileDetail(null);
setProfileSelectedBase(null);
setProfileSearchTerm('');
setProfileError('');
setProfileLoading(false);
@ -583,10 +634,26 @@ export default function Page() {
return map;
}, [categories]);
function formatTrophies(value: number) {
const sign = value > 0 ? '+' : '';
return `${sign}${value} trophies`;
function formatTrophies(value: number) {
const sign = value > 0 ? '+' : '';
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) {
return (
@ -673,7 +740,7 @@ export default function Page() {
id="dashboard-button"
className={`ghost ${view === 'dashboard' ? 'hidden' : ''}`}
type="button"
onClick={() => setView('dashboard')}
onClick={goToDashboard}
>
Dashboard
</button>
@ -808,19 +875,19 @@ export default function Page() {
<h3>Results</h3>
<ul className="list compact">
{profileResults.length ? (
profileResults.map((profile) => (
<li key={profile.id} className="list-item">
<div className="defense-header">
<strong>{profile.username}</strong>
<button
type="button"
className="ghost small"
onClick={() => loadProfile(profile.username)}
disabled={profileLoading}
>
View
</button>
</div>
profileResults.map((profile) => (
<li key={profile.id} className="list-item">
<div className="defense-header">
<strong>{profile.username}</strong>
<button
type="button"
className="ghost small"
onClick={() => loadProfile(profile.username)}
disabled={profileLoading}
>
View
</button>
</div>
<div className="defense-meta">
<span>{profile.publicBaseCount} public bases</span>
<span>{profile.publicDefenseCount} public attacks</span>
@ -856,38 +923,20 @@ export default function Page() {
<ul className="list compact">
{profileDetail.bases.length ? (
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>
<strong>{base.title}</strong>{' '}
{base.isPrivate ? <span className="badge muted">Private</span> : null}
</div>
<span className="badge">{base.summary.count} attacks</span>
</div>
<div className="defense-meta">
<span>{base.summary.count} attacks</span>
<span>{base.summary.averageStars} avg</span>
<span>{base.summary.averagePercent}% avg</span>
<span>{formatTrophies(base.summary.averageTrophies)}</span>
</div>
{base.defenses.length ? (
<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>
)}
<p className="muted">Click to open full details.</p>
</li>
))
) : (
@ -1465,6 +1514,125 @@ export default function Page() {
</div>
</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' : ''}`}>
<div className="card">
<div className="detail-header">