├── public
├── profilee-b.png
├── drag_and_drop.webp
├── avatar.svg
└── profilee.svg
├── .vscode
└── settings.json
├── src
├── lib
│ ├── constants.ts
│ ├── utils.ts
│ ├── posthog-provider.tsx
│ └── routes.ts
├── app
│ ├── auth
│ │ ├── login
│ │ │ └── page.tsx
│ │ └── register
│ │ │ └── page.tsx
│ ├── (main)
│ │ ├── builder
│ │ │ ├── page.tsx
│ │ │ ├── links
│ │ │ │ └── page.tsx
│ │ │ ├── _components
│ │ │ │ ├── links
│ │ │ │ │ ├── adhoc-links
│ │ │ │ │ │ ├── toggle-group-items
│ │ │ │ │ │ │ ├── edit-link.tsx
│ │ │ │ │ │ │ └── edit-appearance.tsx
│ │ │ │ │ │ └── adhoc-links.tsx
│ │ │ │ │ └── social-links
│ │ │ │ │ │ ├── social-icon.tsx
│ │ │ │ │ │ ├── social-icons-section.tsx
│ │ │ │ │ │ └── social-icon-drag.tsx
│ │ │ │ ├── appearance
│ │ │ │ │ ├── profile-section
│ │ │ │ │ │ ├── dropzone.tsx
│ │ │ │ │ │ ├── set-canvas-preview.tsx
│ │ │ │ │ │ ├── edit-bio-title.tsx
│ │ │ │ │ │ └── profile-section.tsx
│ │ │ │ │ └── general-appearance-setting
│ │ │ │ │ │ └── general-appearance-setting.tsx
│ │ │ │ ├── popovers
│ │ │ │ │ └── social-icons-popover.tsx
│ │ │ │ ├── dialogs
│ │ │ │ │ ├── social-icons-dialog.tsx
│ │ │ │ │ ├── adhoc-links-dialog.tsx
│ │ │ │ │ └── image-crop-dialog.tsx
│ │ │ │ ├── navbar.tsx
│ │ │ │ ├── page-elements.tsx
│ │ │ │ ├── drag-overlay-wrapper.tsx
│ │ │ │ ├── elements
│ │ │ │ │ └── username.tsx
│ │ │ │ └── preview
│ │ │ │ │ ├── webpage.tsx
│ │ │ │ │ └── preview.tsx
│ │ │ ├── layout.tsx
│ │ │ └── appearance
│ │ │ │ └── page.tsx
│ │ └── claim
│ │ │ └── username
│ │ │ └── page.tsx
│ ├── api
│ │ ├── auth
│ │ │ └── [...nextauth]
│ │ │ │ └── route.ts
│ │ └── trpc
│ │ │ └── [trpc]
│ │ │ └── route.ts
│ ├── (landing)
│ │ ├── _components
│ │ │ ├── landing-page.tsx
│ │ │ ├── desktop-navbar.tsx
│ │ │ ├── mobile-menu-navbar.tsx
│ │ │ ├── navbar.tsx
│ │ │ └── hero.tsx
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── [link]
│ │ ├── page.tsx
│ │ └── _components
│ │ │ └── LinkClient.tsx
│ └── layout.tsx
├── components
│ ├── context
│ │ ├── client-provider.tsx
│ │ └── designer-context-action.tsx
│ ├── ui
│ │ ├── collapsible.tsx
│ │ ├── loading-spinner.tsx
│ │ ├── label.tsx
│ │ ├── textarea.tsx
│ │ ├── input.tsx
│ │ ├── separator.tsx
│ │ ├── progress.tsx
│ │ ├── sonner.tsx
│ │ ├── switch.tsx
│ │ ├── tooltip.tsx
│ │ ├── popover.tsx
│ │ ├── toggle.tsx
│ │ ├── alert.tsx
│ │ ├── toggle-group.tsx
│ │ ├── button.tsx
│ │ ├── tabs.tsx
│ │ ├── card.tsx
│ │ ├── accordion.tsx
│ │ ├── dialog.tsx
│ │ └── form.tsx
│ ├── tooltip-icon-text.tsx
│ ├── logo.tsx
│ ├── PostHogPageView.tsx
│ ├── error.tsx
│ ├── not-found.tsx
│ ├── font-picker.tsx
│ ├── footer.tsx
│ └── auth
│ │ ├── auth-pages-wrapper.tsx
│ │ ├── login-account-card.tsx
│ │ └── create-account-card.tsx
├── hooks
│ ├── use-designer.tsx
│ ├── use-scroll-top.tsx
│ └── use-hex-to-rgba.tsx
├── server
│ ├── db.ts
│ ├── api
│ │ ├── root.ts
│ │ ├── routers
│ │ │ ├── social-links.ts
│ │ │ ├── images.ts
│ │ │ ├── user-profile.ts
│ │ │ ├── general-appearance.ts
│ │ │ ├── user.ts
│ │ │ └── adhoc-links.ts
│ │ ├── utils
│ │ │ └── user.ts
│ │ ├── schemas
│ │ │ └── index.ts
│ │ └── trpc.ts
│ └── auth.ts
├── trpc
│ ├── server.ts
│ ├── shared.ts
│ └── react.tsx
├── middleware.ts
├── types
│ └── types.ts
├── env.mjs
└── styles
│ └── globals.css
├── postcss.config.cjs
├── prisma
└── migrations
│ ├── 20240810085606_update_links
│ └── migration.sql
│ ├── 20240810134346_changed_default_count_to_1
│ └── migration.sql
│ ├── migration_lock.toml
│ ├── 20240810090250_added_count_in_the_link_analytics_model
│ └── migration.sql
│ ├── 20240814075617_updated_userprofile_schema
│ └── migration.sql
│ ├── 20240810133852_
│ └── migration.sql
│ ├── 20240816091501_type
│ └── migration.sql
│ ├── 20240810090721_
│ └── migration.sql
│ ├── 20240816091405_updated_typo
│ └── migration.sql
│ ├── 20240807090436_revert_the_link_theme_as_data_will_be_json
│ └── migration.sql
│ ├── 20240809091347_test
│ └── migration.sql
│ ├── 20240814172941_added_the_general_appearance_modal
│ └── migration.sql
│ ├── 20240818173248_changed_optional_to_required
│ └── migration.sql
│ └── 20240806065746_new_profille
│ └── migration.sql
├── .prettierrc
├── prettier.config.mjs
├── components.json
├── next.config.mjs
├── .gitignore
├── tsconfig.json
├── .env.example
├── README.md
├── .eslintrc.cjs
├── tailwind.config.ts
├── package.json
└── todo.js
/public/profilee-b.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mittalsam98/profilee/HEAD/public/profilee-b.png
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "git.ignoreLimitWarning": true,
3 | "cSpell.words": ["Profilee"]
4 | }
5 |
--------------------------------------------------------------------------------
/public/drag_and_drop.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mittalsam98/profilee/HEAD/public/drag_and_drop.webp
--------------------------------------------------------------------------------
/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export const DEFAULT_LOGIN_REDIRECT = '/';
2 | export const OAUTH_REDIRECT = '/claim/username';
3 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
8 | module.exports = config;
9 |
--------------------------------------------------------------------------------
/prisma/migrations/20240810085606_update_links/migration.sql:
--------------------------------------------------------------------------------
1 | -- DropForeignKey
2 | ALTER TABLE "LinkAnalytics" DROP CONSTRAINT "LinkAnalytics_adhocLinkId_fkey";
3 |
--------------------------------------------------------------------------------
/prisma/migrations/20240810134346_changed_default_count_to_1/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "LinkAnalytics" ALTER COLUMN "count" SET DEFAULT 1;
3 |
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "jsxSingleQuote": true,
4 | "printWidth": 100,
5 | "trailingComma": "none",
6 | "tabWidth": 2,
7 | "semi": true
8 | }
9 |
--------------------------------------------------------------------------------
/prisma/migrations/20240810090250_added_count_in_the_link_analytics_model/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "LinkAnalytics" ADD COLUMN "count" INTEGER NOT NULL DEFAULT 0;
3 |
--------------------------------------------------------------------------------
/src/app/auth/login/page.tsx:
--------------------------------------------------------------------------------
1 | import LoginAccountCard from '@/components/auth/login-account-card';
2 |
3 | export default function Login() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/auth/register/page.tsx:
--------------------------------------------------------------------------------
1 | import { CreateAccountCard } from '@/components/auth/create-account-card';
2 |
3 | export default function Register() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'next/navigation';
2 |
3 | export default function Builder() {
4 | redirect(`/builder/appearance`); // Navigate to the new post page
5 | }
6 |
--------------------------------------------------------------------------------
/prettier.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').options} */
2 | const config = {
3 | plugins: ["prettier-plugin-tailwindcss"],
4 | };
5 |
6 | export default config;
7 |
--------------------------------------------------------------------------------
/src/app/api/auth/[...nextauth]/route.ts:
--------------------------------------------------------------------------------
1 | import NextAuth from 'next-auth';
2 |
3 | import { authOptions } from '@/server/auth';
4 |
5 | const handler = NextAuth(authOptions);
6 | export { handler as GET, handler as POST };
7 |
--------------------------------------------------------------------------------
/prisma/migrations/20240814075617_updated_userprofile_schema/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "UserProfile" ADD COLUMN "bioColor" TEXT,
3 | ADD COLUMN "bioFontSize" TEXT,
4 | ADD COLUMN "picBorder" TEXT,
5 | ADD COLUMN "titleColor" TEXT,
6 | ADD COLUMN "titleFontSize" TEXT;
7 |
--------------------------------------------------------------------------------
/prisma/migrations/20240810133852_/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - A unique constraint covering the columns `[adhocLinkId]` on the table `LinkAnalytics` will be added. If there are existing duplicate values, this will fail.
5 |
6 | */
7 | -- CreateIndex
8 | CREATE UNIQUE INDEX "LinkAnalytics_adhocLinkId_key" ON "LinkAnalytics"("adhocLinkId");
9 |
--------------------------------------------------------------------------------
/prisma/migrations/20240816091501_type/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `linkCardSahdow` on the `GeneralAppearance` table. All the data in the column will be lost.
5 |
6 | */
7 | -- AlterTable
8 | ALTER TABLE "GeneralAppearance" DROP COLUMN "linkCardSahdow",
9 | ADD COLUMN "linkCardShadow" TEXT;
10 |
--------------------------------------------------------------------------------
/src/components/context/client-provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { SessionProvider } from 'next-auth/react';
4 |
5 | export default function Provider({
6 | children,
7 | session
8 | }: {
9 | children: React.ReactNode;
10 | session: any;
11 | }): React.ReactNode {
12 | return {children} ;
13 | }
14 |
--------------------------------------------------------------------------------
/prisma/migrations/20240810090721_/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - A unique constraint covering the columns `[userId,adhocLinkId]` on the table `LinkAnalytics` will be added. If there are existing duplicate values, this will fail.
5 |
6 | */
7 | -- CreateIndex
8 | CREATE UNIQUE INDEX "LinkAnalytics_userId_adhocLinkId_key" ON "LinkAnalytics"("userId", "adhocLinkId");
9 |
--------------------------------------------------------------------------------
/prisma/migrations/20240816091405_updated_typo/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `useSecondaryBackfround` on the `GeneralAppearance` table. All the data in the column will be lost.
5 |
6 | */
7 | -- AlterTable
8 | ALTER TABLE "GeneralAppearance" DROP COLUMN "useSecondaryBackfround",
9 | ADD COLUMN "useSecondaryBackground" BOOLEAN DEFAULT false;
10 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/styles/globals.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
--------------------------------------------------------------------------------
/src/components/ui/collapsible.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
4 |
5 | const Collapsible = CollapsiblePrimitive.Root;
6 |
7 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
8 |
9 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
10 |
11 | export { Collapsible, CollapsibleTrigger, CollapsibleContent };
12 |
--------------------------------------------------------------------------------
/src/hooks/use-designer.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { DesignerContext } from '@/components/context/designer-context';
4 | import { useContext } from 'react';
5 |
6 | function useDesigner() {
7 | const context = useContext(DesignerContext);
8 |
9 | if (!context) {
10 | throw new Error('useDesigner must be used within a DesignerContext');
11 | }
12 |
13 | return context;
14 | }
15 |
16 | export default useDesigner;
17 |
--------------------------------------------------------------------------------
/src/server/db.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client';
2 |
3 | import { env } from '@/env.mjs';
4 |
5 | const globalForPrisma = globalThis as unknown as {
6 | prisma: PrismaClient | undefined;
7 | };
8 |
9 | export const db =
10 | globalForPrisma.prisma ??
11 | new PrismaClient({
12 | log: env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error']
13 | });
14 |
15 | if (env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
16 |
--------------------------------------------------------------------------------
/prisma/migrations/20240807090436_revert_the_link_theme_as_data_will_be_json/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the `LinkTheme` table. If the table is not empty, all the data it contains will be lost.
5 |
6 | */
7 | -- DropForeignKey
8 | ALTER TABLE "LinkTheme" DROP CONSTRAINT "LinkTheme_adhocLinkId_fkey";
9 |
10 | -- DropTable
11 | DROP TABLE "LinkTheme";
12 |
13 | -- DropEnum
14 | DROP TYPE "PropertSize";
15 |
16 | -- DropEnum
17 | DROP TYPE "TextAlign";
18 |
--------------------------------------------------------------------------------
/src/app/(landing)/_components/landing-page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/ui/button';
2 | import Image from 'next/image';
3 | import Link from 'next/link';
4 | import { RiGithubLine } from 'react-icons/ri';
5 | import PricingSection from './pricing-section';
6 | import Hero from './hero';
7 |
8 | export default function LandingPage() {
9 | return (
10 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
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.mjs');
6 |
7 | /** @type {import("next").NextConfig} */
8 | const config = {
9 | images: {
10 | remotePatterns: [
11 | {
12 | protocol: 'https',
13 | hostname: '**.amazonaws.com',
14 | port: '',
15 | pathname: '/**'
16 | }
17 | ]
18 | }
19 | };
20 |
21 | export default config;
22 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/links/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { DndContext } from '@dnd-kit/core';
3 | import DragOverlayWrapper from '../_components/drag-overlay-wrapper';
4 | import SocialIconsSection from '../_components/links/social-links/social-icons-section';
5 | import AdhocLinks from '../_components/links/adhoc-links/adhoc-links';
6 |
7 | export default function Links() {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/(landing)/layout.tsx:
--------------------------------------------------------------------------------
1 | import Footer from '@/components/footer';
2 | import Navbar from './_components/navbar';
3 |
4 | const LandingPageLayout = ({ children }: { children: React.ReactNode }) => {
5 | return (
6 |
7 |
10 | {children}
11 |
12 |
13 | );
14 | };
15 |
16 | export default LandingPageLayout;
17 |
--------------------------------------------------------------------------------
/src/hooks/use-scroll-top.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 |
3 | export default function useScrollTop(threshold = 10) {
4 | const [scrolled, setScrolled] = useState(false);
5 |
6 | useEffect(() => {
7 | const handleScroll = () => {
8 | if (window.scrollY > threshold) {
9 | setScrolled(true);
10 | } else {
11 | setScrolled(false);
12 | }
13 | };
14 |
15 | window.addEventListener('scroll', handleScroll);
16 | return () => window.removeEventListener('scroll', handleScroll);
17 | }, [threshold]);
18 |
19 | return scrolled;
20 | }
21 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | export const formatBytes = (bytes: number, decimals = 2) => {
9 | if (!+bytes) return '0 Bytes';
10 |
11 | const k = 1024;
12 | const dm = decimals < 0 ? 0 : decimals;
13 | const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
14 |
15 | const i = Math.floor(Math.log(bytes) / Math.log(k));
16 |
17 | return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
18 | };
19 |
--------------------------------------------------------------------------------
/src/components/tooltip-icon-text.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode, useState } from 'react';
2 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
3 |
4 | const ToolTipForTextAndIcon = ({
5 | children,
6 | text,
7 | onClick
8 | }: {
9 | children: ReactNode;
10 | text: string;
11 | onClick?: () => void;
12 | }) => {
13 | return (
14 |
15 |
16 | {children}
17 | {text}
18 |
19 |
20 | );
21 | };
22 |
23 | export default ToolTipForTextAndIcon;
24 |
--------------------------------------------------------------------------------
/src/components/ui/loading-spinner.tsx:
--------------------------------------------------------------------------------
1 | export interface ISVGProps extends React.SVGProps {
2 | size?: number;
3 | className?: string;
4 | }
5 |
6 | export const LoadingSpinner = ({ size = 24, className, ...props }: ISVGProps) => {
7 | return (
8 |
21 |
22 |
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/src/lib/posthog-provider.tsx:
--------------------------------------------------------------------------------
1 | // app/providers.tsx
2 | 'use client';
3 | import posthog from 'posthog-js';
4 | import { PostHogProvider } from 'posthog-js/react';
5 |
6 | if (typeof window !== 'undefined') {
7 | posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
8 | api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
9 | person_profiles: 'identified_only',
10 | // loaded: (posthog) => {
11 | // if (process.env.NODE_ENV === 'development') posthog.debug();
12 | // },
13 | capture_pageview: false // Disable automatic pageview capture, as we capture manually
14 | });
15 | }
16 |
17 | export function PHProvider({ children }: { children: React.ReactNode }) {
18 | return {children} ;
19 | }
20 |
--------------------------------------------------------------------------------
/src/lib/routes.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * An array of routes that are accessible to the public
3 | * These routes do not require authentication
4 | * @type {string[]}
5 | */
6 | export const publicRoutes = ['/'];
7 |
8 | /**
9 | * An array of routes that are used for authentication
10 | * These routes will redirect logged in users to /settings
11 | * @type {string[]}
12 | */
13 | export const authRoutes = ['/auth/login', '/auth/register', '/auth/error'];
14 |
15 | /**
16 | * The prefix for API authentication routes
17 | * Routes that start with this prefix are used for API authentication purposes
18 | * @type {string}
19 | */
20 | export const apiAuthPrefix = '/api/auth';
21 |
22 | /**
23 | * The default redirect path after logging in
24 | * @type {string}
25 | */
26 |
--------------------------------------------------------------------------------
/.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 | # database
12 | /prisma/db.sqlite
13 | /prisma/db.sqlite-journal
14 |
15 | # next.js
16 | /.next/
17 | /out/
18 | next-env.d.ts
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # local env files
34 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
35 | .env
36 | .env*.local
37 |
38 | # vercel
39 | .vercel
40 |
41 | # typescript
42 | *.tsbuildinfo
43 |
--------------------------------------------------------------------------------
/src/app/api/trpc/[trpc]/route.ts:
--------------------------------------------------------------------------------
1 | import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
2 | import { type NextRequest } from 'next/server';
3 |
4 | import { env } from '@/env.mjs';
5 | import { appRouter } from '@/server/api/root';
6 | import { createTRPCContext } from '@/server/api/trpc';
7 |
8 | const handler = (req: NextRequest) =>
9 | fetchRequestHandler({
10 | endpoint: '/api/trpc',
11 | req,
12 | router: appRouter,
13 | createContext: () => createTRPCContext({ req }),
14 | onError:
15 | env.NODE_ENV === 'development'
16 | ? ({ path, error }) => {
17 | console.error(`❌ tRPC failed on ${path ?? ''}: ${error.message}`);
18 | }
19 | : undefined
20 | });
21 |
22 | export { handler as GET, handler as POST };
23 |
--------------------------------------------------------------------------------
/src/components/logo.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/lib/utils';
2 | import { Poppins } from 'next/font/google';
3 | import React from 'react';
4 | import Link from 'next/link';
5 | import Image from 'next/image';
6 |
7 | const font = Poppins({
8 | subsets: ['latin'],
9 | weight: ['400', '600']
10 | });
11 |
12 | type LogoProps = {
13 | height: number;
14 | width: number;
15 | className?: string;
16 | };
17 |
18 | export default function Logo({ height, width, className }: LogoProps) {
19 | return (
20 |
21 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/links/adhoc-links/toggle-group-items/edit-link.tsx:
--------------------------------------------------------------------------------
1 | import { LoadingSpinner } from '@/components/ui/loading-spinner';
2 | import { api } from '@/trpc/react';
3 |
4 | interface PropsTypes {
5 | adhocLinkId: string;
6 | }
7 |
8 | export default function EditLink({ adhocLinkId }: PropsTypes) {
9 | const { data, isFetching, isSuccess, isError, error } = api.adHocLink.getLinkAnalytics.useQuery({
10 | adhocLinkId: adhocLinkId
11 | });
12 | return isFetching ? (
13 |
14 | ) : (
15 |
16 |
19 |
{data?.count}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/PostHogPageView.tsx:
--------------------------------------------------------------------------------
1 | // app/PostHogPageView.tsx
2 | 'use client';
3 |
4 | import { usePathname, useSearchParams } from 'next/navigation';
5 | import { useEffect } from 'react';
6 | import { usePostHog } from 'posthog-js/react';
7 |
8 | export default function PostHogPageView(): null {
9 | const pathname = usePathname();
10 | const searchParams = useSearchParams();
11 | const posthog = usePostHog();
12 | useEffect(() => {
13 | // Track pageviews
14 | if (pathname && posthog) {
15 | let url = window.origin + pathname;
16 | if (searchParams.toString()) {
17 | url = url + `?${searchParams.toString()}`;
18 | }
19 | posthog.capture('$pageview', {
20 | $current_url: url
21 | });
22 | }
23 | }, [pathname, searchParams, posthog]);
24 |
25 | return null;
26 | }
27 |
--------------------------------------------------------------------------------
/src/hooks/use-hex-to-rgba.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 |
3 | // Function to convert hex to rgba with opacity
4 | const hexToRGBA = (hex: string, alpha: number) => {
5 | const bigint = parseInt(hex.replace(/^#/, ''), 16);
6 | const r = (bigint >> 16) & 255;
7 | const g = (bigint >> 8) & 255;
8 | const b = bigint & 255;
9 | return `rgba(${r}, ${g}, ${b}, ${alpha})`;
10 | };
11 |
12 | const useHexToRGBA = (hexColor: string, opacity: number) => {
13 | const [rgbaColor, setRGBAColor] = useState('');
14 |
15 | useEffect(() => {
16 | if (hexColor && opacity >= 0 && opacity <= 1) {
17 | const convertedColor = hexToRGBA(hexColor, opacity);
18 | setRGBAColor(convertedColor);
19 | }
20 | }, [hexColor, opacity]);
21 |
22 | return rgbaColor;
23 | };
24 |
25 | export default useHexToRGBA;
26 |
--------------------------------------------------------------------------------
/src/trpc/server.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createTRPCProxyClient,
3 | loggerLink,
4 | unstable_httpBatchStreamLink,
5 | } from "@trpc/client";
6 | import { headers } from "next/headers";
7 |
8 | import { type AppRouter } from "@/server/api/root";
9 | import { getUrl, transformer } from "./shared";
10 |
11 | export const api = createTRPCProxyClient({
12 | transformer,
13 | links: [
14 | loggerLink({
15 | enabled: (op) =>
16 | process.env.NODE_ENV === "development" ||
17 | (op.direction === "down" && op.result instanceof Error),
18 | }),
19 | unstable_httpBatchStreamLink({
20 | url: getUrl(),
21 | headers() {
22 | const heads = new Map(headers());
23 | heads.set("x-trpc-source", "rsc");
24 | return Object.fromEntries(heads);
25 | },
26 | }),
27 | ],
28 | });
29 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/prisma/migrations/20240809091347_test/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateEnum
2 | CREATE TYPE "EventType" AS ENUM ('CLICK', 'OPEN');
3 |
4 | -- CreateTable
5 | CREATE TABLE "LinkAnalytics" (
6 | "id" TEXT NOT NULL,
7 | "eventType" "EventType" NOT NULL,
8 | "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
9 | "ipAddress" TEXT,
10 | "userAgent" TEXT,
11 | "adhocLinkId" TEXT NOT NULL,
12 | "userId" TEXT,
13 |
14 | CONSTRAINT "LinkAnalytics_pkey" PRIMARY KEY ("id")
15 | );
16 |
17 | -- AddForeignKey
18 | ALTER TABLE "LinkAnalytics" ADD CONSTRAINT "LinkAnalytics_adhocLinkId_fkey" FOREIGN KEY ("adhocLinkId") REFERENCES "AdhocLink"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
19 |
20 | -- AddForeignKey
21 | ALTER TABLE "LinkAnalytics" ADD CONSTRAINT "LinkAnalytics_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
22 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | export interface TextareaProps extends React.TextareaHTMLAttributes {}
6 |
7 | const Textarea = React.forwardRef(
8 | ({ className, ...props }, ref) => {
9 | return (
10 |
18 | );
19 | }
20 | );
21 | Textarea.displayName = 'Textarea';
22 |
23 | export { Textarea };
24 |
--------------------------------------------------------------------------------
/src/app/(landing)/_components/desktop-navbar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { cn } from '@/lib/utils';
3 | import Link from 'next/link';
4 | import { usePathname } from 'next/navigation';
5 |
6 | const NavLink = ({ className, ...props }: React.ComponentProps) => {
7 | const pathname = usePathname();
8 | const isActive = pathname === props.href;
9 | return (
10 |
18 | );
19 | };
20 | export default function DesktopMenu() {
21 | return (
22 |
23 |
24 | Pricing
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/src/components/error.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Button } from '@/components/ui/button';
4 | import { TRPCError } from '@trpc/server';
5 | import Link from 'next/link';
6 | import { useEffect } from 'react';
7 |
8 | export default function Error({ error }: { error: string }) {
9 | return (
10 |
11 |
12 |
13 | {error ?? 'Sorry, something went wrong!'}
14 |
15 |
21 | Back
22 |
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | export interface InputProps extends React.InputHTMLAttributes {}
6 |
7 | const Input = React.forwardRef(
8 | ({ className, type, ...props }, ref) => {
9 | return (
10 |
19 | );
20 | }
21 | );
22 | Input.displayName = 'Input';
23 |
24 | export { Input };
25 |
--------------------------------------------------------------------------------
/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = "horizontal", decorative = true, ...props },
14 | ref
15 | ) => (
16 |
27 | )
28 | )
29 | Separator.displayName = SeparatorPrimitive.Root.displayName
30 |
31 | export { Separator }
32 |
--------------------------------------------------------------------------------
/src/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ProgressPrimitive from "@radix-ui/react-progress"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Progress = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, value, ...props }, ref) => (
12 |
20 |
24 |
25 | ))
26 | Progress.displayName = ProgressPrimitive.Root.displayName
27 |
28 | export { Progress }
29 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "checkJs": 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 | "noUncheckedIndexedAccess": true,
19 | "baseUrl": ".",
20 | "paths": {
21 | "@/*": ["./src/*"]
22 | },
23 | "plugins": [{ "name": "next" }]
24 | },
25 | "include": [
26 | ".eslintrc.cjs",
27 | "next-env.d.ts",
28 | "**/*.ts",
29 | "**/*.tsx",
30 | "**/*.cjs",
31 | "**/*.mjs",
32 | ".next/types/**/*.ts"
33 | ],
34 | "exclude": ["node_modules"]
35 | }
36 |
--------------------------------------------------------------------------------
/public/avatar.svg:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
17 |
--------------------------------------------------------------------------------
/src/trpc/shared.ts:
--------------------------------------------------------------------------------
1 | import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
2 | import superjson from "superjson";
3 |
4 | import { type AppRouter } from "@/server/api/root";
5 |
6 | export const transformer = superjson;
7 |
8 | function getBaseUrl() {
9 | if (typeof window !== "undefined") return "";
10 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
11 | return `http://localhost:${process.env.PORT ?? 3000}`;
12 | }
13 |
14 | export function getUrl() {
15 | return getBaseUrl() + "/api/trpc";
16 | }
17 |
18 | /**
19 | * Inference helper for inputs.
20 | *
21 | * @example type HelloInput = RouterInputs['example']['hello']
22 | */
23 | export type RouterInputs = inferRouterInputs;
24 |
25 | /**
26 | * Inference helper for outputs.
27 | *
28 | * @example type HelloOutput = RouterOutputs['example']['hello']
29 | */
30 | export type RouterOutputs = inferRouterOutputs;
31 |
--------------------------------------------------------------------------------
/src/server/api/root.ts:
--------------------------------------------------------------------------------
1 | import { userRouter } from '@/server/api/routers/user';
2 | import { userProfileRouter } from '@/server/api/routers/user-profile';
3 | import { socialLinkRouter } from '@/server/api/routers/social-links';
4 | import { adHocLinkRouter } from '@/server/api/routers/adhoc-links';
5 | import { createTRPCRouter } from '@/server/api/trpc';
6 | import { imagesRouter } from './routers/images';
7 | import { generalAppearanceRouter } from './routers/general-appearance';
8 |
9 | /**
10 | * This is the primary router for your server.
11 | *
12 | * All routers added in /api/routers should be manually added here.
13 | */
14 | export const appRouter = createTRPCRouter({
15 | user: userRouter,
16 | userProfile: userProfileRouter,
17 | socialLink: socialLinkRouter,
18 | generalAppearance: generalAppearanceRouter,
19 | adHocLink: adHocLinkRouter,
20 | images: imagesRouter
21 | });
22 |
23 | // export type definition of API
24 | export type AppRouter = typeof appRouter;
25 |
--------------------------------------------------------------------------------
/src/server/api/routers/social-links.ts:
--------------------------------------------------------------------------------
1 | import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
2 | import { db } from '@/server/db';
3 | import { getUser } from '../utils/user';
4 | import { SocialLinkSchema } from '../schemas';
5 |
6 | export const socialLinkRouter = createTRPCRouter({
7 | updateSocialLinks: protectedProcedure.input(SocialLinkSchema).mutation(async ({ input, ctx }) => {
8 | const user = await getUser({ ctx: ctx, includeSocialLink: true });
9 | const updatedUserProfile = await db.socialLink.upsert({
10 | where: {
11 | userId: user.id
12 | },
13 | update: {
14 | data: input
15 | },
16 | create: {
17 | data: input,
18 | userId: user.id
19 | }
20 | });
21 | return updatedUserProfile;
22 | }),
23 | getSocialLinks: protectedProcedure.query(async ({ ctx }) => {
24 | const user = await getUser({ ctx: ctx, includeSocialLink: true });
25 | return user;
26 | })
27 | });
28 |
--------------------------------------------------------------------------------
/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 = 'light' } = useTheme();
10 |
11 | return (
12 |
26 | );
27 | };
28 |
29 | export { Toaster };
30 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Since the ".env" file is gitignored, you can use the ".env.example" file to
2 | # build a new ".env" file when you clone the repo. Keep this file up-to-date
3 | # when you add new variables to `.env`.
4 |
5 | # This file will be committed to version control, so make sure not to have any
6 | # secrets in it. If you are cloning this repo, create a copy of this file named
7 | # ".env" and populate it with your secrets.
8 |
9 | # When adding additional environment variables, the schema in "/src/env.mjs"
10 | # should be updated accordingly.
11 |
12 | # Prisma
13 | # https://www.prisma.io/docs/reference/database-reference/connection-urls#env
14 | DATABASE_URL="file:./db.sqlite"
15 |
16 | # Next Auth
17 | # You can generate a new secret on the command line with:
18 | # openssl rand -base64 32
19 | # https://next-auth.js.org/configuration/options#secret
20 | # NEXTAUTH_SECRET=""
21 | NEXTAUTH_URL="http://localhost:3000"
22 |
23 | # Next Auth Discord Provider
24 | DISCORD_CLIENT_ID=""
25 | DISCORD_CLIENT_SECRET=""
26 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Profilee
2 |
3 | Profilee is an open source profile link bio page builder. Featured at offical t3 docs website [here](https://create.t3.gg/en/t3-collection#:~:text=Profilee%20%2D%20A%20Profile%20Link%20Builder)
4 |
5 | ## What is Profilee?
6 |
7 | Profile Link Builder is a powerful tool designed to simplify the process of creating and managing links to various social media profiles and online platforms. Profile Link Builder offers a modern and efficient solution built with cutting-edge technologies where you can customize your profile from dashboard.
8 |
9 | ## Technolgy used
10 | - [Next.js](https://nextjs.org)
11 | - [NextAuth.js](https://next-auth.js.org)
12 | - [Prisma](https://prisma.io)
13 | - [Tailwind CSS](https://tailwindcss.com)
14 | - [tRPC](https://trpc.io)
15 | - AWS S3 for storing images
16 |
17 | ## Upcoming feature
18 | - Ligh and Dark Mode
19 | - QR code support
20 | - PRO plan support
21 | - More customization/ UI updates
22 |
23 | Feel free to raise the PR, I would love to merge the changes
24 |
25 |
26 |
--------------------------------------------------------------------------------
/src/app/(landing)/page.tsx:
--------------------------------------------------------------------------------
1 | import LandingPage from './_components/landing-page';
2 |
3 | export default function Home() {
4 | return (
5 |
6 |
7 | {/* Gradient Backgrounds */}
8 |
9 |
10 |
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/links/social-links/social-icon.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
2 | import { Dispatch, SetStateAction } from 'react';
3 | import SocialLinkDialog from '../../dialogs/social-icons-dialog';
4 | import { socialMediaDataByName } from '../../page-elements';
5 |
6 | const SocialIcon = ({
7 | data,
8 | value,
9 | triggerPopover
10 | }: {
11 | data: string;
12 | value?: string;
13 | triggerPopover?: Dispatch>;
14 | }) => {
15 | return (
16 |
17 |
18 |
19 |
20 | {socialMediaDataByName[data]?.icon}
21 |
22 |
23 | {socialMediaDataByName[data]?.name}
24 |
25 |
26 | );
27 | };
28 | export default SocialIcon;
29 |
--------------------------------------------------------------------------------
/src/components/not-found.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Button } from '@/components/ui/button';
4 | import Link from 'next/link';
5 |
6 | export default function NotFound() {
7 | return (
8 | <>
9 |
10 |
11 |
12 | Sorry, this page isn't available
13 |
14 |
15 | The link you followed may be broken, or the page may have been removed.
16 |
17 |
23 | Back
24 |
25 |
26 |
27 | >
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/app/[link]/page.tsx:
--------------------------------------------------------------------------------
1 | import { getUserByUsername } from '@/server/api/utils/user';
2 | import Webpage from './_components/WebPage';
3 | import Error from '@/components/error';
4 | import { JsonArray } from '@prisma/client/runtime/library';
5 |
6 | type Props = {
7 | params: {
8 | link: string;
9 | };
10 | };
11 |
12 | export default async function Page({ params }: Props) {
13 | const { link } = params;
14 | const data = await getUserByUsername(link, true, true, true, true);
15 |
16 | if (!data) {
17 | return ;
18 | }
19 | const adhocLinks = data.adhocLink?.data as JsonArray;
20 |
21 | return (
22 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { getToken } from 'next-auth/jwt';
2 | import { withAuth } from 'next-auth/middleware';
3 | import { NextFetchEvent, NextRequest, NextResponse } from 'next/server';
4 |
5 | export default async function middleware(req: NextRequest, event: NextFetchEvent) {
6 | const token = await getToken({ req });
7 | const isAuthenticated = !!token;
8 | const { nextUrl } = req;
9 |
10 | if (nextUrl.pathname.includes('/builder') && isAuthenticated && token) {
11 | if (!token?.username) {
12 | return NextResponse.redirect(new URL('/claim/username', req.url));
13 | }
14 | }
15 |
16 | if (nextUrl.pathname === '/claim/username' && token?.username && isAuthenticated) {
17 | return NextResponse.redirect(new URL('/builder', req.url));
18 | }
19 |
20 | const authMiddleware = withAuth({
21 | pages: {
22 | signIn: '/auth/login',
23 | newUser: '/auth/new-user'
24 | }
25 | });
26 |
27 | // @ts-expect-error : Cant extend req with Next Request
28 | return authMiddleware(req, event);
29 | }
30 | export const config = {
31 | matcher: ['/builder', '/claim/username']
32 | };
33 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/appearance/profile-section/dropzone.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import React, { FC } from 'react';
3 | import { Accept, useDropzone } from 'react-dropzone';
4 | import drag_and_drop from '../../../../../../../public/drag_and_drop.webp';
5 |
6 | interface DropzoneProps {
7 | onDrop: (acceptedFiles: File[]) => void;
8 | title: string;
9 | accept?: Accept;
10 | }
11 |
12 | export const Dropzone: FC = ({ title, accept, onDrop }) => {
13 | const { getRootProps, getInputProps } = useDropzone({
14 | accept,
15 | maxFiles: 1,
16 | onDrop
17 | });
18 |
19 | return (
20 |
21 |
27 |
28 |
29 |
{title}
30 |
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/layout.tsx:
--------------------------------------------------------------------------------
1 | import DesignerContextProvider from '@/components/context/designer-context';
2 | import Navbar from './_components/navbar';
3 | import { DndContext } from '@dnd-kit/core';
4 | import Preview from './_components/preview/preview';
5 |
6 | const AdminPageLayout = ({ children }: { children: React.ReactNode }) => {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
17 |
18 |
19 |
20 |
21 | );
22 | };
23 |
24 | export default AdminPageLayout;
25 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/popovers/social-icons-popover.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { MdAddCircle } from 'react-icons/md';
3 | import { Popover, PopoverContent } from '@/components/ui/popover';
4 | import { PopoverTrigger } from '@radix-ui/react-popover';
5 | import { socialMediaDataByName } from '../page-elements';
6 | import SocialIcon from '@/app/(main)/builder/_components/links/social-links/social-icon';
7 | import useDesigner from '@/hooks/use-designer';
8 |
9 | export default function SocialIconsPopover() {
10 | const { state } = useDesigner();
11 | const [open, setOpen] = useState(false);
12 |
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 | {Object.keys(socialMediaDataByName)
21 | .filter((data) => !state.socialLinks[data])
22 | .map((data) => {
23 | return ;
24 | })}
25 |
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SwitchPrimitives from "@radix-ui/react-switch"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ))
27 | Switch.displayName = SwitchPrimitives.Root.displayName
28 |
29 | export { Switch }
30 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
31 |
--------------------------------------------------------------------------------
/prisma/migrations/20240814172941_added_the_general_appearance_modal/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `picBorder` on the `UserProfile` table. All the data in the column will be lost.
5 |
6 | */
7 | -- AlterTable
8 | ALTER TABLE "User" ADD COLUMN "generalAppearanceId" TEXT;
9 |
10 | -- AlterTable
11 | ALTER TABLE "UserProfile" DROP COLUMN "picBorder",
12 | ADD COLUMN "profilePicBorder" TEXT;
13 |
14 | -- CreateTable
15 | CREATE TABLE "GeneralAppearance" (
16 | "id" TEXT NOT NULL,
17 | "hideBranding" BOOLEAN DEFAULT false,
18 | "enableShareButton" BOOLEAN DEFAULT true,
19 | "primaryBackgroundColor" TEXT DEFAULT '#fff',
20 | "primaryBackgroundImage" TEXT,
21 | "fontFamily" TEXT,
22 | "linkCardSahdow" TEXT,
23 | "useSecondaryBackfround" BOOLEAN DEFAULT false,
24 | "secondaryBackgroundColor" TEXT DEFAULT '#fff',
25 | "secondaryBackgroundImage" TEXT,
26 | "userId" TEXT NOT NULL,
27 |
28 | CONSTRAINT "GeneralAppearance_pkey" PRIMARY KEY ("id")
29 | );
30 |
31 | -- CreateIndex
32 | CREATE UNIQUE INDEX "GeneralAppearance_userId_key" ON "GeneralAppearance"("userId");
33 |
34 | -- AddForeignKey
35 | ALTER TABLE "GeneralAppearance" ADD CONSTRAINT "GeneralAppearance_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
36 |
--------------------------------------------------------------------------------
/src/components/font-picker.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Select,
3 | SelectContent,
4 | SelectGroup,
5 | SelectItem,
6 | SelectTrigger,
7 | SelectValue
8 | } from '@/components/ui/select';
9 | import useDesigner from '@/hooks/use-designer';
10 | import { fontsArray } from '@/lib/fonts';
11 |
12 | export default function FontPicker() {
13 | const { state, dispatch } = useDesigner();
14 |
15 | return (
16 |
17 |
20 | dispatch({
21 | type: 'UPDATE_FONT_FAMILY',
22 | payload: value
23 | })
24 | }
25 | >
26 |
27 |
28 |
29 |
30 |
31 | {fontsArray.map((font) => (
32 |
37 | {font.name}
38 |
39 | ))}
40 |
41 |
42 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as PopoverPrimitive from "@radix-ui/react-popover"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Popover = PopoverPrimitive.Root
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ))
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
30 |
31 | export { Popover, PopoverTrigger, PopoverContent }
32 |
--------------------------------------------------------------------------------
/src/app/(landing)/_components/mobile-menu-navbar.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | DropdownMenu,
3 | DropdownMenuContent,
4 | DropdownMenuItem,
5 | DropdownMenuSeparator,
6 | DropdownMenuTrigger
7 | } from '@/components/ui/dropdown-menu';
8 | import { getServerAuthSession } from '@/server/auth';
9 | import { MenuIcon } from 'lucide-react';
10 | import Link from 'next/link';
11 |
12 | export default async function MobileMenuNavbar() {
13 | const session = await getServerAuthSession();
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | Pricing
25 |
26 |
27 |
28 |
29 |
33 | {session ? 'Logout' : 'Sign up'}
34 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/appearance/profile-section/set-canvas-preview.tsx:
--------------------------------------------------------------------------------
1 | import { PercentCrop, PixelCrop } from 'react-image-crop';
2 |
3 | const setCanvasPreview = (image: HTMLImageElement, canvas: HTMLCanvasElement, crop: PixelCrop) => {
4 | const ctx = canvas.getContext('2d');
5 | if (!ctx) {
6 | throw new Error('No 2d context');
7 | }
8 |
9 | // devicePixelRatio slightly increases sharpness on retina devices
10 | // at the expense of slightly slower render times and needing to
11 | // size the image back down if you want to download/upload and be
12 | // true to the images natural size.
13 | const pixelRatio = window.devicePixelRatio;
14 | const scaleX = image.naturalWidth / image.width;
15 | const scaleY = image.naturalHeight / image.height;
16 |
17 | canvas.width = Math.floor(crop.width * scaleX * pixelRatio);
18 | canvas.height = Math.floor(crop.height * scaleY * pixelRatio);
19 |
20 | ctx.scale(pixelRatio, pixelRatio);
21 | ctx.imageSmoothingQuality = 'high';
22 | ctx.save();
23 |
24 | const cropX = crop.x * scaleX;
25 | const cropY = crop.y * scaleY;
26 |
27 | // Move the crop origin to the canvas origin (0,0)
28 | ctx.translate(-cropX, -cropY);
29 | ctx.drawImage(
30 | image,
31 | 0,
32 | 0,
33 | image.naturalWidth,
34 | image.naturalHeight,
35 | 0,
36 | 0,
37 | image.naturalWidth,
38 | image.naturalHeight
39 | );
40 |
41 | ctx.restore();
42 | };
43 | export default setCanvasPreview;
44 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import("eslint").Linter.Config} */
2 | const config = {
3 | parser: '@typescript-eslint/parser',
4 | parserOptions: {
5 | project: true
6 | },
7 | plugins: ['@typescript-eslint'],
8 | extends: [
9 | 'next/core-web-vitals',
10 | 'plugin:@typescript-eslint/recommended-type-checked',
11 | 'plugin:@typescript-eslint/stylistic-type-checked'
12 | ],
13 | rules: {
14 | // These opinionated rules are enabled in stylistic-type-checked above.
15 | // Feel free to reconfigure them to your own preference.
16 | '@typescript-eslint/array-type': 'off',
17 | '@typescript-eslint/consistent-type-definitions': 'off',
18 | '@typescript-eslint/no-unsafe-assignment': 'off',
19 | '@typescript-eslint/no-unsafe-argument': 'off',
20 | '@typescript-eslint/no-unsafe-member-access': 'off',
21 | '@typescript-eslint/no-explicit-any': 'off',
22 | '@typescript-eslint/no-empty-interface': 'off',
23 | '@typescript-eslint/consistent-indexed-object-style': 'off',
24 | '@typescript-eslint/consistent-type-imports': [
25 | 'warn',
26 | {
27 | prefer: 'type-imports',
28 | fixStyle: 'inline-type-imports'
29 | }
30 | ],
31 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
32 | '@typescript-eslint/no-misused-promises': [
33 | 2,
34 | {
35 | checksVoidReturn: { attributes: false }
36 | }
37 | ]
38 | }
39 | };
40 |
41 | module.exports = config;
42 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/links/social-links/social-icons-section.tsx:
--------------------------------------------------------------------------------
1 | import { Card } from '@/components/ui/card';
2 | import useDesigner from '@/hooks/use-designer';
3 | import { SortableContext } from '@dnd-kit/sortable';
4 | import { IoShareSocialSharp } from 'react-icons/io5';
5 | import SocialIconsPopover from '../../popovers/social-icons-popover';
6 | import SocialIconDrag from './social-icon-drag';
7 | import { TooltipProvider } from '@/components/ui/tooltip';
8 |
9 | export default function SocialIconsSection() {
10 | const { state } = useDesigner();
11 |
12 | return (
13 |
14 |
15 |
16 |
17 |
18 | Social Icons
19 |
20 |
21 |
22 | {Object.entries(state.socialLinks).length > 0 && (
23 |
24 |
25 | {Object.entries(state.socialLinks).map(([platform, value]) => (
26 |
27 | ))}
28 |
29 |
30 | )}
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/types/types.ts:
--------------------------------------------------------------------------------
1 | export type preview = 'desktop' | 'mobile';
2 |
3 | export enum TextAlign {
4 | CENTER = 'CENTER',
5 | LEFT = 'LEFT',
6 | RIGHT = 'RIGHT'
7 | }
8 | export enum BorderRadius {
9 | SM = 'SM',
10 | MD = 'MD',
11 | LG = 'LG'
12 | }
13 | export type UserProfile = {
14 | pic: string;
15 | title: string;
16 | username: string;
17 | bio: string;
18 | profilePicBorder: string;
19 | bioColor: string;
20 | titleColor: string;
21 | titleFontSize: string;
22 | bioFontSize: string;
23 | };
24 | export type SocialMediaData = {
25 | name: string;
26 | color?: string;
27 | icon: React.ReactElement;
28 | };
29 |
30 | export type SocialMediaDataContext = {
31 | [platform: string]: string;
32 | };
33 |
34 | export type AdhocLinks = {
35 | name: string;
36 | id: string;
37 | link: string;
38 | isActive: boolean;
39 | theme: LinkTheme;
40 | clicks: number;
41 | };
42 | export type LinkTheme = {
43 | textAlign: TextAlign;
44 | backgroundColor: string;
45 | textColor: string;
46 | borderColor: string;
47 | borderRadius: BorderRadius;
48 | };
49 | export type AdhocLinksDataContext = AdhocLinks[];
50 |
51 | export type GeneralAppearance = {
52 | hideBranding: boolean;
53 | enableShareButton: boolean;
54 | primaryBackgroundColor: string;
55 | primaryBackgroundImage: string;
56 | fontFamily: string;
57 | linkCardShadow: string;
58 | useSecondaryBackground: boolean;
59 | secondaryBackgroundColor: string;
60 | secondaryBackgroundImage: string;
61 | };
62 |
--------------------------------------------------------------------------------
/src/app/[link]/_components/LinkClient.tsx:
--------------------------------------------------------------------------------
1 | // components/WebpageClient.tsx
2 | 'use client';
3 |
4 | import Link from 'next/link';
5 | import { AdhocLinks } from '@/types/types';
6 | import { api } from '@/trpc/react';
7 | import { EventType } from '@prisma/client';
8 |
9 | interface PropsTypes {
10 | adhocLinks?: AdhocLinks[];
11 | userId: string;
12 | }
13 |
14 | export default function LinkClient({ adhocLinks = [], userId }: PropsTypes) {
15 | const { mutateAsync: updateLinkInteraction } = api.adHocLink.adhocLinkInteraction.useMutation();
16 |
17 | const adhocLinkHandler = async (id: string) => {
18 | await updateLinkInteraction({ userId: userId, adhocLinkId: id, eventType: EventType.CLICK });
19 | };
20 |
21 | return (
22 |
23 | {adhocLinks.map((link) =>
24 | link.isActive ? (
25 |
26 | adhocLinkHandler(link.id)}
30 | style={{
31 | background: link.theme.backgroundColor || '',
32 | color: link.theme.textColor || '',
33 | borderColor: link.theme.borderColor || ''
34 | }}
35 | className='flex items-center rounded-lg border px-5 py-4 text-lg leading-6 font-medium shadow-md hover:shadow-xl transition ease-in-out duration-150'
36 | >
37 | {link.name}
38 |
39 |
40 | ) : null
41 | )}
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/ui/toggle.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as TogglePrimitive from '@radix-ui/react-toggle';
5 | import { cva, type VariantProps } from 'class-variance-authority';
6 |
7 | import { cn } from '@/lib/utils';
8 |
9 | const toggleVariants = cva(
10 | 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-s-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
11 | {
12 | variants: {
13 | variant: {
14 | default: 'bg-transparent',
15 | outline: 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground'
16 | },
17 | size: {
18 | default: 'h-10 px-3',
19 | sm: 'h-8 px-1.5',
20 | lg: 'h-11 px-5'
21 | }
22 | },
23 | defaultVariants: {
24 | variant: 'default',
25 | size: 'default'
26 | }
27 | }
28 | );
29 |
30 | const Toggle = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef & VariantProps
33 | >(({ className, variant, size, ...props }, ref) => (
34 |
39 | ));
40 |
41 | Toggle.displayName = TogglePrimitive.Root.displayName;
42 |
43 | export { Toggle, toggleVariants };
44 |
--------------------------------------------------------------------------------
/src/server/api/routers/images.ts:
--------------------------------------------------------------------------------
1 | import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
2 | import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
3 |
4 | import { protectedProcedure, createTRPCRouter } from '../trpc';
5 | import { env } from '@/env.mjs';
6 | import { db } from '@/server/db';
7 |
8 | const client = new S3Client({
9 | region: env.UPLOAD_AWS_REGION,
10 | credentials: {
11 | accessKeyId: env.UPLOAD_AWS_ACCESS_KEY_ID,
12 | secretAccessKey: env.UPLOAD_AWS_SECRET_ACCESS_KEY
13 | }
14 | });
15 |
16 | export const imagesRouter = createTRPCRouter({
17 | signedUrl: protectedProcedure.mutation(async ({ ctx }) => {
18 | const { id } = ctx.session?.user;
19 | const command = new PutObjectCommand({
20 | Bucket: env.UPLOAD_AWS_S3_BUCKET_NAME,
21 | Key: id
22 | });
23 |
24 | return {
25 | url: await getSignedUrl(client, command, { expiresIn: 120 })
26 | };
27 | }),
28 | upload: protectedProcedure.mutation(async ({ ctx }) => {
29 | const { id } = ctx.session?.user;
30 |
31 | await db.userProfile.update({ where: { userId: id }, data: { pic: id } });
32 | return {
33 | message: 'Success'
34 | };
35 | }),
36 | delete: protectedProcedure.mutation(async ({ ctx }) => {
37 | const { id } = ctx.session?.user;
38 | const cmd = new DeleteObjectCommand({
39 | Bucket: env.UPLOAD_AWS_S3_BUCKET_NAME,
40 | Key: id
41 | });
42 | await client.send(cmd);
43 | await db.userProfile.update({ where: { userId: id }, data: { pic: '' } });
44 | return {
45 | message: 'Successfully deleted'
46 | };
47 | })
48 | });
49 |
--------------------------------------------------------------------------------
/src/trpc/react.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4 | import { loggerLink, unstable_httpBatchStreamLink } from '@trpc/client';
5 | import { createTRPCReact } from '@trpc/react-query';
6 | import { useState } from 'react';
7 |
8 | import { type AppRouter } from '@/server/api/root';
9 | import { getUrl, transformer } from './shared';
10 |
11 | export const api = createTRPCReact();
12 |
13 | export function TRPCReactProvider(props: { children: React.ReactNode; headers: Headers }) {
14 | const queryClient = new QueryClient({
15 | defaultOptions: {
16 | queries: {
17 | refetchOnWindowFocus: false,
18 | refetchOnReconnect: true,
19 | retry: 1,
20 | staleTime: Infinity
21 | }
22 | }
23 | });
24 | const [trpcClient] = useState(() =>
25 | api.createClient({
26 | transformer,
27 | links: [
28 | loggerLink({
29 | enabled: (op) =>
30 | process.env.NODE_ENV === 'development' ||
31 | (op.direction === 'down' && op.result instanceof Error)
32 | }),
33 | unstable_httpBatchStreamLink({
34 | url: getUrl(),
35 | headers() {
36 | const heads = new Map(props.headers);
37 | heads.set('x-trpc-source', 'react');
38 | return Object.fromEntries(heads);
39 | }
40 | })
41 | ]
42 | })
43 | );
44 |
45 | return (
46 |
47 |
48 | {props.children}
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/footer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Link from 'next/link';
3 |
4 | export default function Footer() {
5 | return (
6 |
7 |
8 |
9 | Profilee
10 |
11 |
12 |
13 |
14 |
19 | Contact
20 |
21 |
22 |
23 |
28 | Privacy Policy
29 |
30 |
31 |
32 |
37 | Terms
38 |
39 |
40 |
41 |
42 |
43 |
44 | Copyright {new Date().getFullYear()} © All Rights Reserved
45 |
46 |
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = "Alert"
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = "AlertDescription"
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/src/components/context/designer-context-action.tsx:
--------------------------------------------------------------------------------
1 | import { AdhocLinks, SocialMediaDataContext } from '@/types/types';
2 | import { DesignerContextState } from './designer-context';
3 | // Adhoc Links Action
4 | export type AdhocLinksAction = {
5 | type: 'UPDATE_ADHOC_LINK';
6 | payload: AdhocLinks[];
7 | };
8 |
9 | export type UserProfileAction =
10 | | { type: 'UPDATE_PROFILE_IMG'; payload: string }
11 | | { type: 'UPDATE_BIO'; payload: string }
12 | | { type: 'UPDATE_USERNAME'; payload: string }
13 | | { type: 'UPDATE_TITLE'; payload: string }
14 | | { type: 'UPDATE_BIO_COLOR'; payload: string }
15 | | { type: 'UPDATE_BIO_FONT_SIZE'; payload: string }
16 | | { type: 'UPDATE_TITLE_COLOR'; payload: string }
17 | | { type: 'UPDATE_TITLE_FONT_SIZE'; payload: string };
18 |
19 | export type SocialLinksAction = {
20 | type: 'UPDATE_SOCIAL_LINK';
21 | payload: SocialMediaDataContext;
22 | };
23 |
24 | export type InitialStateAction = {
25 | type: 'SET_INITIAL_STATE';
26 | payload: DesignerContextState;
27 | };
28 |
29 | export type GeneralAppearanceAction =
30 | | { type: 'UPDATE_PIC_BORDER'; payload: string }
31 | | { type: 'HDE_BRANDING'; payload: boolean }
32 | | { type: 'ENABLE_SHARE_BUTTON'; payload: boolean }
33 | | {
34 | type: 'UPDATE_PRIMARY_BACKGROUND_COLOR';
35 | payload: string;
36 | }
37 | | { type: 'UPDATE_PRIMARY_BACKGROUND_IMAGE'; payload: string }
38 | | { type: 'UPDATE_FONT_FAMILY'; payload: string }
39 | | { type: 'IS_SECONDARY_BACKGROUND'; payload: boolean }
40 | | { type: 'UPDATE_SECONDARY_BACKGROUND_COLOR'; payload: string }
41 | | { type: 'UPDATE_SECONDARY_BACKGROUND_IMAGE'; payload: string }
42 | | { type: 'UPDATE_LINK_CARD_SHADOW'; payload: string };
43 |
44 | export type DesignerContextAction =
45 | | InitialStateAction
46 | | UserProfileAction
47 | | AdhocLinksAction
48 | | SocialLinksAction
49 | | GeneralAppearanceAction;
50 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/links/adhoc-links/adhoc-links.tsx:
--------------------------------------------------------------------------------
1 | import useDesigner from '@/hooks/use-designer';
2 | import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
3 | import { GripVertical, MoreHorizontal } from 'lucide-react';
4 | import { useState } from 'react';
5 | import AdhocLinkDrag from './adhoc-links-drag';
6 | import AdhocLinksDialog from '../../dialogs/adhoc-links-dialog';
7 |
8 | export default function AdhocLinks() {
9 | const { state } = useDesigner();
10 | const [open, setAdhocLinkDialogOpen] = useState(false);
11 |
12 | return (
13 |
14 |
{
16 | setAdhocLinkDialogOpen(true);
17 | }}
18 | className='text-xs font-semibold bg-black text-white px-4 py-2 rounded-full hover:cursor-pointer'
19 | >
20 | Add New Links
21 |
22 | {state.adhocLinks.length > 0 && (
23 | <>
24 |
25 |
26 | Drag
27 |
28 |
29 |
30 | to sort
31 |
32 |
33 | Tap
34 |
35 |
36 |
37 | to edit
38 |
39 |
40 | >
41 | )}
42 |
47 | {state.adhocLinks.map((link) => (
48 |
49 | ))}
50 |
51 | {open &&
}
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/src/server/api/routers/user-profile.ts:
--------------------------------------------------------------------------------
1 | import { Context, createTRPCRouter, protectedProcedure, publicProcedure } from '@/server/api/trpc';
2 | import { UpdateProfileSchema } from '../schemas';
3 | import { db } from '@/server/db';
4 | import { getUser } from '../utils/user';
5 |
6 | export const userProfileRouter = createTRPCRouter({
7 | updateUserProfile: protectedProcedure
8 | .input(UpdateProfileSchema)
9 | .mutation(async ({ input, ctx }) => {
10 | const user = await getUser({ ctx: ctx, includeUserProfile: true });
11 | const updatedUserProfile = await db.userProfile.upsert({
12 | where: {
13 | userId: user.id
14 | },
15 | update: {
16 | title: input.title,
17 | bio: input.bio,
18 | bioColor: input.bioColor,
19 | titleColor: input.titleColor,
20 | titleFontSize: input.titleFontSize,
21 | bioFontSize: input.bioFontSize,
22 | profilePicBorder: input.profilePicBorder
23 | },
24 | create: {
25 | title: input.title,
26 | bio: input.bio,
27 | bioColor: input.bioColor,
28 | pic: user.id,
29 | titleColor: input.titleColor,
30 | titleFontSize: input.titleFontSize,
31 | bioFontSize: input.bioFontSize,
32 | profilePicBorder: input.profilePicBorder,
33 | userId: user.id
34 | }
35 | });
36 | return updatedUserProfile;
37 | }),
38 | getUserProfile: protectedProcedure.query(async ({ ctx }) => {
39 | const user = await getUser({ ctx: ctx, includeUserProfile: true });
40 | return user;
41 | }),
42 | getUserCompleteProfile: protectedProcedure.query(async ({ ctx }) => {
43 | const user = await getUser({
44 | ctx: ctx,
45 | includeUserProfile: true,
46 | includeAdhocLink: true,
47 | includeSocialLink: true,
48 | includeGeneralAppearance: true
49 | });
50 | return user;
51 | })
52 | });
53 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import '@/styles/globals.css';
2 |
3 | import { Toaster } from '@/components/ui/sonner';
4 | import { Inter } from 'next/font/google';
5 | import { headers } from 'next/headers';
6 |
7 | import Provider from '@/components/context/client-provider';
8 | import { getServerAuthSession } from '@/server/auth';
9 | import { TRPCReactProvider } from '@/trpc/react';
10 | import Script from 'next/script';
11 | import dynamic from 'next/dynamic';
12 | import { PHProvider } from '@/lib/posthog-provider';
13 |
14 | // const PostHogPageView = dynamic(() => import('../components/PostHogPageView'), {
15 | // ssr: false
16 | // });
17 | const inter = Inter({
18 | subsets: ['latin'],
19 | variable: '--font-sans'
20 | });
21 |
22 | export const metadata = {
23 | title: 'Profilee',
24 | description: 'A link in bio builder app',
25 | icons: [{ rel: 'icon', url: '/profilee-b.png' }]
26 | };
27 |
28 | export default async function RootLayout({ children }: { children: React.ReactNode }) {
29 | const session = await getServerAuthSession();
30 |
31 | return (
32 |
33 |
34 |
35 |
42 |
43 |
44 |
45 |
46 |
47 | {/* */}
48 | {children}
49 |
50 |
51 |
52 |
53 |
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/src/components/ui/toggle-group.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
5 | import { type VariantProps } from 'class-variance-authority';
6 |
7 | import { cn } from '@/lib/utils';
8 | import { toggleVariants } from '@/components/ui/toggle';
9 |
10 | const ToggleGroupContext = React.createContext>({
11 | size: 'default',
12 | variant: 'default'
13 | });
14 |
15 | const ToggleGroup = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef &
18 | VariantProps
19 | >(({ className, variant, size, children, ...props }, ref) => (
20 |
25 | {children}
26 |
27 | ));
28 |
29 | ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
30 |
31 | const ToggleGroupItem = React.forwardRef<
32 | React.ElementRef,
33 | React.ComponentPropsWithoutRef &
34 | VariantProps
35 | >(({ className, children, variant, size, ...props }, ref) => {
36 | const context = React.useContext(ToggleGroupContext);
37 |
38 | return (
39 |
50 | {children}
51 |
52 | );
53 | });
54 |
55 | ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
56 |
57 | export { ToggleGroup, ToggleGroupItem };
58 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Slot } from '@radix-ui/react-slot';
3 | import { cva, type VariantProps } from 'class-variance-authority';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const buttonVariants = cva(
8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
9 | {
10 | variants: {
11 | variant: {
12 | default: 'bg-primary text-primary-foreground hover:bg-primary/90',
13 | destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
14 | outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
15 | secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
16 | ghost: 'hover:bg-accent hover:text-accent-foreground',
17 | link: 'text-primary underline-offset-4 hover:underline'
18 | },
19 | size: {
20 | default: 'h-10 px-4 py-2',
21 | sm: 'h-9 rounded-md px-3',
22 | lg: 'h-11 rounded-md px-8',
23 | lgp6: 'h-11 rounded-md px-6',
24 | icon: 'h-10 w-10'
25 | }
26 | },
27 | defaultVariants: {
28 | variant: 'default',
29 | size: 'default'
30 | }
31 | }
32 | );
33 |
34 | export interface ButtonProps
35 | extends React.ButtonHTMLAttributes,
36 | VariantProps {
37 | asChild?: boolean;
38 | }
39 |
40 | const Button = React.forwardRef(
41 | ({ className, variant, size, asChild = false, ...props }, ref) => {
42 | const Comp = asChild ? Slot : 'button';
43 | return (
44 |
45 | );
46 | }
47 | );
48 | Button.displayName = 'Button';
49 |
50 | export { Button, buttonVariants };
51 |
--------------------------------------------------------------------------------
/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TabsPrimitive from "@radix-ui/react-tabs"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Tabs = TabsPrimitive.Root
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | TabsList.displayName = TabsPrimitive.List.displayName
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ))
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ))
53 | TabsContent.displayName = TabsPrimitive.Content.displayName
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent }
56 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/dialogs/social-icons-dialog.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Button } from '@/components/ui/button';
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogHeader,
8 | DialogTitle,
9 | DialogTrigger
10 | } from '@/components/ui/dialog';
11 | import { Input } from '@/components/ui/input';
12 | import useDesigner from '@/hooks/use-designer';
13 | import { Dispatch, SetStateAction, useState } from 'react';
14 |
15 | // Dialog for edit and add social link
16 | export default function SocialLinkDialog({
17 | children,
18 | name,
19 | value,
20 | triggerPopover
21 | }: {
22 | name: string;
23 | value?: string;
24 | children: React.ReactNode;
25 | triggerPopover?: Dispatch>;
26 | }) {
27 | const { dispatch, state } = useDesigner();
28 | const [open, setOpen] = useState(false);
29 | const [input, setInput] = useState(value ?? '');
30 |
31 | return (
32 |
33 | {children}
34 |
35 |
36 |
37 | {value ? 'Edit' : 'Add'} {name} link
38 |
39 |
40 |
41 | setInput(e.target.value)}
46 | />
47 |
48 |
49 | {
52 | dispatch({
53 | type: 'UPDATE_SOCIAL_LINK',
54 | payload: { ...state.socialLinks, [name]: input }
55 | });
56 | setOpen(false);
57 | if (triggerPopover) triggerPopover(false);
58 | }}
59 | disabled={!input}
60 | className='w-full'
61 | >
62 | {value ? 'Edit' : 'Add'}
63 |
64 |
65 | setOpen(false)} className='w-full'>
66 | Close
67 |
68 |
69 |
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/src/server/api/utils/user.ts:
--------------------------------------------------------------------------------
1 | import { TRPCError } from '@trpc/server';
2 | import { Context } from '@/server/api/trpc';
3 | import { db } from '@/server/db';
4 |
5 | export const getUser = async ({
6 | ctx,
7 | includeUserProfile = false,
8 | includeSocialLink = false,
9 | includeAdhocLink = false,
10 | includeGeneralAppearance = false
11 | }: {
12 | ctx: Context;
13 | includeUserProfile?: boolean;
14 | includeSocialLink?: boolean;
15 | includeAdhocLink?: boolean;
16 | includeGeneralAppearance?: boolean;
17 | }) => {
18 | const { session } = ctx;
19 |
20 | const user = await db.user.findUnique({
21 | where: {
22 | id: session?.user.id
23 | },
24 | select: {
25 | id: true,
26 | name: true,
27 | email: true,
28 | image: true,
29 | username: true,
30 | userProfile: includeUserProfile,
31 | socialLink: includeSocialLink,
32 | adhocLink: includeAdhocLink,
33 | generalAppearance: includeGeneralAppearance
34 | }
35 | });
36 |
37 | if (!user) throw new TRPCError({ message: 'User not found', code: 'NOT_FOUND' });
38 |
39 | return user;
40 | };
41 |
42 | export const getUserByEmail = async (email: string) => {
43 | const user = await db.user.findUnique({
44 | where: {
45 | email: email
46 | }
47 | });
48 |
49 | return user;
50 | };
51 |
52 | export const getUserById = async (id: string) => {
53 | const user = await db.user.findUnique({
54 | where: {
55 | id: id
56 | }
57 | });
58 |
59 | return user;
60 | };
61 | export const getUserByUsername = async (
62 | username: string,
63 | includeUserProfile = false,
64 | includeSocialLink = false,
65 | includeAdhocLink = false,
66 | includeGeneralAppearance = false
67 | ) => {
68 | const user = await db.user.findUnique({
69 | where: {
70 | username: username
71 | },
72 | select: {
73 | id: true,
74 | name: true,
75 | email: true,
76 | image: true,
77 | username: true,
78 | userProfile: includeUserProfile,
79 | socialLink: includeSocialLink,
80 | adhocLink: includeAdhocLink,
81 | generalAppearance: includeGeneralAppearance
82 | }
83 | });
84 |
85 | return user;
86 | };
87 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ))
45 | CardTitle.displayName = "CardTitle"
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | CardDescription.displayName = "CardDescription"
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ))
65 | CardContent.displayName = "CardContent"
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ))
77 | CardFooter.displayName = "CardFooter"
78 |
79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
80 |
--------------------------------------------------------------------------------
/src/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AccordionPrimitive from "@radix-ui/react-accordion"
5 | import { ChevronDown } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Accordion = AccordionPrimitive.Root
10 |
11 | const AccordionItem = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
20 | ))
21 | AccordionItem.displayName = "AccordionItem"
22 |
23 | const AccordionTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, children, ...props }, ref) => (
27 |
28 | svg]:rotate-180",
32 | className
33 | )}
34 | {...props}
35 | >
36 | {children}
37 |
38 |
39 |
40 | ))
41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
42 |
43 | const AccordionContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, children, ...props }, ref) => (
47 |
52 | {children}
53 |
54 | ))
55 |
56 | AccordionContent.displayName = AccordionPrimitive.Content.displayName
57 |
58 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
59 |
--------------------------------------------------------------------------------
/src/app/(landing)/_components/navbar.tsx:
--------------------------------------------------------------------------------
1 | import Logo from '@/components/logo';
2 | import { cn } from '@/lib/utils';
3 | import { getServerAuthSession } from '@/server/auth';
4 | import Link from 'next/link';
5 | import DesktopMenu from './desktop-navbar';
6 | import MobileMenuNavbar from './mobile-menu-navbar';
7 |
8 | export default async function DesktopNavbar() {
9 | const session = await getServerAuthSession();
10 |
11 | return (
12 |
17 |
18 |
19 | Profilee
20 |
21 |
22 |
23 |
24 | {session && (
25 |
26 | Dashboard
27 |
33 |
34 |
35 |
36 | )}
37 | {!session && (
38 |
39 | Sign in
40 |
41 | )}
42 |
46 | {session ? 'Logout' : 'Sign up'}
47 |
48 |
49 |
50 |
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/navbar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import Logo from '@/components/logo';
3 | import { Button } from '@/components/ui/button';
4 | import useDesigner from '@/hooks/use-designer';
5 | import { api } from '@/trpc/react';
6 | import { SiGradleplaypublisher } from 'react-icons/si';
7 | import UsernameSettings from './elements/username';
8 | import { IoReload } from 'react-icons/io5';
9 | import Link from 'next/link';
10 | import { useState } from 'react';
11 |
12 | export default function Navbar() {
13 | const { state } = useDesigner();
14 | const [isPublishing, setIsPublishing] = useState(false);
15 |
16 | const { mutateAsync: updateSocialLink } = api.socialLink.updateSocialLinks.useMutation();
17 | const { mutateAsync: updateAdhocLinks } = api.adHocLink.updateAdhocLinks.useMutation({
18 | onSuccess: (res) => {
19 | setIsPublishing(false);
20 | },
21 | onError: () => {
22 | setIsPublishing(false);
23 | }
24 | });
25 |
26 | return (
27 | <>
28 |
29 |
30 |
31 |
32 |
33 |
34 | {
37 | setIsPublishing(true);
38 | await updateSocialLink(state.socialLinks);
39 | await updateAdhocLinks(state.adhocLinks);
40 | }}
41 | variant='outline'
42 | disabled={isPublishing}
43 | className='mx-auto w-32 rounded-full bg-gradient-to-l from-blue-800 to-blue-600 text-sm font-medium text-white'
44 | >
45 | {isPublishing ? 'Publishing' : 'Published'}
46 | {isPublishing ? (
47 |
48 | ) : (
49 |
50 | )}
51 |
52 |
53 | >
54 | );
55 | }
56 |
57 | const NavBarTabs = () => {
58 | return (
59 |
60 | {/*
61 | Analytics
62 | */}
63 | Links
64 | Appearance
65 |
66 | );
67 | };
68 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/appearance/profile-section/edit-bio-title.tsx:
--------------------------------------------------------------------------------
1 | import { GradientPicker } from '@/components/gradient-color-picker';
2 | import { Card } from '@/components/ui/card';
3 | // import {
4 | // Select,
5 | // SelectContent,
6 | // SelectGroup,
7 | // SelectItem,
8 | // SelectTrigger,
9 | // SelectValue
10 | // } from '@/components/ui/select';
11 | import useDesigner from '@/hooks/use-designer';
12 | import React from 'react';
13 |
14 | interface EditBioTitleProps {
15 | title: 'TITLE' | 'BIO';
16 | background: string;
17 | }
18 |
19 | const EditBioTitle: React.FC = ({ title, background }: EditBioTitleProps) => {
20 | const { dispatch } = useDesigner();
21 |
22 | const handleChangeColor = (color: string) => {
23 | dispatch({
24 | type: title === 'TITLE' ? 'UPDATE_TITLE_COLOR' : 'UPDATE_BIO_COLOR',
25 | payload: color
26 | });
27 | };
28 | const handleChangeFontSize = (value: string) => {
29 | dispatch({
30 | type: title === 'TITLE' ? 'UPDATE_TITLE_FONT_SIZE' : 'UPDATE_BIO_FONT_SIZE',
31 | payload: value
32 | });
33 | };
34 |
35 | return (
36 |
37 |
38 |
39 | {title === 'TITLE' ? 'Title' : 'Bio'} font color
40 |
44 |
45 | {/*
46 | {title === 'TITLE' ? 'Title' : 'Bio'} font size
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | Small
55 | Medium
56 | Large
57 |
58 |
59 |
60 |
*/}
61 |
{' '}
62 |
63 | );
64 | };
65 |
66 | export default EditBioTitle;
67 |
--------------------------------------------------------------------------------
/src/server/api/routers/general-appearance.ts:
--------------------------------------------------------------------------------
1 | import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
2 | import { db } from '@/server/db';
3 | import { GeneralAppearanceSchema } from '../schemas';
4 | import { getUser } from '../utils/user';
5 |
6 | export const generalAppearanceRouter = createTRPCRouter({
7 | updateGeneralAppearance: protectedProcedure
8 | .input(GeneralAppearanceSchema)
9 | .mutation(async ({ input, ctx }) => {
10 | const user = await getUser({ ctx: ctx, includeUserProfile: true });
11 | const updatedUserProfile = await db.generalAppearance.upsert({
12 | where: {
13 | userId: user.id
14 | },
15 | update: {
16 | hideBranding: input.hideBranding,
17 | enableShareButton: input.enableShareButton,
18 | primaryBackgroundColor: input.primaryBackgroundColor,
19 | primaryBackgroundImage: input.primaryBackgroundImage,
20 | fontFamily: input.fontFamily,
21 | linkCardShadow: input.linkCardShadow,
22 | useSecondaryBackground: input.useSecondaryBackground,
23 | secondaryBackgroundColor: input.secondaryBackgroundColor,
24 | secondaryBackgroundImage: input.secondaryBackgroundImage
25 | },
26 | create: {
27 | hideBranding: input.hideBranding,
28 | enableShareButton: input.enableShareButton,
29 | primaryBackgroundColor: input.primaryBackgroundColor,
30 | primaryBackgroundImage: input.primaryBackgroundImage,
31 | fontFamily: input.fontFamily,
32 | linkCardShadow: input.linkCardShadow,
33 | useSecondaryBackground: input.useSecondaryBackground,
34 | secondaryBackgroundColor: input.secondaryBackgroundColor,
35 | secondaryBackgroundImage: input.secondaryBackgroundImage,
36 | userId: user.id
37 | }
38 | });
39 | return updatedUserProfile;
40 | }),
41 | getUserProfile: protectedProcedure.query(async ({ ctx }) => {
42 | const user = await getUser({ ctx: ctx, includeUserProfile: true });
43 | return user;
44 | }),
45 | getUserCompleteProfile: protectedProcedure.query(async ({ ctx }) => {
46 | const user = await getUser({
47 | ctx: ctx,
48 | includeUserProfile: true,
49 | includeAdhocLink: true,
50 | includeSocialLink: true,
51 | includeGeneralAppearance: true
52 | });
53 | return user;
54 | })
55 | });
56 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/page-elements.tsx:
--------------------------------------------------------------------------------
1 | import { SocialMediaData } from '@/types/types';
2 | import { Facebook, Github, Instagram, Linkedin, Twitch, Twitter, Youtube } from 'lucide-react';
3 | import {
4 | BsDiscord,
5 | BsMedium,
6 | BsPinterest,
7 | BsSnapchat,
8 | BsTelegram,
9 | BsVimeo,
10 | BsWhatsapp
11 | } from 'react-icons/bs';
12 |
13 | export const socialMediaDataByName: Record = {
14 | Twitter: {
15 | name: 'Twitter',
16 | color: '#1DA1F2',
17 | icon:
18 | },
19 | Facebook: {
20 | name: 'Facebook',
21 | color: '#1877F2',
22 | icon:
23 | },
24 | Instagram: {
25 | name: 'Instagram',
26 | color: '#C13584',
27 | icon:
28 | },
29 | LinkedIn: {
30 | name: 'LinkedIn',
31 | color: '#0A66C2',
32 | icon:
33 | },
34 | YouTube: {
35 | name: 'YouTube',
36 | color: '#FF0000',
37 | icon:
38 | },
39 | Pinterest: {
40 | name: 'Pinterest',
41 | color: '#BD081C',
42 | icon:
43 | },
44 | Snapchat: {
45 | name: 'Snapchat',
46 | color: '#FFFC00',
47 | icon:
48 | },
49 | WhatsApp: {
50 | name: 'WhatsApp',
51 | color: '#25D366',
52 | icon:
53 | },
54 | Discord: {
55 | name: 'Discord',
56 | color: '#5865F2',
57 | icon:
58 | },
59 | Twitch: {
60 | name: 'Twitch',
61 | color: '#6441A5',
62 | icon:
63 | },
64 | Telegram: {
65 | name: 'Telegram',
66 | color: '#0088CC',
67 | icon:
68 | },
69 | Medium: {
70 | name: 'Medium',
71 | color: '#00AB6C',
72 | icon:
73 | },
74 | GitHub: {
75 | name: 'GitHub',
76 | color: '#181717',
77 | icon:
78 | },
79 | Vimeo: {
80 | name: 'Vimeo',
81 | color: '#1AB7EA',
82 | icon:
83 | }
84 | };
85 |
--------------------------------------------------------------------------------
/src/server/api/routers/user.ts:
--------------------------------------------------------------------------------
1 | import { TRPCError } from '@trpc/server';
2 | import bcrypt from 'bcrypt';
3 | import { createTRPCRouter, protectedProcedure, publicProcedure } from '@/server/api/trpc';
4 | import { RegisterSchema, UsernameSchema } from '../schemas';
5 | import { getUserByEmail, getUserById, getUserByUsername } from '../utils/user';
6 | import { db } from '@/server/db';
7 |
8 | export const userRouter = createTRPCRouter({
9 | createUser: publicProcedure.input(RegisterSchema).mutation(async ({ input }) => {
10 | const { email, password, username } = input;
11 | const hashedPassword = await bcrypt.hash(password, 10);
12 |
13 | const existingEmail = await getUserByEmail(email);
14 |
15 | if (existingEmail) {
16 | throw new TRPCError({ message: 'Email already in use!', code: 'BAD_REQUEST' });
17 | }
18 |
19 | const existingUsername = await getUserByUsername(username);
20 | if (existingUsername) {
21 | throw new TRPCError({
22 | message: 'Username already in use. Please use other name!',
23 | code: 'BAD_REQUEST'
24 | });
25 | }
26 |
27 | await db.user.create({
28 | data: {
29 | username,
30 | email,
31 | password: hashedPassword,
32 | name: username
33 | }
34 | });
35 |
36 | return { success: true, message: 'User created successfully ' };
37 | }),
38 | createUpdateUsername: protectedProcedure
39 | .input(UsernameSchema)
40 | .mutation(async ({ input, ctx }) => {
41 | const { username } = input;
42 | const { session } = ctx;
43 |
44 | const existingUsername = await getUserByUsername(username);
45 |
46 | // if (existingUsername?.id === session.user.id) {
47 | // throw new TRPCError({
48 | // message: "Current username and new username can't be same",
49 | // code: 'BAD_REQUEST'
50 | // });
51 | // }
52 | if (existingUsername && existingUsername?.id !== session.user.id) {
53 | throw new TRPCError({
54 | message: 'Username already in use. Please use other name!',
55 | cause: existingUsername.userProfile,
56 | code: 'BAD_REQUEST'
57 | });
58 | }
59 |
60 | const user = await getUserById(session.user.id);
61 | if (!user) throw new TRPCError({ message: 'User not found', code: 'NOT_FOUND' });
62 |
63 | const updatedUser = await db.user.update({
64 | where: { id: user.id },
65 | data: { username: username }
66 | });
67 |
68 | return { success: true, message: updatedUser.username };
69 | })
70 | });
71 |
--------------------------------------------------------------------------------
/src/server/api/routers/adhoc-links.ts:
--------------------------------------------------------------------------------
1 | import { createTRPCRouter, protectedProcedure, publicProcedure } from '@/server/api/trpc';
2 | import { db } from '@/server/db';
3 | import { AdhocLinks } from '@/types/types';
4 | import { EventType } from '@prisma/client';
5 | import { AdhocLinkSchema, LinkAnalytics, LinkInteraction } from '../schemas';
6 | import { getUser } from '../utils/user';
7 |
8 | export const adHocLinkRouter = createTRPCRouter({
9 | updateAdhocLinks: protectedProcedure.input(AdhocLinkSchema).mutation(async ({ input, ctx }) => {
10 | const user = await getUser({ ctx: ctx, includeSocialLink: true });
11 |
12 | const updatedAdhocLink = await db.adhocLink.upsert({
13 | where: {
14 | userId: user.id
15 | },
16 | update: {
17 | data: input as AdhocLinks[]
18 | },
19 | create: {
20 | data: input as AdhocLinks[],
21 | userId: user.id
22 | }
23 | });
24 | return updatedAdhocLink;
25 | }),
26 | getSocialLinks: protectedProcedure.query(async ({ ctx }) => {
27 | const user = await getUser({ ctx: ctx, includeSocialLink: true });
28 | return user;
29 | }),
30 | adhocLinkInteraction: publicProcedure.input(LinkInteraction).mutation(async ({ input }) => {
31 | const { adhocLinkId, userId } = input;
32 |
33 | const linkAnalytics = await db.linkAnalytics.findFirst({
34 | where: {
35 | userId: userId,
36 | adhocLinkId: adhocLinkId
37 | }
38 | });
39 |
40 | if (!linkAnalytics) {
41 | await db.linkAnalytics.create({
42 | data: {
43 | userId,
44 | eventType: EventType.CLICK,
45 | adhocLinkId
46 | }
47 | });
48 | } else {
49 | await db.linkAnalytics.update({
50 | where: {
51 | userId_adhocLinkId: {
52 | userId: userId,
53 | adhocLinkId: adhocLinkId
54 | }
55 | },
56 | data: {
57 | userId,
58 | eventType: EventType.CLICK,
59 | adhocLinkId,
60 | count: {
61 | increment: 1
62 | }
63 | }
64 | });
65 | }
66 |
67 | return { message: 'success' };
68 | }),
69 | getLinkAnalytics: protectedProcedure.input(LinkAnalytics).query(async ({ input, ctx }) => {
70 | const { session } = ctx;
71 | const { adhocLinkId } = input;
72 |
73 | const linkAnalytics = await db.linkAnalytics.findFirst({
74 | where: {
75 | userId: session?.user.id,
76 | adhocLinkId: adhocLinkId
77 | }
78 | });
79 |
80 | return linkAnalytics;
81 | })
82 | });
83 |
--------------------------------------------------------------------------------
/src/server/api/schemas/index.ts:
--------------------------------------------------------------------------------
1 | import { EventType } from '@prisma/client';
2 | import * as z from 'zod';
3 | export const UpdateProfileSchema = z.object({
4 | title: z
5 | .string({
6 | required_error: 'Title is required',
7 | invalid_type_error: 'Title must be a string'
8 | })
9 | .min(1),
10 | bio: z.string(),
11 | bioColor: z.string(),
12 | titleColor: z.string(),
13 | titleFontSize: z.string(),
14 | bioFontSize: z.string(),
15 | profilePicBorder: z.string()
16 | });
17 | export const AdhocLinkSchema = z
18 | .object({
19 | name: z.string({
20 | required_error: 'Title is required',
21 | invalid_type_error: 'Title must be a string'
22 | }),
23 | link: z.string(),
24 | id: z.string(),
25 | isActive: z.boolean(),
26 | theme: z.object({
27 | textAlign: z.string(),
28 | backgroundColor: z.string(),
29 | textColor: z.string(),
30 | borderColor: z.string(),
31 | borderRadius: z.string()
32 | })
33 | })
34 | .array();
35 |
36 | export const SocialLinkSchema = z.record(z.string(), z.string());
37 |
38 | export const LoginSchema = z.object({
39 | email: z
40 | .string()
41 | .min(1, {
42 | message: 'Email is required'
43 | })
44 | .email({
45 | message: 'Email is invalid'
46 | }),
47 | password: z.string().min(1, {
48 | message: 'Password is required'
49 | })
50 | });
51 |
52 | export const RegisterSchema = z.object({
53 | email: z
54 | .string()
55 | .min(1, {
56 | message: 'Email is required'
57 | })
58 | .email({
59 | message: 'Email is invalid'
60 | }),
61 | password: z.string().min(6, {
62 | message: 'Minimum 6 characters required'
63 | }),
64 | username: z.string().min(1, {
65 | message: 'Username is required'
66 | })
67 | });
68 | export const UsernameSchema = z.object({
69 | username: z
70 | .string()
71 | .min(1, {
72 | message: 'Username is required'
73 | })
74 | .refine((s) => !s.includes(' '), 'No spaces are allowed')
75 | });
76 |
77 | export const LinkInteraction = z.object({
78 | adhocLinkId: z.string(),
79 | userId: z.string(),
80 | eventType: z.enum([EventType.CLICK])
81 | });
82 | export const LinkAnalytics = z.object({
83 | adhocLinkId: z.string()
84 | });
85 |
86 | export const GeneralAppearanceSchema = z.object({
87 | hideBranding: z.boolean(),
88 | enableShareButton: z.boolean(),
89 | primaryBackgroundColor: z.string(),
90 | primaryBackgroundImage: z.string(),
91 | fontFamily: z.string(),
92 | linkCardShadow: z.string(),
93 | useSecondaryBackground: z.boolean(),
94 | secondaryBackgroundImage: z.string(),
95 | secondaryBackgroundColor: z.string()
96 | });
97 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/drag-overlay-wrapper.tsx:
--------------------------------------------------------------------------------
1 | import useDesigner from '@/hooks/use-designer';
2 | import { Active, DragOverlay, useDndMonitor } from '@dnd-kit/core';
3 | import { arrayMove } from '@dnd-kit/sortable';
4 | import { useState } from 'react';
5 | import AdhocLinkDrag from './links/adhoc-links/adhoc-links-drag';
6 | import SocialIconDrag from './links/social-links/social-icon-drag';
7 |
8 | export default function DragOverlayWrapper() {
9 | const { dispatch, state } = useDesigner();
10 | const [draggedItem, setDraggedItem] = useState(null);
11 | const [overDraggedParent, setOverDraggedParent] = useState(true);
12 | const draggedContainerID: string = draggedItem?.data?.current?.sortable?.containerId;
13 |
14 | useDndMonitor({
15 | onDragStart: (event) => {
16 | setDraggedItem(event.active);
17 | },
18 | onDragCancel: () => {
19 | setDraggedItem(null);
20 | },
21 | onDragOver: (event) => {
22 | setOverDraggedParent(!!event.over);
23 | },
24 | onDragEnd: (event) => {
25 | const { active, over } = event;
26 | if (!active || !over) return;
27 |
28 | if (draggedContainerID === 'social-icon') {
29 | const oldIndex = Object.keys(state.socialLinks).indexOf(active.id.toString());
30 | const newIndex = Object.keys(state.socialLinks).indexOf(over.id.toString());
31 | dispatch({
32 | type: 'UPDATE_SOCIAL_LINK',
33 | payload: Object.fromEntries(
34 | arrayMove(Object.entries(state.socialLinks), oldIndex, newIndex)
35 | )
36 | });
37 | } else if (draggedContainerID === 'adhoc-links') {
38 | const oldIndex = state.adhocLinks.findIndex((f) => f.id === active.id);
39 | const newIndex = state.adhocLinks.findIndex((f) => f.id === over.id);
40 | dispatch({
41 | type: 'UPDATE_ADHOC_LINK',
42 | payload: arrayMove(state.adhocLinks, oldIndex, newIndex)
43 | });
44 | }
45 | }
46 | });
47 | let node = No Element Selected
;
48 |
49 | if (draggedContainerID === 'social-icon') {
50 | const socialMediaName = draggedItem?.id.toString() ?? '';
51 | node = ;
52 | } else if (draggedContainerID === 'adhoc-links') {
53 | if (draggedItem?.data?.current?.sortable?.index !== undefined) {
54 | const draggedIndex: number = draggedItem?.data?.current?.sortable?.index;
55 | const draggedLink = state.adhocLinks[draggedIndex];
56 |
57 | // Ensure adhocLinks is defined before attempting to access its elements
58 | if (draggedLink) {
59 | node = ;
60 | }
61 | }
62 | }
63 | return {node} ;
64 | }
65 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/appearance/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState, useEffect } from 'react';
4 | import { Button } from '@/components/ui/button';
5 | import GeneralSetting from '../_components/appearance/general-appearance-setting/general-appearance-setting';
6 | import ProfileSection from '../_components/appearance/profile-section/profile-section';
7 | import useDesigner from '@/hooks/use-designer';
8 | import isequal from 'lodash.isequal';
9 | import { api } from '@/trpc/react';
10 | import { UserProfile } from '@prisma/client';
11 | import { Loader2 } from 'lucide-react';
12 |
13 | export default function Appearance() {
14 | const { state, initialValues } = useDesigner();
15 | const [isGeneralAppearanceStateEqual, setIsGeneralAppearanceStateEqual] = useState(true);
16 | const [isProfileStateEqual, setIsProfileStateEqual] = useState(true);
17 | const { isLoading: isLoadingGA, mutateAsync: updateGeneralPreference } =
18 | api.generalAppearance.updateGeneralAppearance.useMutation();
19 | const { isLoading: isLoadingUP, mutateAsync: updateProfile } =
20 | api.userProfile.updateUserProfile.useMutation();
21 |
22 | useEffect(() => {
23 | setIsGeneralAppearanceStateEqual(
24 | isequal(initialValues?.generalAppearance, state.generalAppearance)
25 | );
26 | setIsProfileStateEqual(isequal(initialValues?.userProfile, state.userProfile));
27 | }, [state, initialValues]);
28 |
29 | return (
30 | <>
31 | {
34 | // Update general preference
35 | await updateGeneralPreference({
36 | ...state.generalAppearance
37 | });
38 |
39 | // Update Profile picture
40 | const userProfileObj: Omit = {
41 | bio: state.userProfile.bio,
42 | title: state.userProfile.title,
43 | profilePicBorder: state.userProfile.profilePicBorder,
44 | bioColor: state.userProfile.bioColor,
45 | titleColor: state.userProfile.titleColor,
46 | titleFontSize: state.userProfile.titleFontSize,
47 | bioFontSize: state.userProfile.bioFontSize
48 | };
49 | await updateProfile({
50 | ...userProfileObj
51 | });
52 | }}
53 | variant='ghost'
54 | disabled={isGeneralAppearanceStateEqual && isProfileStateEqual} // Disable button if no changes detected
55 | >
56 | {isLoadingGA || isLoadingUP ? : null}
57 | {isLoadingGA || isLoadingUP ? 'Saving' : 'Save'}
58 |
59 |
60 |
61 | >
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/env.mjs:
--------------------------------------------------------------------------------
1 | import { createEnv } from '@t3-oss/env-nextjs';
2 | import { z } from 'zod';
3 |
4 | export const env = createEnv({
5 | /**
6 | * Specify your server-side environment variables schema here. This way you can ensure the app
7 | * isn't built with invalid env vars.
8 | */
9 | server: {
10 | DATABASE_URL: z
11 | .string()
12 | .url()
13 | .refine(
14 | (str) => !str.includes('YOUR_MYSQL_URL_HERE'),
15 | 'You forgot to change the default URL'
16 | ),
17 | NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
18 | NEXTAUTH_SECRET: process.env.NODE_ENV === 'production' ? z.string() : z.string().optional(),
19 | NEXTAUTH_URL: z.preprocess(
20 | // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL
21 | // Since NextAuth.js automatically uses the VERCEL_URL if present.
22 | (str) => process.env.VERCEL_URL ?? str,
23 | // VERCEL_URL doesn't include `https` so it cant be validated as a URL
24 | process.env.VERCEL ? z.string() : z.string().url()
25 | ),
26 | // Add ` on ID and SECRET if you want to make sure they're not empty
27 | GOOGLE_CLIENT_ID: z.string(),
28 | GOOGLE_CLIENT_SECRET: z.string(),
29 | UPLOAD_AWS_SECRET_ACCESS_KEY: z.string(),
30 | UPLOAD_AWS_ACCESS_KEY_ID: z.string(),
31 | UPLOAD_AWS_REGION: z.string(),
32 | UPLOAD_AWS_S3_BUCKET_NAME: z.string()
33 | },
34 |
35 | /**
36 | * Specify your client-side environment variables schema here. This way you can ensure the app
37 | * isn't built with invalid env vars. To expose them to the client, prefix them with
38 | * `NEXT_PUBLIC_`.
39 | */
40 | client: {
41 | // NEXT_PUBLIC_CLIENTVAR: z.string(),
42 | },
43 |
44 | /**
45 | * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
46 | * middlewares) or client-side so we need to destruct manually.
47 | */
48 | runtimeEnv: {
49 | DATABASE_URL: process.env.DATABASE_URL,
50 | NODE_ENV: process.env.NODE_ENV,
51 | NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
52 | NEXTAUTH_URL: process.env.NEXTAUTH_URL,
53 | GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
54 | GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
55 | UPLOAD_AWS_SECRET_ACCESS_KEY: process.env.UPLOAD_AWS_SECRET_ACCESS_KEY,
56 | UPLOAD_AWS_ACCESS_KEY_ID: process.env.UPLOAD_AWS_ACCESS_KEY_ID,
57 | UPLOAD_AWS_REGION: process.env.UPLOAD_AWS_REGION,
58 | UPLOAD_AWS_S3_BUCKET_NAME: process.env.UPLOAD_AWS_S3_BUCKET_NAME
59 | },
60 | /**
61 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
62 | * useful for Docker builds.
63 | */
64 | skipValidation: !!process.env.SKIP_ENV_VALIDATION,
65 | /**
66 | * Makes it so that empty strings are treated as undefined.
67 | * `SOME_VAR: z.string()` and `SOME_VAR=''` will throw an error.
68 | */
69 | emptyStringAsUndefined: true
70 | });
71 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import { type Config } from 'tailwindcss';
2 | import { fontFamily } from 'tailwindcss/defaultTheme';
3 |
4 | export default {
5 | darkMode: ['class'],
6 |
7 | content: ['./src/**/*.tsx', './components/**/*.{ts,tsx}', './app/**/*.{ts,tsx}'],
8 | theme: {
9 | container: {
10 | center: true,
11 | padding: '2rem',
12 | screens: {
13 | '2xl': '1400px'
14 | }
15 | },
16 | extend: {
17 | flexGrow: {
18 | 1: '1',
19 | 2: '2',
20 | 3: '3'
21 | },
22 | fontFamily: {
23 | sans: ['var(--font-sans)', ...fontFamily.sans]
24 | },
25 | colors: {
26 | border: 'hsl(var(--border))',
27 | input: 'hsl(var(--input))',
28 | ring: 'hsl(var(--ring))',
29 | background: 'hsl(var(--background))',
30 | foreground: 'hsl(var(--foreground))',
31 | primary: {
32 | DEFAULT: 'hsl(var(--primary))',
33 | foreground: 'hsl(var(--primary-foreground))'
34 | },
35 | secondary: {
36 | DEFAULT: 'hsl(var(--secondary))',
37 | foreground: 'hsl(var(--secondary-foreground))'
38 | },
39 | destructive: {
40 | DEFAULT: 'hsl(var(--destructive))',
41 | foreground: 'hsl(var(--destructive-foreground))'
42 | },
43 | muted: {
44 | DEFAULT: 'hsl(var(--muted))',
45 | foreground: 'hsl(var(--muted-foreground))'
46 | },
47 | accent: {
48 | DEFAULT: 'hsl(var(--accent))',
49 | foreground: 'hsl(var(--accent-foreground))'
50 | },
51 | popover: {
52 | DEFAULT: 'hsl(var(--popover))',
53 | foreground: 'hsl(var(--popover-foreground))'
54 | },
55 | card: {
56 | DEFAULT: 'hsl(var(--card))',
57 | foreground: 'hsl(var(--card-foreground))'
58 | }
59 | },
60 | keyframes: {
61 | 'border-spin': {
62 | '100%': {
63 | transform: 'rotate(-360deg)'
64 | }
65 | },
66 | slidein: {
67 | from: {
68 | opacity: '0',
69 | transform: 'translateY(-10px)'
70 | },
71 | to: {
72 | opacity: '1',
73 | transform: 'translateY(0)'
74 | }
75 | },
76 | 'accordion-down': {
77 | from: { height: '0' },
78 | to: { height: 'var(--radix-accordion-content-height)' }
79 | },
80 | 'accordion-up': {
81 | from: { height: 'var(--radix-accordion-content-height)' },
82 | to: { height: '0' }
83 | }
84 | },
85 | animation: {
86 | 'border-spin': 'border-spin 7s linear infinite',
87 | slidein: 'slidein 1s ease var(--slidein-delay, 0) forwards',
88 | 'accordion-down': 'accordion-down 0.2s ease-out',
89 | 'accordion-up': 'accordion-up 0.2s ease-out'
90 | }
91 | }
92 | },
93 | plugins: []
94 | } satisfies Config;
95 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html {
6 | scroll-behavior: smooth;
7 | }
8 | /* Firefox */
9 | * {
10 | scrollbar-width: thin;
11 | scrollbar-color: #e2e8f0;
12 | }
13 |
14 | /* Chrome, Edge, and Safari */
15 | *::-webkit-scrollbar {
16 | width: 10px;
17 | }
18 |
19 | *::-webkit-scrollbar-track {
20 | background: #e2e8f0;
21 | }
22 |
23 | *::-webkit-scrollbar-thumb {
24 | background-color: #cbd5e1;
25 | border: 3px solid #cbd5e1;
26 | }
27 | @layer base {
28 | :root {
29 | --background: 0 0% 100%;
30 | --foreground: 240 10% 3.9%;
31 |
32 | --card: 0 0% 100%;
33 | --card-foreground: 240 10% 3.9%;
34 |
35 | --popover: 0 0% 100%;
36 | --popover-foreground: 240 10% 3.9%;
37 |
38 | --primary: 240 5.9% 10%;
39 | --primary-foreground: 0 0% 98%;
40 |
41 | --secondary: 240 4.8% 95.9%;
42 | --secondary-foreground: 240 5.9% 10%;
43 |
44 | --muted: 240 4.8% 95.9%;
45 | --muted-foreground: 240 3.8% 46.1%;
46 |
47 | --accent: 240 4.8% 95.9%;
48 | --accent-foreground: 240 5.9% 10%;
49 |
50 | --destructive: 0 84.2% 60.2%;
51 | --destructive-foreground: 0 0% 98%;
52 |
53 | --border: 240 5.9% 90%;
54 | --input: 240 5.9% 90%;
55 | --ring: 240 10% 3.9%;
56 |
57 | --radius: 0.5rem;
58 | }
59 |
60 | .dark {
61 | --background: 240 10% 3.9%;
62 | --foreground: 0 0% 98%;
63 |
64 | --card: 240 10% 3.9%;
65 | --card-foreground: 0 0% 98%;
66 |
67 | --popover: 240 10% 3.9%;
68 | --popover-foreground: 0 0% 98%;
69 |
70 | --primary: 0 0% 98%;
71 | --primary-foreground: 240 5.9% 10%;
72 |
73 | --secondary: 240 3.7% 15.9%;
74 | --secondary-foreground: 0 0% 98%;
75 |
76 | --muted: 240 3.7% 15.9%;
77 | --muted-foreground: 240 5% 64.9%;
78 |
79 | --accent: 240 3.7% 15.9%;
80 | --accent-foreground: 0 0% 98%;
81 |
82 | --destructive: 0 62.8% 30.6%;
83 | --destructive-foreground: 0 0% 98%;
84 |
85 | --border: 240 3.7% 15.9%;
86 | --input: 240 3.7% 15.9%;
87 | --ring: 240 4.9% 83.9%;
88 | }
89 | }
90 |
91 | @layer utilities {
92 | .card-wrapper {
93 | @apply relative overflow-hidden rounded-full;
94 | }
95 |
96 | .card-wrapper::before {
97 | background: conic-gradient(
98 | rgba(37, 99, 235, 0.9) 0deg,
99 | rgba(124, 58, 237, 0.9) 0deg,
100 | transparent 180deg
101 | );
102 |
103 | @apply absolute left-[-5%] top-[21%] h-[75%] w-[115%] animate-border-spin content-[''];
104 | }
105 |
106 | /* Body */
107 | .card-content {
108 | @apply absolute left-[1px] top-[1px] h-[calc(100%-2px)] w-[calc(100%-2px)] rounded-full bg-white;
109 | }
110 |
111 | .wavy-background {
112 | background-color: #e5e5f7;
113 | opacity: 0.2;
114 | background-image: repeating-radial-gradient(circle at 0 0, transparent 0, #e5e5f7 10px),
115 | repeating-linear-gradient(#abbfae55, #abbfae);
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/links/social-links/social-icon-drag.tsx:
--------------------------------------------------------------------------------
1 | import useDesigner from '@/hooks/use-designer';
2 | import useHexToRGBA from '@/hooks/use-hex-to-rgba';
3 | import { cn } from '@/lib/utils';
4 | import { useSortable } from '@dnd-kit/sortable';
5 | import { CSS } from '@dnd-kit/utilities';
6 | import { GripVertical, MinusCircle, Pencil } from 'lucide-react';
7 | import SocialLinkDialog from '../../dialogs/social-icons-dialog';
8 | import { socialMediaDataByName } from '../../page-elements';
9 |
10 | const SocialIconDrag = ({
11 | data,
12 | value,
13 | outOfOverlay
14 | }: {
15 | data: string;
16 | value?: string;
17 | outOfOverlay?: boolean;
18 | }) => {
19 | const { color, name, icon } = socialMediaDataByName[data]!;
20 | const { dispatch, state } = useDesigner();
21 | const rgbaColor1 = useHexToRGBA(color!, 0.08);
22 | const rgbaColor2 = useHexToRGBA(color!, 0.2);
23 | const {
24 | attributes,
25 | listeners,
26 | setNodeRef,
27 | setActivatorNodeRef,
28 | transform,
29 | transition,
30 | isDragging,
31 | over
32 | } = useSortable({
33 | id: data
34 | });
35 |
36 | const style = {
37 | transform: CSS.Transform.toString(transform),
38 | transition
39 | };
40 |
41 | return (
42 |
48 |
56 |
64 |
65 |
66 |
67 |
68 |
{icon}
69 |
{name}
70 |
71 |
72 |
73 |
74 |
75 |
76 | {value && (
77 |
78 | {
80 | const linksCopy = { ...state.socialLinks };
81 | delete linksCopy[data];
82 | dispatch({
83 | type: 'UPDATE_SOCIAL_LINK',
84 | payload: linksCopy
85 | });
86 | }}
87 | size={16}
88 | className='text-[#D11A2A]'
89 | />
90 |
91 | )}
92 |
93 | );
94 | };
95 | export default SocialIconDrag;
96 |
--------------------------------------------------------------------------------
/prisma/migrations/20240818173248_changed_optional_to_required/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - Made the column `hideBranding` on table `GeneralAppearance` required. This step will fail if there are existing NULL values in that column.
5 | - Made the column `enableShareButton` on table `GeneralAppearance` required. This step will fail if there are existing NULL values in that column.
6 | - Made the column `primaryBackgroundColor` on table `GeneralAppearance` required. This step will fail if there are existing NULL values in that column.
7 | - Made the column `primaryBackgroundImage` on table `GeneralAppearance` required. This step will fail if there are existing NULL values in that column.
8 | - Made the column `fontFamily` on table `GeneralAppearance` required. This step will fail if there are existing NULL values in that column.
9 | - Made the column `secondaryBackgroundColor` on table `GeneralAppearance` required. This step will fail if there are existing NULL values in that column.
10 | - Made the column `secondaryBackgroundImage` on table `GeneralAppearance` required. This step will fail if there are existing NULL values in that column.
11 | - Made the column `useSecondaryBackground` on table `GeneralAppearance` required. This step will fail if there are existing NULL values in that column.
12 | - Made the column `linkCardShadow` on table `GeneralAppearance` required. This step will fail if there are existing NULL values in that column.
13 | - Made the column `bio` on table `UserProfile` required. This step will fail if there are existing NULL values in that column.
14 | - Made the column `pic` on table `UserProfile` required. This step will fail if there are existing NULL values in that column.
15 | - Made the column `bioColor` on table `UserProfile` required. This step will fail if there are existing NULL values in that column.
16 | - Made the column `bioFontSize` on table `UserProfile` required. This step will fail if there are existing NULL values in that column.
17 | - Made the column `titleColor` on table `UserProfile` required. This step will fail if there are existing NULL values in that column.
18 | - Made the column `titleFontSize` on table `UserProfile` required. This step will fail if there are existing NULL values in that column.
19 | - Made the column `profilePicBorder` on table `UserProfile` required. This step will fail if there are existing NULL values in that column.
20 |
21 | */
22 | -- AlterTable
23 | ALTER TABLE "GeneralAppearance" ALTER COLUMN "hideBranding" SET NOT NULL,
24 | ALTER COLUMN "enableShareButton" SET NOT NULL,
25 | ALTER COLUMN "primaryBackgroundColor" SET NOT NULL,
26 | ALTER COLUMN "primaryBackgroundImage" SET NOT NULL,
27 | ALTER COLUMN "fontFamily" SET NOT NULL,
28 | ALTER COLUMN "secondaryBackgroundColor" SET NOT NULL,
29 | ALTER COLUMN "secondaryBackgroundImage" SET NOT NULL,
30 | ALTER COLUMN "useSecondaryBackground" SET NOT NULL,
31 | ALTER COLUMN "linkCardShadow" SET NOT NULL;
32 |
33 | -- AlterTable
34 | ALTER TABLE "UserProfile" ALTER COLUMN "bio" SET NOT NULL,
35 | ALTER COLUMN "pic" SET NOT NULL,
36 | ALTER COLUMN "bioColor" SET NOT NULL,
37 | ALTER COLUMN "bioFontSize" SET NOT NULL,
38 | ALTER COLUMN "titleColor" SET NOT NULL,
39 | ALTER COLUMN "titleFontSize" SET NOT NULL,
40 | ALTER COLUMN "profilePicBorder" SET NOT NULL;
41 |
--------------------------------------------------------------------------------
/src/app/(landing)/_components/hero.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { MdKeyboardArrowRight } from 'react-icons/md';
3 | import Image from 'next/image';
4 | import Link from 'next/link';
5 | import { Button } from '@/components/ui/button';
6 | import { FaGithub } from 'react-icons/fa6';
7 |
8 | export default function Hero() {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 | Introducing Profilee
16 |
17 |
22 |
23 | Explore
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | Share every{' '}
35 |
36 | profile
37 | {' '}
38 |
39 | in a simple link
40 |
41 |
42 |
43 |
44 |
45 | Profilee is link in bio tool for everything you create, share or sell online. All from a
46 | single link.
47 |
48 |
49 |
50 |
51 |
57 |
58 |
Star us on Github
59 |
60 |
61 |
62 | );
63 | }
64 | // background: conic-gradient( rgba(244, 114, 182, 0.9) 0deg, rgba(192, 132, 252, 0.9) 0deg, transparent 180deg );
65 | // position: absolute;
66 | // left: -5%;
67 | // top: 40%;
68 | // height: 10%;
69 | // width: 115%;
70 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "profilee",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "build": "next build",
7 | "db:push": "prisma db push",
8 | "db:studio": "prisma studio",
9 | "dev": "next dev",
10 | "postinstall": "prisma generate",
11 | "lint": "next lint",
12 | "start": "next start"
13 | },
14 | "dependencies": {
15 | "@aws-sdk/client-s3": "^3.509.0",
16 | "@aws-sdk/s3-request-presigner": "^3.509.0",
17 | "@dnd-kit/core": "^6.0.8",
18 | "@dnd-kit/sortable": "^8.0.0",
19 | "@dnd-kit/utilities": "^3.2.2",
20 | "@hookform/resolvers": "^3.3.4",
21 | "@next-auth/prisma-adapter": "^1.0.7",
22 | "@prisma/client": "^5.1.1",
23 | "@radix-ui/react-accordion": "^1.2.0",
24 | "@radix-ui/react-collapsible": "^1.0.3",
25 | "@radix-ui/react-dialog": "^1.0.5",
26 | "@radix-ui/react-dropdown-menu": "^2.0.6",
27 | "@radix-ui/react-label": "^2.0.2",
28 | "@radix-ui/react-popover": "^1.0.7",
29 | "@radix-ui/react-progress": "^1.1.0",
30 | "@radix-ui/react-select": "^2.1.1",
31 | "@radix-ui/react-separator": "^1.1.0",
32 | "@radix-ui/react-slot": "^1.0.2",
33 | "@radix-ui/react-switch": "^1.1.0",
34 | "@radix-ui/react-tabs": "^1.1.0",
35 | "@radix-ui/react-toggle": "^1.1.0",
36 | "@radix-ui/react-toggle-group": "^1.1.0",
37 | "@radix-ui/react-tooltip": "^1.0.7",
38 | "@t3-oss/env-nextjs": "^0.7.0",
39 | "@tanstack/react-query": "^4.32.6",
40 | "@trpc/client": "^10.37.1",
41 | "@trpc/next": "^10.37.1",
42 | "@trpc/react-query": "^10.37.1",
43 | "@trpc/server": "^10.37.1",
44 | "@types/lodash.debounce": "^4.0.9",
45 | "@types/lodash.isequal": "^4.5.8",
46 | "@uploadthing/react": "^5.7.0",
47 | "bcrypt": "^5.1.1",
48 | "class-variance-authority": "^0.7.0",
49 | "clsx": "^2.0.0",
50 | "lodash.debounce": "^4.0.8",
51 | "lodash.isequal": "^4.5.0",
52 | "lucide-react": "^0.414.0",
53 | "next": "^14.1.0",
54 | "next-auth": "^4.23.0",
55 | "next-themes": "^0.2.1",
56 | "posthog-js": "^1.154.5",
57 | "react": "18.2.0",
58 | "react-colorful": "^5.6.1",
59 | "react-dom": "18.2.0",
60 | "react-dropzone": "^14.2.3",
61 | "react-hook-form": "^7.49.3",
62 | "react-icons": "^4.11.0",
63 | "react-image-crop": "^11.0.6",
64 | "react-loading-skeleton": "^3.3.1",
65 | "sonner": "^1.4.0",
66 | "superjson": "^1.13.1",
67 | "tailwind-merge": "^2.0.0",
68 | "tailwindcss-animate": "^1.0.7",
69 | "uuid": "^9.0.1",
70 | "zod": "^3.22.4"
71 | },
72 | "devDependencies": {
73 | "@types/bcrypt": "^5.0.2",
74 | "@types/eslint": "^8.44.2",
75 | "@types/node": "^18.16.0",
76 | "@types/react": "^18.2.20",
77 | "@types/react-dom": "^18.2.7",
78 | "@types/uuid": "^9.0.7",
79 | "@typescript-eslint/eslint-plugin": "^6.3.0",
80 | "@typescript-eslint/parser": "^6.3.0",
81 | "autoprefixer": "^10.4.14",
82 | "eslint": "^8.47.0",
83 | "eslint-config-next": "^14.1.0",
84 | "postcss": "^8.4.27",
85 | "prettier": "^3.0.0",
86 | "prettier-plugin-tailwindcss": "^0.5.1",
87 | "prisma": "^5.1.1",
88 | "tailwindcss": "^3.3.3",
89 | "typescript": "^5.1.6"
90 | },
91 | "ct3aMetadata": {
92 | "initVersion": "7.22.0"
93 | },
94 | "packageManager": "pnpm@8.9.0"
95 | }
96 |
--------------------------------------------------------------------------------
/public/profilee.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/links/adhoc-links/toggle-group-items/edit-appearance.tsx:
--------------------------------------------------------------------------------
1 | import { GradientPicker } from '@/components/gradient-color-picker';
2 | import useDesigner from '@/hooks/use-designer';
3 | import { AdhocLinks, BorderRadius, TextAlign } from '@/types/types';
4 | import React from 'react';
5 |
6 | interface EditAppearanceProps {
7 | data: AdhocLinks;
8 | }
9 |
10 | const EditAppearance: React.FC = ({ data }) => {
11 | const { state, dispatch } = useDesigner();
12 |
13 | // Update theme property directly
14 | const updateThemeProperty = (
15 | property: 'backgroundColor' | 'textColor' | 'borderColor',
16 | color: string
17 | ) => {
18 | const editIndex = state.adhocLinks.findIndex((val) => val.id === data.id);
19 |
20 | const updatedLinks = [...state.adhocLinks];
21 |
22 | const currentLink = updatedLinks[editIndex];
23 | if (updatedLinks[editIndex] && currentLink) {
24 | const updatedAdhocLink: AdhocLinks = {
25 | name: currentLink.name || '',
26 | id: currentLink.id || '',
27 | link: currentLink.link || '',
28 | isActive: currentLink.isActive ?? true,
29 | clicks: currentLink.clicks || 0,
30 | theme: {
31 | ...currentLink.theme,
32 | textAlign: currentLink.theme.textAlign ?? TextAlign.CENTER,
33 | borderRadius: currentLink.theme.borderRadius ?? BorderRadius.SM,
34 | [property]: color ?? ''
35 | }
36 | };
37 |
38 | updatedLinks[editIndex] = { ...updatedAdhocLink };
39 |
40 | dispatch({
41 | type: 'UPDATE_ADHOC_LINK',
42 | payload: updatedLinks
43 | });
44 | }
45 | };
46 |
47 | const handleBackgroundColorChange = React.useCallback(
48 | (color: string) => {
49 | updateThemeProperty('backgroundColor', color);
50 | },
51 | [updateThemeProperty]
52 | );
53 |
54 | const handleTextColorChange = React.useCallback(
55 | (color: string) => {
56 | updateThemeProperty('textColor', color);
57 | },
58 | [updateThemeProperty]
59 | );
60 |
61 | const handleBorderColorChange = React.useCallback(
62 | (color: string) => {
63 | updateThemeProperty('borderColor', color);
64 | },
65 | [updateThemeProperty]
66 | );
67 |
68 | return (
69 |
70 |
71 | Background Color
72 |
78 |
79 |
80 | Text Color
81 |
85 |
86 |
87 | Border Color
88 |
92 |
93 |
94 | );
95 | };
96 |
97 | export default EditAppearance;
98 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/dialogs/adhoc-links-dialog.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Button } from '@/components/ui/button';
4 | import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
5 | import { Input } from '@/components/ui/input';
6 | import useDesigner from '@/hooks/use-designer';
7 | import { AdhocLinks, BorderRadius, TextAlign } from '@/types/types';
8 | import { Dispatch, SetStateAction, useState } from 'react';
9 | import { v4 as uuidv4 } from 'uuid';
10 |
11 | // Dialog for edit and add Adhoc Link
12 | export default function AdhocLinksDialog({
13 | open,
14 | setOpen,
15 | data
16 | }: {
17 | open: boolean;
18 | setOpen: Dispatch>;
19 | data?: AdhocLinks;
20 | }) {
21 | const { dispatch, state } = useDesigner();
22 | const [inputTitle, setInputTitle] = useState(data?.name ?? '');
23 | const [inputLink, setInputLink] = useState(data?.link ?? '');
24 |
25 | return (
26 |
27 |
28 |
29 |
30 | {data ? 'Edit ' : 'Add '}
31 | link
32 |
33 |
34 |
35 | setInputTitle(e.target.value)}
40 | />
41 | setInputLink(e.target.value)}
46 | />
47 |
48 |
49 | {
52 | const editIndex = data?.id
53 | ? state.adhocLinks.findIndex((val) => val.id === data.id)
54 | : -1;
55 | const prevStateCopy = [...state.adhocLinks];
56 |
57 | const updatedAdhocLink: AdhocLinks = {
58 | name: inputTitle ?? '',
59 | id: uuidv4(),
60 | link: inputLink ?? '',
61 | isActive: true,
62 | clicks: 0,
63 | theme: {
64 | textAlign: TextAlign.CENTER,
65 | backgroundColor: '#fff',
66 | textColor: '#000',
67 | borderColor: '',
68 | borderRadius: BorderRadius.SM
69 | }
70 | };
71 |
72 | if (editIndex !== -1) {
73 | prevStateCopy[editIndex] = updatedAdhocLink;
74 | dispatch({
75 | type: 'UPDATE_ADHOC_LINK',
76 | payload: prevStateCopy
77 | });
78 | } else {
79 | dispatch({
80 | type: 'UPDATE_ADHOC_LINK',
81 | payload: [...prevStateCopy, updatedAdhocLink]
82 | });
83 | }
84 |
85 | setOpen(false);
86 | }}
87 | disabled={!inputLink || !inputTitle}
88 | className='w-full'
89 | >
90 | {data ? 'Edit' : 'Add'}
91 |
92 |
93 | setOpen(false)} className='w-full'>
94 | Close
95 |
96 |
97 |
98 |
99 | );
100 | }
101 |
--------------------------------------------------------------------------------
/src/app/(main)/claim/username/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { zodResolver } from '@hookform/resolvers/zod';
3 | import { useState } from 'react';
4 | import { useForm } from 'react-hook-form';
5 | import { FaExclamationTriangle } from 'react-icons/fa';
6 | import * as z from 'zod';
7 |
8 | import { AuthPagesWrapper } from '@/components/auth/auth-pages-wrapper';
9 | import { Alert, AlertTitle } from '@/components/ui/alert';
10 | import { Button } from '@/components/ui/button';
11 | import {
12 | Form,
13 | FormControl,
14 | FormDescription,
15 | FormField,
16 | FormItem,
17 | FormLabel,
18 | FormMessage
19 | } from '@/components/ui/form';
20 | import { Input } from '@/components/ui/input';
21 | import { UsernameSchema } from '@/server/api/schemas';
22 | import { api } from '@/trpc/react';
23 | import { TRPCClientError } from '@trpc/client';
24 | import { signIn } from 'next-auth/react';
25 | import { useRouter } from 'next/navigation';
26 | import { FaRegArrowAltCircleRight } from 'react-icons/fa';
27 |
28 | export default function ClaimUsername() {
29 | const [error, setError] = useState('');
30 | const router = useRouter();
31 | const form = useForm>({
32 | resolver: zodResolver(UsernameSchema),
33 | defaultValues: {
34 | username: ''
35 | }
36 | });
37 | const { isLoading, mutateAsync: addUsername } = api.user.createUpdateUsername.useMutation({
38 | onError: (error) => {
39 | if (error instanceof TRPCClientError) {
40 | if (error.message) {
41 | setError(error.message);
42 | }
43 | }
44 | },
45 | onSuccess: async (data) => {
46 | if (data.success) {
47 | // TODO : Need to update this one
48 | await signIn('jwt', {
49 | redirect: false
50 | });
51 | router.push('/builder');
52 | }
53 | }
54 | });
55 |
56 | const onSubmit = async (values: z.infer) => {
57 | setError('');
58 | await addUsername(values);
59 | };
60 | return (
61 |
62 |
98 |
99 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/todo.js:
--------------------------------------------------------------------------------
1 |
2 | // ### 1. **Link Customization**
3 | // - **Title**: Allow users to set custom titles for each link.
4 | // - **URL**: Enable users to add and edit URLs easily.
5 | // - **Icons and Thumbnails**: Provide options to upload custom icons or thumbnails for links.
6 | // - **Description**: Allow adding short descriptions under each link for more context.
7 |
8 | // ### 2. **Design Customization**
9 | // - **Themes and Templates**: Offer a variety of themes and templates that users can choose from to match their branding.
10 | // - **Colors**: Allow customization of background colors, button colors, text colors, and link colors.
11 | // - **Fonts**: Provide a selection of fonts or allow integration with Google Fonts.
12 | // - **Button Styles**: Offer different button shapes, sizes, and styles (e.g., rounded, square, shadow effects).
13 |
14 | // ### 3. **Layout Customization**
15 | // - **Link Arrangement**: Enable drag-and-drop functionality for users to arrange the order of links.
16 | // - **Sections and Dividers**: Allow users to organize links into sections with custom headers and dividers.
17 | // - **Grid or List Views**: Provide options for displaying links in a grid or list format.
18 |
19 | // ### 4. **Media Integration**
20 | // - **Images and Videos**: Allow users to embed images or videos directly into their link profiles.
21 | // - **Social Media Icons**: Include options to add social media icons with links to their respective profiles.
22 |
23 | // ### 5. **Analytics**
24 | // - **Link Click Tracking**: Provide analytics to track the number of clicks each link receives.
25 | // - **Visitor Analytics**: Offer insights into visitor demographics, location, and device usage.
26 |
27 | // ### 6. **Custom Domains**
28 | // - **Branded URLs**: Allow users to connect their own domain names for a more professional look.
29 | // - **Subdomains**: Provide the option to create subdomains under your primary domain (e.g., username.yourdomain.com).
30 |
31 | // ### 7. **SEO and Metadata**
32 | // - **Meta Titles and Descriptions**: Allow users to set meta titles and descriptions for better SEO.
33 | // - **Open Graph Tags**: Enable customization of Open Graph tags for better social media sharing previews.
34 |
35 | // ### 8. **Interactive Elements**
36 | // - **Contact Forms**: Provide the ability to add custom contact forms.
37 | // - **Polls and Surveys**: Allow embedding of polls or surveys for more interactive engagement.
38 |
39 | // ### 9. **Integration with Other Services**
40 | // - **Email Marketing**: Integrate with email marketing tools like Mailchimp or ConvertKit.
41 | // - **E-commerce**: Allow linking to products or services directly, with integration options for platforms like Shopify or WooCommerce.
42 | // - **Calendars**: Enable embedding of calendar links for booking appointments or events.
43 |
44 | // ### 10. **Advanced Customization**
45 | // - **Custom CSS**: Provide an option for advanced users to add custom CSS for further design tweaks.
46 | // - **API Access**: Offer API access for developers to integrate with other systems or automate link management.
47 |
48 | // ### Inspiration and Competitor Features
49 | // - **Linktree**: Known for its simplicity and ease of use. Offers basic design customization and analytics.
50 | // - **Campsite**: Focuses on more extensive customization options, including custom domains and detailed analytics.
51 | // - **Beacons**: Offers rich media integration and advanced customization features, including contact forms and email capture.
52 |
53 | // By offering these customization options, you can create a flexible and user-friendly "link in bio" builder that meets the needs of a wide range of users, from influencers and content creators to businesses and professionals.
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/appearance/general-appearance-setting/general-appearance-setting.tsx:
--------------------------------------------------------------------------------
1 | import FontPicker from '@/components/font-picker';
2 | import { GradientPicker } from '@/components/gradient-color-picker';
3 | import { Card } from '@/components/ui/card';
4 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
5 | import { Separator } from '@/components/ui/separator';
6 | import { Switch } from '@/components/ui/switch';
7 | import useDesigner from '@/hooks/use-designer';
8 | import { Cog } from 'lucide-react';
9 | import { MdOutlineExpandMore } from 'react-icons/md';
10 |
11 | export default function GeneralSetting() {
12 | const { state, dispatch } = useDesigner();
13 |
14 | const handleChangeBackgroundColor = (color: string) => {
15 | dispatch({
16 | type: 'UPDATE_PRIMARY_BACKGROUND_COLOR',
17 | payload: color
18 | });
19 | };
20 | const handleChangeSecondaryBackgroundColor = (color: string) => {
21 | dispatch({
22 | type: 'UPDATE_SECONDARY_BACKGROUND_COLOR',
23 | payload: color
24 | });
25 | };
26 |
27 | return (
28 |
29 |
30 |
31 |
32 | General
33 |
34 |
35 |
36 |
37 |
38 |
39 | Primary background color
40 |
45 |
46 |
47 |
48 | Secondary Background
49 | {
53 | dispatch({
54 | type: 'IS_SECONDARY_BACKGROUND',
55 | payload: e
56 | });
57 | }}
58 | />
59 |
60 | {state.generalAppearance.useSecondaryBackground && (
61 | <>
62 |
63 | Secondary background color
64 |
70 |
71 | >
72 | )}
73 |
74 |
75 | Font Family
76 |
77 |
78 |
79 |
80 |
81 |
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/src/components/auth/auth-pages-wrapper.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Button } from '@/components/ui/button';
4 | import { OAUTH_REDIRECT } from '@/lib/constants';
5 | import { signIn } from 'next-auth/react';
6 | import { FcGoogle } from 'react-icons/fc';
7 | import Image from 'next/image';
8 | import Link from 'next/link';
9 | import Logo from '../logo';
10 |
11 | interface AuthPagesWrapper {
12 | children: React.ReactNode;
13 | pageTitle: string;
14 | flow: 'signup' | 'signin' | 'newuser';
15 | pageSubTitle?: string;
16 | }
17 |
18 | export function AuthPagesWrapper({ children, pageTitle, pageSubTitle, flow }: AuthPagesWrapper) {
19 | const onClick = async () => {
20 | const res = await signIn('google', {
21 | callbackUrl: OAUTH_REDIRECT
22 | });
23 | };
24 | return (
25 | <>
26 |
27 |
32 |
37 |
38 |
39 |
40 |
{pageTitle}
41 |
{pageSubTitle}
42 |
43 | {flow !== 'newuser' && (
44 | <>
45 |
46 |
47 | {flow === 'signin' ? 'Sign In' : 'Sign up'} with Google
48 |
49 |
50 |
51 |
52 |
53 |
54 | Or continue with
55 |
56 |
57 | >
58 | )}
59 | {children}
60 | {flow !== 'newuser' && (
61 | <>
62 |
63 |
64 | >
65 | )}
66 |
67 |
68 |
69 | >
70 | );
71 | }
72 |
73 | const AlternateAuthOption = ({ flow }: { flow: 'signup' | 'signin' }) => {
74 | return (
75 |
76 | {flow == 'signup' ? 'Already have an account? ' : "Don't have an account? "}
77 |
81 | {flow == 'signup' ? 'Sign In' : 'Sign up'}
82 |
83 |
84 | );
85 | };
86 |
87 | const TermAndPolicy = () => {
88 | return (
89 |
90 | By clicking continue, you agree to our{' '}
91 |
92 | Terms of Service
93 | {' '}
94 | and{' '}
95 |
96 | Privacy Policy
97 |
98 | .
99 |
100 | );
101 | };
102 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/dialogs/image-crop-dialog.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Button } from '@/components/ui/button';
4 | import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
5 | import { Dispatch, SetStateAction, useRef, useState } from 'react';
6 | import ReactCrop, { centerCrop, makeAspectCrop, PercentCrop, PixelCrop } from 'react-image-crop';
7 | import 'react-image-crop/dist/ReactCrop.css';
8 | import setCanvasPreview from '../appearance/profile-section/set-canvas-preview';
9 |
10 | function dataURLtoBlob(dataurl: string) {
11 | const arr = dataurl.split(',');
12 | const match = arr[0]?.match(/:(.*?);/);
13 | const mime = match ? match[1] : ''; // Fallback to an empty string if match is null
14 | if (!arr[1]) {
15 | throw new Error('Invalid data URL'); // Or handle it another way
16 | }
17 | const bstr = atob(arr[1]);
18 | let n = bstr.length;
19 | const u8arr = new Uint8Array(n);
20 | while (n--) {
21 | u8arr[n] = bstr.charCodeAt(n);
22 | }
23 | return new Blob([u8arr], { type: mime });
24 | }
25 | const ASPECT_RATIO = 1;
26 | const MIN_DIMENSION = 150;
27 | // Dialog for image crop
28 | export default function ImageCropDialog({
29 | open,
30 | setOpen,
31 | imgSrcUrl,
32 | onCropComplete
33 | }: {
34 | open: boolean;
35 | setOpen: Dispatch>;
36 | imgSrcUrl: string;
37 | onCropComplete: (croppedImageBlob: Blob) => void;
38 | }) {
39 | const imgRef = useRef(null);
40 | const previewCanvasRef = useRef(null);
41 |
42 | const [crop, setCrop] = useState();
43 | const [completedCrop, setCompletedCrop] = useState();
44 |
45 | const onImageLoad = (e: React.SyntheticEvent) => {
46 | const { width, height } = e.currentTarget;
47 |
48 | const cropWidthInPercent = (MIN_DIMENSION / width) * 100;
49 |
50 | const crop = makeAspectCrop(
51 | {
52 | unit: '%',
53 | width: cropWidthInPercent
54 | },
55 | ASPECT_RATIO,
56 | width,
57 | height
58 | );
59 | const centeredCrop = centerCrop(crop, width, height);
60 | setCrop(centeredCrop);
61 | };
62 |
63 | const handleCrop = () => {
64 | if (imgRef.current && previewCanvasRef.current && completedCrop) {
65 | setCanvasPreview(imgRef.current, previewCanvasRef.current, completedCrop);
66 | const dataUrl = previewCanvasRef.current.toDataURL();
67 | // You can now use the dataUrl as needed
68 | setOpen(false);
69 | onCropComplete(dataURLtoBlob(dataUrl));
70 | }
71 | };
72 |
73 | return (
74 |
75 |
76 |
77 | Crop image
78 |
79 | setCrop(percentCrop)}
82 | onComplete={(c) => setCompletedCrop(c)}
83 | circularCrop
84 | keepSelection
85 | aspect={ASPECT_RATIO}
86 | minWidth={MIN_DIMENSION}
87 | maxHeight={250}
88 | >
89 | {/* */}
90 |
91 |
92 |
93 |
94 |
95 | Crop
96 |
97 |
98 |
99 | {completedCrop && (
100 |
111 | )}
112 |
113 | );
114 | }
115 |
--------------------------------------------------------------------------------
/src/components/auth/login-account-card.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import * as z from 'zod';
3 | import { useState, useTransition } from 'react';
4 | import { useForm } from 'react-hook-form';
5 | import { zodResolver } from '@hookform/resolvers/zod';
6 | import { FaExclamationTriangle } from 'react-icons/fa';
7 |
8 | import { AuthPagesWrapper } from '@/components/auth/auth-pages-wrapper';
9 | import { Input } from '@/components/ui/input';
10 | import { Button } from '@/components/ui/button';
11 | import { LoginSchema } from '@/server/api/schemas';
12 | import {
13 | Form,
14 | FormControl,
15 | FormField,
16 | FormItem,
17 | FormLabel,
18 | FormMessage
19 | } from '@/components/ui/form';
20 | import { signIn } from 'next-auth/react';
21 | import { Alert, AlertTitle } from '../ui/alert';
22 | import { useSearchParams } from 'next/navigation';
23 | export default function LoginAccountCard() {
24 | const searchParams = useSearchParams();
25 | const [error, setError] = useState('');
26 | const [success, setSuccess] = useState('');
27 | const [isPending, startTransition] = useTransition();
28 | const urlError =
29 | searchParams.get('error') === 'OAuthAccountNotFound'
30 | ? 'User not found with this email! Please create account first'
31 | : '';
32 | const form = useForm>({
33 | resolver: zodResolver(LoginSchema),
34 | defaultValues: {
35 | email: '',
36 | password: ''
37 | }
38 | });
39 |
40 | const onSubmit = (values: z.infer) => {
41 | setError('');
42 | setSuccess('');
43 | startTransition(async () => {
44 | const response = await signIn('credentials', {
45 | ...values,
46 | redirect: false
47 | });
48 |
49 | if (response?.error) {
50 | setError(response.error);
51 | }
52 | });
53 | };
54 | return (
55 |
56 | {error && (
57 |
58 |
59 | {error}
60 |
61 | )}
62 | {urlError && (
63 |
64 |
65 | {urlError}
66 |
67 | )}
68 |
115 |
116 |
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/elements/username.tsx:
--------------------------------------------------------------------------------
1 | import { Input } from '@/components/ui/input';
2 | import useDesigner from '@/hooks/use-designer';
3 | import { cn } from '@/lib/utils';
4 | import { UsernameSchema } from '@/server/api/schemas';
5 | import { api } from '@/trpc/react';
6 | import { TRPCClientError } from '@trpc/client';
7 | import debounce from 'lodash.debounce';
8 | import Link from 'next/link';
9 | import React, { useCallback, useEffect, useState } from 'react';
10 | import { toast } from 'sonner';
11 | import { z } from 'zod';
12 | import { MdOpenInNew } from 'react-icons/md';
13 | import { LuCopy } from 'react-icons/lu';
14 |
15 | const UsernameSettings = () => {
16 | const [isPublishing, setIsPublishing] = useState(false); //FIX this
17 | const { dispatch, state } = useDesigner();
18 | const [usernameError, setUsernameError] = useState('');
19 | const origin = typeof window !== 'undefined' ? window.location.origin + '/' : '';
20 |
21 | const { isLoading, mutateAsync: updateUsername } = api.user.createUpdateUsername.useMutation({
22 | onSuccess: (res) => {
23 | setUsernameError('');
24 | dispatch({
25 | type: 'UPDATE_USERNAME',
26 | payload: res.message ?? state.userProfile.username
27 | });
28 | },
29 | onError: (error) => {
30 | if (error instanceof TRPCClientError) {
31 | if (error.message) {
32 | toast.error(error.message);
33 | setUsernameError(error.message);
34 | }
35 | }
36 | }
37 | });
38 |
39 | useEffect(() => {
40 | setIsPublishing(isLoading);
41 | }, [isLoading]);
42 |
43 | const updatingUsername = async (value: z.infer) => {
44 | await updateUsername(value);
45 | };
46 |
47 | const debouncedInputHandler = useCallback(debounce(updatingUsername, 700), []);
48 |
49 | const inputHandler = async (e: React.ChangeEvent) => {
50 | const { value } = e.target;
51 | const parsedUsername = UsernameSchema.safeParse({ username: value });
52 |
53 | if (!parsedUsername.success) {
54 | setUsernameError(
55 | parsedUsername.error.format().username?._errors[0] ?? 'Not a valid username'
56 | );
57 | return;
58 | }
59 | if (value.length > 0) {
60 | dispatch({
61 | type: 'UPDATE_USERNAME',
62 | payload: value
63 | });
64 | if (value.trim() !== state.userProfile.username.trim()) {
65 | await debouncedInputHandler({ username: value });
66 | }
67 | setUsernameError('');
68 | } else {
69 | setUsernameError('Username is required');
70 | }
71 | };
72 |
73 | return (
74 |
75 |
76 |
77 |
{' '}
87 | {usernameError && (
88 |
{usernameError}
89 | )}
90 |
91 |
{
93 | await navigator.clipboard.writeText(`${origin}/${state.userProfile.username}`);
94 | toast.success('URL copied to clipboard!');
95 | }}
96 | className='p-1 ml-2 cursor-pointer hover:border-neutral-500'
97 | >
98 |
99 |
100 |
105 |
{' '}
106 |
107 |
108 |
109 | );
110 | };
111 |
112 | export default UsernameSettings;
113 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/preview/webpage.tsx:
--------------------------------------------------------------------------------
1 | import useDesigner from '@/hooks/use-designer';
2 | import Image from 'next/image';
3 | import Link from 'next/link';
4 | import { socialMediaDataByName } from '../page-elements';
5 |
6 | export default function Webpage() {
7 | const { state } = useDesigner();
8 |
9 | return (
10 |
14 | {/* Secondary Background */}
15 | {state.generalAppearance.useSecondaryBackground && (
16 |
20 |
21 | {state.userProfile.pic && (
22 |
33 | )}
34 |
35 |
36 | )}
37 |
38 | {/* Primary Background */}
39 | {!state.generalAppearance.useSecondaryBackground && (
40 |
41 | {state.userProfile.pic && (
42 |
53 | )}
54 |
55 | )}
56 |
57 | {/* Title and Bio */}
58 |
63 |
64 |
65 | {state.userProfile.title}
66 |
67 |
68 | {state.userProfile.bio}
69 |
70 |
71 |
72 |
73 | {/* Social Links */}
74 |
75 | {Object.entries(state.socialLinks).length > 0 && (
76 |
77 | {Object.entries(state.socialLinks).map(([platform, value]) => (
78 |
79 | {socialMediaDataByName[platform]?.icon}
80 |
81 | ))}
82 |
83 | )}
84 |
85 | {/* Adhoc Links */}
86 | {state.adhocLinks?.map((link) => {
87 | return link.isActive ? (
88 |
89 |
99 | {link.name}
100 |
101 |
102 | ) : null;
103 | })}
104 |
105 |
106 | );
107 | }
108 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/src/server/api/trpc.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
3 | * 1. You want to modify request context (see Part 1).
4 | * 2. You want to create a new middleware or type of procedure (see Part 3).
5 | *
6 | * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will
7 | * need to use are documented accordingly near the end.
8 | */
9 |
10 | import { initTRPC, TRPCError } from '@trpc/server';
11 | import { type NextRequest } from 'next/server';
12 | import superjson from 'superjson';
13 | import { ZodError } from 'zod';
14 |
15 | import { getServerAuthSession } from '@/server/auth';
16 | import { db } from '@/server/db';
17 |
18 | /**
19 | * 1. CONTEXT
20 | *
21 | * This section defines the "contexts" that are available in the backend API.
22 | *
23 | * These allow you to access things when processing a request, like the database, the session, etc.
24 | */
25 |
26 | interface CreateContextOptions {
27 | headers: Headers;
28 | }
29 |
30 | /**
31 | * This helper generates the "internals" for a tRPC context. If you need to use it, you can export
32 | * it from here.
33 | *
34 | * Examples of things you may need it for:
35 | * - testing, so we don't have to mock Next.js' req/res
36 | * - tRPC's `createSSGHelpers`, where we don't have req/res
37 | *
38 | * @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts
39 | */
40 | export const createInnerTRPCContext = async (opts: CreateContextOptions) => {
41 | const session = await getServerAuthSession();
42 |
43 | return {
44 | session,
45 | headers: opts.headers,
46 | db
47 | };
48 | };
49 | export type Context = Awaited>;
50 |
51 | /**
52 | * This is the actual context you will use in your router. It will be used to process every request
53 | * that goes through your tRPC endpoint.
54 | *
55 | * @see https://trpc.io/docs/context
56 | */
57 | export const createTRPCContext = async (opts: { req: NextRequest }) => {
58 | // Fetch stuff that depends on the request
59 |
60 | return await createInnerTRPCContext({
61 | headers: opts.req.headers
62 | });
63 | };
64 |
65 | /**
66 | * 2. INITIALIZATION
67 | *
68 | * This is where the tRPC API is initialized, connecting the context and transformer. We also parse
69 | * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation
70 | * errors on the backend.
71 | */
72 |
73 | const t = initTRPC.context().create({
74 | transformer: superjson,
75 | errorFormatter({ shape, error }) {
76 | return {
77 | ...shape,
78 | data: {
79 | ...shape.data,
80 | zodError: error.cause instanceof ZodError ? error.cause.flatten() : null
81 | }
82 | };
83 | }
84 | });
85 |
86 | /**
87 | * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
88 | *
89 | * These are the pieces you use to build your tRPC API. You should import these a lot in the
90 | * "/src/server/api/routers" directory.
91 | */
92 |
93 | /**
94 | * This is how you create new routers and sub-routers in your tRPC API.
95 | *
96 | * @see https://trpc.io/docs/router
97 | */
98 | export const createTRPCRouter = t.router;
99 |
100 | /**
101 | * Public (unauthenticated) procedure
102 | *
103 | * This is the base piece you use to build new queries and mutations on your tRPC API. It does not
104 | * guarantee that a user querying is authorized, but you can still access user session data if they
105 | * are logged in.
106 | */
107 | export const publicProcedure = t.procedure;
108 |
109 | /** Reusable middleware that enforces users are logged in before running the procedure. */
110 | const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
111 | if (!ctx.session || !ctx.session.user) {
112 | throw new TRPCError({ code: 'UNAUTHORIZED' });
113 | }
114 | return next({
115 | ctx: {
116 | // infers the `session` as non-nullable
117 | session: { ...ctx.session, user: ctx.session.user }
118 | }
119 | });
120 | });
121 |
122 | /**
123 | * Protected (authenticated) procedure
124 | *
125 | * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies
126 | * the session is valid and guarantees `ctx.session.user` is not null.
127 | *
128 | * @see https://trpc.io/docs/procedures
129 | */
130 | export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
131 |
--------------------------------------------------------------------------------
/src/server/auth.ts:
--------------------------------------------------------------------------------
1 | import { PrismaAdapter } from '@next-auth/prisma-adapter';
2 | import { getServerSession, type DefaultSession, type NextAuthOptions } from 'next-auth';
3 | import bcrypt from 'bcrypt';
4 | import GoogleProvider from 'next-auth/providers/google';
5 | import CredentialsProvider from 'next-auth/providers/credentials';
6 |
7 | import { env } from '@/env.mjs';
8 | import { db } from '@/server/db';
9 | import { TRPCError } from '@trpc/server';
10 | import { LoginSchema } from './api/schemas';
11 | import { getUserByEmail, getUserById } from './api/utils/user';
12 |
13 | /**
14 | * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
15 | * object and keep type safety.
16 | *
17 | * @see https://next-auth.js.org/getting-started/typescript#module-augmentation
18 | */
19 | declare module 'next-auth' {
20 | interface Session extends DefaultSession {
21 | user: {
22 | id: string;
23 | username: string;
24 | } & DefaultSession['user'];
25 | }
26 |
27 | // interface User {
28 | // // ...other properties
29 | // // role: UserRole;
30 | // }
31 | }
32 |
33 | /**
34 | * Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
35 | *
36 | * @see https://next-auth.js.org/configuration/options
37 | */
38 |
39 | export const authOptions: NextAuthOptions = {
40 | pages: {
41 | signIn: '/auth/login',
42 | newUser: '/claim/username'
43 | },
44 | // events: {
45 | // updateUser(props) {
46 | // console.log('----------', { props });
47 | // },
48 | // session(props) {
49 | // console.log('----22222--------------', { props });
50 | // }
51 | // linkAccount(props) {
52 | // console.log('1111', { props });
53 | // }
54 | // },
55 | callbacks: {
56 | async jwt({ token }) {
57 | if (!token.sub) return token;
58 |
59 | const existingUser = await getUserById(token.sub);
60 |
61 | if (!existingUser) return token;
62 |
63 | token.name = existingUser.name;
64 | token.email = existingUser.email;
65 | token.username = existingUser.username;
66 |
67 | return token;
68 | },
69 | session({ token, session }) {
70 | if (token.sub && session.user) {
71 | session.user.id = token.sub;
72 | }
73 |
74 | if (session.user) {
75 | session.user.name = token.name;
76 | session.user.email = token.email;
77 | session.user.username = token.username as string;
78 | }
79 | // console.log('🚀 ~ session ~ session:', { session, token });
80 |
81 | return session;
82 | }
83 | },
84 | adapter: PrismaAdapter(db),
85 | providers: [
86 | CredentialsProvider({
87 | name: 'Credentials',
88 | credentials: {
89 | email: { label: 'email', type: 'text' },
90 | password: { label: 'Password', type: 'password' }
91 | },
92 | async authorize(credentials) {
93 | if (!credentials?.email || !credentials?.password)
94 | throw new TRPCError({
95 | code: 'UNAUTHORIZED',
96 | message: 'Invalid Credentials.'
97 | });
98 |
99 | const validatedFields = LoginSchema.safeParse(credentials);
100 |
101 | if (validatedFields.success) {
102 | const { email, password } = validatedFields.data;
103 |
104 | const user = await getUserByEmail(email);
105 | if (!user?.password) {
106 | throw new TRPCError({
107 | code: 'NOT_FOUND',
108 | message: 'User not found with this email.'
109 | });
110 | }
111 |
112 | const isValidPassword = await bcrypt.compare(password, user.password);
113 | if (!isValidPassword)
114 | throw new TRPCError({
115 | code: 'UNAUTHORIZED',
116 | message: 'Invalid Password.'
117 | });
118 |
119 | if (isValidPassword) return user;
120 | }
121 |
122 | return null;
123 | }
124 | }),
125 | GoogleProvider({
126 | clientId: env.GOOGLE_CLIENT_ID,
127 | clientSecret: env.GOOGLE_CLIENT_SECRET
128 | })
129 | ],
130 | session: { strategy: 'jwt' }
131 | };
132 |
133 | /**
134 | * Wrapper for `getServerSession` so that you don't need to import the `authOptions` in every file.
135 | *
136 | * @see https://next-auth.js.org/configuration/nextjs
137 | */
138 | export const getServerAuthSession = () => getServerSession(authOptions);
139 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/preview/preview.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import useDesigner from '@/hooks/use-designer';
4 | import { cn } from '@/lib/utils';
5 | import { preview } from '@/types/types';
6 | import { Laptop2, Smartphone } from 'lucide-react';
7 | import { useState } from 'react';
8 | import Skeleton from 'react-loading-skeleton';
9 | import Webpage from './webpage';
10 | import WebpageServer from '@/app/[link]/_components/WebPage';
11 |
12 | export default function Preview() {
13 | const { state } = useDesigner();
14 | const [previewMode, setPreviewMode] = useState('mobile');
15 | // const { loading } = useDesigner(); // TODO remove this
16 |
17 | const loading = false;
18 |
19 | return (
20 |
21 |
22 |
23 |
28 |
29 | setPreviewMode('mobile')}
31 | className={cn(
32 | `h-9 w-9 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-200 rounded-lg`,
33 | previewMode === 'mobile' ? 'text-gray-800 bg-gray-200' : ''
34 | )}
35 | />
36 | setPreviewMode('desktop')}
38 | className={cn(
39 | `h-9 w-9 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-200 rounded-lg`,
40 | previewMode === 'desktop' ? 'text-gray-800 bg-gray-200' : ''
41 | )}
42 | />
43 |
44 |
45 |
46 |
47 | {previewMode === 'mobile' && (
48 |
49 |
50 | {/* */}
51 |
52 |
59 |
60 |
61 | )}
62 | {previewMode === 'desktop' && (
63 |
74 | )}
75 |
76 |
77 |
78 | );
79 | }
80 |
81 | const ViewWrapper = ({ loading }: { loading: boolean; mode?: string }) => {
82 | if (loading) {
83 | return (
84 | <>
85 |
90 |
91 | >
92 | );
93 | }
94 |
95 | return (
96 |
103 | );
104 | };
105 |
--------------------------------------------------------------------------------
/src/app/(main)/builder/_components/appearance/profile-section/profile-section.tsx:
--------------------------------------------------------------------------------
1 | import { Card } from '@/components/ui/card';
2 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
3 | import { Input } from '@/components/ui/input';
4 | import { Label } from '@/components/ui/label';
5 | import { Textarea } from '@/components/ui/textarea';
6 | import { Toggle } from '@/components/ui/toggle';
7 | import useDesigner from '@/hooks/use-designer';
8 | import { api } from '@/trpc/react';
9 | import debounce from 'lodash.debounce';
10 | import { Paintbrush } from 'lucide-react';
11 | import { useCallback, useState } from 'react';
12 | import { HiMiniIdentification } from 'react-icons/hi2';
13 | import { MdOutlineExpandMore } from 'react-icons/md';
14 | import EditBioTitle from './edit-bio-title';
15 | import ProfilePicSection from './profile-pic-section';
16 |
17 | export default function ProfileSection() {
18 | const { state, dispatch } = useDesigner();
19 | const [titleError, setTitleError] = useState(false);
20 | const { isLoading, mutateAsync: updateProfile } = api.userProfile.updateUserProfile.useMutation();
21 |
22 | const [titleAppearanceToggle, setTitleAppearanceToggle] = useState(false);
23 | const [bioAppearanceToggle, setBioAppearanceToggle] = useState(false);
24 | // const savingProfile = async ({ title, bio }: { title: string; bio?: string }) => {
25 | // await updateProfile({
26 | // title,
27 | // bio
28 | // });
29 | // };
30 | // const debouncedInputHandler = useCallback(debounce(savingProfile, 700), []);
31 | const inputHandler = (
32 | e: React.ChangeEvent | React.ChangeEvent
33 | ) => {
34 | const { id, value } = e.target;
35 | if (id === 'profile') {
36 | if (value.length > 0) {
37 | // void debouncedInputHandler({ title: value, bio: state.userProfile.bio });
38 | dispatch({
39 | type: 'UPDATE_TITLE',
40 | payload: value
41 | });
42 | setTitleError(false);
43 | } else {
44 | setTitleError(true);
45 | }
46 | } else if (id === 'bio') {
47 | // void debouncedInputHandler({ title: state.userProfile.title, bio: value });
48 | dispatch({
49 | type: 'UPDATE_BIO',
50 | payload: value
51 | });
52 | }
53 | };
54 |
55 | return (
56 |
57 |
58 |
59 |
60 | Profile Section
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
Title
69 |
75 | {titleError && (
76 |
77 | {'Profile should have name'}
78 |
79 | )}
80 |
81 | {titleAppearanceToggle && (
82 |
83 | )}
84 |
93 | {bioAppearanceToggle && (
94 |
95 | )}
96 |
97 |
98 |
99 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/prisma/migrations/20240806065746_new_profille/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateEnum
2 | CREATE TYPE "Plan" AS ENUM ('FREE', 'PRO');
3 |
4 | -- CreateEnum
5 | CREATE TYPE "TextAlign" AS ENUM ('CENTER', 'LEFT', 'RIGHT');
6 |
7 | -- CreateEnum
8 | CREATE TYPE "PropertSize" AS ENUM ('SM', 'MD', 'LG');
9 |
10 | -- CreateTable
11 | CREATE TABLE "Account" (
12 | "id" TEXT NOT NULL,
13 | "userId" TEXT NOT NULL,
14 | "type" TEXT NOT NULL,
15 | "provider" TEXT NOT NULL,
16 | "providerAccountId" TEXT NOT NULL,
17 | "refresh_token" TEXT,
18 | "access_token" TEXT,
19 | "expires_at" INTEGER,
20 | "token_type" TEXT,
21 | "scope" TEXT,
22 | "id_token" TEXT,
23 | "session_state" TEXT,
24 |
25 | CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
26 | );
27 |
28 | -- CreateTable
29 | CREATE TABLE "User" (
30 | "id" TEXT NOT NULL,
31 | "name" TEXT NOT NULL,
32 | "email" TEXT NOT NULL,
33 | "username" TEXT,
34 | "emailVerified" TIMESTAMP(3),
35 | "password" TEXT,
36 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
37 | "updatedAt" TIMESTAMP(3) NOT NULL,
38 | "image" TEXT,
39 | "plan" "Plan" NOT NULL DEFAULT 'FREE',
40 |
41 | CONSTRAINT "User_pkey" PRIMARY KEY ("id")
42 | );
43 |
44 | -- CreateTable
45 | CREATE TABLE "UserProfile" (
46 | "id" TEXT NOT NULL,
47 | "bio" TEXT,
48 | "title" TEXT NOT NULL,
49 | "pic" TEXT,
50 | "userId" TEXT NOT NULL,
51 |
52 | CONSTRAINT "UserProfile_pkey" PRIMARY KEY ("id")
53 | );
54 |
55 | -- CreateTable
56 | CREATE TABLE "SocialLink" (
57 | "id" TEXT NOT NULL,
58 | "data" JSONB NOT NULL,
59 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
60 | "updatedAt" TIMESTAMP(3) NOT NULL,
61 | "userId" TEXT NOT NULL,
62 |
63 | CONSTRAINT "SocialLink_pkey" PRIMARY KEY ("id")
64 | );
65 |
66 | -- CreateTable
67 | CREATE TABLE "AdhocLink" (
68 | "id" TEXT NOT NULL,
69 | "data" JSONB NOT NULL,
70 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
71 | "updatedAt" TIMESTAMP(3) NOT NULL,
72 | "userId" TEXT NOT NULL,
73 |
74 | CONSTRAINT "AdhocLink_pkey" PRIMARY KEY ("id")
75 | );
76 |
77 | -- CreateTable
78 | CREATE TABLE "VerificationToken" (
79 | "identifier" TEXT NOT NULL,
80 | "token" TEXT NOT NULL,
81 | "expires" TIMESTAMP(3) NOT NULL
82 | );
83 |
84 | -- CreateTable
85 | CREATE TABLE "LinkTheme" (
86 | "id" TEXT NOT NULL,
87 | "textAlign" "TextAlign" NOT NULL DEFAULT 'CENTER',
88 | "backgroundColor" TEXT,
89 | "textColor" TEXT,
90 | "borderColor" TEXT,
91 | "borderRadius" "PropertSize" NOT NULL DEFAULT 'MD',
92 | "adhocLinkId" TEXT NOT NULL,
93 |
94 | CONSTRAINT "LinkTheme_pkey" PRIMARY KEY ("id")
95 | );
96 |
97 | -- CreateIndex
98 | CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
99 |
100 | -- CreateIndex
101 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
102 |
103 | -- CreateIndex
104 | CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
105 |
106 | -- CreateIndex
107 | CREATE UNIQUE INDEX "UserProfile_userId_key" ON "UserProfile"("userId");
108 |
109 | -- CreateIndex
110 | CREATE UNIQUE INDEX "SocialLink_userId_key" ON "SocialLink"("userId");
111 |
112 | -- CreateIndex
113 | CREATE UNIQUE INDEX "AdhocLink_userId_key" ON "AdhocLink"("userId");
114 |
115 | -- CreateIndex
116 | CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
117 |
118 | -- CreateIndex
119 | CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
120 |
121 | -- CreateIndex
122 | CREATE UNIQUE INDEX "LinkTheme_adhocLinkId_key" ON "LinkTheme"("adhocLinkId");
123 |
124 | -- AddForeignKey
125 | ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
126 |
127 | -- AddForeignKey
128 | ALTER TABLE "UserProfile" ADD CONSTRAINT "UserProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
129 |
130 | -- AddForeignKey
131 | ALTER TABLE "SocialLink" ADD CONSTRAINT "SocialLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
132 |
133 | -- AddForeignKey
134 | ALTER TABLE "AdhocLink" ADD CONSTRAINT "AdhocLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
135 |
136 | -- AddForeignKey
137 | ALTER TABLE "LinkTheme" ADD CONSTRAINT "LinkTheme_adhocLinkId_fkey" FOREIGN KEY ("adhocLinkId") REFERENCES "AdhocLink"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
138 |
--------------------------------------------------------------------------------
/src/components/auth/create-account-card.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { zodResolver } from '@hookform/resolvers/zod';
3 | import { useState } from 'react';
4 | import { useForm } from 'react-hook-form';
5 | import { FaExclamationTriangle } from 'react-icons/fa';
6 | import * as z from 'zod';
7 |
8 | import { Button } from '@/components/ui/button';
9 | import {
10 | Form,
11 | FormControl,
12 | FormDescription,
13 | FormField,
14 | FormItem,
15 | FormLabel,
16 | FormMessage
17 | } from '@/components/ui/form';
18 | import { Input } from '@/components/ui/input';
19 | import { RegisterSchema } from '@/server/api/schemas';
20 | import { api } from '@/trpc/react';
21 | import { TRPCClientError } from '@trpc/client';
22 | import { Alert, AlertTitle } from '../ui/alert';
23 | import { AuthPagesWrapper } from './auth-pages-wrapper';
24 |
25 | export function CreateAccountCard() {
26 | const [error, setError] = useState('');
27 |
28 | const { isLoading, mutateAsync: createUser } = api.user.createUser.useMutation({
29 | onError: (error) => {
30 | if (error instanceof TRPCClientError) {
31 | if (error.message) {
32 | setError(error.message);
33 | }
34 | }
35 | }
36 | });
37 |
38 | const form = useForm>({
39 | resolver: zodResolver(RegisterSchema),
40 | defaultValues: {
41 | email: '',
42 | password: '',
43 | username: ''
44 | }
45 | });
46 |
47 | const onSubmit = async (values: z.infer) => {
48 | setError('');
49 | await createUser(values);
50 | };
51 |
52 | return (
53 |
58 |
132 |
133 |
134 | );
135 | }
136 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { Slot } from "@radix-ui/react-slot"
4 | import {
5 | Controller,
6 | ControllerProps,
7 | FieldPath,
8 | FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form"
12 |
13 | import { cn } from "@/lib/utils"
14 | import { Label } from "@/components/ui/label"
15 |
16 | const Form = FormProvider
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath
21 | > = {
22 | name: TName
23 | }
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue
27 | )
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext)
44 | const itemContext = React.useContext(FormItemContext)
45 | const { getFieldState, formState } = useFormContext()
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState)
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ")
51 | }
52 |
53 | const { id } = itemContext
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | }
63 | }
64 |
65 | type FormItemContextValue = {
66 | id: string
67 | }
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | )
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
82 |
83 | )
84 | })
85 | FormItem.displayName = "FormItem"
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { error, formItemId } = useFormField()
92 |
93 | return (
94 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message) : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------