├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── README_ZH.md ├── components ├── ColorModeToggle.tsx ├── LanguagesSwitch.tsx ├── Logo.tsx ├── NextChakraLink.tsx ├── UserMenu.tsx ├── layout │ ├── footer │ │ ├── Copyright.tsx │ │ ├── SocialMediaLinks.tsx │ │ └── index.tsx │ ├── header │ │ └── index.tsx │ ├── index.tsx │ └── logo.tsx └── track │ ├── BrowserBar.tsx │ ├── Cobe.tsx │ ├── DeviceModelBar.tsx │ ├── DeviceVendorColumn.tsx │ ├── Map.tsx │ ├── MobilePercent.tsx │ ├── OSPie.tsx │ ├── TrackSetting.tsx │ ├── TrackerTab.tsx │ └── VisitOverview.tsx ├── docs ├── how-to-use-prisma-with-typescript-and-postgres.md └── images │ ├── index.png │ ├── index_dark.png │ ├── index_profile.png │ ├── index_short_result.png │ ├── index_signin.png │ ├── index_signin_zh.png │ ├── signin.png │ ├── signin_dark.png │ ├── track.png │ ├── track_dark.png │ ├── track_dark_zh.png │ ├── track_dashboard.png │ └── track_dashboard_light.png ├── lib ├── prisma.ts └── redis.ts ├── middleware.ts ├── next-env.d.ts ├── next-i18next.config.js ├── next.config.js ├── package.json ├── pages ├── 500.tsx ├── _app.tsx ├── api │ ├── auth │ │ └── [...nextauth].ts │ ├── short.ts │ ├── track │ │ ├── [short].ts │ │ └── index.ts │ └── visit.ts ├── index.tsx ├── login.tsx └── track │ ├── [short].tsx │ └── index.tsx ├── prisma └── schema.prisma ├── public ├── earth-dark.jpg ├── favicon.ico ├── globe-data-min.json ├── locales │ ├── en │ │ ├── common.json │ │ ├── index.json │ │ ├── login.json │ │ ├── password_reset.json │ │ ├── signup.json │ │ └── track.json │ └── zh │ │ ├── common.json │ │ ├── index.json │ │ ├── login.json │ │ ├── password_reset.json │ │ ├── signup.json │ │ └── track.json ├── markers │ ├── ip.png │ ├── marker.png │ ├── user.png │ └── visiter.png ├── undraw_server_down.svg └── vercel.svg ├── src ├── points.json ├── theme │ ├── components │ │ ├── button.ts │ │ ├── index.ts │ │ └── input.ts │ ├── foundations │ │ ├── index.ts │ │ ├── styles.ts │ │ └── tokens.ts │ └── index.ts └── utils │ ├── arrayUntils.ts │ └── index.ts ├── tsconfig.json ├── types └── index.ts └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | UPSTASH_REDIS_REST_URL="https://xxxxxxxx.upstash.io" 2 | UPSTASH_REDIS_REST_TOKEN="YOUR_UPSTASH_REST_TOKEN" 3 | 4 | DATABASE_URL="postgresql://postgres:xxxxxxx" 5 | 6 | GITHUB_CLIENT_ID='YOUR_GITHUB_CLIENT_ID' 7 | GITHUB_CLIENT_SECRET='YOUR_GITHUB_CLIENT_SECRET' 8 | 9 | NEXTAUTH_SECRET="xxxxxxxxxx" 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 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 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | /.env 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # URL Shorter 2 | 3 | # https://dlj.sh 4 | 5 | [简体中文](README_ZH.md) 6 | 7 | ## Simple url shorter build with Next.js, Postgres, Redis 8 | 9 | ## Features 10 | 11 | - Short url 12 | - Track visit 13 | - Track Dashboard Vision 14 | - responsive UI 15 | - i18n (English and Chinese) 16 | - Dark mode 17 | 18 | ## ScreenShot 19 | 20 | index 21 | index-dark 22 | index-profile 23 | track 24 | track dashboard 25 | 26 | ## Track Infos 27 | 28 | - time 29 | - ip 30 | - region 31 | - country 32 | - city 33 | - lat lng 34 | - ua string 35 | - browser name 36 | - browser version 37 | - os name 38 | - os version 39 | - cpu 40 | - device model 41 | - device vendor 42 | - engine name 43 | - engin version 44 | 45 | ## Online demo [url shorter](https://zlz.pw/) 46 | 47 | ## Technologies or libs I use: 48 | 49 | ### Core Shorter 50 | 51 | - Next.js (full stack framework) 52 | - Vercel (deployment serverless) 53 | - NextAuth (github OAuth) 54 | - Redis (storage kv : short code and long urls) 55 | - Upstash (serverless database redis) 56 | - nanoid (generate short code) 57 | - is-url (judge is valid url) 58 | - chakra-ui (frontend ui lib) 59 | - @icon-park/react (icons) 60 | 61 | ### Visitor Track 62 | 63 | - next.js edge middleware (track visit info) 64 | - postgres (storage link and visitor info) 65 | - prisma (ORM) 66 | - leaflet (map: show visitor location) 67 | - react-leaflet (leaflet wrapper) 68 | - cobe (show globe) 69 | - @ant-design/plots (charts) 70 | 71 | 72 | -------------------------------------------------------------------------------- /README_ZH.md: -------------------------------------------------------------------------------- 1 | # URL Shorter 2 | 3 | [English](README.md) 4 | 5 | ## 简单的短链生成器 用 next.js 和 redis 构建 6 | 7 | ## 特点 8 | 9 | - 短链生成 10 | - 短链访客追踪 11 | - 短链追踪仪表盘 12 | - 地图和图标可视化 13 | - 响应式的UI 14 | 15 | ## 项目截图 16 | 17 | 18 | 短链生成-移动设备 19 | 短链访客追踪 20 | 短链追踪仪表盘 21 | 短链追踪仪表盘-移动设备 22 | 23 | ## 短链访客追踪的信息 24 | 25 | - 访问时间 26 | 27 | ### 地理信息 28 | 29 | - ip 地址 30 | - 区域 31 | - 国家 32 | - 城市 33 | - 经纬度坐标 34 | 35 | ### 浏览器信息 36 | 37 | - 浏览器标识 38 | - 浏览器名称 39 | - 浏览器版本 40 | - 操作系统名称 41 | - 操作系统版本 42 | - cpu型号 43 | - 设备型号 44 | - 设备品牌 45 | - 引擎名称 46 | - 引擎版本 47 | 48 | ## 在线预览 demo [短链生成](https://zlz.pw/) 49 | 50 | ## 我用的的技术和库: 51 | 52 | ### 短链生成的核心 53 | 54 | - next.js (全栈框架) 55 | - vercel (serverless 部署) 56 | - redis (储存 kv : shortid and 长链接) 57 | - upstash (serverless redis 数据库) 58 | - ioredis (node redis 库) 59 | - nanoid (生成 short id) 60 | - is-url (判断是否为合法的链接) 61 | - chakra-ui (前端UI库) 62 | - @icon-park/react (图标) 63 | 64 | ### 短链访客追踪 65 | 66 | - next.js edge middleware (收集访客信息) 67 | - postgres (储存短链和访客信息) 68 | - prisma (ORM) 69 | - leaflet (地图: 展示访客位置) 70 | - react-leaflet (leaflet wrapper) 71 | - cobe (展示地球-访客位置) 72 | - @ant-design/plots (图表) 73 | 74 | 75 | -------------------------------------------------------------------------------- /components/ColorModeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton, useColorMode, useColorModeValue } from '@chakra-ui/react' 2 | import { Sun, Moon } from '@icon-park/react' 3 | 4 | export const ColorModeToggle = () => { 5 | const { toggleColorMode } = useColorMode() 6 | const text = useColorModeValue('dark', 'light') 7 | const SwitchIcon = useColorModeValue( 8 | , 9 | 10 | ) 11 | const handleToggleColorMode = () => { 12 | toggleColorMode() 13 | } 14 | 15 | return ( 16 | <> 17 | 26 | 27 | ) 28 | } 29 | 30 | -------------------------------------------------------------------------------- /components/LanguagesSwitch.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Menu, 4 | MenuButton, 5 | MenuItem, 6 | MenuList, 7 | useColorModeValue, 8 | } from '@chakra-ui/react' 9 | import { Translate } from '@icon-park/react' 10 | import { useRouter } from 'next/router' 11 | 12 | export const LanguagesSwitch = () => { 13 | const { locale } = useRouter() 14 | let lang = 'English' 15 | switch (locale) { 16 | case 'zh': 17 | lang = '简体中文' 18 | break 19 | } 20 | 21 | const router = useRouter() 22 | 23 | const bgColor = useColorModeValue('white', 'black') 24 | 25 | return ( 26 | <> 27 | 28 | } variant={'ghost'}> 29 | {lang} 30 | 31 | 32 | { 34 | router 35 | .replace(router.pathname, router.pathname, { locale: 'en' }) 36 | .then() 37 | }} 38 | > 39 | English 40 | 41 | { 43 | router 44 | .replace(router.pathname, router.pathname, { locale: 'zh' }) 45 | .then() 46 | }} 47 | > 48 | 简体中文 49 | 50 | 51 | 52 | 53 | ) 54 | } -------------------------------------------------------------------------------- /components/Logo.tsx: -------------------------------------------------------------------------------- 1 | import { HStack } from '@chakra-ui/react' 2 | import { ApiApp } from '@icon-park/react' 3 | 4 | interface LogoProps{ 5 | size: string 6 | } 7 | 8 | export const Logo = ({ size }: LogoProps) => { 9 | return ( 10 | 11 | 14 | 15 | ) 16 | } -------------------------------------------------------------------------------- /components/NextChakraLink.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from "react"; 2 | import NextLink from "next/link"; 3 | import { LinkProps as NextLinkProps } from "next/dist/client/link"; 4 | import { 5 | Link as ChakraLink, 6 | LinkProps as ChakraLinkProps, 7 | } from "@chakra-ui/react"; 8 | 9 | export type NextChakraLinkProps = PropsWithChildren< 10 | NextLinkProps & Omit 11 | >; 12 | 13 | // Has to be a new component because both chakra and next share the `as` keyword 14 | export const NextChakraLink = ({ 15 | href, 16 | as, 17 | replace, 18 | scroll, 19 | shallow, 20 | prefetch, 21 | children, 22 | ...chakraProps 23 | }: NextChakraLinkProps) => { 24 | return ( 25 | 34 | 46 | {children} 47 | 48 | 49 | ); 50 | }; -------------------------------------------------------------------------------- /components/UserMenu.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Avatar, 3 | Box, 4 | VStack, 5 | Menu, 6 | MenuButton, 7 | MenuList, 8 | HStack, 9 | Divider, 10 | Text, 11 | useColorModeValue, 12 | } from '@chakra-ui/react' 13 | 14 | interface UserMenuProps{ 15 | avatar: string | null 16 | name: string | null 17 | email: string | null 18 | } 19 | 20 | export const UserMenu = ({ avatar, name, email }: Partial) => { 21 | const bgColor = useColorModeValue('white', 'black') 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {name} 33 | 34 | 35 | 36 | {email} 37 | 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /components/layout/footer/Copyright.tsx: -------------------------------------------------------------------------------- 1 | import { Text, TextProps } from '@chakra-ui/react' 2 | 3 | interface CopyrightProps extends TextProps{ 4 | name: string 5 | } 6 | 7 | export const Copyright = (props: CopyrightProps) => ( 8 | 9 | © {new Date().getFullYear()} {props.name}, Inc. All rights reserved. 10 | 11 | ) -------------------------------------------------------------------------------- /components/layout/footer/SocialMediaLinks.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonGroup, ButtonGroupProps, IconButton } from '@chakra-ui/react' 2 | import { Github, Weibo, Twitter } from '@icon-park/react' 3 | 4 | export const SocialMediaLinks = (props: ButtonGroupProps) => ( 5 | 6 | } 12 | /> 13 | } 19 | /> 20 | } 26 | /> 27 | 28 | ) -------------------------------------------------------------------------------- /components/layout/footer/index.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Stack, HStack } from '@chakra-ui/react' 2 | 3 | import { Copyright } from './Copyright' 4 | import { Logo } from '../../Logo' 5 | import { SocialMediaLinks } from './SocialMediaLinks' 6 | import { LanguagesSwitch } from '../../LanguagesSwitch' 7 | import { ColorModeToggle } from '../../ColorModeToggle' 8 | 9 | export const Footer = () => ( 10 | 17 | 18 | 24 | 25 | 26 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ) -------------------------------------------------------------------------------- /components/layout/header/index.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, HStack, Skeleton, Spacer, useColorModeValue } from '@chakra-ui/react' 2 | import { useTranslation } from 'next-i18next' 3 | import { signOut, useSession } from 'next-auth/react' 4 | import { useRouter } from 'next/router' 5 | 6 | import { Logo } from '../../Logo' 7 | import { NextChakraLink } from '../../NextChakraLink' 8 | import { UserMenu } from '../../UserMenu' 9 | 10 | const AuthedLinks = () => { 11 | const { t } = useTranslation('common') 12 | 13 | const { data } = useSession() 14 | const user = data?.user 15 | 16 | return ( 17 | 18 | 19 | {t('header.dashboard')} 20 | 21 | 28 | 29 | 30 | ) 31 | } 32 | 33 | const Loading = () => { 34 | return ( 35 | 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | const NotAuthedLinks = () => { 43 | const { t } = useTranslation('common') 44 | const router = useRouter() 45 | 46 | return ( 47 | 48 | 53 | {t('header.track')} 54 | 55 | 56 | 62 | 63 | 64 | ) 65 | } 66 | 67 | export const Header = () => { 68 | const bg = useColorModeValue('whiteAlpha.900', 'blackAlpha.900') 69 | const { status } = useSession() 70 | const router = useRouter() 71 | 72 | const Links = () => { 73 | return ( 74 | <> 75 | { 76 | status === 'authenticated' ? : 77 | } 78 | 79 | ) 80 | } 81 | 82 | return ( 83 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | {status === 'loading' ? : } 98 | 99 | 100 | 101 | ) 102 | } -------------------------------------------------------------------------------- /components/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | import { Box } from '@chakra-ui/react' 3 | 4 | import { Header } from './header' 5 | import { Footer } from './footer' 6 | import { useRouter } from 'next/router' 7 | 8 | interface IProps{ 9 | children: ReactNode 10 | } 11 | 12 | export const Layout = ({ children }: IProps) => { 13 | return ( 14 | 15 |
16 | 24 | {children} 25 | 26 |