Compare commits
10 Commits
90c0dd6812
...
b73c9311f5
| Author | SHA1 | Date | |
|---|---|---|---|
| b73c9311f5 | |||
| 434b7c8dc8 | |||
| 800c231580 | |||
| aa81948f29 | |||
| a4536d20b4 | |||
| 75337be90c | |||
| 298d25a579 | |||
| 276bea81b0 | |||
| 595520296c | |||
| 4330157491 |
@@ -1,57 +1,60 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Form from "next/form";
|
|
||||||
import { ContactRecord } from "@/app/data";
|
import { ContactRecord } from "@/app/data";
|
||||||
|
import Form from "next/form";
|
||||||
|
import { use } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export default function Contact() {
|
export default function Contact({
|
||||||
const contact = {
|
contact
|
||||||
first: "Your",
|
}: {
|
||||||
last: "Name",
|
contact: Promise<ContactRecord>
|
||||||
avatar: "https://placecats.com/200/200",
|
}) {
|
||||||
twitter: "your_handle",
|
const c = use(contact);
|
||||||
notes: "Some notes",
|
const router = useRouter();
|
||||||
favorite: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="contact">
|
<div id="contact">
|
||||||
<div>
|
<div>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
alt={`${contact.first} ${contact.last} avatar`}
|
alt={`${c.first} ${c.last} avatar`}
|
||||||
key={contact.avatar}
|
key={c.avatar}
|
||||||
src={contact.avatar}
|
src={c.avatar}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1>
|
<h1>
|
||||||
{contact.first || contact.last ? (
|
{c.first || c.last ? (
|
||||||
<>
|
<>
|
||||||
{contact.first} {contact.last}
|
{c.first} {c.last}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<i>No Name</i>
|
<i>No Name</i>
|
||||||
)}
|
)}
|
||||||
<Favorite contact={contact}/>
|
<Favorite contact={c}/>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{contact.twitter ? (
|
{c.twitter ? (
|
||||||
<p>
|
<p>
|
||||||
<a
|
<a
|
||||||
href={`https://twitter.com/${contact.twitter}`}
|
href={`https://twitter.com/${c.twitter}`}
|
||||||
>
|
>
|
||||||
{contact.twitter}
|
{c.twitter}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{contact.notes ? <p>{contact.notes}</p> : null}
|
{c.notes ? <p>{c.notes}</p> : null}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Form action="edit">
|
<button
|
||||||
<button type="submit">Edit</button>
|
type="button"
|
||||||
</Form>
|
onClick={() => router.push(`/contacts/${c.id}/edit`)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
action="destroy"
|
action="destroy"
|
||||||
78
src/app/(sidebar)/contacts/[contactId]/edit/page.tsx
Normal file
78
src/app/(sidebar)/contacts/[contactId]/edit/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import Form from "next/form";
|
||||||
|
import { fetchContact } from "@/app/(sidebar)/loaders";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { updateContact } from "@/app/data";
|
||||||
|
|
||||||
|
|
||||||
|
async function editContact(contactId: string, formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const updates = Object.fromEntries(formData);
|
||||||
|
await updateContact(contactId, updates);
|
||||||
|
redirect(`/contacts/${contactId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditContactPage({
|
||||||
|
params
|
||||||
|
}: {
|
||||||
|
params: Promise<{ contactId: string }>
|
||||||
|
}) {
|
||||||
|
const { contactId } = await params;
|
||||||
|
const contact = await fetchContact(contactId).then((c) => {
|
||||||
|
if (c === null) notFound();
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
const editThisContact = editContact.bind(null, contactId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form key={contact.id} id="contact-form" action={editThisContact}>
|
||||||
|
<p>
|
||||||
|
<span>Name</span>
|
||||||
|
<input
|
||||||
|
aria-label="First name"
|
||||||
|
defaultValue={contact.first}
|
||||||
|
name="first"
|
||||||
|
placeholder="First"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label="Last name"
|
||||||
|
defaultValue={contact.last}
|
||||||
|
name="last"
|
||||||
|
placeholder="Last"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<label>
|
||||||
|
<span>Twitter</span>
|
||||||
|
<input
|
||||||
|
defaultValue={contact.twitter}
|
||||||
|
name="twitter"
|
||||||
|
placeholder="@jack"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Avatar URL</span>
|
||||||
|
<input
|
||||||
|
aria-label="Avatar URL"
|
||||||
|
defaultValue={contact.avatar}
|
||||||
|
name="avatar"
|
||||||
|
placeholder="https://example.com/avatar.jpg"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Notes</span>
|
||||||
|
<textarea
|
||||||
|
defaultValue={contact.notes}
|
||||||
|
name="notes"
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<button type="button">Cancel</button>
|
||||||
|
</p>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/(sidebar)/contacts/[contactId]/loading.tsx
Normal file
5
src/app/(sidebar)/contacts/[contactId]/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default function ContactLoading() {
|
||||||
|
return (
|
||||||
|
<div>Loading Contact...</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/app/(sidebar)/contacts/[contactId]/not-found.tsx
Normal file
8
src/app/(sidebar)/contacts/[contactId]/not-found.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<main id="error-page">
|
||||||
|
<h1>404</h1>
|
||||||
|
<p>The requested contact could not be found.</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/app/(sidebar)/contacts/[contactId]/page.tsx
Normal file
19
src/app/(sidebar)/contacts/[contactId]/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Contact from "@/app/(sidebar)/contacts/[contactId]/Contact";
|
||||||
|
import { fetchContact } from "@/app/(sidebar)/loaders";
|
||||||
|
|
||||||
|
export default async function ContactPage({
|
||||||
|
params
|
||||||
|
}: {
|
||||||
|
params: Promise<{ contactId: string }>
|
||||||
|
}) {
|
||||||
|
const { contactId } = await params;
|
||||||
|
const contact = fetchContact(contactId).then((c) => {
|
||||||
|
if (c === null) notFound();
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Contact contact={contact}/>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/app/(sidebar)/layout.tsx
Normal file
56
src/app/(sidebar)/layout.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React, { Suspense } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Form from "next/form";
|
||||||
|
import ContactList from "./sidebar-contacts";
|
||||||
|
import { createEmptyContact } from "@/app/data";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { fetchContacts } from "@/app/(sidebar)/loaders";
|
||||||
|
|
||||||
|
async function newContact() {
|
||||||
|
"use server";
|
||||||
|
await createEmptyContact();
|
||||||
|
revalidatePath('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SidebarRootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
const contacts = fetchContacts();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div id="sidebar">
|
||||||
|
<h1>
|
||||||
|
<Link href="/about">React Router Contacts</Link>
|
||||||
|
</h1>
|
||||||
|
<div>
|
||||||
|
<Form id="search-form" role="search" action="." formMethod="get">
|
||||||
|
<input
|
||||||
|
aria-label="Search contacts"
|
||||||
|
id="q"
|
||||||
|
name="q"
|
||||||
|
placeholder="Search"
|
||||||
|
type="search"/>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
hidden={true}
|
||||||
|
id="search-spinner"/>
|
||||||
|
</Form>
|
||||||
|
<Form action={newContact}>
|
||||||
|
<button type="submit">New</button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
<nav>
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<ContactList contacts={contacts}/>
|
||||||
|
</Suspense>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div id={'detail'}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/app/(sidebar)/loaders.ts
Normal file
9
src/app/(sidebar)/loaders.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { getContact, getContacts } from "@/app/data";
|
||||||
|
|
||||||
|
export const fetchContacts = async () => {
|
||||||
|
return await getContacts();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchContact = async (contactId: string) => {
|
||||||
|
return await getContact(contactId);
|
||||||
|
};
|
||||||
15
src/app/(sidebar)/page.tsx
Normal file
15
src/app/(sidebar)/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<p id="index-page">
|
||||||
|
This is a demo for React Router.
|
||||||
|
<br/>
|
||||||
|
Check out{" "}
|
||||||
|
<a href="https://reactrouter.com">
|
||||||
|
the docs at reactrouter.com
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/app/(sidebar)/sidebar-contacts.tsx
Normal file
27
src/app/(sidebar)/sidebar-contacts.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { ContactRecord } from "@/app/data";
|
||||||
|
import React, { use } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function ContactList({ contacts }: { contacts: Promise<ContactRecord[]> }) {
|
||||||
|
const allContacts = use(contacts);
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{allContacts.map((c) => (
|
||||||
|
<li key={c.id}>
|
||||||
|
<Link href={`/contacts/${c.id}`}>
|
||||||
|
{c.first || c.last ? (
|
||||||
|
<>
|
||||||
|
{c.first} {c.last}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<i>No Name</i>
|
||||||
|
)}
|
||||||
|
{c.favorite ? (
|
||||||
|
<span>★</span>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/app/about/page.tsx
Normal file
45
src/app/about/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function About() {
|
||||||
|
return (
|
||||||
|
<div id="about">
|
||||||
|
<Link href="/">← Go to demo</Link>
|
||||||
|
<h1>About React Router Contacts</h1>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
This is a demo application showing off some of the
|
||||||
|
powerful features of React Router, including
|
||||||
|
dynamic routing, nested routes, loaders, actions,
|
||||||
|
and more.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Features</h2>
|
||||||
|
<p>
|
||||||
|
Explore the demo to see how React Router handles:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
Data loading and mutations with loaders and
|
||||||
|
actions
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Nested routing with parent/child relationships
|
||||||
|
</li>
|
||||||
|
<li>URL-based routing with dynamic segments</li>
|
||||||
|
<li>Pending and optimistic UI</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Learn More</h2>
|
||||||
|
<p>
|
||||||
|
Check out the official documentation at{" "}
|
||||||
|
<a href="https://reactrouter.com">
|
||||||
|
reactrouter.com
|
||||||
|
</a>{" "}
|
||||||
|
to learn more about building great web
|
||||||
|
applications with React Router.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import Form from "next/form";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Next.js Address Book",
|
title: "Next.js Address Book",
|
||||||
@@ -18,42 +16,8 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>
|
<body>
|
||||||
<div id="sidebar">
|
{children}
|
||||||
<h1>React Router Contacts</h1>
|
|
||||||
<div>
|
|
||||||
<Form id="search-form" role="search" action="." formMethod="get">
|
|
||||||
<input
|
|
||||||
aria-label="Search contacts"
|
|
||||||
id="q"
|
|
||||||
name="q"
|
|
||||||
placeholder="Search"
|
|
||||||
type="search"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
hidden={true}
|
|
||||||
id="search-spinner"
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
<Form action="." formMethod="post">
|
|
||||||
<button type="submit">New</button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
<nav>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<Link href={`/contacts/1`}>Your Name</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link href={`/contacts/2`}>Your Friend</Link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
<div id={'detail'}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import Form from "next/form";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<div></div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user