├── .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 |
3 | SharePreviews
4 |
5 |
6 |
7 | No-code dynamic Open Graph images generator. Open-source.
8 |
9 |
10 |
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 |
{
36 | if (hasUnsavedChanges && !promptUnsavedChanges()) {
37 | return
38 | }
39 | router.push(`/${project.pathname}/templates`)
40 | }}
41 | className="flex items-center justify-center text-muted-foreground"
42 | >
43 |
44 | Exit
45 |
46 |
47 |
{`${project?.name} / `}
48 | {template?.name ? (
49 |
{template.name}
50 | ) : (
51 |
52 | )}
53 |
54 |
75 |
76 |
77 | )
78 | }
79 |
80 | function SaveButton({ hasUnsavedChanges }: { hasUnsavedChanges: boolean }) {
81 | const { pending } = useFormStatus()
82 |
83 | return (
84 |
90 | {pending ? (
91 |
92 | ) : (
93 | <>
94 |
95 | Save
96 | >
97 | )}
98 |
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 |
22 | {
26 | track('star_on_github')
27 | }}
28 | >
29 |
33 | Star on GitHub
34 |
35 |
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 |
16 | {
19 | track('start_for_free_home')
20 | }}
21 | >
22 | Get Started
23 |
24 |
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 |
50 | copyToClipboard(
51 | 'https://sharepreviews.com/og/08f36805-dea2-4575-b047-a96c9466d1f4?title_value={VALUE}&description_value={VALUE}'
52 | )
53 | }
54 | >
55 | {isCopied ? (
56 |
57 | ) : (
58 |
59 | )}
60 |
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 |
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 |
47 | {isLoading ? (
48 |
49 | ) : (
50 | 'Add to...'
51 | )}
52 |
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 |
57 |
58 | Santiago Galán
59 |
60 |
61 |
62 |
63 | {user ? (
64 |
68 | ) : (
69 |
70 |
74 | Log in to customize
75 |
76 |
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 | copyToClipboard(children)}
54 | >
55 | {isCopied ? (
56 |
57 | ) : (
58 |
59 | )}
60 |
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 |
11 |
15 |
19 |
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 |
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 |
99 |
100 |
101 | )
102 | }
103 |
104 | function CreateButton() {
105 | const { pending } = useFormStatus()
106 |
107 | return (
108 |
109 | {pending ? (
110 |
111 | ) : (
112 | 'Create'
113 | )}
114 |
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 | Cancel
53 |
54 |
55 | {
57 | if (multiSelectedLayers.length > 0) {
58 | setLayers(
59 | layers.filter(
60 | (layer) =>
61 | !multiSelectedLayers.find(
62 | (selectedLayer) => selectedLayer.id === layer.id
63 | )
64 | )
65 | )
66 | setMultiSelectedLayers([])
67 | } else if (selectedLayer) {
68 | setLayers(
69 | layers.filter((layer) => layer.id !== selectedLayer?.id)
70 | )
71 | setSelectedLayer(undefined)
72 | setMultiSelectedLayers([])
73 | }
74 | }}
75 | >
76 | Delete
77 |
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 |
114 |
115 |
116 | )
117 | }
118 |
119 | function CreateButton() {
120 | const { pending } = useFormStatus()
121 |
122 | return (
123 |
124 | {pending ? (
125 |
126 | ) : (
127 | 'Create'
128 | )}
129 |
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 |
76 |
77 |
78 | )
79 | }
80 |
81 | function CreateButton() {
82 | const { pending } = useFormStatus()
83 |
84 | return (
85 |
86 | {pending ? (
87 |
88 | ) : (
89 | 'Create'
90 | )}
91 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
24 |
25 |
26 | Toggle theme
27 |
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 |
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 |
--------------------------------------------------------------------------------