Skip to content

Commit 0a98a15

Browse files
committedJan 23, 2024
Initial commit from Create Next App
0 parents  commit 0a98a15

15 files changed

+2400
-0
lines changed
 

‎.env.example

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
KV_URL=
2+
KV_REST_API_URL=
3+
KV_REST_API_TOKEN=
4+
KV_REST_API_READ_ONLY_TOKEN=

‎.gitignore

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+
3+
# dependencies
4+
/node_modules
5+
/.pnp
6+
.pnp.js
7+
8+
# testing
9+
/coverage
10+
11+
# next.js
12+
/.next/
13+
/out/
14+
15+
# production
16+
/build
17+
18+
# misc
19+
.DS_Store
20+
*.pem
21+
22+
# debug
23+
npm-debug.log*
24+
yarn-debug.log*
25+
yarn-error.log*
26+
27+
# local env files
28+
.env*.local
29+
30+
# vercel
31+
.vercel
32+
33+
# typescript
34+
*.tsbuildinfo
35+
next-env.d.ts

‎README.md

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Redis Example (with Upstash)
2+
3+
This example showcases how to use Redis as a data store in a Next.js project.
4+
5+
The example is a roadmap voting application where users can enter and vote for feature requests. It features the following:
6+
7+
- Users can add and upvote items (features in the roadmap)
8+
- Users can enter their email addresses to be notified about the released items.
9+
10+
## Demo
11+
12+
- [https://roadmap-redis.vercel.app/](https://roadmap-redis.vercel.app/)
13+
14+
## Deploy Your Own
15+
16+
This examples uses [Upstash](https://upstash.com) (Serverless Redis Database) as its data storage. During deployment. The integration will help you create a free Redis database and link it to your Vercel project automatically.
17+
18+
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%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&)
19+
20+
## How to use
21+
22+
Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), or [pnpm](https://pnpm.io) to bootstrap the example:
23+
24+
```bash
25+
npx create-next-app --example with-redis roadmap
26+
# or
27+
yarn create next-app --example with-redis roadmap
28+
# or
29+
pnpm create next-app --example with-redis roadmap
30+
```
31+
32+
Deploy it to the cloud with [Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)).

‎app/actions.tsx

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"use server";
2+
3+
import { kv } from "@vercel/kv";
4+
import { revalidatePath } from "next/cache";
5+
import { Feature } from "./types";
6+
7+
export async function saveFeature(feature: Feature, formData: FormData) {
8+
let newFeature = {
9+
...feature,
10+
title: formData.get("feature") as string,
11+
};
12+
await kv.hset(`item:${newFeature.id}`, newFeature);
13+
await kv.zadd("items_by_score", {
14+
score: Number(newFeature.score),
15+
member: newFeature.id,
16+
});
17+
18+
revalidatePath("/");
19+
}
20+
21+
export async function saveEmail(formData: FormData) {
22+
const email = formData.get("email");
23+
24+
function validateEmail(email: FormDataEntryValue) {
25+
const re =
26+
/^(([^<>()[\]\\.,;:\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,}))$/;
27+
return re.test(String(email).toLowerCase());
28+
}
29+
30+
if (email && validateEmail(email)) {
31+
await kv.sadd("emails", email);
32+
revalidatePath("/");
33+
}
34+
}
35+
36+
export async function upvote(feature: Feature) {
37+
const newScore = Number(feature.score) + 1;
38+
await kv.hset(`item:${feature.id}`, {
39+
...feature,
40+
score: newScore,
41+
});
42+
43+
await kv.zadd("items_by_score", { score: newScore, member: feature.id });
44+
45+
revalidatePath("/");
46+
}

‎app/favicon.ico

14.7 KB
Binary file not shown.

‎app/form.tsx

+186
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"use client";
2+
3+
import clsx from "clsx";
4+
import { useOptimistic, useRef, useTransition } from "react";
5+
import { saveFeature, upvote } from "./actions";
6+
import { v4 as uuidv4 } from "uuid";
7+
import { Feature } from "./types";
8+
9+
function Item({
10+
isFirst,
11+
isLast,
12+
isReleased,
13+
hasVoted,
14+
feature,
15+
pending,
16+
mutate,
17+
}: {
18+
isFirst: boolean;
19+
isLast: boolean;
20+
isReleased: boolean;
21+
hasVoted: boolean;
22+
feature: Feature;
23+
pending: boolean;
24+
mutate: any;
25+
}) {
26+
let upvoteWithId = upvote.bind(null, feature);
27+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
28+
let [isPending, startTransition] = useTransition();
29+
30+
return (
31+
<form
32+
action={upvoteWithId}
33+
onSubmit={(event) => {
34+
event.preventDefault();
35+
36+
startTransition(async () => {
37+
mutate({
38+
updatedFeature: {
39+
...feature,
40+
score: Number(feature.score) + 1,
41+
},
42+
pending: true,
43+
});
44+
await upvote(feature);
45+
});
46+
}}
47+
className={clsx(
48+
"p-6 mx-8 flex items-center border-t border-l border-r",
49+
isFirst && "rounded-t-md",
50+
isLast && "border-b rounded-b-md",
51+
)}
52+
>
53+
<button
54+
className={clsx(
55+
"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",
56+
(isReleased || hasVoted) &&
57+
"bg-green-100 cursor-not-allowed ring-green-300",
58+
pending && "bg-gray-100 cursor-not-allowed",
59+
)}
60+
disabled={isReleased || hasVoted || pending}
61+
type="submit"
62+
>
63+
{isReleased ? "✅" : "👍"}
64+
</button>
65+
<h3 className="text font-semibold w-full text-left">{feature.title}</h3>
66+
<div className="bg-gray-200 text-gray-700 text-sm rounded-xl px-2 ml-2">
67+
{feature.score}
68+
</div>
69+
</form>
70+
);
71+
}
72+
73+
type FeatureState = {
74+
newFeature: Feature;
75+
updatedFeature?: Feature;
76+
pending: boolean;
77+
};
78+
79+
export default function FeatureForm({ features }: { features: Feature[] }) {
80+
let formRef = useRef<HTMLFormElement>(null);
81+
let [state, mutate] = useOptimistic(
82+
{ features, pending: false },
83+
function createReducer(state, newState: FeatureState) {
84+
if (newState.newFeature) {
85+
return {
86+
features: [...state.features, newState.newFeature],
87+
pending: newState.pending,
88+
};
89+
} else {
90+
return {
91+
features: [
92+
...state.features.filter(
93+
(f) => f.id !== newState.updatedFeature!.id,
94+
),
95+
newState.updatedFeature,
96+
] as Feature[],
97+
pending: newState.pending,
98+
};
99+
}
100+
},
101+
);
102+
103+
let sortedFeatures = state.features.sort((a, b) => {
104+
// First, compare by score in descending order
105+
if (Number(a.score) > Number(b.score)) return -1;
106+
if (Number(a.score) < Number(b.score)) return 1;
107+
108+
// If scores are equal, then sort by created_at in ascending order
109+
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
110+
});
111+
112+
let featureStub = {
113+
id: uuidv4(),
114+
title: "", // will used value from form
115+
created_at: new Date().toISOString(),
116+
score: "1",
117+
};
118+
let saveWithNewFeature = saveFeature.bind(null, featureStub);
119+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
120+
let [isPending, startTransition] = useTransition();
121+
122+
return (
123+
<>
124+
<div className="mx-8 w-full">
125+
<form
126+
className="relative my-8"
127+
ref={formRef}
128+
action={saveWithNewFeature}
129+
onSubmit={(event) => {
130+
event.preventDefault();
131+
let formData = new FormData(event.currentTarget);
132+
let newFeature = {
133+
...featureStub,
134+
title: formData.get("feature") as string,
135+
};
136+
137+
formRef.current?.reset();
138+
startTransition(async () => {
139+
mutate({
140+
newFeature,
141+
pending: true,
142+
});
143+
144+
await saveFeature(newFeature, formData);
145+
});
146+
}}
147+
>
148+
<input
149+
aria-label="Suggest a feature for our roadmap"
150+
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"
151+
maxLength={150}
152+
placeholder="I want..."
153+
required
154+
type="text"
155+
name="feature"
156+
disabled={state.pending}
157+
/>
158+
<button
159+
className={clsx(
160+
"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",
161+
state.pending && "bg-gray-700 cursor-not-allowed",
162+
)}
163+
type="submit"
164+
disabled={state.pending}
165+
>
166+
Request
167+
</button>
168+
</form>
169+
</div>
170+
<div className="w-full">
171+
{sortedFeatures.map((feature: any, index: number) => (
172+
<Item
173+
key={feature.id}
174+
isFirst={index === 0}
175+
isLast={index === sortedFeatures.length - 1}
176+
isReleased={false}
177+
hasVoted={false}
178+
feature={feature}
179+
pending={state.pending}
180+
mutate={mutate}
181+
/>
182+
))}
183+
</div>
184+
</>
185+
);
186+
}

‎app/globals.css

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
@tailwind base;
2+
@tailwind components;
3+
@tailwind utilities;
4+
5+
body {
6+
@apply bg-gray-50;
7+
}
8+
9+
input {
10+
/** Remove shadow on Safari iOS */
11+
-webkit-appearance: none;
12+
}

‎app/layout.tsx

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import "./globals.css";
2+
import { GeistSans } from "geist/font/sans";
3+
4+
export default function RootLayout({
5+
children,
6+
}: {
7+
children: React.ReactNode;
8+
}) {
9+
return (
10+
<html lang="en" className={GeistSans.variable}>
11+
<body>{children}</body>
12+
</html>
13+
);
14+
}

0 commit comments

Comments
 (0)
Please sign in to comment.