commit 0bf0ab5e47dfc6a22c281b23602068239519c586 Author: Hymmel Date: Thu Oct 9 08:59:08 2025 +0200 Init diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e8e32ca --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json ./ +COPY tsconfig.json ./ +COPY next.config.js ./ +COPY next-env.d.ts ./ +COPY app ./app + +RUN npm install +RUN npm run build + +ENV PORT=3000 +EXPOSE 3000 + +CMD ["npm", "run", "start"] diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..06f494c --- /dev/null +++ b/app/globals.css @@ -0,0 +1,393 @@ +:root { + color-scheme: light dark; + font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #0f172a; + color: #f8fafc; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + min-height: 100vh; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 2rem 1rem; + background: #0f172a; +} + +.container { + width: min(960px, 100%); + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.hidden { + display: none !important; +} + +.card { + background: rgba(15, 23, 42, 0.8); + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 16px; + padding: 1.5rem; + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.4); + backdrop-filter: blur(12px); +} + +.card h1, +.card h2, +.card h3 { + margin-bottom: 0.75rem; +} + +.subtitle { + color: #94a3b8; + margin-bottom: 1.5rem; +} + +.tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.tab-button { + flex: 1; + padding: 0.75rem; + background: rgba(148, 163, 184, 0.1); + border: none; + border-radius: 12px; + color: inherit; + font-size: 0.95rem; + cursor: pointer; + transition: background 0.2s ease; +} + +.tab-button.active { + background: #38bdf8; + color: #0f172a; + font-weight: 600; +} + +.form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.input-group { + display: grid; + gap: 0.6rem; +} + +.input-label { + font-size: 0.85rem; + color: #cbd5f5; +} + +.image-mode { + display: inline-flex; + gap: 1rem; + flex-wrap: wrap; +} + +.image-mode label { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.85rem; + color: #cbd5f5; +} + +.form.compact label { + display: grid; + gap: 0.35rem; +} + +label { + font-size: 0.85rem; + color: #cbd5f5; +} + +input, +textarea, +select, +button { + font: inherit; +} + +input, +textarea, +select { + padding: 0.65rem 0.75rem; + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.4); + background: rgba(15, 23, 42, 0.6); + color: inherit; +} + +textarea { + resize: vertical; +} + +input[type='number']::-webkit-inner-spin-button, +input[type='number']::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +input[type='number'] { + -moz-appearance: textfield; +} + +button { + padding: 0.75rem; + border-radius: 12px; + border: none; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +button.primary { + background: linear-gradient(135deg, #38bdf8, #0369a1); + color: #0f172a; + font-weight: 600; + box-shadow: 0 10px 20px rgba(56, 189, 248, 0.25); +} + +button.ghost { + background: transparent; + border: 1px solid rgba(148, 163, 184, 0.4); + color: inherit; +} + +button.ghost.accent { + border-color: rgba(56, 189, 248, 0.6); + color: #38bdf8; + background: rgba(56, 189, 248, 0.08); +} + +button.ghost.accent:hover { + background: rgba(56, 189, 248, 0.16); +} + +button.ghost.accent.active { + background: rgba(56, 189, 248, 0.24); + color: #0f172a; +} + +button:active { + transform: translateY(1px); +} + +.form-error { + min-height: 1.1rem; + color: #f87171; + font-size: 0.8rem; +} + +.card > * + * { + margin-top: 1rem; +} + +.top-bar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + padding-bottom: 1rem; +} + +.top-bar h1 { + margin-bottom: 0.25rem; +} + +#user-username { + color: #94a3b8; + font-size: 0.9rem; +} + +.top-bar-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +button.small { + padding: 0.55rem 0.9rem; + font-size: 0.9rem; +} + +.view-section { + display: grid; + gap: 1.5rem; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; + padding-bottom: 1rem; +} + +.forms-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; +} + +.stat { + font-size: 1.1rem; + line-height: 1.5; +} + +.list { + display: flex; + flex-direction: column; + gap: 0.75rem; + list-style: none; +} + +.list.compact { + gap: 0.5rem; +} + +.list-item { + background: rgba(15, 23, 42, 0.6); + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 12px; + padding: 1rem; + display: grid; + gap: 0.5rem; +} + +.list-item.clickable { + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.list-item.clickable:hover { + border-color: rgba(56, 189, 248, 0.6); + background: rgba(15, 23, 42, 0.75); +} + +.badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + background: rgba(56, 189, 248, 0.15); + color: #38bdf8; + border-radius: 999px; + padding: 0.25rem 0.6rem; + font-size: 0.75rem; +} + +.defense-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +#defense-log-card { + margin-bottom: 1rem; +} + +.defense-meta { + font-size: 0.85rem; + color: #94a3b8; + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +#base-detail-view, +#category-detail-view { + grid-template-columns: minmax(0, 1fr); +} + +.detail-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 0.5rem; +} + +.detail-created { + font-size: 0.85rem; + color: #94a3b8; +} + +.detail-links { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.detail-links a { + color: #38bdf8; + text-decoration: none; + font-size: 0.85rem; +} + +.detail-links a:hover { + text-decoration: underline; +} + +.detail-image { + margin-top: 1rem; + border-radius: 12px; + overflow: hidden; + border: 1px solid rgba(148, 163, 184, 0.2); +} + +.detail-image img { + display: block; + width: 100%; + height: auto; +} + +.styled-number { + appearance: textfield; + padding-right: 2.75rem; + background-image: linear-gradient(-135deg, rgba(56, 189, 248, 0) 50%, rgba(56, 189, 248, 0.8) 50%), + linear-gradient(45deg, rgba(56, 189, 248, 0) 50%, rgba(56, 189, 248, 0.8) 50%); + background-repeat: no-repeat; + background-size: 12px 12px; + background-position: calc(100% - 0.85rem) 0.9rem, calc(100% - 0.85rem) calc(100% - 0.9rem); +} + +.styled-number:focus { + border-color: rgba(56, 189, 248, 0.8); +} + +.muted { + color: #94a3b8; +} + +@media (max-width: 600px) { + body { + padding: 1.5rem 1rem; + } + + .card { + padding: 1.25rem; + } + + .tabs { + flex-direction: column; + } + + .tab-button { + width: 100%; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..2aee197 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'Base Noter', + description: 'Track how each Clash of Clans base defends against every army', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..32dd66b --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,833 @@ +'use client'; + +import { FormEvent, useEffect, useMemo, useState } from 'react'; + +const API_BASE = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4100'; + +const API = { + signup: `${API_BASE}/auth/signup`, + login: `${API_BASE}/auth/login`, + logout: `${API_BASE}/auth/logout`, + me: `${API_BASE}/auth/me`, + categories: `${API_BASE}/army-categories`, + bases: `${API_BASE}/bases`, + addDefense: (baseId: string) => `${API_BASE}/bases/${baseId}/defenses`, + defenses: `${API_BASE}/defenses`, +}; + +type User = { + id: string; + username: string; + createdAt: string; +}; + +type ArmyCategory = { + id: string; + name: string; + createdAt: string; +}; + +type BaseItem = { + id: string; + title: string; + description: string; + url: string; + imageUrl: string; + createdAt: string; +}; + +type DefenseItem = { + id: string; + stars: number; + percent: number; + trophies: number; + armyCategoryId: string; + createdAt: string; + baseId: string; + baseTitle: string; + categoryName?: string; +}; + +type Summary = { + count: number; + averageStars: number; + averagePercent: number; + averageTrophies: number; +}; + +type BaseSummary = Summary & { + baseId: string; + title: string; + categories: Array; +}; + +type CategorySummary = Summary & { + categoryId: string; + name: string; + bases: Array; +}; + +type Summaries = { + overall: Summary; + categories: CategorySummary[]; + bases: BaseSummary[]; +}; + +type ErrorState = { + login: string; + signup: string; + category: string; + base: string; + defense: string; +}; + +async function request(method: string, url: string, body?: any, options?: { isForm?: boolean }) { + const isForm = options?.isForm ?? false; + const fetchOptions: RequestInit = { + method, + credentials: 'include', + }; + + if (isForm && body instanceof FormData) { + fetchOptions.body = body; + } else if (body !== undefined) { + fetchOptions.headers = { 'Content-Type': 'application/json' }; + fetchOptions.body = JSON.stringify(body); + } + + const response = await fetch(url, fetchOptions); + const contentType = response.headers.get('content-type') ?? ''; + const data = contentType.includes('application/json') ? await response.json() : {}; + if (!response.ok) { + const message = (data && data.error) || 'Something went wrong'; + throw new Error(message); + } + return data; +} + +const initialErrors: ErrorState = { + login: '', + signup: '', + category: '', + base: '', + defense: '', +}; + +export default function Page() { + const [authTab, setAuthTab] = useState<'login' | 'signup'>('login'); + const [view, setView] = useState<'dashboard' | 'forms' | 'baseDetail' | 'categoryDetail'>('dashboard'); + const [user, setUser] = useState(null); + const [categories, setCategories] = useState([]); + const [bases, setBases] = useState([]); + const [defenses, setDefenses] = useState([]); + const [summaries, setSummaries] = useState(null); + const [selectedBaseId, setSelectedBaseId] = useState(null); + const [selectedCategoryId, setSelectedCategoryId] = useState(null); + const [errors, setErrors] = useState(initialErrors); + const [imageMode, setImageMode] = useState<'upload' | 'url'>('upload'); + const [loading, setLoading] = useState(true); + + useEffect(() => { + (async () => { + try { + const data = await request('GET', API.me); + setUser(data.user); + await refreshData(); + setView('dashboard'); + } catch (_err) { + setUser(null); + setView('dashboard'); + } finally { + setLoading(false); + } + })(); + }, []); + + async function refreshData() { + if (!user && !document.cookie.includes('token')) { + return; + } + try { + const [basesRes, categoriesRes, defensesRes] = await Promise.all([ + request('GET', API.bases), + request('GET', API.categories), + request('GET', API.defenses), + ]); + setBases(basesRes.bases || []); + setCategories(categoriesRes.categories || []); + setDefenses(defensesRes.defenses || []); + setSummaries({ + overall: defensesRes.overall, + categories: defensesRes.categories, + bases: defensesRes.bases, + }); + } catch (error) { + console.error(error); + } + } + + const baseSummaryMap = useMemo(() => { + const map = new Map(); + summaries?.bases.forEach((baseSummary) => map.set(baseSummary.baseId, baseSummary)); + return map; + }, [summaries]); + + const categorySummaryMap = useMemo(() => { + const map = new Map(); + summaries?.categories.forEach((categorySummary) => map.set(categorySummary.categoryId, categorySummary)); + return map; + }, [summaries]); + + async function handleLogin(event: FormEvent) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const payload = Object.fromEntries(formData.entries()); + try { + setErrors((prev) => ({ ...prev, login: '' })); + const data = await request('POST', API.login, payload); + setUser(data.user); + await refreshData(); + setView('dashboard'); + } catch (error: any) { + setErrors((prev) => ({ ...prev, login: error.message })); + } + } + + async function handleSignup(event: FormEvent) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const payload = Object.fromEntries(formData.entries()); + try { + if ((payload.password as string)?.length < 6) { + throw new Error('Use at least 6 characters for your password.'); + } + setErrors((prev) => ({ ...prev, signup: '' })); + const data = await request('POST', API.signup, payload); + setUser(data.user); + await refreshData(); + setView('dashboard'); + } catch (error: any) { + setErrors((prev) => ({ ...prev, signup: error.message })); + } + } + + async function handleCategorySubmit(event: FormEvent) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const payload = Object.fromEntries(formData.entries()); + try { + setErrors((prev) => ({ ...prev, category: '' })); + await request('POST', API.categories, payload); + event.currentTarget.reset(); + await refreshData(); + } catch (error: any) { + setErrors((prev) => ({ ...prev, category: error.message })); + } + } + + async function handleBaseSubmit(event: FormEvent) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + formData.set('imageMode', imageMode); + if (imageMode === 'upload') { + formData.delete('imageUrl'); + } else { + formData.delete('imageFile'); + } + try { + setErrors((prev) => ({ ...prev, base: '' })); + await request('POST', API.bases, formData, { isForm: true }); + event.currentTarget.reset(); + setImageMode('upload'); + await refreshData(); + } catch (error: any) { + setErrors((prev) => ({ ...prev, base: error.message })); + } + } + + async function handleDefenseSubmit(event: FormEvent) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const payload = Object.fromEntries(formData.entries()); + try { + const baseId = payload.baseId as string; + setErrors((prev) => ({ ...prev, defense: '' })); + await request('POST', API.addDefense(baseId), payload); + event.currentTarget.reset(); + await refreshData(); + } catch (error: any) { + setErrors((prev) => ({ ...prev, defense: error.message })); + } + } + + async function handleLogout() { + try { + await request('POST', API.logout); + } catch (error) { + console.error(error); + } finally { + setUser(null); + setBases([]); + setCategories([]); + setDefenses([]); + setSummaries(null); + setSelectedBaseId(null); + setSelectedCategoryId(null); + setView('dashboard'); + } + } + + function openBaseDetail(baseId: string) { + setSelectedBaseId(baseId); + setView('baseDetail'); + } + + function openCategoryDetail(categoryId: string) { + setSelectedCategoryId(categoryId); + setView('categoryDetail'); + } + + const baseDetail = selectedBaseId ? baseSummaryMap.get(selectedBaseId) : null; + const baseDetailMeta = selectedBaseId ? bases.find((base) => base.id === selectedBaseId) : null; + const categoryDetail = selectedCategoryId ? categorySummaryMap.get(selectedCategoryId) : null; + + const categoryNameMap = useMemo(() => { + const map = new Map(); + categories.forEach((category) => map.set(category.id, category.name)); + return map; + }, [categories]); + + function formatTrophies(value: number) { + const sign = value > 0 ? '+' : ''; + return `${sign}${value} trophies`; + } + + if (loading) { + return ( +
+
+

Loading Base Noter...

+
+
+ ); + } + + if (!user) { + return ( +
+
+

Base Noter

+

Track how each base defends against every army.

+
+ + +
+
+ {authTab === 'login' ? ( +
+ + + +

+ {errors.login} +

+
+ ) : ( +
+ + + +

+ {errors.signup} +

+
+ )} +
+
+
+ ); + } + + return ( +
+
+
+
+

Base Noter

+ {user.username} +
+
+ + + +
+
+ +
+
+
+

Overall Average

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

Base Averages

+
    + {summaries && summaries.bases.length ? ( + summaries.bases.map((base) => ( +
  • openBaseDetail(base.baseId)} + > +
    + {base.title} + {base.count} defenses +
    +
    + {base.averageStars}★ avg + {base.averagePercent}% avg + {formatTrophies(base.averageTrophies)} avg +
    +
  • + )) + ) : ( +
  • + {bases.length + ? 'Bases have defenses pending tracking.' + : 'Add a base to start collecting its averages.'} +
  • + )} +
+
+
+
+

Category Averages

+
    + {summaries && summaries.categories.length ? ( + summaries.categories.map((category) => ( +
  • openCategoryDetail(category.categoryId)} + > +
    + {category.name} + {category.count} attacks +
    +
    + {category.averageStars}★ avg + {category.averagePercent}% avg + {formatTrophies(category.averageTrophies)} avg +
    +
  • + )) + ) : ( +
  • Create an army category to start tracking.
  • + )} +
+
+
+ +
+

Defense Log

+

Newest entries appear on top.

+
    + {defenses.length ? ( + defenses.map((defense) => { + const date = new Date(defense.createdAt); + const categoryName = categoryNameMap.get(defense.armyCategoryId) || 'Unknown Army'; + return ( +
  • +
    +
    + {defense.baseTitle} + {categoryName} +
    +
    + {defense.stars}★ • {defense.percent}% • {formatTrophies(defense.trophies)} +
    +
    +
    + {date.toLocaleString()} +
    +
  • + ); + }) + ) : ( +
  • No defenses recorded yet.
  • + )} +
+
+
+ +
+
+

New Army Category

+
+ + +

+ {errors.category} +

+
+
+
+

New Base

+
+ + + +
+ Image Source +
+ + +
+ {imageMode === 'upload' ? ( + + ) : ( + + )} +
+ +

+ {errors.base} +

+
+
+
+

Log Defense

+
+ + + + + + +

+ {errors.defense} +

+
+
+
+ +
+
+
+ + + {baseDetailMeta ? `Created ${new Date(baseDetailMeta.createdAt).toLocaleString()}` : ''} + +
+

{baseDetailMeta?.title}

+

+ {baseDetailMeta?.description || 'No description yet.'} +

+ +
+ {baseDetailMeta?.imageUrl && ( + // eslint-disable-next-line @next/next/no-img-element + {`Preview + )} +
+
+
+

Base Averages

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

Army Categories vs This Base

+
    + {baseDetail && baseDetail.categories.length ? ( + baseDetail.categories.map((category) => ( +
  • openCategoryDetail(category.categoryId)} + > +
    + {category.name} + {category.count} attacks +
    +
    + {category.averageStars}★ avg + {category.averagePercent}% avg + {formatTrophies(category.averageTrophies)} avg +
    +
  • + )) + ) : ( +
  • No army categories have attacked this base yet.
  • + )} +
+
+
+

Defenses

+
    + {defenses.filter((defense) => defense.baseId === selectedBaseId).length ? ( + defenses + .filter((defense) => defense.baseId === selectedBaseId) + .map((defense) => { + const date = new Date(defense.createdAt); + const categoryName = categoryNameMap.get(defense.armyCategoryId) || 'Unknown Army'; + return ( +
  • +
    + {categoryName} +
    + {defense.stars}★ • {defense.percent}% • {formatTrophies(defense.trophies)} +
    +
    +
    + {date.toLocaleString()} +
    +
  • + ); + }) + ) : ( +
  • No defenses recorded for this base yet.
  • + )} +
+
+
+ +
+
+
+ +
+

{categoryDetail?.name}

+

+ Average performance of this army across your bases. +

+
+
+

Category Averages

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

Performance by Base

+
    + {categoryDetail && categoryDetail.bases.length ? ( + categoryDetail.bases.map((base) => ( +
  • openBaseDetail(base.baseId)} + > +
    + {base.title} + {base.count} defenses +
    +
    + {base.averageStars}★ avg + {base.averagePercent}% avg + {formatTrophies(base.averageTrophies)} avg +
    +
  • + )) + ) : ( +
  • This army has not attacked any bases yet.
  • + )} +
+
+
+

Defenses

+
    + {defenses.filter((defense) => defense.armyCategoryId === selectedCategoryId).length ? ( + defenses + .filter((defense) => defense.armyCategoryId === selectedCategoryId) + .map((defense) => { + const date = new Date(defense.createdAt); + return ( +
  • +
    + {defense.baseTitle} +
    + {defense.stars}★ • {defense.percent}% • {formatTrophies(defense.trophies)} +
    +
    +
    + {date.toLocaleString()} +
    +
  • + ); + }) + ) : ( +
  • No logged defenses for this army yet.
  • + )} +
+
+
+
+
+ ); +} diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..7d28a51 --- /dev/null +++ b/next.config.js @@ -0,0 +1,15 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: 'http', + hostname: 'localhost', + port: '4100', + pathname: '/uploads/**' + } + ], + }, +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..b62f93c --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "base-noter-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start --hostname 0.0.0.0" + }, + "dependencies": { + "next": "14.2.3", + "react": "18.2.0", + "react-dom": "18.2.0" + }, + "devDependencies": { + "@types/node": "20.12.7", + "@types/react": "18.2.66", + "@types/react-dom": "18.2.22", + "typescript": "5.4.5" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8930545 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +}