├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE.md ├── README.md ├── app ├── (app) │ ├── [project] │ │ ├── dashboard.tsx │ │ ├── page.tsx │ │ ├── templates │ │ │ ├── dashboard.tsx │ │ │ └── page.tsx │ │ └── validator │ │ │ └── page.tsx │ ├── create-project │ │ └── page.tsx │ ├── layout.tsx │ └── template.tsx ├── (editor) │ ├── [project] │ │ └── templates │ │ │ └── [templateId] │ │ │ └── edit │ │ │ ├── page.tsx │ │ │ ├── visual-editor-canvas.tsx │ │ │ ├── visual-editor-header.tsx │ │ │ ├── visual-editor-left-panel.tsx │ │ │ ├── visual-editor-right-panel.tsx │ │ │ └── visual-editor.tsx │ └── layout.tsx ├── (marketing) │ ├── StarOnGithubButton.tsx │ ├── StartForFreeButtonHome.tsx │ ├── UrlExample.tsx │ ├── blog │ │ ├── Posts.tsx │ │ ├── [slug] │ │ │ └── page.tsx │ │ └── page.tsx │ ├── card-validator │ │ ├── launch-screen.tsx │ │ ├── page.tsx │ │ └── preview-validator.tsx │ ├── footer.tsx │ ├── home │ │ └── page.tsx │ ├── layout.tsx │ ├── page.tsx │ ├── privacy │ │ └── page.tsx │ ├── starter-templates │ │ ├── [templateId] │ │ │ ├── AddTemplateToProjectButton.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── selected-templates.tsx │ └── terms │ │ └── page.tsx ├── actions │ └── actions.ts ├── api │ ├── auth │ │ ├── callback │ │ │ └── route.ts │ │ └── webhooks │ │ │ └── user-events │ │ │ └── route.ts │ ├── metatags │ │ └── validate │ │ │ ├── route.ts │ │ │ ├── tag-validations.ts │ │ │ └── utils.ts │ ├── templates │ │ ├── [templateId] │ │ │ └── route.ts │ │ └── route.ts │ └── uploadthing │ │ ├── core.ts │ │ └── route.ts ├── constants.ts ├── db │ ├── index.ts │ ├── operations │ │ ├── projects.ts │ │ ├── templates.ts │ │ ├── uploaded_images.ts │ │ └── users.ts │ └── schema.ts ├── globals.css ├── icon.png ├── layout.tsx ├── lib │ ├── crisp.tsx │ ├── upstash.ts │ └── workos.ts ├── og │ └── [templateId] │ │ └── route.tsx ├── opengraph-image.png ├── providers.tsx ├── twitter-image.png ├── ui │ ├── components │ │ ├── Avatar.tsx │ │ ├── Badge.tsx │ │ ├── Button.tsx │ │ ├── Card.tsx │ │ ├── CodeBlock.tsx │ │ ├── Command.tsx │ │ ├── Dialog.tsx │ │ ├── DropdownMenu.tsx │ │ ├── Input.tsx │ │ ├── Label.tsx │ │ ├── NavigationMenu.tsx │ │ ├── Popover.tsx │ │ ├── Progress.tsx │ │ ├── ScrollArea.tsx │ │ ├── Select.tsx │ │ ├── Separator.tsx │ │ ├── Sheet.tsx │ │ ├── Spinner.tsx │ │ ├── Switch.tsx │ │ ├── Table.tsx │ │ ├── Tabs.tsx │ │ ├── Toggle.tsx │ │ ├── ToggleGroup.tsx │ │ ├── Tooltip.tsx │ │ └── UploadThingComponents.ts │ ├── dialogs │ │ ├── create-first-project-dialog.tsx │ │ ├── delete-layer-dialog.tsx │ │ ├── new-layer-dialog.tsx │ │ ├── new-project-dialog.tsx │ │ └── new-template-dialog.tsx │ ├── header-app.tsx │ ├── header-marketing.tsx │ ├── header-mobile.tsx │ ├── menu-toggle.tsx │ ├── preview-mockups │ │ ├── discord-mockup.tsx │ │ ├── empty-mockup.tsx │ │ ├── facebook-mockup.tsx │ │ ├── linkedin-mockup.tsx │ │ ├── slack-mockup.tsx │ │ ├── telegram-mockup.tsx │ │ ├── twitter-app-mockup.tsx │ │ ├── twitter-web-mockup.tsx │ │ └── whatsapp-mockup.tsx │ ├── svgs │ │ ├── FixedSizeIcon.tsx │ │ └── social-icons │ │ │ ├── Discord.tsx │ │ │ ├── Facebook.tsx │ │ │ ├── GitHub.tsx │ │ │ ├── LinkedIn.tsx │ │ │ ├── Slack.tsx │ │ │ ├── Telegram.tsx │ │ │ ├── WhatsApp.tsx │ │ │ ├── X.tsx │ │ │ └── YouTube.tsx │ ├── testimonial-badge.tsx │ ├── testimonials-WOL.tsx │ ├── theme-toggle.tsx │ └── validator-input.tsx └── utils.ts ├── assets ├── inter-black.otf ├── inter-bold.otf ├── inter-extra-bold.otf ├── inter-extra-light.otf ├── inter-light.otf ├── inter-medium.otf ├── inter-regular.otf ├── inter-semi-bold.otf └── inter-thin.otf ├── drizzle.config.ts ├── drizzle ├── 0000_curious_kingpin.sql ├── 0001_same_felicia_hardy.sql ├── 0002_medical_manta.sql ├── 0003_wealthy_professor_monster.sql ├── 0004_bitter_terrax.sql ├── 0005_tiny_moondragon.sql ├── 0006_condemned_kingpin.sql ├── 0007_curved_namora.sql ├── 0008_bizarre_grey_gargoyle.sql ├── 0009_brainy_vermin.sql └── meta │ ├── 0000_snapshot.json │ ├── 0001_snapshot.json │ ├── 0002_snapshot.json │ ├── 0003_snapshot.json │ ├── 0004_snapshot.json │ ├── 0005_snapshot.json │ ├── 0006_snapshot.json │ ├── 0007_snapshot.json │ ├── 0008_snapshot.json │ ├── 0009_snapshot.json │ └── _journal.json ├── middleware.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── icon.svg ├── marketing │ ├── arrow.svg │ ├── editor-screenshot.png │ └── hardcoded-header-image.png ├── metatags-example.png ├── mvp-thumbnail.webp ├── no-metatags-example.png ├── noise-light.png ├── pfp.jpeg ├── robots.txt └── sitemap.xml ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | 39 | # BaseHub 40 | .basehub -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "plugins": ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "files.associations": { 5 | "*.css": "tailwindcss" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | sharepreviews – An open-source preview (Open Graph/Twitter Cards) validator, generator and manager. 3 |

SharePreviews

4 |
5 | 6 |

7 | No-code dynamic Open Graph images generator. Open-source. 8 |

9 | 10 |
11 | 12 | Twitter 13 | 14 | 15 | License 16 | 17 |
18 | 19 | ## Tech Stack 20 | 21 | - [TypeScript](https://www.typescriptlang.org) – language 22 | - [Next.js](https://nextjs.org) – framework 23 | - [Tailwind](https://tailwindcss.com) – CSS 24 | - [Vercel](https://vercel.com/) – deployments 25 | - [Vercel Analytics](https://vercel.com/analytics) – analytics 26 | - [WorkOS](https://workos.com) – auth 27 | - [Upstash](https://upstash.com) – redis 28 | - [Neon](https://neon.tech) – database 29 | - [Drizzle](https://orm.drizzle.team/) - ORM 30 | - [UploadThing](https://uploadthing.com/) - file storage 31 | 32 | ## License 33 | 34 | Inspired by [Dub](https://dub.co/), SharePreviews is open-source under the GNU Affero General Public License Version 3 (AGPLv3) or any later version. You can [find it here](https://github.com/sgalanb/sharepreviews?tab=AGPL-3.0-1-ov-file#readme). 35 | -------------------------------------------------------------------------------- /app/(app)/[project]/page.tsx: -------------------------------------------------------------------------------- 1 | import OverviewDashboard from '@/app/(app)/[project]/dashboard' 2 | import { getProjectByPathname } from '@/app/db/operations/projects' 3 | import { ProjectType } from '@/app/db/schema' 4 | import { getProjectUsage, getUserUsage } from '@/app/lib/upstash' 5 | import { getUser } from '@/app/lib/workos' 6 | import { Metadata } from 'next' 7 | import { redirect } from 'next/navigation' 8 | 9 | export async function generateMetadata({ 10 | params, 11 | searchParams, 12 | }: Props): Promise { 13 | const selectedProject = await getProjectByPathname(params.project) 14 | 15 | return { 16 | title: `${selectedProject?.name} | SharePreviews`, 17 | } 18 | } 19 | 20 | type Props = { 21 | params: { project: string } 22 | searchParams: { [key: string]: string | string[] | undefined } 23 | } 24 | 25 | export default async function Overview({ params, searchParams }: Props) { 26 | const { user } = await getUser() 27 | 28 | const selectedProject = (await getProjectByPathname(params.project)) as 29 | | ProjectType 30 | | undefined 31 | 32 | if (!selectedProject || selectedProject.userId !== user?.id) { 33 | redirect('/') 34 | } 35 | 36 | const userUsage = await getUserUsage(user?.id ?? '') 37 | 38 | const projectUsage = await getProjectUsage(selectedProject.id ?? '') 39 | 40 | return ( 41 | 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /app/(app)/[project]/templates/page.tsx: -------------------------------------------------------------------------------- 1 | import TemplatesDashboard from '@/app/(app)/[project]/templates/dashboard' 2 | import { getProjectByPathname } from '@/app/db/operations/projects' 3 | import { ProjectType } from '@/app/db/schema' 4 | import { getUser } from '@/app/lib/workos' 5 | import { Metadata } from 'next' 6 | import { redirect } from 'next/navigation' 7 | 8 | export async function generateMetadata({ 9 | params, 10 | searchParams, 11 | }: Props): Promise { 12 | const selectedProject = await getProjectByPathname(params.project) 13 | 14 | return { 15 | title: `Templates - ${selectedProject?.name} | SharePreviews`, 16 | } 17 | } 18 | 19 | type Props = { 20 | params: { project: string } 21 | searchParams: { [key: string]: string | string[] | undefined } 22 | } 23 | 24 | export default async function TemplatesPage({ params, searchParams }: Props) { 25 | const { user } = await getUser() 26 | const selectedProject = (await getProjectByPathname( 27 | params.project 28 | )) as ProjectType 29 | 30 | if (!selectedProject || selectedProject.userId !== user?.id) { 31 | redirect('/') 32 | } 33 | 34 | return 35 | } 36 | -------------------------------------------------------------------------------- /app/(app)/[project]/validator/page.tsx: -------------------------------------------------------------------------------- 1 | import ValidatorLaunchScreen from '@/app/(marketing)/card-validator/launch-screen' 2 | import PreviewValidator from '@/app/(marketing)/card-validator/preview-validator' 3 | import { getProjectByPathname } from '@/app/db/operations/projects' 4 | import { ProjectType } from '@/app/db/schema' 5 | import { Metadata } from 'next' 6 | 7 | export async function generateMetadata({ 8 | params, 9 | searchParams, 10 | }: Props): Promise { 11 | const inputUrl = searchParams?.url as string 12 | const cleanUrl = inputUrl?.replace(/(^\w+:|^)\/\//, '') 13 | const selectedProject = await getProjectByPathname(params.project) 14 | 15 | if (!inputUrl) { 16 | return { 17 | title: `Card Validator - ${selectedProject?.name} | SharePreviews`, 18 | } 19 | } 20 | return { 21 | title: `${cleanUrl} - ${selectedProject?.name} | SharePreviews`, 22 | } 23 | } 24 | 25 | type Props = { 26 | params: { project: string } 27 | searchParams: { [key: string]: string | string[] | undefined } 28 | } 29 | 30 | export default async function AppValidator({ params, searchParams }: Props) { 31 | const inputUrl = searchParams?.url as string 32 | const selectedProject = (await getProjectByPathname( 33 | params.project 34 | )) as ProjectType 35 | 36 | if (inputUrl) { 37 | return ( 38 | 39 | ) 40 | } else { 41 | return ( 42 | 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/(app)/create-project/page.tsx: -------------------------------------------------------------------------------- 1 | import { getUser } from '@/app/lib/workos' 2 | import CreateFirstProjectDialog from '@/app/ui/dialogs/create-first-project-dialog' 3 | import { get } from '@vercel/edge-config' 4 | 5 | export default async function CreateProject() { 6 | const { user } = await getUser() 7 | const reservedNames = (await get('reserved-project-names')) as string[] 8 | 9 | return ( 10 | } 13 | reservedNames={reservedNames} 14 | /> 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /app/(app)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getUserProjects } from '@/app/db/operations/projects' 2 | import { getAuthorizationUrl, getUser } from '@/app/lib/workos' 3 | import { Providers } from '@/app/providers' 4 | import HeaderApp from '@/app/ui/header-app' 5 | import HeaderMobile from '@/app/ui/header-mobile' 6 | import { get } from '@vercel/edge-config' 7 | import { Metadata } from 'next' 8 | import { Inter } from 'next/font/google' 9 | import { redirect } from 'next/navigation' 10 | import '../globals.css' 11 | 12 | const inter = Inter({ subsets: ['latin'] }) 13 | 14 | export const metadata: Metadata = { 15 | title: 'SharePreviews', 16 | robots: { 17 | index: false, 18 | }, 19 | } 20 | 21 | export default async function AppLayout({ 22 | children, 23 | }: { 24 | children: React.ReactNode 25 | }) { 26 | const { isAuthenticated, user } = await getUser() 27 | const authorizationUrl = getAuthorizationUrl({ screenHint: 'sign-up' }) 28 | 29 | if (!isAuthenticated) { 30 | redirect(authorizationUrl) 31 | } 32 | 33 | const reservedNames = (await get('reserved-project-names')) as string[] 34 | 35 | const userProjects = await getUserProjects(user?.id ?? '') 36 | 37 | return ( 38 | 39 | 40 | 46 |
47 |
53 | 61 | 67 |
68 |
69 | {children} 70 |
71 |
72 |
73 |
74 |
75 | 76 | 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /app/(app)/template.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { motion } from 'framer-motion' 4 | 5 | export default function Template({ children }: { children: React.ReactNode }) { 6 | return ( 7 | 13 | {children} 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /app/(editor)/[project]/templates/[templateId]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import VisualEditor from '@/app/(editor)/[project]/templates/[templateId]/edit/visual-editor' 2 | import { getProjectByPathname } from '@/app/db/operations/projects' 3 | import { getTemplateById } from '@/app/db/operations/templates' 4 | import { ProjectType } from '@/app/db/schema' 5 | import { getUser } from '@/app/lib/workos' 6 | import { Metadata } from 'next' 7 | import { redirect } from 'next/navigation' 8 | 9 | export async function generateMetadata({ 10 | params, 11 | searchParams, 12 | }: Props): Promise { 13 | const selectedProject = await getProjectByPathname(params.project) 14 | const selectedTemplate = await getTemplateById(params.templateId) 15 | 16 | return { 17 | title: `${selectedTemplate?.name} - ${selectedProject?.name} | SharePreviews`, 18 | } 19 | } 20 | 21 | interface LayerInterface { 22 | id: string 23 | name: string 24 | x: number 25 | y: number 26 | width: number 27 | height: number 28 | rotation: number 29 | opacity: number 30 | type: 'text' | 'image' | 'rectangle' 31 | conditionalVisibility: boolean 32 | conditionalVisibilityVariableName: string 33 | } 34 | 35 | interface TextLayerInterface extends LayerInterface { 36 | type: 'text' 37 | widthType: 'fixed' | 'fit' 38 | heightType: 'fixed' | 'fit' 39 | lineClamp: number 40 | fontName: string 41 | fontFamily: string 42 | fontWeight: number 43 | fontUrl: string 44 | size: number 45 | lineHeight: number 46 | alignHorizontal: 'flex-start' | 'center' | 'flex-end' 47 | alignVertical: 'flex-start' | 'center' | 'flex-end' 48 | //balance: boolean // text wrap balance, wait for satori to support 49 | color: string 50 | background: boolean 51 | bgColor: string 52 | bgOpacity: number 53 | bgPaddingX: number 54 | bgPaddingY: number 55 | bgCornerRadius: number 56 | conditionalValue: boolean 57 | conditionalValueVariableName: string 58 | exampleValue?: string 59 | value?: string 60 | } 61 | 62 | interface ImageLayerInterface extends LayerInterface { 63 | type: 'image' 64 | cornerRadius: number 65 | objectFit: 'fill' | 'contain' | 'cover' 66 | conditionalValue: boolean 67 | conditionalValueVariableName: string 68 | exampleSrc?: string 69 | src?: string 70 | } 71 | 72 | interface RectangleLayerInterface extends LayerInterface { 73 | type: 'rectangle' 74 | cornerRadius: number 75 | color: string 76 | } 77 | 78 | export type LayerType = 79 | | TextLayerInterface 80 | | ImageLayerInterface 81 | | RectangleLayerInterface 82 | 83 | type Props = { 84 | params: { project: string; templateId: string } 85 | searchParams: { [key: string]: string | string[] | undefined } 86 | } 87 | 88 | export default async function EditTemplate({ params, searchParams }: Props) { 89 | const { user } = await getUser() 90 | const selectedProject = (await getProjectByPathname(params.project)) as 91 | | ProjectType 92 | | undefined 93 | 94 | if (!selectedProject || selectedProject.userId !== user?.id) { 95 | redirect('/') 96 | } 97 | 98 | return ( 99 |
100 | 105 |
106 | ) 107 | } 108 | -------------------------------------------------------------------------------- /app/(editor)/[project]/templates/[templateId]/edit/visual-editor-header.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { LayerType } from '@/app/(editor)/[project]/templates/[templateId]/edit/page' 4 | import { updateTemplateAction } from '@/app/actions/actions' 5 | import { ProjectType, TemplateType } from '@/app/db/schema' 6 | import { Button } from '@/app/ui/components/Button' 7 | import Spinner from '@/app/ui/components/Spinner' 8 | import { ChevronLeft, Save } from 'lucide-react' 9 | import { useRouter } from 'next/navigation' 10 | import { useFormStatus } from 'react-dom' 11 | 12 | export default function VisualEditorHeader({ 13 | layers, 14 | canvasBackgroundColor, 15 | project, 16 | template, 17 | originalTemplate, 18 | hasUnsavedChanges, 19 | promptUnsavedChanges, 20 | }: { 21 | layers: LayerType[] 22 | canvasBackgroundColor: string 23 | project: ProjectType 24 | template: TemplateType | undefined 25 | originalTemplate: TemplateType | undefined 26 | hasUnsavedChanges: boolean 27 | promptUnsavedChanges: () => boolean 28 | }) { 29 | const router = useRouter() 30 | // `/${project.pathname}/templates` 31 | return ( 32 |
33 |
34 | 46 |
47 | {`${project?.name} / `} 48 | {template?.name ? ( 49 | {template.name} 50 | ) : ( 51 |
52 | )} 53 |
54 |
{ 56 | if ( 57 | template?.id && 58 | template?.name && 59 | project?.id && 60 | project?.pathname 61 | ) { 62 | updateTemplateAction({ 63 | id: template.id, 64 | name: template.name, 65 | projectId: project.id, 66 | projectPathname: project.pathname, 67 | layersData: JSON.stringify(layers), 68 | canvasBackgroundColor, 69 | }) 70 | } 71 | }} 72 | > 73 | 74 | 75 |
76 |
77 | ) 78 | } 79 | 80 | function SaveButton({ hasUnsavedChanges }: { hasUnsavedChanges: boolean }) { 81 | const { pending } = useFormStatus() 82 | 83 | return ( 84 | 99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /app/(editor)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getAuthorizationUrl, getUser } from '@/app/lib/workos' 2 | import { Providers } from '@/app/providers' 3 | import { Metadata } from 'next' 4 | import { Inter } from 'next/font/google' 5 | import { redirect } from 'next/navigation' 6 | import '../globals.css' 7 | 8 | const inter = Inter({ subsets: ['latin'] }) 9 | 10 | export const metadata: Metadata = { 11 | robots: { 12 | index: false, 13 | }, 14 | } 15 | 16 | export default async function EditorLayout({ 17 | children, 18 | }: { 19 | children: React.ReactNode 20 | }) { 21 | const { isAuthenticated, user } = await getUser() 22 | const authorizationUrl = getAuthorizationUrl({ screenHint: 'sign-in' }) 23 | 24 | if (!isAuthenticated) { 25 | redirect(authorizationUrl) 26 | } 27 | 28 | return ( 29 | 30 | 31 | 37 |
38 |
39 | {children} 40 |
41 |
42 |
43 | 44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /app/(marketing)/StarOnGithubButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Button } from '@/app/ui/components/Button' 4 | import GitHub from '@/app/ui/svgs/social-icons/GitHub' 5 | import { track } from '@vercel/analytics/react' 6 | import Link from 'next/link' 7 | 8 | export default function StarOnGithubButton({ 9 | variant, 10 | }: { 11 | variant: 'short' | 'long' 12 | }) { 13 | return ( 14 | <> 15 | {variant === 'short' ? ( 16 | 36 | ) : ( 37 | { 42 | track('star_on_github') 43 | }} 44 | > 45 | 49 | Star on GitHub 50 | 51 | )} 52 | 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /app/(marketing)/StartForFreeButtonHome.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Button } from '@/app/ui/components/Button' 4 | import { track } from '@vercel/analytics/react' 5 | import Link from 'next/link' 6 | 7 | export default function StartForFreeButtonHome({ 8 | isAuthenticated, 9 | authorizationUrl, 10 | }: { 11 | isAuthenticated: boolean 12 | authorizationUrl: string 13 | }) { 14 | return ( 15 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /app/(marketing)/UrlExample.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Button } from '@/app/ui/components/Button' 4 | import { motion } from 'framer-motion' 5 | import { Check, Copy } from 'lucide-react' 6 | import { useState } from 'react' 7 | 8 | export default function UrlExample() { 9 | const [isCopied, setIsCopied] = useState(false) 10 | 11 | const copyToClipboard = async (text: string) => { 12 | try { 13 | await navigator.clipboard.writeText(text) 14 | setIsCopied(true) 15 | setTimeout(() => setIsCopied(false), 700) // Reset after 0.7 seconds 16 | } catch (err) { 17 | console.error('Failed to copy:', err) 18 | } 19 | } 20 | 21 | // Animation variants for the button 22 | const buttonVariants = { 23 | initial: { scale: 1 }, 24 | animate: { scale: 1.2, transition: { duration: 0.2 } }, 25 | exit: { scale: 1, transition: { duration: 0.2 } }, 26 | } 27 | return ( 28 |
29 |       
30 |         sharepreviews.com/og/
31 |         {
32 |           
33 |             08f36805-dea2-4575-b047-a96c9466d1f4
34 |           
35 |         }
36 |         ?title_value={{`{VALUE}`}}
37 |         &description_value={{`{VALUE}`}}
38 |       
39 |       
45 |         
61 |       
62 |     
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /app/(marketing)/blog/Posts.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Card } from '@/app/ui/components/Card' 4 | import { Tabs, TabsList, TabsTrigger } from '@/app/ui/components/Tabs' 5 | import dayjs from 'dayjs' 6 | import Image from 'next/image' 7 | import Link from 'next/link' 8 | import { useState } from 'react' 9 | 10 | export default function Posts({ posts }: { posts: any[] }) { 11 | const [selectedCategory, setSelectedCategory] = useState('all') 12 | 13 | console.log(selectedCategory) 14 | console.log(posts) 15 | 16 | return ( 17 | setSelectedCategory(value)} 20 | className="mb-16 flex w-full flex-col items-center justify-center gap-8 px-4 lg:mb-0 lg:px-8" 21 | > 22 | 23 | 24 | All 25 | 26 | 27 | Changelog 28 | 29 | 30 | Technical Articles 31 | 32 | 33 | 34 |
35 | {posts 36 | .filter( 37 | (post) => 38 | post.category?._slug === selectedCategory || 39 | selectedCategory === 'all' 40 | ) 41 | .map((post) => { 42 | return ( 43 | 44 | 48 |
49 |
50 |
51 |

52 | {post._title} 53 |

54 |
55 |
56 | author profile picture 63 | 64 | {post?.author?.name} 65 | 66 | · 67 | 68 | {dayjs(post?.publishDate).format('MMM D, YYYY')} 69 | 70 |
71 |
72 |
73 | 74 |
75 | ) 76 | })} 77 |
78 |
79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /app/(marketing)/blog/page.tsx: -------------------------------------------------------------------------------- 1 | import Posts from '@/app/(marketing)/blog/Posts' 2 | import Footer from '@/app/(marketing)/footer' 3 | import { getUser } from '@/app/lib/workos' 4 | import { QueryGenqlSelection, basehub } from 'basehub' 5 | import { Pump } from 'basehub/react-pump' 6 | import { Metadata } from 'next' 7 | import { draftMode } from 'next/headers' 8 | 9 | const postBySlugQuery = () => { 10 | return { 11 | blog: { 12 | posts: { 13 | __args: { first: 10, orderBy: 'publishDate__DESC' }, 14 | items: { 15 | _id: true, 16 | _title: true, 17 | _slug: true, 18 | publishDate: true, 19 | category: { _slug: true, _title: true }, 20 | author: { 21 | name: true, 22 | avatar: { 23 | url: true, 24 | }, 25 | }, 26 | ogImage: { 27 | url: true, 28 | }, 29 | title: true, 30 | description: true, 31 | }, 32 | }, 33 | }, 34 | } satisfies QueryGenqlSelection 35 | } 36 | 37 | export async function generateMetadata(): Promise { 38 | const { blog } = await basehub({ 39 | next: { revalidate: 60 }, 40 | draft: draftMode().isEnabled, 41 | }).query(postBySlugQuery()) 42 | 43 | return { 44 | title: 'Blog | sharepreviews', 45 | description: 46 | 'Learn how to manage and generate Open Graph metatags with sharepreviews. Product updates, guides and technical articles.', 47 | alternates: { 48 | canonical: 'https://sharepreviews.com/blog', 49 | }, 50 | openGraph: { 51 | url: 'https://sharepreviews.com/blog', 52 | type: 'article', 53 | siteName: 'SharePreviews', 54 | }, 55 | twitter: { 56 | site: '@sgalanb', 57 | creator: '@sgalanb', 58 | card: 'summary_large_image', 59 | }, 60 | } 61 | } 62 | 63 | export default async function BlogPage() { 64 | const { isAuthenticated } = await getUser() 65 | 66 | return ( 67 | 68 | {async ([{ blog }]) => { 69 | 'use server' 70 | const posts = blog.posts.items 71 | 72 | return ( 73 |
74 |

Blog

75 | 76 | 77 | 78 |
79 |
80 | ) 81 | }} 82 |
83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /app/(marketing)/card-validator/launch-screen.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import ValidatorInput from '@/app/ui/validator-input' 4 | import { AnimatePresence, motion } from 'framer-motion' 5 | import Link from 'next/link' 6 | 7 | export default function ValidatorLaunchScreen({ 8 | isApp, 9 | projectPathname, 10 | }: { 11 | isApp: boolean 12 | projectPathname: string 13 | }) { 14 | return ( 15 | 16 | 22 |
27 |
28 |

29 | Social Card Validator 30 |

31 |

34 | Check how your links look when shared. Validate that you have the 35 | right metatags in place so your cards are displayed correctly. 36 |

37 |
38 |
39 | 40 |
41 | E.g.: 42 | 50 | vercel.com 51 | 52 | 60 | teenage.engineering 61 | 62 | 70 | dub.co 71 | 72 |
73 |
74 |
75 |
76 |
77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /app/(marketing)/card-validator/page.tsx: -------------------------------------------------------------------------------- 1 | import ValidatorLaunchScreen from '@/app/(marketing)/card-validator/launch-screen' 2 | import PreviewValidator from '@/app/(marketing)/card-validator/preview-validator' 3 | import Footer from '@/app/(marketing)/footer' 4 | import { getProjectByPathname } from '@/app/db/operations/projects' 5 | import { ProjectType } from '@/app/db/schema' 6 | import { getUser } from '@/app/lib/workos' 7 | import { Metadata } from 'next' 8 | 9 | export async function generateMetadata({ 10 | searchParams, 11 | }: Props): Promise { 12 | const inputUrl = searchParams?.url as string 13 | const cleanUrl = inputUrl?.replace(/(^\w+:|^)\/\//, '') 14 | 15 | if (!inputUrl) { 16 | return { 17 | title: 'Card Validator | SharePreviews', 18 | description: 19 | 'Check how your links look when shared. Validate that you have the right metatags in place so your cards are displayed correctly. Free tool for Open Graph and Twitter Cards.', 20 | alternates: { 21 | canonical: 'https://sharepreviews.com/card-validator', 22 | }, 23 | openGraph: { 24 | url: 'https://sharepreviews.com/card-validator', 25 | type: 'website', 26 | siteName: 'SharePreviews', 27 | images: [ 28 | 'https://utfs.io/f/5d0c1c4a-2d8c-433b-b140-aabcfc3f97ba-67kr6v.png', 29 | ], 30 | }, 31 | twitter: { 32 | site: '@sgalanb', 33 | creator: '@sgalanb', 34 | card: 'summary_large_image', 35 | images: [ 36 | 'https://utfs.io/f/5d0c1c4a-2d8c-433b-b140-aabcfc3f97ba-67kr6v.png', 37 | ], 38 | }, 39 | } 40 | } else { 41 | //const metatags = await fetch(`/api/metatags/validate?url=${cleanUrl}`) 42 | 43 | // TODO 44 | 45 | return { 46 | title: `${cleanUrl} | SharePreviews`, 47 | description: 48 | 'Check how your links look when shared. Validate that you have the right metatags in place so your cards are displayed correctly. Free tool for Open Graph and Twitter Cards.', 49 | alternates: { 50 | canonical: 'https://sharepreviews.com/card-validator', 51 | }, 52 | openGraph: { 53 | url: 'https://sharepreviews.com/card-validator', 54 | type: 'website', 55 | siteName: 'SharePreviews', 56 | images: [ 57 | 'https://utfs.io/f/5d0c1c4a-2d8c-433b-b140-aabcfc3f97ba-67kr6v.png', 58 | ], 59 | }, 60 | twitter: { 61 | site: '@sgalanb', 62 | creator: '@sgalanb', 63 | card: 'summary_large_image', 64 | images: [ 65 | 'https://utfs.io/f/5d0c1c4a-2d8c-433b-b140-aabcfc3f97ba-67kr6v.png', 66 | ], 67 | }, 68 | } 69 | } 70 | } 71 | 72 | type Props = { 73 | params: { project: string } 74 | searchParams: { [key: string]: string | string[] | undefined } 75 | } 76 | 77 | export default async function Validator({ params, searchParams }: Props) { 78 | const { isAuthenticated } = await getUser() 79 | 80 | const inputUrl = searchParams?.url as string 81 | const selectedProject = (await getProjectByPathname( 82 | params.project 83 | )) as ProjectType 84 | 85 | return ( 86 |
89 | {inputUrl ? ( 90 | 94 | ) : ( 95 | 99 | )} 100 |
101 |
102 | ) 103 | } 104 | -------------------------------------------------------------------------------- /app/(marketing)/home/page.tsx: -------------------------------------------------------------------------------- 1 | import Home from '@/app/(marketing)/page' 2 | import { Metadata } from 'next' 3 | 4 | export const metadata: Metadata = { 5 | title: 6 | 'SharePreviews | Boost your links engagement with stunning social cards', 7 | robots: { 8 | index: false, 9 | }, 10 | } 11 | 12 | export default async function NoIndexHome() { 13 | return 14 | } 15 | -------------------------------------------------------------------------------- /app/(marketing)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getAuthorizationUrl, getUser } from '@/app/lib/workos' 2 | import { Providers } from '@/app/providers' 3 | import HeaderMarketing from '@/app/ui/header-marketing' 4 | import HeaderMobile from '@/app/ui/header-mobile' 5 | import { Analytics } from '@vercel/analytics/react' 6 | import { Metadata } from 'next' 7 | import { Inter } from 'next/font/google' 8 | import '../globals.css' 9 | 10 | const inter = Inter({ subsets: ['latin'] }) 11 | 12 | export const metadata: Metadata = { 13 | title: 'SharePreviews', 14 | } 15 | 16 | export default async function MarketingLayout({ 17 | children, 18 | }: { 19 | children: React.ReactNode 20 | }) { 21 | const { isAuthenticated, user } = await getUser() 22 | const authorizationUrl = getAuthorizationUrl({ screenHint: 'sign-up' }) 23 | 24 | return ( 25 | 26 | 27 | 33 |
39 |
40 | 46 | 51 | {children} 52 |
53 |
54 |
55 | 56 | 57 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /app/(marketing)/starter-templates/[templateId]/AddTemplateToProjectButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { duplicateTemplateAction } from '@/app/actions/actions' 4 | import { ProjectType } from '@/app/db/schema' 5 | import { Button } from '@/app/ui/components/Button' 6 | import { 7 | Command, 8 | CommandEmpty, 9 | CommandGroup, 10 | CommandInput, 11 | CommandItem, 12 | } from '@/app/ui/components/Command' 13 | import { 14 | Popover, 15 | PopoverContent, 16 | PopoverTrigger, 17 | } from '@/app/ui/components/Popover' 18 | import Spinner from '@/app/ui/components/Spinner' 19 | import { useState } from 'react' 20 | 21 | export default function AddTemplateToProjectButton({ 22 | userProjects, 23 | templateId, 24 | }: { 25 | userProjects: ProjectType[] 26 | templateId: string 27 | }) { 28 | // Project Selector 29 | const [openProjectsCombobox, setOpenProjectsCombobox] = 30 | useState(false) 31 | 32 | const projectsList = userProjects?.map((project) => ({ 33 | value: project.pathname, 34 | label: project.name, 35 | })) 36 | 37 | const [isLoading, setIsLoading] = useState(false) 38 | 39 | return ( 40 | 41 | 42 | 53 | 54 | 55 | 56 | 57 | No project found. 58 | 59 | {projectsList?.map((project) => ( 60 | { 64 | duplicateTemplateAction({ 65 | templateToDuplicateId: templateId, 66 | targetProjectId: userProjects.find( 67 | (project) => project.pathname === newValue 68 | )?.id as string, 69 | targetProjectPathname: newValue, 70 | }) 71 | setIsLoading(true) 72 | setOpenProjectsCombobox(false) 73 | }} 74 | className="cursor-pointer pl-8" 75 | > 76 | {project.label} 77 | 78 | ))} 79 | 80 | 81 | 82 | 83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /app/(marketing)/starter-templates/[templateId]/page.tsx: -------------------------------------------------------------------------------- 1 | import Footer from '@/app/(marketing)/footer' 2 | import AddTemplateToProjectButton from '@/app/(marketing)/starter-templates/[templateId]/AddTemplateToProjectButton' 3 | import { getUserProjects } from '@/app/db/operations/projects' 4 | import { getTemplateById } from '@/app/db/operations/templates' 5 | import { getAuthorizationUrl, getUser } from '@/app/lib/workos' 6 | import { Button } from '@/app/ui/components/Button' 7 | 8 | import { getUrlWithConditionalVariablesTrue } from '@/app/utils' 9 | import { ChevronLeft } from 'lucide-react' 10 | import Image from 'next/image' 11 | import Link from 'next/link' 12 | import { redirect } from 'next/navigation' 13 | 14 | export default async function StarterTemplatePage({ 15 | params, 16 | }: { 17 | params: { templateId: string } 18 | }) { 19 | const { isAuthenticated, user } = await getUser() 20 | const authorizationUrl = getAuthorizationUrl({ screenHint: 'sign-up' }) 21 | 22 | const userProjects = await getUserProjects(user?.id ?? '') 23 | 24 | const selectedTemplate = await getTemplateById(params.templateId) 25 | 26 | if (selectedTemplate?.projectId !== '8528aee3-1808-4942-ad2e-bd6ad266643d') { 27 | redirect('/starter-templates') 28 | } 29 | 30 | return ( 31 |
32 | 36 | 37 | Starter Templates 38 | 39 |
40 |
41 |
42 |
43 |

44 | {selectedTemplate?.name} 45 |

46 |

47 | {selectedTemplate?.description} 48 |

49 |
50 | author profile picture 57 | 58 | Santiago Galán 59 | 60 |
61 |
62 |
63 | {user ? ( 64 | 68 | ) : ( 69 | 77 | )} 78 |
79 |
80 |
81 | {selectedTemplate && ( 82 |
83 | {/* eslint-disable-next-line jsx-a11y/alt-text, @next/next/no-img-element */} 84 | 88 |
89 | )} 90 |
91 |
92 |
93 | ) 94 | } 95 | -------------------------------------------------------------------------------- /app/(marketing)/starter-templates/page.tsx: -------------------------------------------------------------------------------- 1 | import Footer from '@/app/(marketing)/footer' 2 | import SelectedTemplates from '@/app/(marketing)/starter-templates/selected-templates' 3 | import { getTemplateByArrayOfIds } from '@/app/db/operations/templates' 4 | import { getUser } from '@/app/lib/workos' 5 | import { 6 | Tabs, 7 | TabsContent, 8 | TabsList, 9 | TabsTrigger, 10 | } from '@/app/ui/components/Tabs' 11 | import { Metadata } from 'next' 12 | 13 | export const metadata: Metadata = { 14 | title: 'Starter Templates | SharePreviews', 15 | } 16 | 17 | const starterTemplatesIds = [ 18 | { 19 | id: 'f20c60c8-0e4e-43cb-85d7-1ec2357530ae', 20 | type: 'ecommerce', 21 | }, 22 | { 23 | id: '6446c4ba-ebbc-4688-be9d-9d0aa268627a', 24 | type: 'blog', 25 | }, 26 | { 27 | id: '27c80424-6b31-455e-88e1-6b36f0e75cf9', 28 | type: 'social', 29 | }, 30 | { 31 | id: '08f36805-dea2-4575-b047-a96c9466d1f4', 32 | type: 'other', 33 | }, 34 | ] 35 | 36 | export default async function StarterTemplatesPage() { 37 | const { isAuthenticated } = await getUser() 38 | 39 | const starterTemplates = await getTemplateByArrayOfIds( 40 | starterTemplatesIds.map((template) => template.id) 41 | ) 42 | 43 | const starterTemplatesWithType = starterTemplates.map((template) => { 44 | const templateType = starterTemplatesIds.find( 45 | (starterTemplate) => starterTemplate.id === template.id 46 | )?.type 47 | 48 | return { 49 | ...template, 50 | type: templateType, 51 | } 52 | }) 53 | 54 | return ( 55 |
56 |
57 |

Starter Templates

58 |

59 | A curated collection of dynamic card images made with{' '} 60 | SharePreviews. You can use them 61 | as starter templates for your own projects. 62 |

63 |
64 | 68 | 69 | 70 | All 71 | 72 | 73 | Blogs 74 | 75 | 76 | eCommerce 77 | 78 | 79 | Social 80 | 81 | {/* 82 | Events 83 | */} 84 | 85 | Other 86 | 87 | 88 | 89 |
90 | 91 | 92 | 93 | 94 | template.type === 'blog' 97 | )} 98 | /> 99 | 100 | 101 | template.type === 'ecommerce' 104 | )} 105 | /> 106 | 107 | 108 | template.type === 'social' 111 | )} 112 | /> 113 | 114 | 115 | template.type === 'events' 118 | )} 119 | /> 120 | 121 | 122 | template.type === 'other' 125 | )} 126 | /> 127 | 128 |
129 |
130 |
131 |
132 | ) 133 | } 134 | -------------------------------------------------------------------------------- /app/(marketing)/starter-templates/selected-templates.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { TemplateType } from '@/app/db/schema' 4 | import { getUrlWithConditionalVariablesTrue } from '@/app/utils' 5 | import Image from 'next/image' 6 | import Link from 'next/link' 7 | 8 | export default function SelectedTemplates({ 9 | selectedTemplates, 10 | }: { 11 | selectedTemplates: TemplateType[] 12 | }) { 13 | return ( 14 |
15 | {selectedTemplates.map((template) => ( 16 | 21 | 28 |
29 |

30 | {template.name} 31 |

32 |

33 | {template.description} 34 |

35 |
36 | 37 | ))} 38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /app/actions/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { createProject } from '@/app/db/operations/projects' 4 | import { 5 | createTemplate, 6 | deleteTemplate, 7 | getTemplateById, 8 | updateTemplate, 9 | } from '@/app/db/operations/templates' 10 | import { createUploadedImage } from '@/app/db/operations/uploaded_images' 11 | import { 12 | createOrUpdateProjectRedis, 13 | createOrUpdateTemplateRedis, 14 | deleteTemplateRedis, 15 | } from '@/app/lib/upstash' 16 | import { logOutUser } from '@/app/lib/workos' 17 | import { revalidatePath } from 'next/cache' 18 | import { redirect } from 'next/navigation' 19 | import { v4 as uuidv4 } from 'uuid' 20 | 21 | export async function redirectAction(href: string) { 22 | return redirect(href) 23 | } 24 | 25 | export async function logout() { 26 | logOutUser() 27 | redirect('/') 28 | } 29 | 30 | export async function createProjectAction({ 31 | name, 32 | userId, 33 | }: { 34 | name: string 35 | userId: string 36 | }) { 37 | const createdProject = await createProject({ 38 | name, 39 | userId, 40 | }) 41 | 42 | await createOrUpdateProjectRedis({ 43 | id: createdProject[0].insertId, 44 | name: name, 45 | pathname: createdProject[0].pathname, 46 | plan: 'free', 47 | ownerUserId: userId, 48 | }) 49 | 50 | revalidatePath('/', 'layout') 51 | redirect(`/${createdProject[0].pathname}`) 52 | } 53 | 54 | export async function createTemplateAction({ 55 | name, 56 | projectId, 57 | projectPathname, 58 | layersData, 59 | }: { 60 | name: string 61 | projectId: string 62 | projectPathname: string 63 | layersData: string 64 | }) { 65 | const newId = uuidv4() 66 | 67 | const createPostgres = await createTemplate({ 68 | id: newId, 69 | name, 70 | projectId, 71 | layersData, 72 | canvasBackgroundColor: '#ffffff', 73 | }) 74 | 75 | const createRedis = createOrUpdateTemplateRedis({ 76 | templateId: newId, 77 | layersData, 78 | projectId, 79 | canvasBackgroundColor: '#ffffff', 80 | }) 81 | 82 | await Promise.all([createPostgres, createRedis]).then(() => { 83 | redirect(`/${projectPathname}/templates/${newId}/edit`) 84 | }) 85 | } 86 | 87 | export async function duplicateTemplateAction({ 88 | templateToDuplicateId, 89 | targetProjectId, 90 | targetProjectPathname, 91 | }: { 92 | templateToDuplicateId: string 93 | targetProjectId: string 94 | targetProjectPathname: string 95 | }) { 96 | const templateToDuplicate = await getTemplateById(templateToDuplicateId) 97 | 98 | const newId = uuidv4() 99 | 100 | if (!templateToDuplicate) { 101 | return 102 | } 103 | 104 | const createPostgres = await createTemplate({ 105 | id: newId, 106 | name: templateToDuplicate?.name + ' copy', 107 | projectId: targetProjectId, 108 | layersData: templateToDuplicate?.layersData, 109 | canvasBackgroundColor: templateToDuplicate.canvasBackgroundColor, 110 | }) 111 | 112 | const createRedis = createOrUpdateTemplateRedis({ 113 | templateId: newId, 114 | layersData: templateToDuplicate?.layersData, 115 | projectId: targetProjectId, 116 | canvasBackgroundColor: templateToDuplicate.canvasBackgroundColor, 117 | }) 118 | 119 | await Promise.all([createPostgres, createRedis]).then(() => { 120 | redirect(`/${targetProjectPathname}/templates/${newId}/edit`) 121 | }) 122 | } 123 | 124 | export async function updateTemplateAction({ 125 | id, 126 | name, 127 | projectId, 128 | projectPathname, 129 | layersData, 130 | canvasBackgroundColor, 131 | }: { 132 | id: string 133 | name: string 134 | projectId: string 135 | projectPathname: string 136 | layersData: string 137 | canvasBackgroundColor: string 138 | }) { 139 | const updatePostgres = await updateTemplate({ 140 | id, 141 | name, 142 | layersData, 143 | canvasBackgroundColor, 144 | }) 145 | 146 | const updateRedis = createOrUpdateTemplateRedis({ 147 | templateId: id, 148 | layersData, 149 | projectId, 150 | canvasBackgroundColor, 151 | }) 152 | 153 | await Promise.all([updatePostgres, updateRedis]).then(() => { 154 | redirect(`/${projectPathname}/templates`) 155 | }) 156 | } 157 | 158 | export async function deleteTemplateAction({ id }: { id: string }) { 159 | const deletePostgres = await deleteTemplate(id) 160 | const deleteRedis = deleteTemplateRedis(id) 161 | 162 | await Promise.all([deletePostgres, deleteRedis]) 163 | } 164 | 165 | export async function createUploadedImageAction({ 166 | key, 167 | url, 168 | userId, 169 | }: { 170 | key: string 171 | url: string 172 | userId: string 173 | }) { 174 | await createUploadedImage({ 175 | key, 176 | url, 177 | userId, 178 | }) 179 | } 180 | -------------------------------------------------------------------------------- /app/api/auth/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { WorkOS } from '@workos-inc/node' 2 | import { SignJWT } from 'jose' 3 | import { NextRequest, NextResponse } from 'next/server' 4 | 5 | // Get secret 6 | const secret = new Uint8Array( 7 | Buffer.from(process.env.JWT_SECRET_KEY as string, 'base64') 8 | ) 9 | 10 | const workos = new WorkOS(process.env.WORKOS_API_KEY) 11 | const clientId = process.env.WORKOS_CLIENT_ID 12 | 13 | export async function GET(req: NextRequest) { 14 | // The authorization code returned by AuthKit 15 | const code = req.nextUrl.searchParams.get('code') 16 | const state = req.nextUrl.searchParams.get('state') 17 | 18 | if (!code) { 19 | return NextResponse.redirect('/') 20 | } 21 | 22 | if (!clientId) { 23 | throw new Error('WORKOS_CLIENT_ID is not defined') 24 | } 25 | 26 | const { user } = await workos.userManagement.authenticateWithCode({ 27 | code, 28 | clientId, 29 | }) 30 | 31 | // Cleanup params and redirect 32 | const url = req.nextUrl.clone() 33 | url.searchParams.delete('code') 34 | if (state) { 35 | url.pathname = state 36 | url.searchParams.delete('state') 37 | } else { 38 | url.pathname = '/' 39 | } 40 | 41 | const response = NextResponse.redirect(url) 42 | 43 | // Create a JWT with the user's information 44 | const token = await new SignJWT({ 45 | user, 46 | }) 47 | .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) 48 | .setIssuedAt() 49 | .setExpirationTime('1y') 50 | .sign(secret) 51 | 52 | // Store in a cookie 53 | response.cookies.set({ 54 | name: 'token', 55 | value: token, 56 | path: '/', 57 | httpOnly: true, 58 | secure: true, 59 | sameSite: 'lax', 60 | }) 61 | 62 | return response 63 | } 64 | -------------------------------------------------------------------------------- /app/api/auth/webhooks/user-events/route.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createDbUser, 3 | deleteDbUser, 4 | getDbUserById, 5 | updateDbUser, 6 | } from '@/app/db/operations/users' 7 | import WorkOS from '@workos-inc/node' 8 | import { NextRequest } from 'next/server' 9 | 10 | type TypeEventType = 'user.created' | 'user.updated' | 'user.deleted' 11 | 12 | export type TypeEventUserData = { 13 | id: string 14 | email: string 15 | first_name: string 16 | last_name: string 17 | email_verified: boolean 18 | created_at: string 19 | updated_at: string 20 | } 21 | 22 | export async function POST(req: NextRequest) { 23 | const body = await req.json() 24 | const eventType = body.event as TypeEventType 25 | const eventUserData = body.data as TypeEventUserData 26 | const workos = new WorkOS(process.env.WORKOS_API_KEY) 27 | const sigHeader = req.headers.get('WorkOS-Signature') || '' 28 | 29 | const webhook = workos.webhooks.verifyHeader({ 30 | payload: body, 31 | sigHeader: sigHeader, 32 | secret: process.env.WORKOS_USER_EVENTS_WEBHOOK_SECRET!, 33 | }) 34 | 35 | if (!webhook) return new Response('Unauthorized', { status: 401 }) 36 | 37 | try { 38 | // Check if a user with this ID exists in the DB 39 | const user = await getDbUserById(eventUserData.id) 40 | 41 | // If the user doesn't exist in the DB and the event is a creation, add the new user to the DB 42 | if (!user && eventType === 'user.created') { 43 | await createDbUser(eventUserData) 44 | } 45 | 46 | // If the user exists in the DB and the event is an update, update the user in the DB 47 | if (!!user && eventType === 'user.updated') { 48 | await updateDbUser(eventUserData) 49 | } 50 | 51 | // If the user exists in the DB and the event is a deletion, delete the user from the DB 52 | if (!!user && eventType === 'user.deleted') { 53 | await deleteDbUser(eventUserData.id) 54 | } 55 | 56 | // If the user doesn't exist in the DB and the event is an update or deletion (which shouldn't happen), return an error response so the webhook fails and WorkOS notifies us 57 | if (!user && eventType !== 'user.created') { 58 | return new Response('User not found', { status: 404 }) 59 | } 60 | if (!user && eventType === 'user.deleted') { 61 | return new Response('User not found', { status: 404 }) 62 | } 63 | 64 | return new Response('OK', { status: 200 }) 65 | } catch (e) { 66 | console.log(e) 67 | return new Response('Endpoint error', { status: 500 }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/api/metatags/validate/route.ts: -------------------------------------------------------------------------------- 1 | import { getValidatedMetatags } from '@/app/api/metatags/validate/utils' 2 | import { ratelimit } from '@/app/lib/upstash' 3 | import { isValidUrl } from '@/app/utils' 4 | import { ipAddress } from '@vercel/edge' 5 | import { NextRequest } from 'next/server' 6 | 7 | export const runtime = 'edge' 8 | 9 | export async function GET(req: NextRequest) { 10 | const url = req.nextUrl.searchParams.get('url') 11 | if (!url || !isValidUrl(url)) { 12 | return new Response('Invalid URL', { status: 400 }) 13 | } 14 | 15 | // We'll use the user's IP address to rate limit the requests 16 | const ip = ipAddress(req) ?? '' 17 | const { success } = await ratelimit().limit(ip) 18 | if (!success) { 19 | return new Response("You've hit the rate limit", { status: 429 }) 20 | } 21 | 22 | // If the URL is valid and the request gets permission from the ratelimit, we'll fetch the metatags and return them 23 | const validatedMetatags = await getValidatedMetatags(url) 24 | return new Response(JSON.stringify(validatedMetatags), { 25 | status: 200, 26 | headers: { 27 | 'Content-Type': 'application/json', 28 | 'Access-Control-Allow-Origin': '*', 29 | }, 30 | }) 31 | } 32 | 33 | export function OPTIONS() { 34 | return new Response(null, { 35 | status: 204, 36 | headers: { 37 | 'Access-Control-Allow-Origin': '*', 38 | 'Access-Control-Allow-Methods': 'GET, OPTIONS', 39 | }, 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /app/api/templates/[templateId]/route.ts: -------------------------------------------------------------------------------- 1 | import { getTemplateById } from '@/app/db/operations/templates' 2 | import { NextRequest } from 'next/server' 3 | 4 | export const dynamic = 'force-dynamic' 5 | 6 | export async function GET(req: NextRequest) { 7 | const templateId = req.nextUrl.pathname.split('/')[3] 8 | 9 | if (!templateId) { 10 | return new Response('Missing templateId', { status: 400 }) 11 | } 12 | 13 | const templates = await getTemplateById(templateId) 14 | 15 | return new Response(JSON.stringify(templates), { 16 | status: 200, 17 | headers: { 18 | 'Content-Type': 'application/json', 19 | 'Access-Control-Allow-Origin': '*', 20 | }, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /app/api/templates/route.ts: -------------------------------------------------------------------------------- 1 | import { getProjectsTemplates } from '@/app/db/operations/templates' 2 | import { getTemplateUrlsRedis } from '@/app/lib/upstash' 3 | import { NextRequest } from 'next/server' 4 | 5 | export const dynamic = 'force-dynamic' 6 | 7 | export async function GET(req: NextRequest) { 8 | const projectId = req.nextUrl.searchParams.get('projectId') 9 | 10 | if (!projectId) { 11 | return new Response('Missing projectId', { status: 400 }) 12 | } 13 | 14 | try { 15 | const templatesWithoutURLs = await getProjectsTemplates(projectId) 16 | 17 | const templates = await Promise.all( 18 | templatesWithoutURLs.map(async (template) => { 19 | const urls = await getTemplateUrlsRedis(template.id) 20 | return { 21 | ...template, 22 | urls, 23 | } 24 | }) 25 | ) 26 | 27 | return new Response(JSON.stringify(templates), { 28 | status: 200, 29 | }) 30 | } catch (error) { 31 | return new Response('Internal Server Error', { status: 500 }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/api/uploadthing/core.ts: -------------------------------------------------------------------------------- 1 | import { getUser } from '@/app/lib/workos' 2 | import { createUploadthing, type FileRouter } from 'uploadthing/next' 3 | import { UploadThingError } from 'uploadthing/server' 4 | 5 | const f = createUploadthing() 6 | 7 | // FileRouter for your app, can contain multiple FileRoutes 8 | export const ourFileRouter = { 9 | // Define as many FileRoutes as you like, each with a unique routeSlug 10 | imageUploader: f({ 11 | 'image/jpeg': { maxFileSize: '4MB', maxFileCount: 1 }, 12 | 'image/png': { maxFileSize: '4MB', maxFileCount: 1 }, 13 | }) 14 | // Set permissions and file types for this FileRoute 15 | .middleware(async ({ req }) => { 16 | // This code runs on your server before upload 17 | const auth = await getUser() 18 | 19 | // If you throw, the user will not be able to upload 20 | if (!auth.user || !auth.isAuthenticated) { 21 | throw new UploadThingError('Unauthorized') 22 | } 23 | 24 | // Whatever is returned here is accessible in onUploadComplete as `metadata` 25 | return { userId: auth.user.id } 26 | }) 27 | .onUploadComplete(async ({ metadata, file }) => { 28 | // This code runs on your server after upload 29 | 30 | // Whatever is returned here is sent to the clientside `onClientUploadComplete` callback 31 | return { uploadedBy: metadata.userId } 32 | }), 33 | } satisfies FileRouter 34 | 35 | export type OurFileRouter = typeof ourFileRouter 36 | -------------------------------------------------------------------------------- /app/api/uploadthing/route.ts: -------------------------------------------------------------------------------- 1 | import { createRouteHandler } from 'uploadthing/next' 2 | 3 | import { ourFileRouter } from './core' 4 | 5 | // Export routes for Next App Router 6 | export const { GET, POST } = createRouteHandler({ 7 | router: ourFileRouter, 8 | }) 9 | -------------------------------------------------------------------------------- /app/constants.ts: -------------------------------------------------------------------------------- 1 | export const FREE_IMAGES = 200 2 | export const FREE_TEMPLATES = 5 3 | 4 | export const GOOGLE_FAVICON_URL = 5 | 'https://www.google.com/s2/favicons?sz=64&domain_url=' 6 | 7 | export const SECOND_LEVEL_DOMAINS = new Set([ 8 | 'com', 9 | 'co', 10 | 'net', 11 | 'org', 12 | 'edu', 13 | 'gov', 14 | 'in', 15 | ]) 16 | 17 | export const SPECIAL_APEX_DOMAINS = new Set([ 18 | 'my.id', 19 | 'github.io', 20 | 'vercel.app', 21 | 'now.sh', 22 | 'pages.dev', 23 | 'webflow.io', 24 | 'netlify.app', 25 | 'fly.dev', 26 | 'web.app', 27 | ]) 28 | 29 | export const ccTLDs = new Set([ 30 | 'af', 31 | 'ax', 32 | 'al', 33 | 'dz', 34 | 'as', 35 | 'ad', 36 | 'ao', 37 | 'ai', 38 | 'aq', 39 | 'ag', 40 | 'ar', 41 | 'am', 42 | 'aw', 43 | 'ac', 44 | 'au', 45 | 'at', 46 | 'az', 47 | 'bs', 48 | 'bh', 49 | 'bd', 50 | 'bb', 51 | 'eus', 52 | 'by', 53 | 'be', 54 | 'bz', 55 | 'bj', 56 | 'bm', 57 | 'bt', 58 | 'bo', 59 | 'bq', 60 | 'an', 61 | 'nl', 62 | 'ba', 63 | 'bw', 64 | 'bv', 65 | 'br', 66 | 'io', 67 | 'vg', 68 | 'bn', 69 | 'bg', 70 | 'bf', 71 | 'mm', 72 | 'bi', 73 | 'kh', 74 | 'cm', 75 | 'ca', 76 | 'cv', 77 | 'cat', 78 | 'ky', 79 | 'cf', 80 | 'td', 81 | 'cl', 82 | 'cn', 83 | 'cx', 84 | 'cc', 85 | 'co', 86 | 'km', 87 | 'cd', 88 | 'cg', 89 | 'ck', 90 | 'cr', 91 | 'ci', 92 | 'hr', 93 | 'cu', 94 | 'cw', 95 | 'cy', 96 | 'cz', 97 | 'dk', 98 | 'dj', 99 | 'dm', 100 | 'do', 101 | 'tl', 102 | 'tp', 103 | 'ec', 104 | 'eg', 105 | 'sv', 106 | 'gq', 107 | 'er', 108 | 'ee', 109 | 'et', 110 | 'eu', 111 | 'fk', 112 | 'fo', 113 | 'fm', 114 | 'fj', 115 | 'fi', 116 | 'fr', 117 | 'gf', 118 | 'pf', 119 | 'tf', 120 | 'ga', 121 | 'gal', 122 | 'gm', 123 | 'ps', 124 | 'ge', 125 | 'de', 126 | 'gh', 127 | 'gi', 128 | 'gr', 129 | 'gl', 130 | 'gd', 131 | 'gp', 132 | 'gu', 133 | 'gt', 134 | 'gg', 135 | 'gn', 136 | 'gw', 137 | 'gy', 138 | 'ht', 139 | 'hm', 140 | 'hn', 141 | 'hk', 142 | 'hu', 143 | 'is', 144 | 'in', 145 | 'id', 146 | 'ir', 147 | 'iq', 148 | 'ie', 149 | 'im', 150 | 'il', 151 | 'it', 152 | 'jm', 153 | 'jp', 154 | 'je', 155 | 'jo', 156 | 'kz', 157 | 'ke', 158 | 'ki', 159 | 'kw', 160 | 'kg', 161 | 'la', 162 | 'lv', 163 | 'lb', 164 | 'ls', 165 | 'lr', 166 | 'ly', 167 | 'li', 168 | 'lt', 169 | 'lu', 170 | 'mo', 171 | 'mk', 172 | 'mg', 173 | 'mw', 174 | 'my', 175 | 'mv', 176 | 'ml', 177 | 'mt', 178 | 'mh', 179 | 'mq', 180 | 'mr', 181 | 'mu', 182 | 'yt', 183 | 'mx', 184 | 'md', 185 | 'mc', 186 | 'mn', 187 | 'me', 188 | 'ms', 189 | 'ma', 190 | 'mz', 191 | 'mm', 192 | 'na', 193 | 'nr', 194 | 'np', 195 | 'nl', 196 | 'nc', 197 | 'nz', 198 | 'ni', 199 | 'ne', 200 | 'ng', 201 | 'nu', 202 | 'nf', 203 | 'nc', 204 | 'tr', 205 | 'kp', 206 | 'mp', 207 | 'no', 208 | 'om', 209 | 'pk', 210 | 'pw', 211 | 'ps', 212 | 'pa', 213 | 'pg', 214 | 'py', 215 | 'pe', 216 | 'ph', 217 | 'pn', 218 | 'pl', 219 | 'pt', 220 | 'pr', 221 | 'qa', 222 | 'ro', 223 | 'ru', 224 | 'rw', 225 | 're', 226 | 'bq', 227 | 'an', 228 | 'bl', 229 | 'gp', 230 | 'fr', 231 | 'sh', 232 | 'kn', 233 | 'lc', 234 | 'mf', 235 | 'gp', 236 | 'fr', 237 | 'pm', 238 | 'vc', 239 | 'ws', 240 | 'sm', 241 | 'st', 242 | 'sa', 243 | 'sn', 244 | 'rs', 245 | 'sc', 246 | 'sl', 247 | 'sg', 248 | 'bq', 249 | 'an', 250 | 'nl', 251 | 'sx', 252 | 'an', 253 | 'sk', 254 | 'si', 255 | 'sb', 256 | 'so', 257 | 'so', 258 | 'za', 259 | 'gs', 260 | 'kr', 261 | 'ss', 262 | 'es', 263 | 'lk', 264 | 'sd', 265 | 'sr', 266 | 'sj', 267 | 'sz', 268 | 'se', 269 | 'ch', 270 | 'sy', 271 | 'tw', 272 | 'tj', 273 | 'tz', 274 | 'th', 275 | 'tg', 276 | 'tk', 277 | 'to', 278 | 'tt', 279 | 'tn', 280 | 'tr', 281 | 'tm', 282 | 'tc', 283 | 'tv', 284 | 'ug', 285 | 'ua', 286 | 'ae', 287 | 'uk', 288 | 'us', 289 | 'vi', 290 | 'uy', 291 | 'uz', 292 | 'vu', 293 | 'va', 294 | 've', 295 | 'vn', 296 | 'wf', 297 | 'eh', 298 | 'ma', 299 | 'ye', 300 | 'zm', 301 | 'zw', 302 | ]) 303 | -------------------------------------------------------------------------------- /app/db/index.ts: -------------------------------------------------------------------------------- 1 | import { neon } from '@neondatabase/serverless' 2 | import { drizzle } from 'drizzle-orm/neon-http' 3 | import * as schema from './schema' 4 | 5 | const sql = neon(process.env.NEON_POSTGRES_URL!) as any 6 | 7 | export const db = drizzle(sql, { schema }) 8 | -------------------------------------------------------------------------------- /app/db/operations/projects.ts: -------------------------------------------------------------------------------- 1 | import { db } from '@/app/db' 2 | import { projects } from '@/app/db/schema' 3 | import { eq, sql } from 'drizzle-orm' 4 | import { unstable_cache } from 'next/cache' 5 | 6 | export const getUserProjects = unstable_cache( 7 | async (userId: string) => 8 | await db.query['projects'].findMany({ 9 | where: eq(projects.userId, userId), 10 | }), 11 | ['projects'], 12 | { tags: ['projects'] } 13 | ) 14 | 15 | export async function getProjectByPathname(pathname: string) { 16 | return await db.query['projects'].findFirst({ 17 | where: eq(projects.pathname, pathname), 18 | }) 19 | } 20 | 21 | async function generateUniqueProjectPathname( 22 | projectName: string 23 | ): Promise { 24 | const basePathname = projectName 25 | .toLowerCase() 26 | .replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric characters with dashes 27 | .replace(/^-+|-+$/g, '') // Remove leading and trailing dashes 28 | let uniquePathname = basePathname 29 | let counter = 1 30 | 31 | while (await projectPathnameExists(uniquePathname)) { 32 | uniquePathname = `${basePathname}-${counter}` 33 | counter++ 34 | } 35 | 36 | return uniquePathname 37 | } 38 | 39 | async function projectPathnameExists(pathname: string): Promise { 40 | const project = await db.query['projects'].findFirst({ 41 | where: eq(projects.pathname, pathname), 42 | }) 43 | return !!project 44 | } 45 | 46 | export async function createProject({ 47 | name, 48 | userId, 49 | }: { 50 | name: string 51 | userId: string 52 | }) { 53 | return await db 54 | .insert(projects) 55 | .values({ 56 | name: name, 57 | userId: userId, 58 | pathname: await generateUniqueProjectPathname(name), 59 | updatedAt: new Date(), 60 | }) 61 | .returning({ 62 | insertId: projects.id, 63 | name: projects.name, 64 | userId: projects.userId, 65 | pathname: projects.pathname, 66 | }) 67 | } 68 | 69 | export async function updateProjectSubscription({ 70 | projectId, 71 | plan, 72 | productId, 73 | variantId, 74 | suscriptionId, 75 | suscriptionItemId, 76 | }: { 77 | projectId: string 78 | plan: string 79 | productId: string | null 80 | variantId: string | null 81 | suscriptionId: string | null 82 | suscriptionItemId: string | null 83 | }) { 84 | return await db 85 | .update(projects) 86 | .set({ 87 | plan, 88 | productId, 89 | variantId, 90 | suscriptionId, 91 | suscriptionItemId, 92 | updatedAt: new Date(), 93 | }) 94 | .where(eq(projects.id, projectId)) 95 | } 96 | 97 | export async function incrementProjectImagesCreated({ 98 | projectId, 99 | quantity, 100 | }: { 101 | projectId: string 102 | quantity: number 103 | }) { 104 | return await db 105 | .update(projects) 106 | .set({ 107 | imagesCreated: sql`${projects.imagesCreated} + ${quantity}`, 108 | updatedAt: new Date(), 109 | }) 110 | .where(eq(projects.id, projectId)) 111 | } 112 | -------------------------------------------------------------------------------- /app/db/operations/templates.ts: -------------------------------------------------------------------------------- 1 | import { db } from '@/app/db' 2 | import { templates } from '@/app/db/schema' 3 | import { count, eq, inArray } from 'drizzle-orm' 4 | 5 | export async function getProjectsTemplates(projectId: string) { 6 | return await db.query['templates'].findMany({ 7 | where: eq(templates.projectId, projectId), 8 | }) 9 | } 10 | 11 | export async function getTemplateById(id: string) { 12 | return await db.query['templates'].findFirst({ 13 | where: eq(templates.id, id), 14 | }) 15 | } 16 | 17 | export async function getTemplateByArrayOfIds(ids: string[]) { 18 | return await db.query['templates'].findMany({ 19 | where: inArray(templates.id, ids), 20 | }) 21 | } 22 | 23 | export async function getAllTemplatesCount() { 24 | const templatesCount = await db.select({ count: count() }).from(templates) 25 | return templatesCount[0].count 26 | } 27 | 28 | export async function createTemplate({ 29 | id, 30 | name, 31 | projectId, 32 | layersData, 33 | canvasBackgroundColor, 34 | }: { 35 | id: string 36 | name: string 37 | projectId: string 38 | layersData: string 39 | canvasBackgroundColor: string 40 | }) { 41 | return await db.insert(templates).values({ 42 | id, 43 | name, 44 | projectId, 45 | layersData, 46 | canvasBackgroundColor, 47 | updatedAt: new Date(), 48 | }) 49 | } 50 | 51 | export async function updateTemplate({ 52 | id, 53 | name, 54 | layersData, 55 | canvasBackgroundColor, 56 | }: { 57 | id: string 58 | name: string 59 | layersData: string 60 | canvasBackgroundColor: string 61 | }) { 62 | return await db 63 | .update(templates) 64 | .set({ 65 | name: name, 66 | layersData: layersData, 67 | updatedAt: new Date(), 68 | canvasBackgroundColor: canvasBackgroundColor, 69 | }) 70 | .where(eq(templates.id, id)) 71 | } 72 | 73 | export async function deleteTemplate(id: string) { 74 | return await db.delete(templates).where(eq(templates.id, id)) 75 | } 76 | -------------------------------------------------------------------------------- /app/db/operations/uploaded_images.ts: -------------------------------------------------------------------------------- 1 | import { db } from '@/app/db' 2 | import { uploadedImages } from '@/app/db/schema' 3 | 4 | export async function createUploadedImage({ 5 | key, 6 | url, 7 | userId, 8 | }: { 9 | key: string 10 | url: string 11 | userId: string 12 | }) { 13 | return await db.insert(uploadedImages).values({ 14 | key: key, 15 | url: url, 16 | userId: userId, 17 | updatedAt: new Date(), 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /app/db/operations/users.ts: -------------------------------------------------------------------------------- 1 | import { TypeEventUserData } from '@/app/api/auth/webhooks/user-events/route' 2 | import { db } from '@/app/db' 3 | import { users } from '@/app/db/schema' 4 | import { count, eq } from 'drizzle-orm' 5 | 6 | export async function getDbUserById(id: string) { 7 | return await db.query['users'].findFirst({ 8 | where: eq(users.id, id), 9 | }) 10 | } 11 | 12 | export async function getAllUsersCount() { 13 | const usersCount = await db.select({ count: count() }).from(users) 14 | return usersCount[0].count - 1 // Exclude the local test user 15 | } 16 | 17 | export async function createDbUser(eventUserData: TypeEventUserData) { 18 | return await db.insert(users).values({ 19 | id: eventUserData.id, 20 | email: eventUserData.email, 21 | firstName: eventUserData.first_name, 22 | lastName: eventUserData.last_name, 23 | emailVerified: eventUserData.email_verified, 24 | updatedAt: new Date(eventUserData.updated_at), 25 | }) 26 | } 27 | 28 | export async function updateDbUser(eventUserData: TypeEventUserData) { 29 | return await db 30 | .update(users) 31 | .set({ 32 | email: eventUserData.email, 33 | firstName: eventUserData.first_name, 34 | lastName: eventUserData.last_name, 35 | emailVerified: eventUserData.email_verified, 36 | updatedAt: new Date(eventUserData.updated_at), 37 | }) 38 | .where(eq(users.id, eventUserData.id)) 39 | } 40 | 41 | export async function deleteDbUser(id: string) { 42 | return await db.delete(users).where(eq(users.id, id)) 43 | } 44 | -------------------------------------------------------------------------------- /app/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | boolean, 3 | integer, 4 | pgTable, 5 | text, 6 | timestamp, 7 | uuid, 8 | varchar, 9 | } from 'drizzle-orm/pg-core' 10 | 11 | export const users = pgTable('users', { 12 | id: varchar('id', { length: 31 }).primaryKey().notNull(), // ID format of WorkOS 13 | email: varchar('email', { length: 255 }).notNull().unique(), 14 | firstName: text('first_name'), 15 | lastName: text('last_name'), 16 | emailVerified: boolean('email_verified'), 17 | createdAt: timestamp('createdAt').defaultNow().notNull(), 18 | updatedAt: timestamp('updatedAt').notNull(), 19 | }) 20 | export type UserType = typeof users.$inferInsert 21 | 22 | export const projects = pgTable('projects', { 23 | id: uuid('uuid').defaultRandom().primaryKey().notNull(), 24 | name: text('name').notNull(), 25 | pathname: text('pathname').notNull().unique(), 26 | plan: text('plan').default('free'), // DEPRECATED - all projects are free now 27 | productId: text('product_id'), // Lemon Squeezy product ID 28 | variantId: text('variant_id'), // Lemon Squeezy variant ID 29 | suscriptionId: text('suscription_id'), // Lemon Squeezy subscription ID 30 | suscriptionItemId: text('suscription_item_id'), // Lemon Squeezy subscription item ID 31 | imagesCreated: integer('images_created').default(0).notNull(), // Number of images created (historical) 32 | userId: varchar('user_id', { length: 31 }) // ID of the project owner 33 | .references(() => users.id) 34 | .notNull(), 35 | createdAt: timestamp('createdAt').defaultNow().notNull(), 36 | updatedAt: timestamp('updatedAt').notNull(), 37 | }) 38 | export type ProjectType = typeof projects.$inferInsert 39 | 40 | // Templates are also stored on upstash for quick retrieval. 41 | export const templates = pgTable('templates', { 42 | id: uuid('uuid').defaultRandom().primaryKey().notNull(), 43 | name: text('name').notNull(), 44 | description: text('description'), 45 | projectId: uuid('project_id') 46 | .references(() => projects.id) 47 | .notNull(), 48 | layersData: text('layersData').notNull(), // JSON data 49 | canvasBackgroundColor: text('canvasBackgroundColor') 50 | .default('#ffffff') 51 | .notNull(), 52 | createdAt: timestamp('createdAt').defaultNow().notNull(), 53 | updatedAt: timestamp('updatedAt').notNull(), 54 | }) 55 | 56 | export type TemplateType = typeof templates.$inferInsert 57 | 58 | export const uploadedImages = pgTable('uploaded_images', { 59 | id: uuid('uuid').defaultRandom().primaryKey(), 60 | key: text('key').notNull(), 61 | url: text('url').notNull(), 62 | userId: varchar('user_id', { length: 31 }) 63 | .references(() => users.id) 64 | .notNull(), 65 | createdAt: timestamp('createdAt').defaultNow().notNull(), 66 | updatedAt: timestamp('updatedAt').notNull(), 67 | }) 68 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | .title { 7 | @apply text-3xl font-bold; 8 | } 9 | .second-title { 10 | @apply text-2xl font-medium; 11 | } 12 | .third-title { 13 | @apply text-xl font-medium; 14 | } 15 | .subtitle { 16 | @apply text-lg font-normal leading-6; 17 | } 18 | .marketing-title { 19 | @apply text-[2rem] font-bold leading-9; 20 | } 21 | .marketing-second-title { 22 | @apply text-2xl font-medium; 23 | } 24 | .marketing-third-title { 25 | @apply text-xl font-medium; 26 | } 27 | .marketing-subtitle { 28 | @apply text-lg font-normal; 29 | } 30 | } 31 | 32 | @layer base { 33 | :root { 34 | --background: 0 0% 100%; /* #ffffff */ 35 | --foreground: 0 0% 4%; /* #0a0a0a neutral-950 */ 36 | --card: 0 0% 100%; /* #ffffff */ 37 | --card-foreground: 0 0% 4%; /* #0a0a0a neutral-950 */ 38 | --popover: 0 0% 100%; /* #ffffff */ 39 | --popover-foreground: 0 0% 4%; /* #0a0a0a neutral-950 */ 40 | --primary: 19 100% 58%; /* #ff6d2a */ 41 | --primary-foreground: 0 0% 100%; /* #ffffff */ 42 | --secondary: 0 0% 90%; /* #e5e5e5 neutral-200 */ 43 | --secondary-foreground: 0 0% 9%; /* #171717 neutral-900 */ 44 | --muted: 0 0% 96%; /* #fafafa neutral-100 */ 45 | --muted-foreground: 0 0% 45%; /* #737373 neutral-500 */ 46 | --accent: 0 0% 96%; /* #fafafa neutral-100 */ 47 | --accent-foreground: 0 0% 9%; /* #171717 neutral-900 */ 48 | --destructive: 0 84.2% 60.2%; /* EF4444 */ 49 | --destructive-foreground: 0 0% 98%; /* #fafafa neutral-50 */ 50 | --border: 0 0% 90%; /* #e5e5e5 neutral-200 */ 51 | --input: 0 0% 90%; /* #e5e5e5 neutral-200 */ 52 | --ring: 19 100% 58%; /* #ff6d2a */ 53 | --radius: 0.75rem; 54 | } 55 | 56 | .dark { 57 | --background: 0 0% 9%; /* #171717 neutral-900 */ 58 | --foreground: 0 0% 100%; /* ffffff */ 59 | --card: 0 0% 9%; /* #171717 neutral-900 */ 60 | --card-foreground: 0 0% 100%; /* ffffff */ 61 | --popover: 0 0% 9%; /* #171717 neutral-900 */ 62 | --popover-foreground: 0 0% 100%; /* ffffff */ 63 | --primary: 19 100% 55%; /* #ff6319 */ 64 | --primary-foreground: 0 0% 100%; /* ffffff */ 65 | --secondary: 0 0% 25%; /* #404040 neutral-700 */ 66 | --secondary-foreground: 0 0% 100%; /* ffffff */ 67 | --muted: 0 0% 25%; /* #404040 neutral-700 */ 68 | --muted-foreground: 0 0% 64%; /* #a3a3a3 neutral-400 */ 69 | --accent: 0 0% 25%; /* #404040 neutral-700 */ 70 | --accent-foreground: 0 0% 100%; /* ffffff */ 71 | --destructive: 0 72.2% 50.6%; /* #dc2626 */ 72 | --destructive-foreground: 0 0% 100%; /* ffffff */ 73 | --border: 0 0% 25%; /* #404040 neutral-700 */ 74 | --input: 0 0% 25%; /* #404040 neutral-700 */ 75 | --ring: 19 100% 55%; /* #ff6319 */ 76 | } 77 | } 78 | 79 | @layer base { 80 | * { 81 | @apply border-border ring-ring; 82 | } 83 | body { 84 | @apply scroll-smooth bg-background text-sm text-foreground selection:bg-primary/50; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /app/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgalanb/sharepreviews/642681f9af9fefc2035ef069bdda39fb32253e2d/app/icon.png -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import dynamic from 'next/dynamic' 2 | 3 | export default async function RootLayout({ 4 | children, 5 | }: { 6 | children: React.ReactNode 7 | }) { 8 | const Crisp = dynamic(() => import('./lib/crisp')) 9 | 10 | return ( 11 | 12 | 13 | {children} 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /app/lib/crisp.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Crisp } from 'crisp-sdk-web' 4 | import { useEffect } from 'react' 5 | 6 | export default function CrispChat() { 7 | useEffect(() => { 8 | Crisp.configure('9f015aa2-4e0b-49e5-ae3c-29849fbae596') 9 | }) 10 | 11 | return null 12 | } 13 | -------------------------------------------------------------------------------- /app/lib/workos.ts: -------------------------------------------------------------------------------- 1 | import { User, WorkOS } from '@workos-inc/node' 2 | import { jwtVerify } from 'jose' 3 | import { cookies } from 'next/headers' 4 | 5 | const workos = new WorkOS(process.env.WORKOS_API_KEY as string) 6 | const clientId = process.env.WORKOS_CLIENT_ID 7 | const secret = new Uint8Array( 8 | Buffer.from(process.env.JWT_SECRET_KEY as string, 'base64') 9 | ) 10 | const redirectUri = process.env.WORKOS_REDIRECT_URI ?? '' 11 | 12 | export function getAuthorizationUrl({ 13 | screenHint, 14 | redirectPathname, 15 | }: { 16 | screenHint: 'sign-in' | 'sign-up' 17 | redirectPathname?: string 18 | }) { 19 | if (!clientId) { 20 | throw new Error('WORKOS_CLIENT_ID is not defined') 21 | } 22 | const authorizationUrl = workos.userManagement.getAuthorizationUrl({ 23 | // Specify that we'd like AuthKit to handle the authentication flow 24 | provider: 'authkit', 25 | // Callback endpoint URL. WorkOS will redirect to this after a user authenticates with them. 26 | redirectUri, 27 | clientId, 28 | screenHint: screenHint ?? 'sign-in', 29 | state: redirectPathname, 30 | }) 31 | 32 | return authorizationUrl 33 | } 34 | 35 | export async function getUser() { 36 | const token = cookies().get('token')?.value 37 | 38 | // Verify the JWT signature 39 | let verifiedToken 40 | try { 41 | if (!token) { 42 | return { isAuthenticated: false } 43 | } 44 | verifiedToken = await jwtVerify(token, secret) 45 | } catch { 46 | return { isAuthenticated: false } 47 | } 48 | 49 | // Return the User object if the token is valid 50 | return { 51 | isAuthenticated: true, 52 | user: verifiedToken.payload.user as User, 53 | } 54 | } 55 | 56 | export async function logOutUser() { 57 | cookies().set('token', '', { 58 | expires: new Date(0), 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgalanb/sharepreviews/642681f9af9fefc2035ef069bdda39fb32253e2d/app/opengraph-image.png -------------------------------------------------------------------------------- /app/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { User } from '@workos-inc/node' 4 | import { Crisp } from 'crisp-sdk-web' 5 | import { ThemeProvider } from 'next-themes' 6 | import { useEffect } from 'react' 7 | 8 | export function Providers({ 9 | children, 10 | user, 11 | ...props 12 | }: { 13 | children: React.ReactNode 14 | user: User 15 | [key: string]: any 16 | }) { 17 | useEffect(() => { 18 | if (user?.email) { 19 | Crisp.user.setEmail(user.email) 20 | Crisp.user.setNickname(`${user.firstName} ${user.lastName}` || user.email) 21 | } 22 | }, [user]) 23 | 24 | return {children} 25 | } 26 | -------------------------------------------------------------------------------- /app/twitter-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgalanb/sharepreviews/642681f9af9fefc2035ef069bdda39fb32253e2d/app/twitter-image.png -------------------------------------------------------------------------------- /app/ui/components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/app/utils' 4 | import * as AvatarPrimitive from '@radix-ui/react-avatar' 5 | import * as React from 'react' 6 | 7 | const Avatar = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | )) 20 | Avatar.displayName = AvatarPrimitive.Root.displayName 21 | 22 | const AvatarImage = React.forwardRef< 23 | React.ElementRef, 24 | React.ComponentPropsWithoutRef 25 | >(({ className, ...props }, ref) => ( 26 | 31 | )) 32 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 33 | 34 | const AvatarFallback = React.forwardRef< 35 | React.ElementRef, 36 | React.ComponentPropsWithoutRef 37 | >(({ className, ...props }, ref) => ( 38 | 46 | )) 47 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 48 | 49 | export { Avatar, AvatarFallback, AvatarImage } 50 | -------------------------------------------------------------------------------- /app/ui/components/Badge.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/app/utils' 2 | import { cva, type VariantProps } from 'class-variance-authority' 3 | import * as React from 'react' 4 | 5 | const badgeVariants = cva( 6 | 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-sm font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', 7 | { 8 | variants: { 9 | variant: { 10 | default: 11 | 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', 12 | secondary: 13 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 14 | destructive: 15 | 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', 16 | outline: 'text-foreground', 17 | }, 18 | }, 19 | defaultVariants: { 20 | variant: 'default', 21 | }, 22 | } 23 | ) 24 | 25 | export interface BadgeProps 26 | extends React.HTMLAttributes, 27 | VariantProps {} 28 | 29 | function Badge({ className, variant, ...props }: BadgeProps) { 30 | return ( 31 |
32 | ) 33 | } 34 | 35 | export { Badge, badgeVariants } 36 | -------------------------------------------------------------------------------- /app/ui/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/app/utils' 2 | import { Slot } from '@radix-ui/react-slot' 3 | import { cva, type VariantProps } from 'class-variance-authority' 4 | import * as React from 'react' 5 | 6 | const buttonVariants = cva( 7 | '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 cursor-default', 8 | { 9 | variants: { 10 | variant: { 11 | default: 'bg-primary text-primary-foreground hover:bg-primary/90', 12 | destructive: 13 | 'bg-destructive text-destructive-foreground hover:bg-destructive/90', 14 | outline: 15 | 'border border-input bg-card hover:bg-accent hover:text-accent-foreground focus-visible:ring-foreground', 16 | secondary: 17 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 18 | ghost: 'hover:bg-accent hover:text-accent-foreground', 19 | link: 'text-primary underline-offset-4 hover:underline', 20 | }, 21 | size: { 22 | default: 'h-10 px-4 py-2', 23 | sm: 'h-9 rounded-md px-3', 24 | lg: 'h-11 rounded-md px-8', 25 | icon: 'h-10 w-10', 26 | }, 27 | }, 28 | defaultVariants: { 29 | variant: 'default', 30 | size: 'default', 31 | }, 32 | } 33 | ) 34 | 35 | export interface ButtonProps 36 | extends React.ButtonHTMLAttributes, 37 | VariantProps { 38 | asChild?: boolean 39 | } 40 | 41 | const Button = React.forwardRef( 42 | ({ className, variant, size, asChild = false, ...props }, ref) => { 43 | const Comp = asChild ? Slot : 'button' 44 | return ( 45 | 50 | ) 51 | } 52 | ) 53 | Button.displayName = 'Button' 54 | 55 | export { Button, buttonVariants } 56 | -------------------------------------------------------------------------------- /app/ui/components/Card.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/app/utils' 2 | import * as React from 'react' 3 | 4 | const Card = React.forwardRef< 5 | HTMLDivElement, 6 | React.HTMLAttributes 7 | >(({ className, ...props }, ref) => ( 8 |
16 | )) 17 | Card.displayName = 'Card' 18 | 19 | const CardHeader = React.forwardRef< 20 | HTMLDivElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 |
28 | )) 29 | CardHeader.displayName = 'CardHeader' 30 | 31 | const CardTitle = React.forwardRef< 32 | HTMLParagraphElement, 33 | React.HTMLAttributes 34 | >(({ className, ...props }, ref) => ( 35 |

40 | )) 41 | CardTitle.displayName = 'CardTitle' 42 | 43 | const CardDescription = React.forwardRef< 44 | HTMLParagraphElement, 45 | React.HTMLAttributes 46 | >(({ className, ...props }, ref) => ( 47 |

52 | )) 53 | CardDescription.displayName = 'CardDescription' 54 | 55 | const CardContent = React.forwardRef< 56 | HTMLDivElement, 57 | React.HTMLAttributes 58 | >(({ className, ...props }, ref) => ( 59 |

60 | )) 61 | CardContent.displayName = 'CardContent' 62 | 63 | const CardFooter = React.forwardRef< 64 | HTMLDivElement, 65 | React.HTMLAttributes 66 | >(({ className, ...props }, ref) => ( 67 |
72 | )) 73 | CardFooter.displayName = 'CardFooter' 74 | 75 | export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } 76 | -------------------------------------------------------------------------------- /app/ui/components/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Button } from '@/app/ui/components/Button' 4 | import { cn } from '@/app/utils' 5 | import { motion } from 'framer-motion' 6 | import { Check, Copy } from 'lucide-react' 7 | import { useState } from 'react' 8 | 9 | export default function CodeBlock({ 10 | children, 11 | className, 12 | ...props 13 | }: { 14 | children: string 15 | className?: string 16 | }) { 17 | const [isCopied, setIsCopied] = useState(false) 18 | 19 | const copyToClipboard = async (text: string) => { 20 | try { 21 | await navigator.clipboard.writeText(text) 22 | setIsCopied(true) 23 | setTimeout(() => setIsCopied(false), 700) // Reset after 0.7 seconds 24 | } catch (err) { 25 | console.error('Failed to copy:', err) 26 | } 27 | } 28 | 29 | // Animation variants for the button 30 | const buttonVariants = { 31 | initial: { scale: 1 }, 32 | animate: { scale: 1.2, transition: { duration: 0.2 } }, 33 | exit: { scale: 1, transition: { duration: 0.2 } }, 34 | } 35 | 36 | return ( 37 |
44 |       {children}
45 |       
50 |         
61 |       
62 |     
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /app/ui/components/Dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/app/utils' 4 | import * as DialogPrimitive from '@radix-ui/react-dialog' 5 | import * as React from 'react' 6 | 7 | const Dialog = DialogPrimitive.Root 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger 10 | 11 | const DialogPortal = DialogPrimitive.Portal 12 | 13 | const DialogClose = DialogPrimitive.Close 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, children, ...props }, ref) => ( 34 | 35 | 36 | 44 | {children} 45 | {/* 46 | 47 | Close 48 | */} 49 | 50 | 51 | )) 52 | DialogContent.displayName = DialogPrimitive.Content.displayName 53 | 54 | const DialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ) 66 | DialogHeader.displayName = 'DialogHeader' 67 | 68 | const DialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ) 80 | DialogFooter.displayName = 'DialogFooter' 81 | 82 | const DialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 91 | )) 92 | DialogTitle.displayName = DialogPrimitive.Title.displayName 93 | 94 | const DialogDescription = React.forwardRef< 95 | React.ElementRef, 96 | React.ComponentPropsWithoutRef 97 | >(({ className, ...props }, ref) => ( 98 | 103 | )) 104 | DialogDescription.displayName = DialogPrimitive.Description.displayName 105 | 106 | export { 107 | Dialog, 108 | DialogClose, 109 | DialogContent, 110 | DialogDescription, 111 | DialogFooter, 112 | DialogHeader, 113 | DialogOverlay, 114 | DialogPortal, 115 | DialogTitle, 116 | DialogTrigger, 117 | } 118 | -------------------------------------------------------------------------------- /app/ui/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/app/utils' 2 | import * as React from 'react' 3 | 4 | export interface InputProps 5 | extends React.InputHTMLAttributes { 6 | leftLabel?: React.ReactNode 7 | containerClassName?: string 8 | } 9 | 10 | const Input = React.forwardRef( 11 | ({ className, containerClassName, type, leftLabel, ...props }, ref) => { 12 | return ( 13 |
14 | {leftLabel && ( 15 | 16 | {leftLabel} 17 | 18 | )} 19 | 30 |
31 | ) 32 | } 33 | ) 34 | Input.displayName = 'Input' 35 | 36 | export { Input } 37 | -------------------------------------------------------------------------------- /app/ui/components/Label.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/app/utils' 4 | import * as LabelPrimitive from '@radix-ui/react-label' 5 | import { cva, type VariantProps } from 'class-variance-authority' 6 | import * as React from 'react' 7 | 8 | const labelVariants = cva( 9 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' 10 | ) 11 | 12 | const Label = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef & 15 | VariantProps 16 | >(({ className, ...props }, ref) => ( 17 | 22 | )) 23 | Label.displayName = LabelPrimitive.Root.displayName 24 | 25 | export { Label } 26 | -------------------------------------------------------------------------------- /app/ui/components/Popover.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/app/utils' 4 | import * as PopoverPrimitive from '@radix-ui/react-popover' 5 | import * as React from 'react' 6 | 7 | const Popover = PopoverPrimitive.Root 8 | 9 | const PopoverTrigger = PopoverPrimitive.Trigger 10 | 11 | const PopoverContent = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( 15 | 16 | 26 | 27 | )) 28 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 29 | 30 | export { Popover, PopoverContent, PopoverTrigger } 31 | -------------------------------------------------------------------------------- /app/ui/components/Progress.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/app/utils' 4 | import * as ProgressPrimitive from '@radix-ui/react-progress' 5 | import * as React from 'react' 6 | 7 | const Progress = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, value, ...props }, ref) => ( 11 | 19 | 23 | 24 | )) 25 | Progress.displayName = ProgressPrimitive.Root.displayName 26 | 27 | export { Progress } 28 | -------------------------------------------------------------------------------- /app/ui/components/ScrollArea.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/app/utils' 4 | import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area' 5 | import * as React from 'react' 6 | 7 | const ScrollArea = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, children, ...props }, ref) => ( 11 | 16 | 17 | {children} 18 | 19 | 20 | 21 | 22 | )) 23 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 24 | 25 | const ScrollBar = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, orientation = 'vertical', ...props }, ref) => ( 29 | 42 | 43 | 44 | )) 45 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 46 | 47 | export { ScrollArea, ScrollBar } 48 | -------------------------------------------------------------------------------- /app/ui/components/Separator.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/app/utils' 4 | import * as SeparatorPrimitive from '@radix-ui/react-separator' 5 | import * as React from 'react' 6 | 7 | const Separator = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >( 11 | ( 12 | { className, orientation = 'horizontal', decorative = true, ...props }, 13 | ref 14 | ) => ( 15 | 26 | ) 27 | ) 28 | Separator.displayName = SeparatorPrimitive.Root.displayName 29 | 30 | export { Separator } 31 | -------------------------------------------------------------------------------- /app/ui/components/Sheet.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/app/utils' 4 | import * as SheetPrimitive from '@radix-ui/react-dialog' 5 | import { cva, type VariantProps } from 'class-variance-authority' 6 | import { X } from 'lucide-react' 7 | import * as React from 'react' 8 | 9 | const Sheet = SheetPrimitive.Root 10 | 11 | const SheetTrigger = SheetPrimitive.Trigger 12 | 13 | const SheetClose = SheetPrimitive.Close 14 | 15 | const SheetPortal = SheetPrimitive.Portal 16 | 17 | const SheetOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName 31 | 32 | const sheetVariants = cva( 33 | 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500', 34 | { 35 | variants: { 36 | side: { 37 | top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', 38 | bottom: 39 | 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', 40 | left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', 41 | right: 42 | 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm', 43 | }, 44 | }, 45 | defaultVariants: { 46 | side: 'right', 47 | }, 48 | } 49 | ) 50 | 51 | interface SheetContentProps 52 | extends React.ComponentPropsWithoutRef, 53 | VariantProps {} 54 | 55 | const SheetContent = React.forwardRef< 56 | React.ElementRef, 57 | SheetContentProps 58 | >(({ side = 'right', className, children, ...props }, ref) => ( 59 | 60 | 61 | 66 | {children} 67 | 68 | 69 | Close 70 | 71 | 72 | 73 | )) 74 | SheetContent.displayName = SheetPrimitive.Content.displayName 75 | 76 | const SheetHeader = ({ 77 | className, 78 | ...props 79 | }: React.HTMLAttributes) => ( 80 |
87 | ) 88 | SheetHeader.displayName = 'SheetHeader' 89 | 90 | const SheetFooter = ({ 91 | className, 92 | ...props 93 | }: React.HTMLAttributes) => ( 94 |
101 | ) 102 | SheetFooter.displayName = 'SheetFooter' 103 | 104 | const SheetTitle = React.forwardRef< 105 | React.ElementRef, 106 | React.ComponentPropsWithoutRef 107 | >(({ className, ...props }, ref) => ( 108 | 113 | )) 114 | SheetTitle.displayName = SheetPrimitive.Title.displayName 115 | 116 | const SheetDescription = React.forwardRef< 117 | React.ElementRef, 118 | React.ComponentPropsWithoutRef 119 | >(({ className, ...props }, ref) => ( 120 | 125 | )) 126 | SheetDescription.displayName = SheetPrimitive.Description.displayName 127 | 128 | export { 129 | Sheet, 130 | SheetClose, 131 | SheetContent, 132 | SheetDescription, 133 | SheetFooter, 134 | SheetHeader, 135 | SheetOverlay, 136 | SheetPortal, 137 | SheetTitle, 138 | SheetTrigger, 139 | } 140 | -------------------------------------------------------------------------------- /app/ui/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | export default function Spinner({ className }: { className: string }) { 2 | return ( 3 |
4 | 20 | Loading... 21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /app/ui/components/Switch.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/app/utils' 4 | import * as SwitchPrimitives from '@radix-ui/react-switch' 5 | import * as React from 'react' 6 | 7 | const Switch = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | 24 | 25 | )) 26 | Switch.displayName = SwitchPrimitives.Root.displayName 27 | 28 | export { Switch } 29 | -------------------------------------------------------------------------------- /app/ui/components/Table.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/app/utils' 2 | import * as React from 'react' 3 | 4 | const Table = React.forwardRef< 5 | HTMLTableElement, 6 | React.HTMLAttributes 7 | >(({ className, ...props }, ref) => ( 8 |
9 | 14 | 15 | )) 16 | Table.displayName = 'Table' 17 | 18 | const TableHeader = React.forwardRef< 19 | HTMLTableSectionElement, 20 | React.HTMLAttributes 21 | >(({ className, ...props }, ref) => ( 22 | 23 | )) 24 | TableHeader.displayName = 'TableHeader' 25 | 26 | const TableBody = React.forwardRef< 27 | HTMLTableSectionElement, 28 | React.HTMLAttributes 29 | >(({ className, ...props }, ref) => ( 30 | 35 | )) 36 | TableBody.displayName = 'TableBody' 37 | 38 | const TableFooter = React.forwardRef< 39 | HTMLTableSectionElement, 40 | React.HTMLAttributes 41 | >(({ className, ...props }, ref) => ( 42 | tr]:last:border-b-0', 46 | className 47 | )} 48 | {...props} 49 | /> 50 | )) 51 | TableFooter.displayName = 'TableFooter' 52 | 53 | const TableRow = React.forwardRef< 54 | HTMLTableRowElement, 55 | React.HTMLAttributes 56 | >(({ className, ...props }, ref) => ( 57 | 58 | )) 59 | TableRow.displayName = 'TableRow' 60 | 61 | const TableHead = React.forwardRef< 62 | HTMLTableCellElement, 63 | React.ThHTMLAttributes 64 | >(({ className, ...props }, ref) => ( 65 |
73 | )) 74 | TableHead.displayName = 'TableHead' 75 | 76 | const TableCell = React.forwardRef< 77 | HTMLTableCellElement, 78 | React.TdHTMLAttributes 79 | >(({ className, ...props }, ref) => ( 80 | 85 | )) 86 | TableCell.displayName = 'TableCell' 87 | 88 | const TableCaption = React.forwardRef< 89 | HTMLTableCaptionElement, 90 | React.HTMLAttributes 91 | >(({ className, ...props }, ref) => ( 92 |
97 | )) 98 | TableCaption.displayName = 'TableCaption' 99 | 100 | export { 101 | Table, 102 | TableBody, 103 | TableCaption, 104 | TableCell, 105 | TableFooter, 106 | TableHead, 107 | TableHeader, 108 | TableRow, 109 | } 110 | -------------------------------------------------------------------------------- /app/ui/components/Tabs.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/app/utils' 4 | import * as TabsPrimitive from '@radix-ui/react-tabs' 5 | import * as React from 'react' 6 | 7 | const Tabs = TabsPrimitive.Root 8 | 9 | const TabsList = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | )) 22 | TabsList.displayName = TabsPrimitive.List.displayName 23 | 24 | const TabsTrigger = React.forwardRef< 25 | React.ElementRef, 26 | React.ComponentPropsWithoutRef 27 | >(({ className, ...props }, ref) => ( 28 | 36 | )) 37 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 38 | 39 | const TabsContent = React.forwardRef< 40 | React.ElementRef, 41 | React.ComponentPropsWithoutRef 42 | >(({ className, ...props }, ref) => ( 43 | 51 | )) 52 | TabsContent.displayName = TabsPrimitive.Content.displayName 53 | 54 | export { Tabs, TabsContent, TabsList, TabsTrigger } 55 | -------------------------------------------------------------------------------- /app/ui/components/Toggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/app/utils' 4 | import * as TogglePrimitive from '@radix-ui/react-toggle' 5 | import { cva, type VariantProps } from 'class-variance-authority' 6 | import * as React from 'react' 7 | 8 | const toggleVariants = cva( 9 | 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-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', 10 | { 11 | variants: { 12 | variant: { 13 | default: 'bg-transparent', 14 | outline: 15 | 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground', 16 | }, 17 | size: { 18 | default: 'h-10 px-3', 19 | sm: 'h-9 px-2.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 & 33 | VariantProps 34 | >(({ className, variant, size, ...props }, ref) => ( 35 | 40 | )) 41 | 42 | Toggle.displayName = TogglePrimitive.Root.displayName 43 | 44 | export { Toggle, toggleVariants } 45 | -------------------------------------------------------------------------------- /app/ui/components/ToggleGroup.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { toggleVariants } from '@/app/ui/components/Toggle' 4 | import { cn } from '@/app/utils' 5 | import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group' 6 | import { VariantProps } from 'class-variance-authority' 7 | import * as React from 'react' 8 | 9 | const ToggleGroupContext = React.createContext< 10 | VariantProps 11 | >({ 12 | size: 'default', 13 | variant: 'default', 14 | }) 15 | 16 | const ToggleGroup = React.forwardRef< 17 | React.ElementRef, 18 | React.ComponentPropsWithoutRef & 19 | VariantProps 20 | >(({ className, variant, size, children, ...props }, ref) => ( 21 | 26 | 27 | {children} 28 | 29 | 30 | )) 31 | 32 | ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName 33 | 34 | const ToggleGroupItem = React.forwardRef< 35 | React.ElementRef, 36 | React.ComponentPropsWithoutRef & 37 | VariantProps 38 | >(({ className, children, variant, size, ...props }, ref) => { 39 | const context = React.useContext(ToggleGroupContext) 40 | 41 | return ( 42 | 53 | {children} 54 | 55 | ) 56 | }) 57 | 58 | ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName 59 | 60 | export { ToggleGroup, ToggleGroupItem } 61 | -------------------------------------------------------------------------------- /app/ui/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@/app/utils' 4 | import * as TooltipPrimitive from '@radix-ui/react-tooltip' 5 | import * as React from 'react' 6 | 7 | const TooltipProvider = TooltipPrimitive.Provider 8 | 9 | const Tooltip = TooltipPrimitive.Root 10 | 11 | const TooltipTrigger = TooltipPrimitive.Trigger 12 | 13 | const TooltipContent = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, sideOffset = 4, ...props }, ref) => ( 17 | 26 | )) 27 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 28 | 29 | export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } 30 | -------------------------------------------------------------------------------- /app/ui/components/UploadThingComponents.ts: -------------------------------------------------------------------------------- 1 | import { OurFileRouter } from '@/app/api/uploadthing/core' 2 | import { 3 | generateUploadButton, 4 | generateUploadDropzone, 5 | } from '@uploadthing/react' 6 | 7 | export const UploadButton = generateUploadButton() 8 | export const UploadDropzone = generateUploadDropzone() 9 | -------------------------------------------------------------------------------- /app/ui/dialogs/create-first-project-dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { createProjectAction } from '@/app/actions/actions' 4 | import { Button } from '@/app/ui/components/Button' 5 | import { 6 | Dialog, 7 | DialogContent, 8 | DialogDescription, 9 | DialogFooter, 10 | DialogHeader, 11 | DialogTitle, 12 | DialogTrigger, 13 | } from '@/app/ui/components/Dialog' 14 | import { Input } from '@/app/ui/components/Input' 15 | import { Label } from '@/app/ui/components/Label' 16 | import Spinner from '@/app/ui/components/Spinner' 17 | import { useRouter } from 'next/navigation' 18 | import { useEffect, useRef, useState } from 'react' 19 | import { useFormStatus } from 'react-dom' 20 | 21 | export default function CreateFirstProjectDialog({ 22 | trigger, 23 | userId, 24 | reservedNames, 25 | }: { 26 | trigger: React.ReactNode 27 | userId: string 28 | reservedNames: string[] 29 | }) { 30 | const router = useRouter() 31 | const [isOpen, setOpen] = useState(false) 32 | 33 | useEffect(() => { 34 | setOpen(true) 35 | }, []) 36 | 37 | const inputRef = useRef(null) 38 | 39 | const [error, setError] = useState(false) 40 | 41 | return ( 42 | 43 | setOpen(true)}> 44 | {trigger} 45 | 46 | 47 |
{ 49 | const name = formData.get('name') as string 50 | 51 | if (name) { 52 | if (reservedNames.includes(name)) { 53 | setError(true) 54 | inputRef?.current?.focus() 55 | return 56 | } 57 | await createProjectAction({ 58 | name, 59 | userId, 60 | }) 61 | } else { 62 | inputRef?.current?.focus() 63 | } 64 | }} 65 | autoComplete="off" 66 | > 67 | 68 | Create a new project 69 | 70 | Create a project to store image templates for your website, app, 71 | or business. 72 | 73 | 74 |
75 |
76 | 79 | setError(false)} 87 | /> 88 |
89 |
90 | 91 | {error && ( 92 | 93 | This project name is reserved. Please choose another name. 94 | 95 | )} 96 | 97 | 98 |
99 |
100 |
101 | ) 102 | } 103 | 104 | function CreateButton() { 105 | const { pending } = useFormStatus() 106 | 107 | return ( 108 | 115 | ) 116 | } 117 | -------------------------------------------------------------------------------- /app/ui/dialogs/delete-layer-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { LayerType } from '@/app/(editor)/[project]/templates/[templateId]/edit/page' 2 | import { Button } from '@/app/ui/components/Button' 3 | import { 4 | Dialog, 5 | DialogClose, 6 | DialogContent, 7 | DialogDescription, 8 | DialogFooter, 9 | DialogHeader, 10 | DialogTitle, 11 | DialogTrigger, 12 | } from '@/app/ui/components/Dialog' 13 | import { Dispatch, SetStateAction } from 'react' 14 | 15 | export default function DeleteLayerDialog({ 16 | trigger, 17 | layers, 18 | setLayers, 19 | selectedLayer, 20 | setSelectedLayer, 21 | multiSelectedLayers, 22 | setMultiSelectedLayers, 23 | }: { 24 | trigger: React.ReactNode 25 | layers: LayerType[] 26 | setLayers: Dispatch> 27 | selectedLayer: LayerType | undefined 28 | setSelectedLayer: Dispatch> 29 | multiSelectedLayers: LayerType[] 30 | setMultiSelectedLayers: Dispatch> 31 | }) { 32 | return ( 33 | 34 | {trigger} 35 | 36 | 37 | 38 | Delete{' '} 39 | {multiSelectedLayers.length > 1 40 | ? `${multiSelectedLayers.length} layers` 41 | : 'layer'} 42 | 43 | 44 | Are you sure you want to delete{' '} 45 | {multiSelectedLayers.length > 1 46 | ? `these ${multiSelectedLayers.length} layers?` 47 | : 'this layer?'} 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 78 | 79 | 80 | 81 | 82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /app/ui/dialogs/new-project-dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { createProjectAction } from '@/app/actions/actions' 4 | import { Button } from '@/app/ui/components/Button' 5 | import { 6 | Dialog, 7 | DialogClose, 8 | DialogContent, 9 | DialogDescription, 10 | DialogFooter, 11 | DialogHeader, 12 | DialogTitle, 13 | DialogTrigger, 14 | } from '@/app/ui/components/Dialog' 15 | import { Input } from '@/app/ui/components/Input' 16 | import { Label } from '@/app/ui/components/Label' 17 | import Spinner from '@/app/ui/components/Spinner' 18 | import { useRef, useState } from 'react' 19 | import { useFormStatus } from 'react-dom' 20 | 21 | export default function NewProjectDialog({ 22 | trigger, 23 | userId, 24 | reservedNames, 25 | }: { 26 | trigger: React.ReactNode 27 | userId: string 28 | reservedNames: string[] 29 | }) { 30 | const [isOpen, setOpen] = useState(false) 31 | 32 | const inputRef = useRef(null) 33 | 34 | const closeDialog = () => { 35 | setOpen(false) 36 | } 37 | 38 | const [error, setError] = useState('') 39 | 40 | return ( 41 | 42 | setOpen(true)}> 43 | {trigger} 44 | 45 | 46 |
{ 48 | const name = formData.get('name') as string 49 | const latinCharactersOrNumbersOrSpaces = /^[a-zA-Z0-9 ]+$/ 50 | if (name) { 51 | if (reservedNames.includes(name)) { 52 | setError( 53 | 'This project name is reserved. Please choose another name.' 54 | ) 55 | inputRef?.current?.focus() 56 | return 57 | } 58 | if (name.length < 3) { 59 | setError('Project name must be at least 3 characters.') 60 | inputRef?.current?.focus() 61 | return 62 | } 63 | if (name.length > 50) { 64 | setError('Project name must be at most 50 characters.') 65 | inputRef?.current?.focus() 66 | return 67 | } 68 | if (!name.match(latinCharactersOrNumbersOrSpaces)) { 69 | setError('Project name must contain only latin characters.') 70 | inputRef?.current?.focus() 71 | return 72 | } 73 | 74 | createProjectAction({ 75 | name, 76 | userId, 77 | }).then(() => { 78 | closeDialog() 79 | }) 80 | } else { 81 | inputRef?.current?.focus() 82 | } 83 | }} 84 | > 85 | 86 | New project 87 | Create a new project. 88 | 89 |
90 |
91 | 94 | 102 |
103 |
104 | 105 | {!!error && {error}} 106 | 107 | 110 | 111 | 112 | 113 |
114 |
115 |
116 | ) 117 | } 118 | 119 | function CreateButton() { 120 | const { pending } = useFormStatus() 121 | 122 | return ( 123 | 130 | ) 131 | } 132 | -------------------------------------------------------------------------------- /app/ui/dialogs/new-template-dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { createTemplateAction } from '@/app/actions/actions' 4 | import { ProjectType } from '@/app/db/schema' 5 | import { Button } from '@/app/ui/components/Button' 6 | import { 7 | Dialog, 8 | DialogClose, 9 | DialogContent, 10 | DialogDescription, 11 | DialogFooter, 12 | DialogHeader, 13 | DialogTitle, 14 | DialogTrigger, 15 | } from '@/app/ui/components/Dialog' 16 | import { Input } from '@/app/ui/components/Input' 17 | import { Label } from '@/app/ui/components/Label' 18 | import Spinner from '@/app/ui/components/Spinner' 19 | import { useRef } from 'react' 20 | import { useFormStatus } from 'react-dom' 21 | 22 | export default function NewTemplateDialog({ 23 | trigger, 24 | project, 25 | }: { 26 | trigger: React.ReactNode 27 | project: ProjectType 28 | }) { 29 | const inputRef = useRef(null) 30 | 31 | return ( 32 | 33 | {trigger} 34 | 35 |
{ 37 | if (formData.get('name')) { 38 | createTemplateAction({ 39 | name: formData.get('name') as string, 40 | projectId: project?.id as string, 41 | projectPathname: project?.pathname as string, 42 | layersData: '[]', 43 | }) 44 | } else { 45 | inputRef?.current?.focus() 46 | } 47 | }} 48 | > 49 | 50 | New template 51 | Create a new image template. 52 | 53 |
54 |
55 | 58 | 65 |
66 |
67 | 68 | 69 | 72 | 73 | 74 | 75 |
76 |
77 |
78 | ) 79 | } 80 | 81 | function CreateButton() { 82 | const { pending } = useFormStatus() 83 | 84 | return ( 85 | 92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /app/ui/menu-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'framer-motion' 2 | 3 | const Path = (props: any) => ( 4 | 10 | ) 11 | 12 | export const MenuToggle = ({ 13 | toggle, 14 | isOpen, 15 | }: { 16 | toggle: () => void 17 | isOpen: boolean 18 | }) => ( 19 | 25 | 26 | 33 | 40 | 41 | 42 | ) 43 | -------------------------------------------------------------------------------- /app/ui/preview-mockups/empty-mockup.tsx: -------------------------------------------------------------------------------- 1 | import { X } from 'lucide-react' 2 | 3 | export default function EmptyMockup() { 4 | return ( 5 |
6 | 7 | 8 | Link won't have a preview.
9 | Check metatags. 10 |
11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /app/ui/preview-mockups/facebook-mockup.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ValidatedMetatagsType } from '@/app/api/metatags/validate/utils' 4 | import EmptyMockup from '@/app/ui/preview-mockups/empty-mockup' 5 | import { getDomainWithoutWWW, getImageSizeFromUrl } from '@/app/utils' 6 | import { useEffect, useState } from 'react' 7 | 8 | export default function FacebookMockup({ 9 | metatags, 10 | normalizedUrl, 11 | }: { 12 | metatags?: ValidatedMetatagsType 13 | normalizedUrl: string 14 | }) { 15 | const isValid = 16 | metatags && 17 | (metatags.title.value || 18 | metatags['og:title'].value || 19 | metatags['twitter:title'].value) 20 | 21 | const [isSquare, setIsSquare] = useState(false) 22 | 23 | useEffect(() => { 24 | if ( 25 | metatags && 26 | metatags['twitter:card'].value === 'summary' && 27 | (metatags['og:image'].value || metatags['twitter:image'].value) 28 | ) { 29 | getImageSizeFromUrl( 30 | metatags['og:image'].value || metatags['twitter:image'].value 31 | ).then((size) => { 32 | if (size) { 33 | setIsSquare(size.width === size.height) 34 | } 35 | }) 36 | } 37 | }, [metatags]) 38 | 39 | return ( 40 | <> 41 | {isValid ? ( 42 | isSquare ? ( 43 |
44 | {(metatags['og:image'].value || 45 | metatags['twitter:image'].value) && ( 46 | // eslint-disable-next-line @next/next/no-img-element 47 | Preview 54 | )} 55 |
56 | 57 | {getDomainWithoutWWW(normalizedUrl ?? '')} 58 | 59 | 60 | {metatags['twitter:title'].value || 61 | metatags['og:title'].value || 62 | metatags.title.value} 63 | 64 | {(metatags['og:description'].value || 65 | metatags['twitter:description'].value || 66 | metatags.description.value) && ( 67 | 68 | {metatags['og:description'].value || 69 | metatags['twitter:description'].value || 70 | metatags.description.value} 71 | 72 | )} 73 |
74 |
75 | ) : ( 76 |
77 | {(metatags['og:image'].value || 78 | metatags['twitter:image'].value) && ( 79 | // eslint-disable-next-line @next/next/no-img-element 80 | Preview 87 | )} 88 |
89 | 90 | {getDomainWithoutWWW(normalizedUrl ?? '')} 91 | 92 | 93 | {metatags['og:title'].value || metatags.title.value} 94 | 95 | {(metatags['og:description'].value || 96 | metatags.description.value) && ( 97 | 98 | {metatags['og:description'].value || 99 | metatags.description.value} 100 | 101 | )} 102 |
103 |
104 | ) 105 | ) : ( 106 | 107 | )} 108 | 109 | ) 110 | } 111 | -------------------------------------------------------------------------------- /app/ui/preview-mockups/linkedin-mockup.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ValidatedMetatagsType } from '@/app/api/metatags/validate/utils' 4 | import EmptyMockup from '@/app/ui/preview-mockups/empty-mockup' 5 | import { getDomainWithoutWWW, getImageSizeFromUrl } from '@/app/utils' 6 | import { useEffect, useState } from 'react' 7 | 8 | export default function LinkedInMockup({ 9 | metatags, 10 | normalizedUrl, 11 | }: { 12 | metatags?: ValidatedMetatagsType 13 | normalizedUrl: string 14 | }) { 15 | const isValid = 16 | metatags && 17 | (metatags.title.value || 18 | metatags['og:title'].value || 19 | metatags['twitter:title'].value) 20 | 21 | const [isSquare, setIsSquare] = useState(false) 22 | 23 | useEffect(() => { 24 | if ( 25 | metatags && 26 | (metatags['og:image'].value || metatags['twitter:image'].value) 27 | ) { 28 | getImageSizeFromUrl( 29 | metatags['og:image'].value || metatags['twitter:image'].value 30 | ).then((size) => { 31 | if (size) { 32 | setIsSquare(size.width === size.height) 33 | } 34 | }) 35 | } 36 | }, [metatags]) 37 | 38 | return ( 39 | <> 40 | {isValid ? ( 41 | isSquare ? ( 42 |
43 | {(metatags['og:image'].value || 44 | metatags['twitter:image'].value) && ( 45 | // eslint-disable-next-line @next/next/no-img-element 46 | Preview 53 | )} 54 |
55 | 56 | {metatags['og:title'].value || metatags.title.value} 57 | 58 | 59 | {getDomainWithoutWWW(normalizedUrl ?? '') + ' • 1 min read'} 60 | 61 |
62 |
63 | ) : ( 64 |
65 | {(metatags['og:image'].value || 66 | metatags['twitter:image'].value) && ( 67 | // eslint-disable-next-line @next/next/no-img-element 68 | Preview 75 | )} 76 |
77 | 78 | {metatags['og:title'].value || metatags.title.value} 79 | 80 | 81 | {getDomainWithoutWWW(normalizedUrl ?? '') + ' • 1 min read'} 82 | 83 |
84 |
85 | ) 86 | ) : ( 87 | 88 | )} 89 | 90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /app/ui/preview-mockups/slack-mockup.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ValidatedMetatagsType } from '@/app/api/metatags/validate/utils' 4 | import { GOOGLE_FAVICON_URL } from '@/app/constants' 5 | import EmptyMockup from '@/app/ui/preview-mockups/empty-mockup' 6 | import { getApexDomain, getImageSizeFromUrl } from '@/app/utils' 7 | import { useEffect, useState } from 'react' 8 | 9 | export default function SlackMockup({ 10 | metatags, 11 | normalizedUrl, 12 | }: { 13 | metatags?: ValidatedMetatagsType 14 | normalizedUrl: string 15 | }) { 16 | const isValid = 17 | metatags && 18 | (metatags.title.value || 19 | metatags['og:title'].value || 20 | metatags['twitter:title'].value) 21 | 22 | const [isSquare, setIsSquare] = useState(false) 23 | 24 | useEffect(() => { 25 | if (metatags && metatags['twitter:card'].value === 'summary') { 26 | setIsSquare(true) 27 | } else if ( 28 | metatags && 29 | (metatags['og:image'].value || metatags['twitter:image'].value) 30 | ) { 31 | getImageSizeFromUrl( 32 | metatags['og:image'].value || metatags['twitter:image'].value 33 | ).then((size) => { 34 | if (size) { 35 | setIsSquare(size.width === size.height) 36 | } 37 | }) 38 | } 39 | }, [metatags]) 40 | 41 | return ( 42 | <> 43 | {isValid ? ( 44 |
45 |
46 |
47 |
48 | {/* eslint-disable-next-line @next/next/no-img-element */} 49 | 56 | 57 | {metatags['og:site_name'].value || 58 | getApexDomain(normalizedUrl ?? '')} 59 | 60 |
61 | 62 | {metatags['og:title'].value || 63 | metatags['twitter:title'].value || 64 | metatags.title.value} 65 | 66 | 67 | {metatags['og:description'].value || 68 | metatags['twitter:description'].value || 69 | metatags.description.value} 70 | 71 | {!isSquare ? ( 72 | <> 73 | {(metatags['og:image'].value || 74 | metatags['twitter:image'].value) && ( 75 |
84 | )} 85 | 86 | ) : ( 87 | <> 88 | {(metatags['og:image'].value || 89 | metatags['twitter:image'].value) && ( 90 |
99 | )} 100 | 101 | )} 102 |
103 |
104 | ) : ( 105 | 106 | )} 107 | 108 | ) 109 | } 110 | -------------------------------------------------------------------------------- /app/ui/preview-mockups/twitter-app-mockup.tsx: -------------------------------------------------------------------------------- 1 | import { ValidatedMetatagsType } from '@/app/api/metatags/validate/utils' 2 | import EmptyMockup from '@/app/ui/preview-mockups/empty-mockup' 3 | import { getDomainWithoutWWW } from '@/app/utils' 4 | 5 | export default function TwitterAppMockup({ 6 | metatags, 7 | normalizedUrl, 8 | }: { 9 | metatags?: ValidatedMetatagsType 10 | normalizedUrl: string 11 | }) { 12 | const isValid = 13 | metatags && 14 | metatags['twitter:card'].value && 15 | (metatags['twitter:image'].value || metatags['og:image'].value) 16 | 17 | const isSquare = metatags && metatags['twitter:card'].value === 'summary' 18 | 19 | return ( 20 | <> 21 | {isValid ? ( 22 | isSquare ? ( 23 |
24 | {/* eslint-disable-next-line @next/next/no-img-element */} 25 | Preview 32 |
33 | 34 | {getDomainWithoutWWW(normalizedUrl ?? '')} 35 | 36 | 37 | {metatags['twitter:title'].value || 38 | metatags['og:title'].value || 39 | metatags.title.value} 40 | 41 | 42 | {metatags['twitter:description'].value || 43 | metatags['og:description'].value || 44 | metatags.description.value} 45 | 46 |
47 |
48 | ) : ( 49 |
50 |
51 |
52 | 53 | {getDomainWithoutWWW(normalizedUrl ?? '')} 54 | 55 |
56 |
57 | {/* eslint-disable-next-line @next/next/no-img-element */} 58 | Preview 65 |
66 | ) 67 | ) : ( 68 | 69 | )} 70 | 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /app/ui/preview-mockups/twitter-web-mockup.tsx: -------------------------------------------------------------------------------- 1 | import { ValidatedMetatagsType } from '@/app/api/metatags/validate/utils' 2 | import EmptyMockup from '@/app/ui/preview-mockups/empty-mockup' 3 | import { getDomainWithoutWWW } from '@/app/utils' 4 | 5 | export default function TwitterWebMockup({ 6 | metatags, 7 | normalizedUrl, 8 | }: { 9 | metatags: ValidatedMetatagsType 10 | normalizedUrl: string 11 | }) { 12 | const isValid = 13 | metatags && 14 | metatags['twitter:card'].value && 15 | (metatags['twitter:image'].value || metatags['og:image'].value) 16 | 17 | const isSquare = metatags && metatags['twitter:card'].value === 'summary' 18 | 19 | return ( 20 | <> 21 | {isValid ? ( 22 | isSquare ? ( 23 |
24 | {/* eslint-disable-next-line @next/next/no-img-element */} 25 | Preview 32 |
33 | 34 | {getDomainWithoutWWW(normalizedUrl ?? '')} 35 | 36 | 37 | {metatags['twitter:title'].value || 38 | metatags['og:title'].value || 39 | metatags.title.value} 40 | 41 | 42 | {metatags['twitter:description'].value || 43 | metatags['og:description'].value || 44 | metatags.description.value} 45 | 46 |
47 |
48 | ) : ( 49 |
50 |
51 |
52 |
53 | 54 | {metatags['twitter:title'].value || 55 | metatags['og:title'].value || 56 | metatags.title.value} 57 | 58 |
59 |
60 | {/* eslint-disable-next-line @next/next/no-img-element */} 61 | Preview 68 |
69 | 70 | {'From ' + getDomainWithoutWWW(normalizedUrl ?? '')} 71 | 72 |
73 | ) 74 | ) : ( 75 | 76 | )} 77 | 78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /app/ui/svgs/FixedSizeIcon.tsx: -------------------------------------------------------------------------------- 1 | export default function FixedSizeIcon({ className }: { className?: string }) { 2 | return ( 3 | 15 | 16 | 17 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /app/ui/svgs/social-icons/Discord.tsx: -------------------------------------------------------------------------------- 1 | export default function Discord({ 2 | className, 3 | ...props 4 | }: { 5 | className: string 6 | props?: React.SVGProps 7 | }) { 8 | return ( 9 | 15 | 16 | 17 | 22 | 23 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /app/ui/svgs/social-icons/Facebook.tsx: -------------------------------------------------------------------------------- 1 | export default function Facebook({ 2 | className, 3 | ...props 4 | }: { 5 | className: string 6 | props?: React.SVGProps 7 | }) { 8 | return ( 9 | 15 | 19 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /app/ui/svgs/social-icons/GitHub.tsx: -------------------------------------------------------------------------------- 1 | export default function GitHub({ 2 | className, 3 | fillClassName, 4 | color = '#0E1117', 5 | ...props 6 | }: { 7 | className: string 8 | fillClassName?: string 9 | color?: string 10 | props?: React.SVGProps 11 | }) { 12 | return ( 13 | 19 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /app/ui/svgs/social-icons/LinkedIn.tsx: -------------------------------------------------------------------------------- 1 | export default function LinkedIn({ 2 | className, 3 | fillClassName, 4 | color = '#0a66c2', 5 | ...props 6 | }: { 7 | className: string 8 | fillClassName?: string 9 | color?: string 10 | props?: React.SVGProps 11 | }) { 12 | return ( 13 | 19 | 20 | 24 | 29 | 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /app/ui/svgs/social-icons/Slack.tsx: -------------------------------------------------------------------------------- 1 | export default function Slack({ 2 | className, 3 | ...props 4 | }: { 5 | className: string 6 | props?: React.SVGProps 7 | }) { 8 | return ( 9 | 15 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 41 | 45 | 49 | 50 | 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /app/ui/svgs/social-icons/Telegram.tsx: -------------------------------------------------------------------------------- 1 | export default function Telegram({ 2 | className, 3 | ...props 4 | }: { 5 | className: string 6 | props?: React.SVGProps 7 | }) { 8 | return ( 9 | 15 | 16 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 33 | 34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /app/ui/svgs/social-icons/WhatsApp.tsx: -------------------------------------------------------------------------------- 1 | export default function WhatsApp({ 2 | className, 3 | ...props 4 | }: { 5 | className: string 6 | props?: React.SVGProps 7 | }) { 8 | return ( 9 | 16 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /app/ui/svgs/social-icons/X.tsx: -------------------------------------------------------------------------------- 1 | export default function X({ 2 | className, 3 | fillClassName = 'fill-white', 4 | color = '#ffffff', 5 | ...props 6 | }: { 7 | className: string 8 | fillClassName?: string 9 | color?: string 10 | props?: React.SVGProps 11 | }) { 12 | return ( 13 | 19 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /app/ui/svgs/social-icons/YouTube.tsx: -------------------------------------------------------------------------------- 1 | export default function YouTube({ 2 | className, 3 | fillClassName, 4 | color = '#FF0000', 5 | ...props 6 | }: { 7 | className: string 8 | fillClassName?: string 9 | color?: string 10 | props?: React.SVGProps 11 | }) { 12 | return ( 13 | 19 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /app/ui/testimonial-badge.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect } from 'react' 4 | 5 | export default function TestimonialsBadge() { 6 | useEffect(() => { 7 | const scriptIds = [ 8 | '1a012f35-b416-4a43-b367-c650737b3195', 9 | 'b750cc73-45fa-4776-9e43-b8af631d55b6', 10 | ] 11 | 12 | scriptIds.forEach((id) => { 13 | const script = document.createElement('script') 14 | script.src = `https://widget.senja.io/widget/${id}/platform.js` 15 | script.async = true 16 | document.body.appendChild(script) 17 | 18 | return () => { 19 | document.body.removeChild(script) 20 | } 21 | }) 22 | }, []) 23 | 24 | return ( 25 | <> 26 |
32 |
38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /app/ui/testimonials-WOL.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect } from 'react' 4 | 5 | export default function TestimonialsWOL() { 6 | useEffect(() => { 7 | // Define the widget IDs in an array for easy management 8 | const widgetIds = [ 9 | '4f4d6a80-8cf4-47fd-92cd-49fde82a0798', 10 | 'aaba8f67-2508-49f7-a4c3-9adbf9b2047d', 11 | ] 12 | 13 | // Function to dynamically load each script based on its ID 14 | widgetIds.forEach((id) => { 15 | const script = document.createElement('script') 16 | script.src = `https://widget.senja.io/widget/${id}/platform.js` 17 | script.async = true 18 | document.body.appendChild(script) 19 | 20 | // Cleanup function to remove the script from the body when component unmounts 21 | return () => { 22 | document.body.removeChild(script) 23 | } 24 | }) 25 | }, []) // The empty dependency array ensures this effect only runs once on component mount 26 | 27 | return ( 28 | <> 29 |
35 |
41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /app/ui/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Button } from '@/app/ui/components/Button' 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuItem, 8 | DropdownMenuTrigger, 9 | } from '@/app/ui/components/DropdownMenu' 10 | import { Moon, Sun } from 'lucide-react' 11 | import { useTheme } from 'next-themes' 12 | 13 | export function ThemeToggle({ ghost = false }: { ghost?: boolean }) { 14 | const { setTheme } = useTheme() 15 | 16 | return ( 17 | 18 | 19 | 28 | 29 | 30 | setTheme('light')}> 31 | Light 32 | 33 | setTheme('dark')}> 34 | Dark 35 | 36 | setTheme('system')}> 37 | System 38 | 39 | 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /app/ui/validator-input.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Button } from '@/app/ui/components/Button' 4 | import { Input } from '@/app/ui/components/Input' 5 | import { Label } from '@/app/ui/components/Label' 6 | import Spinner from '@/app/ui/components/Spinner' 7 | import { 8 | getUrlFromString, 9 | getUrlFromStringWithoutWWW, 10 | getUrlFromStringWithoutWWWOrProtocol, 11 | } from '@/app/utils' 12 | import { useRouter, useSearchParams } from 'next/navigation' 13 | import { useRef, useState } from 'react' 14 | 15 | export default function ValidatorInput({ 16 | isApp, 17 | isHome, 18 | isLoading, 19 | projectPathname, 20 | revalidateMetatags, 21 | isValidating, 22 | }: { 23 | isApp: boolean 24 | isHome?: boolean 25 | isLoading?: boolean 26 | projectPathname?: string 27 | revalidateMetatags?: () => void 28 | isValidating?: boolean 29 | }) { 30 | const router = useRouter() 31 | const searchParams = useSearchParams() 32 | const inputUrl = searchParams?.get('url') || '' 33 | const normalizedUrl = getUrlFromStringWithoutWWW(inputUrl) 34 | const urlWithoutWWWOrProtocol = getUrlFromStringWithoutWWWOrProtocol( 35 | normalizedUrl || '' 36 | ) 37 | 38 | const [inputError, setInputError] = useState(false) 39 | 40 | const onSubmitSite = (e: React.FormEvent) => { 41 | e.preventDefault() 42 | const eventUrl = e.currentTarget.url.value 43 | const normalizedUrl = getUrlFromStringWithoutWWW( 44 | getUrlFromString(eventUrl || '') || '' 45 | ) 46 | 47 | if (!!normalizedUrl) { 48 | router.push( 49 | `${ 50 | eventUrl 51 | ? isApp 52 | ? `/${projectPathname}/validator?url=${normalizedUrl}` 53 | : `/card-validator?url=${normalizedUrl}` 54 | : '' 55 | }` 56 | ) 57 | revalidateMetatags && revalidateMetatags() 58 | } else { 59 | setInputError(true) 60 | } 61 | } 62 | 63 | const inputRef = useRef(null) 64 | 65 | return ( 66 |
70 |
71 | { 81 | setInputError(false) 82 | }} 83 | leftLabel={ 84 | 87 | } 88 | className="bg-card pl-[4.75rem]" 89 | /> 90 | {inputError && ( 91 |
Please enter a valid URL.
92 | )} 93 |
94 | 101 |
102 | ) 103 | } 104 | -------------------------------------------------------------------------------- /assets/inter-black.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgalanb/sharepreviews/642681f9af9fefc2035ef069bdda39fb32253e2d/assets/inter-black.otf -------------------------------------------------------------------------------- /assets/inter-bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgalanb/sharepreviews/642681f9af9fefc2035ef069bdda39fb32253e2d/assets/inter-bold.otf -------------------------------------------------------------------------------- /assets/inter-extra-bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgalanb/sharepreviews/642681f9af9fefc2035ef069bdda39fb32253e2d/assets/inter-extra-bold.otf -------------------------------------------------------------------------------- /assets/inter-extra-light.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgalanb/sharepreviews/642681f9af9fefc2035ef069bdda39fb32253e2d/assets/inter-extra-light.otf -------------------------------------------------------------------------------- /assets/inter-light.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgalanb/sharepreviews/642681f9af9fefc2035ef069bdda39fb32253e2d/assets/inter-light.otf -------------------------------------------------------------------------------- /assets/inter-medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgalanb/sharepreviews/642681f9af9fefc2035ef069bdda39fb32253e2d/assets/inter-medium.otf -------------------------------------------------------------------------------- /assets/inter-regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgalanb/sharepreviews/642681f9af9fefc2035ef069bdda39fb32253e2d/assets/inter-regular.otf -------------------------------------------------------------------------------- /assets/inter-semi-bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgalanb/sharepreviews/642681f9af9fefc2035ef069bdda39fb32253e2d/assets/inter-semi-bold.otf -------------------------------------------------------------------------------- /assets/inter-thin.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgalanb/sharepreviews/642681f9af9fefc2035ef069bdda39fb32253e2d/assets/inter-thin.otf -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { loadEnvConfig } from '@next/env' 2 | import type { Config } from 'drizzle-kit' 3 | import { cwd } from 'node:process' 4 | 5 | loadEnvConfig(cwd()) 6 | 7 | export default { 8 | schema: './app/db/schema.ts', 9 | out: './drizzle', 10 | driver: 'pg', 11 | dbCredentials: { 12 | connectionString: process.env.NEON_POSTGRES_URL!, 13 | }, 14 | verbose: true, 15 | strict: true, 16 | } satisfies Config 17 | -------------------------------------------------------------------------------- /drizzle/0000_curious_kingpin.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "users" ( 2 | "id" varchar(31) PRIMARY KEY NOT NULL, 3 | "email" varchar(255) NOT NULL, 4 | "first_name" text, 5 | "last_name" text, 6 | "email_verified" boolean, 7 | "createdAt" timestamp DEFAULT now() NOT NULL, 8 | "updatedAt" timestamp NOT NULL 9 | ); 10 | --> statement-breakpoint 11 | CREATE UNIQUE INDEX IF NOT EXISTS "unique_idx" ON "users" ("email"); -------------------------------------------------------------------------------- /drizzle/0001_same_felicia_hardy.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "projects" ( 2 | "uuid" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, 3 | "name" text NOT NULL, 4 | "user_id" varchar(31) NOT NULL, 5 | "createdAt" timestamp DEFAULT now() NOT NULL, 6 | "updatedAt" timestamp NOT NULL 7 | ); 8 | --> statement-breakpoint 9 | DO $$ BEGIN 10 | ALTER TABLE "projects" ADD CONSTRAINT "projects_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE no action ON UPDATE no action; 11 | EXCEPTION 12 | WHEN duplicate_object THEN null; 13 | END $$; 14 | -------------------------------------------------------------------------------- /drizzle/0002_medical_manta.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX IF EXISTS "unique_idx";--> statement-breakpoint 2 | ALTER TABLE "projects" ADD COLUMN "pathname" text NOT NULL;--> statement-breakpoint 3 | ALTER TABLE "projects" ADD CONSTRAINT "projects_pathname_unique" UNIQUE("pathname");--> statement-breakpoint 4 | ALTER TABLE "users" ADD CONSTRAINT "users_email_unique" UNIQUE("email"); -------------------------------------------------------------------------------- /drizzle/0003_wealthy_professor_monster.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "templates" ( 2 | "uuid" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, 3 | "name" text NOT NULL, 4 | "project_id" uuid NOT NULL, 5 | "createdAt" timestamp DEFAULT now() NOT NULL, 6 | "updatedAt" timestamp NOT NULL, 7 | "data" text NOT NULL 8 | ); 9 | --> statement-breakpoint 10 | CREATE TABLE IF NOT EXISTS "uploaded_images" ( 11 | "uuid" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, 12 | "key" text NOT NULL, 13 | "url" text NOT NULL, 14 | "user_id" varchar(31) NOT NULL, 15 | "createdAt" timestamp DEFAULT now() NOT NULL, 16 | "updatedAt" timestamp NOT NULL 17 | ); 18 | --> statement-breakpoint 19 | DO $$ BEGIN 20 | ALTER TABLE "templates" ADD CONSTRAINT "templates_project_id_projects_uuid_fk" FOREIGN KEY ("project_id") REFERENCES "projects"("uuid") ON DELETE no action ON UPDATE no action; 21 | EXCEPTION 22 | WHEN duplicate_object THEN null; 23 | END $$; 24 | --> statement-breakpoint 25 | DO $$ BEGIN 26 | ALTER TABLE "uploaded_images" ADD CONSTRAINT "uploaded_images_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE no action ON UPDATE no action; 27 | EXCEPTION 28 | WHEN duplicate_object THEN null; 29 | END $$; 30 | -------------------------------------------------------------------------------- /drizzle/0004_bitter_terrax.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "templates" RENAME COLUMN "data" TO "layersData"; -------------------------------------------------------------------------------- /drizzle/0005_tiny_moondragon.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "projects" ADD COLUMN "plan" text;--> statement-breakpoint 2 | ALTER TABLE "projects" ADD COLUMN "suscription_item_id" text;--> statement-breakpoint 3 | ALTER TABLE "projects" ADD COLUMN "images_created" integer DEFAULT 0 NOT NULL; -------------------------------------------------------------------------------- /drizzle/0006_condemned_kingpin.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "projects" ALTER COLUMN "plan" SET DEFAULT 'free'; -------------------------------------------------------------------------------- /drizzle/0007_curved_namora.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "templates" ADD COLUMN "canvasBackgroundColor" text DEFAULT '#ffffff' NOT NULL; -------------------------------------------------------------------------------- /drizzle/0008_bizarre_grey_gargoyle.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "projects" ADD COLUMN "product_id" text;--> statement-breakpoint 2 | ALTER TABLE "projects" ADD COLUMN "variant_id" text;--> statement-breakpoint 3 | ALTER TABLE "projects" ADD COLUMN "suscription_id" text; -------------------------------------------------------------------------------- /drizzle/0009_brainy_vermin.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "templates" ADD COLUMN "description" text; -------------------------------------------------------------------------------- /drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "56d0ee98-adc1-47d2-9668-93f58718037f", 3 | "prevId": "00000000-0000-0000-0000-000000000000", 4 | "version": "5", 5 | "dialect": "pg", 6 | "tables": { 7 | "users": { 8 | "name": "users", 9 | "schema": "", 10 | "columns": { 11 | "id": { 12 | "name": "id", 13 | "type": "varchar(31)", 14 | "primaryKey": true, 15 | "notNull": true 16 | }, 17 | "email": { 18 | "name": "email", 19 | "type": "varchar(255)", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "first_name": { 24 | "name": "first_name", 25 | "type": "text", 26 | "primaryKey": false, 27 | "notNull": false 28 | }, 29 | "last_name": { 30 | "name": "last_name", 31 | "type": "text", 32 | "primaryKey": false, 33 | "notNull": false 34 | }, 35 | "email_verified": { 36 | "name": "email_verified", 37 | "type": "boolean", 38 | "primaryKey": false, 39 | "notNull": false 40 | }, 41 | "createdAt": { 42 | "name": "createdAt", 43 | "type": "timestamp", 44 | "primaryKey": false, 45 | "notNull": true, 46 | "default": "now()" 47 | }, 48 | "updatedAt": { 49 | "name": "updatedAt", 50 | "type": "timestamp", 51 | "primaryKey": false, 52 | "notNull": true 53 | } 54 | }, 55 | "indexes": { 56 | "unique_idx": { 57 | "name": "unique_idx", 58 | "columns": [ 59 | "email" 60 | ], 61 | "isUnique": true 62 | } 63 | }, 64 | "foreignKeys": {}, 65 | "compositePrimaryKeys": {}, 66 | "uniqueConstraints": {} 67 | } 68 | }, 69 | "enums": {}, 70 | "schemas": {}, 71 | "_meta": { 72 | "columns": {}, 73 | "schemas": {}, 74 | "tables": {} 75 | } 76 | } -------------------------------------------------------------------------------- /drizzle/meta/0001_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "c71425a9-797a-4753-a7ca-00476838c0b5", 3 | "prevId": "56d0ee98-adc1-47d2-9668-93f58718037f", 4 | "version": "5", 5 | "dialect": "pg", 6 | "tables": { 7 | "projects": { 8 | "name": "projects", 9 | "schema": "", 10 | "columns": { 11 | "uuid": { 12 | "name": "uuid", 13 | "type": "uuid", 14 | "primaryKey": true, 15 | "notNull": true, 16 | "default": "gen_random_uuid()" 17 | }, 18 | "name": { 19 | "name": "name", 20 | "type": "text", 21 | "primaryKey": false, 22 | "notNull": true 23 | }, 24 | "user_id": { 25 | "name": "user_id", 26 | "type": "varchar(31)", 27 | "primaryKey": false, 28 | "notNull": true 29 | }, 30 | "createdAt": { 31 | "name": "createdAt", 32 | "type": "timestamp", 33 | "primaryKey": false, 34 | "notNull": true, 35 | "default": "now()" 36 | }, 37 | "updatedAt": { 38 | "name": "updatedAt", 39 | "type": "timestamp", 40 | "primaryKey": false, 41 | "notNull": true 42 | } 43 | }, 44 | "indexes": {}, 45 | "foreignKeys": { 46 | "projects_user_id_users_id_fk": { 47 | "name": "projects_user_id_users_id_fk", 48 | "tableFrom": "projects", 49 | "tableTo": "users", 50 | "columnsFrom": [ 51 | "user_id" 52 | ], 53 | "columnsTo": [ 54 | "id" 55 | ], 56 | "onDelete": "no action", 57 | "onUpdate": "no action" 58 | } 59 | }, 60 | "compositePrimaryKeys": {}, 61 | "uniqueConstraints": {} 62 | }, 63 | "users": { 64 | "name": "users", 65 | "schema": "", 66 | "columns": { 67 | "id": { 68 | "name": "id", 69 | "type": "varchar(31)", 70 | "primaryKey": true, 71 | "notNull": true 72 | }, 73 | "email": { 74 | "name": "email", 75 | "type": "varchar(255)", 76 | "primaryKey": false, 77 | "notNull": true 78 | }, 79 | "first_name": { 80 | "name": "first_name", 81 | "type": "text", 82 | "primaryKey": false, 83 | "notNull": false 84 | }, 85 | "last_name": { 86 | "name": "last_name", 87 | "type": "text", 88 | "primaryKey": false, 89 | "notNull": false 90 | }, 91 | "email_verified": { 92 | "name": "email_verified", 93 | "type": "boolean", 94 | "primaryKey": false, 95 | "notNull": false 96 | }, 97 | "createdAt": { 98 | "name": "createdAt", 99 | "type": "timestamp", 100 | "primaryKey": false, 101 | "notNull": true, 102 | "default": "now()" 103 | }, 104 | "updatedAt": { 105 | "name": "updatedAt", 106 | "type": "timestamp", 107 | "primaryKey": false, 108 | "notNull": true 109 | } 110 | }, 111 | "indexes": { 112 | "unique_idx": { 113 | "name": "unique_idx", 114 | "columns": [ 115 | "email" 116 | ], 117 | "isUnique": true 118 | } 119 | }, 120 | "foreignKeys": {}, 121 | "compositePrimaryKeys": {}, 122 | "uniqueConstraints": {} 123 | } 124 | }, 125 | "enums": {}, 126 | "schemas": {}, 127 | "_meta": { 128 | "columns": {}, 129 | "schemas": {}, 130 | "tables": {} 131 | } 132 | } -------------------------------------------------------------------------------- /drizzle/meta/0002_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "9804880d-5862-4a29-8585-899e4621ddb3", 3 | "prevId": "c71425a9-797a-4753-a7ca-00476838c0b5", 4 | "version": "5", 5 | "dialect": "pg", 6 | "tables": { 7 | "projects": { 8 | "name": "projects", 9 | "schema": "", 10 | "columns": { 11 | "uuid": { 12 | "name": "uuid", 13 | "type": "uuid", 14 | "primaryKey": true, 15 | "notNull": true, 16 | "default": "gen_random_uuid()" 17 | }, 18 | "name": { 19 | "name": "name", 20 | "type": "text", 21 | "primaryKey": false, 22 | "notNull": true 23 | }, 24 | "pathname": { 25 | "name": "pathname", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": true 29 | }, 30 | "user_id": { 31 | "name": "user_id", 32 | "type": "varchar(31)", 33 | "primaryKey": false, 34 | "notNull": true 35 | }, 36 | "createdAt": { 37 | "name": "createdAt", 38 | "type": "timestamp", 39 | "primaryKey": false, 40 | "notNull": true, 41 | "default": "now()" 42 | }, 43 | "updatedAt": { 44 | "name": "updatedAt", 45 | "type": "timestamp", 46 | "primaryKey": false, 47 | "notNull": true 48 | } 49 | }, 50 | "indexes": {}, 51 | "foreignKeys": { 52 | "projects_user_id_users_id_fk": { 53 | "name": "projects_user_id_users_id_fk", 54 | "tableFrom": "projects", 55 | "tableTo": "users", 56 | "columnsFrom": [ 57 | "user_id" 58 | ], 59 | "columnsTo": [ 60 | "id" 61 | ], 62 | "onDelete": "no action", 63 | "onUpdate": "no action" 64 | } 65 | }, 66 | "compositePrimaryKeys": {}, 67 | "uniqueConstraints": { 68 | "projects_pathname_unique": { 69 | "name": "projects_pathname_unique", 70 | "nullsNotDistinct": false, 71 | "columns": [ 72 | "pathname" 73 | ] 74 | } 75 | } 76 | }, 77 | "users": { 78 | "name": "users", 79 | "schema": "", 80 | "columns": { 81 | "id": { 82 | "name": "id", 83 | "type": "varchar(31)", 84 | "primaryKey": true, 85 | "notNull": true 86 | }, 87 | "email": { 88 | "name": "email", 89 | "type": "varchar(255)", 90 | "primaryKey": false, 91 | "notNull": true 92 | }, 93 | "first_name": { 94 | "name": "first_name", 95 | "type": "text", 96 | "primaryKey": false, 97 | "notNull": false 98 | }, 99 | "last_name": { 100 | "name": "last_name", 101 | "type": "text", 102 | "primaryKey": false, 103 | "notNull": false 104 | }, 105 | "email_verified": { 106 | "name": "email_verified", 107 | "type": "boolean", 108 | "primaryKey": false, 109 | "notNull": false 110 | }, 111 | "createdAt": { 112 | "name": "createdAt", 113 | "type": "timestamp", 114 | "primaryKey": false, 115 | "notNull": true, 116 | "default": "now()" 117 | }, 118 | "updatedAt": { 119 | "name": "updatedAt", 120 | "type": "timestamp", 121 | "primaryKey": false, 122 | "notNull": true 123 | } 124 | }, 125 | "indexes": {}, 126 | "foreignKeys": {}, 127 | "compositePrimaryKeys": {}, 128 | "uniqueConstraints": { 129 | "users_email_unique": { 130 | "name": "users_email_unique", 131 | "nullsNotDistinct": false, 132 | "columns": [ 133 | "email" 134 | ] 135 | } 136 | } 137 | } 138 | }, 139 | "enums": {}, 140 | "schemas": {}, 141 | "_meta": { 142 | "columns": {}, 143 | "schemas": {}, 144 | "tables": {} 145 | } 146 | } -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "pg", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1708550044993, 9 | "tag": "0000_curious_kingpin", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "5", 15 | "when": 1709334527342, 16 | "tag": "0001_same_felicia_hardy", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "5", 22 | "when": 1709416099402, 23 | "tag": "0002_medical_manta", 24 | "breakpoints": true 25 | }, 26 | { 27 | "idx": 3, 28 | "version": "5", 29 | "when": 1709823882893, 30 | "tag": "0003_wealthy_professor_monster", 31 | "breakpoints": true 32 | }, 33 | { 34 | "idx": 4, 35 | "version": "5", 36 | "when": 1709941031111, 37 | "tag": "0004_bitter_terrax", 38 | "breakpoints": true 39 | }, 40 | { 41 | "idx": 5, 42 | "version": "5", 43 | "when": 1710953691205, 44 | "tag": "0005_tiny_moondragon", 45 | "breakpoints": true 46 | }, 47 | { 48 | "idx": 6, 49 | "version": "5", 50 | "when": 1710963948166, 51 | "tag": "0006_condemned_kingpin", 52 | "breakpoints": true 53 | }, 54 | { 55 | "idx": 7, 56 | "version": "5", 57 | "when": 1710970434309, 58 | "tag": "0007_curved_namora", 59 | "breakpoints": true 60 | }, 61 | { 62 | "idx": 8, 63 | "version": "5", 64 | "when": 1710975340152, 65 | "tag": "0008_bizarre_grey_gargoyle", 66 | "breakpoints": true 67 | }, 68 | { 69 | "idx": 9, 70 | "version": "5", 71 | "when": 1711224377029, 72 | "tag": "0009_brainy_vermin", 73 | "breakpoints": true 74 | } 75 | ] 76 | } -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { db } from '@/app/db' 2 | import { projects } from '@/app/db/schema' 3 | import { getUser } from '@/app/lib/workos' 4 | import { eq } from 'drizzle-orm' 5 | import type { NextRequest } from 'next/server' 6 | import { NextResponse } from 'next/server' 7 | 8 | export async function middleware(request: NextRequest) { 9 | const { isAuthenticated, user } = await getUser() 10 | 11 | if (request.nextUrl.pathname === '/' && isAuthenticated) { 12 | // Don't use the cached function because it throws an error when used in the middleware. 13 | // "incrementalCache missing in unstable_cache" 14 | const userProjects = await db.query['projects'].findMany({ 15 | where: eq(projects.userId, user?.id!), 16 | }) 17 | const haveProjects = userProjects.length > 0 18 | 19 | if (!haveProjects) { 20 | return NextResponse.redirect(new URL('/create-project', request.url)) 21 | } else { 22 | const firstProject = userProjects.sort((a, b) => 23 | a.name.localeCompare(b.name) 24 | )[0] 25 | return NextResponse.redirect( 26 | new URL(`/${firstProject.pathname}`, request.url) 27 | ) 28 | } 29 | } 30 | } 31 | 32 | export const config = { 33 | matcher: ['/'], // Only run this middleware on these paths 34 | } 35 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sharepreviews", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "basehub dev & next dev", 7 | "build": "basehub && next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "migration:generate": "drizzle-kit generate:pg", 11 | "migration:push": "drizzle-kit push:pg" 12 | }, 13 | "dependencies": { 14 | "@dnd-kit/modifiers": "^7.0.0", 15 | "@dnd-kit/sortable": "^8.0.0", 16 | "@neondatabase/serverless": "^0.9.0", 17 | "@radix-ui/react-avatar": "^1.0.4", 18 | "@radix-ui/react-dialog": "^1.0.5", 19 | "@radix-ui/react-dropdown-menu": "^2.0.6", 20 | "@radix-ui/react-form": "^0.0.3", 21 | "@radix-ui/react-label": "^2.0.2", 22 | "@radix-ui/react-navigation-menu": "^1.1.4", 23 | "@radix-ui/react-popover": "^1.0.7", 24 | "@radix-ui/react-progress": "^1.0.3", 25 | "@radix-ui/react-scroll-area": "^1.0.5", 26 | "@radix-ui/react-select": "^2.0.0", 27 | "@radix-ui/react-separator": "^1.0.3", 28 | "@radix-ui/react-slot": "^1.0.2", 29 | "@radix-ui/react-switch": "^1.0.3", 30 | "@radix-ui/react-tabs": "^1.0.4", 31 | "@radix-ui/react-toggle": "^1.0.3", 32 | "@radix-ui/react-toggle-group": "^1.0.4", 33 | "@radix-ui/react-tooltip": "^1.0.7", 34 | "@uploadthing/react": "^6.2.4", 35 | "@upstash/ratelimit": "^1.0.0", 36 | "@vercel/analytics": "^1.1.4", 37 | "@vercel/edge": "^1.1.1", 38 | "@vercel/edge-config": "^1.1.0", 39 | "@workos-inc/node": "^7.1.0", 40 | "basehub": "^7.0.3", 41 | "class-variance-authority": "^0.7.0", 42 | "clsx": "^2.0.0", 43 | "cmdk": "^0.2.1", 44 | "crisp-sdk-web": "^1.0.22", 45 | "dayjs": "^1.11.10", 46 | "drizzle-orm": "^0.29.3", 47 | "framer-motion": "^10.16.16", 48 | "he": "^1.2.0", 49 | "iframe-resizer-react": "^1.1.0", 50 | "jose": "^5.2.0", 51 | "lucide-react": "^0.356.0", 52 | "next": "^14.1.4", 53 | "next-themes": "^0.2.1", 54 | "node-html-parser": "^6.1.11", 55 | "pg": "^8.11.3", 56 | "prettier": "^3.1.1", 57 | "prettier-plugin-organize-imports": "^3.2.4", 58 | "prettier-plugin-tailwindcss": "^0.5.9", 59 | "react": "^18", 60 | "react-dom": "^18", 61 | "swr": "^2.2.4", 62 | "tailwind-merge": "^2.1.0", 63 | "tailwindcss-animate": "^1.0.7", 64 | "uploadthing": "^6.5.2", 65 | "uuid": "^9.0.1" 66 | }, 67 | "devDependencies": { 68 | "@types/he": "^1.2.3", 69 | "@types/node": "^20", 70 | "@types/react": "^18", 71 | "@types/react-dom": "^18", 72 | "@types/uuid": "^9.0.8", 73 | "autoprefixer": "^10.0.1", 74 | "drizzle-kit": "^0.20.17", 75 | "eslint": "^8", 76 | "eslint-config-next": "14.0.4", 77 | "postcss": "^8", 78 | "tailwindcss": "^3.4.0", 79 | "typescript": "^5" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/marketing/arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/marketing/editor-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgalanb/sharepreviews/642681f9af9fefc2035ef069bdda39fb32253e2d/public/marketing/editor-screenshot.png -------------------------------------------------------------------------------- /public/marketing/hardcoded-header-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgalanb/sharepreviews/642681f9af9fefc2035ef069bdda39fb32253e2d/public/marketing/hardcoded-header-image.png -------------------------------------------------------------------------------- /public/metatags-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgalanb/sharepreviews/642681f9af9fefc2035ef069bdda39fb32253e2d/public/metatags-example.png -------------------------------------------------------------------------------- /public/mvp-thumbnail.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgalanb/sharepreviews/642681f9af9fefc2035ef069bdda39fb32253e2d/public/mvp-thumbnail.webp -------------------------------------------------------------------------------- /public/no-metatags-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgalanb/sharepreviews/642681f9af9fefc2035ef069bdda39fb32253e2d/public/no-metatags-example.png -------------------------------------------------------------------------------- /public/noise-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgalanb/sharepreviews/642681f9af9fefc2035ef069bdda39fb32253e2d/public/noise-light.png -------------------------------------------------------------------------------- /public/pfp.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgalanb/sharepreviews/642681f9af9fefc2035ef069bdda39fb32253e2d/public/pfp.jpeg -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | 4 | Sitemap: https://sharepreviews.com/sitemap.xml -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://sharepreviews.com 5 | 6 | 7 | 8 | 9 | https://sharepreviews.com/starter-templates 10 | 11 | 12 | 13 | https://sharepreviews.com/starter-templates/6446c4ba-ebbc-4688-be9d-9d0aa268627a 14 | 15 | 16 | 17 | https://sharepreviews.com/starter-templates/08f36805-dea2-4575-b047-a96c9466d1f4 18 | 19 | 20 | 21 | https://sharepreviews.com/starter-templates/27c80424-6b31-455e-88e1-6b36f0e75cf9 22 | 23 | 24 | 25 | https://sharepreviews.com/starter-templates/f20c60c8-0e4e-43cb-85d7-1ec2357530ae 26 | 27 | 28 | 29 | https://sharepreviews.com/card-validator 30 | 31 | 32 | 33 | https://sharepreviews.com/pricing 34 | 35 | 36 | 37 | https://sharepreviews.com/blog 38 | 39 | 40 | 41 | https://sharepreviews.com/blog/everything-you-should-know-about-open-graph 42 | 2024-05-02 43 | 44 | 45 | https://sharepreviews.com/blog/duplicate-templates-and-twitter-visual-guide 46 | 2024-04-30 47 | 48 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { withUt } from 'uploadthing/tw' 2 | 3 | export default withUt({ 4 | darkMode: 'class', 5 | content: [ 6 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 7 | './components/**/*.{js,ts,jsx,tsx,mdx}', 8 | './app/**/*.{js,ts,jsx,tsx,mdx}', 9 | './node_modules/@tremor/**/*.{js,ts,jsx,tsx}', 10 | ], 11 | theme: { 12 | container: { 13 | center: true, 14 | padding: '2rem', 15 | screens: { 16 | '2xl': '1400px', 17 | }, 18 | }, 19 | extend: { 20 | backgroundImage: { 21 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 22 | 'gradient-conic': 23 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 24 | noise: 'url(/noise-light.png)', 25 | }, 26 | colors: { 27 | // shadcn/ui theming variables 28 | border: 'hsl(var(--border))', 29 | input: 'hsl(var(--input))', 30 | ring: 'hsl(var(--ring))', 31 | background: 'hsl(var(--background))', 32 | foreground: 'hsl(var(--foreground))', 33 | primary: { 34 | DEFAULT: 'hsl(var(--primary))', 35 | foreground: 'hsl(var(--primary-foreground))', 36 | }, 37 | secondary: { 38 | DEFAULT: 'hsl(var(--secondary))', 39 | foreground: 'hsl(var(--secondary-foreground))', 40 | }, 41 | destructive: { 42 | DEFAULT: 'hsl(var(--destructive))', 43 | foreground: 'hsl(var(--destructive-foreground))', 44 | }, 45 | muted: { 46 | DEFAULT: 'hsl(var(--muted))', 47 | foreground: 'hsl(var(--muted-foreground))', 48 | }, 49 | accent: { 50 | DEFAULT: 'hsl(var(--accent))', 51 | foreground: 'hsl(var(--accent-foreground))', 52 | }, 53 | popover: { 54 | DEFAULT: 'hsl(var(--popover))', 55 | foreground: 'hsl(var(--popover-foreground))', 56 | }, 57 | card: { 58 | DEFAULT: 'hsl(var(--card))', 59 | foreground: 'hsl(var(--card-foreground))', 60 | }, 61 | }, 62 | borderRadius: { 63 | // shadcn/ui theming variables 64 | lg: `var(--radius)`, 65 | md: `calc(var(--radius) - 2px)`, 66 | sm: 'calc(var(--radius) - 4px)', 67 | }, 68 | boxShadow: { 69 | 'image-border': 'inset 0 0 0 1px rgba(0,0,0,.07)', 70 | }, 71 | keyframes: { 72 | 'accordion-down': { 73 | from: { height: '0' }, 74 | to: { height: 'var(--radix-accordion-content-height)' }, 75 | }, 76 | 'accordion-up': { 77 | from: { height: 'var(--radix-accordion-content-height)' }, 78 | to: { height: '0' }, 79 | }, 80 | }, 81 | animation: { 82 | 'accordion-down': 'accordion-down 0.2s ease-out', 83 | 'accordion-up': 'accordion-up 0.2s ease-out', 84 | }, 85 | }, 86 | }, 87 | plugins: [require('tailwindcss-animate')], 88 | }) 89 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------