Repository: jnsahaj/tweakcn Branch: main Commit: 9fb9c4c77f75 Files: 450 Total size: 1.8 MB Directory structure: gitextract_41hngla7/ ├── .dockerignore ├── .github/ │ ├── FUNDING.yml │ └── ISSUE_TEMPLATE/ │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .husky/ │ └── pre-commit ├── .prettierignore ├── .prettierrc ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── actions/ │ ├── account.ts │ ├── ai-usage.ts │ ├── checkout.ts │ ├── community-themes.ts │ ├── customer.ts │ └── themes.ts ├── app/ │ ├── (auth)/ │ │ └── components/ │ │ └── auth-dialog.tsx │ ├── (legal)/ │ │ ├── layout.tsx │ │ └── privacy-policy/ │ │ └── page.tsx │ ├── ai/ │ │ ├── components/ │ │ │ ├── ai-announcement.tsx │ │ │ ├── ai-chat-form.tsx │ │ │ ├── ai-chat-hero.tsx │ │ │ ├── chat-heading.tsx │ │ │ ├── community-theme-card.tsx │ │ │ ├── community-themes.tsx │ │ │ └── suggested-pill-actions.tsx │ │ ├── layout.tsx │ │ ├── loading.tsx │ │ └── page.tsx │ ├── api/ │ │ ├── auth/ │ │ │ └── [...all]/ │ │ │ └── route.ts │ │ ├── enhance-prompt/ │ │ │ └── route.ts │ │ ├── generate-theme/ │ │ │ └── route.ts │ │ ├── google-fonts/ │ │ │ └── route.ts │ │ ├── oauth/ │ │ │ ├── app-info/ │ │ │ │ └── route.ts │ │ │ ├── authorize/ │ │ │ │ └── route.ts │ │ │ ├── revoke/ │ │ │ │ └── route.ts │ │ │ ├── token/ │ │ │ │ └── route.ts │ │ │ └── userinfo/ │ │ │ └── route.ts │ │ ├── subscription/ │ │ │ └── route.ts │ │ ├── v1/ │ │ │ ├── me/ │ │ │ │ └── route.ts │ │ │ └── themes/ │ │ │ ├── [themeId]/ │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ └── webhook/ │ │ └── polar/ │ │ └── route.ts │ ├── community/ │ │ ├── components/ │ │ │ ├── community-sidebar.tsx │ │ │ ├── community-theme-card.tsx │ │ │ ├── community-theme-preview-dialog.tsx │ │ │ └── community-themes-content.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── dashboard/ │ │ ├── layout.tsx │ │ ├── loading.tsx │ │ └── page.tsx │ ├── editor/ │ │ └── theme/ │ │ └── [[...themeId]]/ │ │ ├── layout.tsx │ │ ├── loading.tsx │ │ └── page.tsx │ ├── figma/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── globals.css │ ├── layout.tsx │ ├── loaders.css │ ├── not-found.tsx │ ├── oauth/ │ │ └── authorize/ │ │ └── page.tsx │ ├── page.tsx │ ├── pricing/ │ │ ├── components/ │ │ │ └── checkout-button.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── r/ │ │ ├── themes/ │ │ │ └── [id]/ │ │ │ └── route.ts │ │ └── v0/ │ │ └── [id]/ │ │ └── route.ts │ ├── settings/ │ │ ├── account/ │ │ │ ├── components/ │ │ │ │ └── delete-account-section.tsx │ │ │ └── page.tsx │ │ ├── components/ │ │ │ ├── customer-portal-link.tsx │ │ │ ├── settings-header.tsx │ │ │ ├── settings-sidebar.tsx │ │ │ ├── theme-card.tsx │ │ │ ├── themes-list.tsx │ │ │ ├── usage-stats.tsx │ │ │ └── user-info.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── portal/ │ │ │ └── route.ts │ │ ├── themes/ │ │ │ └── page.tsx │ │ └── usage/ │ │ └── page.tsx │ ├── sitemap.ts │ ├── success/ │ │ ├── layout.tsx │ │ └── page.tsx │ └── themes/ │ └── [themeId]/ │ ├── error.tsx │ ├── layout.tsx │ ├── loading.tsx │ ├── not-found.tsx │ ├── opengraph-image.alt.txt │ ├── opengraph-image.tsx │ └── page.tsx ├── components/ │ ├── ai-elements/ │ │ ├── code-block.tsx │ │ ├── conversation.tsx │ │ └── response.tsx │ ├── auth-dialog-wrapper.tsx │ ├── block-viewer.tsx │ ├── copy-button.tsx │ ├── debug-button.tsx │ ├── dynamic-font-loader.tsx │ ├── dynamic-website-preview.tsx │ ├── editor/ │ │ ├── action-bar/ │ │ │ ├── action-bar.tsx │ │ │ └── components/ │ │ │ ├── action-bar-buttons.tsx │ │ │ ├── ai-generate-button.tsx │ │ │ ├── code-button.tsx │ │ │ ├── import-button.tsx │ │ │ ├── mcp-dialog.tsx │ │ │ ├── more-options.tsx │ │ │ ├── publish-button.tsx │ │ │ ├── reset-button.tsx │ │ │ ├── save-button.tsx │ │ │ ├── share-button.tsx │ │ │ ├── theme-toggle.tsx │ │ │ └── undo-redo-buttons.tsx │ │ ├── ai/ │ │ │ ├── ai-chat-form-body.tsx │ │ │ ├── alert-banner.tsx │ │ │ ├── chat-image-preview.tsx │ │ │ ├── chat-input.tsx │ │ │ ├── chat-interface.tsx │ │ │ ├── chat-theme-preview.tsx │ │ │ ├── closeable-suggested-pill-actions.tsx │ │ │ ├── drag-and-drop-image-uploader.tsx │ │ │ ├── enhance-prompt-button.tsx │ │ │ ├── image-uploader.tsx │ │ │ ├── loading-logo.tsx │ │ │ ├── message-actions.tsx │ │ │ ├── message-edit-form.tsx │ │ │ ├── message.tsx │ │ │ ├── messages.tsx │ │ │ ├── no-messages-placeholder.tsx │ │ │ ├── pill-action-button.tsx │ │ │ ├── stream-text.tsx │ │ │ └── uploaded-image-preview.tsx │ │ ├── code-panel-dialog.tsx │ │ ├── code-panel.tsx │ │ ├── color-picker.tsx │ │ ├── color-selector-popover.tsx │ │ ├── colors-tab-content.tsx │ │ ├── contrast-checker.tsx │ │ ├── control-section.tsx │ │ ├── css-import-dialog.tsx │ │ ├── custom-textarea.tsx │ │ ├── editor.tsx │ │ ├── font-picker.tsx │ │ ├── hsl-adjustment-controls.tsx │ │ ├── hsl-preset-button.tsx │ │ ├── inspector-class-item.tsx │ │ ├── inspector-overlay.tsx │ │ ├── mention-list.tsx │ │ ├── mention-suggestion.ts │ │ ├── section-context.tsx │ │ ├── shadow-control.tsx │ │ ├── share-dialog.tsx │ │ ├── slider-with-input.tsx │ │ ├── theme-control-actions.tsx │ │ ├── theme-control-panel.tsx │ │ ├── theme-font-select.tsx │ │ ├── theme-preset-select.tsx │ │ ├── theme-preview/ │ │ │ ├── color-preview.tsx │ │ │ ├── components-showcase.tsx │ │ │ ├── examples-preview-container.tsx │ │ │ └── tabs-trigger-pill.tsx │ │ ├── theme-preview-panel.tsx │ │ └── theme-save-dialog.tsx │ ├── effects/ │ │ ├── frame-highlight.tsx │ │ ├── noise-effect.tsx │ │ └── spotlight.tsx │ ├── error-boundary.tsx │ ├── examples/ │ │ ├── ai-chat-demo.tsx │ │ ├── cards/ │ │ │ ├── activity-goal.tsx │ │ │ ├── calendar.tsx │ │ │ ├── chat.tsx │ │ │ ├── cookie-settings.tsx │ │ │ ├── create-account.tsx │ │ │ ├── date-picker-with-range.tsx │ │ │ ├── exercise-minutes.tsx │ │ │ ├── forms.tsx │ │ │ ├── github-card.tsx │ │ │ ├── index.tsx │ │ │ ├── payment-method.tsx │ │ │ ├── payments.tsx │ │ │ ├── report-issue.tsx │ │ │ ├── share.tsx │ │ │ ├── stats.tsx │ │ │ └── team-members.tsx │ │ ├── custom/ │ │ │ └── index.tsx │ │ ├── dashboard/ │ │ │ ├── components/ │ │ │ │ ├── app-sidebar.tsx │ │ │ │ ├── chart-area-interactive.tsx │ │ │ │ ├── chart-bar-mixed.tsx │ │ │ │ ├── chart-pie-donut.tsx │ │ │ │ ├── data-table.tsx │ │ │ │ ├── nav-documents.tsx │ │ │ │ ├── nav-main.tsx │ │ │ │ ├── nav-secondary.tsx │ │ │ │ ├── nav-user.tsx │ │ │ │ ├── section-cards.tsx │ │ │ │ └── site-header.tsx │ │ │ ├── data.json │ │ │ └── index.tsx │ │ ├── mail/ │ │ │ ├── components/ │ │ │ │ ├── account-switcher.tsx │ │ │ │ ├── mail-display.tsx │ │ │ │ ├── mail-list.tsx │ │ │ │ ├── mail.tsx │ │ │ │ └── nav.tsx │ │ │ ├── data.tsx │ │ │ ├── index.tsx │ │ │ └── use-mail.ts │ │ ├── music/ │ │ │ ├── components/ │ │ │ │ ├── album-artwork.tsx │ │ │ │ ├── menu.tsx │ │ │ │ ├── podcast-empty-placeholder.tsx │ │ │ │ └── sidebar.tsx │ │ │ ├── data/ │ │ │ │ ├── albums.ts │ │ │ │ └── playlists.ts │ │ │ └── index.tsx │ │ ├── pricing/ │ │ │ └── pricing.tsx │ │ ├── tasks/ │ │ │ ├── components/ │ │ │ │ ├── columns.tsx │ │ │ │ ├── data-table-column-header.tsx │ │ │ │ ├── data-table-faceted-filter.tsx │ │ │ │ ├── data-table-pagination.tsx │ │ │ │ ├── data-table-row-actions.tsx │ │ │ │ ├── data-table-toolbar.tsx │ │ │ │ ├── data-table-view-options.tsx │ │ │ │ ├── data-table.tsx │ │ │ │ └── user-nav.tsx │ │ │ ├── data/ │ │ │ │ ├── data.tsx │ │ │ │ ├── schema.ts │ │ │ │ └── tasks.json │ │ │ └── index.tsx │ │ └── typography/ │ │ ├── blog-post.tsx │ │ ├── font-showcase.tsx │ │ └── typography-demo.tsx │ ├── figma-export-dialog.tsx │ ├── figma-header.tsx │ ├── footer.tsx │ ├── get-pro-cta.tsx │ ├── get-pro-dialog-wrapper.tsx │ ├── header.tsx │ ├── home/ │ │ ├── ai-generation-cta.tsx │ │ ├── cta.tsx │ │ ├── faq.tsx │ │ ├── features.tsx │ │ ├── header.tsx │ │ ├── hero.tsx │ │ ├── how-it-works.tsx │ │ ├── testimonials.tsx │ │ ├── theme-preset-buttons.tsx │ │ └── theme-preset-selector.tsx │ ├── horizontal-scroll-area.tsx │ ├── icons/ │ │ └── tailwind-css.tsx │ ├── icons.tsx │ ├── loader.tsx │ ├── loading.tsx │ ├── posthog-init.tsx │ ├── social-link.tsx │ ├── tag-selector.tsx │ ├── theme-preview.tsx │ ├── theme-provider.tsx │ ├── theme-script.tsx │ ├── theme-toggle.tsx │ ├── theme-view.tsx │ ├── tooltip-wrapper.tsx │ ├── ui/ │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── base-ui-tabs.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── revola.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip.tsx │ │ └── use-toast.ts │ └── user-profile-dropdown.tsx ├── components.json ├── config/ │ └── theme.ts ├── db/ │ ├── index.ts │ └── schema.ts ├── docker-compose.yml ├── docs/ │ └── oauth-api.md ├── drizzle/ │ ├── 0000_rare_moira_mactaggert.sql │ ├── 0001_late_mikhail_rasputin.sql │ ├── 0002_nebulous_randall.sql │ ├── 0003_bumpy_quasimodo.sql │ ├── 0004_red_monster_badoon.sql │ └── meta/ │ ├── 0000_snapshot.json │ ├── 0001_snapshot.json │ ├── 0002_snapshot.json │ ├── 0003_snapshot.json │ ├── 0004_snapshot.json │ └── _journal.json ├── drizzle.config.ts ├── eslint.config.mjs ├── hooks/ │ ├── inspector/ │ │ ├── use-inspector-mouse-events.ts │ │ ├── use-inspector-scroll.ts │ │ ├── use-inspector-state.ts │ │ └── use-theme-inspector.ts │ ├── themes/ │ │ ├── index.ts │ │ ├── use-community-themes.ts │ │ ├── use-theme-mutations.ts │ │ └── use-themes-data.ts │ ├── use-ai-chat-form.ts │ ├── use-ai-enhance-prompt.ts │ ├── use-ai-theme-generation-core.ts │ ├── use-chat-context.tsx │ ├── use-contrast-checker.ts │ ├── use-controls-tab-from-url.ts │ ├── use-copy-to-clipboard.ts │ ├── use-debounced-callback.ts │ ├── use-dialog-actions.tsx │ ├── use-document-drag-and-drop-intent.ts │ ├── use-feedback-text.ts │ ├── use-font-search.ts │ ├── use-fullscreen.ts │ ├── use-github-stars.ts │ ├── use-guards.ts │ ├── use-iframe-theme-injector.ts │ ├── use-image-upload-reducer.ts │ ├── use-image-upload.ts │ ├── use-media-query.tsx │ ├── use-mobile.tsx │ ├── use-mounted.tsx │ ├── use-post-login-action.ts │ ├── use-scroll-start-end.ts │ ├── use-subscription.ts │ ├── use-theme-inspector-classnames.ts │ ├── use-theme-inspector-regex.ts │ ├── use-theme-inspector.ts │ ├── use-theme-preset-from-url.ts │ ├── use-toast.ts │ └── use-website-preview.ts ├── lib/ │ ├── ai/ │ │ ├── generate-theme/ │ │ │ ├── index.ts │ │ │ └── tools.ts │ │ ├── parse-ai-sdk-transport-error.ts │ │ ├── prompts.ts │ │ └── providers.ts │ ├── auth-client.ts │ ├── auth.ts │ ├── checkout.ts │ ├── constants.ts │ ├── error-response.ts │ ├── figma-constants.ts │ ├── inspector/ │ │ ├── class-utils.ts │ │ ├── inspector-state-utils.ts │ │ ├── segment-classname.ts │ │ └── theme-class-finder.ts │ ├── oauth.ts │ ├── polar.ts │ ├── posthog.ts │ ├── query-client.tsx │ ├── shared.ts │ ├── subscription.ts │ └── utils.ts ├── middleware.ts ├── next.config.ts ├── package.json ├── postcss.config.mjs ├── public/ │ ├── live-preview.js │ └── r/ │ └── registry.json ├── routes.ts ├── scripts/ │ ├── create-oauth-app.ts │ ├── generate-registry.ts │ └── generate-theme-registry.ts ├── store/ │ ├── ai-chat-store.ts │ ├── ai-local-draft-store.ts │ ├── auth-store.ts │ ├── color-control-focus-store.ts │ ├── editor-store.ts │ ├── get-pro-dialog-store.ts │ ├── idb-storage.ts │ ├── preferences-store.ts │ ├── theme-preset-store.ts │ └── website-preview-store.ts ├── tsconfig.json ├── types/ │ ├── ai.ts │ ├── community.ts │ ├── editor.ts │ ├── errors.ts │ ├── fonts.ts │ ├── index.ts │ ├── live-preview-embed.ts │ ├── subscription.ts │ └── theme.ts └── utils/ ├── ai/ │ ├── ai-prompt.tsx │ ├── apply-theme.ts │ ├── image-upload.ts │ ├── message-converter.ts │ ├── messages.ts │ └── prompts.ts ├── apply-style-to-element.ts ├── apply-theme.ts ├── color-converter.ts ├── contrast-checker.ts ├── debounce.ts ├── fonts/ │ ├── google-fonts.ts │ └── index.ts ├── format.ts ├── parse-css-input.ts ├── registry/ │ ├── tailwind-colors.ts │ ├── themes.ts │ └── v0.ts ├── shadows.ts ├── subscription.ts ├── theme-fonts.ts ├── theme-preset-helper.ts ├── theme-presets.ts ├── theme-style-generator.ts ├── theme-styles.ts └── try-catch.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ # Ignore node_modules — let Docker install fresh inside the image node_modules # Ignore environment files (not needed inside build context) .env .env.* # OS-specific .DS_Store # Git stuff .git .gitignore # Build artifacts .next dist out ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [jnsahaj] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. 22] **Smartphone (please complete the following information):** - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] - Browser [e.g. stock browser, safari] - Version [e.g. 22] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies node_modules /.pnp .pnp.* .yarn/* !.yarn/patches !.yarn/plugins !.yarn/releases !.yarn/versions # bun lock bun.lock # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem .cursor # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) .env .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts # build artifacts public/r/themes ================================================ FILE: .husky/pre-commit ================================================ npm run lint ================================================ FILE: .prettierignore ================================================ # Builds .next/ dist/ build/ out/ # Dependencies node_modules/ # Coverage and tests coverage/ .nyc_output/ # Misc .git/ .github/ .vscode/ .cursor/ .husky/ # Package files pnpm-lock.yaml package-lock.json yarn.lock # Generated files public/ stubs/ drizzle/ # Config files that should maintain their format *.config.js *.config.mjs *.config.cjs *.config.ts # Environment files .env* # Markdown files *.md ================================================ FILE: .prettierrc ================================================ { "semi": true, "singleQuote": false, "trailingComma": "es5", "tabWidth": 2, "printWidth": 100, "bracketSpacing": true, "arrowParens": "always", "endOfLine": "lf", "plugins": ["prettier-plugin-tailwindcss"], "pluginSearchDirs": false } ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to tweakcn.com Thanks for your interest in contributing to tweakcn.com! We're excited to have you here. Please take a moment to review this document before submitting your first pull request. We also strongly recommend checking open [Issues](https://github.com/jnsahaj/tweakcn/issues) and [Pull Requests](https://github.com/jnsahaj/tweakcn/pulls) to see if someone else is working on something similar. If you need any help or want to discuss ideas, feel free to join our community on [Discord](https://discord.com/invite/Phs4u2NM3n). ## About This Project tweakcn.com is a powerful Visual Theme Editor designed for Tailwind CSS & shadcn/ui components. Websites built with shadcn/ui often share a similar look; tweakcn helps you visually customize these components to make your projects stand out. ## Project Structure This repository contains the Next.js application for tweakcn.com. Here's a simplified overview of the project's directory structure: ``` ├── actions/ # Next.js Server Actions ├── app/ ├── (auth)/ # Authentication routes ├── (legal)/ # Legal pages (privacy policy) ├── api/ # Public API endpoints ├── dashboard/ # User dashboard (saved themes) ├── editor/ # Main theme editor route ├── layout.tsx # Root application layout └── page.tsx # Landing page route ├── components/ ├── editor/ # Theme editor interface components ├── examples/ # Demo components for theme previews ├── home/ # Landing page components └── ui/ # Base shadcn/ui components ├── config/ # App configuration & default values ├── db/ # Database schema & logic (Drizzle ORM) ├── hooks/ # Custom React hooks ├── lib/ # 3rd-party library integrations & helpers ├── public/ └── r/ # Holds JSON files for the theme registry ├── scripts/ # Utility scripts used during development ├── store/ # Global state management (Zustand) └── utils/ # General utility functions and helpers ``` ## How to Contribute ### Non-Technical Even if you don't plan to write code, there are many ways to contribute: - **Create an Issue:** If you find a bug, have an idea for a new feature, or want to suggest an improvement, please [create an issue on GitHub](https://github.com/jnsahaj/tweakcn/issues). This helps us track and prioritize feedback. - **Spread the Word:** If you like tweakcn.com, please share it with your friends, colleagues, and on social media. Helping grow the community makes the tool better for everyone. - **Use tweakcn.com:** The best feedback comes from real-world usage! As you use the editor, if you encounter any issues or have ideas for improvement, please let us know by creating an issue or reaching out on [Discord](https://discord.com/invite/Phs4u2NM3n). ### Prerequisites - Node.js 18+ - npm / yarn / pnpm ### Installation 1. **Fork the Repository:** Start by creating your own copy of the [tweakcn repository](https://github.com/jnsahaj/tweakcn) on GitHub. Click the "Fork" button in the top-right corner. 2. **Clone Your Fork:** Clone the repository you just forked to your local machine: ```bash git clone https://github.com/YOUR_USERNAME/tweakcn.git cd tweakcn ``` Replace `YOUR_USERNAME` with your actual GitHub username. 3. **Install Dependencies:** Install the necessary project dependencies: ```bash npm install ``` ### Set up the development environment (follow closely) 1. **Configure Environment Variables:** ```bash cp .env.example .env.local # Copy the example environment file ``` - Open the `.env.local` file and replace the placeholder values with your actual credentials obtained from the services. 2. **Apply Database Schema:** Push the database schema defined in `db/schema.ts` to your Neon database using Drizzle Kit: ```bash npx drizzle-kit push ``` - _(Optional)_ You can view your database structure using Drizzle Studio by running `npx drizzle-kit studio`. 3. **Create a New Branch:** Before making changes, create a dedicated branch for your feature or bug fix: ```bash git checkout -b your-descriptive-branch-name ``` (e.g., `feature/add-community-gallery`, `fix/login-button-style`) 4. **Start the Development Server:** ```bash npm run dev ``` 5. Open [http://localhost:3000](http://localhost:3000) in your browser. You're now ready to start coding! ### Troubleshooting Setup If you encounter unexpected issues, especially after pulling new changes or related to database/auth setup, try resetting your local environment: 1. Stop the development server (Ctrl+C). 2. Delete the `node_modules` and `.next` directories: ```bash # On macOS / Linux: rm -rf node_modules .next # On Windows (PowerShell): Remove-Item -Recurse -Force node_modules, .next ``` 3. Reinstall dependencies: ```bash npm install ``` 4. Re-run the database push command (optional, but good practice if schema might have changed): ```bash npx drizzle-kit push ``` 5. Restart the development server: ```bash npm run dev ``` ## Submitting Your Changes (Pull Request Workflow) Once you've made your changes and tested them locally, follow these steps to submit them for review: 1. **Stage Your Changes:** Add the files you've modified to the Git staging area. ```bash git add . ``` 2. **Commit Your Changes:** Commit your staged changes with a descriptive message that follows the **Conventional Commits** specification. This helps automate releases and makes the commit history easier to understand. ```bash git commit -m "feat(editor): Add contrast checker component" ``` - **Format:** `type(scope): description` (e.g., `fix(auth): Correct GitHub redirect URL`, `docs(readme): Update setup instructions`). - **Common Types:** `feat` (new feature), `fix` (bug fix), `docs` (documentation), `style` (code style), `chore` (build process, tooling). - Refer to the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) for more details. 3. **Push to Your Fork:** Push your committed changes to the branch on your forked repository on GitHub. ```bash git push origin your-descriptive-branch-name ``` Replace `your-descriptive-branch-name` with the actual name of your branch. 4. **Open a Pull Request (PR):** - Go to the original [tweakcn repository](https://github.com/jnsahaj/tweakcn) on GitHub. - You should see a prompt suggesting you create a Pull Request from your recently pushed branch. Click on it. If not, navigate to the "Pull requests" tab and click "New pull request". - Ensure the base repository is `jnsahaj/tweakcn` and the base branch is `main` (or the appropriate target branch). - Ensure the head repository is your fork and the compare branch is `your-descriptive-branch-name`. - **Write a Clear Description:** Fill out the pull request template (if one exists). Provide a clear title and a detailed description of the changes you've made. Explain _why_ you made the changes and link to any relevant GitHub Issues (e.g., "Closes #123"). 5. **Review Process:** - Once submitted, maintainers will review your pull request. - Maintainers may provide feedback or request changes directly on the pull request. Please address these comments by pushing further commits to your branch. - Once approved, a maintainer will merge your changes into the main project. Thank you for contributing! ================================================ FILE: Dockerfile ================================================ FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm install COPY . . EXPOSE 3000 CMD ["npm", "run", "dev"] ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================

tweakcn.com

Vercel OSS Program

Discord GitHub Repo stars X (formerly Twitter) URL

**[tweakcn](https://tweakcn.com)** is a powerful Visual Theme Editor for tailwind CSS & shadcn/ui components. It comes with Beautiful theme presets to get started, while aiming to offer advanced customisation for each aspect of your UI ![tweakcn.com](public/og-image.v050725.png) ## Motivation Websites made with shadcn/ui famously look the same. tweakcn is a tool that helps you customize shadcn/ui components visually, to make your components stand-out. The goal is to build a platform where a user can discover endless customization options and then have the ability to put their own twist on it. ## Current Features You can find the full feature list here: https://tweakcn.com/#features ## Run Locally **IMPORTANT: For contributions, please see [CONTRIBUTING.md](CONTRIBUTING.md).** ### Prerequisites - Node.js 18+ - npm / yarn / pnpm ### Installation 1. Clone the repository: ```bash git clone https://github.com/jnsahaj/tweakcn.git cd tweakcn ``` 2. Install dependencies: ```bash npm install ``` 3. Start the development server: ```bash npm run dev ``` 4. Open [http://localhost:3000](http://localhost:3000) in your browser. ## Contributors Made with [contrib.rocks](https://contrib.rocks). ### Interested in Contributing? Contributions are welcome! Please feel free to submit a Pull Request. # Star History

GitHub Star History for jnsahaj/tweakcn

================================================ FILE: actions/account.ts ================================================ "use server"; import { db } from "@/db"; import { user as userTable, subscription } from "@/db/schema"; import { eq } from "drizzle-orm"; import { getCurrentUserId } from "@/lib/shared"; import { logError } from "@/lib/shared"; import { actionError, actionSuccess, ErrorCode, type ActionResult } from "@/types/errors"; import { polar } from "@/lib/polar"; export async function deleteAccount(): Promise> { try { const userId = await getCurrentUserId(); // Try to delete Polar customer (cancels subscriptions + revokes benefits) // Free users won't have a Polar customer, so we catch and ignore errors try { await polar.customers.deleteExternal({ externalId: userId }); } catch (_e) { // Expected for free users — no Polar customer exists } // Delete subscription records (no CASCADE on this table) await db.delete(subscription).where(eq(subscription.userId, userId)); // Delete user — CASCADE handles: sessions, accounts, themes, // communityThemes, communityThemeTags, themeLikes, aiUsage, // oauthAuthorizationCode, oauthToken await db.delete(userTable).where(eq(userTable.id, userId)); return actionSuccess(true); } catch (error) { logError(error as Error, { action: "deleteAccount" }); return actionError(ErrorCode.UNKNOWN_ERROR, "Failed to delete account. Please try again."); } } ================================================ FILE: actions/ai-usage.ts ================================================ "use server"; import { db } from "@/db"; import { aiUsage } from "@/db/schema"; import { getCurrentUserId } from "@/lib/shared"; import { ValidationError } from "@/types/errors"; import cuid from "cuid"; import { and, count, eq, gte } from "drizzle-orm"; import { z } from "zod"; const getDaysSinceEpoch = (daysAgo: number = 0) => Math.floor(Date.now() / (24 * 60 * 60 * 1000)) - daysAgo; // Schema for recording usage events (internal use - still tracks tokens) const recordUsageSchema = z.object({ promptTokens: z.number().min(0).default(0), completionTokens: z.number().min(0).default(0), modelId: z.string(), }); // Schema for timeframe validation const timeframeSchema = z.union([z.literal("1d"), z.literal("7d"), z.literal("30d")]); // Types type Timeframe = z.infer; // Simplified user-facing interface - only shows requests interface UsageStats { requests: number; timeframe: Timeframe; } interface ChartDataPoint { daysSinceEpoch?: number; hoursSinceEpoch?: number; date: string; totalRequests: number; } export async function recordAIUsage(input: { modelId: string; promptTokens?: number; completionTokens?: number; }) { try { const userId = await getCurrentUserId(); const validation = recordUsageSchema.safeParse(input); if (!validation.success) { throw new ValidationError("Invalid usage data", validation.error.format()); } const { promptTokens, completionTokens, modelId } = validation.data; const daysSinceEpoch = getDaysSinceEpoch(0); const [insertedUsage] = await db .insert(aiUsage) .values({ id: cuid(), userId, modelId, promptTokens: promptTokens.toString(), completionTokens: completionTokens.toString(), daysSinceEpoch: daysSinceEpoch.toString(), createdAt: new Date(), }) .returning(); return insertedUsage; } catch (error) { console.error("Error recording usage:", error); throw error; } } export async function getMyUsageStats(timeframe: Timeframe): Promise { try { const userId = await getCurrentUserId(); const validation = timeframeSchema.safeParse(timeframe); if (!validation.success) { throw new ValidationError("Invalid timeframe"); } const days = timeframe === "1d" ? 1 : timeframe === "7d" ? 7 : 30; const startDay = getDaysSinceEpoch(days); // Get user's events in time range const events = await db .select() .from(aiUsage) .where(and(eq(aiUsage.userId, userId), gte(aiUsage.daysSinceEpoch, startDay.toString()))); return { requests: events.length, timeframe, }; } catch (error) { console.error("Error getting usage stats:", error); throw error; } } export async function getMyAllTimeRequestCount(userId: string): Promise { try { const result = await db .select({ count: count() }) .from(aiUsage) .where(eq(aiUsage.userId, userId)); return result[0]?.count ?? 0; } catch (error) { console.error("Error getting all-time request count:", error); throw error; } } export async function getMyUsageChartData(timeframe: Timeframe): Promise { try { const userId = await getCurrentUserId(); const validation = timeframeSchema.safeParse(timeframe); if (!validation.success) { throw new ValidationError("Invalid timeframe"); } // For 1d, we want hourly granularity if (timeframe === "1d") { const hours = 24; const startTime = Date.now() - hours * 60 * 60 * 1000; // Get user's events in the last 24 hours const events = await db .select() .from(aiUsage) .where(and(eq(aiUsage.userId, userId), gte(aiUsage.createdAt, new Date(startTime)))); // Group by hour const chartData: ChartDataPoint[] = []; for (let i = hours - 1; i >= 0; i--) { const hourStart = Date.now() - i * 60 * 60 * 1000; const hourEnd = Date.now() - (i - 1) * 60 * 60 * 1000; const hourEvents = events.filter( (e) => e.createdAt.getTime() >= hourStart && e.createdAt.getTime() < hourEnd ); const totalRequests = hourEvents.length; chartData.push({ hoursSinceEpoch: Math.floor(hourStart / (60 * 60 * 1000)), date: new Date(hourStart).toISOString(), totalRequests, }); } return chartData; } // Daily logic for 7d and 30d const days = timeframe === "7d" ? 7 : 30; const startDay = getDaysSinceEpoch(days); // Get user's events in time range const events = await db .select() .from(aiUsage) .where(and(eq(aiUsage.userId, userId), gte(aiUsage.daysSinceEpoch, startDay.toString()))); // Group by day const chartData: ChartDataPoint[] = []; for (let i = days - 1; i >= 0; i--) { const daysSince = getDaysSinceEpoch(i); const dayEvents = events.filter((e) => parseInt(e.daysSinceEpoch) === daysSince); const totalRequests = dayEvents.length; chartData.push({ daysSinceEpoch: daysSince, date: new Date(daysSince * 24 * 60 * 60 * 1000).toISOString().split("T")[0], totalRequests, }); } return chartData; } catch (error) { console.error("Error getting usage chart data:", error); throw error; } } // Internal function for detailed usage (including tokens) - not exposed to users export async function getDetailedUsageStats( timeframe: Timeframe, modelId: string ): Promise<{ requests: number; promptTokens: number; completionTokens: number; totalTokens: number; timeframe: Timeframe; }> { try { const userId = await getCurrentUserId(); const validation = timeframeSchema.safeParse(timeframe); if (!validation.success) { throw new ValidationError("Invalid timeframe"); } const days = timeframe === "1d" ? 1 : timeframe === "7d" ? 7 : 30; const startDay = getDaysSinceEpoch(days); // Get user's events for the model const events = await db .select() .from(aiUsage) .where( and( eq(aiUsage.userId, userId), eq(aiUsage.modelId, modelId), gte(aiUsage.daysSinceEpoch, startDay.toString()) ) ); const requests = events.length; const promptTokens = events.reduce((sum, e) => sum + parseInt(e.promptTokens), 0); const completionTokens = events.reduce((sum, e) => sum + parseInt(e.completionTokens), 0); const totalTokens = promptTokens + completionTokens; return { requests, promptTokens, completionTokens, totalTokens, timeframe, }; } catch (error) { console.error("Error getting detailed usage stats:", error); throw error; } } ================================================ FILE: actions/checkout.ts ================================================ "use server"; import { polar } from "@/lib/polar"; import { getCurrentUser, logError } from "@/lib/shared"; import { getOrCreateCustomer } from "./customer"; export const createCheckout = async () => { try { const user = await getCurrentUser(); const customer = await getOrCreateCustomer(user); const checkout = await polar.checkouts.create({ products: [process.env.NEXT_PUBLIC_TWEAKCN_PRO_PRODUCT_ID!], customerId: customer?.id, successUrl: `${process.env.BASE_URL}/success?checkout_id={CHECKOUT_ID}`, }); return { url: checkout.url }; } catch (error) { logError(error as Error, { action: "createCheckout" }); return { error: "Failed to create checkout" }; } }; ================================================ FILE: actions/community-themes.ts ================================================ "use server"; import { z } from "zod"; import { db } from "@/db"; import { communityTheme, communityThemeTag, themeLike, theme as themeTable, user as userTable, } from "@/db/schema"; import { eq, and, desc, asc, sql, count, inArray } from "drizzle-orm"; import cuid from "cuid"; import { auth } from "@/lib/auth"; import { headers } from "next/headers"; import { UnauthorizedError, ValidationError, ThemeNotFoundError, ErrorCode, actionError, actionSuccess, type ActionResult, } from "@/types/errors"; import { COMMUNITY_THEMES_PAGE_SIZE, COMMUNITY_THEME_TAGS, MAX_TAGS_PER_THEME, } from "@/lib/constants"; import type { CommunityTheme, CommunitySortOption, CommunityFilterOption, CommunityTimeRange, CommunityThemesResponse, } from "@/types/community"; import { unstable_cache, revalidateTag } from "next/cache"; import { Ratelimit } from "@upstash/ratelimit"; import { kv } from "@vercel/kv"; const ratelimit = new Ratelimit({ redis: kv, limiter: Ratelimit.fixedWindow(5, "3600s"), }); async function getOptionalUserId(): Promise { try { const session = await auth.api.getSession({ headers: await headers(), }); return session?.user?.id ?? null; } catch { return null; } } async function getCurrentUserId(): Promise { const session = await auth.api.getSession({ headers: await headers(), }); if (!session?.user?.id) { throw new UnauthorizedError(); } return session.user.id; } function logError(error: Error, context: Record) { if (error.name === "UnauthorizedError" || error.name === "ValidationError") { console.warn("Expected error:", { error: error.message, context }); } else { console.error("Unexpected error:", { error: error.message, stack: error.stack, context, }); } } const getCommunityThemesSchema = z.object({ sort: z.enum(["popular", "newest", "oldest"]).default("popular"), cursor: z.union([z.string(), z.number()]).optional(), limit: z.number().min(1).max(50).default(COMMUNITY_THEMES_PAGE_SIZE), filter: z.enum(["all", "mine", "liked"]).default("all"), tags: z.array(z.string()).default([]), timeRange: z.enum(["weekly", "monthly", "all"]).default("all"), }); // Core query logic for community themes (no headers() access — cacheable) async function fetchCommunityThemesCore( sort: string, cursor: string | number | null, limit: number, filter: string, tags: string[], userId: string | null, timeRange: string = "all" ): Promise { const fetchLimit = limit + 1; const conditions = []; if (filter === "mine") { if (!userId) throw new UnauthorizedError(); conditions.push(eq(communityTheme.userId, userId)); } if (filter === "liked") { if (!userId) throw new UnauthorizedError(); conditions.push( sql`exists(select 1 from theme_like where theme_like.theme_id = ${communityTheme.id} and theme_like.user_id = ${userId})` ); } if (tags.length > 0) { conditions.push( sql`exists(select 1 from community_theme_tag where community_theme_tag.community_theme_id = ${communityTheme.id} and community_theme_tag.tag in (${sql.join( tags.map((t) => sql`${t}`), sql`, ` )}))` ); } // For weekly/monthly popular sort, filter to themes published within the time range if (sort === "popular" && timeRange !== "all") { const intervalSql = timeRange === "weekly" ? sql`interval '7 days'` : sql`interval '30 days'`; conditions.push( sql`${communityTheme.publishedAt} > now() - ${intervalSql}` ); } const selectFields = { id: communityTheme.id, themeId: communityTheme.themeId, publishedAt: communityTheme.publishedAt, themeName: themeTable.name, themeStyles: themeTable.styles, authorId: userTable.id, authorName: userTable.name, authorImage: userTable.image, likeCount: communityTheme.likeCount, ...(userId ? { isLikedByMe: sql`exists( select 1 from theme_like where theme_like.theme_id = ${communityTheme.id} and theme_like.user_id = ${userId} )`.as("is_liked_by_me"), } : {}), }; const baseQuery = db .select(selectFields) .from(communityTheme) .innerJoin(themeTable, eq(communityTheme.themeId, themeTable.id)) .innerJoin(userTable, eq(communityTheme.userId, userTable.id)); let results; if (sort === "popular") { const offset = typeof cursor === "number" ? cursor : 0; results = await baseQuery .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(desc(communityTheme.likeCount), desc(communityTheme.publishedAt)) .limit(fetchLimit) .offset(offset); } else if (sort === "newest") { if (cursor && typeof cursor === "string") { conditions.push(sql`${communityTheme.publishedAt} < ${cursor}`); } results = await baseQuery .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(desc(communityTheme.publishedAt)) .limit(fetchLimit); } else { if (cursor && typeof cursor === "string") { conditions.push(sql`${communityTheme.publishedAt} > ${cursor}`); } results = await baseQuery .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(asc(communityTheme.publishedAt)) .limit(fetchLimit); } const hasMore = results.length > limit; const themes = results.slice(0, limit); let nextCursor: string | number | null = null; if (hasMore) { if (sort === "popular") { nextCursor = (typeof cursor === "number" ? cursor : 0) + limit; } else { const lastTheme = themes[themes.length - 1]; nextCursor = lastTheme.publishedAt.toISOString(); } } const communityThemeIds = themes.map((t) => t.id); const tagsRows = communityThemeIds.length > 0 ? await db .select({ communityThemeId: communityThemeTag.communityThemeId, tag: communityThemeTag.tag, }) .from(communityThemeTag) .where( inArray(communityThemeTag.communityThemeId, communityThemeIds) ) : []; const tagsMap = new Map(); for (const row of tagsRows) { const existing = tagsMap.get(row.communityThemeId) ?? []; existing.push(row.tag); tagsMap.set(row.communityThemeId, existing); } const mappedThemes: CommunityTheme[] = themes.map((row) => ({ id: row.id, themeId: row.themeId, name: row.themeName, styles: row.themeStyles, author: { id: row.authorId, name: row.authorName, image: row.authorImage, }, likeCount: Number(row.likeCount), isLikedByMe: "isLikedByMe" in row ? Boolean(row.isLikedByMe) : false, publishedAt: row.publishedAt.toISOString(), tags: tagsMap.get(row.id) ?? [], })); return { themes: mappedThemes, nextCursor }; } const getCachedCommunityThemes = unstable_cache( fetchCommunityThemesCore, ["community-themes"], { revalidate: 60, tags: ["community-themes"] } ); export async function getCommunityThemes( sort: CommunitySortOption = "popular", cursor?: string | number, limit: number = COMMUNITY_THEMES_PAGE_SIZE, filter: CommunityFilterOption = "all", tags: string[] = [], timeRange: CommunityTimeRange = "all" ): Promise { try { const validation = getCommunityThemesSchema.safeParse({ sort, cursor, limit, filter, tags, timeRange, }); if (!validation.success) { throw new ValidationError("Invalid input", validation.error.format()); } const userId = await getOptionalUserId(); return getCachedCommunityThemes( sort, cursor ?? null, limit, filter, tags, userId, timeRange ); } catch (error) { logError(error as Error, { action: "getCommunityThemes", sort, cursor }); throw error; } } export async function publishTheme( themeId: string, tags: string[] = [] ): Promise> { try { const userId = await getCurrentUserId(); if (!themeId) { throw new ValidationError("Theme ID required"); } // Validate tags if (tags.length > MAX_TAGS_PER_THEME) { throw new ValidationError( `You can select at most ${MAX_TAGS_PER_THEME} tags` ); } const validTags = tags.filter((t): t is string => (COMMUNITY_THEME_TAGS as readonly string[]).includes(t) ); // Verify theme ownership const [existingTheme] = await db .select() .from(themeTable) .where(and(eq(themeTable.id, themeId), eq(themeTable.userId, userId))) .limit(1); if (!existingTheme) { throw new ThemeNotFoundError("Theme not found or not owned by user"); } // Check not already published const [existing] = await db .select() .from(communityTheme) .where(eq(communityTheme.themeId, themeId)) .limit(1); if (existing) { return actionError( ErrorCode.ALREADY_PUBLISHED, "This theme is already published to the community." ); } // Rate limit if (process.env.NODE_ENV !== "development") { const { success } = await ratelimit.limit(`publish:${userId}`); if (!success) { return actionError( ErrorCode.UNKNOWN_ERROR, "You're publishing too fast. Please try again later." ); } } const id = cuid(); await db.insert(communityTheme).values({ id, themeId, userId, publishedAt: new Date(), }); if (validTags.length > 0) { try { await db.insert(communityThemeTag).values( validTags.map((tag) => ({ communityThemeId: id, tag, })) ); } catch (tagError) { // Roll back the community theme row if tags insert fails await db.delete(communityTheme).where(eq(communityTheme.id, id)); throw tagError; } } revalidateTag("community-themes"); revalidateTag("community-tag-counts"); return actionSuccess({ id }); } catch (error) { logError(error as Error, { action: "publishTheme", themeId }); throw error; } } export async function unpublishTheme( themeId: string ): Promise> { try { const userId = await getCurrentUserId(); if (!themeId) { throw new ValidationError("Theme ID required"); } const [deleted] = await db .delete(communityTheme) .where( and( eq(communityTheme.themeId, themeId), eq(communityTheme.userId, userId) ) ) .returning({ id: communityTheme.id }); if (!deleted) { throw new ThemeNotFoundError( "Published theme not found or not owned by user" ); } revalidateTag("community-themes"); revalidateTag("community-tag-counts"); return actionSuccess({ success: true }); } catch (error) { logError(error as Error, { action: "unpublishTheme", themeId }); throw error; } } export async function toggleLikeTheme( communityThemeId: string ): Promise> { try { const userId = await getCurrentUserId(); if (!communityThemeId) { throw new ValidationError("Community theme ID required"); } // Check if already liked const [existingLike] = await db .select() .from(themeLike) .where( and( eq(themeLike.userId, userId), eq(themeLike.themeId, communityThemeId) ) ) .limit(1); if (existingLike) { // Unlike: delete + decrement await db .delete(themeLike) .where( and( eq(themeLike.userId, userId), eq(themeLike.themeId, communityThemeId) ) ); const [updated] = await db .update(communityTheme) .set({ likeCount: sql`GREATEST(${communityTheme.likeCount} - 1, 0)`, }) .where(eq(communityTheme.id, communityThemeId)) .returning({ likeCount: communityTheme.likeCount }); revalidateTag("community-themes"); return actionSuccess({ liked: false, likeCount: updated.likeCount, }); } else { // Like: insert + increment await db.insert(themeLike).values({ userId, themeId: communityThemeId, createdAt: new Date(), }); const [updated] = await db .update(communityTheme) .set({ likeCount: sql`${communityTheme.likeCount} + 1` }) .where(eq(communityTheme.id, communityThemeId)) .returning({ likeCount: communityTheme.likeCount }); revalidateTag("community-themes"); return actionSuccess({ liked: true, likeCount: updated.likeCount, }); } } catch (error) { logError(error as Error, { action: "toggleLikeTheme", communityThemeId, }); throw error; } } async function fetchCommunityDataForThemeCore( themeId: string, userId: string | null ): Promise<{ communityThemeId: string; author: { id: string; name: string; image: string | null }; likeCount: number; isLikedByMe: boolean; publishedAt: string; tags: string[]; } | null> { const [result] = await db .select({ id: communityTheme.id, publishedAt: communityTheme.publishedAt, authorId: userTable.id, authorName: userTable.name, authorImage: userTable.image, likeCount: communityTheme.likeCount, ...(userId ? { isLikedByMe: sql`exists( select 1 from theme_like where theme_like.theme_id = ${communityTheme.id} and theme_like.user_id = ${userId} )`.as("is_liked_by_me"), } : {}), }) .from(communityTheme) .innerJoin(userTable, eq(communityTheme.userId, userTable.id)) .where(eq(communityTheme.themeId, themeId)) .limit(1); if (!result) return null; const tagRows = await db .select({ tag: communityThemeTag.tag }) .from(communityThemeTag) .where(eq(communityThemeTag.communityThemeId, result.id)); return { communityThemeId: result.id, author: { id: result.authorId, name: result.authorName, image: result.authorImage, }, likeCount: Number(result.likeCount), isLikedByMe: "isLikedByMe" in result ? Boolean(result.isLikedByMe) : false, publishedAt: result.publishedAt.toISOString(), tags: tagRows.map((r) => r.tag), }; } const getCachedCommunityDataForTheme = unstable_cache( fetchCommunityDataForThemeCore, ["community-theme-data"], { revalidate: 60, tags: ["community-themes"] } ); export async function getCommunityDataForTheme( themeId: string ): Promise<{ communityThemeId: string; author: { id: string; name: string; image: string | null }; likeCount: number; isLikedByMe: boolean; publishedAt: string; tags: string[]; } | null> { try { const userId = await getOptionalUserId(); return getCachedCommunityDataForTheme(themeId, userId); } catch (error) { logError(error as Error, { action: "getCommunityDataForTheme", themeId, }); return null; } } export async function getMyPublishedThemeIds(): Promise { try { const userId = await getCurrentUserId(); const published = await db .select({ themeId: communityTheme.themeId }) .from(communityTheme) .where(eq(communityTheme.userId, userId)); return published.map((p) => p.themeId); } catch (error) { logError(error as Error, { action: "getMyPublishedThemeIds" }); throw error; } } export async function updateCommunityThemeTags( themeId: string, tags: string[] ): Promise> { try { const userId = await getCurrentUserId(); if (!themeId) { throw new ValidationError("Theme ID required"); } if (tags.length > MAX_TAGS_PER_THEME) { throw new ValidationError( `You can select at most ${MAX_TAGS_PER_THEME} tags` ); } const validTags = tags.filter((t): t is string => (COMMUNITY_THEME_TAGS as readonly string[]).includes(t) ); // Verify ownership via the community_theme row const [ct] = await db .select({ id: communityTheme.id }) .from(communityTheme) .where( and( eq(communityTheme.themeId, themeId), eq(communityTheme.userId, userId) ) ) .limit(1); if (!ct) { throw new ThemeNotFoundError( "Published theme not found or not owned by user" ); } // Delete existing tags and insert new ones await db .delete(communityThemeTag) .where(eq(communityThemeTag.communityThemeId, ct.id)); if (validTags.length > 0) { await db.insert(communityThemeTag).values( validTags.map((tag) => ({ communityThemeId: ct.id, tag, })) ); } revalidateTag("community-themes"); revalidateTag("community-tag-counts"); return actionSuccess({ tags: validTags }); } catch (error) { logError(error as Error, { action: "updateCommunityThemeTags", themeId, }); throw error; } } const getCachedTagCounts = unstable_cache( async () => { const rows = await db .select({ tag: communityThemeTag.tag, count: count().as("tag_count"), }) .from(communityThemeTag) .groupBy(communityThemeTag.tag) .orderBy(sql`tag_count desc`); return rows.map((r) => ({ tag: r.tag, count: Number(r.count) })); }, ["community-tag-counts"], { revalidate: 300, tags: ["community-tag-counts"] } ); export async function getCommunityTagCounts(): Promise< { tag: string; count: number }[] > { try { return getCachedTagCounts(); } catch (error) { logError(error as Error, { action: "getCommunityTagCounts" }); return []; } } ================================================ FILE: actions/customer.ts ================================================ import "server-only"; import { polar } from "@/lib/polar"; import { logError } from "@/lib/shared"; import { Customer } from "@polar-sh/sdk/models/components/customer.js"; import { User } from "better-auth"; export const getOrCreateCustomer = async (user: User) => { let customer: Customer | null = null; try { customer = await polar.customers.getExternal({ externalId: user.id }); } catch (_e) { customer = null; } if (customer) return customer; try { const newCustomer = await polar.customers.create({ email: user.email, externalId: user.id, name: user.name, }); return newCustomer; } catch (err) { logError(err as Error, { action: "createCustomer", user }); } return null; }; ================================================ FILE: actions/themes.ts ================================================ "use server"; import { z } from "zod"; import { db } from "@/db"; import { theme as themeTable, communityTheme } from "@/db/schema"; import { eq, and, sql } from "drizzle-orm"; import cuid from "cuid"; import { auth } from "@/lib/auth"; import { headers } from "next/headers"; import { themeStylesSchema, type ThemeStyles } from "@/types/theme"; import { cache } from "react"; import { UnauthorizedError, ValidationError, ThemeNotFoundError, ErrorCode, actionError, actionSuccess, type ActionResult, } from "@/types/errors"; import { MAX_FREE_THEMES } from "@/lib/constants"; import { getMyActiveSubscription } from "@/lib/subscription"; // Helper to get user ID with better error handling async function getCurrentUserId(): Promise { const session = await auth.api.getSession({ headers: await headers(), }); if (!session?.user?.id) { throw new UnauthorizedError(); } return session.user.id; } // Log errors for observability function logError(error: Error, context: Record) { console.error("Theme action error:", error, context); // TODO: Add server-side error reporting to PostHog or your preferred service // For production, you'd want to send critical errors to an external service if (error.name === "UnauthorizedError" || error.name === "ValidationError") { // These are expected errors, log but don't report console.warn("Expected error:", { error: error.message, context }); } else { // Unexpected errors should be reported console.error("Unexpected error:", { error: error.message, stack: error.stack, context }); } } const createThemeSchema = z.object({ name: z.string().min(1, "Theme name cannot be empty").max(50, "Theme name too long"), styles: themeStylesSchema, }); const updateThemeSchema = z.object({ id: z.string().min(1, "Theme ID required"), name: z.string().min(1, "Theme name cannot be empty").max(50, "Theme name too long").optional(), styles: themeStylesSchema.optional(), }); // Layer 1: Clean server actions with proper error handling export async function getThemes() { try { const userId = await getCurrentUserId(); const userThemes = await db .select({ id: themeTable.id, userId: themeTable.userId, name: themeTable.name, styles: themeTable.styles, createdAt: themeTable.createdAt, updatedAt: themeTable.updatedAt, isPublished: sql`${communityTheme.id} is not null`.as( "is_published" ), }) .from(themeTable) .leftJoin(communityTheme, eq(themeTable.id, communityTheme.themeId)) .where(eq(themeTable.userId, userId)); return userThemes; } catch (error) { logError(error as Error, { action: "getThemes" }); throw error; } } export const getTheme = cache(async (themeId: string) => { try { if (!themeId) { throw new ValidationError("Theme ID required"); } const [theme] = await db.select().from(themeTable).where(eq(themeTable.id, themeId)).limit(1); if (!theme) { throw new ThemeNotFoundError(); } return theme; } catch (error) { logError(error as Error, { action: "getTheme", themeId }); throw error; } }); export async function createTheme(formData: { name: string; styles: ThemeStyles }) { try { const userId = await getCurrentUserId(); const validation = createThemeSchema.safeParse(formData); if (!validation.success) { throw new ValidationError("Invalid input", validation.error.format()); } // Check theme limit const userThemes = await db.select().from(themeTable).where(eq(themeTable.userId, userId)); if (userThemes.length >= MAX_FREE_THEMES) { const activeSubscription = await getMyActiveSubscription(userId); const isSubscribed = !!activeSubscription && activeSubscription?.productId === process.env.NEXT_PUBLIC_TWEAKCN_PRO_PRODUCT_ID; if (!isSubscribed) { return actionError( ErrorCode.THEME_LIMIT_REACHED, `You have reached the limit of ${MAX_FREE_THEMES} themes.` ); } } const { name, styles } = validation.data; const newThemeId = cuid(); const now = new Date(); const [insertedTheme] = await db .insert(themeTable) .values({ id: newThemeId, userId: userId, name: name, styles: styles, createdAt: now, updatedAt: now, }) .returning(); return actionSuccess(insertedTheme); } catch (error) { logError(error as Error, { action: "createTheme", formData: { name: formData.name } }); throw error; } } export async function updateTheme(formData: { id: string; name?: string; styles?: ThemeStyles }) { try { const userId = await getCurrentUserId(); const validation = updateThemeSchema.safeParse(formData); if (!validation.success) { throw new ValidationError("Invalid input", validation.error.format()); } const { id: themeId, name, styles } = validation.data; if (!name && !styles) { throw new ValidationError("No update data provided"); } const updateData: Partial = { updatedAt: new Date(), }; if (name) updateData.name = name; if (styles) updateData.styles = styles; const [updatedTheme] = await db .update(themeTable) .set(updateData) .where(and(eq(themeTable.id, themeId), eq(themeTable.userId, userId))) .returning(); if (!updatedTheme) { throw new ThemeNotFoundError("Theme not found or not owned by user"); } return updatedTheme; } catch (error) { logError(error as Error, { action: "updateTheme", themeId: formData.id }); throw error; } } export async function deleteTheme(themeId: string) { try { const userId = await getCurrentUserId(); if (!themeId) { throw new ValidationError("Theme ID required"); } const [deletedTheme] = await db .delete(themeTable) .where(and(eq(themeTable.id, themeId), eq(themeTable.userId, userId))) .returning({ id: themeTable.id, name: themeTable.name }); if (!deletedTheme) { throw new ThemeNotFoundError("Theme not found or not owned by user"); } return deletedTheme; } catch (error) { logError(error as Error, { action: "deleteTheme", themeId }); throw error; } } ================================================ FILE: app/(auth)/components/auth-dialog.tsx ================================================ "use client"; import Github from "@/assets/github.svg"; import Google from "@/assets/google.svg"; import { Button } from "@/components/ui/button"; import { ResponsiveDialog, ResponsiveDialogContent, ResponsiveDialogHeader, ResponsiveDialogTitle, ResponsiveDialogTrigger, } from "@/components/ui/revola"; import { PostLoginActionType } from "@/hooks/use-post-login-action"; import { authClient } from "@/lib/auth-client"; import { Loader2 } from "lucide-react"; import { usePathname, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; interface AuthDialogProps { open: boolean; onOpenChange: (open: boolean) => void; initialMode?: "signin" | "signup"; trigger?: React.ReactNode; // Optional trigger element postLoginActionType?: PostLoginActionType | null; } // Get contextual copy based on the post-login action function getContextualCopy(actionType?: PostLoginActionType | null) { switch (actionType) { case "SAVE_THEME": return { title: "Sign in to Save", description: "Sign in to save your theme and access it from anywhere", }; case "SAVE_THEME_FOR_SHARE": return { title: "Sign in to Share", description: "Sign in to save and share your theme with others", }; case "SAVE_THEME_FOR_V0": return { title: "Sign in to open in v0", description: "Sign in to save your theme and open it in v0", }; case "AI_GENERATE_FROM_PAGE": case "AI_GENERATE_FROM_CHAT": case "AI_GENERATE_FROM_CHAT_SUGGESTION": case "AI_GENERATE_EDIT": case "AI_GENERATE_RETRY": return { title: "Sign in for AI", description: "Sign in to use AI-powered theme generation", }; case "CHECKOUT": return { title: "Sign in to continue", description: "Sign in to complete your purchase", }; default: return null; } } export function AuthDialog({ open, onOpenChange, initialMode = "signin", trigger, postLoginActionType, }: AuthDialogProps) { const pathname = usePathname(); const searchParams = useSearchParams(); const [isSignIn, setIsSignIn] = useState(initialMode === "signin"); const [isGoogleLoading, setIsGoogleLoading] = useState(false); const [isGithubLoading, setIsGithubLoading] = useState(false); const contextualCopy = getContextualCopy(postLoginActionType); const getCallbackUrl = () => { const baseUrl = pathname || "/editor/theme"; const queryString = searchParams.toString(); return queryString ? `${baseUrl}?${queryString}` : baseUrl; }; useEffect(() => { if (open) { setIsSignIn(initialMode === "signin"); } }, [open, initialMode]); const handleGoogleSignIn = async () => { setIsGoogleLoading(true); try { await authClient.signIn.social({ provider: "google", callbackURL: getCallbackUrl(), }); } catch (error) { console.error("Google Sign In Error:", error); // Handle error appropriately (e.g., show a toast notification) } }; const handleGithubSignIn = async () => { setIsGithubLoading(true); try { await authClient.signIn.social({ provider: "github", callbackURL: getCallbackUrl(), }); } catch (error) { console.error("GitHub Sign In Error:", error); // Handle error appropriately } }; const toggleMode = () => { setIsSignIn(!isSignIn); }; return ( {trigger && {trigger}}
{contextualCopy?.title ?? (isSignIn ? "Welcome back" : "Create account")}

{contextualCopy?.description ?? (isSignIn ? "Sign in to your account to continue" : "Sign up to get started with tweakcn")}

{isSignIn ? "New to tweakcn?" : "Already have an account?"}
); } ================================================ FILE: app/(legal)/layout.tsx ================================================ import React from "react"; import { Header } from "@/components/header"; import { Footer } from "@/components/footer"; interface LegalLayoutProps { children: React.ReactNode; } export default function LegalLayout({ children }: LegalLayoutProps) { return (
{children}
); } ================================================ FILE: app/(legal)/privacy-policy/page.tsx ================================================ import { Metadata } from "next"; export const metadata: Metadata = { title: "Privacy Policy | tweakcn", description: "Privacy Policy for tweakcn.", }; export default function PrivacyPolicyPage() { return (

Privacy Policy

Last Updated: 24 Apr 2025

1. Introduction

We value your privacy and are committed to safeguarding your personal data. This privacy policy explains how we collect, use, and protect your information when you use our website, as well as your privacy rights and how they are protected by law.

2. Data Collection

When you use our website, we collect and process the following types of data:

  • Web Analytics: Anonymous user data is collecting using PostHog.
  • Authentication Data: When you sign up, we collect necessary information such as your email address.

3. Sharing and Transferring Your Data

We do not sell, lease, or trade your personal information. However, we may share your data with trusted third parties like to process payments or provide other services on our behalf. We ensure that all third-party providers we work with adhere to data protection standards, in compliance with relevant laws such as the Information Technology Act 2000 and Digital Personal Data Protection Act 2023 under Indian law or any other applicable laws.

4. Data Security

We have implemented suitable technical and organizational measures as per the level of risk to protect your personal data from unauthorized access, loss, or misuse.

5. Data Protection

You have the following rights concerning your personal data:

  • Access: You have the right to request a copy of the personal data we hold about you.
  • Correction: If any of your data is incorrect or incomplete, you can request to have it updated.
  • Erasure: You can request that we delete your personal data, subject to applicable legal exceptions.
  • Objection: You can object to the processing of your personal data for certain purposes.
  • Restriction: You can ask us to restrict the use of your data under certain conditions.
  • Data Portability: You have the right to transfer your personal data to another service provider, if applicable.
  • Withdrawal of Consent: You can withdraw your consent to process your personal data at any time.

6. Cookies

Our website uses essential cookies necessary for user authentication and session management. These cookies help maintain your login status and ensure secure access to your account. We do not use cookies for tracking or advertising purposes.

7. Modifications to This Privacy Policy

We may update this privacy policy occasionally. Any changes will be posted here, and the effective date will be updated at this page. It shall be assumed that you are aware of any changes and accept the same.

8. Contact Us

If you have any questions or concerns about this privacy policy, please reach out at{" "} sahaj@tweakcn.com

By continuing to use this website, you confirm that you have read and understood this Privacy Policy.

); } ================================================ FILE: app/ai/components/ai-announcement.tsx ================================================ "use client"; import { useSubscription } from "@/hooks/use-subscription"; import { ArrowRight } from "lucide-react"; import Link from "next/link"; export function AIAnnouncement() { const { subscriptionStatus, isPending } = useSubscription(); const isPro = subscriptionStatus?.isSubscribed ?? false; if (isPending || isPro) { return null; } return (
Upgrade to Pro for unlimited requests
); } ================================================ FILE: app/ai/components/ai-chat-form.tsx ================================================ "use client"; import { AIChatFormBody } from "@/components/editor/ai/ai-chat-form-body"; import { AlertBanner } from "@/components/editor/ai/alert-banner"; import { EnhancePromptButton } from "@/components/editor/ai/enhance-prompt-button"; import { ImageUploader } from "@/components/editor/ai/image-uploader"; import ThemePresetSelect from "@/components/editor/theme-preset-select"; import { Button } from "@/components/ui/button"; import { useAIChatForm } from "@/hooks/use-ai-chat-form"; import { useAIEnhancePrompt } from "@/hooks/use-ai-enhance-prompt"; import { useGuards } from "@/hooks/use-guards"; import { useSubscription } from "@/hooks/use-subscription"; import { MAX_IMAGE_FILES } from "@/lib/constants"; import { cn } from "@/lib/utils"; import { AIPromptData } from "@/types/ai"; import { ArrowUp, Loader, StopCircle } from "lucide-react"; export function AIChatForm({ onThemeGeneration, isGeneratingTheme, onCancelThemeGeneration, }: { onThemeGeneration: (promptData: AIPromptData) => void; isGeneratingTheme: boolean; onCancelThemeGeneration: () => void; }) { const { editorContentDraft, handleContentChange, promptData, isEmptyPrompt, clearLocalDraft, uploadedImages, fileInputRef, handleImagesUpload, handleImageRemove, isSomeImageUploading, isUserDragging, isInitializing, } = useAIChatForm(); const { checkValidSession, checkValidSubscription } = useGuards(); const { subscriptionStatus } = useSubscription(); const isPro = subscriptionStatus?.isSubscribed ?? false; const hasFreeRequestsLeft = (subscriptionStatus?.requestsRemaining ?? 0) > 0; const { startEnhance, stopEnhance, enhancedPromptAsJsonContent, isEnhancingPrompt } = useAIEnhancePrompt(); const handleEnhancePrompt = () => { if (!checkValidSession() || !checkValidSubscription()) return; // Only send images that are not loading, and strip loading property const images = uploadedImages.filter((img) => !img.loading).map(({ url }) => ({ url })); startEnhance({ ...promptData, images }); }; const handleGenerate = async () => { if (!checkValidSession() || !checkValidSubscription()) return; // Act as an early return // Only send images that are not loading, and strip loading property const images = uploadedImages.filter((img) => !img.loading).map(({ url }) => ({ url })); // Proceed only if there is text, or at least one image if (isEmptyPrompt && images.length === 0) return; onThemeGeneration({ ...promptData, content: promptData?.content ?? "", mentions: promptData?.mentions ?? [], images, }); clearLocalDraft(); }; return (
{(isPro || hasFreeRequestsLeft) && promptData?.content ? ( ) : null} fileInputRef.current?.click()} disabled={ isGeneratingTheme || isEnhancingPrompt || isInitializing || uploadedImages.some((img) => img.loading) || uploadedImages.length >= MAX_IMAGE_FILES } /> {isGeneratingTheme ? ( ) : ( )}
); } ================================================ FILE: app/ai/components/ai-chat-hero.tsx ================================================ "use client"; import { HorizontalScrollArea } from "@/components/horizontal-scroll-area"; import { useChatContext } from "@/hooks/use-chat-context"; import { useAIThemeGenerationCore } from "@/hooks/use-ai-theme-generation-core"; import { useGuards } from "@/hooks/use-guards"; import { usePostLoginAction } from "@/hooks/use-post-login-action"; import { usePreferencesStore } from "@/store/preferences-store"; import { AIPromptData } from "@/types/ai"; import { useRouter } from "next/navigation"; import { AIChatForm } from "./ai-chat-form"; import { ChatHeading } from "./chat-heading"; import { SuggestedPillActions } from "./suggested-pill-actions"; export function AIChatHero() { const { startNewChat } = useChatContext(); const { generateThemeCore, isGeneratingTheme, cancelThemeGeneration } = useAIThemeGenerationCore(); const { checkValidSession, checkValidSubscription } = useGuards(); const router = useRouter(); const { setChatSuggestionsOpen } = usePreferencesStore(); const handleRedirectAndThemeGeneration = (promptData: AIPromptData) => { if (!checkValidSession("signup", "AI_GENERATE_FROM_PAGE", { promptData })) return; if (!checkValidSubscription()) return; startNewChat(); setChatSuggestionsOpen(true); generateThemeCore(promptData); router.push("/editor/theme?tab=ai"); }; usePostLoginAction("AI_GENERATE_FROM_PAGE", ({ promptData }) => { handleRedirectAndThemeGeneration(promptData); }); return (
{/* Chat form input and suggestions */}
{/* Quick suggestions */}
); } ================================================ FILE: app/ai/components/chat-heading.tsx ================================================ export function ChatHeading({ isGeneratingTheme }: { isGeneratingTheme: boolean }) { return (

What can I help you theme?

); } ================================================ FILE: app/ai/components/community-theme-card.tsx ================================================ "use client"; import Logo from "@/assets/logo.svg"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { useEditorStore } from "@/store/editor-store"; import { Theme, ThemePreset } from "@/types/theme"; import { Moon, MoreVertical, Sun } from "lucide-react"; // This is repeating from `dashboard/components/theme-card.tsx` type SwatchDefinition = { name: string; // Text to display on hover bgKey: keyof Theme["styles"]["light" | "dark"]; // Key for background color fgKey: keyof Theme["styles"]["light" | "dark"]; // Key for text color }; // This is repeating from `dashboard/components/theme-card.tsx` const swatchDefinitions: SwatchDefinition[] = [ { name: "Primary", bgKey: "primary", fgKey: "primary-foreground" }, { name: "Secondary", bgKey: "secondary", fgKey: "secondary-foreground" }, { name: "Accent", bgKey: "accent", fgKey: "accent-foreground" }, { name: "Muted", bgKey: "muted", fgKey: "muted-foreground" }, // Special case: Background swatch shows "Foreground" text using the main foreground color { name: "Background", bgKey: "background", fgKey: "foreground" }, ]; export function CommunityThemeCard({ themePreset }: { themePreset: ThemePreset }) { const { themeState } = useEditorStore(); const mode = themeState.currentMode; return (
{/* TEMPORARY CARD IMPLEMENTATION: Based on `dashboard/components/theme-card.tsx` */}
{/* Light mode swatches */}
{swatchDefinitions.map((swatch) => (
))}
{/* Dark mode swatches */}
{swatchDefinitions.map((swatch) => (
))}

{themePreset.label}

{themePreset.createdAt ?? "Unknown creation date"}

); } export function CommunityThemeCardSkeleton() { return (
); } ================================================ FILE: app/ai/components/community-themes.tsx ================================================ import { Button } from "@/components/ui/button"; import { ChevronRight } from "lucide-react"; import { Suspense } from "react"; import { CommunityThemeCard, CommunityThemeCardSkeleton } from "./community-theme-card"; import { ThemePreset } from "@/types/theme"; import { defaultPresets } from "@/utils/theme-presets"; // TODO: Remove this once we have a real API to fetch the community themes const getDefaultThemePresets = async () => { await new Promise((resolve) => setTimeout(resolve, 300)); return defaultPresets; }; export async function CommunityThemes() { const themePresetsPromise = getDefaultThemePresets(); return ( <>

From the Community

Explore the themes the community is creating with tweakcn.

} >
); } interface CommunityThemeCardsProps { themePresetsPromise: Promise>; } export async function CommunityThemeCards({ themePresetsPromise }: CommunityThemeCardsProps) { const themePresets = await themePresetsPromise; const presets = Object.entries(themePresets).reduce( (acc, [id, preset]) => { acc[id] = { label: preset.label, styles: preset.styles, }; return acc; }, {} as Record ); return ( <> {Object.values(presets).map((preset) => ( ))} ); } ================================================ FILE: app/ai/components/suggested-pill-actions.tsx ================================================ "use client"; import { PillActionButton } from "@/components/editor/ai/pill-action-button"; import { useImageUpload } from "@/hooks/use-image-upload"; import { imageUploadReducer } from "@/hooks/use-image-upload-reducer"; import { MAX_IMAGE_FILE_SIZE } from "@/lib/constants"; import { AIPromptData } from "@/types/ai"; import { createCurrentThemePrompt } from "@/utils/ai/ai-prompt"; import { PROMPTS } from "@/utils/ai/prompts"; import { ImageIcon, Sparkles } from "lucide-react"; import { useEffect, useReducer } from "react"; export function SuggestedPillActions({ onThemeGeneration, isGeneratingTheme, }: { onThemeGeneration: (promptData: AIPromptData) => void; isGeneratingTheme: boolean; }) { const [uploadedImages, dispatch] = useReducer(imageUploadReducer, []); const { fileInputRef, handleImagesUpload, canUploadMore, isSomeImageUploading } = useImageUpload({ maxFiles: 1, maxFileSize: MAX_IMAGE_FILE_SIZE, images: uploadedImages, dispatch, }); // Automatically send prompt when an image is selected and loaded useEffect(() => { if (uploadedImages.length > 0 && !isSomeImageUploading) { onThemeGeneration({ content: "", // No text prompt mentions: [], // No mentions images: [uploadedImages[0]], }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [uploadedImages, isSomeImageUploading]); const handleSetPrompt = async (prompt: string) => { const promptData = createCurrentThemePrompt({ prompt }); onThemeGeneration(promptData); }; const handleImageButtonClick = () => { if (canUploadMore && fileInputRef.current) { fileInputRef.current.click(); } }; const handleImageUpload = (event: React.ChangeEvent) => { const fileList = event.target.files; if (!fileList) return; const files = Array.from(fileList); handleImagesUpload(files); }; return ( <> From an Image {Object.entries(PROMPTS).map(([key, { label, prompt }]) => ( handleSetPrompt(prompt)} disabled={isGeneratingTheme} > {label} ))} ); } ================================================ FILE: app/ai/layout.tsx ================================================ import { Header } from "@/components/header"; export default function AiLayout({ children }: { children: React.ReactNode }) { return (
{children}
); } ================================================ FILE: app/ai/loading.tsx ================================================ import { Loading } from "@/components/loading"; export default function AiLoading() { return ; } ================================================ FILE: app/ai/page.tsx ================================================ import { type Metadata } from "next"; import { AIAnnouncement } from "./components/ai-announcement"; import { AIChatHero } from "./components/ai-chat-hero"; export const metadata: Metadata = { title: "Image to shadcn/ui theme. Generate with AI — tweakcn", description: "Transform images into stunning shadcn/ui themes instantly with tweakcn's AI theme generator. Upload any image or describe your vision—our AI creates custom Tailwind CSS themes with real-time preview. Perfect for developers who want beautiful, production-ready themes in seconds.", keywords: "ai theme generator, image to theme, shadcn/ui themes, tailwind css generator, ai design tool, theme from image, ui customization, tweakcn, visual theme creator, color palette generator, design system ai, frontend theming, web design automation", robots: "index, follow", }; export default function AiPage() { return (
{/* AI Chat entry point section */}
); } ================================================ FILE: app/api/auth/[...all]/route.ts ================================================ import { auth } from "@/lib/auth"; import { toNextJsHandler } from "better-auth/next-js"; export const { GET, POST } = toNextJsHandler(auth.handler); ================================================ FILE: app/api/enhance-prompt/route.ts ================================================ import { ENHANCE_PROMPT_SYSTEM } from "@/lib/ai/prompts"; import { baseProviderOptions, myProvider } from "@/lib/ai/providers"; import { handleError } from "@/lib/error-response"; import { requireSubscriptionOrFreeUsage } from "@/lib/subscription"; import { AIPromptData } from "@/types/ai"; import { buildUserContentPartsFromPromptData } from "@/utils/ai/message-converter"; import { smoothStream, streamText } from "ai"; import { NextRequest } from "next/server"; export async function POST(req: NextRequest) { try { await requireSubscriptionOrFreeUsage(req); const body = await req.json(); const { prompt: _prompt, promptData }: { prompt: string; promptData: AIPromptData } = body; const userContentParts = buildUserContentPartsFromPromptData(promptData); const result = streamText({ system: ENHANCE_PROMPT_SYSTEM, messages: [ { role: "user", content: userContentParts, }, ], model: myProvider.languageModel("prompt-enhancement"), providerOptions: baseProviderOptions, experimental_transform: smoothStream({ delayInMs: 10, chunking: "word", }), }); return result.toUIMessageStreamResponse(); } catch (error) { return handleError(error, { route: "/api/enhance-prompt" }); } } ================================================ FILE: app/api/generate-theme/route.ts ================================================ import { recordAIUsage } from "@/actions/ai-usage"; import { THEME_GENERATION_TOOLS } from "@/lib/ai/generate-theme/tools"; import { GENERATE_THEME_SYSTEM } from "@/lib/ai/prompts"; import { baseProviderOptions, myProvider } from "@/lib/ai/providers"; import { handleError } from "@/lib/error-response"; import { getCurrentUserId, logError } from "@/lib/shared"; import { validateSubscriptionAndUsage } from "@/lib/subscription"; import { AdditionalAIContext, ChatMessage } from "@/types/ai"; import { SubscriptionRequiredError } from "@/types/errors"; import { convertMessagesToModelMessages } from "@/utils/ai/message-converter"; import { Ratelimit } from "@upstash/ratelimit"; import { kv } from "@vercel/kv"; import { createUIMessageStream, createUIMessageStreamResponse, stepCountIs, streamText } from "ai"; import { headers } from "next/headers"; import { NextRequest } from "next/server"; const ratelimit = new Ratelimit({ redis: kv, limiter: Ratelimit.fixedWindow(5, "60s"), }); export async function POST(req: NextRequest) { try { const userId = await getCurrentUserId(req); const headersList = await headers(); if (process.env.NODE_ENV !== "development") { const ip = headersList.get("x-forwarded-for") ?? "anonymous"; const { success, limit, reset, remaining } = await ratelimit.limit(ip); if (!success) { return new Response("Rate limit exceeded. Please try again later.", { status: 429, headers: { "X-RateLimit-Limit": limit.toString(), "X-RateLimit-Remaining": remaining.toString(), "X-RateLimit-Reset": reset.toString(), }, }); } } const subscriptionCheck = await validateSubscriptionAndUsage(userId); if (!subscriptionCheck.canProceed) { throw new SubscriptionRequiredError(subscriptionCheck.error, { requestsRemaining: subscriptionCheck.requestsRemaining, }); } const { messages }: { messages: ChatMessage[] } = await req.json(); const modelMessages = await convertMessagesToModelMessages(messages); const stream = createUIMessageStream({ execute: ({ writer }) => { const context: AdditionalAIContext = { writer }; const model = myProvider.languageModel("base"); const result = streamText({ abortSignal: req.signal, model: model, providerOptions: baseProviderOptions, system: GENERATE_THEME_SYSTEM, messages: modelMessages, tools: THEME_GENERATION_TOOLS, stopWhen: stepCountIs(5), onError: (error) => { if (error instanceof Error) console.error(error); }, onFinish: async (result) => { const { totalUsage } = result; try { await recordAIUsage({ modelId: model.modelId, promptTokens: totalUsage.inputTokens, completionTokens: totalUsage.outputTokens, }); } catch (error) { logError(error as Error, { action: "recordAIUsage", totalUsage }); } }, experimental_context: context, }); writer.merge( result.toUIMessageStream({ messageMetadata: ({ part }) => { // `toolName` is not typed for some reason, must be kept in sync with the actual tool names if (part.type === "tool-result" && part.toolName === "generateTheme") { return { themeStyles: part.output }; } }, }) ); }, }); return createUIMessageStreamResponse({ stream }); } catch (error) { if ( error instanceof Error && (error.name === "AbortError" || error.name === "ResponseAborted") ) { return new Response("Request aborted by user", { status: 499 }); } return handleError(error, { route: "/api/generate-theme" }); } } ================================================ FILE: app/api/google-fonts/route.ts ================================================ import { PaginatedFontsResponse } from "@/types/fonts"; import { FALLBACK_FONTS } from "@/utils/fonts"; import { fetchGoogleFonts } from "@/utils/fonts/google-fonts"; import { unstable_cache } from "next/cache"; import { NextRequest, NextResponse } from "next/server"; const cachedFetchGoogleFonts = unstable_cache(fetchGoogleFonts, ["google-fonts-catalogue"], { tags: ["google-fonts-catalogue"], }); export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); const query = searchParams.get("q")?.toLowerCase() || ""; const category = searchParams.get("category")?.toLowerCase(); const limit = Math.min(Number(searchParams.get("limit")) || 50, 100); const offset = Number(searchParams.get("offset")) || 0; let googleFonts = FALLBACK_FONTS; try { googleFonts = await cachedFetchGoogleFonts(process.env.GOOGLE_FONTS_API_KEY); } catch (error) { console.error("Error fetching Google Fonts:", error); console.log("Using fallback fonts"); } // Filter fonts based on search query and category let filteredFonts = googleFonts; if (query) { filteredFonts = filteredFonts.filter((font) => font.family.toLowerCase().includes(query)); } if (category && category !== "all") { filteredFonts = filteredFonts.filter((font) => font.category === category); } const paginatedFonts = filteredFonts.slice(offset, offset + limit); const response: PaginatedFontsResponse = { fonts: paginatedFonts, total: filteredFonts.length, offset, limit, hasMore: offset + limit < filteredFonts.length, }; return NextResponse.json(response); } catch (error) { console.error("Error in Google Fonts API:", error); return NextResponse.json({ error: "Failed to fetch fonts" }, { status: 500 }); } } ================================================ FILE: app/api/oauth/app-info/route.ts ================================================ import { db } from "@/db"; import { oauthApp } from "@/db/schema"; import { oauthError } from "@/lib/oauth"; import { eq, and } from "drizzle-orm"; import { NextRequest } from "next/server"; export async function GET(req: NextRequest) { const clientId = req.nextUrl.searchParams.get("client_id"); if (!clientId) { return oauthError("invalid_request", "Missing client_id"); } const [app] = await db .select({ name: oauthApp.name, description: oauthApp.description }) .from(oauthApp) .where(and(eq(oauthApp.clientId, clientId), eq(oauthApp.isActive, true))) .limit(1); if (!app) { return oauthError("invalid_client", "Unknown client_id"); } return Response.json({ name: app.name, description: app.description }); } ================================================ FILE: app/api/oauth/authorize/route.ts ================================================ import { db } from "@/db"; import { oauthApp, oauthAuthorizationCode } from "@/db/schema"; import { OAUTH_AUTHORIZATION_CODE_EXPIRY_SECONDS } from "@/lib/constants"; import { generateSecureToken, oauthError, parseScopes, validateRedirectUri, validateScopes, } from "@/lib/oauth"; import { auth } from "@/lib/auth"; import { eq, and } from "drizzle-orm"; import { headers } from "next/headers"; import cuid from "cuid"; import { NextRequest } from "next/server"; export async function GET(req: NextRequest) { const params = req.nextUrl.searchParams; const clientId = params.get("client_id"); const redirectUri = params.get("redirect_uri"); const responseType = params.get("response_type"); const scopeParam = params.get("scope"); const state = params.get("state"); const codeChallenge = params.get("code_challenge"); const codeChallengeMethod = params.get("code_challenge_method") ?? "S256"; // Validate required params if (!clientId || !redirectUri || !responseType || !scopeParam) { return oauthError( "invalid_request", "Missing required parameters: client_id, redirect_uri, response_type, scope" ); } if (responseType !== "code") { return oauthError( "unsupported_response_type", "Only response_type=code is supported" ); } // Look up OAuth app const [app] = await db .select({ id: oauthApp.id, redirectUris: oauthApp.redirectUris }) .from(oauthApp) .where(and(eq(oauthApp.clientId, clientId), eq(oauthApp.isActive, true))) .limit(1); if (!app) { return oauthError("invalid_client", "Unknown client_id"); } // Validate redirect URI if (!validateRedirectUri(redirectUri, app.redirectUris)) { return oauthError("invalid_request", "redirect_uri not registered"); } // Validate scopes const scopes = parseScopes(scopeParam); if (!validateScopes(scopes)) { return oauthError("invalid_scope", "Invalid or unsupported scope"); } // Check that the user is logged in const session = await auth.api.getSession({ headers: await headers() }); if (!session?.user?.id) { // Redirect to the OAuth authorize page which handles sign-in const pageUrl = new URL("/oauth/authorize", req.nextUrl.origin); req.nextUrl.searchParams.forEach((value, key) => { pageUrl.searchParams.set(key, value); }); return Response.redirect(pageUrl.toString(), 302); } // Generate authorization code const code = generateSecureToken(); const now = new Date(); await db.insert(oauthAuthorizationCode).values({ id: cuid(), code, appId: app.id, userId: session.user.id, scopes, redirectUri, codeChallenge: codeChallenge ?? null, codeChallengeMethod: codeChallenge ? codeChallengeMethod : null, expiresAt: new Date( now.getTime() + OAUTH_AUTHORIZATION_CODE_EXPIRY_SECONDS * 1000 ), createdAt: now, }); // Redirect back to the app with the code const redirectUrl = new URL(redirectUri); redirectUrl.searchParams.set("code", code); if (state) redirectUrl.searchParams.set("state", state); return Response.redirect(redirectUrl.toString(), 302); } ================================================ FILE: app/api/oauth/revoke/route.ts ================================================ import { db } from "@/db"; import { oauthToken } from "@/db/schema"; import { hashToken, oauthError } from "@/lib/oauth"; import { eq, or } from "drizzle-orm"; import { NextRequest } from "next/server"; export async function POST(req: NextRequest) { const body = await req.formData().catch(() => null); if (!body) { return oauthError("invalid_request", "Request body must be form-encoded"); } const token = body.get("token") as string | null; if (!token) { return oauthError("invalid_request", "Missing required parameter: token"); } const tokenHash = hashToken(token); // Try to match as access token or refresh token const [record] = await db .select({ id: oauthToken.id }) .from(oauthToken) .where( or( eq(oauthToken.accessTokenHash, tokenHash), eq(oauthToken.refreshTokenHash, tokenHash) ) ) .limit(1); if (record) { await db .update(oauthToken) .set({ revokedAt: new Date(), updatedAt: new Date() }) .where(eq(oauthToken.id, record.id)); } // RFC 7009: always return 200 even if token not found return new Response(null, { status: 200 }); } ================================================ FILE: app/api/oauth/token/route.ts ================================================ import { db } from "@/db"; import { oauthAuthorizationCode, oauthToken } from "@/db/schema"; import { authenticateClient, createTokenPair, hashToken, oauthError, verifyCodeChallenge, } from "@/lib/oauth"; import { eq, and, isNull } from "drizzle-orm"; import { NextRequest } from "next/server"; export async function POST(req: NextRequest) { const body = await req.formData().catch(() => null); if (!body) { return oauthError("invalid_request", "Request body must be form-encoded"); } const grantType = body.get("grant_type") as string | null; if (grantType === "authorization_code") { return handleAuthorizationCode(body); } if (grantType === "refresh_token") { return handleRefreshToken(body); } return oauthError("unsupported_grant_type", "Supported: authorization_code, refresh_token"); } async function handleAuthorizationCode(body: FormData) { const clientId = body.get("client_id") as string | null; const clientSecret = body.get("client_secret") as string | null; const code = body.get("code") as string | null; const redirectUri = body.get("redirect_uri") as string | null; const codeVerifier = body.get("code_verifier") as string | null; if (!clientId || !clientSecret || !code || !redirectUri) { return oauthError( "invalid_request", "Missing required parameters: client_id, client_secret, code, redirect_uri" ); } // Authenticate the client const app = await authenticateClient(clientId, clientSecret); if (!app) { return oauthError("invalid_client", "Invalid client credentials", 401); } // Look up the authorization code const [authCode] = await db .select({ id: oauthAuthorizationCode.id, expiresAt: oauthAuthorizationCode.expiresAt, redirectUri: oauthAuthorizationCode.redirectUri, codeChallenge: oauthAuthorizationCode.codeChallenge, codeChallengeMethod: oauthAuthorizationCode.codeChallengeMethod, userId: oauthAuthorizationCode.userId, scopes: oauthAuthorizationCode.scopes, }) .from(oauthAuthorizationCode) .where( and( eq(oauthAuthorizationCode.code, code), eq(oauthAuthorizationCode.appId, app.id), isNull(oauthAuthorizationCode.usedAt) ) ) .limit(1); if (!authCode) { return oauthError("invalid_grant", "Invalid or already used authorization code"); } // Check expiry if (new Date() > authCode.expiresAt) { return oauthError("invalid_grant", "Authorization code expired"); } // Check redirect URI matches if (authCode.redirectUri !== redirectUri) { return oauthError("invalid_grant", "redirect_uri mismatch"); } // Verify PKCE if code challenge was provided during authorization if (authCode.codeChallenge) { if (!codeVerifier) { return oauthError("invalid_request", "code_verifier required for PKCE"); } if ( !verifyCodeChallenge( codeVerifier, authCode.codeChallenge, authCode.codeChallengeMethod ?? "S256" ) ) { return oauthError("invalid_grant", "PKCE verification failed"); } } // Mark code as used await db .update(oauthAuthorizationCode) .set({ usedAt: new Date() }) .where(eq(oauthAuthorizationCode.id, authCode.id)); // Create tokens const tokenResponse = await createTokenPair( app.id, authCode.userId, authCode.scopes ); return Response.json(tokenResponse); } async function handleRefreshToken(body: FormData) { const clientId = body.get("client_id") as string | null; const clientSecret = body.get("client_secret") as string | null; const refreshToken = body.get("refresh_token") as string | null; if (!clientId || !clientSecret || !refreshToken) { return oauthError( "invalid_request", "Missing required parameters: client_id, client_secret, refresh_token" ); } const app = await authenticateClient(clientId, clientSecret); if (!app) { return oauthError("invalid_client", "Invalid client credentials", 401); } // Look up the refresh token const refreshTokenHash = hashToken(refreshToken); const [tokenRecord] = await db .select({ id: oauthToken.id, userId: oauthToken.userId, scopes: oauthToken.scopes, refreshTokenExpiresAt: oauthToken.refreshTokenExpiresAt, }) .from(oauthToken) .where( and( eq(oauthToken.refreshTokenHash, refreshTokenHash), eq(oauthToken.appId, app.id), isNull(oauthToken.revokedAt) ) ) .limit(1); if (!tokenRecord) { return oauthError("invalid_grant", "Invalid refresh token"); } if ( tokenRecord.refreshTokenExpiresAt && new Date() > tokenRecord.refreshTokenExpiresAt ) { return oauthError("invalid_grant", "Refresh token expired"); } // Revoke the old token pair await db .update(oauthToken) .set({ revokedAt: new Date(), updatedAt: new Date() }) .where(eq(oauthToken.id, tokenRecord.id)); // Issue new token pair const tokenResponse = await createTokenPair( app.id, tokenRecord.userId, tokenRecord.scopes ); return Response.json(tokenResponse); } ================================================ FILE: app/api/oauth/userinfo/route.ts ================================================ import { db } from "@/db"; import { user as userTable } from "@/db/schema"; import { oauthError, requireScope, resolveUserFromBearerToken } from "@/lib/oauth"; import { eq } from "drizzle-orm"; import { NextRequest } from "next/server"; /** * OpenID Connect-style userinfo endpoint. * Returns flat user fields for compatibility with generic OAuth clients * (e.g. Better Auth's genericOAuth plugin). */ export async function GET(req: NextRequest) { const tokenData = await resolveUserFromBearerToken( req.headers.get("authorization") ); if (!tokenData) { return oauthError("invalid_token", "Invalid or expired access token", 401); } if (!requireScope(tokenData.scopes, "profile:read")) { return oauthError("insufficient_scope", "Requires profile:read scope", 403); } const [profile] = await db .select({ id: userTable.id, name: userTable.name, email: userTable.email, image: userTable.image, }) .from(userTable) .where(eq(userTable.id, tokenData.userId)) .limit(1); if (!profile) { return oauthError("invalid_token", "User not found", 401); } return Response.json({ sub: profile.id, name: profile.name, email: profile.email, picture: profile.image, }); } ================================================ FILE: app/api/subscription/route.ts ================================================ import { getCurrentUserId, logError } from "@/lib/shared"; import { validateSubscriptionAndUsage } from "@/lib/subscription"; import { SubscriptionStatus } from "@/types/subscription"; import { NextRequest, NextResponse } from "next/server"; export async function GET(request: NextRequest) { try { const userId = await getCurrentUserId(request); const { isSubscribed, requestsRemaining, requestsUsed } = await validateSubscriptionAndUsage(userId); const response: SubscriptionStatus = { isSubscribed, requestsRemaining, requestsUsed, }; return NextResponse.json(response); } catch (error) { logError(error as Error); return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); } } ================================================ FILE: app/api/v1/me/route.ts ================================================ import { db } from "@/db"; import { user as userTable } from "@/db/schema"; import { oauthError, requireAuth } from "@/lib/oauth"; import { eq } from "drizzle-orm"; import { NextRequest } from "next/server"; export async function GET(req: NextRequest) { const auth = await requireAuth(req, "profile:read"); if (auth.error) return auth.error; const [profile] = await db .select({ id: userTable.id, name: userTable.name, email: userTable.email, image: userTable.image, }) .from(userTable) .where(eq(userTable.id, auth.tokenData.userId)) .limit(1); if (!profile) { return oauthError("invalid_token", "User not found", 401); } return Response.json({ data: profile }); } ================================================ FILE: app/api/v1/themes/[themeId]/route.ts ================================================ import { db } from "@/db"; import { theme as themeTable } from "@/db/schema"; import { oauthError, requireAuth } from "@/lib/oauth"; import { eq, and } from "drizzle-orm"; import { NextRequest } from "next/server"; export async function GET( req: NextRequest, { params }: { params: Promise<{ themeId: string }> } ) { const auth = await requireAuth(req, "themes:read"); if (auth.error) return auth.error; const { themeId } = await params; const [theme] = await db .select({ id: themeTable.id, name: themeTable.name, styles: themeTable.styles, createdAt: themeTable.createdAt, updatedAt: themeTable.updatedAt, }) .from(themeTable) .where( and(eq(themeTable.id, themeId), eq(themeTable.userId, auth.tokenData.userId)) ) .limit(1); if (!theme) { return oauthError("not_found", "Theme not found", 404); } return Response.json({ data: theme }); } ================================================ FILE: app/api/v1/themes/route.ts ================================================ import { db } from "@/db"; import { theme as themeTable } from "@/db/schema"; import { requireAuth } from "@/lib/oauth"; import { eq } from "drizzle-orm"; import { NextRequest } from "next/server"; export async function GET(req: NextRequest) { const auth = await requireAuth(req, "themes:read"); if (auth.error) return auth.error; const themes = await db .select({ id: themeTable.id, name: themeTable.name, styles: themeTable.styles, createdAt: themeTable.createdAt, updatedAt: themeTable.updatedAt, }) .from(themeTable) .where(eq(themeTable.userId, auth.tokenData.userId)); return Response.json({ data: themes }); } ================================================ FILE: app/api/webhook/polar/route.ts ================================================ import { db } from "@/db"; import { subscription } from "@/db/schema"; import { Webhooks } from "@polar-sh/nextjs"; function safeParseDate(value: string | Date | null | undefined): Date | null { if (!value) return null; if (value instanceof Date) return value; return new Date(value); } if (!process.env.POLAR_WEBHOOK_SECRET) { throw new Error("POLAR_WEBHOOK_SECRET environment variable is required"); } export const POST = Webhooks({ webhookSecret: process.env.POLAR_WEBHOOK_SECRET, onPayload: async ({ data, type }) => { if ( type === "subscription.created" || type === "subscription.active" || type === "subscription.canceled" || type === "subscription.revoked" || type === "subscription.uncanceled" || type === "subscription.updated" ) { console.log("🎯 Processing subscription webhook:", type); console.log("📦 Payload data:", JSON.stringify(data, null, 2)); try { const userId = data.customer?.externalId; const subscriptionData = { id: data.id, createdAt: new Date(data.createdAt), modifiedAt: safeParseDate(data.modifiedAt), amount: data.amount, currency: data.currency, recurringInterval: data.recurringInterval, status: data.status, currentPeriodStart: safeParseDate(data.currentPeriodStart) || new Date(), currentPeriodEnd: safeParseDate(data.currentPeriodEnd) || new Date(), cancelAtPeriodEnd: data.cancelAtPeriodEnd || false, canceledAt: safeParseDate(data.canceledAt), startedAt: safeParseDate(data.startedAt) || new Date(), endsAt: safeParseDate(data.endsAt), endedAt: safeParseDate(data.endedAt), customerId: data.customerId, productId: data.productId, discountId: data.discountId || null, checkoutId: data.checkoutId || "", customerCancellationReason: data.customerCancellationReason || null, customerCancellationComment: data.customerCancellationComment || null, metadata: data.metadata ? JSON.stringify(data.metadata) : null, customFieldData: data.customFieldData ? JSON.stringify(data.customFieldData) : null, userId: userId as string | null, }; console.log("💾 Final subscription data:", { id: subscriptionData.id, status: subscriptionData.status, userId: subscriptionData.userId, amount: subscriptionData.amount, }); await db .insert(subscription) .values(subscriptionData) .onConflictDoUpdate({ target: subscription.id, set: { modifiedAt: subscriptionData.modifiedAt || new Date(), amount: subscriptionData.amount, currency: subscriptionData.currency, recurringInterval: subscriptionData.recurringInterval, status: subscriptionData.status, currentPeriodStart: subscriptionData.currentPeriodStart, currentPeriodEnd: subscriptionData.currentPeriodEnd, cancelAtPeriodEnd: subscriptionData.cancelAtPeriodEnd, canceledAt: subscriptionData.canceledAt, startedAt: subscriptionData.startedAt, endsAt: subscriptionData.endsAt, endedAt: subscriptionData.endedAt, customerId: subscriptionData.customerId, productId: subscriptionData.productId, discountId: subscriptionData.discountId, checkoutId: subscriptionData.checkoutId, customerCancellationReason: subscriptionData.customerCancellationReason, customerCancellationComment: subscriptionData.customerCancellationComment, metadata: subscriptionData.metadata, customFieldData: subscriptionData.customFieldData, userId: subscriptionData.userId, }, }); console.log("🎉 Subscription data upserted successfully"); } catch (error) { console.error("❌ Error processing subscription webhook:", error); } } }, }); ================================================ FILE: app/community/components/community-sidebar.tsx ================================================ "use client"; import { cn } from "@/lib/utils"; import { useCommunityTagCounts } from "@/hooks/themes"; import { useSessionGuard } from "@/hooks/use-guards"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import type { CommunityFilterOption } from "@/types/community"; interface CommunitySidebarProps { filter: CommunityFilterOption; selectedTags: string[]; onFilterChange: (filter: CommunityFilterOption) => void; onTagToggle: (tag: string) => void; } const filterItems = [ { value: "all" as const, label: "All Themes" }, { value: "mine" as const, label: "My Themes" }, { value: "liked" as const, label: "Liked Themes" }, ]; export function CommunitySidebarContent({ filter, selectedTags, onFilterChange, onTagToggle, }: CommunitySidebarProps) { const { data: tagCounts = [], isLoading: isLoadingTags } = useCommunityTagCounts(); const { checkValidSession } = useSessionGuard(); const handleFilterClick = (value: CommunityFilterOption) => { if (value === "mine" || value === "liked") { if (!checkValidSession("signin")) return; } onFilterChange(value); }; return ( ); } ================================================ FILE: app/community/components/community-theme-card.tsx ================================================ "use client"; import { Badge } from "@/components/ui/badge"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { cn } from "@/lib/utils"; import { Heart } from "lucide-react"; import { useTheme } from "@/components/theme-provider"; import { useToggleLike } from "@/hooks/themes"; import { useSessionGuard } from "@/hooks/use-guards"; import { usePostLoginAction } from "@/hooks/use-post-login-action"; import { ThemePreview } from "@/components/theme-preview"; import type { CommunityTheme } from "@/types/community"; interface CommunityThemeCardProps { theme: CommunityTheme; onPreview: (theme: CommunityTheme) => void; } export function CommunityThemeCard({ theme, onPreview }: CommunityThemeCardProps) { const { theme: currentTheme } = useTheme(); const toggleLike = useToggleLike(); const { checkValidSession } = useSessionGuard(); usePostLoginAction("LIKE_THEME", (data?: { communityThemeId: string }) => { if (data?.communityThemeId === theme.id) { toggleLike.mutate(theme.id); } }); const handleLike = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if ( !checkValidSession("signin", "LIKE_THEME", { communityThemeId: theme.id, }) ) { return; } toggleLike.mutate(theme.id); }; const authorInitials = theme.author.name ?.split(" ") .map((n) => n[0]) .join("") .slice(0, 2) .toUpperCase(); const publishedDate = new Date(theme.publishedAt).toLocaleDateString( "en-US", { day: "numeric", month: "short" } ); return (
onPreview(theme)} className="group cursor-pointer" >
{theme.tags.length > 0 && (
{theme.tags.slice(0, 2).map((tag) => ( {tag} ))} {theme.tags.length > 2 && ( +{theme.tags.length - 2} )}
)}
{theme.author.image && ( )} {authorInitials} {theme.author.name}
{publishedDate}
); } ================================================ FILE: app/community/components/community-theme-preview-dialog.tsx ================================================ "use client"; import { lazy, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; import { cn } from "@/lib/utils"; import { useEditorStore } from "@/store/editor-store"; import { useToggleLike } from "@/hooks/themes"; import { useSessionGuard } from "@/hooks/use-guards"; import { usePostLoginAction } from "@/hooks/use-post-login-action"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { ScrollArea } from "@/components/ui/scroll-area"; import ExamplesPreviewContainer from "@/components/editor/theme-preview/examples-preview-container"; import { Heart, Moon, Sun, ArrowUpRight, ChevronLeft, ChevronRight, Share2 } from "lucide-react"; import { toast } from "@/components/ui/use-toast"; import type { CommunityTheme } from "@/types/community"; const DemoCards = lazy(() => import("@/components/examples/cards")); interface CommunityThemePreviewDialogProps { theme: CommunityTheme | null; themes: CommunityTheme[]; open: boolean; onOpenChange: (open: boolean) => void; onNavigate: (theme: CommunityTheme) => void; } export function CommunityThemePreviewDialog({ theme, themes, open, onOpenChange, onNavigate, }: CommunityThemePreviewDialogProps) { const router = useRouter(); const { themeState, setThemeState } = useEditorStore(); const toggleLike = useToggleLike(); const { checkValidSession } = useSessionGuard(); const currentIndex = theme ? themes.findIndex((t) => t.id === theme.id) : -1; const hasPrev = currentIndex > 0; const hasNext = currentIndex >= 0 && currentIndex < themes.length - 1; const goToPrev = useCallback(() => { if (hasPrev) onNavigate(themes[currentIndex - 1]); }, [hasPrev, themes, currentIndex, onNavigate]); const goToNext = useCallback(() => { if (hasNext) onNavigate(themes[currentIndex + 1]); }, [hasNext, themes, currentIndex, onNavigate]); useEffect(() => { if (!open) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowLeft") { e.preventDefault(); goToPrev(); } else if (e.key === "ArrowRight") { e.preventDefault(); goToNext(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [open, goToPrev, goToNext]); usePostLoginAction("LIKE_THEME", (data?: { communityThemeId: string }) => { if (theme && data?.communityThemeId === theme.id) { toggleLike.mutate(theme.id); } }); if (!theme) return null; const currentMode = themeState.currentMode; const handleToggleTheme = () => { setThemeState({ ...themeState, currentMode: currentMode === "light" ? "dark" : "light", }); }; const handleLike = () => { if ( !checkValidSession("signin", "LIKE_THEME", { communityThemeId: theme.id, }) ) { return; } toggleLike.mutate(theme.id); }; const handleViewDetails = () => { onOpenChange(false); router.push(`/themes/${theme.themeId}`); }; const handleShare = () => { const url = `https://tweakcn.com/themes/${theme.themeId}`; navigator.clipboard.writeText(url); toast({ title: "Theme URL copied to clipboard!", }); }; const authorInitials = theme.author.name ?.split(" ") .map((n) => n[0]) .join("") .slice(0, 2) .toUpperCase(); return ( {/* Navigation arrows — positioned outside the dialog box */} {hasPrev && ( )} {hasNext && ( )} {/* Inner content wrapper — clips content within dialog bounds */}
{/* Header: name + author */}
{theme.name} |
{theme.author.image && ( )} {authorInitials} {theme.author.name}
{/* Toolbar */}
Preview
{/* Card preview — scrollable */}
); } ================================================ FILE: app/community/components/community-themes-content.tsx ================================================ "use client"; import { useCommunityThemes } from "@/hooks/themes"; import { useEditorStore } from "@/store/editor-store"; import type { CommunityFilterOption, CommunityTimeRange, CommunityTheme, } from "@/types/community"; import { useQueryState, parseAsStringLiteral, parseAsArrayOf, parseAsString, } from "nuqs"; import { useEffect, useRef, useState, useCallback } from "react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Separator } from "@/components/ui/separator"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Flame, Loader2, Info, SlidersHorizontal, ChevronDown, Check } from "lucide-react"; import { CommunityThemeCard } from "./community-theme-card"; import { CommunityThemePreviewDialog } from "./community-theme-preview-dialog"; import { CommunitySidebarContent } from "./community-sidebar"; import { Skeleton } from "@/components/ui/skeleton"; import Link from "next/link"; const popularOptions: { timeRange: CommunityTimeRange; label: string; }[] = [ { timeRange: "all", label: "All Time" }, { timeRange: "monthly", label: "This Month" }, { timeRange: "weekly", label: "This Week" }, ]; const otherSortOptions: { sort: "newest" | "oldest"; label: string; }[] = [ { sort: "newest", label: "Newest" }, { sort: "oldest", label: "Oldest" }, ]; export function CommunityThemesContent() { const [sort, setSort] = useQueryState( "sort", parseAsStringLiteral(["popular", "newest", "oldest"] as const).withDefault( "popular" ) ); const [filter, setFilter] = useQueryState( "filter", parseAsStringLiteral(["all", "mine", "liked"] as const).withDefault("all") ); const [tags, setTags] = useQueryState( "tags", parseAsArrayOf(parseAsString, ",").withDefault([]) ); const [timeRange, setTimeRange] = useQueryState( "t", parseAsStringLiteral(["weekly", "monthly", "all"] as const).withDefault( "all" ) ); const [sheetOpen, setSheetOpen] = useState(false); const [previewThemeId, setPreviewThemeId] = useState(null); const { themeState, setThemeState } = useEditorStore(); const handlePreview = useCallback( (theme: CommunityTheme) => { setThemeState({ ...themeState, styles: theme.styles }); setPreviewThemeId(theme.id); }, [themeState, setThemeState] ); const handleFilterChange = useCallback( (newFilter: CommunityFilterOption) => { setFilter(newFilter); setSheetOpen(false); }, [setFilter] ); const handleTagToggle = useCallback( (tag: string) => { setTags((prev) => (prev.includes(tag) ? [] : [tag])); }, [setTags] ); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useCommunityThemes(sort, filter, tags, sort === "popular" ? timeRange : "all"); const themes = data?.pages.flatMap((page) => page.themes) ?? []; const previewTheme = previewThemeId ? themes.find((t) => t.id === previewThemeId) ?? null : null; const sentinelRef = useRef(null); useEffect(() => { const sentinel = sentinelRef.current; if (!sentinel) return; const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }, { rootMargin: "200px" } ); observer.observe(sentinel); return () => observer.disconnect(); }, [hasNextPage, isFetchingNextPage, fetchNextPage]); const hasActiveFilters = filter !== "all" || tags.length > 0; const sidebarProps = { filter, selectedTags: tags, onFilterChange: handleFilterChange, onTagToggle: handleTagToggle, }; return (
{/* Desktop sidebar */} {/* Main content */}
{/* Mobile filter button */} Community Themes
Popular {popularOptions.map((option) => { const isActive = sort === "popular" && timeRange === option.timeRange; return ( { setSort("popular"); setTimeRange(option.timeRange); }} className="cursor-pointer justify-between" > {option.label} {isActive && } ); })} {otherSortOptions.map((option) => { const isActive = sort === option.sort; return ( { setSort(option.sort); }} className="cursor-pointer justify-between" > {option.label} {isActive && } ); })}

Publish your theme

After saving a theme, click the{" "} Publish {" "} button in the editor to share it.

You can also manage all your saved themes from{" "} Settings .

{isLoading ? (
{Array.from({ length: 8 }).map((_, i) => (
))}
) : themes.length === 0 ? (

{filter === "mine" ? "No published themes" : filter === "liked" ? "No liked themes" : "No themes yet"}

{filter === "mine" ? "You haven't published any themes yet. Save a theme in the editor, then publish it." : filter === "liked" ? "You haven't liked any themes yet. Browse community themes and like your favorites." : "Be the first to publish a theme to the community! Save a theme in the editor, then publish it from your settings."}

) : ( <>
{themes.map((theme) => ( ))}
{isFetchingNextPage && ( )}
)}
{ if (!open) setPreviewThemeId(null); }} onNavigate={handlePreview} />
); } ================================================ FILE: app/community/layout.tsx ================================================ import { Footer } from "@/components/footer"; import { Header } from "@/components/header"; export default function CommunityLayout({ children, }: { children: React.ReactNode; }) { return (
{children}
); } ================================================ FILE: app/community/page.tsx ================================================ import { Metadata } from "next"; import { CommunityThemesContent } from "./components/community-themes-content"; import { COMMUNITY_THEME_TAGS } from "@/lib/constants"; export const metadata: Metadata = { title: "Community Themes - tweakcn", description: "Discover and explore beautiful shadcn/ui themes created by the community.", keywords: [...COMMUNITY_THEME_TAGS, "shadcn", "theme", "ui"], openGraph: { title: "Community Themes - tweakcn", description: "Discover and explore beautiful shadcn/ui themes created by the community.", type: "website", }, twitter: { card: "summary_large_image", title: "Community Themes - tweakcn", description: "Discover and explore beautiful shadcn/ui themes created by the community.", }, }; export default function CommunityPage() { return ; } ================================================ FILE: app/dashboard/layout.tsx ================================================ import { Footer } from "@/components/footer"; import { Header } from "@/components/header"; export default function DashboardLayout({ children }: { children: React.ReactNode }) { return (
{children}
); } ================================================ FILE: app/dashboard/loading.tsx ================================================ import { Loading } from "@/components/loading"; export default function DashboardLoading() { return ; } ================================================ FILE: app/dashboard/page.tsx ================================================ import { redirect } from "next/navigation"; // This page is being moved to settings/themes export default function DashboardRedirect() { redirect("/settings/themes"); } ================================================ FILE: app/editor/theme/[[...themeId]]/layout.tsx ================================================ import { Header } from "@/components/header"; export default function EditorLayout({ children }: { children: React.ReactNode }) { return (
{children}
); } ================================================ FILE: app/editor/theme/[[...themeId]]/loading.tsx ================================================ import { Loading } from "@/components/loading"; export default function EditorLoading() { return ; } ================================================ FILE: app/editor/theme/[[...themeId]]/page.tsx ================================================ import { getTheme } from "@/actions/themes"; import Editor from "@/components/editor/editor"; import { Metadata } from "next"; export const metadata: Metadata = { title: "tweakcn — Theme Generator for shadcn/ui", description: "Easily customize and preview your shadcn/ui theme with tweakcn. Modify colors, fonts, and styles in real-time.", }; export default async function EditorPage({ params }: { params: Promise<{ themeId: string[] }> }) { const { themeId } = await params; const themePromise = themeId?.length > 0 ? getTheme(themeId?.[0]) : Promise.resolve(null); return ; } ================================================ FILE: app/figma/layout.tsx ================================================ import { Metadata } from "next"; export const metadata: Metadata = { title: "Apply Your tweakcn Theme to Shadcraft Figma UI Kit | Professional Design System", description: "Transform your tweakcn themes into stunning Figma designs with Shadcraft's premium UI kit. 51 components, 44 blocks, dark mode support, and 1500+ icons. Professional Figma design system for shadcn/ui themes.", keywords: "figma ui kit, shadcn ui figma, tweakcn themes, figma design system, ui components figma, design tokens figma, figma plugin, shadcraft, figma templates, design system integration", authors: [{ name: "tweakcn Team" }], openGraph: { title: "Apply Your tweakcn Theme to Shadcraft Figma UI Kit", description: "Professional Figma UI kit with 51 components, 44 blocks, and seamless tweakcn theme integration. Get the ultimate design system for your projects.", url: "https://tweakcn.com/figma", siteName: "tweakcn", images: [ { url: "https://tweakcn.com/figma-onboarding/shadcraft-preview.jpg", width: 1200, height: 630, alt: "Shadcraft Figma UI Kit Preview", }, ], type: "website", }, twitter: { card: "summary_large_image", title: "Apply Your tweakcn Theme to Shadcraft Figma UI Kit", description: "Professional Figma UI kit with 51 components, 44 blocks, and seamless tweakcn theme integration.", images: ["https://tweakcn.com/figma-onboarding/shadcraft-preview.jpg"], }, robots: "index, follow", alternates: { canonical: "https://tweakcn.com/figma", }, }; export default function FigmaLayout({ children }: { children: React.ReactNode }) { return <>{children}; } ================================================ FILE: app/figma/page.tsx ================================================ "use client"; import { useState, useEffect } from "react"; import { FigmaHeader } from "@/components/figma-header"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import Logo from "@/assets/logo.svg"; import Shadcraft from "@/assets/shadcraft.svg"; import FigmaIcon from "@/assets/figma.svg"; import { Check, X, ArrowUpRight, Figma, Cable, Paintbrush } from "lucide-react"; import Link from "next/link"; import { FIGMA_CONSTANTS, redirectToShadcraft } from "@/lib/figma-constants"; export default function FigmaPage() { const [isScrolled, setIsScrolled] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const steps = FIGMA_CONSTANTS.steps.map((step, index) => ({ ...step, icon: index === 0 ? ( ) : index === 1 ? ( ) : ( ), })); const handleGetStarted = () => { redirectToShadcraft(); }; useEffect(() => { const handleScroll = () => { if (window.scrollY > 10) { setIsScrolled(true); } else { setIsScrolled(false); } }; window.addEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll); }, []); // Structured data for SEO const structuredData = { "@context": "https://schema.org", "@type": "Product", name: "Shadcraft Figma UI Kit - tweakcn Integration", description: "Professional Figma UI kit with 51 components, 44 blocks, dark mode support, and seamless tweakcn theme integration", image: "https://tweakcn.com/figma-onboarding/shadcraft-preview.jpg", brand: { "@type": "Brand", name: "Shadcraft", }, offers: { "@type": "Offer", price: "89", priceCurrency: "USD", priceValidUntil: "2025-12-31", availability: "https://schema.org/InStock", url: FIGMA_CONSTANTS.shadcraftUrl, seller: { "@type": "Organization", name: "Shadcraft", }, }, aggregateRating: { "@type": "AggregateRating", ratingValue: "5", reviewCount: "100+", }, category: "Design Software", additionalProperty: [ { "@type": "PropertyValue", name: "Components", value: "51", }, { "@type": "PropertyValue", name: "Blocks", value: "44", }, { "@type": "PropertyValue", name: "Icons", value: "1500+", }, ], }; return ( <> {/* Structured Data */} `; const NEXT_APP_SNIPPET = `// app/layout.tsx\nexport default function RootLayout({ children, }: { children: React.ReactNode }) { return (