Skip to content

Commit ed68f6b

Browse files
author
Ax
committed
16章全部学习完毕
1 parent 074afc5 commit ed68f6b

17 files changed

+384
-11
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
学习 Next.js ==> https://nextjs.org/learn/dashboard-app/improving-accessibility
22

3-
可以学习14章了
3+
16章全部学习完毕
44

55
- /app/layout.tsx --> 根布局
66
- /app/page.tsx --> 路由 /

app/dashboard/customers/page.tsx

+6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { cookies } from 'next/headers';
22
import Child from './child';
3+
import type { Metadata } from 'next';
4+
5+
export const metadata: Metadata = {
6+
title: 'Customers',
7+
description: 'Acme Dashboard is the best dashboard for all your needs.',
8+
};
39

410
export default async function Page() {
511
console.log('customer page 加载了');

app/dashboard/invoices/page.tsx

+6
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import { lusitana } from '@/app/ui/fonts';
66
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
77
import { Suspense } from 'react';
88
import { fetchInvoicesPages } from '@/app/lib/data';
9+
import type { Metadata } from 'next';
10+
11+
export const metadata: Metadata = {
12+
title: 'Invoices',
13+
description: 'Acme Dashboard is the best dashboard for all your needs.',
14+
};
915

1016
// 路由组件可以接受 路由参数
1117
export default async function Page({

app/favicon.ico

14.7 KB
Binary file not shown.

app/layout.tsx

+9
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
import '@/app/ui/global.css';
22
import { inter } from '@/app/ui/fonts';
3+
import type { Metadata } from 'next';
4+
5+
export const metadata: Metadata = {
6+
title: {
7+
template: '%s | Acme Dashboard',
8+
default: 'Acme Dashboard',
9+
},
10+
description: 'Acme Dashboard is the best dashboard for all your needs.',
11+
};
312

413
export default function RootLayout({
514
children,

app/lib/actions.ts

+54-4
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,52 @@ import { z } from 'zod';
33
import { sql } from '@vercel/postgres';
44
import { revalidatePath } from 'next/cache';
55
import { redirect } from 'next/navigation';
6+
import { signIn } from '@/auth';
7+
import { AuthError } from 'next-auth';
68

79
const FormSchema = z.object({
810
id: z.string(),
9-
customerId: z.string(),
10-
amount: z.coerce.number(), // 将string转为number
11-
status: z.enum(['pending', 'paid']),
11+
customerId: z.string({
12+
invalid_type_error: 'Please select a customer.',
13+
}),
14+
amount: z.coerce
15+
.number()
16+
.gt(0, { message: 'Please enter an amount greater than $0.' }),
17+
// 将string转为number
18+
status: z.enum(['pending', 'paid'], {
19+
invalid_type_error: 'Please select an invoice status.',
20+
}),
1221
date: z.string(),
1322
});
1423

24+
export type State = {
25+
errors?: {
26+
customerId?: string[];
27+
amount?: string[];
28+
status?: string[];
29+
};
30+
message?: string | null;
31+
};
32+
1533
// 删除 id 和 date 字段
1634
const CreateInvoice = FormSchema.omit({ id: true, date: true });
1735

1836
const UpdateInvoice = FormSchema.omit({ id: true, date: true });
1937

20-
export async function createInvoice(formData: FormData) {
38+
export async function createInvoice(_: State, formData: FormData) {
39+
// Validate form fields using Zod
40+
const validatedFields = CreateInvoice.safeParse({
41+
customerId: formData.get('customerId'),
42+
amount: formData.get('amount'),
43+
status: formData.get('status'),
44+
});
45+
// If form validation fails, return errors early. Otherwise, continue.
46+
if (!validatedFields.success) {
47+
return {
48+
errors: validatedFields.error.flatten().fieldErrors,
49+
message: 'Missing Fields. Failed to Create Invoice.',
50+
};
51+
}
2152
const { customerId, amount, status } = CreateInvoice.parse({
2253
customerId: formData.get('customerId'),
2354
amount: formData.get('amount'),
@@ -88,3 +119,22 @@ export async function deleteInvoice(id: string) {
88119
*/
89120
revalidatePath('/dashboard/invoices');
90121
}
122+
123+
export async function authenticate(
124+
prevState: string | undefined,
125+
formData: FormData,
126+
) {
127+
try {
128+
await signIn('credentials', formData);
129+
} catch (error) {
130+
if (error instanceof AuthError) {
131+
switch (error.type) {
132+
case 'CredentialsSignin':
133+
return 'Invalid credentials.';
134+
default:
135+
return 'Something went wrong.';
136+
}
137+
}
138+
throw error;
139+
}
140+
}

app/login/page.tsx

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import AcmeLogo from '@/app/ui/acme-logo';
2+
import LoginForm from '@/app/ui/login-form';
3+
import type { Metadata } from 'next';
4+
5+
export const metadata: Metadata = {
6+
title: 'Login',
7+
description: 'Acme Dashboard is the best dashboard for all your needs.',
8+
};
9+
10+
export default function LoginPage() {
11+
return (
12+
<main className="flex items-center justify-center md:h-screen">
13+
<div className="relative mx-auto flex w-full max-w-[400px] flex-col space-y-2.5 p-4 md:-mt-32">
14+
<div className="flex h-20 w-full items-end rounded-lg bg-blue-500 p-3 md:h-36">
15+
<div className="w-32 text-white md:w-36">
16+
<AcmeLogo />
17+
</div>
18+
</div>
19+
<LoginForm />
20+
</div>
21+
</main>
22+
);
23+
}

app/opengraph-image.png

237 KB
Loading

app/ui/dashboard/sidenav.tsx

+7-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Link from 'next/link';
22
import NavLinks from '@/app/ui/dashboard/nav-links';
33
import AcmeLogo from '@/app/ui/acme-logo';
44
import { PowerIcon } from '@heroicons/react/24/outline';
5+
import { signOut } from '@/auth';
56

67
export default function SideNav() {
78
return (
@@ -17,7 +18,12 @@ export default function SideNav() {
1718
<div className="flex grow flex-row justify-between space-x-2 md:flex-col md:space-x-0 md:space-y-2">
1819
<NavLinks />
1920
<div className="hidden h-auto w-full grow rounded-md bg-gray-50 md:block"></div>
20-
<form>
21+
<form
22+
action={async () => {
23+
'use server';
24+
await signOut();
25+
}}
26+
>
2127
<button className="flex h-[48px] w-full grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3">
2228
<PowerIcon className="w-6" />
2329
<div className="hidden md:block">Sign Out</div>

app/ui/invoices/create-form.tsx

+32-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
'use client';
2+
13
import { CustomerField } from '@/app/lib/definitions';
24
import Link from 'next/link';
35
import {
@@ -8,10 +10,14 @@ import {
810
} from '@heroicons/react/24/outline';
911
import { Button } from '@/app/ui/button';
1012
import { createInvoice } from '@/app/lib/actions';
13+
import { useFormState } from 'react-dom';
1114

1215
export default function Form({ customers }: { customers?: CustomerField[] }) {
16+
const initialState = { message: null, errors: {} };
17+
const [state, dispatch] = useFormState(createInvoice, initialState);
18+
1319
return (
14-
<form action={createInvoice}>
20+
<form action={dispatch}>
1521
<div className="rounded-md bg-gray-50 p-4 md:p-6">
1622
{/* Customer Name */}
1723
<div className="mb-4">
@@ -24,6 +30,7 @@ export default function Form({ customers }: { customers?: CustomerField[] }) {
2430
name="customerId"
2531
className="peer block w-full cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
2632
defaultValue=""
33+
aria-describedby="customer-error"
2734
>
2835
<option value="" disabled>
2936
Select a customer
@@ -36,6 +43,14 @@ export default function Form({ customers }: { customers?: CustomerField[] }) {
3643
</select>
3744
<UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
3845
</div>
46+
<div id="customer-error" aria-live="polite" aria-atomic="true">
47+
{state.errors?.customerId &&
48+
state.errors.customerId.map((error: string) => (
49+
<p className="mt-2 text-sm text-red-500" key={error}>
50+
{error}
51+
</p>
52+
))}
53+
</div>
3954
</div>
4055

4156
{/* Invoice Amount */}
@@ -55,6 +70,14 @@ export default function Form({ customers }: { customers?: CustomerField[] }) {
5570
/>
5671
<CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
5772
</div>
73+
<div id="amount-error" aria-live="polite" aria-atomic="true">
74+
{state.errors?.amount &&
75+
state.errors.amount.map((error: string) => (
76+
<p className="mt-2 text-sm text-red-500" key={error}>
77+
{error}
78+
</p>
79+
))}
80+
</div>
5881
</div>
5982
</div>
6083

@@ -96,6 +119,14 @@ export default function Form({ customers }: { customers?: CustomerField[] }) {
96119
</label>
97120
</div>
98121
</div>
122+
<div id="status-error" aria-live="polite" aria-atomic="true">
123+
{state.errors?.status &&
124+
state.errors.status.map((error: string) => (
125+
<p className="mt-2 text-sm text-red-500" key={error}>
126+
{error}
127+
</p>
128+
))}
129+
</div>
99130
</div>
100131
</fieldset>
101132
</div>

app/ui/login-form.tsx

+21-4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
'use client';
2+
13
import { lusitana } from '@/app/ui/fonts';
24
import {
35
AtSymbolIcon,
@@ -6,10 +8,13 @@ import {
68
} from '@heroicons/react/24/outline';
79
import { ArrowRightIcon } from '@heroicons/react/20/solid';
810
import { Button } from './button';
9-
11+
import { useFormState, useFormStatus } from 'react-dom';
12+
import { authenticate } from '@/app/lib/actions';
1013
export default function LoginForm() {
14+
const [errorMessage, dispatch] = useFormState(authenticate, undefined);
15+
1116
return (
12-
<form className="space-y-3">
17+
<form action={dispatch} className="space-y-3">
1318
<div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8">
1419
<h1 className={`${lusitana.className} mb-3 text-2xl`}>
1520
Please log in to continue.
@@ -57,16 +62,28 @@ export default function LoginForm() {
5762
</div>
5863
<LoginButton />
5964
<div className="flex h-8 items-end space-x-1">
60-
{/* Add form errors here */}
65+
<div
66+
className="flex h-8 items-end space-x-1"
67+
aria-live="polite"
68+
aria-atomic="true"
69+
>
70+
{errorMessage && (
71+
<>
72+
<ExclamationCircleIcon className="h-5 w-5 text-red-500" />
73+
<p className="text-sm text-red-500">{errorMessage}</p>
74+
</>
75+
)}
76+
</div>
6177
</div>
6278
</div>
6379
</form>
6480
);
6581
}
6682

6783
function LoginButton() {
84+
const { pending } = useFormStatus();
6885
return (
69-
<Button className="mt-4 w-full">
86+
<Button className="mt-4 w-full" aria-disabled={pending}>
7087
Log in <ArrowRightIcon className="ml-auto h-5 w-5 text-gray-50" />
7188
</Button>
7289
);

auth.config.ts

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { NextAuthConfig } from 'next-auth';
2+
3+
export const authConfig = {
4+
pages: {
5+
signIn: '/login',
6+
},
7+
callbacks: {
8+
authorized({ auth, request: { nextUrl } }) {
9+
const isLoggedIn = !!auth?.user;
10+
const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
11+
if (isOnDashboard) {
12+
if (isLoggedIn) return true;
13+
return false; // Redirect unauthenticated users to login page
14+
} else if (isLoggedIn) {
15+
return Response.redirect(new URL('/dashboard', nextUrl));
16+
}
17+
return true;
18+
},
19+
},
20+
providers: [], // Add providers with an empty array for now
21+
} satisfies NextAuthConfig;

auth.ts

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import NextAuth from 'next-auth';
2+
import { authConfig } from './auth.config';
3+
import Credentials from 'next-auth/providers/credentials';
4+
import { z } from 'zod';
5+
import { sql } from '@vercel/postgres';
6+
import type { User } from '@/app/lib/definitions';
7+
import bcrypt from 'bcrypt';
8+
9+
async function getUser(email: string): Promise<User | undefined> {
10+
try {
11+
const user = await sql<User>`SELECT * FROM users WHERE email=${email}`;
12+
return user.rows[0];
13+
} catch (error) {
14+
console.error('Failed to fetch user:', error);
15+
throw new Error('Failed to fetch user.');
16+
}
17+
}
18+
19+
export const { auth, signIn, signOut } = NextAuth({
20+
...authConfig,
21+
providers: [
22+
Credentials({
23+
async authorize(credentials) {
24+
const parsedCredentials = z
25+
.object({ email: z.string().email(), password: z.string().min(6) })
26+
.safeParse(credentials);
27+
28+
if (parsedCredentials.success) {
29+
const { email, password } = parsedCredentials.data;
30+
const user = await getUser(email);
31+
if (!user) return null;
32+
const passwordsMatch = await bcrypt.compare(password, user.password);
33+
if (passwordsMatch) return user;
34+
}
35+
36+
return null;
37+
},
38+
}),
39+
],
40+
});

0 commit comments

Comments
 (0)