├── drizzle ├── 0006_melodic_electro.sql ├── 0009_outstanding_loki.sql ├── 0008_exotic_omega_flight.sql ├── 0007_broken_firebird.sql ├── 0010_hard_odin.sql ├── 0011_foamy_shiver_man.sql ├── 0001_handy_barracuda.sql ├── 0003_empty_ted_forrester.sql ├── 0005_late_lady_mastermind.sql ├── 0000_violet_jane_foster.sql ├── 0004_orange_gorilla_man.sql ├── 0002_ancient_roxanne_simpson.sql └── meta │ ├── _journal.json │ ├── 0000_snapshot.json │ ├── 0001_snapshot.json │ ├── 0002_snapshot.json │ └── 0006_snapshot.json ├── src ├── server │ ├── middlewares │ │ ├── index.ts │ │ └── linkMiddleware.ts │ ├── redis.ts │ ├── db │ │ ├── index.ts │ │ └── schema.ts │ ├── api │ │ ├── user.ts │ │ ├── user-link.ts │ │ └── link.ts │ ├── auth.ts │ └── actions │ │ └── link.ts ├── app │ ├── favicon.ico │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ └── cron │ │ │ └── route.ts │ ├── layout.tsx │ └── page.tsx ├── lib │ ├── config.ts │ ├── safe-action.ts │ ├── validations │ │ └── link.tsx │ └── utils.ts ├── types │ └── index.ts ├── components │ ├── theme-provider.tsx │ ├── providers.tsx │ ├── links │ │ ├── custom-link.tsx │ │ ├── link-copy-button.tsx │ │ ├── custom-link-button.tsx │ │ ├── delete-link-dialog.tsx │ │ ├── link-list.tsx │ │ ├── custom-link-dialog.tsx │ │ ├── link-form.tsx │ │ ├── link-options-dropdown.tsx │ │ ├── link-card.tsx │ │ ├── link-qrcode-dialog.tsx │ │ └── custom-link-form.tsx │ ├── layout │ │ ├── footer.tsx │ │ └── header.tsx │ ├── ui │ │ ├── sonner.tsx │ │ ├── textarea.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── separator.tsx │ │ ├── protected-element.tsx │ │ ├── heading.tsx │ │ ├── tooltip.tsx │ │ ├── loader.tsx │ │ ├── avatar.tsx │ │ ├── card.tsx │ │ ├── button.tsx │ │ ├── drawer.tsx │ │ ├── responsive-dialog.tsx │ │ ├── dialog.tsx │ │ ├── form.tsx │ │ ├── alert-dialog.tsx │ │ ├── icons.tsx │ │ └── dropdown-menu.tsx │ ├── auth │ │ ├── user-profile.tsx │ │ ├── oauth-provider-button.tsx │ │ ├── signin-dialog.tsx │ │ ├── user-profile-dialog.tsx │ │ └── user-profile-dropdown.tsx │ └── theme-toggle.tsx ├── hooks │ ├── use-media-query.tsx │ └── use-debounce.tsx ├── middleware.ts ├── styles │ └── globals.css └── env.js ├── public ├── images │ └── screenshot.png ├── vercel.svg └── next.svg ├── postcss.config.cjs ├── vercel.json ├── renovate.json ├── drizzle.config.ts ├── .env.example ├── components.json ├── .vscode └── settings.json ├── next.config.js ├── .gitignore ├── prettier.config.js ├── tsconfig.json ├── LICENSE ├── .eslintrc.cjs ├── .github └── workflows │ └── main.yml ├── README.md ├── package.json └── tailwind.config.ts /drizzle/0006_melodic_electro.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `link` DROP COLUMN `title`; -------------------------------------------------------------------------------- /drizzle/0009_outstanding_loki.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `link` RENAME COLUMN `views` TO `clicks`; -------------------------------------------------------------------------------- /src/server/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export { linkMiddleware } from "./linkMiddleware"; 2 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mehrabmp/cut-it/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /drizzle/0008_exotic_omega_flight.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE userLink ADD `total_links` integer DEFAULT 0 NOT NULL; -------------------------------------------------------------------------------- /public/images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mehrabmp/cut-it/HEAD/public/images/screenshot.png -------------------------------------------------------------------------------- /src/server/redis.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from "@upstash/redis"; 2 | 3 | export const redis = Redis.fromEnv(); 4 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "crons": [ 3 | { 4 | "path": "/api/cron", 5 | "schedule": "0 0 * * *" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/config.ts: -------------------------------------------------------------------------------- 1 | export const GUEST_LINK_EXPIRE_TIME = 60 * 60 * 24; // 1 day 2 | export const GUEST_LINK_COOKIE_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 30; // 30 days 3 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from "~/server/auth"; 2 | import NextAuth from "next-auth"; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 5 | const handler = NextAuth(authOptions); 6 | export { handler as GET, handler as POST }; 7 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { type User, type UserLink } from "~/server/db/schema"; 2 | 3 | export type UserWithLink = User & { userLink?: UserLink }; 4 | 5 | export type SafeActionError = { 6 | serverError?: string; 7 | fetchError?: string; 8 | validationErrors?: Record; 9 | }; 10 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { env } from "~/env"; 2 | import type { Config } from "drizzle-kit"; 3 | 4 | export default { 5 | schema: "./src/server/db/schema.ts", 6 | out: "./drizzle", 7 | driver: "turso", 8 | dbCredentials: { 9 | url: env.TURSO_URL, 10 | authToken: env.TURSO_AUTH_TOKEN, 11 | }, 12 | verbose: true, 13 | strict: true, 14 | } satisfies Config; 15 | -------------------------------------------------------------------------------- /src/server/db/index.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@libsql/client/web"; 2 | import { env } from "~/env"; 3 | import { drizzle } from "drizzle-orm/libsql"; 4 | 5 | import * as schema from "./schema"; 6 | 7 | const client = createClient({ 8 | url: env.TURSO_URL, 9 | authToken: env.TURSO_AUTH_TOKEN, 10 | }); 11 | 12 | export const db = drizzle(client, { schema, logger: true }); 13 | -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { type ThemeProviderProps } from "next-themes/dist/types"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | TURSO_URL= 2 | TURSO_AUTH_TOKEN= 3 | UPSTASH_REDIS_REST_URL= 4 | UPSTASH_REDIS_REST_TOKEN= 5 | NEXTAUTH_URL="http://localhost:3000" 6 | # openssl rand -base64 32 7 | NEXTAUTH_SECRET= 8 | GITHUB_ID= 9 | GITHUB_SECRET= 10 | GOOGLE_CLIENT_ID= 11 | GOOGLE_CLIENT_SECRET= 12 | # set to your domain in production enviorment. 13 | DOMAIN_URL="https://cutit.vercel.app" 14 | # cron job secret 15 | CRON_SECRET= -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "./src/styles/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "~/components", 14 | "utils": "~/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { TooltipProvider } from "~/components/ui/tooltip"; 4 | import { ThemeProvider } from "~/components/theme-provider"; 5 | 6 | export const Providers = ({ children }: { children: React.ReactNode }) => { 7 | return ( 8 | 9 | {children} 10 | 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /drizzle/0007_broken_firebird.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE account ADD `created_at` integer DEFAULT (strftime('%s', 'now')) NOT NULL;--> statement-breakpoint 2 | ALTER TABLE session ADD `created_at` integer DEFAULT (strftime('%s', 'now')) NOT NULL;--> statement-breakpoint 3 | ALTER TABLE user ADD `created_at` integer DEFAULT (strftime('%s', 'now')) NOT NULL;--> statement-breakpoint 4 | ALTER TABLE verificationToken ADD `created_at` integer DEFAULT (strftime('%s', 'now')) NOT NULL; -------------------------------------------------------------------------------- /src/server/api/user.ts: -------------------------------------------------------------------------------- 1 | import { db } from "~/server/db"; 2 | import { users } from "~/server/db/schema"; 3 | import { eq } from "drizzle-orm"; 4 | import { type UserWithLink } from "~/types"; 5 | 6 | export async function getUserById( 7 | id: string, 8 | ): Promise { 9 | const user = await db.query.users.findFirst({ 10 | where: eq(users.id, id), 11 | with: { 12 | userLink: true, 13 | }, 14 | }); 15 | return user; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/links/custom-link.tsx: -------------------------------------------------------------------------------- 1 | import { getServerAuthSession } from "~/server/auth"; 2 | 3 | import { CustomLinkButton } from "~/components/links/custom-link-button"; 4 | import { CustomLinkDialog } from "~/components/links/custom-link-dialog"; 5 | 6 | export const CustomLink = async () => { 7 | const session = await getServerAuthSession(); 8 | 9 | if (!session) { 10 | return ; 11 | } 12 | 13 | return ; 14 | }; 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "explicit" 6 | }, 7 | "editor.formatOnSave": true, 8 | "editor.defaultFormatter": "esbenp.prettier-vscode", 9 | "tailwindCSS.experimental.classRegex": [ 10 | ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], 11 | ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/components/layout/footer.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | export const Footer = () => { 4 | return ( 5 |
6 | {/* Built by {" "} 7 | 12 | Mehrab. 13 | */} 14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /drizzle/0010_hard_odin.sql: -------------------------------------------------------------------------------- 1 | /* 2 | SQLite does not support "Changing existing column type" out of the box, we do not generate automatic migration for that, so it has to be done manually 3 | Please refer to: https://www.techonthenet.com/sqlite/tables/alter_table.php 4 | https://www.sqlite.org/lang_altertable.html 5 | https://stackoverflow.com/questions/2083543/modify-a-columns-type-in-sqlite3 6 | 7 | Due to that we don't generate migration automatically and it has to be done manually 8 | */ -------------------------------------------------------------------------------- /drizzle/0011_foamy_shiver_man.sql: -------------------------------------------------------------------------------- 1 | /* 2 | SQLite does not support "Changing existing column type" out of the box, we do not generate automatic migration for that, so it has to be done manually 3 | Please refer to: https://www.techonthenet.com/sqlite/tables/alter_table.php 4 | https://www.sqlite.org/lang_altertable.html 5 | https://stackoverflow.com/questions/2083543/modify-a-columns-type-in-sqlite3 6 | 7 | Due to that we don't generate migration automatically and it has to be done manually 8 | */ -------------------------------------------------------------------------------- /src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTheme } from "next-themes"; 4 | import { Toaster as Sonner } from "sonner"; 5 | 6 | type ToasterProps = React.ComponentProps; 7 | 8 | const Toaster = ({ ...props }: ToasterProps) => { 9 | const { theme = "system" } = useTheme(); 10 | 11 | return ( 12 | 18 | ); 19 | }; 20 | 21 | export { Toaster }; 22 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful 3 | * for Docker builds. 4 | */ 5 | await import("./src/env.js"); 6 | 7 | /** @type {import("next").NextConfig} */ 8 | const config = { 9 | logging: { 10 | fetches: { 11 | fullUrl: true, 12 | }, 13 | }, 14 | experimental: { 15 | ppr: true, 16 | }, 17 | images: { 18 | remotePatterns: [{ protocol: "https", hostname: "t3.gstatic.com" }], 19 | }, 20 | }; 21 | 22 | export default config; 23 | -------------------------------------------------------------------------------- /.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 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | .vercel 39 | -------------------------------------------------------------------------------- /src/hooks/use-media-query.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export function useMediaQuery(query: string) { 4 | const [value, setValue] = React.useState(false); 5 | 6 | React.useEffect(() => { 7 | function onChange(event: MediaQueryListEvent) { 8 | setValue(event.matches); 9 | } 10 | 11 | const result = matchMedia(query); 12 | result.addEventListener("change", onChange); 13 | setValue(result.matches); 14 | 15 | return () => result.removeEventListener("change", onChange); 16 | }, [query]); 17 | 18 | return value; 19 | } 20 | -------------------------------------------------------------------------------- /drizzle/0001_handy_barracuda.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `links` RENAME COLUMN `view_count` TO `views`;--> statement-breakpoint 2 | /* 3 | SQLite does not support "Set not null to column" out of the box, we do not generate automatic migration for that, so it has to be done manually 4 | Please refer to: https://www.techonthenet.com/sqlite/tables/alter_table.php 5 | https://www.sqlite.org/lang_altertable.html 6 | https://stackoverflow.com/questions/2083543/modify-a-columns-type-in-sqlite3 7 | 8 | Due to that we don't generate migration automatically and it has to be done manually 9 | */ -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/api/cron/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, type NextRequest } from "next/server"; 2 | import { deleteExpiredLinks } from "~/server/api/link"; 3 | import { deleteExpiredUserLinks } from "~/server/api/user-link"; 4 | 5 | function isAuthorized(req: NextRequest): boolean { 6 | return ( 7 | req.headers.get("Authorization") === `Bearer ${process.env.CRON_SECRET}` 8 | ); 9 | } 10 | 11 | export async function GET(req: NextRequest) { 12 | if (!isAuthorized(req)) { 13 | return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); 14 | } 15 | 16 | await deleteExpiredUserLinks(); 17 | await deleteExpiredLinks(); 18 | 19 | return NextResponse.json({ message: "success" }); 20 | } 21 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from "next/server"; 2 | import { linkMiddleware } from "~/server/middlewares"; 3 | 4 | export function middleware(request: NextRequest) { 5 | return linkMiddleware(request); 6 | } 7 | 8 | export const config = { 9 | matcher: [ 10 | /* 11 | * Match all paths except for: 12 | * 1. /api/ routes 13 | * 2. /_next/ (Next.js internals) 14 | * 3. /_proxy/ (special page for OG tags proxying) 15 | * 4. /_static (inside /public) 16 | * 5. /_vercel (Vercel internals) 17 | * 6. Static files (e.g. /favicon.ico, /sitemap.xml, /robots.txt, etc.) 18 | */ 19 | "/((?!api/|_next/|_proxy/|_static|_vercel|[\\w-]+\\.\\w+).*)", 20 | ], 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "~/lib/utils"; 4 | 5 | const Textarea = React.forwardRef< 6 | React.ElementRef<"textarea">, 7 | React.ComponentPropsWithoutRef<"textarea"> 8 | >(({ className, ...props }, ref) => { 9 | return ( 10 |