j
This commit is contained in:
parent
1fed6042ca
commit
81fbb08041
4 changed files with 255 additions and 53 deletions
|
|
@ -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/`)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue