f7baa56792
### What?
Updates Deploy button URLs for all listed examples here:
https://github.com/vercel/next.js/tree/canary/examples
### Why?
The Deploy URLs are currently broken and result in a failed clone
attempt on Vercel.
### How?
The URLs have been changed from https://vercel.com/new/git/external?… to
-> https://vercel.com/new/clone?…
(Last updated
[here](8eaabe2fb0
)
in 2021)
307 lines
9.5 KiB
TypeScript
307 lines
9.5 KiB
TypeScript
import { useState, useRef } from 'react'
|
||
import type { NextApiRequest } from 'next'
|
||
import type { MouseEvent } from 'react'
|
||
import Head from 'next/head'
|
||
import clsx from 'clsx'
|
||
import useSWR, { mutate } from 'swr'
|
||
import toast from 'react-hot-toast'
|
||
import redis from '../lib/redis'
|
||
|
||
type Feature = {
|
||
id: string
|
||
title: string
|
||
score: number
|
||
ip: string
|
||
}
|
||
|
||
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||
|
||
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>
|
||
)
|
||
}
|
||
|
||
function Item({
|
||
isFirst,
|
||
isLast,
|
||
isReleased,
|
||
hasVoted,
|
||
feature,
|
||
}: {
|
||
isFirst: boolean
|
||
isLast: boolean
|
||
isReleased: boolean
|
||
hasVoted: boolean
|
||
feature: Feature
|
||
}) {
|
||
const upvote = async (e: MouseEvent<HTMLButtonElement>) => {
|
||
e.preventDefault()
|
||
|
||
const res = await fetch('/api/vote', {
|
||
body: JSON.stringify({
|
||
id: feature.id,
|
||
title: feature.title,
|
||
}),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
method: 'POST',
|
||
})
|
||
|
||
const { error } = await res.json()
|
||
if (error) {
|
||
return toast.error(error)
|
||
}
|
||
|
||
mutate('/api/features')
|
||
}
|
||
|
||
return (
|
||
<div
|
||
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'
|
||
)}
|
||
disabled={isReleased || hasVoted}
|
||
onClick={upvote}
|
||
>
|
||
{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>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function Roadmap({
|
||
features,
|
||
ip,
|
||
}: {
|
||
features: Feature[]
|
||
ip: string
|
||
}) {
|
||
const [isCreateLoading, setCreateLoading] = useState(false)
|
||
const [isEmailLoading, setEmailLoading] = useState(false)
|
||
const featureInputRef = useRef<HTMLInputElement>(null)
|
||
const subscribeInputRef = useRef<HTMLInputElement>(null)
|
||
|
||
const { data, error } = useSWR('/api/features', fetcher, {
|
||
initialData: { features },
|
||
})
|
||
|
||
if (error) {
|
||
toast.error(error)
|
||
}
|
||
|
||
const addFeature = async (e: MouseEvent<HTMLFormElement>) => {
|
||
e.preventDefault()
|
||
setCreateLoading(true)
|
||
|
||
const res = await fetch('/api/create', {
|
||
body: JSON.stringify({
|
||
title: featureInputRef?.current?.value ?? '',
|
||
}),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
method: 'POST',
|
||
})
|
||
|
||
const { error } = await res.json()
|
||
setCreateLoading(false)
|
||
|
||
if (error) {
|
||
toast.error(error)
|
||
return
|
||
}
|
||
|
||
mutate('/api/features')
|
||
if (featureInputRef.current) {
|
||
featureInputRef.current.value = ''
|
||
}
|
||
}
|
||
|
||
const subscribe = async (e: MouseEvent<HTMLFormElement>) => {
|
||
e.preventDefault()
|
||
setEmailLoading(true)
|
||
|
||
const res = await fetch('/api/subscribe', {
|
||
body: JSON.stringify({
|
||
email: subscribeInputRef?.current?.value ?? '',
|
||
}),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
method: 'POST',
|
||
})
|
||
|
||
const { error } = await res.json()
|
||
setEmailLoading(false)
|
||
|
||
if (error) {
|
||
return toast.error(error)
|
||
}
|
||
|
||
toast.success('You are now subscribed to feature updates!')
|
||
|
||
if (subscribeInputRef.current) {
|
||
subscribeInputRef.current.value = ''
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col items-center justify-center min-h-screen py-2">
|
||
<Head>
|
||
<title>Roadmap – Next.js and Redis Example</title>
|
||
<link rel="icon" href="/favicon.ico" />
|
||
</Head>
|
||
|
||
<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">
|
||
<img src="/upstash.svg" alt="Upstash Logo" className="h-8 sm:h-16" />
|
||
</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 the next release.
|
||
</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">
|
||
<div className="mx-8 w-full">
|
||
<form className="relative my-8" onSubmit={addFeature}>
|
||
<input
|
||
ref={featureInputRef}
|
||
aria-label="Suggest a feature for our roadmap"
|
||
placeholder="I want..."
|
||
type="text"
|
||
maxLength={150}
|
||
required
|
||
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"
|
||
/>
|
||
<button
|
||
className="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"
|
||
type="submit"
|
||
>
|
||
{isCreateLoading ? <LoadingSpinner invert /> : 'Request'}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
<div className="w-full">
|
||
{data.features.map((feature: Feature, index: number) => (
|
||
<Item
|
||
key={index}
|
||
isFirst={index === 0}
|
||
isLast={index === data.features.length - 1}
|
||
isReleased={false}
|
||
hasVoted={feature.ip === ip}
|
||
feature={feature}
|
||
/>
|
||
))}
|
||
</div>
|
||
<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" onSubmit={subscribe}>
|
||
<input
|
||
ref={subscribeInputRef}
|
||
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"
|
||
>
|
||
{isEmailLoading ? <LoadingSpinner /> : '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
|
||
<img src="/vercel.svg" alt="Vercel Logo" className="h-5 mx-2" />
|
||
and
|
||
<img
|
||
src="/upstash.svg"
|
||
alt="Upstash Logo"
|
||
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/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&integration-ids=oac_V3R1GIpkoJorr6fqyiwdhl17"
|
||
>
|
||
<img
|
||
src="https://vercel.com/button"
|
||
alt="Vercel Deploy Button"
|
||
/>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export async function getServerSideProps({ req }: { req: NextApiRequest }) {
|
||
const ip =
|
||
req.headers['x-forwarded-for'] || req.headers['Remote_Addr'] || 'NA'
|
||
const features = (await redis.hvals('features'))
|
||
.map((entry) => JSON.parse(entry))
|
||
.sort((a, b) => {
|
||
// Primary sort is score
|
||
if (a.score > b.score) return -1
|
||
if (a.score < b.score) return 1
|
||
|
||
// Secondary sort is title
|
||
if (a.title > b.title) return 1
|
||
if (a.title < b.title) return -1
|
||
|
||
return 1
|
||
})
|
||
|
||
return { props: { features, ip } }
|
||
}
|