├── public ├── robots.txt ├── og-image.webp ├── x.svg ├── github.svg ├── logo.svg └── factory.svg ├── src ├── app │ ├── favicon.ico │ ├── globals.css │ ├── [slug] │ │ └── route.ts │ ├── about │ │ └── page.tsx │ ├── api │ │ └── route.ts │ ├── layout.tsx │ └── page.tsx ├── lib │ └── supabase-client.ts └── components │ └── HeaderNav.tsx ├── postcss.config.js ├── next-env.d.ts ├── docker-compose.yml ├── .env.example ├── next.config.js ├── .dockerignore ├── .gitignore ├── tailwind.config.ts ├── tsconfig.json ├── package.json ├── Dockerfile ├── LICENSE ├── .cursorrules ├── README.zh.md ├── README.md └── .github └── workflows └── docker-push.yml /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / -------------------------------------------------------------------------------- /public/og-image.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harrisonwang/url-shortener/HEAD/public/og-image.webp -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harrisonwang/url-shortener/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. 6 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | image: iamxiaowangye/url-shortener 4 | ports: 5 | - "3000:3000" 6 | environment: 7 | - NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} 8 | - NEXT_PUBLIC_SUPABASE_KEY=${NEXT_PUBLIC_SUPABASE_KEY} -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_SUPABASE_URL=https://einoylkzpvrajgaeugpp.supabase.co 2 | NEXT_PUBLIC_SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImVpbm95bGt6cHZyYWpnYWV1Z3BwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Mjc0ODg0MjIsImV4cCI6MjA0MzA2NDQyMn0.kFLfPsDt0g-decx9AFWRiLDN1-69WLSV6XHQGO0Yk4o 3 | -------------------------------------------------------------------------------- /public/x.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/supabase-client.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@supabase/supabase-js'; 2 | 3 | const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'http://placeholder-url.com'; 4 | const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_KEY || 'placeholder-key'; 5 | 6 | export const supabase = createClient(supabaseUrl, supabaseKey); -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const nextConfig = { 2 | reactStrictMode: true, 3 | webpack: (config, { isServer }) => { 4 | if (!isServer) { 5 | config.resolve.fallback = { 6 | ...config.resolve.fallback, 7 | fs: false, 8 | }; 9 | } 10 | return config; 11 | }, 12 | } 13 | 14 | module.exports = nextConfig -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Node.js 依赖目录 2 | node_modules 3 | 4 | # Next.js 构建输出 5 | .next 6 | 7 | # 环境变量文件 8 | .env* 9 | 10 | # 日志文件 11 | *.log 12 | 13 | # 操作系统生成的文件 14 | .DS_Store 15 | Thumbs.db 16 | 17 | # 编辑器配置文件 18 | .vscode 19 | .idea 20 | 21 | # Git 相关文件 22 | .git 23 | .gitignore 24 | 25 | # 测试文件 26 | __tests__ 27 | *.test.js 28 | *.spec.js 29 | 30 | # 其他不需要的文件 31 | README.md 32 | LICENSE 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node.js 2 | node_modules/ 3 | npm-debug.log 4 | 5 | # Next.js 6 | .next/ 7 | out/ 8 | 9 | # Environment variables 10 | .env 11 | 12 | # Logs 13 | logs/ 14 | *.log 15 | 16 | # OS generated files 17 | .DS_Store 18 | Thumbs.db 19 | 20 | # Editor files 21 | .vscode/ 22 | .idea/ 23 | 24 | # Build files 25 | dist/ 26 | build/ 27 | 28 | # Temporary files 29 | *.tmp 30 | *.swp 31 | 32 | # Other 33 | *.bak 34 | *.orig -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config: Config = { 4 | content: [ 5 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './src/app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 13 | 'gradient-conic': 14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | } 20 | export default config 21 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer utilities { 6 | .bg-factory { 7 | background-color: #E2E1CF; 8 | } 9 | } 10 | 11 | body { 12 | @apply bg-factory text-neutral-800; 13 | } 14 | 15 | 16 | 17 | body { 18 | font-family: 'Comic Sans MS', sans-serif; 19 | } 20 | 21 | /* 自定义滚动条样式 */ 22 | .custom-scrollbar::-webkit-scrollbar { 23 | width: 8px; 24 | } 25 | 26 | .custom-scrollbar::-webkit-scrollbar-track { 27 | background: #DAD9C4; 28 | } 29 | 30 | .custom-scrollbar::-webkit-scrollbar-thumb { 31 | background: #C3C2AC; 32 | border-radius: 4px; 33 | } 34 | 35 | .custom-scrollbar::-webkit-scrollbar-thumb:hover { 36 | background: #555; 37 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "url-shortener", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "next": "^14.2.8", 13 | "react": "^18", 14 | "react-dom": "^18", 15 | "nanoid": "^3.3.4", 16 | "@supabase/supabase-js": "^2.21.0" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "^20", 20 | "@types/react": "^18", 21 | "@types/react-dom": "^18", 22 | "autoprefixer": "^10", 23 | "eslint": "^8", 24 | "eslint-config-next": "13.5.6", 25 | "postcss": "^8", 26 | "tailwindcss": "^3", 27 | "typescript": "^5" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /public/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 构建阶段 2 | FROM node:20-alpine AS builder 3 | 4 | # 设置工作目录 5 | WORKDIR /app 6 | 7 | # 复制package.json和package-lock.json 8 | COPY package*.json ./ 9 | 10 | # 安装所有依赖 11 | RUN npm ci 12 | 13 | # 复制项目文件 14 | COPY . . 15 | 16 | # 构建项目 17 | RUN npm run build 18 | 19 | # 生产阶段 20 | FROM node:20-alpine 21 | 22 | # 设置工作目录 23 | WORKDIR /app 24 | 25 | # 复制package.json和package-lock.json 26 | COPY package*.json ./ 27 | 28 | # 安装生产依赖 29 | RUN npm ci --only=production 30 | 31 | # 复制构建产物和公共文件 32 | COPY --from=builder /app/.next ./.next 33 | COPY --from=builder /app/public ./public 34 | 35 | # 创建启动脚本 36 | RUN echo '#!/bin/sh' > start.sh && \ 37 | echo 'echo "NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL" > .env' >> start.sh && \ 38 | echo 'echo "NEXT_PUBLIC_SUPABASE_KEY=$NEXT_PUBLIC_SUPABASE_KEY" >> .env' >> start.sh && \ 39 | echo 'npm start' >> start.sh && \ 40 | chmod +x start.sh 41 | 42 | # 暴露端口 43 | EXPOSE 3000 44 | 45 | # 设置启动命令 46 | CMD ["/bin/sh", "/app/start.sh"] 47 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 小王爷 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/app/[slug]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { supabase } from '@/lib/supabase-client'; 3 | 4 | export async function GET( 5 | request: NextRequest, 6 | { params }: { params: { slug: string } } 7 | ) { 8 | try { 9 | const { slug } = params; 10 | 11 | // Query links table to get original URL 12 | const { data, error } = await supabase 13 | .from('links') 14 | .select('url') 15 | .eq('slug', slug) 16 | .single(); 17 | 18 | if (error || !data) { 19 | return NextResponse.json({ error: 'Short link does not exist' }, { status: 404 }); 20 | } 21 | 22 | // Record access log 23 | const ua = request.headers.get('user-agent') || ''; 24 | const ip = request.ip || request.headers.get('x-forwarded-for') || ''; 25 | const referer = request.headers.get('referer') || ''; 26 | 27 | await supabase.from('logs').insert({ 28 | url: data.url, 29 | slug, 30 | referer, 31 | ua, 32 | ip 33 | }); 34 | 35 | // 302 redirect to original URL 36 | return NextResponse.redirect(data.url, 302); 37 | 38 | } catch (error) { 39 | console.error('Redirect failed:', error); 40 | return NextResponse.json({ error: 'Redirect failed' }, { status: 500 }); 41 | } 42 | } -------------------------------------------------------------------------------- /.cursorrules: -------------------------------------------------------------------------------- 1 | # Full-Stack Web Developer 2 | 3 | You are an expert full-stack web developer focused on producing clear, readable Next.js code. 4 | 5 | You always use the latest stable versions of Next.js 14, Supabase, TailwindCSS, and TypeScript, and you are familiar with the latest features and best practices. 6 | 7 | You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning. 8 | 9 | Technical preferences: 10 | 11 | - Always use kebab-case for component names (e.g. my-component.tsx) 12 | - Favour using React Server Components and Next.js SSR features where possible 13 | - Minimize the usage of client components ('use client') to small, isolated components 14 | - Always add loading and error states to data fetching components 15 | - Implement error handling and error logging 16 | - Use semantic HTML elements where possible 17 | 18 | General preferences: 19 | 20 | - Follow the user's requirements carefully & to the letter. 21 | - Always write correct, up-to-date, bug-free, fully functional and working, secure, performant and efficient code. 22 | - Focus on readability over being performant. 23 | - Fully implement all requested functionality. 24 | - Leave NO todo's, placeholders or missing pieces in the code. 25 | - Be sure to reference file names. 26 | - Be concise. Minimize any other prose. 27 | - If you think there might not be a correct answer, you say so. If you do not know the answer, say so instead of guessing. 28 | -------------------------------------------------------------------------------- /src/components/HeaderNav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from 'next/link'; 4 | import { useEffect, useState } from 'react'; 5 | 6 | export default function HeaderNav() { 7 | const [isScrolled, setIsScrolled] = useState(false); 8 | 9 | useEffect(() => { 10 | const handleScroll = () => { 11 | if (window.scrollY > 0) { 12 | setIsScrolled(true); 13 | } else { 14 | setIsScrolled(false); 15 | } 16 | }; 17 | 18 | window.addEventListener('scroll', handleScroll); 19 | return () => { 20 | window.removeEventListener('scroll', handleScroll); 21 | }; 22 | }, []); 23 | 24 | return ( 25 |
26 | 43 |
44 | ); 45 | } -------------------------------------------------------------------------------- /src/app/about/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Link from 'next/link'; 4 | 5 | export default function AboutPage() { 6 | return ( 7 |
8 |
9 |

About URL Shortener

10 |
11 |
12 |

What is URL Shortener?

13 |

14 | URL Shortener is a free tool designed to help users easily shorten long URLs. Our goal is to make URL shortening simple, fast, and efficient. Whether you're a marketer, developer, or anyone who needs to share links, URL Shortener can help you create concise, manageable links. 15 |

16 |

Features

17 |
    18 |
  • Instant URL shortening
  • 19 |
  • Custom short links
  • 20 |
  • Analytics for link clicks
  • 21 |
  • Secure and reliable redirection
  • 22 |
  • Free to use
  • 23 |
24 |

How to Use

25 |
    26 |
  1. Enter your long URL in the input field on the home page
  2. 27 |
  3. Optional: Enter your desired short link
  4. 28 |
  5. Click the "Generate Short Link" button
  6. 29 |
  7. Copy your shortened URL and share it
  8. 30 |
31 |

About the Author

32 |

33 | This website was developed by Harrison Wang. While not a professional developer, I successfully created this tool with the help of AI. The entire project was developed using Cursor and Theme originally by Viggo. 34 |

35 |
36 | 37 | Try It Now 38 | 39 |
40 |
41 |
42 | ); 43 | } -------------------------------------------------------------------------------- /src/app/api/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { supabase } from '@/lib/supabase-client'; 3 | import { nanoid } from 'nanoid'; 4 | 5 | function getBaseUrl(request: NextRequest): string { 6 | const protocol = request.headers.get('x-forwarded-proto') || 'http'; 7 | const host = request.headers.get('host') 8 | return `${protocol}://${host}`; 9 | } 10 | 11 | export async function POST(request: NextRequest) { 12 | try { 13 | const { url, customSlug } = await request.json(); 14 | if (!url) { 15 | return NextResponse.json({ error: 'URL parameter is missing' }, { status: 400 }); 16 | } 17 | 18 | let slug = customSlug || nanoid(8); 19 | 20 | // Check if custom slug already exists 21 | if (customSlug) { 22 | const { data: existingSlug } = await supabase 23 | .from('links') 24 | .select('slug') 25 | .eq('slug', customSlug) 26 | .single(); 27 | 28 | if (existingSlug) { 29 | return NextResponse.json({ error: 'Custom short link already exists, please try another one' }, { status: 400 }); 30 | } 31 | } 32 | 33 | // Check if URL already exists 34 | const { data: existingUrl } = await supabase 35 | .from('links') 36 | .select('url, slug') 37 | .eq('url', url) 38 | .maybeSingle(); 39 | 40 | if (existingUrl) { 41 | const baseUrl = getBaseUrl(request); 42 | return NextResponse.json({ 43 | slug: existingUrl.slug, 44 | link: `${baseUrl}/${existingUrl.slug}` 45 | }, { status: 200 }); 46 | } 47 | 48 | // Create new short link 49 | const ua = request.headers.get('user-agent') || ''; 50 | const ip = request.ip || request.headers.get('x-forwarded-for') || ''; 51 | 52 | const { data: newLink, error: insertError } = await supabase 53 | .from('links') 54 | .insert({ 55 | url, 56 | slug, 57 | ua, 58 | ip, 59 | status: 1 60 | }) 61 | .select() 62 | .single(); 63 | 64 | if (insertError) { 65 | console.error('Error inserting new record:', insertError); 66 | throw insertError; 67 | } 68 | 69 | const baseUrl = getBaseUrl(request); 70 | return NextResponse.json({ 71 | slug: newLink.slug, 72 | link: `${baseUrl}/${newLink.slug}` 73 | }, { status: 201 }); 74 | 75 | } catch (error) { 76 | console.error('Failed to create short link:', error); 77 | if (error instanceof Error) { 78 | console.error('Error details:', error.message); 79 | console.error('Error stack:', error.stack); 80 | } 81 | return NextResponse.json({ error: 'Failed to create short link' }, { status: 500 }); 82 | } 83 | } -------------------------------------------------------------------------------- /README.zh.md: -------------------------------------------------------------------------------- 1 | # URL 短链接生成器 2 | 3 | [English](README.md) | [中文](README.zh.md) 4 | 5 | URL 短链接生成器是一个免费工具,旨在帮助用户轻松缩短长 URL。我们的目标是让 URL 缩短变得简单、快速且高效。无论您是营销人员、开发者还是任何需要分享链接的人,URL 短链接生成器都能帮助您创建简洁、易管理的链接。 6 | 7 | ## 关于作者 8 | 9 | 这个网站是由 [Harrison Wang](https://x.com/voywang) 使用 [Cursor](https://www.cursor.com/) 开发,主题原作者为 [Viggo](https://x.com/decohack)。 10 | 11 | ## 功能 12 | 13 | - **即时 URL 缩短**:快速为任何长 URL 生成短链接。 14 | - **自定义短链接**:创建易记和品牌化的短 URL。 15 | - **分析功能**:跟踪您的短链接的使用情况。 16 | - **安全可靠**:确保安全的重定向和链接管理。 17 | - **免费使用**:创建和管理短链接无需任何费用。 18 | 19 | ## 安装与运行 20 | 21 | ### 准备 Supabase 项目 22 | 23 | 在 Supabase 中创建一个新的项目,然后运行以下 SQL 命令来创建必要的表: 24 | 25 | ```sql 26 | create table 27 | public.links ( 28 | id serial not null, 29 | url text null, 30 | slug text null, 31 | ua text null, 32 | ip text null, 33 | status integer null, 34 | created_at timestamp without time zone null default current_timestamp, 35 | constraint links_pkey primary key (id) 36 | ) tablespace pg_default; 37 | 38 | create table 39 | public.logs ( 40 | id serial not null, 41 | url text null, 42 | slug text null, 43 | referer text null, 44 | ua text null, 45 | ip text null, 46 | created_at timestamp without time zone null default current_timestamp, 47 | constraint logs_pkey primary key (id) 48 | ) tablespace pg_default; 49 | ``` 50 | 51 | ### 1. 部署到 Vercel 52 | 53 | 点击右侧按钮开始部署: 54 | 55 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FHarrisonWang%2Furl-shortener&env=NEXT_PUBLIC_SUPABASE_URL&env=NEXT_PUBLIC_SUPABASE_KEY&project-name=url-shortener&repository-name=url-shortener) 56 | 57 | 配置以下环境变量: 58 | 59 | - `NEXT_PUBLIC_SUPABASE_URL` 60 | - `NEXT_PUBLIC_SUPABASE_KEY` 61 | 62 | ### 2. 使用 Docker 运行 63 | 64 | #### 2.1. 克隆仓库 65 | 66 | ```bash 67 | git clone https://github.com/harrisonwang/url-shortener.git 68 | ``` 69 | 70 | #### 2.2. 重命名 `.env.example` 为 `.env` 并设置环境变量 71 | 72 | ```bash 73 | mv .env.example .env 74 | ``` 75 | 76 | ```bash 77 | NEXT_PUBLIC_SUPABASE_URL= 78 | NEXT_PUBLIC_SUPABASE_KEY= 79 | ``` 80 | 81 | #### 2.3. 运行 Docker 容器 82 | 83 | ```bash 84 | docker compose up -d 85 | ``` 86 | 87 | #### 2.4. 访问应用 88 | 89 | 打开浏览器并访问 `http://localhost:3000`,您应该能够看到 URL 短链接生成器的界面。 90 | 91 | ### 3. 本地运行 92 | 93 | #### 3.1. 克隆仓库 94 | 95 | ```bash 96 | git clone https://github.com/harrisonwang/url-shortener.git 97 | ``` 98 | 99 | #### 3.2. 安装依赖 100 | 101 | ```bash 102 | npm i 103 | ``` 104 | 105 | #### 3.3. 重命名 `.env.example` 为 `.env` 并设置环境变量 106 | 107 | ```bash 108 | mv .env.example .env 109 | ``` 110 | 111 | ```bash 112 | NEXT_PUBLIC_SUPABASE_URL= 113 | NEXT_PUBLIC_SUPABASE_KEY= 114 | ``` 115 | 116 | #### 3.4. 运行开发服务器 117 | 118 | ```bash 119 | npm run dev 120 | ``` 121 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import './globals.css' 3 | import HeaderNav from '../components/HeaderNav' 4 | 5 | export const metadata: Metadata = { 6 | title: 'URL Shortener - Free URL Shortener', 7 | description: 'Shorten your URLs 100% automatically and for free with URL Shortener.', 8 | keywords: 'URL, URL Shortener, Free URL Shortener, Bulk URL Shortener, URL Shortener', 9 | openGraph: { 10 | title: 'URL Shortener - Free URL Shortener', 11 | description: 'Shorten your URLs 100% automatically and for free with URL Shortener.', 12 | images: [ 13 | { 14 | url: '/og-image.webp', 15 | width: 1200, 16 | height: 630, 17 | alt: 'URL Shortener - Free URL Shortener', 18 | }, 19 | ], 20 | url: 'https://url.xiaowangye.org', 21 | siteName: 'URL Shortener', 22 | }, 23 | twitter: { 24 | card: 'summary_large_image', 25 | title: 'URL Shortener - Free URL Shortener', 26 | description: 'Shorten your URLs 100% automatically and for free with URL Shortener.', 27 | images: ['/og-image.webp'], 28 | }, 29 | } 30 | 31 | export default function RootLayout({ 32 | children, 33 | }: { 34 | children: React.ReactNode 35 | }) { 36 | return ( 37 | 38 | 39 | 40 |
41 |
42 | {children} 43 |
44 |
45 |
46 | Factory 47 |
48 | 57 | 58 | 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # URL Shortener 2 | 3 | [English](README.md) | [中文](README.zh.md) 4 | 5 | URL Shortener is a free tool designed to help users easily shorten long URLs. Our goal is to make URL shortening simple, fast, and efficient. Whether you're a marketer, developer, or anyone who needs to share links, URL Shortener can help you create concise, manageable links. 6 | 7 | ## About the Author 8 | 9 | This website is built by [Harrison Wang](https://x.com/voywang) using [Cursor](https://www.cursor.com/) and theme originally by [Viggo](https://x.com/decohack). 10 | 11 | ## Features 12 | 13 | - **Instant URL Shortening**: Quickly generate short links for any long URL. 14 | - **Custom Short Links**: Create memorable and branded short URLs. 15 | - **Analytics**: Track the performance of your shortened links. 16 | - **Secure and Reliable**: Ensure safe redirection and link management. 17 | - **Free to Use**: No cost associated with creating or managing short links. 18 | 19 | ## Installation and Running 20 | 21 | ### Prepare Supabase Project 22 | 23 | Run the following SQL commands in your Supabase project to create the necessary tables: 24 | 25 | ```sql 26 | create table 27 | public.links ( 28 | id serial not null, 29 | url text null, 30 | slug text null, 31 | ua text null, 32 | ip text null, 33 | status integer null, 34 | created_at timestamp without time zone null default current_timestamp, 35 | constraint links_pkey primary key (id) 36 | ) tablespace pg_default; 37 | 38 | create table 39 | public.logs ( 40 | id serial not null, 41 | url text null, 42 | slug text null, 43 | referer text null, 44 | ua text null, 45 | ip text null, 46 | created_at timestamp without time zone null default current_timestamp, 47 | constraint logs_pkey primary key (id) 48 | ) tablespace pg_default; 49 | ``` 50 | 51 | ### 1. Deploy to Vercel 52 | 53 | Click the button below to deploy to Vercel: 54 | 55 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FHarrisonWang%2Furl-shortener&env=NEXT_PUBLIC_SUPABASE_URL&env=NEXT_PUBLIC_SUPABASE_KEY&project-name=url-shortener&repository-name=url-shortener) 56 | 57 | Configure the following environment variables: 58 | 59 | - `NEXT_PUBLIC_SUPABASE_URL` 60 | - `NEXT_PUBLIC_SUPABASE_KEY` 61 | 62 | ### 2. Run Docker 63 | 64 | #### 2.1. Clone the Repository 65 | 66 | ```bash 67 | git clone https://github.com/harrisonwang/url-shortener.git 68 | ``` 69 | 70 | #### 2.2. Rename `.env.example` to `.env` and set environment variables 71 | 72 | ```bash 73 | mv .env.example .env 74 | ``` 75 | 76 | ```bash 77 | NEXT_PUBLIC_SUPABASE_URL= 78 | NEXT_PUBLIC_SUPABASE_KEY= 79 | ``` 80 | 81 | #### 2.3. Run Docker Container 82 | 83 | ```bash 84 | docker compose up -d 85 | ``` 86 | 87 | #### 2.4. Access the Application 88 | 89 | Open your browser and visit `http://localhost:3000`, you should be able to see the URL short link generator interface. 90 | 91 | ### 3. Run Locally 92 | 93 | #### 3.1. Clone the Repository 94 | 95 | ```bash 96 | git clone https://github.com/harrisonwang/url-shortener.git 97 | ``` 98 | 99 | #### 3.2. Install Dependencies 100 | 101 | ```bash 102 | npm i 103 | ``` 104 | 105 | #### 3.3. Rename `.env.example` to `.env` and set environment variables 106 | 107 | ```bash 108 | mv .env.example .env 109 | ``` 110 | 111 | ```bash 112 | NEXT_PUBLIC_SUPABASE_URL= 113 | NEXT_PUBLIC_SUPABASE_KEY= 114 | ``` 115 | 116 | #### 3.4. Run the Development Server 117 | 118 | ```bash 119 | npm run dev 120 | ``` 121 | -------------------------------------------------------------------------------- /.github/workflows/docker-push.yml: -------------------------------------------------------------------------------- 1 | name: "Build and Push Docker Image to Docker Hub" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - .gitignore 9 | - README.md 10 | - README.zh.md 11 | - LICENSE 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | env: 17 | DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} 18 | DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} 19 | IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/url-shortener 20 | 21 | # Allow one concurrent deployment 22 | concurrency: 23 | group: build-push-${{ github.head_ref || github.run_id }} 24 | cancel-in-progress: true 25 | 26 | jobs: 27 | build: 28 | runs-on: ubuntu-latest 29 | strategy: 30 | matrix: 31 | platform: [linux/amd64, linux/arm64] 32 | steps: 33 | - name: Checkout code 34 | uses: actions/checkout@v4 35 | 36 | - name: Prepare 37 | run: | 38 | platform=${{ matrix.platform }} 39 | echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV 40 | 41 | - name: Set up QEMU 42 | uses: docker/setup-qemu-action@v3 43 | 44 | - name: Set up Docker Buildx 45 | uses: docker/setup-buildx-action@v3 46 | 47 | - name: Login to DockerHub 48 | uses: docker/login-action@v2 49 | with: 50 | username: ${{ env.DOCKERHUB_USERNAME }} 51 | password: ${{ env.DOCKERHUB_TOKEN }} 52 | 53 | - name: Extract Docker metadata 54 | id: meta 55 | uses: docker/metadata-action@v5 56 | with: 57 | images: ${{ env.IMAGE_NAME }} 58 | tags: | 59 | type=raw,value=latest 60 | type=sha,enable=true,priority=100,prefix=,suffix=,format=long 61 | 62 | - name: Build Docker image 63 | id: build 64 | uses: docker/build-push-action@v5 65 | with: 66 | context: . 67 | file: ./Dockerfile 68 | platforms: ${{ matrix.platform }} 69 | labels: ${{ steps.meta.outputs.labels }} 70 | outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true 71 | cache-from: type=gha,scope=${{ matrix.platform }} 72 | cache-to: type=gha,mode=max,scope=${{ matrix.platform }} 73 | 74 | - name: Export digest 75 | run: | 76 | mkdir -p /tmp/digests 77 | digest="${{ steps.build.outputs.digest }}" 78 | touch "/tmp/digests/${digest#sha256:}" 79 | 80 | - name: Upload digest 81 | uses: actions/upload-artifact@v4 82 | with: 83 | name: digests-${{ env.PLATFORM_PAIR }} 84 | path: /tmp/digests/* 85 | if-no-files-found: error 86 | retention-days: 1 87 | 88 | create-manifest: 89 | needs: build 90 | runs-on: ubuntu-latest 91 | steps: 92 | - name: Download digests 93 | uses: actions/download-artifact@v4 94 | with: 95 | path: /tmp/digests 96 | pattern: digests-* 97 | merge-multiple: true 98 | 99 | - name: Login to DockerHub 100 | uses: docker/login-action@v2 101 | with: 102 | username: ${{ env.DOCKERHUB_USERNAME }} 103 | password: ${{ env.DOCKERHUB_TOKEN }} 104 | 105 | - name: Extract Docker metadata 106 | id: meta 107 | uses: docker/metadata-action@v5 108 | with: 109 | images: ${{ env.IMAGE_NAME }} 110 | tags: | 111 | type=raw,value=latest 112 | type=sha,enable=true,priority=100,prefix=,suffix=,format=long 113 | 114 | - name: Create manifest list and push 115 | working-directory: /tmp/digests 116 | run: | 117 | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ 118 | $(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *) 119 | 120 | - name: Inspect image 121 | run: | 122 | docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react'; 4 | 5 | interface ShortLink { 6 | originalUrl: string; 7 | shortUrl: string; 8 | id: string; // Add an id field for each link 9 | } 10 | 11 | export default function ShortenPage() { 12 | const [url, setUrl] = useState(''); 13 | const [customSlug, setCustomSlug] = useState(''); 14 | const [shortUrl, setShortUrl] = useState(''); 15 | const [isLoading, setIsLoading] = useState(false); 16 | const [error, setError] = useState(''); 17 | const [copiedId, setCopiedId] = useState(null); 18 | const [history, setHistory] = useState([]); 19 | 20 | const handleSubmit = async (e: React.FormEvent) => { 21 | e.preventDefault(); 22 | setIsLoading(true); 23 | setError(''); 24 | setShortUrl(''); 25 | setCopiedId(null); 26 | 27 | // Add validation for custom slug 28 | if (customSlug && !/^[a-zA-Z0-9]+$/.test(customSlug)) { 29 | setError('Custom short link can only contain letters and numbers'); 30 | setIsLoading(false); 31 | return; 32 | } 33 | 34 | try { 35 | const response = await fetch('/api', { 36 | method: 'POST', 37 | headers: { 38 | 'Content-Type': 'application/json', 39 | }, 40 | body: JSON.stringify({ url, customSlug }), 41 | }); 42 | 43 | const data = await response.json(); 44 | 45 | if (!response.ok) { 46 | throw new Error(data.error || 'Failed to create short link'); 47 | } 48 | 49 | setShortUrl(data.link); 50 | setHistory(prevHistory => [{ 51 | originalUrl: url, 52 | shortUrl: data.link, 53 | id: Date.now().toString() // Use timestamp as a simple unique id 54 | }, ...prevHistory]); 55 | 56 | // Clear input fields 57 | setUrl(''); 58 | setCustomSlug(''); 59 | } catch (err) { 60 | setError(err instanceof Error ? err.message : 'Error creating short link, please try again later'); 61 | } finally { 62 | setIsLoading(false); 63 | } 64 | }; 65 | 66 | const handleCopy = async (textToCopy: string, id: string) => { 67 | try { 68 | await navigator.clipboard.writeText(textToCopy); 69 | setCopiedId(id); 70 | setTimeout(() => setCopiedId(null), 2000); 71 | } catch (err) { 72 | console.error('Copy failed:', err); 73 | } 74 | }; 75 | 76 | const handleClear = () => { 77 | setUrl(''); 78 | }; 79 | 80 | const handleClearCustomSlug = () => { 81 | setCustomSlug(''); 82 | }; 83 | 84 | const handleClearHistory = () => { 85 | setHistory([]); 86 | }; 87 | 88 | return ( 89 |
90 |
91 |

Free URL Shortener

92 |

Shorten your URLs with our free and easy-to-use tool for URL shortening. 100% automatically and free.

93 |
94 |
95 |

Create Short URL

96 |
97 |
98 | 101 |
102 | setUrl(e.target.value)} 107 | required 108 | className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" 109 | placeholder="https://example.com" 110 | /> 111 | {url && ( 112 | 121 | )} 122 |
123 |
124 |
125 | 128 |
129 | setCustomSlug(e.target.value.replace(/[^a-zA-Z0-9]/g, ''))} 134 | className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" 135 | placeholder="my-custom-slug" 136 | /> 137 | {customSlug && ( 138 | 147 | )} 148 |
149 |
150 | Custom short link can only contain letters and numbers 151 |
152 |
153 | 160 |
161 | {error &&

{error}

} 162 | {shortUrl && ( 163 |
164 |

Generated Short URL:

165 |
166 | 167 | {shortUrl} 168 | 169 | 175 |
176 |
177 | )} 178 | {history.length > 0 && ( 179 |
180 |
181 |

History

182 | 188 |
189 |
    190 | {history.map((link) => ( 191 |
  • 192 | 193 | {link.shortUrl} 194 | 195 | 201 |
  • 202 | ))} 203 |
204 |
205 | )} 206 |
207 |
208 | ); 209 | } -------------------------------------------------------------------------------- /public/factory.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 17 | 35 | 40 | 41 | --------------------------------------------------------------------------------