-
Notifications
You must be signed in to change notification settings - Fork 26.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
examples: update Redis to App Router (#59311)
- Loading branch information
Showing
24 changed files
with
1,396 additions
and
482 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
KV_URL= | ||
KV_REST_API_URL= | ||
KV_REST_API_TOKEN= | ||
KV_REST_API_READ_ONLY_TOKEN= |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,7 +4,6 @@ | |
/node_modules | ||
/.pnp | ||
.pnp.js | ||
.yarn/install-state.gz | ||
|
||
# testing | ||
/coverage | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
'use server' | ||
|
||
import { kv } from '@vercel/kv' | ||
import { revalidatePath } from 'next/cache' | ||
import { Feature } from './types' | ||
|
||
export async function saveFeature(feature: Feature) { | ||
await kv.hset(`item:${feature.id}`, feature) | ||
await kv.zadd('items_by_score', { | ||
score: Number(feature.score), | ||
member: feature.id, | ||
}) | ||
|
||
revalidatePath('/') | ||
} | ||
|
||
export async function saveEmail(formData: FormData) { | ||
const email = formData.get('email') | ||
|
||
function validateEmail(email: FormDataEntryValue) { | ||
const re = | ||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ | ||
return re.test(String(email).toLowerCase()) | ||
} | ||
|
||
if (email && validateEmail(email)) { | ||
await kv.sadd('emails', email) | ||
revalidatePath('/') | ||
} | ||
} | ||
|
||
export async function upvote(feature: Feature) { | ||
const newScore = Number(feature.score) + 1 | ||
await kv.hset(`item:${feature.id}`, { | ||
...feature, | ||
score: newScore, | ||
}) | ||
|
||
await kv.zadd('items_by_score', { score: newScore, member: feature.id }) | ||
|
||
revalidatePath('/') | ||
} |
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,163 @@ | ||
'use client' | ||
|
||
import clsx from 'clsx' | ||
import { useOptimistic, useRef } from 'react' | ||
import { saveFeature, upvote } from './actions' | ||
import { v4 as uuidv4 } from 'uuid' | ||
import { Feature } from './types' | ||
|
||
function Item({ | ||
isFirst, | ||
isLast, | ||
isReleased, | ||
hasVoted, | ||
feature, | ||
pending, | ||
mutate, | ||
}: { | ||
isFirst: boolean | ||
isLast: boolean | ||
isReleased: boolean | ||
hasVoted: boolean | ||
feature: Feature | ||
pending: boolean | ||
mutate: any | ||
}) { | ||
return ( | ||
<form | ||
action={async () => { | ||
mutate({ | ||
updatedFeature: { | ||
...feature, | ||
score: (Number(feature.score) + 1).toString(), | ||
}, | ||
pending: true, | ||
}) | ||
await upvote(feature) | ||
}} | ||
className={clsx( | ||
'p-6 mx-8 flex items-center border-t border-l border-r', | ||
isFirst && 'rounded-t-md', | ||
isLast && 'border-b rounded-b-md' | ||
)} | ||
> | ||
<button | ||
className={clsx( | ||
'ring-1 ring-gray-200 rounded-full w-8 min-w-[2rem] h-8 mr-4 focus:outline-none focus:ring focus:ring-blue-300', | ||
(isReleased || hasVoted) && | ||
'bg-green-100 cursor-not-allowed ring-green-300', | ||
pending && 'bg-gray-100 cursor-not-allowed' | ||
)} | ||
disabled={isReleased || hasVoted || pending} | ||
type="submit" | ||
> | ||
{isReleased ? '✅' : '👍'} | ||
</button> | ||
<h3 className="text font-semibold w-full text-left">{feature.title}</h3> | ||
<div className="bg-gray-200 text-gray-700 text-sm rounded-xl px-2 ml-2"> | ||
{feature.score} | ||
</div> | ||
</form> | ||
) | ||
} | ||
|
||
type FeatureState = { | ||
newFeature: Feature | ||
updatedFeature?: Feature | ||
pending: boolean | ||
} | ||
|
||
export default function FeatureForm({ features }: { features: Feature[] }) { | ||
const formRef = useRef<HTMLFormElement>(null) | ||
const [state, mutate] = useOptimistic( | ||
{ features, pending: false }, | ||
function createReducer(state, newState: FeatureState) { | ||
if (newState.newFeature) { | ||
return { | ||
features: [...state.features, newState.newFeature], | ||
pending: newState.pending, | ||
} | ||
} else { | ||
return { | ||
features: [ | ||
...state.features.filter( | ||
(f) => f.id !== newState.updatedFeature!.id | ||
), | ||
newState.updatedFeature, | ||
] as Feature[], | ||
pending: newState.pending, | ||
} | ||
} | ||
} | ||
) | ||
|
||
let sortedFeatures = state.features.sort((a, b) => { | ||
// First, compare by score in descending order | ||
if (Number(a.score) > Number(b.score)) return -1 | ||
if (Number(a.score) < Number(b.score)) return 1 | ||
|
||
// If scores are equal, then sort by created_at in ascending order | ||
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime() | ||
}) | ||
|
||
return ( | ||
<> | ||
<div className="mx-8 w-full"> | ||
<form | ||
className="relative my-8" | ||
ref={formRef} | ||
action={async (formData) => { | ||
const newFeature = { | ||
id: uuidv4(), | ||
title: `${formData.get('feature')}`, | ||
created_at: new Date().toISOString(), | ||
score: '1', | ||
} | ||
|
||
mutate({ | ||
newFeature, | ||
pending: true, | ||
}) | ||
formRef.current?.reset() | ||
await saveFeature(newFeature) | ||
}} | ||
> | ||
<input | ||
aria-label="Suggest a feature for our roadmap" | ||
className="pl-3 pr-28 py-3 mt-1 text-lg block w-full border border-gray-200 rounded-md text-gray-900 placeholder-gray-400 focus:outline-none focus:ring focus:ring-blue-300" | ||
maxLength={150} | ||
placeholder="I want..." | ||
required | ||
type="text" | ||
name="feature" | ||
disabled={state.pending} | ||
/> | ||
<button | ||
className={clsx( | ||
'flex items-center justify-center absolute right-2 top-2 px-4 h-10 text-lg border bg-black text-white rounded-md w-24 focus:outline-none focus:ring focus:ring-blue-300 focus:bg-gray-800', | ||
state.pending && 'bg-gray-700 cursor-not-allowed' | ||
)} | ||
type="submit" | ||
disabled={state.pending} | ||
> | ||
Request | ||
</button> | ||
</form> | ||
</div> | ||
<div className="w-full"> | ||
{sortedFeatures.map((feature: any, index: number) => ( | ||
<Item | ||
key={feature.id} | ||
isFirst={index === 0} | ||
isLast={index === sortedFeatures.length - 1} | ||
isReleased={false} | ||
hasVoted={false} | ||
feature={feature} | ||
pending={state.pending} | ||
mutate={mutate} | ||
/> | ||
))} | ||
</div> | ||
</> | ||
) | ||
} |
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import './globals.css' | ||
import { GeistSans } from 'geist/font/sans' | ||
|
||
export default function RootLayout({ | ||
children, | ||
}: { | ||
children: React.ReactNode | ||
}) { | ||
return ( | ||
<html lang="en" className={GeistSans.variable}> | ||
<body>{children}</body> | ||
</html> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,119 @@ | ||
import { kv } from '@vercel/kv' | ||
import { saveEmail } from './actions' | ||
import FeatureForm from './form' | ||
import { Feature } from './types' | ||
|
||
export let metadata = { | ||
title: 'Next.js and Redis Example', | ||
description: 'Feature roadmap example with Next.js with Redis.', | ||
} | ||
|
||
function VercelLogo(props: React.SVGProps<SVGSVGElement>) { | ||
return ( | ||
<svg | ||
aria-label="Vercel Logo" | ||
xmlns="http://www.w3.org/2000/svg" | ||
fill="none" | ||
viewBox="0 0 24 19" | ||
{...props} | ||
> | ||
<path | ||
clipRule="evenodd" | ||
d="M12.04 2L2.082 18H22L12.04 2z" | ||
fill="#000" | ||
fillRule="evenodd" | ||
stroke="#000" | ||
strokeWidth="1.5" | ||
/> | ||
</svg> | ||
) | ||
} | ||
|
||
async function getFeatures() { | ||
let itemIds = await kv.zrange('items_by_score', 0, 100, { | ||
rev: true, | ||
}) | ||
|
||
if (!itemIds.length) { | ||
return [] | ||
} | ||
|
||
let multi = kv.multi() | ||
itemIds.forEach((id) => { | ||
multi.hgetall(`item:${id}`) | ||
}) | ||
|
||
let items: Feature[] = await multi.exec() | ||
return items.map((item) => { | ||
return { | ||
...item, | ||
score: item.score, | ||
created_at: item.created_at, | ||
} | ||
}) | ||
} | ||
|
||
export default async function Page() { | ||
let features = await getFeatures() | ||
|
||
return ( | ||
<div className="flex flex-col items-center justify-center min-h-screen py-2"> | ||
<main className="flex flex-col items-center justify-center flex-1 px-4 sm:px-20 text-center"> | ||
<div className="flex justify-center items-center bg-black rounded-full w-16 sm:w-24 h-16 sm:h-24 my-8"> | ||
<VercelLogo className="h-8 sm:h-16 invert p-3 mb-1" /> | ||
</div> | ||
<h1 className="text-lg sm:text-2xl font-bold mb-2"> | ||
Help us prioritize our roadmap | ||
</h1> | ||
<h2 className="text-md sm:text-xl mx-4"> | ||
Create or vote up features you want to see in our product. | ||
</h2> | ||
<div className="flex flex-wrap items-center justify-around max-w-4xl my-8 sm:w-full bg-white rounded-md shadow-xl h-full border border-gray-100"> | ||
<FeatureForm features={features} /> | ||
<hr className="border-1 border-gray-200 my-8 mx-8 w-full" /> | ||
<div className="mx-8 w-full"> | ||
<p className="flex text-gray-500"> | ||
Leave your email address here to be notified when feature requests | ||
are released. | ||
</p> | ||
<form className="relative my-4" action={saveEmail}> | ||
<input | ||
name="email" | ||
aria-label="Email for updates" | ||
placeholder="Email Address" | ||
type="email" | ||
autoComplete="email" | ||
maxLength={60} | ||
required | ||
className="px-3 py-3 mt-1 text-lg block w-full border border-gray-200 rounded-md text-gray-900 placeholder-gray-400 focus:outline-none focus:ring focus:ring-blue-300" | ||
/> | ||
<button | ||
className="flex items-center justify-center absolute right-2 top-2 px-4 h-10 border border-gray-200 text-gray-900 rounded-md w-14 focus:outline-none focus:ring focus:ring-blue-300 focus:bg-gray-100" | ||
type="submit" | ||
> | ||
OK | ||
</button> | ||
</form> | ||
<div className="flex flex-col sm:flex-row justify-between items-center"> | ||
<p className="flex items-center my-8 w-full justify-center sm:justify-start"> | ||
Powered by | ||
<VercelLogo className="h-5 mx-2" /> | ||
</p> | ||
<a | ||
target="_blank" | ||
rel="noopener noreferrer" | ||
className="flex rounded focus:outline-none focus:ring focus:ring-blue-300 mb-4 sm:mb-0 min-w-max" | ||
href={`https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgit.luolix.top%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fwith-redis&project-name=redis-roadmap&repository-name=redis-roadmap&demo-title=Redis%20Roadmap&demo-description=Create%20and%20upvote%20features%20for%20your%20product.&demo-url=https%3A%2F%2Froadmap-redis.vercel.app%2F&stores=%5B%7B"type"%3A"kv"%7D%5D&`} | ||
> | ||
<img | ||
src="https://vercel.com/button" | ||
alt="Vercel Deploy Button" | ||
/> | ||
</a> | ||
</div> | ||
</div> | ||
</div> | ||
</main> | ||
</div> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import clsx from 'clsx' | ||
|
||
export default function LoadingSpinner({ invert }: { invert?: boolean }) { | ||
return ( | ||
<svg | ||
className={clsx( | ||
'animate-spin h-5 w-5 text-gray-900 dark:text-gray-100', | ||
invert && 'text-gray-100 dark:text-gray-900' | ||
)} | ||
xmlns="http://www.w3.org/2000/svg" | ||
fill="none" | ||
viewBox="0 0 24 24" | ||
> | ||
<circle | ||
className="opacity-25" | ||
cx="12" | ||
cy="12" | ||
r="10" | ||
stroke="currentColor" | ||
strokeWidth="4" | ||
/> | ||
<path | ||
className="opacity-75" | ||
fill="currentColor" | ||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" | ||
/> | ||
</svg> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
export type Feature = { | ||
id: string | ||
title: string | ||
score: string | ||
created_at: string | ||
} |
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.