├── .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 |
21 |
22 |
23 |
24 |
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 |
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 |
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 |
27 |
28 | )
29 | }
30 |
31 | export const Layouts = ({ children }: IProps) => {
32 | const router = useRouter()
33 | if (router.pathname.startsWith('/dashboard')) {
34 | return (
35 | <>
36 | {children}
37 | >
38 | )
39 | }
40 |
41 | if (router.pathname === '/track/[short]') {
42 | return (
43 | <>
44 | {children}
45 | >
46 | )
47 | }
48 |
49 | return (
50 |
51 | {children}
52 |
53 | )
54 | }
--------------------------------------------------------------------------------
/components/layout/logo.tsx:
--------------------------------------------------------------------------------
1 | import { HStack, Text, useColorModeValue, useToken } from '@chakra-ui/react'
2 | import { CopyLink } from '@icon-park/react'
3 |
4 | export const Logo = () => {
5 | const [white, black] = useToken('colors', ['white', 'gray.800'])
6 | return (
7 |
8 |
13 |
19 | Shorter
20 |
21 |
22 | )
23 | }
--------------------------------------------------------------------------------
/components/track/BrowserBar.tsx:
--------------------------------------------------------------------------------
1 | import { Bar, BarConfig } from '@ant-design/plots'
2 |
3 | const BrowserBar = ({data}:{data: any[]}) => {
4 | const config: BarConfig = {
5 | data,
6 | appendPadding: 10,
7 | xField: 'value',
8 | yField: 'key',
9 | yAxis: {
10 | label: {
11 | autoRotate: false,
12 | },
13 | },
14 | maxBarWidth: 20,
15 | scrollbar: {
16 | type: 'vertical',
17 | style: {
18 | trackColor: 'lightGray',
19 | thumbColor: 'gray'
20 | },
21 | },
22 | }
23 |
24 | return (
25 |
26 | )
27 | }
28 |
29 | export default BrowserBar
--------------------------------------------------------------------------------
/components/track/Cobe.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react'
2 | import createGlobe, { Marker } from 'cobe'
3 |
4 | export interface CodeProps{
5 | size: number,
6 | markers: Marker[]
7 | dark: number
8 | }
9 |
10 | const Cobe = (props: CodeProps) => {
11 | const canvasRef = useRef(null)
12 | useEffect(() => {
13 | if (!canvasRef.current) return
14 | let phi = 0
15 | const globe = createGlobe(canvasRef.current, {
16 | devicePixelRatio: 2,
17 | width: props.size,
18 | height: props.size,
19 | phi: 0,
20 | theta: 0,
21 | dark: props.dark,
22 | diffuse: 1.2,
23 | mapSamples: 16000,
24 | mapBrightness: 6,
25 | baseColor: [0.3, 0.3, 0.3],
26 | markerColor: [0.1, 0.8, 1],
27 | glowColor: [1, 1, 1],
28 | markers: props.markers,
29 | onRender: (state: any) => {
30 | state.phi = phi
31 | phi += 0.007
32 | },
33 | })
34 |
35 | return () => {
36 | globe.destroy()
37 | }
38 | }, [props.markers, props.size])
39 | return (
40 |
47 | )
48 | }
49 |
50 | export default Cobe
--------------------------------------------------------------------------------
/components/track/DeviceModelBar.tsx:
--------------------------------------------------------------------------------
1 | import { Bar, BarConfig } from '@ant-design/plots'
2 |
3 | const DeviceModelBar = ({data}:{data: any[]}) => {
4 | const config: BarConfig = {
5 | data,
6 | appendPadding: 10,
7 | xField: 'value',
8 | yField: 'key',
9 | yAxis: {
10 | label: {
11 | autoRotate: false,
12 | },
13 | },
14 | maxBarWidth: 20,
15 | scrollbar: {
16 | type: 'vertical',
17 | style: {
18 | trackColor: 'lightGray',
19 | thumbColor: 'gray'
20 | },
21 | },
22 | }
23 |
24 | return (
25 | <>
26 |
27 | >
28 | )
29 | }
30 |
31 | export default DeviceModelBar
--------------------------------------------------------------------------------
/components/track/DeviceVendorColumn.tsx:
--------------------------------------------------------------------------------
1 | import { Column, ColumnConfig } from '@ant-design/plots'
2 |
3 | const DeviceVendorColumn = ({data}:{data: any[]}) => {
4 | const config: ColumnConfig = {
5 | appendPadding: 10,
6 | data,
7 | xField: 'key',
8 | yField: 'value',
9 | xAxis: {
10 | label: {
11 | autoRotate: false,
12 | },
13 | },
14 | maxColumnWidth: 20,
15 | scrollbar: {
16 | type: 'horizontal',
17 | style: {
18 | trackColor: 'lightGray',
19 | thumbColor: 'gray'
20 | },
21 | },
22 | }
23 |
24 | return (
25 |
26 | )
27 | }
28 |
29 | export default DeviceVendorColumn
--------------------------------------------------------------------------------
/components/track/Map.tsx:
--------------------------------------------------------------------------------
1 | import { MapContainer, LayersControl, TileLayer, CircleMarker } from 'react-leaflet'
2 | import { Skeleton } from '@chakra-ui/react'
3 |
4 | import 'leaflet/dist/leaflet.css'
5 |
6 | const MapPlaceHolder = () => {
7 | return (
8 | <>
9 |
10 | >
11 | )
12 | }
13 |
14 | export interface MapProps{
15 | points?: [number, number][]
16 | }
17 |
18 | const MyMap = ({ points }: MapProps) => {
19 | return (
20 | }
28 | >
29 |
30 |
31 |
34 |
35 |
36 |
41 |
42 |
43 | {
44 | points?.map((point, index) => {
45 | return (
46 |
47 | )
48 | })
49 | }
50 |
51 | )
52 | }
53 |
54 | export default MyMap
--------------------------------------------------------------------------------
/components/track/MobilePercent.tsx:
--------------------------------------------------------------------------------
1 | import { Liquid, LiquidConfig } from '@ant-design/plots'
2 | import { Box } from '@chakra-ui/react'
3 |
4 | const MobilePercent = () => {
5 | const config: LiquidConfig = {
6 | percent: 0.25,
7 | width: 156,
8 | height: 156,
9 | wave: {
10 | length: 36,
11 | },
12 | }
13 |
14 | return (
15 |
16 |
17 |
18 | )
19 | }
20 |
21 | export default MobilePercent
--------------------------------------------------------------------------------
/components/track/OSPie.tsx:
--------------------------------------------------------------------------------
1 | import { Pie, PieConfig } from '@ant-design/plots'
2 |
3 | const OSPie = ({data}:{data: any[]}) => {
4 | const config: PieConfig = {
5 | appendPadding: 10,
6 | data,
7 | angleField: 'value',
8 | colorField: 'key',
9 | radius: 0.9
10 | }
11 |
12 |
13 |
14 | return (
15 |
16 | )
17 | }
18 |
19 | export default OSPie
--------------------------------------------------------------------------------
/components/track/TrackSetting.tsx:
--------------------------------------------------------------------------------
1 | import { Box, IconButton } from '@chakra-ui/react'
2 | import { Setting } from '@icon-park/react'
3 |
4 | const TrackSetting = () => {
5 | return (
6 |
7 | } />
8 |
9 | )
10 | }
11 |
12 | export default TrackSetting
--------------------------------------------------------------------------------
/components/track/TrackerTab.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { useRouter } from 'next/router'
3 | import {
4 | Input,
5 | Button,
6 | VStack,
7 | Heading,
8 | FormLabel,
9 | FormControl,
10 | } from '@chakra-ui/react'
11 |
12 | export const TrackerTab = () => {
13 | const [shortId, setShortId] = useState('')
14 | const router = useRouter()
15 |
16 | const handleSubmit = () => {
17 | router.push(`/track/dashboard?shortid=${shortId}`).then()
18 | }
19 |
20 | return (
21 |
22 | Tracker
23 |
30 |
31 | Short id:
32 |
33 | {setShortId(e.target.value)}}
37 | />
38 |
47 |
48 |
49 | )
50 | }
--------------------------------------------------------------------------------
/components/track/VisitOverview.tsx:
--------------------------------------------------------------------------------
1 | import { Box, HStack, SimpleGrid, Spacer, Stat, StatLabel, StatNumber } from '@chakra-ui/react'
2 | import IconPark from '@icon-park/react/lib/all'
3 | import { useTranslation } from 'next-i18next'
4 | import MobilePercent from './MobilePercent'
5 |
6 | interface IProps{
7 | visitCount: number,
8 | ipCount: number,
9 | mobileVisit: number,
10 | pcVisit: number,
11 | }
12 |
13 | export const VisitOverview = (props: IProps) => {
14 | const { visitCount, ipCount, mobileVisit, pcVisit } = props
15 |
16 | const { t } = useTranslation('track')
17 |
18 | const dataVisits = [
19 | { title: t('visitCount'), number: visitCount, iconName: 'Click' },
20 | { title: t('ipCount'), number: ipCount, iconName: 'Earth' },
21 | { title: t('mobileVisit'), number: mobileVisit, iconName: 'Phone' },
22 | { title: t('pcVisit'), number: pcVisit, iconName: 'Computer' },
23 | ]
24 | return (
25 |
30 | {dataVisits.map((item, index) => {
31 | return (
32 |
41 |
42 | {item.title}
43 | {item.number}
44 |
45 |
46 |
47 |
48 |
49 |
50 | )
51 | })}
52 |
53 | )
54 | }
--------------------------------------------------------------------------------
/docs/how-to-use-prisma-with-typescript-and-postgres.md:
--------------------------------------------------------------------------------
1 | # How to use prisma with typescript and postgres
2 |
3 | ## Prepare
4 |
5 | - ### prisma installed:
6 | ```shell
7 | npm i - g prisma
8 | ```
9 |
10 | - ### postgres cloud instance
11 | 1. get from heroku for free [Heroku Postgres](https://elements.heroku.com/addons/heroku-postgresql)
12 | 2. get your connection url
13 |
14 | - ### one project
15 |
16 | ## Init
17 |
18 | - ### generate schema.prisma and .env
19 | run
20 | ```shell
21 | prisma init
22 | ```
23 | then your project will add prism/schema.prisma and .env with DATABASE_URL replace .env with your real connection url
24 |
25 | - ### add some models
26 | edit prisma/schema.prisma
27 | ```prisma
28 | generator client {
29 | provider = "prisma-client-js"
30 | }
31 |
32 | datasource db {
33 | provider = "postgresql"
34 | url = env("POSTGRES_DATABASE_URL")
35 | }
36 |
37 | model Link {
38 | id String @id @default(uuid())
39 | createdAt DateTime @default(now())
40 | updatedAt DateTime @updatedAt
41 | url String
42 | shortUrl String
43 | userId String?
44 | User User? @relation(fields: [userId], references: [id])
45 | }
46 |
47 | model Profile {
48 | id String @id @default(uuid())
49 | bio String?
50 | avatar String?
51 | user User @relation(fields: [userId], references: [id])
52 | userId String @unique
53 | }
54 |
55 | model User {
56 | id String @id @default(uuid())
57 | createdAt DateTime @default(now())
58 | updatedAt DateTime @updatedAt
59 | name String?
60 | email String
61 | Link Link[]
62 | Profile Profile[]
63 | }
64 |
65 | ```
66 |
67 | ### create schema in your db
68 | run
69 | ```shell
70 | prisma push
71 | ```
72 | to create schema
73 | run
74 | ```shell
75 | prisma studio
76 | ```
77 | to check
78 |
79 | ## Use in project
80 |
81 | - add api route
82 | ```typescript
83 | import type { NextApiRequest, NextApiResponse } from 'next'
84 | import { PrismaClient } from '@prisma/client'
85 |
86 | const prisma = new PrismaClient()
87 | export default async function handler (req: NextApiRequest, res: NextApiResponse) {
88 | try {
89 | const userCount = await prisma.user.count()
90 | res.status(200).json({ userCount: userCount })
91 | } catch (e) {
92 | res.status(400).json({ msg: 'error' })
93 | } finally {
94 | await prisma.$disconnect()
95 | }
96 | }
97 | ```
98 |
99 | - check
100 | visit localhost:3000/api/hello
101 | your will see
102 | ```json
103 | {
104 | "userCount": 0
105 | }
106 | ```
107 |
108 |
109 |
--------------------------------------------------------------------------------
/docs/images/index.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/url-shorter/57cd12ed12579b877423cce09299e7ad42f4f5dd/docs/images/index.png
--------------------------------------------------------------------------------
/docs/images/index_dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/url-shorter/57cd12ed12579b877423cce09299e7ad42f4f5dd/docs/images/index_dark.png
--------------------------------------------------------------------------------
/docs/images/index_profile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/url-shorter/57cd12ed12579b877423cce09299e7ad42f4f5dd/docs/images/index_profile.png
--------------------------------------------------------------------------------
/docs/images/index_short_result.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/url-shorter/57cd12ed12579b877423cce09299e7ad42f4f5dd/docs/images/index_short_result.png
--------------------------------------------------------------------------------
/docs/images/index_signin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/url-shorter/57cd12ed12579b877423cce09299e7ad42f4f5dd/docs/images/index_signin.png
--------------------------------------------------------------------------------
/docs/images/index_signin_zh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/url-shorter/57cd12ed12579b877423cce09299e7ad42f4f5dd/docs/images/index_signin_zh.png
--------------------------------------------------------------------------------
/docs/images/signin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/url-shorter/57cd12ed12579b877423cce09299e7ad42f4f5dd/docs/images/signin.png
--------------------------------------------------------------------------------
/docs/images/signin_dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/url-shorter/57cd12ed12579b877423cce09299e7ad42f4f5dd/docs/images/signin_dark.png
--------------------------------------------------------------------------------
/docs/images/track.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/url-shorter/57cd12ed12579b877423cce09299e7ad42f4f5dd/docs/images/track.png
--------------------------------------------------------------------------------
/docs/images/track_dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/url-shorter/57cd12ed12579b877423cce09299e7ad42f4f5dd/docs/images/track_dark.png
--------------------------------------------------------------------------------
/docs/images/track_dark_zh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/url-shorter/57cd12ed12579b877423cce09299e7ad42f4f5dd/docs/images/track_dark_zh.png
--------------------------------------------------------------------------------
/docs/images/track_dashboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/url-shorter/57cd12ed12579b877423cce09299e7ad42f4f5dd/docs/images/track_dashboard.png
--------------------------------------------------------------------------------
/docs/images/track_dashboard_light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/url-shorter/57cd12ed12579b877423cce09299e7ad42f4f5dd/docs/images/track_dashboard_light.png
--------------------------------------------------------------------------------
/lib/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client'
2 |
3 | declare global{
4 | namespace NodeJS{
5 | interface Global{}
6 | }
7 | }
8 |
9 | // add prisma to the NodeJS global type
10 | interface CustomNodeJsGlobal extends NodeJS.Global{
11 | prisma: PrismaClient;
12 | }
13 |
14 | // Prevent multiple instances of Prisma Client in development
15 | declare const global: CustomNodeJsGlobal
16 |
17 | const prisma = global.prisma || new PrismaClient()
18 |
19 | if (process.env.NODE_ENV === 'development') global.prisma = prisma
20 |
21 | export default prisma
--------------------------------------------------------------------------------
/lib/redis.ts:
--------------------------------------------------------------------------------
1 | import { Redis } from '@upstash/redis'
2 | import { nanoid } from 'nanoid'
3 |
4 | const redis = new Redis({
5 | url: process.env.UPSTASH_REDIS_REST_URL || '',
6 | token: process.env.UPSTASH_REDIS_REST_TOKEN || '',
7 | })
8 |
9 | export const setUrl = async(url: string): Promise => {
10 | try {
11 | const shortCode = await generateShortCode()
12 | await redis.set(`short/${shortCode}`, url)
13 | return shortCode
14 | } catch (e) {
15 | return null
16 | }
17 | }
18 |
19 | export const getUrl = async(shortCode: string): Promise => {
20 | const url = await redis.get(`short/${shortCode}`)
21 | if (typeof url === 'string') {
22 | return url
23 | }
24 | return null
25 | }
26 |
27 | export const deleteUrl = async(shortCode: string): Promise => {
28 | try {
29 | await redis.del(`short/${shortCode}`)
30 | return true
31 | } catch (e) {
32 | return false
33 | }
34 | }
35 |
36 | // generate short code
37 | const generateShortCode = async(): Promise => {
38 | const shortCode = nanoid(7)
39 | const url = await getUrl(shortCode)
40 | if (url) {
41 | await generateShortCode()
42 | }
43 | return shortCode
44 | }
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse, userAgent } from 'next/server'
2 |
3 | import { getUrl } from './lib/redis'
4 | import { Visit } from './types'
5 |
6 | const middleware = async(req: NextRequest) => {
7 | return await redirects(req)
8 | }
9 |
10 | const redirects = async(req: NextRequest) => {
11 | const start = Date.now()
12 | const path = req.nextUrl.pathname.split('/')[1]
13 | const whiteList = [
14 | 'favicon.ico',
15 | '',
16 | 'api',
17 | '_next',
18 | 'login',
19 | 'track',
20 | '500',
21 | ]
22 | if (whiteList.includes(path)) {
23 | return
24 | }
25 | const url = await getUrl(path)
26 |
27 | const host = req.nextUrl.protocol + '//' + req.nextUrl.host
28 | const end = Date.now()
29 | if (!url) {
30 | return NextResponse.redirect(host)
31 | }
32 | await recordVisits(req, path)
33 | const endVisits = Date.now()
34 | console.log(end - start)
35 | console.log(endVisits - end)
36 | return NextResponse.redirect(url)
37 | }
38 |
39 | const recordVisits = async(req: NextRequest, shortCode: string) => {
40 | const ip = req.ip
41 | const geo = req.geo
42 | const ua = userAgent(req)
43 |
44 | const visit: Visit = {
45 | ip,
46 | link_id: '',
47 | short_code: shortCode,
48 | // geo
49 | country: geo?.country,
50 | city: geo?.city,
51 | latitude: geo?.latitude,
52 | longitude: geo?.longitude,
53 | region: geo?.region,
54 | // ua
55 | is_bot: ua.isBot,
56 | ua: ua.ua,
57 | browser_name: ua.browser.name,
58 | browser_version: ua.browser.version,
59 | device_model: ua.device.model,
60 | device_type: ua.device.type,
61 | device_vendor: ua.device.vendor,
62 | engine_name: ua.engine.name,
63 | engine_version: ua.engine.version,
64 | os_name: ua.os.name,
65 | os_version: ua.os.version,
66 | cpu_architecture: ua.cpu.architecture
67 | }
68 |
69 | const host = req.nextUrl.protocol + '//' + req.nextUrl.host
70 |
71 | await fetch(`${host}/api/visit`, {
72 | method: 'POST',
73 | body: JSON.stringify({
74 | visit,
75 | })
76 | })
77 | }
78 |
79 | /*
80 | const redirectShortId = async(shortId: string, req: NextRequest) => {
81 | const start = Date.now()
82 | const host = req.nextUrl.protocol + '//' + req.nextUrl.host
83 | try {
84 | // get link info
85 | const shortRes = await fetch(`${host}/api/short?short_id=${shortId}`)
86 |
87 | const { data } = await shortRes.json()
88 | // no such link
89 | if (!data) {
90 | return NextResponse.redirect(`${host}/`)
91 | }
92 |
93 | const ip = req.ip
94 | const geo = req.geo
95 | const ua = userAgent(req)
96 |
97 | const { id, url } = data
98 |
99 | const visits = {
100 | browser_name: ua.browser.name,
101 | browser_version: ua.browser.version,
102 | city: geo?.city,
103 | country: geo?.country,
104 | cpu_architecture: ua.cpu.architecture,
105 | device_model: ua.device.model,
106 | device_type: ua.device.type,
107 | device_vendor: ua.device.vendor,
108 | engine_name: ua.engine.name,
109 | engine_version: ua.engine.version,
110 | ip: ip,
111 | is_bot: ua.isBot,
112 | latitude: geo?.latitude,
113 | longitude: geo?.longitude,
114 | link_id: id,
115 | os_name: ua.os.name,
116 | os_version: ua.os.version,
117 | region: geo?.region,
118 | short_id: shortId,
119 | ua: ua.ua
120 | }
121 |
122 | // record visits
123 | await fetch(`${host}/api/track`, {
124 | method: 'POST',
125 | body: JSON.stringify({
126 | visits,
127 | })
128 | })
129 | const end = Date.now()
130 | console.log(end - start)
131 | // redirects
132 | return NextResponse.redirect(url)
133 | } catch (err: any) {
134 | console.log(err)
135 | return NextResponse.redirect(`${host}/`)
136 | }
137 | }
138 | */
139 |
140 | export default middleware
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/next-i18next.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 |
3 | module.exports = {
4 | i18n: {
5 | defaultLocale: 'en',
6 | locales: ['en', 'zh'],
7 | localePath: path.resolve('./public/locales')
8 | }
9 | }
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | const { i18n } = require('./next-i18next.config')
2 |
3 | /** @type {import('next').NextConfig} */
4 | const nextConfig = {
5 | i18n,
6 | reactStrictMode: true,
7 | reloadOnPrerender: process.env.NODE_ENV === "development"
8 | }
9 |
10 | module.exports = nextConfig
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "url-shorter",
3 | "private": false,
4 | "version": "1.1.0",
5 | "description": "url shortener",
6 | "main": "index.js",
7 | "repository": "git@github.com:akazwz/url-shorter.git",
8 | "author": "akazwz ",
9 | "license": "MIT",
10 | "scripts": {
11 | "dev": "next dev -p 80",
12 | "build": "next build",
13 | "start": "next start",
14 | "lint": "next lint"
15 | },
16 | "dependencies": {
17 | "@ant-design/plots": "^1.0.9",
18 | "@chakra-ui/react": "^2.2.1",
19 | "@chakra-ui/react-env": "^2.0.2",
20 | "@emotion/react": "^11.9.3",
21 | "@emotion/styled": "^11.9.3",
22 | "@icon-park/react": "^1.4.2",
23 | "@next-auth/prisma-adapter": "^1.0.3",
24 | "@prisma/client": "^4.0.0",
25 | "@upstash/redis": "^1.7.0",
26 | "axios": "^0.27.2",
27 | "cobe": "^0.5.0",
28 | "framer-motion": "^6.4.3",
29 | "is-url": "^1.2.4",
30 | "leaflet": "^1.8.0",
31 | "nanoid": "^4.0.0",
32 | "next": "^12.2.1",
33 | "next-absolute-url": "^1.2.2",
34 | "next-auth": "^4.10.3",
35 | "next-i18next": "^11.0.0",
36 | "react": "^18.2.0",
37 | "react-dom": "^18.2.0",
38 | "react-leaflet": "^4.0.1",
39 | "recoil": "^0.7.4"
40 | },
41 | "devDependencies": {
42 | "@babel/core": "^7.18.6",
43 | "@types/ioredis": "^4.28.10",
44 | "@types/is-url": "^1.2.30",
45 | "@types/leaflet": "^1.7.11",
46 | "@types/node": "^18.0.3",
47 | "@types/react": "^18.0.15",
48 | "eslint": "^8.19.0",
49 | "eslint-config-next": "^12.2.1",
50 | "prisma": "^4.0.0",
51 | "typescript": "^4.7.4"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/pages/500.tsx:
--------------------------------------------------------------------------------
1 | import { Center, Heading } from '@chakra-ui/react'
2 | import { GetStaticProps } from 'next'
3 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
4 |
5 | export const getStaticProps: GetStaticProps = async({ locale }) => {
6 | return {
7 | props: {
8 | ...(await serverSideTranslations(locale || 'en', ['common'])),
9 | },
10 | }
11 | }
12 |
13 | const ErrorPage = () => {
14 | return (
15 |
16 | 500
17 |
18 | )
19 | }
20 |
21 | export default ErrorPage
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { ChakraProvider } from '@chakra-ui/react'
2 | import { AppProps } from 'next/app'
3 | import { RecoilRoot } from 'recoil'
4 | import { appWithTranslation } from 'next-i18next'
5 | import { SessionProvider } from 'next-auth/react'
6 |
7 | import theme from '../src/theme'
8 | import { Layouts } from '../components/layout'
9 |
10 | function MyApp({ Component, pageProps: { session, ...pageProps }, }: AppProps) {
11 | return (
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | )
22 | }
23 |
24 | export default appWithTranslation(MyApp)
--------------------------------------------------------------------------------
/pages/api/auth/[...nextauth].ts:
--------------------------------------------------------------------------------
1 | import NextAuth, { NextAuthOptions } from 'next-auth'
2 | import GithubProvider from 'next-auth/providers/github'
3 |
4 | export const authOptions: NextAuthOptions = {
5 | secret: process.env.NEXTAUTH_SECRET,
6 | providers: [
7 | GithubProvider({
8 | clientId: process.env.GITHUB_CLIENT_ID,
9 | clientSecret: process.env.GITHUB_CLIENT_SECRET,
10 | })
11 | ],
12 | }
13 |
14 | export default NextAuth(authOptions)
--------------------------------------------------------------------------------
/pages/api/short.ts:
--------------------------------------------------------------------------------
1 | import { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'
2 | import isUrl from 'is-url'
3 | import absoluteUrl from 'next-absolute-url'
4 | import { unstable_getServerSession } from 'next-auth'
5 |
6 | import { deleteUrl, setUrl } from '../../lib/redis'
7 | import { authOptions } from './auth/[...nextauth]'
8 | import { getIp } from '../../src/utils'
9 | import prisma from '../../lib/prisma'
10 |
11 | const handler: NextApiHandler = async(req: NextApiRequest, res: NextApiResponse) => {
12 | switch (req.method) {
13 | case 'POST':
14 | return await handleShortUrl(req, res)
15 | default:
16 | return res.status(405).json({ msg: 'method not allowed' })
17 | }
18 | }
19 |
20 | const handleShortUrl = async(req: NextApiRequest, res: NextApiResponse) => {
21 | const { url } = JSON.parse(req.body)
22 | /* params must be string */
23 | if (typeof url !== 'string') {
24 | return res.status(400).json({ msg: 'params error' })
25 | }
26 |
27 | /* url must be valid absolute url */
28 | if (!isUrl(url)) {
29 | res.status(400).json({ msg: 'url not valid' })
30 | return
31 | }
32 |
33 | const shortCode = await setUrl(url)
34 | if (!shortCode) {
35 | return res.status(400).json({ msg: 'short error' })
36 | }
37 |
38 | try {
39 | const session = await unstable_getServerSession(req, res, authOptions)
40 | const email = session?.user?.email || null
41 |
42 | const { protocol, host } = absoluteUrl(req)
43 |
44 | const shortUrl = `${protocol}//${host.replace('www.', '')}/${shortCode}`
45 | const ip = getIp(req)
46 |
47 | await prisma.link.create({
48 | data: {
49 | url,
50 | shortCode,
51 | ip,
52 | email,
53 | }
54 | })
55 |
56 | return res.status(201).json({
57 | success: true,
58 | data: {
59 | url,
60 | short_code: shortCode,
61 | short_url: shortUrl,
62 | }
63 | })
64 | } catch (e) {
65 | console.log(e)
66 | await deleteUrl(shortCode)
67 | return res.status(400).json({
68 | success: false,
69 | })
70 | }
71 | }
72 |
73 | export default handler
--------------------------------------------------------------------------------
/pages/api/track/[short].ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next'
2 |
3 | import { getUrl } from '../../../lib/redis'
4 | import prisma from '../../../lib/prisma'
5 | import { toChartFormat, unique, uniqueAndCountArr, uniqueSimpleArr } from '../../../src/utils/arrayUntils'
6 |
7 | export default async function handler(req: NextApiRequest, res: NextApiResponse) {
8 | switch (req.method) {
9 | case 'GET':
10 | return await handleGetShortTrack(req, res)
11 | default:
12 | return res.status(405).json({ msg: 'method not allowed' })
13 | }
14 | }
15 |
16 | export interface Position{
17 | latitude: string
18 | longitude: string
19 | }
20 |
21 | export interface ChartFormat{
22 | key: string
23 | value: any
24 | }
25 |
26 | const handleGetShortTrack = async(req: NextApiRequest, res: NextApiResponse) => {
27 | const { short } = req.query
28 | if (typeof short !== 'string') {
29 | return res.status(400).json({ msg: 'params error' })
30 | }
31 |
32 | const url = await getUrl(short)
33 |
34 | if (!url) {
35 | return res.status(400).json({ msg: 'params error' })
36 | }
37 |
38 | const link = await prisma.link.findUnique({
39 | where: {
40 | shortCode: short,
41 | }
42 | })
43 |
44 | if (!link) {
45 | return res.status(400).json({ msg: 'params error' })
46 | }
47 |
48 | if (link.email) {
49 | return res.status(400).json({
50 | msg: 'not public link',
51 | })
52 | }
53 |
54 | const visits = await prisma.visit.findMany({
55 | where: {
56 | link: {
57 | shortCode: short,
58 | },
59 | },
60 | })
61 |
62 | let ips: string[] = []
63 |
64 | let browsers: any = []
65 |
66 | let deviceVendors: any = []
67 | let deviceModels: any = []
68 | // open systems
69 | let oss: any = []
70 | let mobileOSArr: any = []
71 | let pcOSArr: any = []
72 |
73 | let positionsArr: Position[] = []
74 |
75 | const mobileOSNames = ['iOS', 'Android']
76 |
77 | visits.map((visit) => {
78 | if (visit.ip) {
79 | ips.push(visit.ip)
80 | }
81 | browsers.push(visit.browserName)
82 | deviceVendors.push(visit.deviceVendor)
83 | deviceModels.push(visit.deviceModel)
84 | oss.push(visit.osName)
85 |
86 | if (visit.osName && mobileOSNames.includes(visit.osName)) {
87 | mobileOSArr.push(visit.osName)
88 | } else {
89 | pcOSArr.push(visit.osName)
90 | }
91 |
92 | if (visit.latitude && visit.longitude) {
93 | positionsArr.push({
94 | latitude: visit.latitude,
95 | longitude: visit.longitude,
96 | })
97 | }
98 | })
99 |
100 | // unique simple array
101 | ips = uniqueSimpleArr(ips)
102 |
103 | // unique and count
104 | oss = uniqueAndCountArr(oss)
105 | browsers = uniqueAndCountArr(browsers)
106 | deviceModels = uniqueAndCountArr(deviceModels)
107 | deviceVendors = uniqueAndCountArr(deviceVendors)
108 |
109 | // to chart format
110 | const os = toChartFormat(oss)
111 | const browser = toChartFormat(browsers)
112 | const deviceModel = toChartFormat(deviceModels)
113 | const deviceVendor = toChartFormat(deviceVendors)
114 |
115 | const visitCount = visits.length
116 | const ipCount = ips.length
117 | const mobileCount = mobileOSArr.length
118 | const pcCount = pcOSArr.length
119 |
120 | // unique obj total
121 | const positions = unique(positionsArr)
122 | const markers: [number, number][] = []
123 | positions.map((position: Position) => {
124 | markers.push([Number(position.latitude), Number(position.longitude)])
125 | })
126 | return res.status(200).json({
127 | success: true,
128 | short,
129 | ipCount,
130 | visitCount,
131 | mobileCount,
132 | pcCount,
133 | os,
134 | browser,
135 | deviceModel,
136 | deviceVendor,
137 | markers,
138 | })
139 | }
--------------------------------------------------------------------------------
/pages/api/track/index.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next'
2 |
3 | import prisma from '../../../lib/prisma'
4 | import { getUrl } from '../../../lib/redis'
5 |
6 | export default async function handler(req: NextApiRequest, res: NextApiResponse) {
7 | switch (req.method) {
8 | case 'GET':
9 | return await handleGetTrack(req, res)
10 | default:
11 | return res.status(405).json({ msg: 'method not allowed' })
12 | }
13 | }
14 |
15 | const handleGetTrack = async(req: NextApiRequest, res: NextApiResponse) => {
16 | const { short } = req.query
17 | if (typeof short !== 'string') {
18 | return res.status(400).json({ msg: 'params error' })
19 | }
20 |
21 | const url = await getUrl(short)
22 |
23 | if (!url) {
24 | return res.status(400).json({ msg: 'params error' })
25 | }
26 |
27 | const link = await prisma.link.findUnique({
28 | where: {
29 | shortCode: short
30 | },
31 | })
32 |
33 | if (!link) {
34 | return res.status(400).json({ msg: 'params error' })
35 | }
36 |
37 | if (link.email) {
38 | return res.status(400).json({
39 | msg: 'not public link',
40 | email: link.email
41 | })
42 | }
43 |
44 | return res.status(200).json({ success: true })
45 | }
46 |
47 |
--------------------------------------------------------------------------------
/pages/api/visit.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next'
2 |
3 | import prisma from '../../lib/prisma'
4 |
5 | export default async function handler(req: NextApiRequest, res: NextApiResponse) {
6 | switch (req.method) {
7 | case 'POST':
8 | await handleRecordVisit(req, res)
9 | return
10 | default:
11 | res.status(405).json({ msg: 'method not allowed' })
12 | return
13 | }
14 | }
15 |
16 | const handleRecordVisit = async(req: NextApiRequest, res: NextApiResponse) => {
17 | const { visit } = JSON.parse(req.body)
18 |
19 | try {
20 | await prisma.visit.create({
21 | data: {
22 | ip: visit.ip,
23 | link: {
24 | connect: {
25 | shortCode: visit.short_code,
26 | },
27 | },
28 | country: visit.country,
29 | city: visit.city,
30 | latitude: visit.latitude,
31 | longitude: visit.longitude,
32 | region: visit.region,
33 | isBot: visit.is_bot,
34 | ua: visit.ua,
35 | browserName: visit.browser_name,
36 | browserVersion: visit.browser_version,
37 | deviceModel: visit.device_model,
38 | deviceType: visit.device_type,
39 | deviceVendor: visit.device_vendor,
40 | engineName: visit.engine_name,
41 | engineVersion: visit.engine_version,
42 | osName: visit.os_name,
43 | osVersion: visit.os_version,
44 | cpuArchitecture: visit.cpu_architecture,
45 | }
46 | })
47 | return res.status(201).json({
48 | success: true
49 | })
50 | } catch (e) {
51 | return res.status(400).json({
52 | success: false
53 | })
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import isUrl from 'is-url'
3 | import { GetStaticProps, NextPage } from 'next'
4 | import {
5 | Box,
6 | Button,
7 | HStack,
8 | IconButton,
9 | Input,
10 | Spacer,
11 | Stack,
12 | VStack,
13 | useClipboard,
14 | useColorModeValue,
15 | useToast,
16 | } from '@chakra-ui/react'
17 | import { useTranslation } from 'next-i18next'
18 | import { Check, Copy, Lightning } from '@icon-park/react'
19 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
20 |
21 | import { NextChakraLink } from '../components/NextChakraLink'
22 |
23 | export const getStaticProps: GetStaticProps = async({ locale }) => {
24 | return {
25 | props: {
26 | ...(await serverSideTranslations(locale || 'en', ['common', 'index'])),
27 | },
28 | }
29 | }
30 |
31 | const Home: NextPage = () => {
32 | const [url, setUrl] = useState('')
33 | const [shortUrl, setShortUrl] = useState('')
34 | const [loading, setLoading] = useState(false)
35 |
36 | const { hasCopied, onCopy } = useClipboard(shortUrl)
37 |
38 | const inputActiveBg = useColorModeValue('gray.300', 'rgba(132,133,141,0.24)')
39 | const bgColor = useColorModeValue('gray.200', 'rgba(132,133,141,0.12)')
40 |
41 | const toast = useToast()
42 | const { t } = useTranslation('index')
43 |
44 | const handleShort = async() => {
45 | setLoading(true)
46 | const res = await fetch('/api/short', {
47 | method: 'POST',
48 | body: JSON.stringify({
49 | url,
50 | })
51 | })
52 | setLoading(false)
53 | if (res.status === 201) {
54 | const { data } = await res.json()
55 | const { short_url } = data
56 | setShortUrl(short_url)
57 | } else {
58 | toast({
59 | title: t('shortError'),
60 | status: 'error',
61 | position: 'top',
62 | isClosable: true,
63 | })
64 | }
65 | }
66 |
67 | return (
68 |
69 |
83 | setUrl(e.currentTarget.value)}
91 | />
92 | }
96 | variant="ghost"
97 | isDisabled={!isUrl(url)}
98 | onClick={handleShort}
99 | isLoading={loading}
100 | />
101 |
102 |
103 | {shortUrl.length > 0 ? (
104 | <>
105 |
112 |
117 |
118 | {shortUrl}
119 |
120 |
121 | : }
126 | >
127 | {hasCopied ? t('copied') : t('copy')}
128 |
129 |
130 |
131 | >
132 | ) : null}
133 |
134 | )
135 | }
136 |
137 | export default Home
138 |
--------------------------------------------------------------------------------
/pages/login.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import {
3 | Stack,
4 | Heading,
5 | VStack,
6 | Button,
7 | Box,
8 | useToast,
9 | useColorModeValue,
10 | } from '@chakra-ui/react'
11 | import { NextPage, GetStaticProps } from 'next'
12 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
13 | import { useTranslation } from 'next-i18next'
14 | import { Github } from '@icon-park/react'
15 | import { signIn } from 'next-auth/react'
16 |
17 | import { NextChakraLink } from '../components/NextChakraLink'
18 | import { Logo } from '../components/Logo'
19 |
20 | export const getStaticProps: GetStaticProps = async({ locale }) => {
21 | return {
22 | props: {
23 | ...(await serverSideTranslations(locale || 'en', ['login', 'common'])),
24 | },
25 | }
26 | }
27 |
28 | const Login: NextPage = () => {
29 | const [email, setEmail] = useState('')
30 | const [loading, setLoading] = useState(false)
31 |
32 | const toast = useToast()
33 | const { t } = useTranslation('login')
34 |
35 | const bgColor = useColorModeValue('gray.100', 'blackAlpha.300')
36 |
37 | const handleLogin = async() => {
38 | try {
39 | setLoading(true)
40 | await signIn('email')
41 | toast({
42 | title: t('success'),
43 | status: 'success',
44 | duration: 3000,
45 | isClosable: true,
46 | })
47 | } catch (err: any) {
48 | toast({
49 | title: t('error'),
50 | status: 'error',
51 | duration: 3000,
52 | isClosable: true,
53 | })
54 | } finally {
55 | setLoading(false)
56 | }
57 | }
58 |
59 | const handleLoginByGithub = async() => {
60 | try {
61 | setLoading(true)
62 | await signIn('github', {
63 | callbackUrl: '/'
64 | }
65 | )
66 | } catch (err: any) {
67 | toast({
68 | title: t('error'),
69 | status: 'error',
70 | duration: 3000,
71 | isClosable: true,
72 | })
73 | } finally {
74 | setLoading(false)
75 | }
76 | }
77 |
78 | return (
79 |
80 |
81 |
82 |
83 |
84 |
91 |
92 | {t('login')}
93 |
94 | {/*
95 | {t('email')}:
96 | {
99 | setEmail(e.target.value)
100 | }}
101 | />
102 | */}
103 |
104 | {/*
105 |
113 |
114 |
115 |
116 | {t('or')}
117 | */}
118 | }
123 | isLoading={loading}
124 | >
125 | Github
126 |
127 |
128 |
129 |
130 | )
131 | }
132 |
133 | export default Login
--------------------------------------------------------------------------------
/pages/track/[short].tsx:
--------------------------------------------------------------------------------
1 | import { GetServerSideProps } from 'next'
2 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
3 | import absoluteUrl from 'next-absolute-url'
4 | import { Box, Center, SimpleGrid, Spinner, Stack, Text, useColorModeValue } from '@chakra-ui/react'
5 |
6 | import { VisitOverview } from '../../components/track/VisitOverview'
7 | import dynamic from 'next/dynamic'
8 | import Cobe from '../../components/track/Cobe'
9 | import OSPie from '../../components/track/OSPie'
10 | import DeviceModelBar from '../../components/track/DeviceModelBar'
11 | import DeviceVendorColumn from '../../components/track/DeviceVendorColumn'
12 | import BrowserBar from '../../components/track/BrowserBar'
13 | import TrackSetting from '../../components/track/TrackSetting'
14 | import { useEffect, useState } from 'react'
15 | import { useRouter } from 'next/router'
16 | import { Marker } from 'cobe'
17 |
18 | const MyMap = dynamic(() => import('../../components/track/Map'), { ssr: false })
19 |
20 | export const getServerSideProps: GetServerSideProps = async({ params, locale, req }) => {
21 | const { origin } = absoluteUrl(req)
22 | const response = await fetch(`${origin}/api/track?short=${params?.short}`, { method: 'GET' })
23 | if (response.status !== 200) {
24 | console.log(response)
25 | return {
26 | redirect: {
27 | destination: '/',
28 | permanent: false,
29 | }
30 | }
31 | }
32 |
33 | return {
34 | props: {
35 | ...(await serverSideTranslations(locale || 'en', ['common', 'track'])),
36 | },
37 | }
38 | }
39 |
40 | const Loading = () => {
41 | return (
42 |
43 |
44 |
45 | )
46 | }
47 |
48 | interface ShortDashboardProps{
49 | visitCount: number
50 | ipCount: number
51 | mobileCount: number
52 | pcCount: number
53 | markers: [number, number][]
54 | browser: any[]
55 | os: any[],
56 | deviceVendor: any[]
57 | deviceModel: any[]
58 | }
59 |
60 | const ShortDashboard = ({
61 | browser,
62 | deviceModel,
63 | deviceVendor,
64 | os,
65 | ipCount,
66 | visitCount,
67 | markers,
68 | mobileCount,
69 | pcCount
70 | }: ShortDashboardProps) => {
71 | const dark = useColorModeValue(-1, 1)
72 |
73 | const m: Marker[] = []
74 | markers.map((mark) => {
75 | m.push({ location: mark, size: 0.03 })
76 | })
77 | return (
78 |
79 |
80 |
88 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
106 |
113 |
114 |
115 |
122 |
123 |
124 |
131 |
132 |
133 |
140 |
141 |
142 |
143 |
144 |
145 | )
146 | }
147 |
148 | const Short = () => {
149 | const [loading, setLoading] = useState(true)
150 | const [data, setData] = useState({
151 | browser: [],
152 | deviceModel: [],
153 | deviceVendor: [],
154 | os: [],
155 | ipCount: 0,
156 | visitCount: 0,
157 | markers: [],
158 | mobileCount: 0,
159 | pcCount: 0,
160 | })
161 |
162 | const router = useRouter()
163 | useEffect(() => {
164 | if (!router.isReady) return
165 | const { short } = router.query
166 | const getTrackData = async(short: any) => {
167 | setLoading(true)
168 | const res = await fetch(`/api/track/${short}`, { method: 'GET' })
169 | const data = await res.json()
170 | /*const { browser, deviceModel, os, deviceVendor, ipCount, visitCount, markers, mobileCount, pcCount } = data*/
171 | setData(data)
172 | setLoading(false)
173 | }
174 | getTrackData(short).then()
175 | }, [router.isReady, router.query])
176 |
177 | return (
178 | <>
179 | {loading ? : }
180 | >
181 | )
182 | }
183 |
184 | export default Short
--------------------------------------------------------------------------------
/pages/track/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { GetStaticProps } from 'next'
3 | import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
4 | import { VStack, HStack, IconButton, Input, useColorModeValue, Heading, useToast, Text } from '@chakra-ui/react'
5 | import { Trace } from '@icon-park/react'
6 | import { useRouter } from 'next/router'
7 | import { useTranslation } from 'next-i18next'
8 |
9 | export const getStaticProps: GetStaticProps = async({ locale }) => {
10 | return {
11 | props: {
12 | ...(await serverSideTranslations(locale || 'en', ['common', 'track'])),
13 | },
14 | }
15 | }
16 |
17 | const TrackIndex = () => {
18 | const inputActiveBg = useColorModeValue('gray.300', 'rgba(132,133,141,0.24)')
19 | const bgColor = useColorModeValue('gray.200', 'rgba(132,133,141,0.12)')
20 |
21 | const [shortCode, setShortCode] = useState('')
22 | const [loading, setLoading] = useState(false)
23 |
24 | const toast = useToast()
25 | const router = useRouter()
26 | const { t } = useTranslation('track')
27 |
28 | return (
29 |
30 |
31 |
32 | {t('track')}
33 |
34 | {t('tip')}
35 |
36 |
50 | setShortCode(e.currentTarget.value)}
58 | />
59 | }
63 | variant="ghost"
64 | isDisabled={shortCode.length < 5}
65 | onClick={async(event) => {
66 | event.preventDefault()
67 | setLoading(true)
68 | const res = await fetch(`/api/track?short=${shortCode}`, { method: 'GET' })
69 | if (res.status !== 200) {
70 | toast({
71 | title: 'No Such Short',
72 | status: 'error',
73 | isClosable: true,
74 | position: 'top'
75 | })
76 | setLoading(false)
77 | return
78 | }
79 | await router.push(`/track/${shortCode}`)
80 | }}
81 | isLoading={loading}
82 | />
83 |
84 |
85 | )
86 | }
87 |
88 | export default TrackIndex
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | datasource db {
2 | provider = "postgresql"
3 | url = env("DATABASE_URL")
4 | }
5 |
6 | generator client {
7 | provider = "prisma-client-js"
8 | }
9 |
10 | model Link {
11 | id String @id @default(cuid())
12 | createdAt DateTime @default(now())
13 | updatedAt DateTime @updatedAt
14 | url String
15 | shortCode String @unique
16 | ip String?
17 | email String?
18 | visit Visit[]
19 | }
20 |
21 | model Visit {
22 | id String @id @default(uuid())
23 | createdAt DateTime @default(now())
24 | ip String?
25 | link Link? @relation(fields: [linkId], references: [id])
26 | linkId String?
27 | country String? //geo
28 | city String?
29 | latitude String?
30 | longitude String?
31 | region String?
32 | isBot Boolean? //ua
33 | ua String?
34 | browserName String?
35 | browserVersion String?
36 | deviceModel String?
37 | deviceType String?
38 | deviceVendor String?
39 | engineName String?
40 | engineVersion String?
41 | osName String?
42 | osVersion String?
43 | cpuArchitecture String?
44 | }
45 |
--------------------------------------------------------------------------------
/public/earth-dark.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/url-shorter/57cd12ed12579b877423cce09299e7ad42f4f5dd/public/earth-dark.jpg
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/url-shorter/57cd12ed12579b877423cce09299e7ad42f4f5dd/public/favicon.ico
--------------------------------------------------------------------------------
/public/locales/en/common.json:
--------------------------------------------------------------------------------
1 | {
2 | "header": {
3 | "login": "Sign In",
4 | "signup": "Sign Up",
5 | "dashboard": "Dashboard",
6 | "signOut": "Sign Out",
7 | "track": "Track"
8 | }
9 | }
--------------------------------------------------------------------------------
/public/locales/en/index.json:
--------------------------------------------------------------------------------
1 | {
2 | "copy": "Copy",
3 | "copied": "Copied",
4 | "shortError": "Short Error"
5 | }
--------------------------------------------------------------------------------
/public/locales/en/login.json:
--------------------------------------------------------------------------------
1 | {
2 | "login": "Sign In",
3 | "loginPasswordsLess": "Sign In PasswordsLess",
4 | "email": "Email",
5 | "username": "Username",
6 | "password": "Password",
7 | "forgetPassword": "Forget password?",
8 | "account": "Don't have an account?",
9 | "signup": "Sign Up",
10 | "success": "Sign In Success, Please check your email.",
11 | "error": "Sign In Error",
12 | "form": {
13 | "username": "Username length must >= 3",
14 | "password": "Password length must >= 6"
15 | },
16 | "or": "OR",
17 | "signInByGithub": "Sign In By Github"
18 | }
--------------------------------------------------------------------------------
/public/locales/en/password_reset.json:
--------------------------------------------------------------------------------
1 | {
2 | "resetLink": "You'll get an email with a reset link",
3 | "reset": "Request Reset"
4 | }
--------------------------------------------------------------------------------
/public/locales/en/signup.json:
--------------------------------------------------------------------------------
1 | {
2 | "signup": "Sign Up",
3 | "username": "Username",
4 | "password": "Password",
5 | "account": "Already had an account?",
6 | "login": "Sign In",
7 | "success": "Sign Up Success",
8 | "error": "Sign Up Error",
9 | "form": {
10 | "username": "Username length must >= 3",
11 | "password": "Password length must >= 6"
12 | }
13 | }
--------------------------------------------------------------------------------
/public/locales/en/track.json:
--------------------------------------------------------------------------------
1 | {
2 | "track": "Track",
3 | "tip": "can only track public short",
4 | "visitCount": "Visit Count",
5 | "ipCount": "IP Count",
6 | "mobileVisit": "Mobile Visit",
7 | "pcVisit": "PC Visit"
8 | }
--------------------------------------------------------------------------------
/public/locales/zh/common.json:
--------------------------------------------------------------------------------
1 | {
2 | "header": {
3 | "login": "登录",
4 | "signup": "注册",
5 | "dashboard": "仪表盘",
6 | "signOut": "登出",
7 | "track": "追踪"
8 | }
9 | }
--------------------------------------------------------------------------------
/public/locales/zh/index.json:
--------------------------------------------------------------------------------
1 | {
2 | "copy": "复制",
3 | "copied": "已复制",
4 | "shortError": "缩短失败"
5 | }
--------------------------------------------------------------------------------
/public/locales/zh/login.json:
--------------------------------------------------------------------------------
1 | {
2 | "login": "登录",
3 | "loginPasswordsLess": "无密码登录",
4 | "email": "邮箱",
5 | "username": "用户名",
6 | "password": "密码",
7 | "forgetPassword": "忘记密码?",
8 | "account": "还没有账户了?",
9 | "signup": "注册",
10 | "success": "登录成功, 请检查邮件",
11 | "error": "登录失败",
12 | "form": {
13 | "username": "用户名长度必须大于等于3",
14 | "password": "密码长度必须大于等于6"
15 | },
16 | "or": "或者",
17 | "signInByGithub": "通过 Github 登录"
18 | }
--------------------------------------------------------------------------------
/public/locales/zh/password_reset.json:
--------------------------------------------------------------------------------
1 | {
2 | "resetLink": "你将会收到带有重置链接的电子邮件",
3 | "reset": "请求重置"
4 | }
--------------------------------------------------------------------------------
/public/locales/zh/signup.json:
--------------------------------------------------------------------------------
1 | {
2 | "signup": "注册",
3 | "username": "用户名",
4 | "password": "密码",
5 | "account": "已经有账户了?",
6 | "login": "登录",
7 | "success": "注册成功",
8 | "error": "注册失败",
9 | "form": {
10 | "username": "用户名长度必须大于等于3",
11 | "password": "密码长度必须大于等于6"
12 | }
13 | }
--------------------------------------------------------------------------------
/public/locales/zh/track.json:
--------------------------------------------------------------------------------
1 | {
2 | "track": "追踪",
3 | "tip": "只能追踪公共短链",
4 | "visitCount": "访问数",
5 | "ipCount": "IP数",
6 | "mobileVisit": "移动访问",
7 | "pcVisit": "电脑访问"
8 | }
--------------------------------------------------------------------------------
/public/markers/ip.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/url-shorter/57cd12ed12579b877423cce09299e7ad42f4f5dd/public/markers/ip.png
--------------------------------------------------------------------------------
/public/markers/marker.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/url-shorter/57cd12ed12579b877423cce09299e7ad42f4f5dd/public/markers/marker.png
--------------------------------------------------------------------------------
/public/markers/user.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/url-shorter/57cd12ed12579b877423cce09299e7ad42f4f5dd/public/markers/user.png
--------------------------------------------------------------------------------
/public/markers/visiter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/url-shorter/57cd12ed12579b877423cce09299e7ad42f4f5dd/public/markers/visiter.png
--------------------------------------------------------------------------------
/public/undraw_server_down.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/theme/components/button.ts:
--------------------------------------------------------------------------------
1 | import { StyleConfig } from '@chakra-ui/theme-tools'
2 |
3 | const Button: StyleConfig = {
4 | baseStyle: {
5 | ':focus:not(:focus-visible)': {
6 | boxShadow: 'none',
7 | },
8 | },
9 | }
10 |
11 | export default Button
--------------------------------------------------------------------------------
/src/theme/components/index.ts:
--------------------------------------------------------------------------------
1 | import Button from './button'
2 | import Input from './input'
3 |
4 | const components = {
5 | Button,
6 | Input,
7 | }
8 |
9 | export default components
--------------------------------------------------------------------------------
/src/theme/components/input.ts:
--------------------------------------------------------------------------------
1 | import { StyleConfig } from '@chakra-ui/theme-tools'
2 |
3 | const Input: StyleConfig = {
4 | baseStyle: {
5 | _focus: {
6 | outline: 'none',
7 | },
8 | },
9 | }
10 |
11 | export default Input
--------------------------------------------------------------------------------
/src/theme/foundations/index.ts:
--------------------------------------------------------------------------------
1 | import styles from './styles'
2 | import tokens from './tokens'
3 |
4 | const foundations = {
5 | styles,
6 | tokens,
7 | }
8 |
9 | export default foundations
--------------------------------------------------------------------------------
/src/theme/foundations/styles.ts:
--------------------------------------------------------------------------------
1 | import { StyleObjectOrFn } from '@chakra-ui/react'
2 |
3 | const styles: StyleObjectOrFn = {
4 | global: {
5 | body: {
6 | bg: 'body-bg',
7 | color: 'body-color',
8 | },
9 | }
10 | }
11 |
12 | export default styles
13 |
--------------------------------------------------------------------------------
/src/theme/foundations/tokens.ts:
--------------------------------------------------------------------------------
1 | const tokens = {
2 | colors: {
3 | 'body-bg': {
4 | default: 'white',
5 | _dark: 'black',
6 | },
7 | 'body-color': {
8 | default: 'black',
9 | _dark: 'white',
10 | },
11 | }
12 | }
13 |
14 | export default tokens
--------------------------------------------------------------------------------
/src/theme/index.ts:
--------------------------------------------------------------------------------
1 | import { extendTheme } from '@chakra-ui/react'
2 |
3 | import foundations from './foundations'
4 | import components from './components'
5 |
6 | const theme: Record = extendTheme({
7 | ...foundations,
8 | components: { ...components },
9 | config: {
10 | initialColorMode: 'system',
11 | useSystemColorMode: false,
12 | },
13 | })
14 |
15 | export default theme
--------------------------------------------------------------------------------
/src/utils/arrayUntils.ts:
--------------------------------------------------------------------------------
1 | import { ChartFormat } from '../../pages/api/track/[short]'
2 |
3 | export const uniqueSimpleArr = (arr: any[]): any[] => {
4 | return Array.from(new Set(arr))
5 | }
6 |
7 | export const uniqueAndCountArr = (arr: any[]): {} => {
8 | let obj: any = {}
9 | for (let i = 0; i < arr.length; i++) {
10 | if (arr[i] in obj) {
11 | obj[arr[i]] = obj[arr[i]] + 1
12 | } else {
13 | obj[arr[i]] = 1
14 | }
15 | }
16 | return obj
17 | }
18 |
19 | export const unique = (arr: any[]): any[] => {
20 | let arrTempString: string[] = []
21 | arr.map((item: any) => {
22 | arrTempString.push(JSON.stringify(item))
23 | })
24 |
25 | const arrUniqueString = uniqueSimpleArr(arrTempString)
26 | return arrUniqueString.map((item: any) => (JSON.parse(item)))
27 | }
28 |
29 | export const toChartFormat = (obj: any): ChartFormat[] => {
30 | const arr: ChartFormat[] = []
31 | Object.keys(obj).forEach((key: string) => {
32 | arr.push({ key, value: obj[key] })
33 | })
34 | return arr
35 | }
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest } from 'next'
2 |
3 | export const getIp = (request: Request | NextApiRequest): string => {
4 | const xff =
5 | request instanceof Request
6 | ? request.headers.get('x-forwarded-for')
7 | : request.headers['x-forwarded-for']
8 |
9 | return xff ? (Array.isArray(xff) ? xff[0] : xff.split(',')[0]) : '127.0.0.1'
10 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "sourceMap": true,
4 | "target": "es5",
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "allowJs": true,
7 | "skipLibCheck": true,
8 | "strict": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "noEmit": true,
11 | "esModuleInterop": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "jsx": "preserve",
17 | "incremental": true
18 | },
19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
20 | "exclude": ["node_modules"]
21 | }
22 |
--------------------------------------------------------------------------------
/types/index.ts:
--------------------------------------------------------------------------------
1 | export interface Link{
2 | url: string
3 | short_code: string
4 | ip?: string
5 | email?: string
6 | }
7 |
8 | export interface Visit{
9 | link_id: string
10 | short_code: string
11 | ip?: string
12 | country?: string
13 | city?: string
14 | latitude?: string
15 | longitude?: string
16 | region?: string
17 | is_bot?: boolean
18 | ua?: string
19 | browser_name?: string
20 | browser_version?: string
21 | device_model?: string
22 | device_type?: string
23 | device_vendor?: string
24 | engine_name?: string
25 | engine_version?: string
26 | os_name?: string
27 | os_version?: string
28 | cpu_architecture?: string
29 | }
--------------------------------------------------------------------------------