From 81fbb08041b6b9985a573f3c53b4951eab089528 Mon Sep 17 00:00:00 2001 From: Hymmel Date: Thu, 9 Oct 2025 11:03:23 +0200 Subject: [PATCH] j --- README.md | 4 +- backend/.env.example | 4 +- backend/src/server.js | 48 ++++++-- frontend/app/page.tsx | 252 +++++++++++++++++++++++++++++++++++------- 4 files changed, 255 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 7c5c5d0..4a658c3 100644 --- a/README.md +++ b/README.md @@ -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/`) diff --git a/backend/.env.example b/backend/.env.example index 9fcab02..42176b2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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" diff --git a/backend/src/server.js b/backend/src/server.js index 430f317..1b14195 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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 }; diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 51647f5..bf6970e 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -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(null); const [categories, setCategories] = useState([]); const [bases, setBases] = useState([]); @@ -205,6 +210,7 @@ export default function Page() { const [profileDetail, setProfileDetail] = useState(null); const [profileError, setProfileError] = useState(''); const [profileLoading, setProfileLoading] = useState(false); + const [profileSelectedBase, setProfileSelectedBase] = useState(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(); + 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) { 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 @@ -808,19 +875,19 @@ export default function Page() {

Results

    {profileResults.length ? ( - profileResults.map((profile) => ( -
  • -
    - {profile.username} - -
    + profileResults.map((profile) => ( +
  • +
    + {profile.username} + +
    {profile.publicBaseCount} public bases {profile.publicDefenseCount} public attacks @@ -856,38 +923,20 @@ export default function Page() {
      {profileDetail.bases.length ? ( profileDetail.bases.map((base) => ( -
    • +
    • openProfileBaseDetail(base)}>
      {base.title}{' '} {base.isPrivate ? Private : null}
      + {base.summary.count} attacks
      - {base.summary.count} attacks {base.summary.averageStars}★ avg {base.summary.averagePercent}% avg + {formatTrophies(base.summary.averageTrophies)}
      - {base.defenses.length ? ( -
        - {base.defenses.slice(0, PROFILE_DEFENSE_PREVIEW_LIMIT).map((defense) => ( -
      • - {defense.armyCategoryName} - {defense.stars}★ - {defense.percent}% - {formatTrophies(defense.trophies)} - {new Date(defense.createdAt).toLocaleString()} -
      • - ))} - {base.defenses.length > PROFILE_DEFENSE_PREVIEW_LIMIT ? ( -
      • - Showing {PROFILE_DEFENSE_PREVIEW_LIMIT} of {base.defenses.length} attacks. -
      • - ) : null} -
      - ) : ( -

      No public attacks yet.

      - )} +

      Click to open full details.

    • )) ) : ( @@ -1465,6 +1514,125 @@ export default function Page() {
    +
    + {profileSelectedBase ? ( + <> +
    +
    + + + {`Created ${new Date(profileSelectedBase.createdAt).toLocaleString()}`} + +
    +

    + {profileSelectedBase.title}{' '} + {profileSelectedBase.isPrivate ? Private : null} +

    +

    + {profileSelectedBase.description || 'No description yet.'} +

    +
    + + Shared by {profileDetail?.profile.username || 'Unknown player'} + +
    +
    + {profileSelectedBase.url && ( + + Planning Link + + )} +
    +
    + {profileSelectedBase.imageUrl && ( + // eslint-disable-next-line @next/next/no-img-element + {`Preview + )} +
    +
    +
    +

    Base Averages

    +
    + {profileSelectedBase.summary.count ? ( + <> + {profileSelectedBase.summary.averageStars}★ average •{' '} + {profileSelectedBase.summary.averagePercent}% destruction +
    + + {formatTrophies(profileSelectedBase.summary.averageTrophies)} + {' '} + {profileSelectedBase.summary.count} defenses + + ) : ( + 'No defenses logged yet.' + )} +
    +
    +
    +

    Army Categories vs This Base

    +
      + {profileSelectedBaseCategories.length ? ( + profileSelectedBaseCategories.map((category) => ( +
    • +
      + {category.name} + {category.count} attacks +
      +
      + {category.averageStars}★ avg + {category.averagePercent}% avg + {formatTrophies(category.averageTrophies)} avg +
      +
    • + )) + ) : ( +
    • No attacks recorded for this base yet.
    • + )} +
    +
    +
    +

    Defenses

    +
      + {profileSelectedBase.defenses.length ? ( + profileSelectedBase.defenses.map((defense) => ( +
    • +
      + {defense.armyCategoryName || 'Unknown Army'} +
      + {defense.stars}★ • {defense.percent}% • {formatTrophies(defense.trophies)} +
      +
      +
      + {new Date(defense.createdAt).toLocaleString()} +
      +
    • + )) + ) : ( +
    • No defenses recorded yet.
    • + )} +
    +
    + + ) : ( +
    +
    + +
    +

    Select a base from a profile search to view its details.

    +
    + )} +
    +