Skip to content

Commit

Permalink
Dub – an open-source link shortener built with Vercel Edge Functions …
Browse files Browse the repository at this point in the history
…and Upstash.
  • Loading branch information
steven-tey committed Aug 27, 2022
1 parent b02b81d commit 39b8069
Show file tree
Hide file tree
Showing 22 changed files with 3,487 additions and 712 deletions.
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ yarn-error.log*

# local env files
.env*.local
.env

# vercel
.vercel
Expand Down
27 changes: 27 additions & 0 deletions components/blur-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Image, { ImageProps } from "next/future/image";
import { useState, useEffect } from "react";

export default function BlurImage(props: ImageProps) {
const [isLoading, setLoading] = useState(true);
const [src, setSrc] = useState(props.src);
useEffect(() => setSrc(props.src), [props.src]); // update the `src` value when the `prop.src` value changes

return (
<Image
{...props}
src={src}
alt={props.alt}
className={`${props.className} ${
isLoading
? "grayscale blur-2xl scale-110"
: "grayscale-0 blur-0 scale-100"
}`}
onLoadingComplete={async () => {
setLoading(false);
}}
onError={() => {
setSrc(`https://avatar.tobi.sh/${props.alt}`); // if the image fails to load, use the default avatar
}}
/>
);
}
59 changes: 59 additions & 0 deletions components/copy-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useState } from "react";

export default function CopyButton({ url }: { url: string }) {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() => {
setCopied(true);
navigator.clipboard.writeText(url);
setTimeout(() => setCopied(false), 3000);
}}
className="group p-1.5 rounded-full bg-gray-100 hover:bg-blue-100 transition-all"
>
{copied ? (
<TickIcon className="text-gray-500 group-hover:text-blue-800 transition-all" />
) : (
<CopyIcon className="text-gray-500 group-hover:text-blue-800 transition-all" />
)}
</button>
);
}

function TickIcon({ className }: { className: string }) {
return (
<svg
fill="none"
shapeRendering="geometricPrecision"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width="14"
height="14"
className={className}
>
<path d="M20 6L9 17l-5-5" />
</svg>
);
}

function CopyIcon({ className }: { className: string }) {
return (
<svg
fill="none"
shapeRendering="geometricPrecision"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width="14"
height="14"
className={className}
>
<path d="M8 17.929H6c-1.105 0-2-.912-2-2.036V5.036C4 3.91 4.895 3 6 3h8c1.105 0 2 .911 2 2.036v1.866m-6 .17h8c1.105 0 2 .91 2 2.035v10.857C20 21.09 19.105 22 18 22h-8c-1.105 0-2-.911-2-2.036V9.107c0-1.124.895-2.036 2-2.036z" />
</svg>
);
}
68 changes: 68 additions & 0 deletions components/link-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import BlurImage from "@/components/blur-image";
import CopyButton from "@/components/copy-button";
import LoadingDots from "@/components/loading-dots";
import useSWR from "swr";
import fetcher from "@/lib/fetcher";
import nFormatter from "@/lib/n-formatter";

export default function LinkCard({
_key: key,
url,
}: {
_key: string;
url: string;
}) {
const shortURL = `${
process.env.NEXT_PUBLIC_DEMO_APP === "1"
? "https://dub.sh"
: process.env.VERCEL === "1"
? process.env.VERCEL_URL
: "http://localhost:3000"
}/${key}`; // if you're self-hosting you can just replace this with your own domain

const urlHostname = new URL(url).hostname;

const { data: clicks, isValidating } = useSWR<string>(
`/api/links/${key}/clicks`,
fetcher
);

return (
<div
key={key}
className="flex items-center border border-gray-200 hover:border-black p-3 rounded-md transition-all"
>
<BlurImage
src={`https://logo.clearbit.com/${urlHostname}`}
alt={urlHostname}
className="w-10 h-10 rounded-full mr-2 border border-gray-200"
width={20}
height={20}
/>
<div>
<div className="flex items-center space-x-2 mb-1">
<a
className="text-blue-800 font-semibold"
href={shortURL}
target="_blank"
rel="noreferrer"
>
{shortURL.replace(/^https?:\/\//, "")}
</a>
<CopyButton url={shortURL} />
<div className="rounded-md bg-gray-100 px-2 py-0.5">
<p className="text-sm text-gray-500">
{isValidating || !clicks ? (
<LoadingDots color="#71717A" />
) : (
nFormatter(parseInt(clicks))
)}{" "}
clicks
</p>
</div>
</div>
<p className="text-sm text-gray-500 truncate w-72">{url}</p>
</div>
</div>
);
}
40 changes: 40 additions & 0 deletions components/loading-dots.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
.loading {
display: inline-flex;
align-items: center;
}

.loading .spacer {
margin-right: 2px;
}

.loading span {
animation-name: blink;
animation-duration: 1.4s;
animation-iteration-count: infinite;
animation-fill-mode: both;
width: 5px;
height: 5px;
border-radius: 50%;
display: inline-block;
margin: 0 1px;
}

.loading span:nth-of-type(2) {
animation-delay: 0.2s;
}

.loading span:nth-of-type(3) {
animation-delay: 0.4s;
}

@keyframes blink {
0% {
opacity: 0.2;
}
20% {
opacity: 1;
}
100% {
opacity: 0.2;
}
}
13 changes: 13 additions & 0 deletions components/loading-dots.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import styles from "./loading-dots.module.css";

const LoadingDots = ({ color = "#000" }: { color: string }) => {
return (
<span className={styles.loading}>
<span style={{ backgroundColor: color }} />
<span style={{ backgroundColor: color }} />
<span style={{ backgroundColor: color }} />
</span>
);
};

export default LoadingDots;
12 changes: 12 additions & 0 deletions lib/fetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export default async function fetcher<JSON = any>(
input: RequestInfo,
init?: RequestInit
): Promise<JSON> {
const res = await fetch(input, init);

if (!res.ok && res.status === 401) {
throw new Error("Unauthorized");
}

return res.json();
}
21 changes: 21 additions & 0 deletions lib/n-formatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export default function nFormatter(num: number, digits?: number) {
const lookup = [
{ value: 1, symbol: "" },
{ value: 1e3, symbol: "K" },
{ value: 1e6, symbol: "M" },
{ value: 1e9, symbol: "G" },
{ value: 1e12, symbol: "T" },
{ value: 1e15, symbol: "P" },
{ value: 1e18, symbol: "E" },
];
const rx = /\.0+$|(\.[0-9]*[1-9])0+$/;
var item = lookup
.slice()
.reverse()
.find(function (item) {
return num >= item.value;
});
return item
? (num / item.value).toFixed(digits || 1).replace(rx, "$1") + item.symbol
: "0";
}
6 changes: 6 additions & 0 deletions lib/redis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Redis } from "@upstash/redis";

export const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL || "",
token: process.env.UPSTASH_REDIS_REST_TOKEN || "",
});
26 changes: 26 additions & 0 deletions lib/use-local-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useEffect, useState } from "react";

const useLocalStorage = <T>(
key: string,
initialValue: T
): [T, (value: T) => void] => {
const [storedValue, setStoredValue] = useState(initialValue);

useEffect(() => {
// Retrieve from localStorage
const item = window.localStorage.getItem(key);
if (item) {
setStoredValue(JSON.parse(item));
}
}, [key]);

const setValue = (value: any) => {
// Save state
setStoredValue(value);
// Save to localStorage
window.localStorage.setItem(key, JSON.stringify(value));
};
return [storedValue, setValue];
};

export default useLocalStorage;
21 changes: 21 additions & 0 deletions middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { NextRequest, NextFetchEvent, NextResponse } from "next/server";
import { redis } from "@/lib/redis";

export const config = {
matcher: "/([^/.]*)",
};

export default async function middleware(req: NextRequest, ev: NextFetchEvent) {
const url = req.nextUrl.pathname;
const key = url.split("/")[1];
if (key.length === 0) {
return NextResponse.next();
} // skip root
const target = await redis.hget<string>("links", key);
if (target) {
ev.waitUntil(redis.hincrby("stats", key, 1)); // increment click count
return NextResponse.redirect(target);
} else {
return NextResponse.redirect("/");
}
}
6 changes: 5 additions & 1 deletion next.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
/** @type {import('next').NextConfig} */
module.exports = {
reactStrictMode: true,
}
experimental: { images: { allowFutureImage: true } },
images: {
domains: ["logo.clearbit.com", "avatar.tobi.sh"],
},
};
Loading

0 comments on commit 39b8069

Please sign in to comment.