setup db connection + implement data fetching on dashboard + add skeleton + docs

This commit is contained in:
Michi 2025-04-19 17:56:12 +02:00
parent aea677e6f7
commit 00c39d3416
11 changed files with 259 additions and 67 deletions

View file

@ -79,6 +79,19 @@ The main function of the app or just a file is defined by `export default`.
The layout of the app is defined in `layout.js`. The content of `layout.js` is shared between all pages. The layout of the app is defined in `layout.js`. The content of `layout.js` is shared between all pages.
## Imports
When importing function and components from other files, there can be named & default imports. Default imports are when you import the default function of a file which was defined by `export default function`.
Default import
```tsx
import LatestInvoices from '../ui/dashboard/latest-invoices';
```
Named import
```tsx
import { fetchRevenue } from '../lib/data';
```
## Styling ## Styling
In `global.css` some global CSS styling rules were defined. Usally this file is imported in the root layout of the app `layout.js`/`layout.jsx`/`layout.tsx`. In `global.css` some global CSS styling rules were defined. Usally this file is imported in the root layout of the app `layout.js`/`layout.jsx`/`layout.tsx`.
@ -316,6 +329,54 @@ export default function NavLinks() {
} }
``` ```
## Data rendering behaviors
There are two ways of fetching data in Next.js.
- static rendering
- dynamic rendering
Static rendering means that data is fetch while building the app or when the data is revalidating. On each access the cached data will be served. This has the advantage, that the server load can be reduced and the app is faster. Also it has advantages in point of SEO, because there has nothing to be loaded from the server except the page itself.
But this approach isn't useful when personalized data should be shown. There comes dynamic rendering into play.
Dynamic rendering enables to render real time data and personalized content for each user. It's also possible to show infos about the request.
### Data streaming
Data streaming enables you to transfer data to the client in more smaller chunks instead of one full package. So the users see the content, that is already ready and in the background the rest is loaded. So the app feels faster and the user can begin using it earlier. Also the data can be rendered in parellel, instead of waiting until each request is finished.
By adding a `loading.tsx`-File with a `Loading()` component to the app, the streaming can be enabled.
```tsx
export default function Loading(){
return <div>Loading...</div>;
}
```
The component `Loading()` is shown as a fallback while the "real" content is loaded.
Instead of ugly text a skeleton can be implemented. This has the advantage that the user knows where he can expect content and layout shift is minimized.
This loading behaviour is applied to all subpages of the folder its defined in. To prevent this the files, on which it should be appended, can be moved inside a folder in parenthesis. Folders with parenthesis allows to group pages logical, without affecting the URL path.
With `Suspense` its also possible to just add a skeleton to one component. Then the data fetching process has to be a part of the component. To use `Suspense` the component has to be wrapped inside it.
```tsx
import { Suspense } from 'react';
import { RevenueChartSkeleton } from '@/app/ui/skeletons';
export default async function Page(){
return (
<main>
<div className='mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8'>
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
<LatestInvoices latestInvoices={latestInvoices} />
</div>
</main>
);
}
```
When multiple components should load at the same time, they should be moved in a own component and then be put into `Suspense`.
## Ressources ## Ressources
- [Next.js Installation](https://nextjs.org/docs/app/getting-started/installation) - [Next.js Installation](https://nextjs.org/docs/app/getting-started/installation)
- [Next.js React Foundations Course](https://nextjs.org/learn/react-foundations) - [Next.js React Foundations Course](https://nextjs.org/learn/react-foundations)

View file

@ -1,13 +0,0 @@
# Copy from .env.local on the Vercel dashboard
# https://nextjs.org/learn/dashboard-app/setting-up-your-database#create-a-postgres-database
POSTGRES_URL=
POSTGRES_PRISMA_URL=
POSTGRES_URL_NON_POOLING=
POSTGRES_USER=
POSTGRES_HOST=
POSTGRES_PASSWORD=
POSTGRES_DATABASE=
# `openssl rand -base64 32`
AUTH_SECRET=
AUTH_URL=http://localhost:3000/api/auth

View file

@ -0,0 +1,5 @@
import DashboardSkeleton from "../../ui/skeletons";
export default function Loading(){
return <DashboardSkeleton />;
}

View file

@ -0,0 +1,30 @@
import { Card } from '@/app/ui/dashboard/cards'
import RevenueChart from '../../ui/dashboard/revenue-chart';
import LatestInvoices from '../../ui/dashboard/latest-invoices';
import { lusitana } from '../../ui/fonts';
import { Suspense } from 'react';
import { RevenueChartSkeleton, LatestInvoicesSkeleton, CardsSkeleton, CardSkeleton} from '@/app/ui/skeletons';
import CardWrapper from '@/app/ui/dashboard/cards';
export default async function Page(){
return (
<main>
<h1 className={`${lusitana.className} mb-4 text-xl md:text-2x1`}>
Dashboard
</h1>
<div className='grid gap-6 sm: grid-cols-2 lg:grid-cols-4'>
<Suspense fallback={<CardSkeleton />}>
<CardWrapper />
</Suspense>
</div>
<div className='mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8'>
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<LatestInvoicesSkeleton />}>
<LatestInvoices />
</Suspense>
</div>
</main>
);
}

View file

@ -1,3 +0,0 @@
export default function Page(){
return <p>Dashboard page</p>;
}

View file

@ -1,26 +1,22 @@
// import postgres from 'postgres'; import postgres from 'postgres';
// const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' }); const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });
// async function listInvoices() { async function listInvoices() {
// const data = await sql` const data = await sql`
// SELECT invoices.amount, customers.name SELECT invoices.amount, customers.name
// FROM invoices FROM invoices
// JOIN customers ON invoices.customer_id = customers.id JOIN customers ON invoices.customer_id = customers.id
// WHERE invoices.amount = 666; WHERE invoices.amount = 666;
// `; `;
// return data; return data;
// } }
export async function GET() { export async function GET() {
return Response.json({ try {
message: return Response.json(await listInvoices());
'Uncomment this file and remove this line. You can delete this file when you are finished.', } catch (error) {
}); return Response.json({ error }, { status: 500 });
// try { }
// return Response.json(await listInvoices());
// } catch (error) {
// return Response.json({ error }, { status: 500 });
// }
} }

View file

@ -5,6 +5,7 @@ import {
InboxIcon, InboxIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts'; import { lusitana } from '@/app/ui/fonts';
import { fetchCardData } from '@/app/lib/data';
const iconMap = { const iconMap = {
collected: BanknotesIcon, collected: BanknotesIcon,
@ -14,18 +15,23 @@ const iconMap = {
}; };
export default async function CardWrapper() { export default async function CardWrapper() {
const {
numberOfInvoices,
numberOfCustomers,
totalPaidInvoices,
totalPendingInvoices,
} = await fetchCardData();
return ( return (
<> <>
{/* NOTE: Uncomment this code in Chapter 9 */} <Card title="Collected" value={totalPaidInvoices} type="collected" />
{/* <Card title="Collected" value={totalPaidInvoices} type="collected" />
<Card title="Pending" value={totalPendingInvoices} type="pending" /> <Card title="Pending" value={totalPendingInvoices} type="pending" />
<Card title="Total Invoices" value={numberOfInvoices} type="invoices" /> <Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
<Card <Card
title="Total Customers" title="Total Customers"
value={numberOfCustomers} value={numberOfCustomers}
type="customers" type="customers"
/> */} />
</> </>
); );
} }

View file

@ -3,20 +3,18 @@ import clsx from 'clsx';
import Image from 'next/image'; import Image from 'next/image';
import { lusitana } from '@/app/ui/fonts'; import { lusitana } from '@/app/ui/fonts';
import { LatestInvoice } from '@/app/lib/definitions'; import { LatestInvoice } from '@/app/lib/definitions';
export default async function LatestInvoices({ import { fetchLatestInvoices } from '@/app/lib/data';
latestInvoices,
}: { export default async function LatestInvoices() {
latestInvoices: LatestInvoice[]; const latestInvoices = await fetchLatestInvoices();
}) {
return ( return (
<div className="flex w-full flex-col md:col-span-4"> <div className="flex w-full flex-col md:col-span-4">
<h2 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}> <h2 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
Latest Invoices Latest Invoices
</h2> </h2>
<div className="flex grow flex-col justify-between rounded-xl bg-gray-50 p-4"> <div className="flex grow flex-col justify-between rounded-xl bg-gray-50 p-4">
{/* NOTE: Uncomment this code in Chapter 7 */} <div className="bg-white px-6">
{/* <div className="bg-white px-6">
{latestInvoices.map((invoice, i) => { {latestInvoices.map((invoice, i) => {
return ( return (
<div <div
@ -53,7 +51,7 @@ export default async function LatestInvoices({
</div> </div>
); );
})} })}
</div> */} </div>
<div className="flex items-center pb-2 pt-6"> <div className="flex items-center pb-2 pt-6">
<ArrowPathIcon className="h-5 w-5 text-gray-500" /> <ArrowPathIcon className="h-5 w-5 text-gray-500" />
<h3 className="ml-2 text-sm text-gray-500 ">Updated just now</h3> <h3 className="ml-2 text-sm text-gray-500 ">Updated just now</h3>

View file

@ -2,6 +2,7 @@ import { generateYAxis } from '@/app/lib/utils';
import { CalendarIcon } from '@heroicons/react/24/outline'; import { CalendarIcon } from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts'; import { lusitana } from '@/app/ui/fonts';
import { Revenue } from '@/app/lib/definitions'; import { Revenue } from '@/app/lib/definitions';
import { fetchRevenue } from '@/app/lib/data';
// This component is representational only. // This component is representational only.
// For data visualization UI, check out: // For data visualization UI, check out:
@ -9,28 +10,22 @@ import { Revenue } from '@/app/lib/definitions';
// https://www.chartjs.org/ // https://www.chartjs.org/
// https://airbnb.io/visx/ // https://airbnb.io/visx/
export default async function RevenueChart({ export default async function RevenueChart() {
revenue, const revenue = await fetchRevenue();
}: {
revenue: Revenue[];
}) {
const chartHeight = 350; const chartHeight = 350;
// NOTE: Uncomment this code in Chapter 7 const { yAxisLabels, topLabel } = generateYAxis(revenue);
// const { yAxisLabels, topLabel } = generateYAxis(revenue); if (!revenue || revenue.length === 0) {
return <p className="mt-4 text-gray-400">No data available.</p>;
// if (!revenue || revenue.length === 0) { }
// return <p className="mt-4 text-gray-400">No data available.</p>;
// }
return ( return (
<div className="w-full md:col-span-4"> <div className="w-full md:col-span-4">
<h2 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}> <h2 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
Recent Revenue Recent Revenue
</h2> </h2>
{/* NOTE: Uncomment this code in Chapter 7 */} <div className="rounded-xl bg-gray-50 p-4">
{/* <div className="rounded-xl bg-gray-50 p-4">
<div className="sm:grid-cols-13 mt-0 grid grid-cols-12 items-end gap-2 rounded-md bg-white p-4 md:gap-4"> <div className="sm:grid-cols-13 mt-0 grid grid-cols-12 items-end gap-2 rounded-md bg-white p-4 md:gap-4">
<div <div
className="mb-6 hidden flex-col justify-between text-sm text-gray-400 sm:flex" className="mb-6 hidden flex-col justify-between text-sm text-gray-400 sm:flex"
@ -59,7 +54,7 @@ export default async function RevenueChart({
<CalendarIcon className="h-5 w-5 text-gray-500" /> <CalendarIcon className="h-5 w-5 text-gray-500" />
<h3 className="ml-2 text-sm text-gray-500 ">Last 12 months</h3> <h3 className="ml-2 text-sm text-gray-500 ">Last 12 months</h3>
</div> </div>
</div> */} </div>
</div> </div>
); );
} }

View file

@ -1,11 +1,12 @@
{ {
"name": "nextjs-dashboard", "name": "dashboard-app-course",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@neondatabase/serverless": "^1.0.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"autoprefixer": "10.4.20", "autoprefixer": "10.4.20",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
@ -602,6 +603,19 @@
"node-pre-gyp": "bin/node-pre-gyp" "node-pre-gyp": "bin/node-pre-gyp"
} }
}, },
"node_modules/@neondatabase/serverless": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.0.0.tgz",
"integrity": "sha512-XWmEeWpBXIoksZSDN74kftfTnXFEGZ3iX8jbANWBc+ag6dsiQuvuR4LgB0WdCOKMb5AQgjqgufc0TgAsZubUYw==",
"license": "MIT",
"dependencies": {
"@types/node": "^22.10.2",
"@types/pg": "^8.8.0"
},
"engines": {
"node": ">=19.0.0"
}
},
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "15.3.1", "version": "15.3.1",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.1.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.1.tgz",
@ -837,12 +851,22 @@
"version": "22.10.7", "version": "22.10.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
"integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==", "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.20.0" "undici-types": "~6.20.0"
} }
}, },
"node_modules/@types/pg": {
"version": "8.11.13",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.13.tgz",
"integrity": "sha512-6kXByGkvRvwXLuyaWzsebs2du6+XuAB2CuMsuzP7uaihQahshVgSmB22Pmh0vQMkQ1h5+PZU0d+Di1o+WpVWJg==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^4.0.1"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.0.7", "version": "19.0.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.7.tgz",
@ -2099,6 +2123,12 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/obuf": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
"license": "MIT"
},
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -2154,6 +2184,48 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-numeric": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz",
"integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==",
"license": "ISC",
"engines": {
"node": ">=4"
}
},
"node_modules/pg-protocol": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz",
"integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz",
"integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"pg-numeric": "1.0.2",
"postgres-array": "~3.0.1",
"postgres-bytea": "~3.0.0",
"postgres-date": "~2.1.0",
"postgres-interval": "^3.0.0",
"postgres-range": "^1.1.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2311,6 +2383,51 @@
"url": "https://github.com/sponsors/porsager" "url": "https://github.com/sponsors/porsager"
} }
}, },
"node_modules/postgres-array": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz",
"integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/postgres-bytea": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz",
"integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==",
"license": "MIT",
"dependencies": {
"obuf": "~1.1.2"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/postgres-date": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz",
"integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/postgres-interval": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz",
"integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/postgres-range": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz",
"integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==",
"license": "MIT"
},
"node_modules/preact": { "node_modules/preact": {
"version": "10.11.3", "version": "10.11.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
@ -2953,7 +3070,6 @@
"version": "6.20.0", "version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {

View file

@ -7,6 +7,7 @@
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@neondatabase/serverless": "^1.0.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"autoprefixer": "10.4.20", "autoprefixer": "10.4.20",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",