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
**[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

## 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
================================================
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")}
Continue with Google
{isGoogleLoading && }
Continue with GitHub
{isGithubLoading && }
{isSignIn ? "New to tweakcn?" : "Already have an account?"}
{isSignIn ? "Create an account" : "Sign in to your 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 ? (
Stop
) : (
{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 (
View Details
View in Editor
{/* 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
View All
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 (
{filterItems.map((item) => (
handleFilterClick(item.value)}
className={cn(
"flex w-full items-center rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
filter === item.value
? "bg-foreground/10 text-foreground"
: "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
)}
>
{item.label}
))}
Tags
{isLoadingTags ? (
{Array.from({ length: 5 }).map((_, i) => (
))}
) : tagCounts.length > 0 ? (
{tagCounts.map(({ tag, count }) => (
onTagToggle(tag)}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-1.5 text-sm transition-colors",
selectedTags.includes(tag)
? "bg-foreground/10 text-foreground font-medium"
: "text-muted-foreground hover:bg-foreground/5 hover:text-foreground"
)}
>
{tag}
{count}
))}
) : null}
);
}
================================================
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}
{theme.likeCount > 0 && {theme.likeCount} }
);
}
================================================
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
{theme.likeCount > 0 && {theme.likeCount} }
{currentMode === "dark" ? (
) : (
)}
View
{/* 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 */}
Filters
{hasActiveFilters && (
{(filter !== "all" ? 1 : 0) + tags.length}
)}
Community Themes
{sort === "popular"
? `Popular / ${popularOptions.find((o) => o.timeRange === timeRange)?.label ?? "All Time"}`
: sort === "newest"
? "Newest"
: "Oldest"}
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 && }
);
})}
How to publish
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 */}
{/* Gradient Background using CSS variables */}
{/* Header */}
{/* Main Content */}
{/* Header Section */}
{/* Hero Section */}
Apply your theme to the ultimate Figma UI kit
Get started
Preview
Trusted by top designers
{FIGMA_CONSTANTS.designers.map((designer, index) => (
{designer.fallback}
))}
{/* How it works */}
How it works
{steps.map((step, index) => (
{step.icon}
{step.step}
{step.title}
{step.description}
))}
{/* Feature Description */}
Top quality Figma UI kit for professionals
Shadcraft is packed with top quality components, true to the shadcn/ui ethos.
{/* Demo UI Preview */}
More on Shadcraft
{/* Pricing */}
Pricing
What you get with Shadcraft
{FIGMA_CONSTANTS.features.map((feature, index) => (
{feature}
))}
Prices in USD
>
);
}
================================================
FILE: app/globals.css
================================================
@import "tailwindcss";
@import "tw-animate-css";
@import "streamdown/styles.css";
@import "./loaders.css";
@source "../node_modules/streamdown/dist";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
/* Computed Shadow Variants */
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
--tracking-tighter: calc(var(--letter-spacing) - 0.05em);
--tracking-tight: calc(var(--letter-spacing) - 0.025em);
--tracking-normal: var(--letter-spacing);
--tracking-wide: calc(var(--letter-spacing) + 0.025em);
--tracking-wider: calc(var(--letter-spacing) + 0.05em);
--tracking-widest: calc(var(--letter-spacing) + 0.1em);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
* {
color-scheme: light dark;
border-color: var(--color-border);
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
-webkit-font-smoothing: antialiased;
letter-spacing: var(--letter-spacing);
}
@layer base {
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
/* View Transition Wave Effect */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
/* Ensure the outgoing view (old theme) is beneath */
z-index: 0;
}
::view-transition-new(root) {
/* Ensure the incoming view (new theme) is always on top */
z-index: 1;
}
@keyframes reveal {
from {
/* Use CSS variables for the origin, defaulting to center if not set */
clip-path: circle(0% at var(--x, 50%) var(--y, 50%));
opacity: 0.7;
}
to {
/* Use CSS variables for the origin, defaulting to center if not set */
clip-path: circle(150% at var(--x, 50%) var(--y, 50%));
opacity: 1;
}
}
::view-transition-new(root) {
/* Apply the reveal animation */
animation: reveal 0.4s ease-in-out forwards;
}
/* Mention styles */
.mention {
@apply bg-primary/10 rounded-md px-1 font-mono text-sm font-semibold;
}
.code-inline {
@apply bg-muted text-muted-foreground px-1.5 py-0.5 font-mono text-xs font-medium;
}
@keyframes text {
from {
background-position: 0% center;
}
to {
background-position: -200% center;
}
}
.animate-text {
animation: text 3s linear infinite;
}
@utility scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
&::-webkit-scrollbar {
width: 8px;
}
}
@utility scrollbar-gutter-stable {
scrollbar-gutter: stable;
}
@utility scrollbar-gutter-both {
scrollbar-gutter: stable both-edges;
}
================================================
FILE: app/layout.tsx
================================================
import { AuthDialogWrapper } from "@/components/auth-dialog-wrapper";
import { DynamicFontLoader } from "@/components/dynamic-font-loader";
import { GetProDialogWrapper } from "@/components/get-pro-dialog-wrapper";
import { PostHogInit } from "@/components/posthog-init";
import { ThemeProvider } from "@/components/theme-provider";
import { ThemeScript } from "@/components/theme-script";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ChatProvider } from "@/hooks/use-chat-context";
import { QueryProvider } from "@/lib/query-client";
import type { Metadata, Viewport } from "next";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { Suspense } from "react";
import "./globals.css";
export const metadata: Metadata = {
title: "Beautiful themes for shadcn/ui — tweakcn | Theme Editor & Generator",
description:
"Customize theme for shadcn/ui with tweakcn's interactive editor. Supports Tailwind CSS v4, Shadcn UI, and custom styles. Modify properties, preview changes, and get the code in real time.",
keywords:
"theme editor, theme generator, shadcn, ui, components, react, tailwind, button, editor, visual editor, component editor, web development, frontend, design system, UI components, React components, Tailwind CSS, shadcn/ui themes",
authors: [{ name: "Sahaj Jain" }],
openGraph: {
title: "Beautiful themes for shadcn/ui — tweakcn | Theme Editor & Generator",
description:
"Customize theme for shadcn/ui with tweakcn's interactive editor. Supports Tailwind CSS v4, Shadcn UI, and custom styles. Modify properties, preview changes, and get the code in real time.",
url: "https://tweakcn.com/",
siteName: "tweakcn",
images: [
{
url: "https://tweakcn.com/og-image.v050725.png",
width: 1200,
height: 630,
},
],
locale: "en_US",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "Beautiful themes for shadcn/ui — tweakcn | Theme Editor & Generator",
description:
"Customize theme for shadcn/ui with tweakcn's interactive editor. Supports Tailwind CSS v4, Shadcn UI, and custom styles. Modify properties, preview changes, and get the code in real time.",
images: ["https://tweakcn.com/og-image.v050725.png"],
},
robots: "index, follow",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1.0,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{/* PRELOAD FONTS USED BY BUILT-IN THEMES */}
{children}
);
}
================================================
FILE: app/loaders.css
================================================
/* keyframes for loaders */
@theme {
@keyframes typing {
0%,
100% {
transform: translateY(0);
opacity: 0.5;
}
50% {
transform: translateY(-2px);
opacity: 1;
}
}
@keyframes loading-dots {
0%,
100% {
opacity: 0;
}
50% {
opacity: 1;
}
}
@keyframes wave {
0%,
100% {
transform: scaleY(1);
}
50% {
transform: scaleY(0.6);
}
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
@keyframes text-blink {
0%,
100% {
color: var(--primary);
}
50% {
color: var(--muted-foreground);
}
}
@keyframes bounce-dots {
0%,
100% {
transform: scale(0.8);
opacity: 0.5;
}
50% {
transform: scale(1.2);
opacity: 1;
}
}
@keyframes thin-pulse {
0%,
100% {
transform: scale(0.95);
opacity: 0.8;
}
50% {
transform: scale(1.05);
opacity: 0.4;
}
}
@keyframes pulse-dot {
0%,
100% {
transform: scale(1);
opacity: 0.8;
}
50% {
transform: scale(1.5);
opacity: 1;
}
}
@keyframes shimmer-text {
0% {
background-position: 150% center;
}
100% {
background-position: -150% center;
}
}
@keyframes wave-bars {
0%,
100% {
transform: scaleY(1);
opacity: 0.5;
}
50% {
transform: scaleY(0.6);
opacity: 1;
}
}
@keyframes shimmer {
0% {
background-position: 200% 50%;
}
100% {
background-position: -200% 50%;
}
}
@keyframes spinner-fade {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
}
================================================
FILE: app/not-found.tsx
================================================
"use client";
import { ThemePresetButtons } from "@/components/home/theme-preset-buttons";
import { useTheme } from "@/components/theme-provider";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useEditorStore } from "@/store/editor-store";
import { defaultPresets } from "@/utils/theme-presets";
import { Sun, Moon } from "lucide-react";
import Link from "next/link";
export default function NotFound() {
const { theme, toggleTheme } = useTheme();
const { themeState, applyThemePreset } = useEditorStore();
const mode = themeState.currentMode;
const presetNames = Object.keys(defaultPresets);
return (
toggleTheme({ x: e.clientX, y: e.clientY })}
>
{theme === "light" ? : }
Toggle theme
404
Oops, Lost in Space?
Go home or try switching the theme!
Back to Home
);
}
================================================
FILE: app/oauth/authorize/page.tsx
================================================
"use client";
import Github from "@/assets/github.svg";
import Google from "@/assets/google.svg";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";
import { Loader2 } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
const SCOPE_LABELS: Record = {
"themes:read": "Read your saved themes",
"profile:read": "Read your profile (name, email)",
};
export default function OAuthAuthorizePage() {
const searchParams = useSearchParams();
const { data: session, isPending } = authClient.useSession();
const [loadingProvider, setLoadingProvider] = useState(null);
const [error, setError] = useState(null);
const [appName, setAppName] = useState(null);
const [redirecting, setRedirecting] = useState(false);
const clientId = searchParams.get("client_id");
const scopes =
searchParams
.get("scope")
?.split(/[\s,]+/)
.filter(Boolean) ?? [];
useEffect(() => {
if (!clientId) {
setError("Missing client_id parameter");
return;
}
fetch(`/api/oauth/app-info?client_id=${encodeURIComponent(clientId)}`)
.then((res) => res.json())
.then((data) => {
if (data.error) {
setError(data.error_description ?? "Invalid application");
} else {
setAppName(data.name);
}
})
.catch(() => setError("Failed to validate application"));
}, [clientId]);
useEffect(() => {
if (!session || redirecting || error) return;
setRedirecting(true);
const apiUrl = `/api/oauth/authorize?${searchParams.toString()}`;
window.location.href = apiUrl;
}, [session, searchParams, redirecting, error]);
const callbackURL = `/oauth/authorize?${searchParams.toString()}`;
const handleSignIn = async (provider: "google" | "github") => {
setLoadingProvider(provider);
try {
await authClient.signIn.social({ provider, callbackURL });
} catch {
setLoadingProvider(null);
}
};
const isLoading = loadingProvider !== null;
if (error) {
return (
);
}
if (isPending || redirecting || !appName) {
return (
);
}
return (
handleSignIn("google")}
className="h-10 w-full justify-center gap-2"
disabled={isLoading}
>
Continue with Google
{loadingProvider === "google" && }
handleSignIn("github")}
className="h-10 w-full justify-center gap-2"
disabled={isLoading}
>
Continue with GitHub
{loadingProvider === "github" && }
{scopes.length > 0 && (
Permissions requested
{scopes.map((scope) => (
{SCOPE_LABELS[scope] ?? scope}
))}
)}
Authorizing will grant {appName} access to the permissions above.
);
}
================================================
FILE: app/page.tsx
================================================
"use client";
import { Footer } from "@/components/footer";
import { AIGenerationCTA } from "@/components/home/ai-generation-cta";
import { CTA } from "@/components/home/cta";
import { FAQ } from "@/components/home/faq";
import { Features } from "@/components/home/features";
import { Header } from "@/components/home/header";
import { Hero } from "@/components/home/hero";
import { HowItWorks } from "@/components/home/how-it-works";
import { Testimonials } from "@/components/home/testimonials";
// import { ThemePresetSelector } from "@/components/home/theme-preset-selector";
import { useEffect, useState } from "react";
export default function Home() {
const [isScrolled, setIsScrolled] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
useEffect(() => {
const handleScroll = () => {
if (window.scrollY > 10) {
setIsScrolled(true);
} else {
setIsScrolled(false);
}
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
);
}
================================================
FILE: app/pricing/components/checkout-button.tsx
================================================
"use client";
import { createCheckout } from "@/actions/checkout";
import { Button } from "@/components/ui/button";
import { usePostLoginAction } from "@/hooks/use-post-login-action";
import { SUBSCRIPTION_STATUS_QUERY_KEY, useSubscription } from "@/hooks/use-subscription";
import { toast } from "@/hooks/use-toast";
import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/store/auth-store";
import { useQueryClient } from "@tanstack/react-query";
import { Gem, Loader } from "lucide-react";
import { useRouter } from "next/navigation";
import { ComponentProps, useTransition } from "react";
interface CheckoutButtonProps extends ComponentProps {}
export function CheckoutButton({ disabled, className, ...props }: CheckoutButtonProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const { data: session } = authClient.useSession();
const { openAuthDialog } = useAuthStore();
usePostLoginAction("CHECKOUT", () => {
handleOpenCheckout();
});
const queryClient = useQueryClient();
const { subscriptionStatus } = useSubscription();
const isPro = subscriptionStatus?.isSubscribed ?? false;
const handleOpenCheckout = async () => {
if (!session) {
openAuthDialog("signup", "CHECKOUT");
return;
}
if (subscriptionStatus?.isSubscribed) {
router.push("/settings");
return;
}
startTransition(async () => {
const res = await createCheckout();
if ("error" in res || !res.url) {
toast({
title: "Error",
description: res.error || "Failed to create checkout",
variant: "destructive",
});
return;
}
queryClient.invalidateQueries({ queryKey: [SUBSCRIPTION_STATUS_QUERY_KEY] });
router.push(res.url);
});
};
return (
{isPending ? (
Redirecting to Checkout
) : isPro ? (
{`You're Subscribed to Pro`}
) : (
"Upgrade to Pro"
)}
);
}
================================================
FILE: app/pricing/layout.tsx
================================================
import { Footer } from "@/components/footer";
import { Header } from "@/components/header";
export default function PricingLayout({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
================================================
FILE: app/pricing/page.tsx
================================================
import { NoiseEffect } from "@/components/effects/noise-effect";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { AI_REQUEST_FREE_TIER_LIMIT } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { FREE_SUB_FEATURES, PRO_SUB_FEATURES } from "@/utils/subscription";
import { Calendar, Check, Circle, Mail } from "lucide-react";
import Link from "next/link";
import { CheckoutButton } from "./components/checkout-button";
import { Metadata } from "next";
import { Testimonials } from "@/components/home/testimonials";
export const metadata: Metadata = {
title: "Pricing — tweakcn",
robots: "index, follow",
};
export default function PricingPage() {
return (
{/* Background decoration */}
{/* Header Section */}
Choose your perfect plan
Start building beautiful themes for free. Upgrade to Pro when you're ready.
{/* Pricing Cards */}
{/* Free Plan */}
Free
$0
/month
No credit card required
Get Started Free
{/* Pro Plan */}
Pro
$8
/month
Billed monthly • Cancel anytime
Everything in Free, plus:
{/* FAQs Section */}
FAQs
Here's everything you may want to know. For any other info, just{" "}
reach us
.
{PRICING_FAQS.map((faq, i) => (
{faq.question}
{faq.answer}
))}
{/* Bottom Section */}
Need something custom or have questions?
Get in touch
);
}
const PRICING_FAQS = [
{
question: "What do I get when I upgrade to Pro?",
answer: `You get unlimited AI-generated themes, AI theme generation from images, unlimited saved themes, priority support, and more features coming soon. We're developing new features for Pro users!`,
},
{
question: "Can I still use tweakcn for free?",
answer: `Yes! tweakcn provides a comprehensive free tier that includes theme customization, access to preset themes, and up to ${AI_REQUEST_FREE_TIER_LIMIT} free AI-generated themes. You can build and export themes without any payment required.`,
},
{
question: "Does tweakcn offer a free trial for the Pro plan?",
answer: `No, there are no free trials. However, you get access to generate up to ${AI_REQUEST_FREE_TIER_LIMIT} themes with AI, plus unlimited manual theme customization using the free visual editor.`,
},
{
question: "What happens to saved themes when downgrading to free?",
answer:
"All your created themes remain yours forever. When you downgrade from Pro, you keep full access to all themes you've built, but you'll be limited to the free tier's AI generation quota and features.",
},
{
question: "Can I cancel or switch at any time?",
answer:
"Yes! You have complete control over your subscription. Cancel anytime through your account settings, and you'll retain Pro access until your current billing period ends before automatically switching to the free tier.",
},
{
question: "How secure is the payment?",
answer:
"We use Polar for secure payment processing, which handles all transactions with industry-standard encryption. Your payment details are never stored on our servers.",
},
];
================================================
FILE: app/r/themes/[id]/route.ts
================================================
import { NextResponse } from "next/server";
import { getTheme } from "@/actions/themes";
import { generateThemeRegistryItemFromStyles } from "@/utils/registry/themes";
import { registryItemSchema } from "shadcn/schema";
import { getBuiltInThemeStyles } from "@/utils/theme-preset-helper";
import { ThemeStyles } from "@/types/theme";
export const dynamic = "force-static";
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
try {
// First, check if this is a built-in theme
let themeName: string;
let themeStyles: ThemeStyles;
const builtInTheme = getBuiltInThemeStyles(id.replace(/\.json$/, ""));
if (builtInTheme) {
themeName = builtInTheme.name;
themeStyles = builtInTheme.styles;
} else {
// Fall back to database lookup for user-saved themes
const theme = await getTheme(id);
themeName = theme.name;
themeStyles = theme.styles;
}
const generatedRegistryItem = generateThemeRegistryItemFromStyles(themeName, themeStyles);
// Validate the generated registry item against the official shadcn registry item schema
// https://ui.shadcn.com/docs/registry/registry-item-json
const parsedRegistryItem = registryItemSchema.safeParse(generatedRegistryItem);
if (!parsedRegistryItem.success) {
console.error(
"Could not parse the registry item from the database:",
parsedRegistryItem.error.format()
);
return new NextResponse("Unexpected registry item format.", {
status: 500,
headers: {
"Content-Type": "application/json",
},
});
}
// If the parsing is successful, we can safely access the data property
// and return it as the registry item json being sure it's in a correct format.
const registryItem = parsedRegistryItem.data;
return new NextResponse(JSON.stringify(registryItem), {
status: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json",
},
});
} catch (e) {
console.error("Error fetching the theme registry item:", e);
return new NextResponse("Failed to fetch the theme registry item.", {
status: 500,
headers: {
"Content-Type": "application/json",
},
});
}
}
================================================
FILE: app/r/v0/[id]/route.ts
================================================
import { NextResponse } from "next/server";
import { getTheme } from "@/actions/themes";
import { generateV0RegistryPayload } from "@/utils/registry/v0";
import { getBuiltInThemeStyles } from "@/utils/theme-preset-helper";
export const dynamic = "force-static";
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
try {
// First, check if this is a built-in theme
const builtInTheme = getBuiltInThemeStyles(id.replace(/\.json$/, ""));
if (builtInTheme) {
const payload = generateV0RegistryPayload(builtInTheme.name, builtInTheme.styles);
return new NextResponse(JSON.stringify(payload), {
status: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json",
},
});
}
// Fall back to database lookup for user-saved themes
const theme = await getTheme(id);
const payload = generateV0RegistryPayload(theme.name, theme.styles);
return new NextResponse(JSON.stringify(payload), {
status: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/json",
},
});
} catch (error) {
console.error("Error generating v0 registry payload:", error);
const isNotFound =
error instanceof Error &&
(error.name === "ThemeNotFoundError" || error.message.includes("not found"));
return new NextResponse(isNotFound ? "Theme not found" : "Failed to generate v0 payload", {
status: isNotFound ? 404 : 500,
headers: {
"Content-Type": "application/json",
},
});
}
}
================================================
FILE: app/settings/account/components/delete-account-section.tsx
================================================
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { usePostHog } from "posthog-js/react";
import { AlertTriangle, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
} from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import { toast } from "@/hooks/use-toast";
import { authClient } from "@/lib/auth-client";
import { deleteAccount } from "@/actions/account";
const CONFIRMATION_TEXT = "DELETE";
export function DeleteAccountSection() {
const [open, setOpen] = useState(false);
const [confirmText, setConfirmText] = useState("");
const [isDeleting, setIsDeleting] = useState(false);
const router = useRouter();
const posthog = usePostHog();
const isConfirmed = confirmText === CONFIRMATION_TEXT;
const handleDelete = async () => {
if (!isConfirmed) return;
setIsDeleting(true);
const result = await deleteAccount();
if (result.success) {
posthog.reset();
await authClient.signOut();
router.push("/");
} else {
toast({
title: "Failed to delete account",
description: result.error.message,
variant: "destructive",
});
setIsDeleting(false);
}
};
return (
<>
Danger Zone
Permanently delete your account and all associated data. This action
cannot be undone.
setOpen(true)}
>
Delete Account
{
if (!isDeleting) {
setOpen(v);
if (!v) setConfirmText("");
}
}}>
Delete your account
This will permanently delete your account and all associated
data, including:
All saved themes
Community published themes
AI usage history
Active subscription (if any)
This action cannot be undone.
Type {CONFIRMATION_TEXT} to
confirm
setConfirmText(e.target.value)}
placeholder={CONFIRMATION_TEXT}
disabled={isDeleting}
autoComplete="off"
/>
Cancel
{isDeleting && }
Delete Account
>
);
}
================================================
FILE: app/settings/account/page.tsx
================================================
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { SettingsHeader } from "../components/settings-header";
import { DeleteAccountSection } from "./components/delete-account-section";
export default async function AccountPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) redirect("/editor/theme");
return (
);
}
================================================
FILE: app/settings/components/customer-portal-link.tsx
================================================
import { auth } from "@/lib/auth";
import { polar } from "@/lib/polar";
import { cn } from "@/lib/utils";
import { headers } from "next/headers";
import Link from "next/link";
export async function CustomerPortalLink() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user.id) {
return null;
}
const result = await polar.customerSessions.create({
externalCustomerId: session?.user.id,
});
const customerPortalLink = result.customerPortalUrl;
return (
Manage Subscription
);
}
================================================
FILE: app/settings/components/settings-header.tsx
================================================
export function SettingsHeader({ title, description }: { title: string; description?: string }) {
return (
{title}
{description &&
{description}
}
);
}
================================================
FILE: app/settings/components/settings-sidebar.tsx
================================================
"use client";
import { Separator } from "@/components/ui/separator";
import { useSubscription } from "@/hooks/use-subscription";
import { cn } from "@/lib/utils";
import { ChartNoAxesCombined, CreditCard, ExternalLink, LucideIcon, Palette, UserCog } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
type NavItem =
| {
type: "link";
href: string;
label: string;
icon?: LucideIcon;
isExternal?: boolean;
}
| {
type: "separator";
id: string;
};
const BASE_NAV_ITEMS: NavItem[] = [
{ type: "link", href: "/settings/themes", label: "Themes", icon: Palette },
{ type: "link", href: "/settings/usage", label: "AI Usage", icon: ChartNoAxesCombined },
{ type: "separator", id: "account-separator" },
{ type: "link", href: "/settings/account", label: "Account", icon: UserCog },
];
const getSubscriptionNavItems = (): NavItem[] => [
{ type: "separator", id: "subscription-separator" },
{
type: "link",
href: "/settings/portal",
label: "Manage Subscription",
icon: CreditCard,
isExternal: true,
},
];
export function SettingsSidebar() {
const pathname = usePathname();
const { subscriptionStatus } = useSubscription();
const navItems = useMemo(() => {
if (subscriptionStatus?.isSubscribed) {
return [...BASE_NAV_ITEMS, ...getSubscriptionNavItems()];
}
return BASE_NAV_ITEMS;
}, [subscriptionStatus?.isSubscribed]);
return (
{navItems.map((item) => {
if (item.type === "separator") {
return ;
}
const isActive = pathname === item.href;
return (
{item.icon && }
{item.label}
{item.isExternal && }
);
})}
);
}
================================================
FILE: app/settings/components/theme-card.tsx
================================================
"use client";
import { Theme } from "@/types/theme";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { TagSelector } from "@/components/tag-selector";
import {
MoreVertical,
Trash2,
Edit,
Loader2,
Zap,
ExternalLink,
Copy,
Globe,
GlobeLock,
Tag,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { useEditorStore } from "@/store/editor-store";
import { useDeleteTheme } from "@/hooks/themes";
import {
usePublishTheme,
useUnpublishTheme,
useUpdateCommunityThemeTags,
} from "@/hooks/themes";
import Link from "next/link";
import { toast } from "@/components/ui/use-toast";
import { ThemePreview } from "@/components/theme-preview";
interface ThemeCardProps {
theme: Theme;
isPublished?: boolean;
className?: string;
}
export function ThemeCard({
theme,
isPublished = false,
className,
}: ThemeCardProps) {
const { themeState, setThemeState } = useEditorStore();
const deleteThemeMutation = useDeleteTheme();
const publishMutation = usePublishTheme();
const unpublishMutation = useUnpublishTheme();
const updateTagsMutation = useUpdateCommunityThemeTags();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showPublishDialog, setShowPublishDialog] = useState(false);
const [showEditTagsDialog, setShowEditTagsDialog] = useState(false);
const [selectedTags, setSelectedTags] = useState([]);
const mode = themeState.currentMode;
const handleDelete = () => {
setShowDeleteDialog(true);
};
const handleConfirmDelete = () => {
deleteThemeMutation.mutate(theme.id, {
onSuccess: () => {
setShowDeleteDialog(false);
},
});
};
const handleQuickApply = () => {
setThemeState({
...themeState,
styles: theme.styles,
});
};
const handleShare = () => {
const url = `https://tweakcn.com/themes/${theme.id}`;
navigator.clipboard.writeText(url);
toast({
title: "Theme URL copied to clipboard!",
});
};
const handlePublish = () => {
setShowPublishDialog(true);
};
const handleConfirmPublish = () => {
publishMutation.mutate(
{ themeId: theme.id, tags: selectedTags },
{
onSuccess: () => {
setShowPublishDialog(false);
setSelectedTags([]);
},
}
);
};
const handleEditTags = () => {
setSelectedTags([]);
setShowEditTagsDialog(true);
};
const handleConfirmEditTags = () => {
updateTagsMutation.mutate(
{ themeId: theme.id, tags: selectedTags },
{
onSuccess: () => {
setShowEditTagsDialog(false);
setSelectedTags([]);
},
}
);
};
const handleUnpublish = () => {
unpublishMutation.mutate(theme.id);
};
return (
{theme.name}
{isPublished && (
Published
)}
{new Date(theme.createdAt).toLocaleDateString("en-US", {
day: "numeric",
month: "short",
year: "numeric",
})}
Quick Apply
Open Theme
Edit Theme
Copy URL
{isPublished ? (
<>
Edit Tags
{unpublishMutation.isPending ? (
) : (
)}
Unpublish from Community
>
) : (
{publishMutation.isPending ? (
) : (
)}
Publish to Community
)}
{deleteThemeMutation.isPending ? (
) : (
)}
Delete Theme
Are you sure you want to delete your {theme.name} theme?
This action cannot be undone. This will permanently delete your
theme.
{isPublished &&
" It will also be removed from the community."}
Cancel
{deleteThemeMutation.isPending ? (
<>
Deleting...
>
) : (
"Delete"
)}
Publish "{theme.name}" to the community?
Your theme will be publicly visible on the community page. You can
unpublish it at any time.
setShowPublishDialog(false)}
>
Cancel
{publishMutation.isPending ? (
<>
Publishing...
>
) : (
"Publish"
)}
Edit Tags
Update the tags for "{theme.name}".
setShowEditTagsDialog(false)}
>
Cancel
{updateTagsMutation.isPending ? (
<>
Saving...
>
) : (
"Save Tags"
)}
);
}
================================================
FILE: app/settings/components/themes-list.tsx
================================================
"use client";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useIsMobile } from "@/hooks/use-mobile";
import type { Theme } from "@/types/theme";
import { ArrowUpDown, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { ThemeCard } from "./theme-card";
interface ThemeWithPublished extends Theme {
isPublished: boolean;
}
interface ThemesListProps {
themes: ThemeWithPublished[];
}
export function ThemesList({ themes }: ThemesListProps) {
const [filteredThemes, setFilteredThemes] =
useState(themes);
const [searchTerm, setSearchTerm] = useState("");
const [sortOption, setSortOption] = useState("newest");
const isMobile = useIsMobile();
useEffect(() => {
const filtered = themes.filter((theme) =>
theme.name?.toLowerCase().includes(searchTerm.toLowerCase())
);
// Sort based on selected option
const sorted = [...filtered].sort((a, b) => {
switch (sortOption) {
case "newest":
return (b.createdAt?.getTime() || 0) - (a.createdAt?.getTime() || 0);
case "oldest":
return (a.createdAt?.getTime() || 0) - (b.createdAt?.getTime() || 0);
case "name":
return (a.name || "").localeCompare(b.name || "");
default:
return 0;
}
});
setFilteredThemes(sorted);
}, [themes, searchTerm, sortOption]);
return (
{filteredThemes.length === 0 && searchTerm ? (
No themes found
No themes match your search term "{searchTerm}".
) : (
{filteredThemes.map((theme) => (
))}
)}
);
}
================================================
FILE: app/settings/components/usage-stats.tsx
================================================
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import { BarChart, Bar, XAxis, YAxis } from "recharts";
import { Skeleton } from "@/components/ui/skeleton";
import { getMyUsageStats, getMyUsageChartData } from "@/actions/ai-usage";
type Timeframe = "1d" | "7d" | "30d";
interface UsageStats {
requests: number;
timeframe: Timeframe;
}
interface ChartDataPoint {
daysSinceEpoch?: number;
hoursSinceEpoch?: number;
date: string;
totalRequests: number;
}
const timeframeLabels = {
"1d": "Last 24 hours",
"7d": "Last 7 days",
"30d": "Last 30 days",
};
const chartConfig = {
totalRequests: {
label: "Requests",
color: "var(--primary)",
},
};
export function UsageStats() {
const [timeframe, setTimeframe] = useState("7d");
const [stats, setStats] = useState(null);
const [chartData, setChartData] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const [statsData, chartDataResponse] = await Promise.all([
getMyUsageStats(timeframe),
getMyUsageChartData(timeframe),
]);
setStats(statsData);
setChartData(chartDataResponse);
} catch (error) {
console.error("Error fetching usage data:", error);
} finally {
setLoading(false);
}
};
fetchData();
}, [timeframe]);
const formatDate = (dateString: string, timeframe: Timeframe) => {
const date = new Date(dateString);
if (timeframe === "1d") {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
return date.toLocaleDateString([], { month: "short", day: "numeric" });
};
return (
setTimeframe(value)}>
Last 24 hours
Last 7 days
Last 30 days
{loading ? (
) : (
{stats?.requests || 0}
requests in {timeframeLabels[timeframe].toLowerCase()}
)}
{loading ? (
) : chartData.length > 0 ? (
formatDate(value, timeframe)}
axisLine={false}
tickLine={false}
className="text-xs"
/>
}
labelFormatter={(value) => formatDate(value, timeframe)}
/>
) : (
No usage data available
Make some AI requests to see your usage statistics
)}
);
}
================================================
FILE: app/settings/components/user-info.tsx
================================================
"use client";
import { useSubscription } from "@/hooks/use-subscription";
import { authClient } from "@/lib/auth-client";
import { Gem } from "lucide-react";
export function UserInfo() {
const { data: session } = authClient.useSession();
const { subscriptionStatus } = useSubscription();
const isPro = subscriptionStatus?.isSubscribed ?? false;
return (
{session?.user.name}{" "}
{isPro && (
Pro
)}
{session?.user.email}
);
}
================================================
FILE: app/settings/layout.tsx
================================================
import { Header } from "@/components/header";
import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { SettingsSidebar } from "./components/settings-sidebar";
export default async function SettingsLayout({ children }: { children: React.ReactNode }) {
return (
);
}
================================================
FILE: app/settings/page.tsx
================================================
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export default async function SettingsIndex() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) redirect("/editor/theme");
redirect("/settings/themes");
}
================================================
FILE: app/settings/portal/route.ts
================================================
import { polar } from "@/lib/polar";
import { getCurrentUserId } from "@/lib/shared";
import { CustomerPortal } from "@polar-sh/nextjs";
export const GET = CustomerPortal({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
server: process.env.NODE_ENV === "production" ? "production" : "sandbox",
getCustomerId: async (req) => {
const userId = await getCurrentUserId(req);
const customer = await polar.customers.getExternal({ externalId: userId });
return customer.id;
},
});
================================================
FILE: app/settings/themes/page.tsx
================================================
import { getThemes } from "@/actions/themes";
import { ThemesList } from "@/app/settings/components/themes-list";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { auth } from "@/lib/auth";
import { Palette, Plus } from "lucide-react";
import { headers } from "next/headers";
import Link from "next/link";
import { redirect } from "next/navigation";
import { SettingsHeader } from "../components/settings-header";
export default async function ThemesPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) redirect("/editor/theme");
const themes = await getThemes();
const sortedThemes = themes.sort((a, b) => {
return (b.createdAt?.getTime() || 0) - (a.createdAt?.getTime() || 0);
});
return (
{sortedThemes.length === 0 ? (
No themes created yet
Create your first custom theme to personalize your projects with unique color palettes.
) : (
)}
);
}
================================================
FILE: app/settings/usage/page.tsx
================================================
import { UsageStats } from "@/app/settings/components/usage-stats";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { SettingsHeader } from "../components/settings-header";
export default async function UsagePage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) redirect("/editor/theme");
return (
);
}
================================================
FILE: app/sitemap.ts
================================================
import { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = process.env.BASE_URL ?? "https://tweakcn.com";
return [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1,
},
{
url: `${baseUrl}/editor/theme`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.8,
},
{
url: `${baseUrl}/ai`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.8,
},
{
url: `${baseUrl}/pricing`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.6,
},
{
url: `${baseUrl}/community`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.8,
},
{
url: `${baseUrl}/figma`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.7,
},
];
}
================================================
FILE: app/success/layout.tsx
================================================
export default function SuccessLayout({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
================================================
FILE: app/success/page.tsx
================================================
import { NoiseEffect } from "@/components/effects/noise-effect";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ArrowRight, CheckCircle } from "lucide-react";
import Link from "next/link";
export default function SuccessPage() {
return (
{/* Success Icon */}
Payment Successful!
Welcome to tweakcn Pro ! Your
subscription is now active and you have access to all premium features.
Continue Editing
Go to Settings
Need help?{" "}
Contact us
);
}
================================================
FILE: app/themes/[themeId]/error.tsx
================================================
"use client";
import { useEffect } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function ThemeError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
Something went wrong!
There was an error loading this theme. Please try again later.
Try again
Return to Settings
);
}
================================================
FILE: app/themes/[themeId]/layout.tsx
================================================
import { Footer } from "@/components/footer";
import { Header } from "@/components/header";
export default function ThemeLayout({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
================================================
FILE: app/themes/[themeId]/loading.tsx
================================================
import { Loading } from "@/components/loading";
export default function ThemeLoading() {
return ;
}
================================================
FILE: app/themes/[themeId]/not-found.tsx
================================================
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function ThemeNotFound() {
return (
Theme Not Found
The theme you're looking for doesn't exist or you don't
have permission to view it.
Return to Editor
);
}
================================================
FILE: app/themes/[themeId]/opengraph-image.alt.txt
================================================
Theme preview for tweakcn
================================================
FILE: app/themes/[themeId]/opengraph-image.tsx
================================================
import { ImageResponse } from "next/og";
import { getTheme } from "@/actions/themes";
// Image metadata
export const size = {
width: 1200,
height: 630,
};
export const contentType = "image/png";
// Dynamic route params
export default async function Image({
params,
}: {
params: { themeId: string };
}) {
const theme = await getTheme(params.themeId);
// Set default colors if theme doesn't exist
const primaryColor = theme?.styles?.light?.primary || "#000000";
const secondaryColor = theme?.styles?.light?.secondary || "#ffffff";
const accentColor = theme?.styles?.light?.accent || "#0070f3";
const mutedColor = theme?.styles?.light?.muted || "#f5f5f5";
const borderColor = theme?.styles?.light?.border || "#e5e5e5";
const backgroundColor = theme?.styles?.light?.background || "#ffffff";
const foregroundColor = theme?.styles?.light?.foreground || "#000000";
const themeName = theme?.name || "Theme";
return new ImageResponse(
(
{/* Top section for theme name */}
{/* Bottom section with horizontal color swatches */}
{/* Primary */}
{/* Secondary */}
{/* Accent */}
{/* Muted */}
{/* Border */}
),
{
...size,
}
);
}
================================================
FILE: app/themes/[themeId]/page.tsx
================================================
import { getTheme } from "@/actions/themes";
import { getCommunityDataForTheme } from "@/actions/community-themes";
import ThemeView from "@/components/theme-view";
import { Metadata } from "next";
interface ThemePageProps {
params: Promise<{
themeId: string;
}>;
}
export async function generateMetadata({ params }: ThemePageProps): Promise {
const { themeId } = await params;
const [theme, communityData] = await Promise.all([
getTheme(themeId),
getCommunityDataForTheme(themeId),
]);
const tags = communityData?.tags ?? [];
const authorName = communityData?.author?.name;
const description =
tags.length > 0 && authorName
? `A ${tags.join(", ")} shadcn/ui theme by ${authorName}`
: `Discover shadcn/ui themes - ${theme?.name} theme`;
return {
title: theme?.name + " - tweakcn",
description,
keywords: tags.length > 0 ? tags : undefined,
openGraph: {
title: `${theme?.name} - tweakcn`,
description,
type: "website",
},
twitter: {
card: "summary_large_image",
title: `${theme?.name} - tweakcn`,
description,
},
robots: {
index: !!communityData,
follow: true,
},
};
}
export default async function ThemePage({ params }: ThemePageProps) {
const { themeId } = await params;
const [theme, communityData] = await Promise.all([
getTheme(themeId),
getCommunityDataForTheme(themeId),
]);
return (
);
}
================================================
FILE: components/ai-elements/code-block.tsx
================================================
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import { createContext, useContext, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useTheme } from "../theme-provider";
type CodeBlockContextType = {
code: string;
};
const CodeBlockContext = createContext({
code: "",
});
export type CodeBlockProps = HTMLAttributes & {
code: string;
language: string;
showLineNumbers?: boolean;
children?: ReactNode;
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => {
const { theme } = useTheme();
return (
{code}
{children && (
{children}
)}
);
};
export type CodeBlockCopyButtonProps = ComponentProps & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator.clipboard.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
} catch (error) {
onError?.(error as Error);
}
};
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
{children ?? }
);
};
================================================
FILE: components/ai-elements/conversation.tsx
================================================
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ArrowDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps;
export const Conversation = ({ className, ...props }: ConversationProps) => (
);
export type ConversationContentProps = ComponentProps;
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
);
export type ConversationScrollButtonProps = ComponentProps;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
)
);
};
================================================
FILE: components/ai-elements/response.tsx
================================================
"use client";
import { cn } from "@/lib/utils";
import { type ComponentProps, memo } from "react";
import { Streamdown } from "streamdown";
type ResponseProps = ComponentProps;
export const Response = memo(
({ className, ...props }: ResponseProps) => (
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_li>p]:my-0",
className
)}
{...props}
/>
),
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
prevProps.isAnimating === nextProps.isAnimating
);
Response.displayName = "Response";
================================================
FILE: components/auth-dialog-wrapper.tsx
================================================
"use client";
import { AuthDialog } from "@/app/(auth)/components/auth-dialog";
import { useAuthStore } from "@/store/auth-store";
import { useEffect } from "react";
import { authClient } from "@/lib/auth-client";
import { executePostLoginAction } from "@/hooks/use-post-login-action";
import { usePostHog } from "posthog-js/react";
export function AuthDialogWrapper() {
const { isOpen, mode, closeAuthDialog, postLoginAction, clearPostLoginAction } = useAuthStore();
const { data: session } = authClient.useSession();
const posthog = usePostHog();
useEffect(() => {
if (isOpen && session) {
closeAuthDialog();
}
if (session && session.user && session.user.email) {
// Identify user with PostHog
posthog.identify(session.user.email, {
name: session.user.name,
email: session.user.email,
});
}
if (session && postLoginAction) {
// Execute action immediately - the system will now handle waiting for handlers
executePostLoginAction(postLoginAction);
clearPostLoginAction();
}
}, [session, isOpen, closeAuthDialog, postLoginAction, clearPostLoginAction, posthog]);
return (
);
}
================================================
FILE: components/block-viewer.tsx
================================================
"use client";
import { Monitor, Smartphone, Tablet } from "lucide-react";
import React from "react";
import { PanelImperativeHandle } from "react-resizable-panels";
import { ComponentErrorBoundary } from "@/components/error-boundary";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { cn } from "@/lib/utils";
type BlockViewerContext = {
resizablePanelRef: React.RefObject;
toggleValue: string;
setToggleValue: (value: string) => void;
};
const BlockViewerContext = React.createContext(null);
function useBlockViewer() {
const context = React.useContext(BlockViewerContext);
if (!context) {
throw new Error("useBlockViewer must be used within a BlockViewerProvider.");
}
return context;
}
function BlockViewerProvider({ children }: { children: React.ReactNode }) {
const resizablePanelRef = React.useRef(null);
const [toggleValue, setToggleValue] = React.useState("100");
return (
{children}
);
}
export function BlockViewer({
className,
name,
children,
...props
}: React.ComponentProps<"div"> & {
name: string;
}) {
return (
{children}
);
}
export function BlockViewerToolbar({
name,
className,
toolbarControls,
}: React.ComponentProps<"div"> & {
name: string;
className?: string;
toolbarControls?: React.ReactNode;
}) {
const { resizablePanelRef, toggleValue, setToggleValue } = useBlockViewer();
return (
{!!toolbarControls ? (
toolbarControls
) : (
{name}
)}
{
if (value && resizablePanelRef?.current) {
resizablePanelRef.current.resize(parseInt(value));
setToggleValue(value);
}
}}
>
);
}
export function BlockViewerDisplay({
name,
className,
children,
...props
}: React.ComponentProps<"div"> & {
name: string;
}) {
const { resizablePanelRef, setToggleValue } = useBlockViewer();
// Auto-resize to full width when screen goes under lg breakpoint (1024px)
React.useEffect(() => {
const mql = window.matchMedia("(max-width: 1023px)");
const resizePanel = () => {
if (window.innerWidth < 1024 && resizablePanelRef?.current) {
resizablePanelRef.current.resize(100);
setToggleValue("100");
}
};
resizePanel();
mql.addEventListener("change", resizePanel);
return () => mql.removeEventListener("change", resizePanel);
}, [resizablePanelRef, setToggleValue]);
return (
);
}
================================================
FILE: components/copy-button.tsx
================================================
"use client";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
import { cn } from "@/lib/utils";
import { Copy, CopyCheck } from "lucide-react";
import { ComponentProps } from "react";
import { TooltipWrapper } from "./tooltip-wrapper";
import { Button } from "./ui/button";
interface CopyButtonProps extends ComponentProps {
textToCopy: string;
successMessage?: {
title?: string;
description?: string;
};
}
export function CopyButton({ textToCopy, successMessage, className, ...props }: CopyButtonProps) {
const { copyToClipboard, hasCopied } = useCopyToClipboard();
return (
svg]:size-3.5", className)}
onClick={() => copyToClipboard(textToCopy, successMessage)}
{...props}
>
{hasCopied ? : }
Copy
);
}
================================================
FILE: components/debug-button.tsx
================================================
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Bug } from "lucide-react";
interface DebugButtonProps extends React.ComponentProps {
debug?: boolean;
}
const isDevMode = process.env.NODE_ENV === "development";
export function DebugButton({ className, debug = isDevMode, ...props }: DebugButtonProps) {
if (!debug) return null;
return (
);
}
================================================
FILE: components/dynamic-font-loader.tsx
================================================
"use client";
import { useMounted } from "@/hooks/use-mounted";
import { useEditorStore } from "@/store/editor-store";
import { extractFontFamily, getDefaultWeights } from "@/utils/fonts";
import { loadGoogleFont } from "@/utils/fonts/google-fonts";
import { useEffect, useMemo } from "react";
export function DynamicFontLoader() {
const { themeState } = useEditorStore();
const isMounted = useMounted();
const fontSans = themeState.styles.light["font-sans"];
const fontSerif = themeState.styles.light["font-serif"];
const fontMono = themeState.styles.light["font-mono"];
const currentFonts = useMemo(() => {
return {
sans: fontSans,
serif: fontSerif,
mono: fontMono,
} as const;
}, [fontSans, fontSerif, fontMono]);
useEffect(() => {
if (!isMounted) return;
try {
Object.entries(currentFonts).forEach(([_type, fontValue]) => {
const fontFamily = extractFontFamily(fontValue);
if (fontFamily) {
const weights = getDefaultWeights(["400", "500", "600", "700"]);
loadGoogleFont(fontFamily, weights);
}
});
} catch (e) {
console.warn("DynamicFontLoader: Failed to load Google fonts:", e);
}
}, [isMounted, currentFonts]);
return null;
}
================================================
FILE: components/dynamic-website-preview.tsx
================================================
"use client";
import Logo from "@/assets/logo.svg";
import { CodeBlock, CodeBlockCopyButton } from "@/components/ai-elements/code-block";
import { BlockViewer, BlockViewerDisplay, BlockViewerToolbar } from "@/components/block-viewer";
import { LoadingLogo } from "@/components/editor/ai/loading-logo";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useIframeThemeInjector } from "@/hooks/use-iframe-theme-injector";
import { useWebsitePreview } from "@/hooks/use-website-preview";
import { cn } from "@/lib/utils";
import { IframeStatus } from "@/types/live-preview-embed";
import {
AlertCircle,
CheckCircle,
CloudAlert,
ExternalLink,
Globe,
Info,
Loader,
RefreshCw,
X,
XCircle,
} from "lucide-react";
import { usePostHog } from "posthog-js/react";
import React, { useEffect, useRef } from "react";
/**
* Dynamic Website Preview - Load and theme external websites
*
* Usage Examples:
*
* // Same-origin mode (default) - direct DOM theme injection
*
*
* // Cross-origin mode - requires external sites to include embed script
*
*
* The allowCrossOrigin flag must be explicitly set to true to enable
* external website theming via the embed script.
*/
const SCRIPT_URL = "https://tweakcn.com/live-preview.min.js";
// Code snippets for quick installation across common setups
const HTML_SNIPPET = `\n`;
const NEXT_APP_SNIPPET = `// app/layout.tsx\nexport default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
{children}
)
}`;
const NEXT_PAGES_SNIPPET = `// pages/_document.tsx\n
import { Html, Head, Main, NextScript } from 'next/document';
export default function Document() {
return (
);
}`;
const VITE_SNIPPET = `\n
`;
const REMIX_SNIPPET = `// app/root.tsx\nimport { Links, Meta, Outlet, Scripts } from "@remix-run/react";
export default function App() {
return (
);
}`;
type DynamicWebsitePreviewContextType = ReturnType &
Omit, "ref">;
const DynamicWebsitePreviewContext = React.createContext(
null
);
function useDynamicWebsitePreview() {
const context = React.useContext(DynamicWebsitePreviewContext);
if (!context) {
throw new Error(
"useDynamicWebsitePreview must be used within a DynamicWebsitePreviewProvider."
);
}
return context;
}
function DynamicWebsitePreviewProvider({
children,
allowCrossOrigin = false,
}: {
children: React.ReactNode;
allowCrossOrigin?: boolean;
}) {
const websitePreviewState = useWebsitePreview({ allowCrossOrigin });
const posthog = usePostHog();
const { status, retryValidation, themeInjectionError } = useIframeThemeInjector({
allowCrossOrigin: allowCrossOrigin && !!websitePreviewState.currentUrl,
iframeRef: websitePreviewState.iframeRef,
});
const statusRef = useRef(status);
// eslint-disable-next-line
statusRef.current = status;
useEffect(() => {
if (websitePreviewState.currentUrl) {
setTimeout(() => {
// capturing after 1s delay so status is finalized
posthog.capture("DYNAMIC_PREVIEW_LOADED", {
previewUrl: websitePreviewState.currentUrl,
status: statusRef.current,
});
}, 1000);
}
}, [websitePreviewState.currentUrl, posthog]);
const contextValue = {
...websitePreviewState,
status,
retryValidation,
themeInjectionError,
};
return (
{children}
);
}
export function DynamicWebsitePreview({
className,
name,
allowCrossOrigin = false,
...props
}: React.ComponentPropsWithoutRef<"div"> & {
name: string;
allowCrossOrigin?: boolean;
}) {
return (
} className="bg-muted h-fit" />
);
}
function DynamicWebsitePreviewContent({ name }: { name: string }) {
const { currentUrl, error: previewError } = useDynamicWebsitePreview();
if (!currentUrl && !previewError) {
return ;
}
if (previewError) {
return ;
}
return ;
}
function Controls() {
const {
inputUrl,
setInputUrl,
currentUrl,
isLoading: previewIsLoading,
loadUrl,
refreshIframe,
openInNewTab,
reset,
allowCrossOrigin,
} = useDynamicWebsitePreview();
const handleReset = () => {
if (currentUrl) {
reset();
setInputUrl("");
} else {
setInputUrl("");
}
};
return (
);
}
function NoWebsitePreviewLoaded() {
return (
Preview your Website in tweakcn
1.
Add the script below to your website based on your framework
2.
Paste your website's URL (e.g.,{" "}
http://localhost:3000) above to preview it with
the theme applied in real-time
Script Tag
Next.js (App)
Next.js (Pages)
Vite
Remix
);
}
function WebsitePreviewLoading() {
return (
);
}
function WebsitePreviewError({ error }: { error: string }) {
return (
Error Loading Website Preview
{error}
);
}
/**
* Content component that manages the iframe and its loading states
* Theme injection is now handled entirely by the useIframeThemeInjector hook
*/
function WebsitePreview({ name }: { name: string }) {
const {
currentUrl,
isLoading: previewIsLoading,
status,
retryValidation,
allowCrossOrigin,
iframeRef,
handleIframeLoad,
handleIframeError,
themeInjectionError,
} = useDynamicWebsitePreview();
return (
{previewIsLoading && (
)}
{!previewIsLoading && !!status && allowCrossOrigin && (
)}
);
}
const ConnectionStatus = React.memo(
({
status,
retryValidation,
isLoading,
errorMsg,
}: {
status: IframeStatus;
retryValidation: () => void;
isLoading: boolean;
errorMsg?: string | null;
}) => {
const [isVisible, setIsVisible] = React.useState(false);
const [displayedStatus, setDisplayedStatus] = React.useState(status);
const showTimeoutRef = React.useRef(null);
const hideTimeoutRef = React.useRef(null);
const hasShownSupportedRef = React.useRef(false);
React.useEffect(() => {
// Clear any existing timeouts
if (showTimeoutRef.current) {
clearTimeout(showTimeoutRef.current);
showTimeoutRef.current = null;
}
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
// If we've already shown "supported" and hidden it, don't show it again
// unless there was an error state in between
if (status === "supported" && hasShownSupportedRef.current) {
return;
}
// Reset the flag if we hit an error state
if (status === "missing" || status === "unsupported" || status === "error") {
hasShownSupportedRef.current = false;
}
// Debounce: Wait 1s before showing the status to avoid flashing
showTimeoutRef.current = setTimeout(() => {
setDisplayedStatus(status);
setIsVisible(true);
// Auto-hide after delay only for "supported" status
if (status === "supported") {
hasShownSupportedRef.current = true;
hideTimeoutRef.current = setTimeout(() => {
setIsVisible(false);
}, 2000);
}
}, 500);
return () => {
if (showTimeoutRef.current) {
clearTimeout(showTimeoutRef.current);
}
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
};
}, [status]);
if (isLoading || status === "unknown" || !isVisible) return null;
return (
{errorMsg ? (
{ICONS[displayedStatus]}
) : (
ICONS[displayedStatus]
)}
{TEXTS[displayedStatus]}
{(displayedStatus === "missing" ||
displayedStatus === "unsupported" ||
displayedStatus === "error") && (
Retry
)}
);
}
);
ConnectionStatus.displayName = "ConnectionStatus";
const ICONS: Record = {
unknown: null,
checking: ,
connected: ,
supported: ,
unsupported: ,
missing: ,
error: ,
};
const TEXTS: Record = {
unknown: "",
checking: "Checking connection",
connected: "Connected",
supported: "Live preview enabled",
unsupported: "Unsupported site",
missing: "Script not found",
error: "An error occurred",
};
================================================
FILE: components/editor/action-bar/action-bar.tsx
================================================
"use client";
import { ActionBarButtons } from "@/components/editor/action-bar/components/action-bar-buttons";
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
import { useDialogActions } from "@/hooks/use-dialog-actions";
export function ActionBar() {
const { isCreatingTheme, handleSaveClick, handleShareClick, setCssImportOpen, setCodePanelOpen } =
useDialogActions();
return (
setCssImportOpen(true)}
onCodeClick={() => setCodePanelOpen(true)}
onSaveClick={() => handleSaveClick()}
isSaving={isCreatingTheme}
onShareClick={handleShareClick}
/>
);
}
================================================
FILE: components/editor/action-bar/components/action-bar-buttons.tsx
================================================
import { Separator } from "@/components/ui/separator";
import { useAIThemeGenerationCore } from "@/hooks/use-ai-theme-generation-core";
import { useEditorStore } from "@/store/editor-store";
import { useThemePresetStore } from "@/store/theme-preset-store";
import { useThemesData } from "@/hooks/themes";
import { CodeButton } from "./code-button";
import { ImportButton } from "./import-button";
import { MoreOptions } from "./more-options";
import { PublishButton } from "./publish-button";
import { ResetButton } from "./reset-button";
import { SaveButton } from "./save-button";
import { ShareButton } from "./share-button";
import { ThemeToggle } from "./theme-toggle";
import { UndoRedoButtons } from "./undo-redo-buttons";
import { useMemo } from "react";
interface ActionBarButtonsProps {
onImportClick: () => void;
onCodeClick: () => void;
onSaveClick: () => void;
onShareClick: (id?: string) => void;
isSaving: boolean;
}
export function ActionBarButtons({
onImportClick,
onCodeClick,
onSaveClick,
onShareClick,
isSaving,
}: ActionBarButtonsProps) {
const { themeState, resetToCurrentPreset, hasUnsavedChanges } = useEditorStore();
const { isGeneratingTheme } = useAIThemeGenerationCore();
const { getPreset } = useThemePresetStore();
const { data: themes } = useThemesData();
const currentPreset = themeState?.preset ? getPreset(themeState?.preset) : undefined;
const isSavedPreset = !!currentPreset && currentPreset.source === "SAVED";
const isPublished = useMemo(() => {
if (!isSavedPreset || !themes || !themeState.preset) return false;
const theme = themes.find((t) => t.id === themeState.preset);
return theme?.isPublished ?? false;
}, [isSavedPreset, themes, themeState.preset]);
const handleReset = () => {
resetToCurrentPreset();
};
return (
onShareClick(themeState.preset)} disabled={isGeneratingTheme} />
{isSavedPreset && !hasUnsavedChanges() ? (
) : (
)}
);
}
================================================
FILE: components/editor/action-bar/components/ai-generate-button.tsx
================================================
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface AIGenerateButtonProps {
onClick: () => void;
}
export function AIGenerateButton({ onClick }: AIGenerateButtonProps) {
return (
Generate
Generate theme with AI
);
}
================================================
FILE: components/editor/action-bar/components/code-button.tsx
================================================
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Braces } from "lucide-react";
interface CodeButtonProps extends React.ComponentProps {}
export function CodeButton({ className, ...props }: CodeButtonProps) {
return (
Code
);
}
================================================
FILE: components/editor/action-bar/components/import-button.tsx
================================================
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { FileCode } from "lucide-react";
interface ImportButtonProps extends React.ComponentProps {}
export function ImportButton({ className, ...props }: ImportButtonProps) {
return (
Import
);
}
================================================
FILE: components/editor/action-bar/components/mcp-dialog.tsx
================================================
import {
Tabs,
TabsContent,
TabsIndicator,
TabsList,
TabsTrigger,
} from "@/components/ui/base-ui-tabs";
import { Button } from "@/components/ui/button";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/revola";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
import { Check, Copy } from "lucide-react";
import { usePostHog } from "posthog-js/react";
interface MCPDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const mcpConfig = {
mcpServers: {
shadcn: {
command: "npx",
args: ["-y", "shadcn@canary", "registry:mcp"],
env: {
REGISTRY_URL: "https://tweakcn.com/r/themes/registry.json",
},
},
},
};
export function MCPDialog({ open, onOpenChange }: MCPDialogProps) {
const { hasCopied, copyToClipboard } = useCopyToClipboard();
const posthog = usePostHog();
const handleCopy = async (config: typeof mcpConfig) => {
copyToClipboard(JSON.stringify(config, null, 2));
posthog.capture("COPY_MCP_SETUP");
};
return (
Setup MCP
Use the code below to configure the registry in your IDE.
Cursor
Windsurf
Copy and paste the code into{" "}
.cursor/mcp.json
Copy and paste the code into{" "}
.codeium/windsurf/mcp_config.json
handleCopy(mcpConfig)}
className="h-8"
aria-label={hasCopied ? "Copied to clipboard" : "Copy to clipboard"}
>
{hasCopied ? (
<>
Copied
>
) : (
<>
Copy
>
)}
{JSON.stringify(mcpConfig, null, 2)}
);
}
================================================
FILE: components/editor/action-bar/components/more-options.tsx
================================================
import McpIcon from "@/assets/mcp.svg";
import ContrastChecker from "@/components/editor/contrast-checker";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useEditorStore } from "@/store/editor-store";
import { MoreVertical } from "lucide-react";
import { useState } from "react";
import { MCPDialog } from "./mcp-dialog";
interface MoreOptionsProps extends React.ComponentProps {}
export function MoreOptions({ ...props }: MoreOptionsProps) {
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
const { themeState } = useEditorStore();
return (
<>
setMcpDialogOpen(true)} asChild>
MCP
e.preventDefault()} asChild>
>
);
}
================================================
FILE: components/editor/action-bar/components/publish-button.tsx
================================================
"use client";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { TagSelector } from "@/components/tag-selector";
import { cn } from "@/lib/utils";
import { Globe, Loader2 } from "lucide-react";
import { useState } from "react";
import { usePublishTheme } from "@/hooks/themes";
interface PublishButtonProps {
themeId: string;
isPublished: boolean;
disabled?: boolean;
className?: string;
}
export function PublishButton({
themeId,
isPublished,
disabled,
className,
}: PublishButtonProps) {
const publishMutation = usePublishTheme();
const [showDialog, setShowDialog] = useState(false);
const [selectedTags, setSelectedTags] = useState([]);
if (isPublished) {
return (
Published
);
}
const handleConfirmPublish = () => {
publishMutation.mutate(
{ themeId, tags: selectedTags },
{
onSuccess: () => {
setShowDialog(false);
setSelectedTags([]);
},
}
);
};
return (
<>
setShowDialog(true)}
>
{publishMutation.isPending ? (
) : (
)}
Publish
Publish to the community?
Your theme will be publicly visible on the community page. You can
unpublish it at any time.
setShowDialog(false)}>
Cancel
{publishMutation.isPending ? (
<>
Publishing...
>
) : (
"Publish"
)}
>
);
}
================================================
FILE: components/editor/action-bar/components/reset-button.tsx
================================================
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { RefreshCw } from "lucide-react";
interface ResetButtonProps extends React.ComponentProps {}
export function ResetButton({ className, ...props }: ResetButtonProps) {
return (
Reset
);
}
================================================
FILE: components/editor/action-bar/components/save-button.tsx
================================================
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Heart, Loader2 } from "lucide-react";
interface SaveButtonProps extends React.ComponentProps {
isSaving: boolean;
}
export function SaveButton({ isSaving, disabled, className, ...props }: SaveButtonProps) {
return (
{isSaving ? : }
Save
);
}
================================================
FILE: components/editor/action-bar/components/share-button.tsx
================================================
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Loader2, Share2 } from "lucide-react";
interface ShareButtonProps extends React.ComponentProps {
isSharing?: boolean;
}
export function ShareButton({
onClick,
isSharing,
disabled,
className,
...props
}: ShareButtonProps) {
return (
{isSharing ? (
) : (
)}
Share
);
}
================================================
FILE: components/editor/action-bar/components/theme-toggle.tsx
================================================
import { useTheme } from "@/components/theme-provider";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { cn } from "@/lib/utils";
import { Switch as SwitchPrimitives } from "radix-ui";
import { Moon, Sun } from "lucide-react";
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
const handleThemeToggle = (event: React.MouseEvent) => {
const { clientX: x, clientY: y } = event;
toggleTheme({ x, y });
};
return (
{theme === "dark" ? : }
);
}
================================================
FILE: components/editor/action-bar/components/undo-redo-buttons.tsx
================================================
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { useEditorStore } from "@/store/editor-store";
import { Redo, Undo } from "lucide-react";
interface UndoRedoButtonsProps extends React.ComponentProps {}
export function UndoRedoButtons({ disabled, ...props }: UndoRedoButtonsProps) {
const { undo, redo, canUndo, canRedo } = useEditorStore();
return (
);
}
================================================
FILE: components/editor/ai/ai-chat-form-body.tsx
================================================
"use client";
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
import { AI_PROMPT_CHARACTER_LIMIT } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { JSONContent } from "@tiptap/react";
import dynamic from "next/dynamic";
import { DragAndDropImageUploader } from "./drag-and-drop-image-uploader";
import { UploadedImagePreview } from "./uploaded-image-preview";
const CustomTextarea = dynamic(() => import("@/components/editor/custom-textarea"), {
ssr: false,
loading: () => (
),
});
interface AIChatFormBodyProps {
isUserDragging: boolean;
disabled: boolean;
canSubmit: boolean;
uploadedImages: { url: string; loading: boolean }[];
handleImagesUpload: (files: File[]) => void;
handleImageRemove: (index: number) => void;
handleContentChange: (jsonContent: JSONContent) => void;
handleGenerate: () => void;
initialEditorContent: JSONContent | undefined;
externalEditorContent?: JSONContent;
textareaKey?: string | number;
isStreamingContent?: boolean;
}
export function AIChatFormBody({
isUserDragging,
disabled,
canSubmit,
uploadedImages,
handleImagesUpload,
handleImageRemove,
handleContentChange,
handleGenerate,
initialEditorContent,
externalEditorContent,
textareaKey,
isStreamingContent = false,
}: AIChatFormBodyProps) {
return (
<>
{isUserDragging && (
img.loading)}
/>
)}
{uploadedImages.length > 0 && !isUserDragging && (
{uploadedImages.map((img, idx) => (
handleImageRemove(idx)}
/>
))}
)}
>
);
}
================================================
FILE: components/editor/ai/alert-banner.tsx
================================================
"use client";
import { Button } from "@/components/ui/button";
import { useSubscription } from "@/hooks/use-subscription";
import { authClient } from "@/lib/auth-client";
import { AI_REQUEST_FREE_TIER_LIMIT } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { X } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
export function AlertBanner() {
const [showBanner, setShowBanner] = useState(false);
const { data: session } = authClient.useSession();
const isLoggedIn = !!session?.user.id;
const { subscriptionStatus, isPending } = useSubscription();
const isPro = subscriptionStatus?.isSubscribed ?? false;
const freeProMessagesLeft = subscriptionStatus?.requestsRemaining ?? 0;
useEffect(() => {
let timer: NodeJS.Timeout;
const shouldShowBanner =
isLoggedIn && !isPro && freeProMessagesLeft <= AI_REQUEST_FREE_TIER_LIMIT;
if (shouldShowBanner) {
if (showBanner) return;
timer = setTimeout(() => setShowBanner(true), 1000);
} else {
setShowBanner(false);
}
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoggedIn, isPro, freeProMessagesLeft]);
const getBannerContent = () => {
if (isLoggedIn && !isPro && freeProMessagesLeft > 0) {
return (
You have {freeProMessagesLeft} Free
{` Pro `}
messages left.
);
}
if (isLoggedIn && !isPro && freeProMessagesLeft <= 0) {
return (
Upgrade to Pro to unlock unlimited
requests.
);
}
};
if (isPro || isPending) return null;
return (
{getBannerContent()}
Upgrade
setShowBanner(false)}
>
);
}
export function BannerWrapper({ children, show }: { children: React.ReactNode; show: boolean }) {
return (
);
}
================================================
FILE: components/editor/ai/chat-image-preview.tsx
================================================
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogTrigger,
} from "@/components/ui/revola";
import { cn } from "@/lib/utils";
import { ImageIcon } from "lucide-react";
import Image from "next/image";
import { ComponentProps } from "react";
interface ChatImagePreviewProps extends ComponentProps {
name?: string;
}
export function ChatImagePreview({ name, src, className, alt, ...props }: ChatImagePreviewProps) {
return (
Image Preview
);
}
================================================
FILE: components/editor/ai/chat-input.tsx
================================================
"use client";
import { Loader } from "@/components/loader";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { useAIChatForm } from "@/hooks/use-ai-chat-form";
import { useAIEnhancePrompt } from "@/hooks/use-ai-enhance-prompt";
import { useChatContext } from "@/hooks/use-chat-context";
import { useGuards } from "@/hooks/use-guards";
import { useSubscription } from "@/hooks/use-subscription";
import { usePostLoginAction } from "@/hooks/use-post-login-action";
import { MAX_IMAGE_FILES } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { AIPromptData } from "@/types/ai";
import { ArrowUp, Loader as LoaderIcon, Plus, StopCircle } from "lucide-react";
import { AIChatFormBody } from "./ai-chat-form-body";
import { AlertBanner, BannerWrapper } from "./alert-banner";
import { EnhancePromptButton } from "./enhance-prompt-button";
import { ImageUploader } from "./image-uploader";
type ThemeGenerationPayload = {
promptData: AIPromptData;
options: {
shouldClearLocalDraft?: boolean;
};
};
interface ChatInputProps {
onThemeGeneration: (promptData: AIPromptData) => Promise;
isGeneratingTheme: boolean;
onCancelThemeGeneration: () => void;
}
export function ChatInput({
onThemeGeneration,
isGeneratingTheme,
onCancelThemeGeneration,
}: ChatInputProps) {
const { messages, startNewChat } = useChatContext();
const { checkValidSession, checkValidSubscription } = useGuards();
const { subscriptionStatus } = useSubscription();
const isPro = subscriptionStatus?.isSubscribed ?? false;
const hasFreeRequestsLeft = (subscriptionStatus?.requestsRemaining ?? 0) > 0;
const {
editorContentDraft,
handleContentChange,
promptData,
isEmptyPrompt,
clearLocalDraft,
uploadedImages,
fileInputRef,
handleImagesUpload,
handleImageRemove,
clearUploadedImages,
isSomeImageUploading,
isUserDragging,
isInitializing,
} = useAIChatForm();
const handleNewChat = () => {
startNewChat();
clearLocalDraft();
clearUploadedImages();
};
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 generateTheme = async (payload: ThemeGenerationPayload) => {
const { promptData, options } = payload;
if (options.shouldClearLocalDraft) {
clearLocalDraft();
clearUploadedImages();
}
onThemeGeneration(promptData);
};
const handleGenerateSubmit = async () => {
// 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;
const payload: ThemeGenerationPayload = {
promptData: {
...promptData,
images,
},
options: {
shouldClearLocalDraft: true,
},
};
if (!checkValidSession("signup", "AI_GENERATE_FROM_CHAT", payload)) return;
if (!checkValidSubscription()) return;
generateTheme(payload);
};
usePostLoginAction("AI_GENERATE_FROM_CHAT", (payload) => {
generateTheme(payload);
});
return (
New chat
{(isPro || hasFreeRequestsLeft) && promptData?.content ? (
) : null}
fileInputRef.current?.click()}
disabled={
isGeneratingTheme ||
isEnhancingPrompt ||
isInitializing ||
uploadedImages.some((img) => img.loading) ||
uploadedImages.length >= MAX_IMAGE_FILES
}
/>
{isGeneratingTheme ? (
Stop
) : (
{isGeneratingTheme ? : }
)}
);
}
================================================
FILE: components/editor/ai/chat-interface.tsx
================================================
"use client";
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 { toast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
import { AIPromptData } from "@/types/ai";
import dynamic from "next/dynamic";
import React from "react";
import { ChatInput } from "./chat-input";
import { ClosableSuggestedPillActions } from "./closeable-suggested-pill-actions";
const Messages = dynamic(() => import("./messages").then((mod) => mod.Messages), {
ssr: false,
});
const NoMessagesPlaceholder = dynamic(
() => import("./no-messages-placeholder").then((mod) => mod.NoMessagesPlaceholder),
{
ssr: false,
}
);
export function ChatInterface() {
const { messages, regenerate, resetMessagesUpToIndex } = useChatContext();
const { isGeneratingTheme, generateThemeCore, cancelThemeGeneration } =
useAIThemeGenerationCore();
const { checkValidSession, checkValidSubscription } = useGuards();
const hasMessages = messages.length > 0;
const [editingMessageIndex, setEditingMessageIndex] = React.useState(null);
const handleGenerateFromSuggestion = (promptData: AIPromptData | undefined) => {
if (!checkValidSession("signup", "AI_GENERATE_FROM_CHAT_SUGGESTION", { promptData })) return;
if (!checkValidSubscription()) return;
generateThemeCore(promptData);
};
const handleRetry = (messageIndex: number) => {
if (!checkValidSession("signup", "AI_GENERATE_RETRY", { messageIndex })) return;
if (!checkValidSubscription()) return;
setEditingMessageIndex(null);
const messageToRetry = messages[messageIndex];
if (messageToRetry) {
regenerate({ messageId: messageToRetry.id });
} else {
toast({
title: "Cannot retry this message",
description: "Seems like this message is not longer available.",
variant: "destructive",
});
}
};
const handleEdit = (messageIndex: number) => {
if (!checkValidSession()) return; // Simply act as an early return
setEditingMessageIndex(messageIndex);
};
const handleEditCancel = () => {
setEditingMessageIndex(null);
};
const handleEditSubmit = (messageIndex: number, promptData: AIPromptData) => {
if (!checkValidSession("signup", "AI_GENERATE_EDIT", { messageIndex, promptData })) {
return;
}
if (!checkValidSubscription()) return;
// Reset messages up to the edited message
resetMessagesUpToIndex(messageIndex);
setEditingMessageIndex(null);
generateThemeCore(promptData);
};
usePostLoginAction("AI_GENERATE_FROM_CHAT_SUGGESTION", ({ promptData }) => {
handleGenerateFromSuggestion(promptData);
});
usePostLoginAction("AI_GENERATE_RETRY", ({ messageIndex }) => {
handleRetry(messageIndex);
});
usePostLoginAction("AI_GENERATE_EDIT", ({ messageIndex, promptData }) => {
handleEditSubmit(messageIndex, promptData);
});
return (
{hasMessages ? (
) : (
)}
{/* Chat form input and suggestions */}
);
}
================================================
FILE: components/editor/ai/chat-theme-preview.tsx
================================================
import { Loader } from "@/components/loader";
import { useTheme } from "@/components/theme-provider";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { useFeedbackText } from "@/hooks/use-feedback-text";
import { cn } from "@/lib/utils";
import { ThemeStyles } from "@/types/theme";
import { applyGeneratedTheme } from "@/utils/ai/apply-theme";
import { AlertCircle, CheckCheck, ChevronsUpDown, Loader2, Zap } from "lucide-react";
import { ComponentProps, useState } from "react";
type ChatThemePreviewProps = ComponentProps<"div"> & ChatThemePreviewPropsBase;
type ChatThemePreviewPropsBase =
| {
status: "loading";
expanded?: boolean;
themeStyles?: Partial;
}
| {
status: "error";
expanded?: boolean;
errorText?: string;
themeStyles?: Partial;
}
| {
status: "complete";
expanded?: boolean;
themeStyles: ThemeStyles;
};
export function ChatThemePreview({
status,
expanded = false,
themeStyles,
className,
children,
...props
}: ChatThemePreviewProps) {
const [isExpanded, setIsExpanded] = useState(expanded);
const { theme: mode } = useTheme();
const loading = status === "loading";
const feedbackText = useFeedbackText({
showFeedbackText: loading,
feedbackMessages: FEEDBACK_MESSAGES,
rotationIntervalInSeconds: 8,
});
if (loading) {
return (
);
}
if (status === "error")
return (
Generation cancelled or failed.
);
const handleApplyTheme = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (status === "complete") applyGeneratedTheme(themeStyles);
};
if (status === "complete")
return (
setIsExpanded(!isExpanded)}
>
Apply
);
return null;
}
const FEEDBACK_MESSAGES = [
"Generating your theme...",
"Tweaking color tokens...",
"Making a good theme takes time...",
"Still working on your theme...",
"Almost there...",
];
================================================
FILE: components/editor/ai/closeable-suggested-pill-actions.tsx
================================================
"use client";
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
import { Button } from "@/components/ui/button";
import { usePreferencesStore } from "@/store/preferences-store";
import { AIPromptData } from "@/types/ai";
import { createCurrentThemePrompt } from "@/utils/ai/ai-prompt";
import { PROMPTS } from "@/utils/ai/prompts";
import { Sparkles, X } from "lucide-react";
import { PillActionButton } from "./pill-action-button";
export function ClosableSuggestedPillActions({
onGenerateTheme,
isGeneratingTheme,
}: {
onGenerateTheme: (promptData: AIPromptData) => void;
isGeneratingTheme: boolean;
}) {
const { chatSuggestionsOpen, setChatSuggestionsOpen } = usePreferencesStore();
const handleSetPrompt = async (prompt: string) => {
const promptData = createCurrentThemePrompt({ prompt });
onGenerateTheme(promptData);
};
if (!chatSuggestionsOpen) return null;
return (
Suggestions
setChatSuggestionsOpen(false)}
>
{Object.entries(PROMPTS).map(([key, { label, prompt }]) => (
handleSetPrompt(prompt)}
disabled={isGeneratingTheme}
>
{label}
))}
);
}
================================================
FILE: components/editor/ai/drag-and-drop-image-uploader.tsx
================================================
import { cn } from "@/lib/utils";
import { Upload } from "lucide-react";
import { useDropzone } from "react-dropzone";
interface DragAndDropImageUploaderProps {
onDrop: (files: File[]) => void;
disabled?: boolean;
className?: string;
}
export function DragAndDropImageUploader({
onDrop,
disabled,
className,
}: DragAndDropImageUploaderProps) {
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
multiple: true,
noClick: true,
disabled,
accept: {
"image/jpeg": [],
"image/jpg": [],
"image/png": [],
"image/webp": [],
"image/svg+xml": [],
},
});
return (
<>
>
);
}
================================================
FILE: components/editor/ai/enhance-prompt-button.tsx
================================================
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { CircleStop, WandSparkles } from "lucide-react";
interface EnhancePromptButtonProps extends React.ComponentProps {
isEnhancing: boolean;
onStart: () => void;
onStop: () => void;
}
export function EnhancePromptButton({
className,
disabled,
isEnhancing,
onStart,
onStop,
...props
}: EnhancePromptButtonProps) {
return (
{isEnhancing ? : }
);
}
================================================
FILE: components/editor/ai/image-uploader.tsx
================================================
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { MAX_IMAGE_FILE_SIZE, MAX_IMAGE_FILES } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { ALLOWED_IMAGE_TYPES } from "@/utils/ai/image-upload";
import { ImagePlus } from "lucide-react";
import { ComponentProps } from "react";
interface ImageUploaderProps extends ComponentProps {
fileInputRef: React.RefObject;
onImagesUpload: (files: File[]) => void;
}
export function ImageUploader({
fileInputRef,
onImagesUpload,
disabled,
className,
...props
}: ImageUploaderProps) {
const handleImagesUpload = (event: React.ChangeEvent) => {
const fileList = event.target.files;
if (!fileList) return;
const files = Array.from(fileList);
onImagesUpload(files);
};
return (
<>
Image
>
);
}
================================================
FILE: components/editor/ai/loading-logo.tsx
================================================
import Logo from "@/assets/logo.svg";
import { Sparkle } from "lucide-react";
export function LoadingLogo() {
return (
<>
>
);
}
================================================
FILE: components/editor/ai/message-actions.tsx
================================================
import { CopyButton } from "@/components/copy-button";
import { DebugButton } from "@/components/debug-button";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { type ChatMessage } from "@/types/ai";
import { Edit, RefreshCw } from "lucide-react";
type MessageActionsProps = {
message: ChatMessage;
onRetry?: () => void;
onEdit?: () => void;
isGeneratingTheme: boolean;
isEditing?: boolean;
};
export function MessageActions({
message,
onRetry,
onEdit,
isGeneratingTheme,
isEditing,
}: MessageActionsProps) {
const isUser = message.role === "user";
const isAssistant = message.role === "assistant";
const getCopyContent = () => {
const convertTextPartsToString = (message: ChatMessage) => {
return (
message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n") ?? ""
);
};
if (isUser && message.metadata) {
return message.metadata.promptData?.content ?? convertTextPartsToString(message);
}
return convertTextPartsToString(message);
};
return (
{onRetry && (
)}
{onEdit && isUser && (
)}
{
console.log("----- 🐛 Debugging Message -----");
console.dir(message, { depth: null });
}}
/>
);
}
================================================
FILE: components/editor/ai/message-edit-form.tsx
================================================
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { useDocumentDragAndDropIntent } from "@/hooks/use-document-drag-and-drop-intent";
import { useImageUpload } from "@/hooks/use-image-upload";
import { imageUploadReducer } from "@/hooks/use-image-upload-reducer";
import { AI_PROMPT_CHARACTER_LIMIT, MAX_IMAGE_FILES, MAX_IMAGE_FILE_SIZE } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { AIPromptData, type ChatMessage } from "@/types/ai";
import {
convertJSONContentToPromptData,
convertPromptDataToJSONContent,
isEmptyPromptData,
} from "@/utils/ai/ai-prompt";
import { JSONContent } from "@tiptap/react";
import { Check, X } from "lucide-react";
import { useMemo, useReducer, useState } from "react";
import CustomTextarea from "../custom-textarea";
import { DragAndDropImageUploader } from "./drag-and-drop-image-uploader";
import { ImageUploader } from "./image-uploader";
import { UploadedImagePreview } from "./uploaded-image-preview";
interface MessageEditFormProps {
message: ChatMessage;
onEditSubmit: (newPromptData: AIPromptData) => void;
onEditCancel: () => void;
disabled: boolean;
}
export function MessageEditForm({
message,
onEditSubmit,
onEditCancel,
disabled,
}: MessageEditFormProps) {
const promptData = message.metadata?.promptData;
const [editJsonContent, setEditJsonContent] = useState(() => {
if (!promptData) return { type: "doc", content: [] };
return convertPromptDataToJSONContent(promptData);
});
const [uploadedImages, dispatch] = useReducer(
imageUploadReducer,
promptData?.images ? promptData.images.map((img) => ({ ...img, loading: false })) : []
);
const {
fileInputRef,
handleImagesUpload,
handleImageRemove,
isSomeImageUploading,
canUploadMore,
} = useImageUpload({
maxFiles: MAX_IMAGE_FILES,
maxFileSize: MAX_IMAGE_FILE_SIZE,
images: uploadedImages,
dispatch,
});
const newPromptData = useMemo(
() => convertJSONContentToPromptData(editJsonContent),
[editJsonContent]
);
const isEmptyPrompt = isEmptyPromptData(newPromptData, uploadedImages);
const handleEditConfirm = () => {
if (isEmptyPrompt) return;
onEditSubmit({
...promptData,
...newPromptData,
images: uploadedImages.filter((img) => !img.loading).map(({ url }) => ({ url })),
});
};
const { isUserDragging } = useDocumentDragAndDropIntent();
return (
{isUserDragging && (
img.loading)}
/>
)}
{uploadedImages.length > 0 && !isUserDragging && (
{uploadedImages.map((img, idx) => (
handleImageRemove(idx)}
showPreviewOnHover={false}
/>
))}
)}
fileInputRef.current?.click()}
disabled={!canUploadMore}
/>
);
}
================================================
FILE: components/editor/ai/message.tsx
================================================
import Logo from "@/assets/logo.svg";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/store/editor-store";
import { AIPromptData, type ChatMessage } from "@/types/ai";
import { buildAIPromptRender } from "@/utils/ai/ai-prompt";
import ColorPreview from "../theme-preview/color-preview";
import { ChatImagePreview } from "./chat-image-preview";
import { ChatThemePreview } from "./chat-theme-preview";
import { LoadingLogo } from "./loading-logo";
import { MessageActions } from "./message-actions";
import { MessageEditForm } from "./message-edit-form";
import { StreamText } from "./stream-text";
type MessageProps = {
message: ChatMessage;
onRetry: () => void;
isEditing: boolean;
onEdit: () => void;
onEditSubmit: (newPromptData: AIPromptData) => void;
onEditCancel: () => void;
isLastMessageStreaming: boolean;
isGeneratingTheme: boolean;
};
export default function Message({
message,
onRetry,
isEditing,
onEdit,
onEditSubmit,
onEditCancel,
isLastMessageStreaming,
isGeneratingTheme,
}: MessageProps) {
const isUser = message.role === "user";
const isAssistant = message.role === "assistant";
const showMessageActions = !isLastMessageStreaming;
return (
{isUser && (
)}
{isAssistant && (
)}
{showMessageActions && (
)}
);
}
interface AssistantMessageProps {
message: ChatMessage;
isLastMessageStreaming: boolean;
}
function AssistantMessage({ message, isLastMessageStreaming }: AssistantMessageProps) {
const { themeState } = useEditorStore();
return (
{isLastMessageStreaming ? (
) : (
)}
{message.parts.map((part, idx) => {
const { type } = part;
const key = `message-${message.id}-part-${idx}`;
if (type === "text") {
return (
);
}
if (type === "tool-generateTheme") {
const { state } = part;
if (state === "output-available") {
const themeStyles = part.output;
return (
);
}
if (state === "output-error") {
return
;
}
return
;
}
})}
);
}
interface UserMessageProps {
message: ChatMessage;
isEditing: boolean;
onRetry: () => void;
onEdit: () => void;
onEditSubmit: (newPromptData: AIPromptData) => void;
onEditCancel: () => void;
isGeneratingTheme: boolean;
}
function UserMessage({
message,
isEditing,
onEditSubmit,
onEditCancel,
isGeneratingTheme,
}: UserMessageProps) {
const promptData = message.metadata?.promptData;
const shouldDisplayMsgContent = promptData?.content?.trim() != "";
const getDisplayContent = () => {
if (promptData) {
return buildAIPromptRender(promptData);
}
return message.parts.map((part) => (part.type === "text" ? part.text : "")).join("");
};
const msgContent = getDisplayContent();
const getImagesToDisplay = () => {
const images = promptData?.images ?? [];
if (images.length === 1) {
return (
);
} else if (images.length > 1) {
return (
{images.map((image, idx) => (
))}
);
}
return null;
};
const msgImages = getImagesToDisplay();
if (isEditing) {
return (
);
}
return (
{msgImages}
{shouldDisplayMsgContent && (
{msgContent}
)}
);
}
================================================
FILE: components/editor/ai/messages.tsx
================================================
import Logo from "@/assets/logo.svg";
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import { Loader } from "@/components/loader";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { useChatContext } from "@/hooks/use-chat-context";
import { useScrollStartEnd } from "@/hooks/use-scroll-start-end";
import { cn } from "@/lib/utils";
import { AIPromptData, type ChatMessage } from "@/types/ai";
import {
filterMessagesToDisplay,
getLastAssistantMessage,
getUserMessages,
} from "@/utils/ai/messages";
import { parseAiSdkTransportError } from "@/lib/ai/parse-ai-sdk-transport-error";
import { X } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { LoadingLogo } from "./loading-logo";
import Message from "./message";
type ChatMessagesProps = {
messages: ChatMessage[];
onRetry: (messageIndex: number) => void;
onEdit: (messageIndex: number) => void;
onEditSubmit: (messageIndex: number, newPromptData: AIPromptData) => void;
onEditCancel: () => void;
editingMessageIndex?: number | null;
isGeneratingTheme: boolean;
};
export function Messages({
messages,
onRetry,
onEdit,
onEditSubmit,
onEditCancel,
editingMessageIndex,
isGeneratingTheme,
}: ChatMessagesProps) {
const { status, error, clearError } = useChatContext();
const { isScrollStart, isScrollEnd, scrollStartRef, scrollEndRef } = useScrollStartEnd();
const previousUserMsgLength = useRef(
messages.filter((message) => message.role === "user").length
);
// Scroll to the bottom of the conversation when a new user message is added
useEffect(() => {
const scrollEndElement = scrollEndRef.current;
if (!scrollEndElement) return;
const currentUserMsgCount = getUserMessages(messages).length;
const didUserMsgCountChange = previousUserMsgLength.current !== currentUserMsgCount;
if (!didUserMsgCountChange && status === "streaming") return;
previousUserMsgLength.current = currentUserMsgCount;
requestAnimationFrame(() => {
console.log("scrolling to end");
scrollEndElement.scrollIntoView({ behavior: "smooth", block: "start" });
});
}, [messages, status]);
const visibleMessages = useMemo(() => filterMessagesToDisplay(messages), [messages]);
const showLoadingMessage = useMemo(() => {
const isSubmitted = status === "submitted";
const isStreaming = status === "streaming";
const isError = status === "error";
const lastAssistantMsgHasText = getLastAssistantMessage(messages)?.parts.some(
(part) => part.type === "text" && Boolean(part.text)
);
return !isError && (isSubmitted || (isStreaming && !lastAssistantMsgHasText));
}, [status, messages]);
const errorText = useMemo(() => {
if (!error) return undefined;
const defaultMessage = "Failed to generate theme. Please try again.";
const normalized = parseAiSdkTransportError(error, defaultMessage);
return normalized.message ?? defaultMessage;
}, [error]);
return (
{/* Top fade out effect when scrolling */}
{visibleMessages.map((message, index) => {
const isLastMessage = index === messages.length - 1;
const isStreaming = status === "submitted" || status === "streaming";
const isLastMessageStreaming =
message.role === "assistant" && isStreaming && isLastMessage;
return (
onRetry(index)}
isEditing={editingMessageIndex === index}
onEdit={() => onEdit(index)}
onEditSubmit={(newPromptData) => onEditSubmit(index, newPromptData)}
onEditCancel={onEditCancel}
isLastMessageStreaming={isLastMessageStreaming}
isGeneratingTheme={isGeneratingTheme}
/>
);
})}
{/* Loading message when AI is generating */}
{showLoadingMessage && (
)}
{/* Error message when generating theme fails */}
{status === "error" && error && (
)}
{/* Bottom fade out effect when scrolling */}
);
}
================================================
FILE: components/editor/ai/no-messages-placeholder.tsx
================================================
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList } from "@/components/ui/tabs";
import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
import { AIPromptData } from "@/types/ai";
import { createCurrentThemePrompt, createPromptDataFromPreset } from "@/utils/ai/ai-prompt";
import { CREATE_PROMPTS, REMIX_PROMPTS, VARIANT_PROMPTS } from "@/utils/ai/prompts";
import { Blend, PaintRoller, WandSparkles } from "lucide-react";
import { ComponentProps, Fragment } from "react";
import TabsTriggerPill from "../theme-preview/tabs-trigger-pill";
export function NoMessagesPlaceholder({
onGenerateTheme,
isGeneratingTheme,
}: {
onGenerateTheme: (promptData: AIPromptData) => void;
isGeneratingTheme: boolean;
}) {
const { data: session } = authClient.useSession();
const userName = session?.user.name?.split(" ")[0];
const heading = `What can I help you theme${userName ? `, ${userName}` : ""}?`;
return (
{heading}
Create
Remix
Tweak
{CREATE_PROMPTS.map((prompt, index) => (
onGenerateTheme({
content: prompt.prompt,
mentions: [],
})
}
>
{prompt.displayContent}
{index < CREATE_PROMPTS.length - 1 && }
))}
{REMIX_PROMPTS.map((prompt, index) => (
onGenerateTheme(createPromptDataFromPreset(prompt.prompt, prompt.basePreset))
}
>
{prompt.displayContent}
{index < REMIX_PROMPTS.length - 1 && }
))}
{VARIANT_PROMPTS.map((prompt, index) => (
onGenerateTheme(createCurrentThemePrompt({ prompt: prompt.prompt }))}
>
{prompt.displayContent}
{index < VARIANT_PROMPTS.length - 1 && }
))}
);
}
interface PromptButtonProps extends ComponentProps {}
function PromptButton({ className, children, ...props }: PromptButtonProps) {
return (
{children}
);
}
================================================
FILE: components/editor/ai/pill-action-button.tsx
================================================
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ComponentProps } from "react";
export interface AIPillActionButtonProps extends ComponentProps {}
export function PillActionButton({
className,
children,
disabled,
...props
}: AIPillActionButtonProps) {
return (
svg]:size-3",
className
)}
disabled={disabled}
{...props}
>
{children}
);
}
================================================
FILE: components/editor/ai/stream-text.tsx
================================================
import { Response } from "@/components/ai-elements/response";
interface StreamTextProps {
text: string;
animate?: boolean;
markdown?: boolean;
className?: string;
}
export function StreamText({
text,
animate = false,
markdown = false,
className,
}: StreamTextProps) {
if (markdown)
return (
{text}
);
return {text} ;
}
================================================
FILE: components/editor/ai/uploaded-image-preview.tsx
================================================
"use client";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
import { Loader, X } from "lucide-react";
import Image from "next/image";
interface ImagePreviewProps {
src: string;
isImageLoading: boolean;
handleImageRemove: () => void;
showPreviewOnHover?: boolean;
}
export function UploadedImagePreview({
src,
isImageLoading,
handleImageRemove,
showPreviewOnHover = true,
}: ImagePreviewProps) {
if (isImageLoading) {
return (
);
}
return (
{showPreviewOnHover && (
)}
);
}
================================================
FILE: components/editor/code-panel-dialog.tsx
================================================
import CodePanel from "./code-panel";
import { ThemeEditorState } from "@/types/editor";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/revola";
interface CodePanelDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
themeEditorState: ThemeEditorState;
themeId?: string;
}
export function CodePanelDialog({ open, onOpenChange, themeEditorState, themeId }: CodePanelDialogProps) {
return (
Theme Code
View and copy the code for your theme.
);
}
================================================
FILE: components/editor/code-panel.tsx
================================================
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Copy, Check, Heart } from "lucide-react";
import { ThemeEditorState } from "@/types/editor";
import { ScrollArea, ScrollBar } from "../ui/scroll-area";
import { CodeBlock } from "@/components/ai-elements/code-block";
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
TabsIndicator,
} from "@/components/ui/base-ui-tabs";
import {
Select,
SelectContent,
SelectTrigger,
SelectValue,
SelectItem,
} from "@/components/ui/select";
import { usePostHog } from "posthog-js/react";
import { useEditorStore } from "@/store/editor-store";
import { usePreferencesStore } from "@/store/preferences-store";
import {
generateThemeCode,
generateTailwindConfigCode,
generateLayoutCode,
} from "@/utils/theme-style-generator";
import { useThemePresetStore } from "@/store/theme-preset-store";
import { useDialogActions } from "@/hooks/use-dialog-actions";
import { ColorFormat } from "@/types";
interface CodePanelProps {
themeEditorState: ThemeEditorState;
themeId?: string;
}
const CodePanel: React.FC = ({ themeEditorState, themeId }) => {
const [registryCopied, setRegistryCopied] = useState(false);
const [copied, setCopied] = useState(false);
const [activeTab, setActiveTab] = useState("index.css");
const posthog = usePostHog();
const { handleSaveClick } = useDialogActions();
const preset = useEditorStore((state) => state.themeState.preset);
const colorFormat = usePreferencesStore((state) => state.colorFormat);
const tailwindVersion = usePreferencesStore((state) => state.tailwindVersion);
const packageManager = usePreferencesStore((state) => state.packageManager);
const setColorFormat = usePreferencesStore((state) => state.setColorFormat);
const setTailwindVersion = usePreferencesStore((state) => state.setTailwindVersion);
const setPackageManager = usePreferencesStore((state) => state.setPackageManager);
const hasUnsavedChanges = useEditorStore((state) => state.hasUnsavedChanges);
const isSavedPreset = useThemePresetStore(
(state) => preset && state.getPreset(preset)?.source === "SAVED"
);
const getAvailableColorFormats = usePreferencesStore((state) => state.getAvailableColorFormats);
const code = generateThemeCode(themeEditorState, colorFormat, tailwindVersion);
const configCode = generateTailwindConfigCode(themeEditorState, colorFormat, tailwindVersion);
const layoutCode = generateLayoutCode(themeEditorState);
const getRegistryCommand = (id: string, isSaved: boolean) => {
const url = isSaved
? `https://tweakcn.com/r/themes/${id}`
: `https://tweakcn.com/r/themes/${id}.json`;
switch (packageManager) {
case "pnpm":
return `pnpm dlx shadcn@latest add ${url}`;
case "npm":
return `npx shadcn@latest add ${url}`;
case "yarn":
return `yarn dlx shadcn@latest add ${url}`;
case "bun":
return `bunx shadcn@latest add ${url}`;
}
};
const registryId = themeId ?? preset;
const isRegistrySaved = !!themeId || !!isSavedPreset;
const copyRegistryCommand = async () => {
try {
await navigator.clipboard.writeText(getRegistryCommand(registryId ?? "default", isRegistrySaved));
setRegistryCopied(true);
setTimeout(() => setRegistryCopied(false), 2000);
captureCopyEvent("COPY_REGISTRY_COMMAND");
} catch (err) {
console.error("Failed to copy text:", err);
}
};
const captureCopyEvent = (event: string) => {
posthog.capture(event, {
editorType: "theme",
preset,
colorFormat,
tailwindVersion,
});
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
captureCopyEvent("COPY_CODE");
} catch (err) {
console.error("Failed to copy text:", err);
}
};
const showRegistryCommand = useMemo(() => {
if (themeId) return true;
return preset && preset !== "default" && !hasUnsavedChanges();
}, [themeId, preset, hasUnsavedChanges]);
const PackageManagerHeader = ({ actionButton }: { actionButton: React.ReactNode }) => (
{(["pnpm", "npm", "yarn", "bun"] as const).map((pm) => (
setPackageManager(pm)}
className={`px-3 py-1.5 text-sm font-medium ${
packageManager === pm
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{pm}
))}
{actionButton}
);
return (
Theme Code
{registryCopied ? : }
) : (
handleSaveClick()}
className="ml-auto h-8 gap-1"
aria-label="Save theme"
>
Save
)
}
/>
{showRegistryCommand ? (
{getRegistryCommand(registryId as string, isRegistrySaved)}
) : (
Save your theme to get the registry command
)}
{
setTailwindVersion(value);
if (value === "4" && colorFormat === "hsl") {
setColorFormat("oklch");
}
if (activeTab === "tailwind.config.ts") {
setActiveTab("index.css");
}
}}
>
Tailwind v3
Tailwind v4
setColorFormat(value)}>
{getAvailableColorFormats().map((colorFormat) => (
{colorFormat}
))}
index.css
{tailwindVersion === "3" && (
tailwind.config.ts
)}
layout.tsx (Next.js)
copyToClipboard(
activeTab === "index.css"
? code
: activeTab === "layout.tsx"
? layoutCode
: configCode
)
}
className="h-8"
aria-label={copied ? "Copied to clipboard" : "Copy to clipboard"}
>
{copied ? (
<>
Copied
>
) : (
<>
Copy
>
)}
{tailwindVersion === "3" && (
)}
);
};
export default CodePanel;
================================================
FILE: components/editor/color-picker.tsx
================================================
import { DEBOUNCE_DELAY } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { useColorControlFocus } from "@/store/color-control-focus-store";
import { ColorPickerProps } from "@/types";
import { debounce } from "@/utils/debounce";
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { ColorSelectorPopover } from "./color-selector-popover";
import { SectionContext } from "./section-context";
const ColorPicker = ({ color, onChange, label, name }: ColorPickerProps) => {
const [isOpen, setIsOpen] = useState(false);
const [shouldAnimate, setShouldAnimate] = useState(false);
const rootRef = useRef(null);
const textInputRef = useRef(null);
const animationTimerRef = useRef(null);
const sectionCtx = useContext(SectionContext);
const { registerColor, unregisterColor, highlightTarget } = useColorControlFocus();
useEffect(() => {
if (!name) return;
registerColor(name, rootRef.current);
return () => unregisterColor(name);
}, [name, registerColor, unregisterColor]);
useEffect(() => {
if (textInputRef.current) {
textInputRef.current.value = color;
}
}, [color]);
const debouncedOnChange = useMemo(
() =>
debounce((value: string) => {
onChange(value);
}, DEBOUNCE_DELAY),
[onChange]
);
const handleColorChange = useCallback(
(e: React.ChangeEvent) => {
const newColor = e.target.value;
debouncedOnChange(newColor);
},
[debouncedOnChange]
);
const handleTextInputChange = useCallback(
(e: React.ChangeEvent) => {
const colorString = e.target.value;
debouncedOnChange(colorString);
},
[debouncedOnChange]
);
useEffect(() => {
return () => debouncedOnChange.cancel();
}, [debouncedOnChange]);
const isHighlighted = name && highlightTarget === name;
useEffect(() => {
if (animationTimerRef.current) {
clearTimeout(animationTimerRef.current);
animationTimerRef.current = null;
}
if (isHighlighted) {
setShouldAnimate(true);
sectionCtx?.setIsExpanded(true);
setTimeout(
() => {
rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
},
sectionCtx?.isExpanded ? 0 : 100
);
animationTimerRef.current = setTimeout(() => {
setShouldAnimate(false);
animationTimerRef.current = null;
}, 1500);
} else {
setShouldAnimate(false);
}
return () => {
if (animationTimerRef.current) {
clearTimeout(animationTimerRef.current);
animationTimerRef.current = null;
}
};
}, [isHighlighted, sectionCtx]);
return (
);
};
export default ColorPicker;
================================================
FILE: components/editor/color-selector-popover.tsx
================================================
"use client";
import TailwindCSS from "@/components/icons/tailwind-css";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { ColorSelectorTab, usePreferencesStore } from "@/store/preferences-store";
import { TAILWIND_PALETTE } from "@/utils/registry/tailwind-colors";
import { Check, LayoutGrid, List } from "lucide-react";
import { useCallback } from "react";
import { formatHex, parse } from "culori";
import { Separator } from "../ui/separator";
type ColorSelectorPopoverProps = {
currentColor: string;
onChange: (color: string) => void;
};
export function ColorSelectorPopover({ currentColor, onChange }: ColorSelectorPopoverProps) {
const handleColorSelect = useCallback(
(color: string) => {
onChange(color);
},
[onChange]
);
const { setColorSelectorTab, colorSelectorTab } = usePreferencesStore();
const handleTabChange = useCallback(
(value: string) => {
setColorSelectorTab(value as ColorSelectorTab);
},
[setColorSelectorTab]
);
const toHex = (c: string) => formatHex(parse(c));
const isColorSelected = useCallback(
(color: string) => {
try {
return toHex(currentColor) === toHex(color);
} catch {
return currentColor === color;
}
},
[currentColor]
);
return (
No Tailwind color found.
{Object.entries(TAILWIND_PALETTE).map(([key, colors]) => {
const colorName = key.charAt(0).toUpperCase() + key.slice(1);
return (
{Object.entries(colors).map(([shade, color]) => {
const isSelected = isColorSelected(color);
return (
handleColorSelect(color)}
className="flex items-center gap-2"
>
{shade === "DEFAULT" ? key : `${key}-${shade}`}
{isSelected && }
);
})}
);
})}
{Object.entries(TAILWIND_PALETTE).map(([key, colors]) => {
return (
{Object.entries(colors).map(([shade, color]) => {
return (
handleColorSelect(color)}
className="rounded-none"
size="md"
/>
);
})}
);
})}
);
}
interface ColorSwatchProps extends React.HTMLAttributes {
isSelected: boolean;
color: string;
name: string;
size?: "sm" | "md" | "lg";
}
function ColorSwatch({
color,
name,
className,
isSelected,
size = "sm",
...props
}: ColorSwatchProps) {
const sizeClasses = {
sm: "size-5",
md: "size-6",
lg: "size-8",
};
const isTransparent = color === "transparent";
return (
);
}
================================================
FILE: components/editor/colors-tab-content.tsx
================================================
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { RefreshCw, Search, X } from "lucide-react";
import ColorPicker from "@/components/editor/color-picker";
import ControlSection from "@/components/editor/control-section";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { cn } from "@/lib/utils";
import { FocusColorId } from "@/store/color-control-focus-store";
import { ThemeStyleProps } from "@/types/theme";
type ColorEntry = {
key: keyof ThemeStyleProps;
name: FocusColorId;
label: string;
};
type ColorGroup = {
title: string;
expanded?: boolean;
colors: ColorEntry[];
};
const COLOR_GROUPS: ColorGroup[] = [
{
title: "Primary",
expanded: true,
colors: [
{ key: "primary", name: "primary", label: "Background" },
{ key: "primary-foreground", name: "primary-foreground", label: "Foreground" },
],
},
{
title: "Secondary",
expanded: true,
colors: [
{ key: "secondary", name: "secondary", label: "Background" },
{ key: "secondary-foreground", name: "secondary-foreground", label: "Foreground" },
],
},
{
title: "Accent",
colors: [
{ key: "accent", name: "accent", label: "Background" },
{ key: "accent-foreground", name: "accent-foreground", label: "Foreground" },
],
},
{
title: "Base",
colors: [
{ key: "background", name: "background", label: "Background" },
{ key: "foreground", name: "foreground", label: "Foreground" },
],
},
{
title: "Card",
colors: [
{ key: "card", name: "card", label: "Background" },
{ key: "card-foreground", name: "card-foreground", label: "Foreground" },
],
},
{
title: "Popover",
colors: [
{ key: "popover", name: "popover", label: "Background" },
{ key: "popover-foreground", name: "popover-foreground", label: "Foreground" },
],
},
{
title: "Muted",
colors: [
{ key: "muted", name: "muted", label: "Background" },
{ key: "muted-foreground", name: "muted-foreground", label: "Foreground" },
],
},
{
title: "Destructive",
colors: [
{ key: "destructive", name: "destructive", label: "Background" },
{ key: "destructive-foreground", name: "destructive-foreground", label: "Foreground" },
],
},
{
title: "Border & Input",
colors: [
{ key: "border", name: "border", label: "Border" },
{ key: "input", name: "input", label: "Input" },
{ key: "ring", name: "ring", label: "Ring" },
],
},
{
title: "Chart",
colors: [
{ key: "chart-1", name: "chart-1", label: "Chart 1" },
{ key: "chart-2", name: "chart-2", label: "Chart 2" },
{ key: "chart-3", name: "chart-3", label: "Chart 3" },
{ key: "chart-4", name: "chart-4", label: "Chart 4" },
{ key: "chart-5", name: "chart-5", label: "Chart 5" },
],
},
{
title: "Sidebar",
colors: [
{ key: "sidebar", name: "sidebar", label: "Background" },
{ key: "sidebar-foreground", name: "sidebar-foreground", label: "Foreground" },
{ key: "sidebar-primary", name: "sidebar-primary", label: "Primary" },
{ key: "sidebar-primary-foreground", name: "sidebar-primary-foreground", label: "Primary FG" },
{ key: "sidebar-accent", name: "sidebar-accent", label: "Accent" },
{ key: "sidebar-accent-foreground", name: "sidebar-accent-foreground", label: "Accent FG" },
{ key: "sidebar-border", name: "sidebar-border", label: "Border" },
{ key: "sidebar-ring", name: "sidebar-ring", label: "Ring" },
],
},
];
// Maps sidebar color keys to their base counterparts
const SIDEBAR_SYNC_MAP: Partial> = {
sidebar: "background",
"sidebar-foreground": "foreground",
"sidebar-primary": "primary",
"sidebar-primary-foreground": "primary-foreground",
"sidebar-accent": "accent",
"sidebar-accent-foreground": "accent-foreground",
"sidebar-border": "border",
"sidebar-ring": "ring",
};
// Reverse map: base key → sidebar key
const BASE_TO_SIDEBAR_MAP = Object.fromEntries(
Object.entries(SIDEBAR_SYNC_MAP).map(([sidebar, base]) => [base, sidebar])
) as Partial>;
interface ColorsTabContentProps {
currentStyles: ThemeStyleProps;
updateStyle: (key: K, value: ThemeStyleProps[K]) => void;
updateStyles: (updates: Partial) => void;
}
export function ColorsTabContent({ currentStyles, updateStyle, updateStyles }: ColorsTabContentProps) {
const [search, setSearch] = useState("");
const [sidebarSyncEnabled, setSidebarSyncEnabled] = useState(false);
// Sync all sidebar colors to their base counterparts in a single batch
const syncSidebarToBase = useCallback(() => {
const updates: Partial = {};
for (const [sidebarKey, baseKey] of Object.entries(SIDEBAR_SYNC_MAP)) {
const baseValue = currentStyles[baseKey as keyof ThemeStyleProps];
if (baseValue !== undefined) {
(updates as Record)[sidebarKey] = baseValue;
}
}
updateStyles(updates);
}, [currentStyles, updateStyles]);
const toggleSidebarSync = useCallback(() => {
setSidebarSyncEnabled((prev) => !prev);
}, []);
// Sync sidebar colors when toggle is turned on
useEffect(() => {
if (sidebarSyncEnabled) {
syncSidebarToBase();
}
// Only run when sync is toggled on, not when syncSidebarToBase changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sidebarSyncEnabled]);
// Keep sidebar in sync when base colors change while sync is enabled
const wrappedUpdateStyle = useCallback(
(key: K, value: ThemeStyleProps[K]) => {
// If sync is on and a base color changed, batch both updates together
if (sidebarSyncEnabled && key in BASE_TO_SIDEBAR_MAP) {
const sidebarKey = BASE_TO_SIDEBAR_MAP[key]!;
updateStyles({
[key]: value,
[sidebarKey]: value,
} as Partial);
} else {
updateStyle(key, value);
}
},
[updateStyle, updateStyles, sidebarSyncEnabled]
);
const filteredGroups = useMemo(() => {
if (!search.trim()) return COLOR_GROUPS;
const query = search.toLowerCase();
return COLOR_GROUPS.map((group) => ({
...group,
expanded: true,
colors: group.colors.filter(
(c) =>
c.label.toLowerCase().includes(query) ||
c.name.toLowerCase().includes(query) ||
group.title.toLowerCase().includes(query)
),
})).filter((group) => group.colors.length > 0);
}, [search]);
return (
{filteredGroups.length === 0 && (
No colors found
)}
{filteredGroups.map((group) => (
{sidebarSyncEnabled ? "Sync on" : "Sync"}
) : undefined
}
>
{group.colors.map((color) => (
wrappedUpdateStyle(color.key, value as ThemeStyleProps[typeof color.key])}
label={color.label}
/>
))}
))}
);
}
================================================
FILE: components/editor/contrast-checker.tsx
================================================
import { useTheme } from "@/components/theme-provider";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogTrigger,
} from "@/components/ui/revola";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { ThemeStyleProps } from "@/types/theme";
import { AlertTriangle, Check, Contrast, Moon, Sun } from "lucide-react";
import { useState } from "react";
import { useContrastChecker } from "../../hooks/use-contrast-checker";
import { Button } from "../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
type ContrastCheckerProps = {
currentStyles: ThemeStyleProps;
};
const MIN_CONTRAST_RATIO = 4.5;
type ColorCategory = "content" | "interactive" | "functional";
type ColorPair = {
id: string;
foregroundId: keyof ThemeStyleProps;
backgroundId: keyof ThemeStyleProps;
foreground: string | undefined;
background: string | undefined;
label: string;
category: ColorCategory;
};
const ContrastChecker = ({ currentStyles }: ContrastCheckerProps) => {
const [filter, setFilter] = useState<"all" | "issues">("all");
const { theme, toggleTheme } = useTheme();
const colorPairsToCheck: ColorPair[] = [
// Content - Base, background, cards, containers
{
id: "base",
foregroundId: "foreground",
backgroundId: "background",
foreground: currentStyles?.["foreground"],
background: currentStyles?.["background"],
label: "Base",
category: "content",
},
{
id: "card",
foregroundId: "card-foreground",
backgroundId: "card",
foreground: currentStyles?.["card-foreground"],
background: currentStyles?.["card"],
label: "Card",
category: "content",
},
{
id: "popover",
foregroundId: "popover-foreground",
backgroundId: "popover",
foreground: currentStyles?.["popover-foreground"],
background: currentStyles?.["popover"],
label: "Popover",
category: "content",
},
{
id: "muted",
foregroundId: "muted-foreground",
backgroundId: "muted",
foreground: currentStyles?.["muted-foreground"],
background: currentStyles?.["muted"],
label: "Muted",
category: "content",
},
// Interactive - Buttons, links, actions
{
id: "primary",
foregroundId: "primary-foreground",
backgroundId: "primary",
foreground: currentStyles?.["primary-foreground"],
background: currentStyles?.["primary"],
label: "Primary",
category: "interactive",
},
{
id: "secondary",
foregroundId: "secondary-foreground",
backgroundId: "secondary",
foreground: currentStyles?.["secondary-foreground"],
background: currentStyles?.["secondary"],
label: "Secondary",
category: "interactive",
},
{
id: "accent",
foregroundId: "accent-foreground",
backgroundId: "accent",
foreground: currentStyles?.["accent-foreground"],
background: currentStyles?.["accent"],
label: "Accent",
category: "interactive",
},
// Functional - Sidebar, destructive, special purposes
{
id: "destructive",
foregroundId: "destructive-foreground",
backgroundId: "destructive",
foreground: currentStyles?.["destructive-foreground"],
background: currentStyles?.["destructive"],
label: "Destructive",
category: "functional",
},
{
id: "sidebar",
foregroundId: "sidebar-foreground",
backgroundId: "sidebar",
foreground: currentStyles?.["sidebar-foreground"],
background: currentStyles?.["sidebar"],
label: "Sidebar Base",
category: "functional",
},
{
id: "sidebar-primary",
foregroundId: "sidebar-primary-foreground",
backgroundId: "sidebar-primary",
foreground: currentStyles?.["sidebar-primary-foreground"],
background: currentStyles?.["sidebar-primary"],
label: "Sidebar Primary",
category: "functional",
},
{
id: "sidebar-accent",
foregroundId: "sidebar-accent-foreground",
backgroundId: "sidebar-accent",
foreground: currentStyles?.["sidebar-accent-foreground"],
background: currentStyles?.["sidebar-accent"],
label: "Sidebar Accent",
category: "functional",
},
];
const validColorPairsToCheck = colorPairsToCheck.filter(
(pair): pair is ColorPair & { foreground: string; background: string } =>
!!pair.foreground && !!pair.background
);
const contrastResults = useContrastChecker(validColorPairsToCheck);
const getContrastResult = (pairId: string) => {
return contrastResults?.find((res) => res.id === pairId);
};
const totalIssues = contrastResults?.filter(
(result) => result.contrastRatio < MIN_CONTRAST_RATIO
).length;
const filteredPairs =
filter === "all"
? colorPairsToCheck
: colorPairsToCheck.filter((pair) => {
const result = getContrastResult(pair.id);
return result && result.contrastRatio < MIN_CONTRAST_RATIO;
});
// Group color pairs by category
const categoryLabels: Record = {
content: "Content & Containers",
interactive: "Interactive Elements",
functional: "Navigation & Functional",
};
const categories: ColorCategory[] = ["content", "interactive", "functional"];
const groupedPairs = categories
.map((category) => ({
category,
label: categoryLabels[category],
pairs: filteredPairs.filter((pair) => pair.category === category),
}))
.filter((group) => group.pairs.length > 0);
return (
Contrast
Contrast Checker
WCAG 2.0 AA requires a contrast ratio of at least {MIN_CONTRAST_RATIO}:1{" • "}
Learn more
toggleTheme({ x: e.clientX, y: e.clientY })}
>
{theme === "light" ? (
) : (
)}
Toggle theme
setFilter("all")}
>
All
setFilter("issues")}
>
Issues ({totalIssues})
{groupedPairs.map((group) => (
{group.label}
{group.pairs.map((pair) => {
const result = getContrastResult(pair.id);
const isValid =
result?.contrastRatio !== undefined &&
result?.contrastRatio >= MIN_CONTRAST_RATIO;
const contrastRatio = result?.contrastRatio?.toFixed(2) ?? "N/A";
return (
{pair.label}
{!isValid && }
{isValid ? (
<>
{contrastRatio}
>
) : (
<>
{contrastRatio}
>
)}
Background
{pair.background}
Foreground
{pair.foreground}
{pair.foreground && pair.background ? (
) : (
Preview
)}
);
})}
))}
);
};
export default ContrastChecker;
================================================
FILE: components/editor/control-section.tsx
================================================
import React, { useState } from "react";
import { ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { ControlSectionProps } from "@/types";
import { SectionContext } from "./section-context";
const ControlSection = ({ title, children, expanded = false, className, headerAction }: ControlSectionProps) => {
const [isExpanded, setIsExpanded] = useState(expanded);
return (
setIsExpanded((prev) => !prev),
}}
>
setIsExpanded(!isExpanded)}
aria-label={isExpanded ? "Collapse section" : "Expand section"}
>
{title}
{headerAction}
);
};
export default ControlSection;
================================================
FILE: components/editor/css-import-dialog.tsx
================================================
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/revola";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { AlertCircle } from "lucide-react";
import React, { useState } from "react";
interface CssImportDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onImport: (css: string) => void;
}
const CssImportDialog: React.FC = ({ open, onOpenChange, onImport }) => {
const [cssText, setCssText] = useState("");
const [error, setError] = useState(null);
const handleImport = () => {
// Basic validation - check if the CSS contains some expected variables
if (!cssText.trim()) {
setError("Please enter CSS content");
return;
}
try {
// Here you would add more sophisticated CSS parsing validation
// For now we'll just do a simple check
if (!cssText.includes("--") || !cssText.includes(":")) {
setError(
"Invalid CSS format. CSS should contain variable definitions like --primary: #color"
);
return;
}
onImport(cssText);
setCssText("");
setError(null);
onOpenChange(false);
} catch {
setError("Failed to parse CSS. Please check your syntax.");
}
};
const handleClose = () => {
setCssText("");
setError(null);
onOpenChange(false);
};
return (
Import Custom CSS
Paste your CSS file below to customize the theme colors. Make sure to include
variables like --primary, --background, etc.
Cancel
Import
);
};
export default CssImportDialog;
================================================
FILE: components/editor/custom-textarea.tsx
================================================
"use client";
import { suggestion } from "@/components/editor/mention-suggestion";
import { toast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
import CharacterCount from "@tiptap/extension-character-count";
import Mention from "@tiptap/extension-mention";
import Placeholder from "@tiptap/extension-placeholder";
import { EditorContent, JSONContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect } from "react";
interface CustomTextareaProps {
className?: string;
disabled?: boolean;
canSubmit?: boolean;
onContentChange: (jsonContent: JSONContent) => void;
onSubmit: () => void;
onImagesPaste?: (files: File[]) => void;
characterLimit?: number;
initialEditorContent?: JSONContent | null;
externalEditorContent?: JSONContent | null;
isStreamingContent?: boolean;
}
export default function CustomTextarea({
className,
disabled = false,
canSubmit = false,
onContentChange,
onSubmit,
onImagesPaste,
characterLimit,
initialEditorContent,
externalEditorContent,
isStreamingContent = false,
}: CustomTextareaProps) {
const editor = useEditor({
immediatelyRender: false,
editable: !disabled,
extensions: [
StarterKit,
Mention.configure({
HTMLAttributes: {
class: "mention",
},
suggestion: suggestion,
}),
Placeholder.configure({
placeholder: "Describe your theme...",
emptyEditorClass:
"cursor-text before:content-[attr(data-placeholder)] before:absolute before:inset-x-1 before:top-1 before:opacity-50 before-pointer-events-none",
}),
CharacterCount.configure({
limit: characterLimit,
}),
],
autofocus: !disabled,
editorProps: {
attributes: {
class: cn(
"min-w-0 min-h-[50px] max-h-[120px] wrap-anywhere text-foreground/90 scrollbar-thin overflow-y-auto w-full bg-background p-1 text-sm focus-visible:outline-none max-sm:text-[16px]! transition-all",
disabled && "opacity-75 pointer-events-none",
className
),
},
handleKeyDown: (view, event) => {
if (disabled) {
event.preventDefault();
return true;
}
if (event.key === "Enter" && !event.shiftKey && !disabled && canSubmit) {
const mentionPluginKey = Mention.options.suggestion.pluginKey;
if (!mentionPluginKey) {
console.error("Mention plugin key not found.");
return false;
}
const { state } = view;
const mentionState = mentionPluginKey.getState(state);
if (mentionState?.active) {
return false;
} else {
event.preventDefault();
onSubmit();
return true;
}
}
return false;
},
handlePaste: (_view, event) => {
if (disabled) {
event.preventDefault();
return true;
}
if (!characterLimit) return false;
const clipboardData = event.clipboardData;
if (!clipboardData) return false;
// Check for image files
if (onImagesPaste) {
const files = Array.from(clipboardData.files);
const imageFiles = files.filter((file) => file.type.startsWith("image/"));
if (imageFiles.length > 0) {
event.preventDefault();
onImagesPaste(imageFiles);
return true;
}
}
const pastedText = clipboardData.getData("text/plain");
const currentCharacterCount = editor?.storage.characterCount.characters() || 0;
const totalCharacters = currentCharacterCount + pastedText.length;
if (totalCharacters > characterLimit) {
event.preventDefault();
toast({
title: "Text too long",
description: `The pasted content would exceed the ${characterLimit} character limit.`,
variant: "destructive",
});
return true;
}
return false;
},
},
content: initialEditorContent || "",
onCreate: ({ editor }) => {
if (disabled) return;
editor.commands.focus("end");
},
onUpdate: ({ editor }) => {
const jsonContent = editor.getJSON();
onContentChange(jsonContent);
},
});
useEffect(() => {
if (!editor) return;
editor.setEditable(!disabled);
if (disabled) editor.commands.blur();
else editor.commands.focus("end");
}, [disabled, editor]);
// Stream external content into the editor
useEffect(() => {
if (!editor || !externalEditorContent || !isStreamingContent) return;
try {
const currentContent = JSON.stringify(editor.getJSON());
const nextContent = JSON.stringify(externalEditorContent);
if (currentContent === nextContent) return;
// Preserve cursor position at the end
editor.commands.setContent(externalEditorContent, false);
editor.commands.focus("end");
} catch (_e) {
// If setContent fails for any reason, silently ignore; user can keep typing
}
}, [externalEditorContent, editor, isStreamingContent]);
if (!editor) {
return null;
}
const characterCount = editor.storage.characterCount.characters();
const isLimitExceeded = characterLimit && characterCount > characterLimit;
const shouldShowCount = characterLimit && characterCount >= characterLimit * 0.9;
return (
{shouldShowCount && (
{characterCount} / {characterLimit}
)}
);
}
================================================
FILE: components/editor/editor.tsx
================================================
"use client";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DialogActionsProvider } from "@/hooks/use-dialog-actions";
import { useIsMobile } from "@/hooks/use-mobile";
import { useEditorStore } from "@/store/editor-store";
import { Theme, ThemeStyles } from "@/types/theme";
import { Sliders } from "lucide-react";
import React, { use, useEffect } from "react";
import { ActionBar } from "./action-bar/action-bar";
import ThemeControlPanel from "./theme-control-panel";
import ThemePreviewPanel from "./theme-preview-panel";
interface EditorProps {
themePromise: Promise;
}
const isThemeStyles = (styles: unknown): styles is ThemeStyles => {
return (
!!styles &&
typeof styles === "object" &&
styles !== null &&
"light" in styles &&
"dark" in styles
);
};
const Editor: React.FC = ({ themePromise }) => {
const themeState = useEditorStore((state) => state.themeState);
const setThemeState = useEditorStore((state) => state.setThemeState);
const isMobile = useIsMobile();
const initialTheme = themePromise ? use(themePromise) : null;
const handleStyleChange = React.useCallback(
(newStyles: ThemeStyles) => {
const prev = useEditorStore.getState().themeState;
setThemeState({ ...prev, styles: newStyles });
},
[setThemeState]
);
useEffect(() => {
if (initialTheme && isThemeStyles(initialTheme.styles)) {
const prev = useEditorStore.getState().themeState;
setThemeState({
...prev,
styles: initialTheme.styles,
preset: initialTheme.id,
});
}
}, [initialTheme, setThemeState]);
if (initialTheme && !isThemeStyles(initialTheme.styles)) {
return (
Fetched theme data is invalid.
);
}
const styles = themeState.styles;
// Mobile layout
if (isMobile) {
return (
);
}
// Desktop layout
return (
);
};
export default Editor;
================================================
FILE: components/editor/font-picker.tsx
================================================
"use client";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { FilterFontCategory, useFontSearch } from "@/hooks/use-font-search";
import { cn } from "@/lib/utils";
import { FontInfo } from "@/types/fonts";
import { buildFontFamily, getDefaultWeights, waitForFont } from "@/utils/fonts";
import { loadGoogleFont } from "@/utils/fonts/google-fonts";
import { Check, ChevronDown, FunnelX, Loader2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { TooltipWrapper } from "../tooltip-wrapper";
const POPULAR_FONTS: Record = {
"sans-serif": [
{ family: "Inter", category: "sans-serif", variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
{ family: "Roboto", category: "sans-serif", variants: ["100", "300", "400", "500", "700", "900"], variable: false },
{ family: "Open Sans", category: "sans-serif", variants: ["300", "400", "500", "600", "700", "800"], variable: true },
{ family: "Poppins", category: "sans-serif", variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], variable: false },
{ family: "Montserrat", category: "sans-serif", variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
{ family: "Lato", category: "sans-serif", variants: ["100", "300", "400", "700", "900"], variable: false },
{ family: "Nunito", category: "sans-serif", variants: ["200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
{ family: "Raleway", category: "sans-serif", variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
{ family: "DM Sans", category: "sans-serif", variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
{ family: "Plus Jakarta Sans", category: "sans-serif", variants: ["200", "300", "400", "500", "600", "700", "800"], variable: true },
{ family: "Geist", category: "sans-serif", variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
],
serif: [
{ family: "Playfair Display", category: "serif", variants: ["400", "500", "600", "700", "800", "900"], variable: true },
{ family: "Merriweather", category: "serif", variants: ["300", "400", "700", "900"], variable: false },
{ family: "Lora", category: "serif", variants: ["400", "500", "600", "700"], variable: true },
{ family: "PT Serif", category: "serif", variants: ["400", "700"], variable: false },
{ family: "Noto Serif", category: "serif", variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
{ family: "Source Serif 4", category: "serif", variants: ["200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
{ family: "Libre Baskerville", category: "serif", variants: ["400", "700"], variable: false },
{ family: "EB Garamond", category: "serif", variants: ["400", "500", "600", "700", "800"], variable: true },
{ family: "Crimson Text", category: "serif", variants: ["400", "600", "700"], variable: false },
{ family: "Bitter", category: "serif", variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
],
monospace: [
{ family: "JetBrains Mono", category: "monospace", variants: ["100", "200", "300", "400", "500", "600", "700", "800"], variable: true },
{ family: "Fira Code", category: "monospace", variants: ["300", "400", "500", "600", "700"], variable: true },
{ family: "Source Code Pro", category: "monospace", variants: ["200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
{ family: "Roboto Mono", category: "monospace", variants: ["100", "200", "300", "400", "500", "600", "700"], variable: true },
{ family: "IBM Plex Mono", category: "monospace", variants: ["100", "200", "300", "400", "500", "600", "700"], variable: false },
{ family: "Space Mono", category: "monospace", variants: ["400", "700"], variable: false },
{ family: "Ubuntu Mono", category: "monospace", variants: ["400", "700"], variable: false },
{ family: "Inconsolata", category: "monospace", variants: ["200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
{ family: "Geist Mono", category: "monospace", variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], variable: true },
{ family: "Anonymous Pro", category: "monospace", variants: ["400", "700"], variable: false },
{ family: "Red Hat Mono", category: "monospace", variants: ["300", "400", "500", "600", "700"], variable: true },
],
display: [
{ family: "Bebas Neue", category: "display", variants: ["400"], variable: false },
{ family: "Abril Fatface", category: "display", variants: ["400"], variable: false },
{ family: "Righteous", category: "display", variants: ["400"], variable: false },
{ family: "Fredoka", category: "display", variants: ["300", "400", "500", "600", "700"], variable: true },
{ family: "Lobster", category: "display", variants: ["400"], variable: false },
{ family: "Comfortaa", category: "display", variants: ["300", "400", "500", "600", "700"], variable: true },
{ family: "Alfa Slab One", category: "display", variants: ["400"], variable: false },
{ family: "Bungee", category: "display", variants: ["400"], variable: false },
{ family: "Lilita One", category: "display", variants: ["400"], variable: false },
{ family: "Permanent Marker", category: "display", variants: ["400"], variable: false },
],
handwriting: [
{ family: "Dancing Script", category: "handwriting", variants: ["400", "500", "600", "700"], variable: true },
{ family: "Pacifico", category: "handwriting", variants: ["400"], variable: false },
{ family: "Caveat", category: "handwriting", variants: ["400", "500", "600", "700"], variable: true },
{ family: "Satisfy", category: "handwriting", variants: ["400"], variable: false },
{ family: "Great Vibes", category: "handwriting", variants: ["400"], variable: false },
{ family: "Sacramento", category: "handwriting", variants: ["400"], variable: false },
{ family: "Kalam", category: "handwriting", variants: ["300", "400", "700"], variable: false },
{ family: "Patrick Hand", category: "handwriting", variants: ["400"], variable: false },
{ family: "Indie Flower", category: "handwriting", variants: ["400"], variable: false },
{ family: "Shadows Into Light", category: "handwriting", variants: ["400"], variable: false },
],
};
interface FontPickerProps {
value?: string;
category?: FilterFontCategory;
onSelect: (font: FontInfo) => void;
placeholder?: string;
className?: string;
}
function FontItem({
font,
isSelected,
isLoading,
onSelect,
selectedRef,
}: {
font: FontInfo;
isSelected: boolean;
isLoading: boolean;
onSelect: (font: FontInfo) => void;
selectedRef: React.Ref | null;
}) {
const fontFamily = buildFontFamily(font.family, font.category);
return (
onSelect(font)}
disabled={isLoading}
onMouseEnter={() => loadGoogleFont(font.family, ["400"])}
ref={selectedRef}
>
{font.family}
{isLoading && }
{font.category}
{font.variable && (
•
Variable
)}
{isSelected && }
);
}
export function FontPicker({
value,
category,
onSelect,
placeholder = "Search fonts...",
className,
}: FontPickerProps) {
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState(category || "all");
const [loadingFont, setLoadingFont] = useState(null);
const scrollRef = useRef(null);
const selectedFontRef = useRef(null);
const hasScrolledToSelectedFont = useRef(false);
const debouncedSetSearchQuery = useDebouncedCallback(setSearchQuery, 300);
useEffect(() => {
debouncedSetSearchQuery(inputValue);
}, [inputValue, debouncedSetSearchQuery]);
const fontQuery = useFontSearch({
query: searchQuery,
category: selectedCategory,
limit: 15,
enabled: open,
});
useEffect(() => {
if (!open) return;
scrollRef.current?.scrollTo({ top: 0 });
}, [selectedCategory, searchQuery, open]);
useEffect(() => {
if (open && fontQuery.data && !hasScrolledToSelectedFont.current) {
requestAnimationFrame(() => {
selectedFontRef.current?.scrollIntoView({
block: "center",
inline: "nearest",
});
});
hasScrolledToSelectedFont.current = true;
} else if (!open) {
hasScrolledToSelectedFont.current = false;
}
}, [open, fontQuery.data]);
// Flatten all pages into a single array
const allFonts = useMemo(() => {
if (!fontQuery.data) return [];
return fontQuery.data.pages.flatMap((page) => page.fonts);
}, [fontQuery.data]);
// Popular fonts for the current category (only shown when not searching)
const popularFonts = useMemo(() => {
if (searchQuery) return [];
if (selectedCategory === "all") {
// Show a mix from sans-serif, serif, monospace
return [
...POPULAR_FONTS["sans-serif"].slice(0, 2),
...POPULAR_FONTS["serif"].slice(0, 1),
...POPULAR_FONTS["monospace"].slice(0, 1),
];
}
return POPULAR_FONTS[selectedCategory] || [];
}, [searchQuery, selectedCategory]);
// Filter out popular fonts from the main list to avoid duplicates
const remainingFonts = useMemo(() => {
if (popularFonts.length === 0) return allFonts;
const popularFamilies = new Set(popularFonts.map((f) => f.family));
return allFonts.filter((font) => !popularFamilies.has(font.family));
}, [allFonts, popularFonts]);
// Intersection Observer ref callback for infinite scroll
const loadMoreRefCallback = useCallback(
(node: HTMLDivElement | null) => {
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting && fontQuery.hasNextPage && !fontQuery.isFetchingNextPage) {
fontQuery.fetchNextPage();
}
},
{
root: scrollRef.current,
rootMargin: "100px",
threshold: 0,
}
);
observer.observe(node);
return () => observer.unobserve(node);
},
[fontQuery.hasNextPage, fontQuery.isFetchingNextPage, fontQuery.fetchNextPage]
);
const handleFontSelect = useCallback(
async (font: FontInfo) => {
setLoadingFont(font.family);
try {
const weights = getDefaultWeights(font.variants);
loadGoogleFont(font.family, weights);
await waitForFont(font.family, weights[0]);
} catch (error) {
console.warn(`Failed to load font ${font.family}:`, error);
}
setLoadingFont(null);
onSelect(font);
},
[onSelect]
);
// Get current font info for display
const currentFont = useMemo(() => {
if (!value) return null;
// First try to find the font in the search results
const foundFont = allFonts.find((font: FontInfo) => font.family === value);
if (foundFont) return foundFont;
// If not found in search results, create a fallback FontInfo object
// This happens when a font is selected and then the search changes
const extractedFontName = value.split(",")[0].trim().replace(/['"]/g, "");
return {
family: extractedFontName,
category: category || "sans-serif",
variants: ["400"],
variable: false,
} as FontInfo;
}, [value, allFonts, category]);
return (
{currentFont ? (
{currentFont.family}
) : (
{placeholder}
)}
{inputValue && (
setInputValue("")}
className="absolute top-2 right-2 size-6"
>
)}
setSelectedCategory(value as FilterFontCategory)}
>
All Fonts
Sans Serif Fonts
Serif Fonts
Monospace Fonts
Display Fonts
Handwriting Fonts
{fontQuery.isLoading ? (
Loading fonts...
) : allFonts.length === 0 ? (
No fonts found.
) : (
{popularFonts.length > 0 && (
{popularFonts.map((font) => (
))}
)}
0 ? "All Fonts" : undefined}>
{remainingFonts.map((font: FontInfo) => (
))}
{/* Load more trigger element */}
{fontQuery.hasNextPage &&
}
{/* Loading indicator for infinite scroll */}
{fontQuery.isFetchingNextPage && (
Loading more fonts...
)}
)}
);
}
================================================
FILE: components/editor/hsl-adjustment-controls.tsx
================================================
"use client";
import React, { useCallback, useMemo, useRef, useEffect, useState } from "react";
import { SliderWithInput } from "./slider-with-input";
import { useEditorStore } from "../../store/editor-store";
import { COMMON_STYLES, defaultThemeState } from "../../config/theme";
import { ThemeEditorState } from "@/types/editor";
import { converter, formatHex, Hsl } from "culori";
import { debounce } from "@/utils/debounce";
import { isDeepEqual } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { HslPresetButton } from "./hsl-preset-button";
import { ChevronDown } from "lucide-react";
// Adjusts a color by modifying HSL values
function adjustColorByHsl(
color: string,
hueShift: number,
saturationScale: number,
lightnessScale: number
): string {
const hsl = converter("hsl")(color);
const h = hsl?.h;
const s = hsl?.s;
const l = hsl?.l;
if (h === undefined || s === undefined || l === undefined) {
return color;
}
const adjustedHsl = {
h: (((h + hueShift) % 360) + 360) % 360,
s: Math.min(1, Math.max(0, s * saturationScale)),
l: Math.min(1, Math.max(0.1, l * lightnessScale)),
};
const out = converter("hsl")(adjustedHsl as Hsl);
return formatHex(out);
}
// Preset HSL adjustment values
const HSL_PRESETS = [
// Hue Adjustments
{ label: "Hue (-120°)", hueShift: -120, saturationScale: 1, lightnessScale: 1 },
{ label: "Hue (-60°)", hueShift: -60, saturationScale: 1, lightnessScale: 1 },
{ label: "Hue (+60°)", hueShift: 60, saturationScale: 1, lightnessScale: 1 },
{ label: "Hue (+120°)", hueShift: 120, saturationScale: 1, lightnessScale: 1 },
{ label: "Hue Invert", hueShift: 180, saturationScale: 1, lightnessScale: 1 },
// Saturation Adjustments
{ label: "Grayscale", hueShift: 0, saturationScale: 0, lightnessScale: 1 },
{ label: "Muted", hueShift: 0, saturationScale: 0.6, lightnessScale: 1 },
{ label: "Vibrant", hueShift: 0, saturationScale: 1.4, lightnessScale: 1 },
// Lightness Adjustments
{ label: "Dimmer", hueShift: 0, saturationScale: 1, lightnessScale: 0.8 },
{ label: "Brighter", hueShift: 0, saturationScale: 1, lightnessScale: 1.2 },
// Combined Adjustments
{ label: "H(+30) S(-50) L(-5%)", hueShift: 30, saturationScale: 0.5, lightnessScale: 0.95 },
{ label: "H(-20) S(+20) L(+5%)", hueShift: -20, saturationScale: 1.2, lightnessScale: 1.05 },
{ label: "H(+20) S(-30) L(-5%)", hueShift: 20, saturationScale: 0.7, lightnessScale: 0.95 },
{ label: "H(-10) S(-25) L(+10%)", hueShift: -10, saturationScale: 0.75, lightnessScale: 1.1 },
{ label: "H(+60) S(+50) L(+10%)", hueShift: 60, saturationScale: 1.5, lightnessScale: 1.1 },
];
const HslAdjustmentControls = () => {
const { themeState, setThemeState, saveThemeCheckpoint, themeCheckpoint } = useEditorStore();
const debouncedUpdateRef = useRef | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
// Get current HSL adjustments with fallback to defaults
const currentHslAdjustments = useMemo(
() => themeState.hslAdjustments ?? defaultThemeState.hslAdjustments!,
[themeState.hslAdjustments]
);
// Save checkpoint if HSL adjustments are at default values
useEffect(() => {
if (isDeepEqual(themeState.hslAdjustments, defaultThemeState.hslAdjustments)) {
saveThemeCheckpoint();
}
}, [themeState.hslAdjustments, saveThemeCheckpoint]);
// Setup debounced update function
useEffect(() => {
debouncedUpdateRef.current = debounce((hslAdjustments: ThemeEditorState["hslAdjustments"]) => {
const {
hueShift = defaultThemeState.hslAdjustments!.hueShift,
saturationScale = defaultThemeState.hslAdjustments!.saturationScale,
lightnessScale = defaultThemeState.hslAdjustments!.lightnessScale,
} = hslAdjustments ?? {};
const adjustments = { hueShift, saturationScale, lightnessScale };
const state = themeCheckpoint ?? themeState;
const { light: lightStyles, dark: darkStyles } = state.styles;
const updatedLightStyles = Object.keys(lightStyles)
.filter((key) => !COMMON_STYLES.includes(key))
.reduce>((acc, key) => {
const colorKey = key as keyof typeof lightStyles;
return {
...acc,
[key]: adjustColorByHsl(
lightStyles[colorKey] || "",
adjustments.hueShift,
adjustments.saturationScale,
adjustments.lightnessScale
),
};
}, {});
const updatedDarkStyles = Object.keys(darkStyles)
.filter((key) => !COMMON_STYLES.includes(key))
.reduce>((acc, key) => {
const colorKey = key as keyof typeof darkStyles;
return {
...acc,
[key]: adjustColorByHsl(
darkStyles[colorKey] || "",
adjustments.hueShift,
adjustments.saturationScale,
adjustments.lightnessScale
),
};
}, {});
// Update theme state with all changes
setThemeState({
...themeState,
hslAdjustments,
styles: {
light: { ...lightStyles, ...updatedLightStyles },
dark: { ...darkStyles, ...updatedDarkStyles },
},
});
}, 10);
return () => debouncedUpdateRef.current?.cancel();
}, [themeState, setThemeState, themeCheckpoint]);
// Handle HSL value changes
const handleHslChange = useCallback(
(property: keyof typeof currentHslAdjustments, value: number) => {
if (debouncedUpdateRef.current) {
debouncedUpdateRef.current({
...currentHslAdjustments,
[property]: value,
});
}
},
[currentHslAdjustments]
);
const handleBatchHslChange = useCallback((value: typeof currentHslAdjustments) => {
if (debouncedUpdateRef.current) {
debouncedUpdateRef.current(value);
}
}, []);
const currentStyles = (themeCheckpoint ?? themeState).styles[themeState.currentMode];
return (
{/* Responsive preset grid */}
{HSL_PRESETS.map((preset) => (
{
handleBatchHslChange(preset);
}}
/>
))}
{/* Show/Hide more button - shows if total presets exceed the smallest breakpoint's column count */}
{HSL_PRESETS.length > 5 && (
setIsExpanded(!isExpanded)}
>
{isExpanded ? "Hide" : "Show more"} presets
)}
handleHslChange("hueShift", value)}
unit="deg"
min={-180}
max={180}
step={1}
label="Hue"
/>
handleHslChange("saturationScale", value)}
unit="x"
min={0}
max={2}
step={0.01}
label="Saturation"
/>
handleHslChange("lightnessScale", value)}
unit="x"
min={0.2}
max={2}
step={0.01}
label="Lightness"
/>
);
};
export default HslAdjustmentControls;
================================================
FILE: components/editor/hsl-preset-button.tsx
================================================
"use client";
import type React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
interface HslPresetButtonProps {
label: string;
hueShift: number;
saturationScale: number;
lightnessScale: number;
baseBg: string;
basePrimary: string;
baseSecondary?: string;
onClick: () => void;
selected: boolean;
adjustColorByHsl: (
color: string,
hueShift: number,
saturationScale: number,
lightnessScale: number
) => string;
}
export const HslPresetButton: React.FC = ({
label,
hueShift,
saturationScale,
lightnessScale,
baseBg,
basePrimary,
baseSecondary = "#888888",
onClick,
selected,
adjustColorByHsl,
}) => {
const previewBg = adjustColorByHsl(baseBg, hueShift, saturationScale, lightnessScale);
const previewPrimary = adjustColorByHsl(basePrimary, hueShift, saturationScale, lightnessScale);
const previewSecondary = adjustColorByHsl(
baseSecondary,
hueShift,
saturationScale,
lightnessScale
);
return (
{selected && (
)}
{label}
);
};
================================================
FILE: components/editor/inspector-class-item.tsx
================================================
"use client";
import React, { memo, useCallback, useMemo } from "react";
import { cn } from "@/lib/utils";
import { SquarePen } from "lucide-react";
import { FocusColorId, useColorControlFocus } from "@/store/color-control-focus-store";
import { segmentClassName } from "@/lib/inspector/segment-classname";
import { useEditorStore } from "@/store/editor-store";
interface InspectorClassItemProps {
className: string;
}
const InspectorClassItem = memo(({ className }: InspectorClassItemProps) => {
const { focusColor } = useColorControlFocus();
const { themeState } = useEditorStore();
const styles = themeState.styles[themeState.currentMode];
const segments = useMemo(() => segmentClassName(className), [className]);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const color = segments.value;
if (color) {
focusColor(color as FocusColorId);
}
},
[segments.value, focusColor]
);
const renderSegmentedClassName = useCallback((): React.ReactNode => {
const parts = [];
if (segments.selector) {
parts.push(
{segments.selector}:
);
}
if (segments.prefix) {
parts.push(
{segments.prefix}
);
}
if (segments.value) {
parts.push(
-
,
{segments.value}
);
}
if (segments.opacity) {
parts.push(
/
,
{segments.opacity}
);
}
return <>{parts}>;
}, [segments]);
return (
{renderSegmentedClassName()}
);
});
InspectorClassItem.displayName = "InspectorClassItem";
export default InspectorClassItem;
================================================
FILE: components/editor/inspector-overlay.tsx
================================================
"use client";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { Separator } from "@/components/ui/separator";
import { useClassNames } from "@/hooks/use-theme-inspector-classnames";
import { cn } from "@/lib/utils";
import { Inspect } from "lucide-react";
import React from "react";
import { createPortal } from "react-dom";
import InspectorClassItem from "./inspector-class-item";
interface InspectorState {
rect: DOMRect | null;
className: string;
}
interface InspectorOverlayProps {
inspector: InspectorState;
enabled: boolean;
rootRef: React.RefObject;
}
const InspectorOverlay = ({ inspector, enabled, rootRef }: InspectorOverlayProps) => {
const classNames = useClassNames(inspector.className);
if (!enabled || !inspector.rect || typeof window === "undefined" || !rootRef.current) {
return null;
}
// Get the container's bounding rect to convert from viewport coordinates to container-relative coordinates
const containerRect = rootRef.current.getBoundingClientRect();
const relativeRect = {
top: inspector.rect.top - containerRect.top,
left: inspector.rect.left - containerRect.left,
width: inspector.rect.width,
height: inspector.rect.height,
};
return createPortal(
Inspector
{classNames.map((cls) => (
))}
,
rootRef.current
);
};
const arePropsEqual = (
prevProps: InspectorOverlayProps,
nextProps: InspectorOverlayProps
): boolean => {
if (prevProps.enabled !== nextProps.enabled) return false;
if (prevProps.rootRef !== nextProps.rootRef) return false;
const prevRect = prevProps.inspector.rect;
const nextRect = nextProps.inspector.rect;
if (!prevRect && !nextRect)
return prevProps.inspector.className === nextProps.inspector.className;
if (!prevRect || !nextRect) return false;
return (
prevRect.top === nextRect.top &&
prevRect.left === nextRect.left &&
prevRect.width === nextRect.width &&
prevRect.height === nextRect.height &&
prevProps.inspector.className === nextProps.inspector.className
);
};
export default React.memo(InspectorOverlay, arePropsEqual);
================================================
FILE: components/editor/mention-list.tsx
================================================
"use client";
import React, { forwardRef, useEffect, useImperativeHandle, useState, useRef } from "react";
import { cn } from "@/lib/utils";
// Define the structure of the theme item object
interface ThemeItem {
id: string;
label: string;
}
interface MentionListProps {
items: ThemeItem[]; // Update items type to ThemeItem[]
command: (item: { id: string; label: string }) => void; // Update command type if needed, here passing the whole object
}
// Use a type for the ref handle if needed, e.g., { onKeyDown: ... }
// Using `any` for now as in the original code
export interface MentionListRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
export const MentionList = forwardRef((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const containerRef = useRef(null);
const selectedItemRef = useRef(null);
// Function to select item (adapted from reference)
const selectItem = (index: number) => {
const item = props.items[index];
if (item) {
// Pass the whole item object to the command function
props.command(item);
}
};
// Arrow key handlers using modulo (adapted from reference)
const upHandler = () => {
setSelectedIndex((prevIndex) => (prevIndex + props.items.length - 1) % props.items.length);
};
const downHandler = () => {
setSelectedIndex((prevIndex) => (prevIndex + 1) % props.items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => setSelectedIndex(0), [props.items]);
// Auto-scroll effect when selectedIndex changes
useEffect(() => {
if (selectedItemRef.current && containerRef.current) {
selectedItemRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [selectedIndex]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === "ArrowUp") {
// Use modulo handlers
upHandler();
return true;
}
if (event.key === "ArrowDown") {
// Use modulo handlers
downHandler();
return true;
}
if (event.key === "Enter") {
// Use enter handler
enterHandler();
return true;
}
if (event.key === "Tab") {
// Use tab handler
enterHandler();
return true;
}
return false;
},
}));
return (
{props.items.length ? (
props.items.map((item, index) => (
{
e.stopPropagation();
selectItem(index);
}}
>
{item.label}
))
) : (
No result
)}
);
});
MentionList.displayName = "MentionList";
================================================
FILE: components/editor/mention-suggestion.ts
================================================
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ReactRenderer } from "@tiptap/react";
import tippy from "tippy.js";
import { MentionList } from "@/components/editor/mention-list"; // We'll create this component next
import { useThemePresetStore } from "@/store/theme-preset-store"; // Import the theme store
export const suggestion = {
items: ({ query }: { query: string }) => {
// Get all presets from the store
const allPresets = useThemePresetStore.getState().getAllPresets();
// Convert presets object to the required array format { id: string, label: string }
const themeItems = Object.entries(allPresets).map(([id, preset]) => ({
id: id, // Use the preset key as the id
label: preset.label, // Use the preset label
}));
// Filter based on the query
return themeItems
.filter((item) => {
const labelWithoutSpaces =
item.label?.replace(/\s+/g, "").toLowerCase() || "";
const queryWithoutSpaces = query.replace(/\s+/g, "").toLowerCase();
return labelWithoutSpaces.includes(queryWithoutSpaces);
})
.slice(0, 7)
.concat({ id: "editor:current-changes", label: "Current Theme" }); // Limit to 5 suggestions
},
render: () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: any) => {
component = new ReactRenderer(MentionList, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate(props: any) {
component?.updateProps(props);
if (!props.clientRect) {
return;
}
popup?.[0]?.setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props: any) {
if (props.event.key === "Escape") {
popup?.[0]?.hide();
return true;
}
// @ts-expect-error - This is a valid way to access the component's methods
return component?.ref?.onKeyDown(props);
},
onExit() {
popup?.[0]?.destroy();
component?.destroy();
popup = null;
component = null;
},
};
},
};
================================================
FILE: components/editor/section-context.tsx
================================================
import { createContext } from "react";
interface SectionContextType {
/** Whether the parent ControlSection is currently expanded */
isExpanded: boolean;
/** Set the expanded state explicitly */
setIsExpanded: (expanded: boolean) => void;
/** Helper to toggle the expanded state */
toggleExpanded: () => void;
}
/**
* Context that allows descendants of a ControlSection to query or mutate
* the expanded / collapsed state of their parent section.
*/
export const SectionContext = createContext(undefined);
================================================
FILE: components/editor/shadow-control.tsx
================================================
import React from "react";
import { SliderWithInput } from "./slider-with-input";
import ColorPicker from "./color-picker";
interface ShadowControlProps {
shadowColor: string;
shadowOpacity: number;
shadowBlur: number;
shadowSpread: number;
shadowOffsetX: number;
shadowOffsetY: number;
onChange: (key: string, value: string | number) => void;
}
const ShadowControl: React.FC = ({
shadowColor,
shadowOpacity,
shadowBlur,
shadowSpread,
shadowOffsetX,
shadowOffsetY,
onChange,
}) => {
return (
onChange("shadow-color", color)}
label="Color"
/>
onChange("shadow-opacity", value)}
min={0}
max={1}
step={0.01}
unit=""
label="Opacity"
/>
onChange("shadow-blur", value)}
min={0}
max={50}
step={0.5}
unit="px"
label="Blur"
/>
onChange("shadow-spread", value)}
min={-50}
max={50}
step={0.5}
unit="px"
label="Spread"
/>
onChange("shadow-offset-x", value)}
min={-50}
max={50}
step={0.5}
unit="px"
label="Offset X"
/>
onChange("shadow-offset-y", value)}
min={-50}
max={50}
step={0.5}
unit="px"
label="Offset Y"
/>
);
};
export default ShadowControl;
================================================
FILE: components/editor/share-dialog.tsx
================================================
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/revola";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
import { Check, Copy } from "lucide-react";
interface ShareDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
url: string;
}
export function ShareDialog({ open, onOpenChange, url }: ShareDialogProps) {
const { isCopying, hasCopied, copyToClipboard } = useCopyToClipboard();
const handleCopy = async () => {
await copyToClipboard(url, {
title: "Theme URL copied to clipboard!",
});
};
return (
);
}
================================================
FILE: components/editor/slider-with-input.tsx
================================================
import { useEffect, useState } from "react";
import { Label } from "../ui/label";
import { Input } from "../ui/input";
import { Slider } from "../ui/slider";
export const SliderWithInput = ({
value,
onChange,
min,
max,
step = 1,
label,
unit = "px",
}: {
value: number;
onChange: (value: number) => void;
min: number;
max: number;
step?: number;
label: string;
unit?: string;
}) => {
const [localValue, setLocalValue] = useState(value.toString());
useEffect(() => {
setLocalValue(value.toString());
}, [value]);
const handleChange = (e: React.ChangeEvent) => {
const raw = e.target.value;
setLocalValue(raw);
const num = parseFloat(raw.replace(',', '.'));
if (!isNaN(num)) {
onChange(Math.max(min, Math.min(max, num)));
}
};
return (
);
};
================================================
FILE: components/editor/theme-control-actions.tsx
================================================
import { FileCode, Palette, RefreshCw, LucideIcon, Undo2 } from "lucide-react";
import { Button } from "../ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { cn } from "@/lib/utils";
interface MenuItemProps {
icon: LucideIcon;
label: string;
onClick: () => void;
disabled?: boolean;
title?: string;
}
const MenuItem = ({
icon: Icon,
label,
onClick,
disabled,
title,
}: MenuItemProps) => {
return (
{label}
);
};
interface ThemeControlActionsProps {
hasChanges: boolean;
hasPresetChanges: boolean;
onReset: () => void;
onResetToPreset: () => void;
onImportClick: () => void;
}
const ThemeControlActions = ({
hasChanges,
hasPresetChanges,
onReset,
onResetToPreset,
onImportClick,
}: ThemeControlActionsProps) => {
const menuItems: MenuItemProps[] = [
{
icon: FileCode,
label: "Import from CSS file",
onClick: onImportClick,
},
{
icon: RefreshCw,
label: "Reset to Current Preset",
onClick: onResetToPreset,
disabled: !hasPresetChanges,
title: hasPresetChanges ? "Reset to current preset" : "No changes from preset",
},
{
icon: Undo2,
label: "Reset to Default Theme",
onClick: onReset,
disabled: !hasChanges,
title: hasChanges ? "Reset to base theme" : "No changes to reset",
},
];
return (
Options
{menuItems.map((item, index) => (
))}
);
};
export default ThemeControlActions;
================================================
FILE: components/editor/theme-control-panel.tsx
================================================
"use client";
import { AlertCircle, Sparkle } from "lucide-react";
import React from "react";
import { ChatInterface } from "@/components/editor/ai/chat-interface";
import { ColorsTabContent } from "@/components/editor/colors-tab-content";
import ControlSection from "@/components/editor/control-section";
import { FontPicker } from "@/components/editor/font-picker";
import HslAdjustmentControls from "@/components/editor/hsl-adjustment-controls";
import ShadowControl from "@/components/editor/shadow-control";
import { SliderWithInput } from "@/components/editor/slider-with-input";
import ThemePresetSelect from "@/components/editor/theme-preset-select";
import TabsTriggerPill from "@/components/editor/theme-preview/tabs-trigger-pill";
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList } from "@/components/ui/tabs";
import { COMMON_STYLES, defaultThemeState } from "@/config/theme";
import { useAIThemeGenerationCore } from "@/hooks/use-ai-theme-generation-core";
import { useControlsTabFromUrl, type ControlTab } from "@/hooks/use-controls-tab-from-url";
import { useEditorStore } from "@/store/editor-store";
import { type FontInfo } from "@/types/fonts";
import { ThemeStyles, ThemeStyleProps } from "@/types/theme";
import { buildFontFamily } from "@/utils/fonts";
import { getAppliedThemeFont } from "@/utils/theme-fonts";
interface ThemeControlPanelProps {
styles: ThemeStyles;
currentMode: "light" | "dark";
onChange: (styles: ThemeStyles) => void;
}
const ThemeControlPanel = ({ styles, currentMode, onChange }: ThemeControlPanelProps) => {
const { themeState } = useEditorStore();
const { tab, handleSetTab } = useControlsTabFromUrl();
const { isGeneratingTheme } = useAIThemeGenerationCore();
const currentStyles = React.useMemo(
() => ({
...defaultThemeState.styles[currentMode],
...styles?.[currentMode],
}),
[currentMode, styles]
);
const updateStyle = React.useCallback(
(key: K, value: (typeof currentStyles)[K]) => {
// apply common styles to both light and dark modes
if (COMMON_STYLES.includes(key)) {
onChange({
...styles,
light: { ...styles.light, [key]: value },
dark: { ...styles.dark, [key]: value },
});
return;
}
onChange({
...styles,
[currentMode]: {
...currentStyles,
[key]: value,
},
});
},
[onChange, styles, currentMode, currentStyles]
);
const updateStyles = React.useCallback(
(updates: Partial) => {
onChange({
...styles,
[currentMode]: {
...currentStyles,
...updates,
},
});
},
[onChange, styles, currentMode, currentStyles]
);
// Ensure we have valid styles for the current mode
if (!currentStyles) {
return null; // Or some fallback UI
}
const radius = parseFloat(currentStyles.radius.replace("rem", ""));
return (
<>
handleSetTab(v as ControlTab)}
className="flex min-h-0 w-full flex-1 flex-col"
>
Colors
Typography
Other
Generate
Sans-Serif
{
const fontFamily = buildFontFamily(font.family, font.category);
updateStyle("font-sans", fontFamily);
}}
/>
Serif
{
const fontFamily = buildFontFamily(font.family, font.category);
updateStyle("font-serif", fontFamily);
}}
/>
Mono
{
const fontFamily = buildFontFamily(font.family, font.category);
updateStyle("font-mono", fontFamily);
}}
/>
updateStyle("letter-spacing", `${value}em`)}
min={-0.5}
max={0.5}
step={0.025}
unit="em"
label="Tracking"
/>
updateStyle("radius", `${value}rem`)}
min={0}
max={5}
step={0.025}
unit="rem"
label="Radius"
/>
updateStyle("spacing", `${value}rem`)}
min={0.15}
max={0.35}
step={0.01}
unit="rem"
label="Spacing"
/>
{
if (key === "shadow-color") {
updateStyle(key, value as string);
} else if (key === "shadow-opacity") {
updateStyle(key, value.toString());
} else {
updateStyle(key as keyof ThemeStyleProps, `${value}px`);
}
}}
/>
>
);
};
export default ThemeControlPanel;
================================================
FILE: components/editor/theme-font-select.tsx
================================================
// THIS COMPONENT MIGHT BE REPLACED BY THE GOOGLE FONT PICKER
import React, { useMemo } from "react";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface ThemeFontSelectProps {
fonts: Record;
defaultValue: string;
currentFont: string | null;
onFontChange: (font: string) => void;
}
const ThemeFontSelect: React.FC = ({
fonts,
defaultValue,
currentFont,
onFontChange,
}) => {
const fontNames = useMemo(() => ["System", ...Object.keys(fonts)], [fonts]);
const value = currentFont ? (fonts[currentFont] ?? defaultValue) : defaultValue;
return (
{fontNames.map((fontName) => (
{fontName}
))}
);
};
export default ThemeFontSelect;
================================================
FILE: components/editor/theme-preset-select.tsx
================================================
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/store/editor-store";
import { useThemePresetStore } from "@/store/theme-preset-store";
import { ThemePreset } from "@/types/theme";
import { getPresetThemeStyles } from "@/utils/theme-preset-helper";
import {
ArrowLeft,
ArrowRight,
Check,
ChevronDown,
Heart,
Search,
Settings,
Shuffle,
} from "lucide-react";
import Link from "next/link";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ThemeToggle } from "../theme-toggle";
import { TooltipWrapper } from "../tooltip-wrapper";
interface ThemePresetSelectProps extends React.ComponentProps {
withCycleThemes?: boolean;
}
interface ColorBoxProps {
color: string;
}
const ColorBox: React.FC = ({ color }) => (
);
interface ThemeColorsProps {
presetName: string;
mode: "light" | "dark";
}
const ThemeColors: React.FC = ({ presetName, mode }) => {
const styles = getPresetThemeStyles(presetName)[mode];
return (
);
};
const isThemeNew = (preset: ThemePreset) => {
if (!preset.createdAt) return false;
const createdAt = new Date(preset.createdAt);
const timePeriod = new Date();
timePeriod.setDate(timePeriod.getDate() - 5);
return createdAt > timePeriod;
};
const ThemeControls = () => {
const applyThemePreset = useEditorStore((store) => store.applyThemePreset);
const presets = useThemePresetStore((store) => store.getAllPresets());
const presetNames = useMemo(() => ["default", ...Object.keys(presets)], [presets]);
const randomize = useCallback(() => {
const random = Math.floor(Math.random() * presetNames.length);
applyThemePreset(presetNames[random]);
}, [presetNames, applyThemePreset]);
return (
);
};
interface ThemeCycleButtonProps extends React.ComponentProps {
direction: "prev" | "next";
}
const ThemeCycleButton: React.FC = ({
direction,
onClick,
className,
...props
}) => (
{direction === "prev" ? (
) : (
)}
{direction === "prev" ? "Previous theme" : "Next theme"}
);
interface ThemePresetCycleControlsProps extends React.ComponentProps {
filteredPresets: string[];
currentPresetName: string;
className?: string;
}
const ThemePresetCycleControls: React.FC = ({
filteredPresets,
currentPresetName,
className,
...props
}) => {
const applyThemePreset = useEditorStore((store) => store.applyThemePreset);
const currentIndex =
useMemo(
() => filteredPresets.indexOf(currentPresetName || "default"),
[filteredPresets, currentPresetName]
) ?? 0;
const cycleTheme = useCallback(
(direction: "prev" | "next") => {
const newIndex =
direction === "next"
? (currentIndex + 1) % filteredPresets.length
: (currentIndex - 1 + filteredPresets.length) % filteredPresets.length;
applyThemePreset(filteredPresets[newIndex]);
},
[currentIndex, filteredPresets, applyThemePreset]
);
return (
<>
cycleTheme("prev")}
{...props}
/>
cycleTheme("next")}
{...props}
/>
>
);
};
const ThemePresetSelect: React.FC = ({
withCycleThemes = true,
className,
...props
}) => {
const themeState = useEditorStore((store) => store.themeState);
const applyThemePreset = useEditorStore((store) => store.applyThemePreset);
const hasUnsavedChanges = useEditorStore((store) => store.hasUnsavedChanges);
const currentPreset = themeState.preset;
const mode = themeState.currentMode;
const presets = useThemePresetStore((store) => store.getAllPresets());
const loadSavedPresets = useThemePresetStore((store) => store.loadSavedPresets);
const unloadSavedPresets = useThemePresetStore((store) => store.unloadSavedPresets);
const [search, setSearch] = useState("");
const { data: session } = authClient.useSession();
useEffect(() => {
if (session?.user) {
loadSavedPresets();
} else {
unloadSavedPresets();
}
}, [loadSavedPresets, unloadSavedPresets, session?.user]);
const isSavedTheme = useCallback(
(presetId: string) => {
return presets[presetId]?.source === "SAVED";
},
[presets]
);
const presetNames = useMemo(() => ["default", ...Object.keys(presets)], [presets]);
const currentPresetName = presetNames?.find((name) => name === currentPreset);
const filteredPresets = useMemo(() => {
const filteredList =
search.trim() === ""
? presetNames
: presetNames.filter((name) => {
if (name === "default") {
return "default".toLowerCase().includes(search.toLowerCase());
}
return presets[name]?.label?.toLowerCase().includes(search.toLowerCase());
});
// Separate saved and default themes
const savedThemesList = filteredList.filter((name) => name !== "default" && isSavedTheme(name));
const defaultThemesList = filteredList.filter((name) => !savedThemesList.includes(name));
// Sort each list, with "default" at the top for default themes
const sortThemes = (list: string[]) => {
const defaultTheme = list.filter((name) => name === "default");
const otherThemes = list
.filter((name) => name !== "default")
.sort((a, b) => {
const labelA = presets[a]?.label || a;
const labelB = presets[b]?.label || b;
return labelA.localeCompare(labelB);
});
return [...defaultTheme, ...otherThemes];
};
// Combine saved themes first, then default themes
return [...sortThemes(savedThemesList), ...sortThemes(defaultThemesList)];
}, [presetNames, search, presets, isSavedTheme]);
const filteredSavedThemes = useMemo(() => {
return filteredPresets.filter((name) => name !== "default" && isSavedTheme(name));
}, [filteredPresets, isSavedTheme]);
const filteredDefaultThemes = useMemo(() => {
return filteredPresets.filter((name) => name === "default" || !isSavedTheme(name));
}, [filteredPresets, isSavedTheme]);
return (
{currentPresetName !== "default" &&
currentPresetName &&
isSavedTheme(currentPresetName) &&
!hasUnsavedChanges() && (
)}
{presets[currentPresetName || "default"]?.label ||
currentPresetName ||
"default"}
{hasUnsavedChanges() && "*"}
{filteredPresets.length} theme
{filteredPresets.length !== 1 ? "s" : ""}
No themes found.
{/* Saved Themes Group */}
{filteredSavedThemes.length > 0 && (
<>
Saved Themes
Manage
}
>
{filteredSavedThemes
.filter((name) => name !== "default" && isSavedTheme(name))
.map((presetName, index) => (
{
applyThemePreset(presetName);
setSearch("");
}}
className="data-[highlighted]:bg-secondary/50 flex items-center gap-2 py-2"
>
{presets[presetName]?.label || presetName}
{presets[presetName] && isThemeNew(presets[presetName]) && (
New
)}
{presetName === currentPresetName && (
)}
))}
>
)}
{filteredSavedThemes.length === 0 && search.trim() === "" && (
<>
Save
a theme to find it here.
>
)}
{/* Default Theme Group */}
{filteredDefaultThemes.length > 0 && (
{filteredDefaultThemes.map((presetName, index) => (
{
applyThemePreset(presetName);
setSearch("");
}}
className="data-[highlighted]:bg-secondary/50 flex items-center gap-2 py-2"
>
{presets[presetName]?.label || presetName}
{presets[presetName] && isThemeNew(presets[presetName]) && (
New
)}
{presetName === currentPresetName && (
)}
))}
)}
Discover more themes →
{withCycleThemes && (
)}
);
};
export default ThemePresetSelect;
================================================
FILE: components/editor/theme-preview/color-preview.tsx
================================================
import { CopyButton } from "@/components/copy-button";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { FocusColorId, useColorControlFocus } from "@/store/color-control-focus-store";
import { ThemeEditorPreviewProps } from "@/types/theme";
import { SquarePen } from "lucide-react";
interface ColorPreviewProps {
styles: ThemeEditorPreviewProps["styles"];
currentMode: ThemeEditorPreviewProps["currentMode"];
}
function ColorPreviewItem({ label, color, name }: { label: string; color: string; name: string }) {
const { focusColor } = useColorControlFocus();
return (
focusColor(name as FocusColorId)}
className="size-7 @max-3xl:size-6 [&>svg]:size-3.5"
>
);
}
const ColorPreview = ({ styles, currentMode }: ColorPreviewProps) => {
if (!styles || !styles[currentMode]) {
return null;
}
return (
{/* Primary Colors */}
{/* Secondary & Accent Colors */}
Secondary & Accent Colors
{/* UI Component Colors */}
{/* Utility & Form Colors */}
{/* Status & Feedback Colors */}
{/* Chart & Data Visualization Colors */}
Chart & Visualization Colors
{/* Sidebar Colors */}
Sidebar & Navigation Colors
);
};
export default ColorPreview;
================================================
FILE: components/editor/theme-preview/components-showcase.tsx
================================================
import { ThemeEditorPreviewProps } from "@/types/theme";
import { Settings, Info, AlertTriangle, Star } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Table,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell,
} from "@/components/ui/table";
interface ComponentsShowcaseProps {
styles: ThemeEditorPreviewProps["styles"];
currentMode: ThemeEditorPreviewProps["currentMode"];
}
const ComponentsShowcase = ({ styles, currentMode }: ComponentsShowcaseProps) => {
if (!styles || !styles[currentMode]) {
return null;
}
return (
{/* Button showcase */}
Buttons & Interactive Elements
Primary
Secondary
Outline
Ghost
Link
Delete
{/* Cards & Containers */}
Cards & Containers
Feature Card
Card description with muted foreground color
This card demonstrates the card background and foreground colors,
with content showing regular text.
Cancel
Continue
Popover Container
This container shows popover colors and styling.
Muted Container
Container with muted background and foreground colors.
{/* Status Indicators */}
Status Indicators & Alerts
Default Badge
Secondary
Outline
Error
Custom
Information
Standard alert with default styling.
Error
Destructive alert showcasing error state colors.
Success Alert
Custom alert using accent colors with an opacity modifier.
{/* Data Display */}
Data Display
User
Status
Role
Actions
Alex Johnson
Active
Admin
Sarah Chen
Inactive
User
);
};
export default ComponentsShowcase;
================================================
FILE: components/editor/theme-preview/examples-preview-container.tsx
================================================
import { Suspense } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { Loading } from "@/components/loading";
const LoadingSkeleton = () => (
);
const ExamplesPreviewContainer = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => {
return (
);
};
export default ExamplesPreviewContainer;
================================================
FILE: components/editor/theme-preview/tabs-trigger-pill.tsx
================================================
import { TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import * as React from "react";
const TabsTriggerPill = ({
children,
className,
...props
}: React.ComponentPropsWithoutRef) => {
return (
{children}
);
};
export default TabsTriggerPill;
================================================
FILE: components/editor/theme-preview-panel.tsx
================================================
"use client";
import ShadcnBlocksLogo from "@/assets/shadcnblocks.svg";
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
import { ThemeToggle } from "@/components/theme-toggle";
import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList } from "@/components/ui/tabs";
import { useDialogActions } from "@/hooks/use-dialog-actions";
import { useFullscreen } from "@/hooks/use-fullscreen";
import { useThemeInspector } from "@/hooks/use-theme-inspector";
import { cn } from "@/lib/utils";
import { ThemeEditorPreviewProps } from "@/types/theme";
import { Inspect, Maximize, Minimize, MoreVertical } from "lucide-react";
import Link from "next/link";
import { useQueryState } from "nuqs";
import { lazy } from "react";
import InspectorOverlay from "./inspector-overlay";
import ColorPreview from "./theme-preview/color-preview";
import ExamplesPreviewContainer from "./theme-preview/examples-preview-container";
import TabsTriggerPill from "./theme-preview/tabs-trigger-pill";
const DemoCards = lazy(() => import("@/components/examples/cards"));
const DemoMail = lazy(() => import("@/components/examples/mail"));
const DemoDashboard = lazy(() => import("@/components/examples/dashboard"));
const DemoPricing = lazy(() => import("@/components/examples/pricing/pricing"));
const TypographyDemo = lazy(() => import("@/components/examples/typography/typography-demo"));
const CustomDemo = lazy(() => import("@/components/examples/custom"));
const V0Logo = ({ className }: { className?: string }) => (
);
const ThemePreviewPanel = ({
styles,
currentMode,
themeId,
themeName,
}: ThemeEditorPreviewProps & { themeId?: string; themeName?: string }) => {
const { isFullscreen, toggleFullscreen } = useFullscreen();
const [activeTab, setActiveTab] = useQueryState("p", {
defaultValue: "cards",
});
const { handleOpenInV0 } = useDialogActions();
const {
rootRef,
inspector,
inspectorEnabled,
handleMouseMove,
handleMouseLeave,
toggleInspector,
} = useThemeInspector();
if (!styles || !styles[currentMode]) {
return null;
}
const handleTabChange = (value: string) => {
setActiveTab(value);
};
return (
<>
Custom
Cards
Dashboard
Mail
Pricing
Color Palette
handleTabChange("typography")}>
Typography
handleOpenInV0(themeId, themeName)} className="group px-2.5">
Open in
{isFullscreen && (
)}
{/* Inspector toggle button */}
{inspectorEnabled && on }
{isFullscreen ? (
) : (
)}
Shadcnblocks.com
600+ extra shadcn blocks
>
);
};
export default ThemePreviewPanel;
================================================
FILE: components/editor/theme-save-dialog.tsx
================================================
"use client";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "../ui/revola";
const formSchema = z.object({
themeName: z.string().min(1, "Theme name cannot be empty."),
});
interface ThemeSaveDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (themeName: string) => Promise;
isSaving: boolean;
initialThemeName?: string;
ctaLabel?: string;
title?: string;
description?: string;
existingThemeName?: string;
onUpdateExisting?: () => Promise;
isUpdating?: boolean;
}
export function ThemeSaveDialog({
open,
onOpenChange,
onSave,
isSaving,
initialThemeName = "",
ctaLabel = "Save Theme",
title = "Save Theme",
description = "Enter a name for your theme so you can find it later.",
existingThemeName,
onUpdateExisting,
isUpdating = false,
}: ThemeSaveDialogProps) {
const form = useForm>({
resolver: zodResolver(formSchema),
defaultValues: {
themeName: initialThemeName,
},
});
const onSubmit = (values: z.infer) => {
onSave(values.themeName);
};
useEffect(() => {
if (open) {
form.reset({ themeName: initialThemeName });
}
}, [open, initialThemeName, form]);
const handleOpenChange = (newOpen: boolean) => {
onOpenChange(newOpen);
};
const hasUpdateOption = !!existingThemeName && !!onUpdateExisting;
return (
{title}
{description}
{hasUpdateOption && (
<>
{isUpdating ? (
<>
Updating
>
) : (
<>Update “{existingThemeName}”>
)}
or save as a new theme
>
)}
onOpenChange(false)}
>
Cancel
{isSaving || form.formState.isSubmitting ? (
<>
Saving
>
) : hasUpdateOption ? (
"Save as New"
) : (
ctaLabel
)}
);
}
================================================
FILE: components/effects/frame-highlight.tsx
================================================
import { cn } from "@/lib/utils";
import { ComponentProps } from "react";
export function FrameHighlight({ children, className, ...props }: ComponentProps<"span">) {
return (
<>
{" "}
{children}
{" "}
>
);
}
function Corner({ className }: ComponentProps<"svg">) {
return (
);
}
================================================
FILE: components/effects/noise-effect.tsx
================================================
export function NoiseEffect() {
return (
);
}
================================================
FILE: components/effects/spotlight.tsx
================================================
import { cn } from "@/lib/utils";
type SpotlightProps = {
className?: string;
fill?: string;
};
export function Spotlight({ className, fill }: SpotlightProps) {
return (
);
}
================================================
FILE: components/error-boundary.tsx
================================================
import { FileWarning } from "lucide-react";
import React from "react";
export class ComponentErrorBoundary extends React.Component<
{ children: React.ReactNode; name: string; fallback?: React.ReactNode },
{ hasError: boolean }
> {
constructor(props: { children: React.ReactNode; name: string; fallback: React.ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error(`Error in component ${this.props.name}:`, error, errorInfo);
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
Something went wrong in component:{" "}
{this.props.name}
);
}
return this.props.children;
}
}
================================================
FILE: components/examples/ai-chat-demo.tsx
================================================
"use client";
import Message from "@/components/editor/ai/message";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { ChatMessage } from "@/types/ai";
import { ThemeStyles } from "@/types/theme";
import { defaultPresets } from "@/utils/theme-presets";
import { useEffect, useRef } from "react";
export function AIChatDemo({
disabled = true,
className,
}: {
disabled?: boolean;
className?: string;
}) {
const ref = useRef(null);
useEffect(() => {
if (!ref.current) return;
// Always block tabbing: set tabIndex="-1" on all focusable children
const focusables = ref.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusables.forEach((el) => {
(el as HTMLElement).setAttribute("tabindex", "-1");
// Only set disabled if supported and disabled is true
if (disabled && "disabled" in el) (el as HTMLButtonElement).disabled = true;
if (!disabled && "disabled" in el) (el as HTMLButtonElement).disabled = false;
});
}, [disabled]);
return (
{/* Scrollable parent */}
{/* Non-interactive chat content */}
{CHAT_PLACEHOLDER_MESSAGES.map((msg) => (
{}}
isEditing={false}
onEdit={() => {}}
onEditSubmit={() => {}}
onEditCancel={() => {}}
/>
))}
);
}
const CHAT_PLACEHOLDER_MESSAGES: ChatMessage[] = [
{
id: "1",
role: "user",
parts: [
{
type: "text",
text: "Can you generate a theme from this image?",
},
],
metadata: {
promptData: {
content: "Generate a theme from this image.",
mentions: [],
images: [
{
url: "/og-image.v050725.png",
},
],
},
},
},
{
id: "2",
role: "assistant",
parts: [
{
type: "text",
text: "I've generated a Midnight Bloom theme based on your image. It features deep purples and blues for a calming, modern look.",
},
],
metadata: {
themeStyles: defaultPresets["midnight-bloom"].styles as ThemeStyles,
},
},
{
id: "3",
role: "user",
parts: [
{
type: "text",
text: "Can you generate a theme inspired by @Twitter?",
},
],
},
{
id: "4",
role: "assistant",
parts: [
{
type: "text",
text: "Alright, I've whipped up a Twitter-inspired theme. Expect bright blues and clean contrasts for a social, energetic vibe.",
},
],
metadata: {
themeStyles: defaultPresets["twitter"].styles as ThemeStyles,
},
},
{
id: "5",
role: "user",
parts: [
{
type: "text",
text: "How about a @Supabase theme?",
},
],
},
{
id: "6",
role: "assistant",
parts: [
{
type: "text",
text: "I've generated a Supabase theme for you. It uses fresh greens and dark backgrounds for a modern, developer-friendly feel.",
},
],
metadata: {
themeStyles: defaultPresets["supabase"].styles as ThemeStyles,
},
},
];
================================================
FILE: components/examples/cards/activity-goal.tsx
================================================
"use client";
import { MinusIcon, PlusIcon } from "lucide-react";
import * as React from "react";
import { Bar, BarChart } from "recharts";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ChartConfig, ChartContainer } from "@/components/ui/chart";
const data = [
{
goal: 400,
},
{
goal: 300,
},
{
goal: 200,
},
{
goal: 300,
},
{
goal: 200,
},
{
goal: 278,
},
{
goal: 189,
},
{
goal: 239,
},
{
goal: 300,
},
{
goal: 200,
},
{
goal: 278,
},
{
goal: 189,
},
{
goal: 349,
},
];
const chartConfig = {
goal: {
label: "Goal",
color: "var(--primary)",
},
} satisfies ChartConfig;
export function CardsActivityGoal() {
const [goal, setGoal] = React.useState(350);
function onClick(adjustment: number) {
setGoal(Math.max(200, Math.min(400, goal + adjustment)));
}
return (
Move Goal
Set your daily activity goal.
onClick(-10)}
disabled={goal <= 200}
>
Decrease
onClick(10)}
disabled={goal >= 400}
>
Increase
Set Goal
);
}
================================================
FILE: components/examples/cards/calendar.tsx
================================================
"use client";
import { addDays } from "date-fns";
import { Calendar } from "@/components/ui/calendar";
import { Card, CardContent } from "@/components/ui/card";
const start = new Date(2025, 5, 5);
export function CardsCalendar() {
return (
);
}
================================================
FILE: components/examples/cards/chat.tsx
================================================
"use client";
import * as React from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/revola";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { ArrowUpIcon, CheckIcon, PlusIcon } from "lucide-react";
const users = [
{
name: "Olivia Martin",
email: "m@example.com",
avatar: "/avatars/01.png",
},
{
name: "Isabella Nguyen",
email: "isabella.nguyen@email.com",
avatar: "/avatars/03.png",
},
{
name: "Emma Wilson",
email: "emma@example.com",
avatar: "/avatars/05.png",
},
{
name: "Jackson Lee",
email: "lee@example.com",
avatar: "/avatars/02.png",
},
{
name: "William Kim",
email: "will@email.com",
avatar: "/avatars/04.png",
},
] as const;
type User = (typeof users)[number];
export function CardsChat() {
const [open, setOpen] = React.useState(false);
const [selectedUsers, setSelectedUsers] = React.useState([]);
const [messages, setMessages] = React.useState([
{
role: "agent",
content: "Hi, how can I help you today?",
},
{
role: "user",
content: "Hey, I'm having trouble with my account.",
},
{
role: "agent",
content: "What seems to be the problem?",
},
{
role: "user",
content: "I can't log in.",
},
]);
const [input, setInput] = React.useState("");
const inputLength = input.trim().length;
return (
<>
S
Sofia Davis
m@example.com
setOpen(true)}
>
New message
New message
{messages.map((message, index) => (
{message.content}
))}
New message
Invite a user to this thread. This will create a new group message.
No users found.
{users.map((user) => (
{
if (selectedUsers.includes(user)) {
return setSelectedUsers(
selectedUsers.filter((selectedUser) => selectedUser !== user)
);
}
return setSelectedUsers(
[...users].filter((u) => [...selectedUsers, user].includes(u))
);
}}
>
{user.name[0]}
{selectedUsers.includes(user) ? (
) : null}
))}
{selectedUsers.length > 0 ? (
{selectedUsers.map((user) => (
{user.name[0]}
))}
) : (
Select users to add to this thread.
)}
{
setOpen(false);
}}
disabled={selectedUsers.length < 2}
>
Continue
>
);
}
================================================
FILE: components/examples/cards/cookie-settings.tsx
================================================
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
export function CardsCookieSettings() {
return (
Cookie Settings
Manage your cookie settings here.
Strictly Necessary
These cookies are essential in order to use the website and use its features.
Functional Cookies
These cookies allow the website to provide personalized functionality.
Save preferences
);
}
================================================
FILE: components/examples/cards/create-account.tsx
================================================
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function CardsCreateAccount() {
return (
Create an account
Enter your email below to create your account
Email
Password
Create account
);
}
================================================
FILE: components/examples/cards/date-picker-with-range.tsx
================================================
"use client";
import { addDays, format } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import * as React from "react";
import { DateRange } from "react-day-picker";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
export function DatePickerWithRange({ className }: React.HTMLAttributes) {
const [date, setDate] = React.useState({
from: new Date(2022, 0, 20),
to: addDays(new Date(2022, 0, 20), 20),
});
return (
Date picker with range
Select a date range.
{date?.from ? (
date.to ? (
<>
{format(date.from, "LLL dd, y")} - {format(date.to, "LLL dd, y")}
>
) : (
format(date.from, "LLL dd, y")
)
) : (
Pick a date
)}
);
}
================================================
FILE: components/examples/cards/exercise-minutes.tsx
================================================
"use client";
import { CartesianGrid, Line, LineChart, XAxis } from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
const data = [
{
average: 400,
today: 240,
day: "Monday",
},
{
average: 300,
today: 139,
day: "Tuesday",
},
{
average: 200,
today: 980,
day: "Wednesday",
},
{
average: 278,
today: 390,
day: "Thursday",
},
{
average: 189,
today: 480,
day: "Friday",
},
{
average: 239,
today: 380,
day: "Saturday",
},
{
average: 349,
today: 430,
day: "Sunday",
},
];
const chartConfig = {
today: {
label: "Today",
color: "var(--primary)",
},
average: {
label: "Average",
color: "var(--primary)",
},
} satisfies ChartConfig;
export function CardsExerciseMinutes() {
return (
Exercise Minutes
Your exercise minutes are ahead of where you normally are.
value.slice(0, 3)}
/>
} />
);
}
================================================
FILE: components/examples/cards/forms.tsx
================================================
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
const plans = [
{
id: "starter",
name: "Starter Plan",
description: "Perfect for small businesses.",
price: "$10",
},
{
id: "pro",
name: "Pro Plan",
description: "More features and storage.",
price: "$20",
},
] as const;
export function CardsForms() {
return (
Upgrade your subscription
You are currently on the free plan. Upgrade to the pro plan to get access to all features.
Plan
Select the plan that best fits your needs.
{plans.map((plan) => (
{plan.name}
{plan.description}
))}
Notes
I agree to the terms and conditions
Allow us to send you emails
Cancel
Upgrade Plan
);
}
================================================
FILE: components/examples/cards/github-card.tsx
================================================
"use client";
import { Circle, Star } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import Link from "next/link";
export function GithubCard() {
return (
tweakcn
A visual editor for shadcn/ui components with beautiful themes. Accessible.
Customizable. Open Source.
Star
TypeScript
20k
Updated April 2023
);
}
================================================
FILE: components/examples/cards/index.tsx
================================================
import { CardsActivityGoal } from "@/components/examples/cards/activity-goal";
import { CardsCalendar } from "@/components/examples/cards/calendar";
import { CardsChat } from "@/components/examples/cards/chat";
import { CardsCookieSettings } from "@/components/examples/cards/cookie-settings";
import { CardsCreateAccount } from "@/components/examples/cards/create-account";
import { CardsExerciseMinutes } from "@/components/examples/cards/exercise-minutes";
import { CardsForms } from "@/components/examples/cards/forms";
import { CardsPayments } from "@/components/examples/cards/payments";
import { CardsReportIssue } from "@/components/examples/cards/report-issue";
import { CardsShare } from "@/components/examples/cards/share";
import { CardsStats } from "@/components/examples/cards/stats";
import { CardsTeamMembers } from "@/components/examples/cards/team-members";
import { DatePickerWithRange } from "./date-picker-with-range";
import { GithubCard } from "./github-card";
export default function CardsDemo() {
return (
);
}
================================================
FILE: components/examples/cards/payment-method.tsx
================================================
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const plans = [
{
id: "starter",
name: "Starter Plan",
description: "Perfect for small businesses.",
price: "$10",
},
{
id: "pro",
name: "Pro Plan",
description: "Advanced features with more storage.",
price: "$20",
},
] as const;
export function CardsPaymentMethod() {
return (
Payment Method
Add a new payment method to your account.
Name
Plan
Select the plan that best fits your needs.
{plans.map((plan) => (
{plan.name}
{plan.description}
))}
Card number
Expires
January
February
March
April
May
June
July
August
September
October
November
December
Year
{Array.from({ length: 10 }, (_, i) => (
{new Date().getFullYear() + i}
))}
CVC
Continue
);
}
================================================
FILE: components/examples/cards/payments.tsx
================================================
"use client";
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table";
import { MoreHorizontalIcon } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const data: Payment[] = [
{
id: "m5gr84i9",
amount: 316,
status: "success",
email: "ken99@example.com",
},
{
id: "3u1reuv4",
amount: 242,
status: "success",
email: "Abe45@example.com",
},
{
id: "derv1ws0",
amount: 837,
status: "processing",
email: "Monserrat44@example.com",
},
{
id: "bhqecj4p",
amount: 721,
status: "failed",
email: "carmella@example.com",
},
{
id: "k9f2m3n4",
amount: 450,
status: "pending",
email: "jason78@example.com",
},
{
id: "p5q6r7s8",
amount: 1280,
status: "success",
email: "sarah23@example.com",
},
];
export type Payment = {
id: string;
amount: number;
status: "pending" | "processing" | "success" | "failed";
email: string;
};
export const columns: ColumnDef[] = [
{
id: "select",
header: ({ table }) => (
table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {row.getValue("status")}
,
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) => {row.getValue("email")}
,
},
{
accessorKey: "amount",
header: () => Amount
,
cell: ({ row }) => {
const amount = parseFloat(row.getValue("amount"));
// Format the amount as a dollar amount
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
return {formatted}
;
},
},
{
id: "actions",
enableHiding: false,
cell: ({ row }) => {
const payment = row.original;
return (
Open menu
Actions
navigator.clipboard.writeText(payment.id)}>
Copy payment ID
View customer
View payment details
);
},
},
];
export function CardsPayments() {
const [sorting, setSorting] = React.useState([]);
const [columnFilters, setColumnFilters] = React.useState([]);
const [columnVisibility, setColumnVisibility] = React.useState({});
const [rowSelection, setRowSelection] = React.useState({});
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
Payments
Manage your payments.
{table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header) => {
return (
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
);
})}
))}
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
{row.getVisibleCells().map((cell) => (
{flexRender(cell.column.columnDef.cell, cell.getContext())}
))}
))
) : (
No results.
)}
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
);
}
================================================
FILE: components/examples/cards/report-issue.tsx
================================================
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
export function CardsReportIssue() {
const id = React.useId();
return (
Report an issue
What area are you having problems with?
Area
Team
Billing
Account
Deployments
Support
Security Level
Severity 1 (Highest)
Severity 2
Severity 3
Severity 4 (Lowest)
Subject
Description
Cancel
Submit
);
}
================================================
FILE: components/examples/cards/share.tsx
================================================
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
const people = [
{
name: "Olivia Martin",
email: "m@example.com",
avatar: "/avatars/03.png",
},
{
name: "Isabella Nguyen",
email: "b@example.com",
avatar: "/avatars/04.png",
},
{
name: "Sofia Davis",
email: "p@example.com",
avatar: "/avatars/05.png",
},
{
name: "Ethan Thompson",
email: "e@example.com",
avatar: "/avatars/01.png",
},
];
export function CardsShare() {
return (
Share this document
Anyone with the link can view this document.
Link
Copy Link
People with access
{people.map((person) => (
{person.name.charAt(0)}
{person.name}
{person.email}
Can edit
Can view
))}
);
}
================================================
FILE: components/examples/cards/stats.tsx
================================================
"use client";
import { Area, AreaChart, Line, LineChart } from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ChartConfig, ChartContainer } from "@/components/ui/chart";
const data = [
{
revenue: 10400,
subscription: 40,
},
{
revenue: 14405,
subscription: 90,
},
{
revenue: 9400,
subscription: 200,
},
{
revenue: 8200,
subscription: 278,
},
{
revenue: 7000,
subscription: 89,
},
{
revenue: 9600,
subscription: 239,
},
{
revenue: 11244,
subscription: 78,
},
{
revenue: 26475,
subscription: 89,
},
];
const chartConfig = {
revenue: {
label: "Revenue",
color: "var(--primary)",
},
subscription: {
label: "Subscriptions",
color: "var(--primary)",
},
} satisfies ChartConfig;
export function CardsStats() {
return (
Total Revenue
$15,231.89
+20.1% from last month
Subscriptions
+2,350
+180.1% from last month
);
}
================================================
FILE: components/examples/cards/team-members.tsx
================================================
"use client";
import { ChevronDown } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
const teamMembers = [
{
name: "Sofia Davis",
email: "m@example.com",
avatar: "/avatars/01.png",
role: "Owner",
},
{
name: "Jackson Lee",
email: "p@example.com",
avatar: "/avatars/02.png",
role: "Developer",
},
{
name: "Isabella Nguyen",
email: "i@example.com",
avatar: "/avatars/03.png",
role: "Billing",
},
];
const roles = [
{
name: "Viewer",
description: "Can view and comment.",
},
{
name: "Developer",
description: "Can view, comment and edit.",
},
{
name: "Billing",
description: "Can view, comment and manage billing.",
},
{
name: "Owner",
description: "Admin-level access to all resources.",
},
];
export function CardsTeamMembers() {
return (
Team Members
Invite your team members to collaborate.
{teamMembers.map((member) => (
{member.name.charAt(0)}
{member.name}
{member.email}
{member.role}
No roles found.
{roles.map((role) => (
{role.name}
{role.description}
))}
))}
);
}
================================================
FILE: components/examples/custom/index.tsx
================================================
import { DynamicWebsitePreview } from "@/components/dynamic-website-preview";
export default function CustomDemo() {
return ;
}
================================================
FILE: components/examples/dashboard/components/app-sidebar.tsx
================================================
import * as React from "react";
import {
ArrowUpCircleIcon,
BarChartIcon,
CameraIcon,
ClipboardListIcon,
DatabaseIcon,
FileCodeIcon,
FileIcon,
FileTextIcon,
FolderIcon,
HelpCircleIcon,
LayoutDashboardIcon,
ListIcon,
SearchIcon,
SettingsIcon,
UsersIcon,
} from "lucide-react";
import { NavDocuments } from "@/components/examples/dashboard/components/nav-documents";
import { NavMain } from "@/components/examples/dashboard/components/nav-main";
import { NavSecondary } from "@/components/examples/dashboard/components/nav-secondary";
import { NavUser } from "@/components/examples/dashboard/components/nav-user";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
const data = {
user: {
name: "shadcn",
email: "m@example.com",
avatar: "/avatars/shadcn.jpg",
},
navMain: [
{
title: "Dashboard",
url: "#",
icon: LayoutDashboardIcon,
},
{
title: "Lifecycle",
url: "#",
icon: ListIcon,
},
{
title: "Analytics",
url: "#",
icon: BarChartIcon,
},
{
title: "Projects",
url: "#",
icon: FolderIcon,
},
{
title: "Team",
url: "#",
icon: UsersIcon,
},
],
navClouds: [
{
title: "Capture",
icon: CameraIcon,
isActive: true,
url: "#",
items: [
{
title: "Active Proposals",
url: "#",
},
{
title: "Archived",
url: "#",
},
],
},
{
title: "Proposal",
icon: FileTextIcon,
url: "#",
items: [
{
title: "Active Proposals",
url: "#",
},
{
title: "Archived",
url: "#",
},
],
},
{
title: "Prompts",
icon: FileCodeIcon,
url: "#",
items: [
{
title: "Active Proposals",
url: "#",
},
{
title: "Archived",
url: "#",
},
],
},
],
navSecondary: [
{
title: "Settings",
url: "#",
icon: SettingsIcon,
},
{
title: "Get Help",
url: "#",
icon: HelpCircleIcon,
},
{
title: "Search",
url: "#",
icon: SearchIcon,
},
],
documents: [
{
name: "Data Library",
url: "#",
icon: DatabaseIcon,
},
{
name: "Reports",
url: "#",
icon: ClipboardListIcon,
},
{
name: "Word Assistant",
url: "#",
icon: FileIcon,
},
],
};
export function AppSidebar({ ...props }: React.ComponentProps) {
return (
Acme Inc.
);
}
================================================
FILE: components/examples/dashboard/components/chart-area-interactive.tsx
================================================
import * as React from "react";
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
import { useIsMobile } from "@/hooks/use-mobile";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
export const description = "An interactive area chart";
const chartData = [
{ date: "2024-04-01", desktop: 222, mobile: 150 },
{ date: "2024-04-02", desktop: 97, mobile: 180 },
{ date: "2024-04-03", desktop: 167, mobile: 120 },
{ date: "2024-04-04", desktop: 242, mobile: 260 },
{ date: "2024-04-05", desktop: 373, mobile: 290 },
{ date: "2024-04-06", desktop: 301, mobile: 340 },
{ date: "2024-04-07", desktop: 245, mobile: 180 },
{ date: "2024-04-08", desktop: 409, mobile: 320 },
{ date: "2024-04-09", desktop: 59, mobile: 110 },
{ date: "2024-04-10", desktop: 261, mobile: 190 },
{ date: "2024-04-11", desktop: 327, mobile: 350 },
{ date: "2024-04-12", desktop: 292, mobile: 210 },
{ date: "2024-04-13", desktop: 342, mobile: 380 },
{ date: "2024-04-14", desktop: 137, mobile: 220 },
{ date: "2024-04-15", desktop: 120, mobile: 170 },
{ date: "2024-04-16", desktop: 138, mobile: 190 },
{ date: "2024-04-17", desktop: 446, mobile: 360 },
{ date: "2024-04-18", desktop: 364, mobile: 410 },
{ date: "2024-04-19", desktop: 243, mobile: 180 },
{ date: "2024-04-20", desktop: 89, mobile: 150 },
{ date: "2024-04-21", desktop: 137, mobile: 200 },
{ date: "2024-04-22", desktop: 224, mobile: 170 },
{ date: "2024-04-23", desktop: 138, mobile: 230 },
{ date: "2024-04-24", desktop: 387, mobile: 290 },
{ date: "2024-04-25", desktop: 215, mobile: 250 },
{ date: "2024-04-26", desktop: 75, mobile: 130 },
{ date: "2024-04-27", desktop: 383, mobile: 420 },
{ date: "2024-04-28", desktop: 122, mobile: 180 },
{ date: "2024-04-29", desktop: 315, mobile: 240 },
{ date: "2024-04-30", desktop: 454, mobile: 380 },
{ date: "2024-05-01", desktop: 165, mobile: 220 },
{ date: "2024-05-02", desktop: 293, mobile: 310 },
{ date: "2024-05-03", desktop: 247, mobile: 190 },
{ date: "2024-05-04", desktop: 385, mobile: 420 },
{ date: "2024-05-05", desktop: 481, mobile: 390 },
{ date: "2024-05-06", desktop: 498, mobile: 520 },
{ date: "2024-05-07", desktop: 388, mobile: 300 },
{ date: "2024-05-08", desktop: 149, mobile: 210 },
{ date: "2024-05-09", desktop: 227, mobile: 180 },
{ date: "2024-05-10", desktop: 293, mobile: 330 },
{ date: "2024-05-11", desktop: 335, mobile: 270 },
{ date: "2024-05-12", desktop: 197, mobile: 240 },
{ date: "2024-05-13", desktop: 197, mobile: 160 },
{ date: "2024-05-14", desktop: 448, mobile: 490 },
{ date: "2024-05-15", desktop: 473, mobile: 380 },
{ date: "2024-05-16", desktop: 338, mobile: 400 },
{ date: "2024-05-17", desktop: 499, mobile: 420 },
{ date: "2024-05-18", desktop: 315, mobile: 350 },
{ date: "2024-05-19", desktop: 235, mobile: 180 },
{ date: "2024-05-20", desktop: 177, mobile: 230 },
{ date: "2024-05-21", desktop: 82, mobile: 140 },
{ date: "2024-05-22", desktop: 81, mobile: 120 },
{ date: "2024-05-23", desktop: 252, mobile: 290 },
{ date: "2024-05-24", desktop: 294, mobile: 220 },
{ date: "2024-05-25", desktop: 201, mobile: 250 },
{ date: "2024-05-26", desktop: 213, mobile: 170 },
{ date: "2024-05-27", desktop: 420, mobile: 460 },
{ date: "2024-05-28", desktop: 233, mobile: 190 },
{ date: "2024-05-29", desktop: 78, mobile: 130 },
{ date: "2024-05-30", desktop: 340, mobile: 280 },
{ date: "2024-05-31", desktop: 178, mobile: 230 },
{ date: "2024-06-01", desktop: 178, mobile: 200 },
{ date: "2024-06-02", desktop: 470, mobile: 410 },
{ date: "2024-06-03", desktop: 103, mobile: 160 },
{ date: "2024-06-04", desktop: 439, mobile: 380 },
{ date: "2024-06-05", desktop: 88, mobile: 140 },
{ date: "2024-06-06", desktop: 294, mobile: 250 },
{ date: "2024-06-07", desktop: 323, mobile: 370 },
{ date: "2024-06-08", desktop: 385, mobile: 320 },
{ date: "2024-06-09", desktop: 438, mobile: 480 },
{ date: "2024-06-10", desktop: 155, mobile: 200 },
{ date: "2024-06-11", desktop: 92, mobile: 150 },
{ date: "2024-06-12", desktop: 492, mobile: 420 },
{ date: "2024-06-13", desktop: 81, mobile: 130 },
{ date: "2024-06-14", desktop: 426, mobile: 380 },
{ date: "2024-06-15", desktop: 307, mobile: 350 },
{ date: "2024-06-16", desktop: 371, mobile: 310 },
{ date: "2024-06-17", desktop: 475, mobile: 520 },
{ date: "2024-06-18", desktop: 107, mobile: 170 },
{ date: "2024-06-19", desktop: 341, mobile: 290 },
{ date: "2024-06-20", desktop: 408, mobile: 450 },
{ date: "2024-06-21", desktop: 169, mobile: 210 },
{ date: "2024-06-22", desktop: 317, mobile: 270 },
{ date: "2024-06-23", desktop: 480, mobile: 530 },
{ date: "2024-06-24", desktop: 132, mobile: 180 },
{ date: "2024-06-25", desktop: 141, mobile: 190 },
{ date: "2024-06-26", desktop: 434, mobile: 380 },
{ date: "2024-06-27", desktop: 448, mobile: 490 },
{ date: "2024-06-28", desktop: 149, mobile: 200 },
{ date: "2024-06-29", desktop: 103, mobile: 160 },
{ date: "2024-06-30", desktop: 446, mobile: 400 },
];
const chartConfig = {
visitors: {
label: "Visitors",
},
desktop: {
label: "Desktop",
color: "var(--chart-1)",
},
mobile: {
label: "Mobile",
color: "var(--chart-2)",
},
} satisfies ChartConfig;
export function ChartAreaInteractive() {
const isMobile = useIsMobile();
const [timeRange, setTimeRange] = React.useState("30d");
React.useEffect(() => {
if (isMobile) {
setTimeRange("7d");
}
}, [isMobile]);
const filteredData = chartData.filter((item) => {
const date = new Date(item.date);
const referenceDate = new Date("2024-06-30");
let daysToSubtract = 90;
if (timeRange === "30d") {
daysToSubtract = 30;
} else if (timeRange === "7d") {
daysToSubtract = 7;
}
const startDate = new Date(referenceDate);
startDate.setDate(startDate.getDate() - daysToSubtract);
return date >= startDate;
});
return (
Total Visitors
Total for the last 3 months
Last 3 months
Last 3 months
Last 30 days
Last 7 days
Last 3 months
Last 30 days
Last 7 days
{
const date = new Date(value);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}}
/>
{
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}}
indicator="dot"
/>
}
/>
);
}
================================================
FILE: components/examples/dashboard/components/chart-bar-mixed.tsx
================================================
"use client"
import { TrendingUp } from "lucide-react"
import { Bar, BarChart, XAxis, YAxis } from "recharts"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
const chartData = [
{ browser: "chrome", visitors: 275, fill: "var(--color-chrome)" },
{ browser: "safari", visitors: 200, fill: "var(--color-safari)" },
{ browser: "firefox", visitors: 187, fill: "var(--color-firefox)" },
{ browser: "edge", visitors: 173, fill: "var(--color-edge)" },
{ browser: "other", visitors: 90, fill: "var(--color-other)" },
]
const chartConfig = {
visitors: {
label: "Visitors",
},
chrome: {
label: "Chrome",
color: "var(--chart-1)",
},
safari: {
label: "Safari",
color: "var(--chart-2)",
},
firefox: {
label: "Firefox",
color: "var(--chart-3)",
},
edge: {
label: "Edge",
color: "var(--chart-4)",
},
other: {
label: "Other",
color: "var(--chart-5)",
},
} satisfies ChartConfig
export function ChartBarMixed() {
return (
Bar Chart - Mixed
January - June 2024
chartConfig[value as keyof typeof chartConfig]?.label
}
/>
}
/>
Trending up by 5.2% this month
Showing total visitors for the last 6 months
)
}
================================================
FILE: components/examples/dashboard/components/chart-pie-donut.tsx
================================================
"use client"
import * as React from "react"
import { TrendingUp } from "lucide-react"
import { Label, Pie, PieChart } from "recharts"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
const chartData = [
{ browser: "chrome", visitors: 275, fill: "var(--color-chrome)" },
{ browser: "safari", visitors: 200, fill: "var(--color-safari)" },
{ browser: "firefox", visitors: 287, fill: "var(--color-firefox)" },
{ browser: "edge", visitors: 173, fill: "var(--color-edge)" },
{ browser: "other", visitors: 190, fill: "var(--color-other)" },
]
const chartConfig = {
visitors: {
label: "Visitors",
},
chrome: {
label: "Chrome",
color: "var(--chart-1)",
},
safari: {
label: "Safari",
color: "var(--chart-2)",
},
firefox: {
label: "Firefox",
color: "var(--chart-3)",
},
edge: {
label: "Edge",
color: "var(--chart-4)",
},
other: {
label: "Other",
color: "var(--chart-5)",
},
} satisfies ChartConfig
export function ChartPieDonut() {
const totalVisitors = React.useMemo(() => {
return chartData.reduce((acc, curr) => acc + curr.visitors, 0)
}, [])
return (
Pie Chart - Donut with Text
January - June 2024
}
/>
{
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
{totalVisitors.toLocaleString()}
Visitors
)
}
}}
/>
Trending up by 5.2% this month
Showing total visitors for the last 6 months
)
}
================================================
FILE: components/examples/dashboard/components/data-table.tsx
================================================
import * as React from "react";
import {
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
type UniqueIdentifier,
} from "@dnd-kit/core";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ColumnDef,
ColumnFiltersState,
Row,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
ColumnsIcon,
GripVerticalIcon,
MoreVerticalIcon,
PlusIcon,
TrendingUpIcon,
} from "lucide-react";
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
import { toast } from "sonner";
import { z } from "zod";
import { useIsMobile } from "@/hooks/use-mobile";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export const schema = z.object({
id: z.number(),
header: z.string(),
type: z.string(),
target: z.string(),
limit: z.string(),
reviewer: z.string(),
});
// Create a separate component for the drag handle
function DragHandle({ id }: { id: number }) {
const { attributes, listeners } = useSortable({
id,
});
return (
Drag to reorder
);
}
const columns: ColumnDef>[] = [
{
id: "drag",
header: () => null,
cell: ({ row }) => ,
},
{
id: "select",
header: ({ table }) => (
table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "header",
header: "Header",
cell: ({ row }) => {
return ;
},
enableHiding: false,
},
{
accessorKey: "type",
header: "Section Type",
cell: ({ row }) => (
{row.original.type}
),
},
{
accessorKey: "target",
header: () => Target
,
cell: ({ row }) => (
),
},
{
accessorKey: "limit",
header: () => Limit
,
cell: ({ row }) => (
),
},
{
accessorKey: "reviewer",
header: "Reviewer",
cell: ({ row }) => {
const isAssigned = row.original.reviewer !== "Assign reviewer";
if (isAssigned) {
return row.original.reviewer;
}
return (
<>
Reviewer
Eddie Lake
Jamik Tashpulatov
>
);
},
},
{
id: "actions",
cell: () => (
Open menu
Edit
Make a copy
Favorite
Delete
),
},
];
function DraggableRow({ row }: { row: Row> }) {
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: row.original.id,
});
return (
{row.getVisibleCells().map((cell) => (
{flexRender(cell.column.columnDef.cell, cell.getContext())}
))}
);
}
export function DataTable({
data: initialData,
}: {
data: z.infer[];
}) {
const [data, setData] = React.useState(() => initialData);
const [rowSelection, setRowSelection] = React.useState({});
const [columnVisibility, setColumnVisibility] =
React.useState({});
const [columnFilters, setColumnFilters] = React.useState(
[]
);
const [sorting, setSorting] = React.useState([]);
const [pagination, setPagination] = React.useState({
pageIndex: 0,
pageSize: 10,
});
const sortableId = React.useId();
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
);
const dataIds = React.useMemo(
() => data?.map(({ id }) => id) || [],
[data]
);
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
pagination,
},
getRowId: (row) => row.id.toString(),
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
});
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (active && over && active.id !== over.id) {
setData((data) => {
const oldIndex = dataIds.indexOf(active.id);
const newIndex = dataIds.indexOf(over.id);
return arrayMove(data, oldIndex, newIndex);
});
}
}
return (
View
Outline
Past Performance
Key Personnel
Focus Documents
Outline
Past Performance{" "}
3
Key Personnel{" "}
2
Focus Documents
Customize Columns
Columns
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" &&
column.getCanHide()
)
.map((column) => {
return (
column.toggleVisibility(!!value)
}
>
{column.id}
);
})}
Add Section
{table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header) => {
return (
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
);
})}
))}
{table.getRowModel().rows?.length ? (
{table.getRowModel().rows.map((row) => (
))}
) : (
No results.
)}
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
Rows per page
{
table.setPageSize(Number(value));
}}
>
{[10, 20, 30, 40, 50].map((pageSize) => (
{pageSize}
))}
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
Go to first page
table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Go to previous page
table.nextPage()}
disabled={!table.getCanNextPage()}
>
Go to next page
table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
Go to last page
);
}
const chartData = [
{ month: "January", desktop: 186, mobile: 80 },
{ month: "February", desktop: 305, mobile: 200 },
{ month: "March", desktop: 237, mobile: 120 },
{ month: "April", desktop: 73, mobile: 190 },
{ month: "May", desktop: 209, mobile: 130 },
{ month: "June", desktop: 214, mobile: 140 },
];
const chartConfig = {
desktop: {
label: "Desktop",
color: "var(--primary)",
},
mobile: {
label: "Mobile",
color: "var(--primary)",
},
} satisfies ChartConfig;
function TableCellViewer({ item }: { item: z.infer }) {
const isMobile = useIsMobile();
return (
{item.header}
{item.header}
Showing total visitors for the last 6 months
{!isMobile && (
<>
value.slice(0, 3)}
hide
/>
}
/>
Trending up by 5.2% this month{" "}
Showing total visitors for the last 6 months. This is just
some random text to test the layout. It spans multiple lines
and should wrap around.
>
)}
Submit
Done
);
}
================================================
FILE: components/examples/dashboard/components/nav-documents.tsx
================================================
import {
FolderIcon,
MoreHorizontalIcon,
ShareIcon,
type LucideIcon,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
export function NavDocuments({
items,
}: {
items: {
name: string;
url: string;
icon: LucideIcon;
}[];
}) {
const { isMobile } = useSidebar();
return (
Documents
{items.map((item) => (
{item.name}
More
Open
Share
))}
More
);
}
================================================
FILE: components/examples/dashboard/components/nav-main.tsx
================================================
"use client";
import { PlusCircleIcon, type LucideIcon } from "lucide-react";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
export function NavMain({
items,
}: {
items: {
title: string;
url: string;
icon?: LucideIcon;
}[];
}) {
return (
Quick Create
{items.map((item) => (
{item.icon && }
{item.title}
))}
);
}
================================================
FILE: components/examples/dashboard/components/nav-secondary.tsx
================================================
"use client";
import * as React from "react";
import { LucideIcon } from "lucide-react";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
export function NavSecondary({
items,
...props
}: {
items: {
title: string;
url: string;
icon: LucideIcon;
}[];
} & React.ComponentPropsWithoutRef) {
return (
{items.map((item) => (
{item.title}
))}
);
}
================================================
FILE: components/examples/dashboard/components/nav-user.tsx
================================================
"use client";
import {
BellIcon,
CreditCardIcon,
LogOutIcon,
MoreVerticalIcon,
UserCircleIcon,
} from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
export function NavUser({
user,
}: {
user: {
name: string;
email: string;
avatar: string;
};
}) {
const { isMobile } = useSidebar();
return (
CN
{user.name}
{user.email}
CN
{user.name}
{user.email}
Account
Billing
Notifications
Log out
);
}
================================================
FILE: components/examples/dashboard/components/section-cards.tsx
================================================
import { TrendingDownIcon, TrendingUpIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export function SectionCards() {
return (
Total Revenue
$1,250.00
+12.5%
Trending up this month
Visitors for the last 6 months
New Customers
1,234
-20%
Down 20% this period
Acquisition needs attention
Active Accounts
45,678
+12.5%
Strong user retention
Engagement exceed targets
Growth Rate
4.5%
+4.5%
Steady performance
Meets growth projections
);
}
================================================
FILE: components/examples/dashboard/components/site-header.tsx
================================================
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
export function SiteHeader() {
return (
);
}
================================================
FILE: components/examples/dashboard/data.json
================================================
[
{
"id": 1,
"header": "Cover page",
"type": "Cover page",
"status": "In Process",
"target": "18",
"limit": "5",
"reviewer": "Eddie Lake"
},
{
"id": 2,
"header": "Table of contents",
"type": "Table of contents",
"status": "Done",
"target": "29",
"limit": "24",
"reviewer": "Eddie Lake"
},
{
"id": 3,
"header": "Executive summary",
"type": "Narrative",
"status": "Done",
"target": "10",
"limit": "13",
"reviewer": "Eddie Lake"
},
{
"id": 4,
"header": "Technical approach",
"type": "Narrative",
"status": "Done",
"target": "27",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 5,
"header": "Design",
"type": "Narrative",
"status": "In Process",
"target": "2",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 6,
"header": "Capabilities",
"type": "Narrative",
"status": "In Process",
"target": "20",
"limit": "8",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 7,
"header": "Integration with existing systems",
"type": "Narrative",
"status": "In Process",
"target": "19",
"limit": "21",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 8,
"header": "Innovation and Advantages",
"type": "Narrative",
"status": "Done",
"target": "25",
"limit": "26",
"reviewer": "Assign reviewer"
},
{
"id": 9,
"header": "Overview of EMR's Innovative Solutions",
"type": "Technical content",
"status": "Done",
"target": "7",
"limit": "23",
"reviewer": "Assign reviewer"
},
{
"id": 10,
"header": "Advanced Algorithms and Machine Learning",
"type": "Narrative",
"status": "Done",
"target": "30",
"limit": "28",
"reviewer": "Assign reviewer"
},
{
"id": 11,
"header": "Adaptive Communication Protocols",
"type": "Narrative",
"status": "Done",
"target": "9",
"limit": "31",
"reviewer": "Assign reviewer"
},
{
"id": 12,
"header": "Advantages Over Current Technologies",
"type": "Narrative",
"status": "Done",
"target": "12",
"limit": "0",
"reviewer": "Assign reviewer"
},
{
"id": 13,
"header": "Past Performance",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "33",
"reviewer": "Assign reviewer"
},
{
"id": 14,
"header": "Customer Feedback and Satisfaction Levels",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "34",
"reviewer": "Assign reviewer"
},
{
"id": 15,
"header": "Implementation Challenges and Solutions",
"type": "Narrative",
"status": "Done",
"target": "3",
"limit": "35",
"reviewer": "Assign reviewer"
},
{
"id": 16,
"header": "Security Measures and Data Protection Policies",
"type": "Narrative",
"status": "In Process",
"target": "6",
"limit": "36",
"reviewer": "Assign reviewer"
},
{
"id": 17,
"header": "Scalability and Future Proofing",
"type": "Narrative",
"status": "Done",
"target": "4",
"limit": "37",
"reviewer": "Assign reviewer"
},
{
"id": 18,
"header": "Cost-Benefit Analysis",
"type": "Plain language",
"status": "Done",
"target": "14",
"limit": "38",
"reviewer": "Assign reviewer"
},
{
"id": 19,
"header": "User Training and Onboarding Experience",
"type": "Narrative",
"status": "Done",
"target": "17",
"limit": "39",
"reviewer": "Assign reviewer"
},
{
"id": 20,
"header": "Future Development Roadmap",
"type": "Narrative",
"status": "Done",
"target": "11",
"limit": "40",
"reviewer": "Assign reviewer"
},
{
"id": 21,
"header": "System Architecture Overview",
"type": "Technical content",
"status": "In Process",
"target": "24",
"limit": "18",
"reviewer": "Maya Johnson"
},
{
"id": 22,
"header": "Risk Management Plan",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "22",
"reviewer": "Carlos Rodriguez"
},
{
"id": 23,
"header": "Compliance Documentation",
"type": "Legal",
"status": "In Process",
"target": "31",
"limit": "27",
"reviewer": "Sarah Chen"
},
{
"id": 24,
"header": "API Documentation",
"type": "Technical content",
"status": "Done",
"target": "8",
"limit": "12",
"reviewer": "Raj Patel"
},
{
"id": 25,
"header": "User Interface Mockups",
"type": "Visual",
"status": "In Process",
"target": "19",
"limit": "25",
"reviewer": "Leila Ahmadi"
},
{
"id": 26,
"header": "Database Schema",
"type": "Technical content",
"status": "Done",
"target": "22",
"limit": "20",
"reviewer": "Thomas Wilson"
},
{
"id": 27,
"header": "Testing Methodology",
"type": "Technical content",
"status": "In Process",
"target": "17",
"limit": "14",
"reviewer": "Assign reviewer"
},
{
"id": 28,
"header": "Deployment Strategy",
"type": "Narrative",
"status": "Done",
"target": "26",
"limit": "30",
"reviewer": "Eddie Lake"
},
{
"id": 29,
"header": "Budget Breakdown",
"type": "Financial",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 30,
"header": "Market Analysis",
"type": "Research",
"status": "Done",
"target": "29",
"limit": "32",
"reviewer": "Sophia Martinez"
},
{
"id": 31,
"header": "Competitor Comparison",
"type": "Research",
"status": "In Process",
"target": "21",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 32,
"header": "Maintenance Plan",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "23",
"reviewer": "Alex Thompson"
},
{
"id": 33,
"header": "User Personas",
"type": "Research",
"status": "In Process",
"target": "27",
"limit": "24",
"reviewer": "Nina Patel"
},
{
"id": 34,
"header": "Accessibility Compliance",
"type": "Legal",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 35,
"header": "Performance Metrics",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "David Kim"
},
{
"id": 36,
"header": "Disaster Recovery Plan",
"type": "Technical content",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 37,
"header": "Third-party Integrations",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Eddie Lake"
},
{
"id": 38,
"header": "User Feedback Summary",
"type": "Research",
"status": "Done",
"target": "20",
"limit": "15",
"reviewer": "Assign reviewer"
},
{
"id": 39,
"header": "Localization Strategy",
"type": "Narrative",
"status": "In Process",
"target": "12",
"limit": "19",
"reviewer": "Maria Garcia"
},
{
"id": 40,
"header": "Mobile Compatibility",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "James Wilson"
},
{
"id": 41,
"header": "Data Migration Plan",
"type": "Technical content",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Assign reviewer"
},
{
"id": 42,
"header": "Quality Assurance Protocols",
"type": "Technical content",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Priya Singh"
},
{
"id": 43,
"header": "Stakeholder Analysis",
"type": "Research",
"status": "In Process",
"target": "11",
"limit": "14",
"reviewer": "Eddie Lake"
},
{
"id": 44,
"header": "Environmental Impact Assessment",
"type": "Research",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Assign reviewer"
},
{
"id": 45,
"header": "Intellectual Property Rights",
"type": "Legal",
"status": "In Process",
"target": "17",
"limit": "20",
"reviewer": "Sarah Johnson"
},
{
"id": 46,
"header": "Customer Support Framework",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 47,
"header": "Version Control Strategy",
"type": "Technical content",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 48,
"header": "Continuous Integration Pipeline",
"type": "Technical content",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Michael Chen"
},
{
"id": 49,
"header": "Regulatory Compliance",
"type": "Legal",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Assign reviewer"
},
{
"id": 50,
"header": "User Authentication System",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "Eddie Lake"
},
{
"id": 51,
"header": "Data Analytics Framework",
"type": "Technical content",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 52,
"header": "Cloud Infrastructure",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 53,
"header": "Network Security Measures",
"type": "Technical content",
"status": "In Process",
"target": "29",
"limit": "32",
"reviewer": "Lisa Wong"
},
{
"id": 54,
"header": "Project Timeline",
"type": "Planning",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Eddie Lake"
},
{
"id": 55,
"header": "Resource Allocation",
"type": "Planning",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Assign reviewer"
},
{
"id": 56,
"header": "Team Structure and Roles",
"type": "Planning",
"status": "Done",
"target": "20",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 57,
"header": "Communication Protocols",
"type": "Planning",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 58,
"header": "Success Metrics",
"type": "Planning",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Eddie Lake"
},
{
"id": 59,
"header": "Internationalization Support",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 60,
"header": "Backup and Recovery Procedures",
"type": "Technical content",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 61,
"header": "Monitoring and Alerting System",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Daniel Park"
},
{
"id": 62,
"header": "Code Review Guidelines",
"type": "Technical content",
"status": "Done",
"target": "12",
"limit": "15",
"reviewer": "Eddie Lake"
},
{
"id": 63,
"header": "Documentation Standards",
"type": "Technical content",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 64,
"header": "Release Management Process",
"type": "Planning",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Assign reviewer"
},
{
"id": 65,
"header": "Feature Prioritization Matrix",
"type": "Planning",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Emma Davis"
},
{
"id": 66,
"header": "Technical Debt Assessment",
"type": "Technical content",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Eddie Lake"
},
{
"id": 67,
"header": "Capacity Planning",
"type": "Planning",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 68,
"header": "Service Level Agreements",
"type": "Legal",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Assign reviewer"
}
]
================================================
FILE: components/examples/dashboard/index.tsx
================================================
"use client";
import { AppSidebar } from "@/components/examples/dashboard/components/app-sidebar";
import { ChartAreaInteractive } from "@/components/examples/dashboard/components/chart-area-interactive";
import { DataTable } from "@/components/examples/dashboard/components/data-table";
import { SectionCards } from "@/components/examples/dashboard/components/section-cards";
import { SiteHeader } from "@/components/examples/dashboard/components/site-header";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { ChartBarMixed } from "./components/chart-bar-mixed";
import { ChartPieDonut } from "./components/chart-pie-donut";
import data from "./data.json";
export default function Dashboard() {
return (
);
}
================================================
FILE: components/examples/mail/components/account-switcher.tsx
================================================
import * as React from "react";
import { cn } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface AccountSwitcherProps {
isCollapsed: boolean;
accounts: {
label: string;
email: string;
icon: React.ReactNode;
}[];
}
export function AccountSwitcher({ isCollapsed, accounts }: AccountSwitcherProps) {
const [selectedAccount, setSelectedAccount] = React.useState(
accounts[0].email
);
return (
span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-1 [&>span]:truncate [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0",
isCollapsed &&
"flex h-9 w-9 shrink-0 items-center justify-center p-0 [&>span]:w-auto [&>svg]:hidden"
)}
aria-label="Select account"
>
{accounts.find((account) => account.email === selectedAccount)?.icon}
{accounts.find((account) => account.email === selectedAccount)?.label}
{accounts.map((account) => (
{account.icon}
{account.email}
))}
);
}
================================================
FILE: components/examples/mail/components/mail-display.tsx
================================================
import { addDays } from "date-fns";
import { addHours } from "date-fns";
import { format } from "date-fns";
import { nextSaturday } from "date-fns";
import {
Archive,
ArchiveX,
Clock,
Forward,
MoreVertical,
Reply,
ReplyAll,
Trash2,
} from "lucide-react";
import { DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { DropdownMenu, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Mail } from "@/components/examples/mail/data";
import { useState } from "react";
interface MailDisplayProps {
mail: Mail | null;
}
export function MailDisplay({ mail }: MailDisplayProps) {
const [selectedDate, setSelectedDate] = useState(new Date());
return (
Archive
Move to junk
Move to trash
Snooze
Snooze until
Later today{" "}
{format(addHours(selectedDate, 4), "E, h:mm b")}
Tomorrow
{format(addDays(selectedDate, 1), "E, h:mm b")}
This weekend
{format(nextSaturday(selectedDate), "E, h:mm b")}
Next week
{format(addDays(selectedDate, 7), "E, h:mm b")}
Reply
Reply all
Forward
More
Mark as unread
Star thread
Add label
Mute thread
{mail ? (
{mail.name
.split(" ")
.map((chunk) => chunk[0])
.join("")}
{mail.name}
{mail.subject}
Reply-To: {mail.email}
{mail.date && (
{format(new Date(mail.date), "PPpp")}
)}
{mail.text}
) : (
No message selected
)}
);
}
================================================
FILE: components/examples/mail/components/mail-list.tsx
================================================
import { ComponentProps } from "react";
import { formatDistanceToNow } from "date-fns";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Mail } from "@/components/examples/mail/data";
import { useMail } from "@/components/examples/mail/use-mail";
interface MailListProps {
items: Mail[];
}
export function MailList({ items }: MailListProps) {
const [mail, setMail] = useMail();
return (
{items.map((item) => (
setMail({
...mail,
selected: item.id,
})
}
>
{item.name}
{!item.read &&
}
{formatDistanceToNow(new Date(item.date), {
addSuffix: true,
})}
{item.subject}
{item.text.substring(0, 300)}
{item.labels.length ? (
{item.labels.map((label) => (
{label}
))}
) : null}
))}
);
}
function getBadgeVariantFromLabel(label: string): ComponentProps["variant"] {
if (["work"].includes(label.toLowerCase())) {
return "default";
}
if (["personal"].includes(label.toLowerCase())) {
return "outline";
}
return "secondary";
}
================================================
FILE: components/examples/mail/components/mail.tsx
================================================
import * as React from "react";
import {
AlertCircle,
Archive,
ArchiveX,
File,
Inbox,
MessagesSquare,
Search,
Send,
ShoppingCart,
Trash2,
Users2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TooltipProvider } from "@/components/ui/tooltip";
import { AccountSwitcher } from "@/components/examples/mail/components/account-switcher";
import { MailDisplay } from "@/components/examples/mail/components/mail-display";
import { MailList } from "@/components/examples/mail/components/mail-list";
import { Nav } from "@/components/examples/mail/components/nav";
import { type Mail } from "@/components/examples/mail/data";
import { useMail } from "@/components/examples/mail/use-mail";
interface MailProps {
accounts: {
label: string;
email: string;
icon: React.ReactNode;
}[];
mails: Mail[];
defaultLayout?: number[];
defaultCollapsed?: boolean;
navCollapsedSize: number;
}
export function Mail({
accounts,
mails,
defaultLayout = [20, 32, 48],
defaultCollapsed = false,
navCollapsedSize,
}: MailProps) {
const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed);
const [mail] = useMail();
return (
{
document.cookie = `react-resizable-panels:layout:mail=${JSON.stringify(layout)}`;
}}
className="h-full max-h-[min(800px,90vh)] items-stretch"
>
{
const collapsed = panelSize.asPercentage <= navCollapsedSize;
setIsCollapsed(collapsed);
document.cookie = `react-resizable-panels:collapsed=${JSON.stringify(collapsed)}`;
}}
className={cn(isCollapsed && "min-w-[50px] transition-all duration-300 ease-in-out")}
>
Inbox
All mail
Unread
!item.read)} />
item.id === mail.selected) || null} />
);
}
================================================
FILE: components/examples/mail/components/nav.tsx
================================================
import { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface NavProps {
isCollapsed: boolean;
links: {
title: string;
label?: string;
icon: LucideIcon;
variant: "default" | "ghost";
}[];
}
export function Nav({ links, isCollapsed }: NavProps) {
return (
{links.map((link, index) =>
isCollapsed ? (
{link.title}
{link.title}
{link.label && (
{link.label}
)}
) : (
{link.title}
{link.label && (
{link.label}
)}
)
)}
);
}
================================================
FILE: components/examples/mail/data.tsx
================================================
export const mails = [
{
id: "6c84fb90-12c4-11e1-840d-7b25c5ee775a",
name: "William Smith",
email: "williamsmith@example.com",
subject: "Meeting Tomorrow",
text: "Hi, let's have a meeting tomorrow to discuss the project. I've been reviewing the project details and have some ideas I'd like to share. It's crucial that we align on our next steps to ensure the project's success.\n\nPlease come prepared with any questions or insights you may have. Looking forward to our meeting!\n\nBest regards, William",
date: "2023-10-22T09:00:00",
read: true,
labels: ["meeting", "work", "important"],
},
{
id: "110e8400-e29b-11d4-a716-446655440000",
name: "Alice Smith",
email: "alicesmith@example.com",
subject: "Re: Project Update",
text: "Thank you for the project update. It looks great! I've gone through the report, and the progress is impressive. The team has done a fantastic job, and I appreciate the hard work everyone has put in.\n\nI have a few minor suggestions that I'll include in the attached document.\n\nLet's discuss these during our next meeting. Keep up the excellent work!\n\nBest regards, Alice",
date: "2023-10-22T10:30:00",
read: true,
labels: ["work", "important"],
},
{
id: "3e7c3f6d-bdf5-46ae-8d90-171300f27ae2",
name: "Bob Johnson",
email: "bobjohnson@example.com",
subject: "Weekend Plans",
text: "Any plans for the weekend? I was thinking of going hiking in the nearby mountains. It's been a while since we had some outdoor fun.\n\nIf you're interested, let me know, and we can plan the details. It'll be a great way to unwind and enjoy nature.\n\nLooking forward to your response!\n\nBest, Bob",
date: "2023-04-10T11:45:00",
read: true,
labels: ["personal"],
},
{
id: "61c35085-72d7-42b4-8d62-738f700d4b92",
name: "Emily Davis",
email: "emilydavis@example.com",
subject: "Re: Question about Budget",
text: "I have a question about the budget for the upcoming project. It seems like there's a discrepancy in the allocation of resources.\n\nI've reviewed the budget report and identified a few areas where we might be able to optimize our spending without compromising the project's quality.\n\nI've attached a detailed analysis for your reference. Let's discuss this further in our next meeting.\n\nThanks, Emily",
date: "2023-03-25T13:15:00",
read: false,
labels: ["work", "budget"],
},
{
id: "8f7b5db9-d935-4e42-8e05-1f1d0a3dfb97",
name: "Michael Wilson",
email: "michaelwilson@example.com",
subject: "Important Announcement",
text: "I have an important announcement to make during our team meeting. It pertains to a strategic shift in our approach to the upcoming product launch. We've received valuable feedback from our beta testers, and I believe it's time to make some adjustments to better meet our customers' needs.\n\nThis change is crucial to our success, and I look forward to discussing it with the team. Please be prepared to share your insights during the meeting.\n\nRegards, Michael",
date: "2023-03-10T15:00:00",
read: false,
labels: ["meeting", "work", "important"],
},
{
id: "1f0f2c02-e299-40de-9b1d-86ef9e42126b",
name: "Sarah Brown",
email: "sarahbrown@example.com",
subject: "Re: Feedback on Proposal",
text: "Thank you for your feedback on the proposal. It looks great! I'm pleased to hear that you found it promising. The team worked diligently to address all the key points you raised, and I believe we now have a strong foundation for the project.\n\nI've attached the revised proposal for your review.\n\nPlease let me know if you have any further comments or suggestions. Looking forward to your response.\n\nBest regards, Sarah",
date: "2023-02-15T16:30:00",
read: true,
labels: ["work"],
},
{
id: "17c0a96d-4415-42b1-8b4f-764efab57f66",
name: "David Lee",
email: "davidlee@example.com",
subject: "New Project Idea",
text: "I have an exciting new project idea to discuss with you. It involves expanding our services to target a niche market that has shown considerable growth in recent months.\n\nI've prepared a detailed proposal outlining the potential benefits and the strategy for execution.\n\nThis project has the potential to significantly impact our business positively. Let's set up a meeting to dive into the details and determine if it aligns with our current goals.\n\nBest regards, David",
date: "2023-01-28T17:45:00",
read: false,
labels: ["meeting", "work", "important"],
},
{
id: "2f0130cb-39fc-44c4-bb3c-0a4337edaaab",
name: "Olivia Wilson",
email: "oliviawilson@example.com",
subject: "Vacation Plans",
text: "Let's plan our vacation for next month. What do you think? I've been thinking of visiting a tropical paradise, and I've put together some destination options.\n\nI believe it's time for us to unwind and recharge. Please take a look at the options and let me know your preferences.\n\nWe can start making arrangements to ensure a smooth and enjoyable trip.\n\nExcited to hear your thoughts! Olivia",
date: "2022-12-20T18:30:00",
read: true,
labels: ["personal"],
},
{
id: "de305d54-75b4-431b-adb2-eb6b9e546014",
name: "James Martin",
email: "jamesmartin@example.com",
subject: "Re: Conference Registration",
text: "I've completed the registration for the conference next month. The event promises to be a great networking opportunity, and I'm looking forward to attending the various sessions and connecting with industry experts.\n\nI've also attached the conference schedule for your reference.\n\nIf there are any specific topics or sessions you'd like me to explore, please let me know. It's an exciting event, and I'll make the most of it.\n\nBest regards, James",
date: "2022-11-30T19:15:00",
read: true,
labels: ["work", "conference"],
},
{
id: "7dd90c63-00f6-40f3-bd87-5060a24e8ee7",
name: "Sophia White",
email: "sophiawhite@example.com",
subject: "Team Dinner",
text: "Let's have a team dinner next week to celebrate our success. We've achieved some significant milestones, and it's time to acknowledge our hard work and dedication.\n\nI've made reservations at a lovely restaurant, and I'm sure it'll be an enjoyable evening.\n\nPlease confirm your availability and any dietary preferences. Looking forward to a fun and memorable dinner with the team!\n\nBest, Sophia",
date: "2022-11-05T20:30:00",
read: false,
labels: ["meeting", "work"],
},
{
id: "99a88f78-3eb4-4d87-87b7-7b15a49a0a05",
name: "Daniel Johnson",
email: "danieljohnson@example.com",
subject: "Feedback Request",
text: "I'd like your feedback on the latest project deliverables. We've made significant progress, and I value your input to ensure we're on the right track.\n\nI've attached the deliverables for your review, and I'm particularly interested in any areas where you think we can further enhance the quality or efficiency.\n\nYour feedback is invaluable, and I appreciate your time and expertise. Let's work together to make this project a success.\n\nRegards, Daniel",
date: "2022-10-22T09:30:00",
read: false,
labels: ["work"],
},
{
id: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
name: "Ava Taylor",
email: "avataylor@example.com",
subject: "Re: Meeting Agenda",
text: "Here's the agenda for our meeting next week. I've included all the topics we need to cover, as well as time allocations for each.\n\nIf you have any additional items to discuss or any specific points to address, please let me know, and we can integrate them into the agenda.\n\nIt's essential that our meeting is productive and addresses all relevant matters.\n\nLooking forward to our meeting! Ava",
date: "2022-10-10T10:45:00",
read: true,
labels: ["meeting", "work"],
},
{
id: "c1a0ecb4-2540-49c5-86f8-21e5ce79e4e6",
name: "William Anderson",
email: "williamanderson@example.com",
subject: "Product Launch Update",
text: "The product launch is on track. I'll provide an update during our call. We've made substantial progress in the development and marketing of our new product.\n\nI'm excited to share the latest updates with you during our upcoming call. It's crucial that we coordinate our efforts to ensure a successful launch. Please come prepared with any questions or insights you may have.\n\nLet's make this product launch a resounding success!\n\nBest regards, William",
date: "2022-09-20T12:00:00",
read: false,
labels: ["meeting", "work", "important"],
},
{
id: "ba54eefd-4097-4949-99f2-2a9ae4d1a836",
name: "Mia Harris",
email: "miaharris@example.com",
subject: "Re: Travel Itinerary",
text: "I've received the travel itinerary. It looks great! Thank you for your prompt assistance in arranging the details. I've reviewed the schedule and the accommodations, and everything seems to be in order. I'm looking forward to the trip, and I'm confident it'll be a smooth and enjoyable experience.\n\nIf there are any specific activities or attractions you recommend at our destination, please feel free to share your suggestions.\n\nExcited for the trip! Mia",
date: "2022-09-10T13:15:00",
read: true,
labels: ["personal", "travel"],
},
{
id: "df09b6ed-28bd-4e0c-85a9-9320ec5179aa",
name: "Ethan Clark",
email: "ethanclark@example.com",
subject: "Team Building Event",
text: "Let's plan a team-building event for our department. Team cohesion and morale are vital to our success, and I believe a well-organized team-building event can be incredibly beneficial. I've done some research and have a few ideas for fun and engaging activities.\n\nPlease let me know your thoughts and availability. We want this event to be both enjoyable and productive.\n\nTogether, we'll strengthen our team and boost our performance.\n\nRegards, Ethan",
date: "2022-08-25T15:30:00",
read: false,
labels: ["meeting", "work"],
},
{
id: "d67c1842-7f8b-4b4b-9be1-1b3b1ab4611d",
name: "Chloe Hall",
email: "chloehall@example.com",
subject: "Re: Budget Approval",
text: "The budget has been approved. We can proceed with the project. I'm delighted to inform you that our budget proposal has received the green light from the finance department. This is a significant milestone, and it means we can move forward with the project as planned.\n\nI've attached the finalized budget for your reference. Let's ensure that we stay on track and deliver the project on time and within budget.\n\nIt's an exciting time for us! Chloe",
date: "2022-08-10T16:45:00",
read: true,
labels: ["work", "budget"],
},
{
id: "6c9a7f94-8329-4d70-95d3-51f68c186ae1",
name: "Samuel Turner",
email: "samuelturner@example.com",
subject: "Weekend Hike",
text: "Who's up for a weekend hike in the mountains? I've been craving some outdoor adventure, and a hike in the mountains sounds like the perfect escape. If you're up for the challenge, we can explore some scenic trails and enjoy the beauty of nature.\n\nI've done some research and have a few routes in mind.\n\nLet me know if you're interested, and we can plan the details.\n\nIt's sure to be a memorable experience! Samuel",
date: "2022-07-28T17:30:00",
read: false,
labels: ["personal"],
},
];
export type Mail = (typeof mails)[number];
export const accounts = [
{
label: "Alicia Koch",
email: "alicia@example.com",
icon: (
Gmail
),
},
{
label: "Alicia Koch",
email: "alicia2@example.com",
icon: (
Vercel
),
},
{
label: "Alicia Koch",
email: "alicia3@example.com",
icon: (
iCloud
),
},
];
export type Account = (typeof accounts)[number];
export const contacts = [
{
name: "Emma Johnson",
email: "emma.johnson@example.com",
},
{
name: "Liam Wilson",
email: "liam.wilson@example.com",
},
{
name: "Olivia Davis",
email: "olivia.davis@example.com",
},
{
name: "Noah Martinez",
email: "noah.martinez@example.com",
},
{
name: "Ava Taylor",
email: "ava.taylor@example.com",
},
{
name: "Lucas Brown",
email: "lucas.brown@example.com",
},
{
name: "Sophia Smith",
email: "sophia.smith@example.com",
},
{
name: "Ethan Wilson",
email: "ethan.wilson@example.com",
},
{
name: "Isabella Jackson",
email: "isabella.jackson@example.com",
},
{
name: "Mia Clark",
email: "mia.clark@example.com",
},
{
name: "Mason Lee",
email: "mason.lee@example.com",
},
{
name: "Layla Harris",
email: "layla.harris@example.com",
},
{
name: "William Anderson",
email: "william.anderson@example.com",
},
{
name: "Ella White",
email: "ella.white@example.com",
},
{
name: "James Thomas",
email: "james.thomas@example.com",
},
{
name: "Harper Lewis",
email: "harper.lewis@example.com",
},
{
name: "Benjamin Moore",
email: "benjamin.moore@example.com",
},
{
name: "Aria Hall",
email: "aria.hall@example.com",
},
{
name: "Henry Turner",
email: "henry.turner@example.com",
},
{
name: "Scarlett Adams",
email: "scarlett.adams@example.com",
},
];
export type Contact = (typeof contacts)[number];
================================================
FILE: components/examples/mail/index.tsx
================================================
import { Mail } from "@/components/examples/mail/components/mail";
import { accounts, mails } from "@/components/examples/mail/data";
export default function MailPage() {
return ;
}
================================================
FILE: components/examples/mail/use-mail.ts
================================================
import { create } from "zustand";
import { Mail, mails } from "@/components/examples/mail/data";
interface Config {
selected: Mail["id"] | null;
}
const useMailStore = create<
Config & { setState: (newState: Partial) => void }
>((set) => ({
selected: mails[0].id,
setState: (newState) => set((state) => ({ ...state, ...newState })),
}));
export function useMail(): [Config, (newState: Partial) => void] {
const selected = useMailStore((state) => state.selected);
const setState = useMailStore((state) => state.setState);
return [{ selected }, setState];
}
================================================
FILE: components/examples/music/components/album-artwork.tsx
================================================
import * as React from "react";
import { PlusCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Album } from "../data/albums";
import { playlists } from "../data/playlists";
interface AlbumArtworkProps extends React.HTMLAttributes {
album: Album;
aspectRatio?: "portrait" | "square";
width?: number;
height?: number;
}
export function AlbumArtwork({
album,
aspectRatio = "portrait",
width,
height,
className,
...props
}: AlbumArtworkProps) {
return (
Add to Library
Add to Playlist
New Playlist
{playlists.map((playlist) => (
{playlist}
))}
Play Next
Play Later
Create Station
Like
Share
{album.name}
{album.artist}
);
}
================================================
FILE: components/examples/music/components/menu.tsx
================================================
import {
Menubar,
MenubarCheckboxItem,
MenubarContent,
MenubarItem,
MenubarLabel,
MenubarMenu,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSeparator,
MenubarShortcut,
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
MenubarTrigger,
} from "@/components/ui/menubar";
export function Menu() {
return (
Music
About Music
Preferences... ⌘,
Hide Music... ⌘H
Hide Others... ⇧⌘H
Quit Music ⌘Q
File
New
Playlist ⌘N
Playlist from Selection ⇧⌘N
Smart Playlist... ⌥⌘N
Playlist Folder
Genius Playlist
Open Stream URL... ⌘U
Close Window ⌘W
Library
Update Cloud Library
Update Genius
Organize Library...
Export Library...
Import Playlist...
Export Playlist...
Show Duplicate Items
Get Album Artwork
Get Track Names
Import... ⌘O
Burn Playlist to Disc...
Show in Finder ⇧⌘R {" "}
Convert
Page Setup...
Print... ⌘P
Edit
Undo ⌘Z
Redo ⇧⌘Z
Cut ⌘X
Copy ⌘C
Paste ⌘V
Select All ⌘A
Deselect All ⇧⌘A
Smart Dictation...{" "}
Emoji & Symbols{" "}
View
Show Playing Next
Show Lyrics
Show Status Bar
Hide Sidebar
Enter Full Screen
Account
Switch Account
Andy
Benoit
Luis
Manage Family...
Add Account...
);
}
================================================
FILE: components/examples/music/components/podcast-empty-placeholder.tsx
================================================
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogTrigger,
} from "@/components/ui/revola";
export function PodcastEmptyPlaceholder() {
return (
No episodes added
You have not added any podcasts. Add one below.
Add Podcast
Add Podcast
Copy and paste the podcast feed URL to import.
Import Podcast
);
}
================================================
FILE: components/examples/music/components/sidebar.tsx
================================================
import * as React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Playlist } from "../data/playlists";
interface SidebarProps extends React.HTMLAttributes {
playlists: Playlist[];
}
export function Sidebar({ className, playlists }: SidebarProps) {
return (
Library
Playlists
Songs
Made for You
Artists
Albums
Playlists
{playlists?.map((playlist, i) => (
{playlist}
))}
);
}
================================================
FILE: components/examples/music/data/albums.ts
================================================
export interface Album {
name: string;
artist: string;
cover: string;
}
export const listenNowAlbums: Album[] = [
{
name: "React Rendezvous",
artist: "Ethan Byte",
cover:
"https://images.unsplash.com/photo-1611348586804-61bf6c080437?w=300&dpr=2&q=80",
},
{
name: "Async Awakenings",
artist: "Nina Netcode",
cover:
"https://images.unsplash.com/photo-1468817814611-b7edf94b5d60?w=300&dpr=2&q=80",
},
{
name: "The Art of Reusability",
artist: "Lena Logic",
cover:
"https://images.unsplash.com/photo-1528143358888-6d3c7f67bd5d?w=300&dpr=2&q=80",
},
{
name: "Stateful Symphony",
artist: "Beth Binary",
cover:
"https://images.unsplash.com/photo-1490300472339-79e4adc6be4a?w=300&dpr=2&q=80",
},
];
export const madeForYouAlbums: Album[] = [
{
name: "Thinking Components",
artist: "Lena Logic",
cover:
"https://images.unsplash.com/photo-1615247001958-f4bc92fa6a4a?w=300&dpr=2&q=80",
},
{
name: "Functional Fury",
artist: "Beth Binary",
cover:
"https://images.unsplash.com/photo-1513745405825-efaf9a49315f?w=300&dpr=2&q=80",
},
{
name: "React Rendezvous",
artist: "Ethan Byte",
cover:
"https://images.unsplash.com/photo-1614113489855-66422ad300a4?w=300&dpr=2&q=80",
},
{
name: "Stateful Symphony",
artist: "Beth Binary",
cover:
"https://images.unsplash.com/photo-1446185250204-f94591f7d702?w=300&dpr=2&q=80",
},
{
name: "Async Awakenings",
artist: "Nina Netcode",
cover:
"https://images.unsplash.com/photo-1468817814611-b7edf94b5d60?w=300&dpr=2&q=80",
},
{
name: "The Art of Reusability",
artist: "Lena Logic",
cover:
"https://images.unsplash.com/photo-1490300472339-79e4adc6be4a?w=300&dpr=2&q=80",
},
];
================================================
FILE: components/examples/music/data/playlists.ts
================================================
export type Playlist = (typeof playlists)[number];
export const playlists = [
"Recently Added",
"Recently Played",
"Top Songs",
"Top Albums",
"Top Artists",
"Logic Discography",
"Bedtime Beats",
"Feeling Happy",
"I miss Y2K Pop",
"Runtober",
"Mellow Days",
"Eminem Essentials",
];
================================================
FILE: components/examples/music/index.tsx
================================================
import { PlusCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AlbumArtwork } from "./components/album-artwork";
import { Menu } from "./components/menu";
import { PodcastEmptyPlaceholder } from "./components/podcast-empty-placeholder";
import { Sidebar } from "./components/sidebar";
import { listenNowAlbums, madeForYouAlbums } from "./data/albums";
import { playlists } from "./data/playlists";
export default function MusicPage() {
return (
<>
Listen Now
Top picks for you. Updated daily.
{listenNowAlbums.map((album) => (
))}
Made for You
Your personal playlists. Updated daily.
{madeForYouAlbums.map((album) => (
))}
New Episodes
Your favorite podcasts. Updated daily.
>
);
}
================================================
FILE: components/examples/pricing/pricing.tsx
================================================
"use client";
import { ArrowRight, CircleCheck } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
interface PricingFeature {
text: string;
}
interface PricingPlan {
id: string;
name: string;
description: string;
monthlyPrice: string;
yearlyPrice: string;
features: PricingFeature[];
button: {
text: string;
url: string;
};
}
interface Pricing2Props {
heading?: string;
description?: string;
plans?: PricingPlan[];
}
const Pricing2 = ({
heading = "Pricing",
description = "Check out our affordable pricing plans",
plans = [
{
id: "plus",
name: "Plus",
description: "For personal use",
monthlyPrice: "$19",
yearlyPrice: "$15",
features: [
{ text: "Up to 5 team members" },
{ text: "Basic components library" },
{ text: "Community support" },
{ text: "1GB storage space" },
],
button: {
text: "Purchase",
url: "https://www.shadcnblocks.com",
},
},
{
id: "pro",
name: "Pro",
description: "For professionals",
monthlyPrice: "$49",
yearlyPrice: "$35",
features: [
{ text: "Unlimited team members" },
{ text: "Advanced components" },
{ text: "Priority support" },
{ text: "Unlimited storage" },
],
button: {
text: "Purchase",
url: "https://www.shadcnblocks.com",
},
},
],
}: Pricing2Props) => {
const [isYearly, setIsYearly] = useState(false);
return (
{heading}
{description}
Monthly
setIsYearly(!isYearly)} />
Yearly
{plans.map((plan) => (
{plan.name}
{plan.description}
{isYearly ? plan.yearlyPrice : plan.monthlyPrice}
Billed{" "}
{isYearly
? `$${Number(plan.yearlyPrice.slice(1)) * 12}`
: `$${Number(plan.monthlyPrice.slice(1)) * 12}`}{" "}
annually
{plan.id === "pro" && (
Everything in Plus, and:
)}
{plan.features.map((feature, index) => (
{feature.text}
))}
{plan.button.text}
))}
);
};
export default Pricing2;
================================================
FILE: components/examples/tasks/components/columns.tsx
================================================
import { ColumnDef } from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { labels, priorities, statuses } from "../data/data";
import { Task } from "../data/schema";
import { DataTableColumnHeader } from "./data-table-column-header";
import { DataTableRowActions } from "./data-table-row-actions";
export const columns: ColumnDef[] = [
{
id: "select",
header: ({ table }) => (
table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
row.toggleSelected(!!value)}
aria-label="Select row"
className="translate-y-[2px]"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "id",
header: ({ column }) => ,
cell: ({ row }) => {row.getValue("id")}
,
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "title",
header: ({ column }) => ,
cell: ({ row }) => {
const label = labels.find((label) => label.value === row.original.label);
return (
{label && {label.label} }
{row.getValue("title")}
);
},
},
{
accessorKey: "status",
header: ({ column }) => ,
cell: ({ row }) => {
const status = statuses.find(
(status) => status.value === row.getValue("status")
);
if (!status) {
return null;
}
return (
{status.icon && (
)}
{status.label}
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
},
{
accessorKey: "priority",
header: ({ column }) => (
),
cell: ({ row }) => {
const priority = priorities.find(
(priority) => priority.value === row.getValue("priority")
);
if (!priority) {
return null;
}
return (
{priority.icon && (
)}
{priority.label}
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
},
{
id: "actions",
cell: ({ row }) => ,
},
];
================================================
FILE: components/examples/tasks/components/data-table-column-header.tsx
================================================
import { Column } from "@tanstack/react-table";
import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface DataTableColumnHeaderProps
extends React.HTMLAttributes {
column: Column;
title: string;
}
export function DataTableColumnHeader({
column,
title,
className,
}: DataTableColumnHeaderProps) {
if (!column.getCanSort()) {
return {title}
;
}
return (
{title}
{column.getIsSorted() === "desc" ? (
) : column.getIsSorted() === "asc" ? (
) : (
)}
column.toggleSorting(false)}>
Asc
column.toggleSorting(true)}>
Desc
column.toggleVisibility(false)}>
Hide
);
}
================================================
FILE: components/examples/tasks/components/data-table-faceted-filter.tsx
================================================
import * as React from "react";
import { Column } from "@tanstack/react-table";
import { Check, PlusCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
interface DataTableFacetedFilterProps {
column?: Column;
title?: string;
options: {
label: string;
value: string;
icon?: React.ComponentType<{ className?: string }>;
}[];
}
export function DataTableFacetedFilter({
column,
title,
options,
}: DataTableFacetedFilterProps) {
const facets = column?.getFacetedUniqueValues();
const selectedValues = new Set(column?.getFilterValue() as string[]);
return (
{title}
{selectedValues?.size > 0 && (
<>
{selectedValues.size}
{selectedValues.size > 2 ? (
{selectedValues.size} selected
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
{option.label}
))
)}
>
)}
No results found.
{options.map((option) => {
const isSelected = selectedValues.has(option.value);
return (
{
if (isSelected) {
selectedValues.delete(option.value);
} else {
selectedValues.add(option.value);
}
const filterValues = Array.from(selectedValues);
column?.setFilterValue(
filterValues.length ? filterValues : undefined
);
}}
>
{option.icon && (
)}
{option.label}
{facets?.get(option.value) && (
{facets.get(option.value)}
)}
);
})}
{selectedValues.size > 0 && (
<>
column?.setFilterValue(undefined)}
className="justify-center text-center"
>
Clear filters
>
)}
);
}
================================================
FILE: components/examples/tasks/components/data-table-pagination.tsx
================================================
import { Table } from "@tanstack/react-table";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface DataTablePaginationProps {
table: Table;
}
export function DataTablePagination({
table,
}: DataTablePaginationProps) {
return (
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
Rows per page
{
table.setPageSize(Number(value));
}}
>
{[10, 20, 30, 40, 50].map((pageSize) => (
{pageSize}
))}
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
Go to first page
table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Go to previous page
table.nextPage()}
disabled={!table.getCanNextPage()}
>
Go to next page
table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
Go to last page
);
}
================================================
FILE: components/examples/tasks/components/data-table-row-actions.tsx
================================================
import { Row } from "@tanstack/react-table";
import { MoreHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { labels } from "../data/data";
import { taskSchema } from "../data/schema";
interface DataTableRowActionsProps {
row: Row;
}
export function DataTableRowActions({
row,
}: DataTableRowActionsProps) {
const task = taskSchema.parse(row.original);
return (
Open menu
Edit
Make a copy
Favorite
Labels
{labels.map((label) => (
{label.label}
))}
Delete
⌘⌫
);
}
================================================
FILE: components/examples/tasks/components/data-table-toolbar.tsx
================================================
import { Table } from "@tanstack/react-table";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { DataTableViewOptions } from "@/components/examples/tasks/components/data-table-view-options";
import { priorities, statuses } from "../data/data";
import { DataTableFacetedFilter } from "./data-table-faceted-filter";
interface DataTableToolbarProps {
table: Table;
}
export function DataTableToolbar({ table }: DataTableToolbarProps) {
const isFiltered = table.getState().columnFilters.length > 0;
return (
);
}
================================================
FILE: components/examples/tasks/components/data-table-view-options.tsx
================================================
import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Table } from "@tanstack/react-table";
import { Settings2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
interface DataTableViewOptionsProps {
table: Table;
}
export function DataTableViewOptions({
table,
}: DataTableViewOptionsProps) {
return (
View
Toggle columns
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" && column.getCanHide()
)
.map((column) => {
return (
column.toggleVisibility(!!value)}
>
{column.id}
);
})}
);
}
================================================
FILE: components/examples/tasks/components/data-table.tsx
================================================
"use client";
import * as React from "react";
import {
ColumnDef,
ColumnFiltersState,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { DataTablePagination } from "./data-table-pagination";
import { DataTableToolbar } from "./data-table-toolbar";
interface DataTableProps {
columns: ColumnDef[];
data: TData[];
}
export function DataTable({
columns,
data,
}: DataTableProps) {
const [rowSelection, setRowSelection] = React.useState({});
const [columnVisibility, setColumnVisibility] = React.useState(
{}
);
const [columnFilters, setColumnFilters] = React.useState([]);
const [sorting, setSorting] = React.useState([]);
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
});
return (
{table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header) => {
return (
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
);
})}
))}
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
{row.getVisibleCells().map((cell) => (
{flexRender(cell.column.columnDef.cell, cell.getContext())}
))}
))
) : (
No results.
)}
);
}
================================================
FILE: components/examples/tasks/components/user-nav.tsx
================================================
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function UserNav() {
return (
SC
Profile
⇧⌘P
Billing
⌘B
Settings
⌘S
New Team
Log out
⇧⌘Q
);
}
================================================
FILE: components/examples/tasks/data/data.tsx
================================================
import {
ArrowDown,
ArrowRight,
ArrowUp,
CheckCircle,
Circle,
CircleOff,
HelpCircle,
Timer,
} from "lucide-react"
export const labels = [
{
value: "bug",
label: "Bug",
},
{
value: "feature",
label: "Feature",
},
{
value: "documentation",
label: "Documentation",
},
]
export const statuses = [
{
value: "backlog",
label: "Backlog",
icon: HelpCircle,
},
{
value: "todo",
label: "Todo",
icon: Circle,
},
{
value: "in progress",
label: "In Progress",
icon: Timer,
},
{
value: "done",
label: "Done",
icon: CheckCircle,
},
{
value: "canceled",
label: "Canceled",
icon: CircleOff,
},
]
export const priorities = [
{
label: "Low",
value: "low",
icon: ArrowDown,
},
{
label: "Medium",
value: "medium",
icon: ArrowRight,
},
{
label: "High",
value: "high",
icon: ArrowUp,
},
]
================================================
FILE: components/examples/tasks/data/schema.ts
================================================
import { z } from "zod"
// We're keeping a simple non-relational schema here.
// IRL, you will have a schema for your data models.
export const taskSchema = z.object({
id: z.string(),
title: z.string(),
status: z.string(),
label: z.string(),
priority: z.string(),
})
export type Task = z.infer
================================================
FILE: components/examples/tasks/data/tasks.json
================================================
[
{
"id": "TASK-8782",
"title": "You can't compress the program without quantifying the open-source SSD pixel!",
"status": "in progress",
"label": "documentation",
"priority": "medium"
},
{
"id": "TASK-7878",
"title": "Try to calculate the EXE feed, maybe it will index the multi-byte pixel!",
"status": "backlog",
"label": "documentation",
"priority": "medium"
},
{
"id": "TASK-7839",
"title": "We need to bypass the neural TCP card!",
"status": "todo",
"label": "bug",
"priority": "high"
},
{
"id": "TASK-5562",
"title": "The SAS interface is down, bypass the open-source pixel so we can back up the PNG bandwidth!",
"status": "backlog",
"label": "feature",
"priority": "medium"
},
{
"id": "TASK-8686",
"title": "I'll parse the wireless SSL protocol, that should driver the API panel!",
"status": "canceled",
"label": "feature",
"priority": "medium"
},
{
"id": "TASK-1280",
"title": "Use the digital TLS panel, then you can transmit the haptic system!",
"status": "done",
"label": "bug",
"priority": "high"
},
{
"id": "TASK-7262",
"title": "The UTF8 application is down, parse the neural bandwidth so we can back up the PNG firewall!",
"status": "done",
"label": "feature",
"priority": "high"
},
{
"id": "TASK-1138",
"title": "Generating the driver won't do anything, we need to quantify the 1080p SMTP bandwidth!",
"status": "in progress",
"label": "feature",
"priority": "medium"
},
{
"id": "TASK-7184",
"title": "We need to program the back-end THX pixel!",
"status": "todo",
"label": "feature",
"priority": "low"
},
{
"id": "TASK-5160",
"title": "Calculating the bus won't do anything, we need to navigate the back-end JSON protocol!",
"status": "in progress",
"label": "documentation",
"priority": "high"
},
{
"id": "TASK-5618",
"title": "Generating the driver won't do anything, we need to index the online SSL application!",
"status": "done",
"label": "documentation",
"priority": "medium"
},
{
"id": "TASK-6699",
"title": "I'll transmit the wireless JBOD capacitor, that should hard drive the SSD feed!",
"status": "backlog",
"label": "documentation",
"priority": "medium"
},
{
"id": "TASK-2858",
"title": "We need to override the online UDP bus!",
"status": "backlog",
"label": "bug",
"priority": "medium"
},
{
"id": "TASK-9864",
"title": "I'll reboot the 1080p FTP panel, that should matrix the HEX hard drive!",
"status": "done",
"label": "bug",
"priority": "high"
},
{
"id": "TASK-8404",
"title": "We need to generate the virtual HEX alarm!",
"status": "in progress",
"label": "bug",
"priority": "low"
},
{
"id": "TASK-5365",
"title": "Backing up the pixel won't do anything, we need to transmit the primary IB array!",
"status": "in progress",
"label": "documentation",
"priority": "low"
},
{
"id": "TASK-1780",
"title": "The CSS feed is down, index the bluetooth transmitter so we can compress the CLI protocol!",
"status": "todo",
"label": "documentation",
"priority": "high"
},
{
"id": "TASK-6938",
"title": "Use the redundant SCSI application, then you can hack the optical alarm!",
"status": "todo",
"label": "documentation",
"priority": "high"
},
{
"id": "TASK-9885",
"title": "We need to compress the auxiliary VGA driver!",
"status": "backlog",
"label": "bug",
"priority": "high"
},
{
"id": "TASK-3216",
"title": "Transmitting the transmitter won't do anything, we need to compress the virtual HDD sensor!",
"status": "backlog",
"label": "documentation",
"priority": "medium"
},
{
"id": "TASK-9285",
"title": "The IP monitor is down, copy the haptic alarm so we can generate the HTTP transmitter!",
"status": "todo",
"label": "bug",
"priority": "high"
},
{
"id": "TASK-1024",
"title": "Overriding the microchip won't do anything, we need to transmit the digital OCR transmitter!",
"status": "in progress",
"label": "documentation",
"priority": "low"
},
{
"id": "TASK-7068",
"title": "You can't generate the capacitor without indexing the wireless HEX pixel!",
"status": "canceled",
"label": "bug",
"priority": "low"
},
{
"id": "TASK-6502",
"title": "Navigating the microchip won't do anything, we need to bypass the back-end SQL bus!",
"status": "todo",
"label": "bug",
"priority": "high"
},
{
"id": "TASK-5326",
"title": "We need to hack the redundant UTF8 transmitter!",
"status": "todo",
"label": "bug",
"priority": "low"
},
{
"id": "TASK-6274",
"title": "Use the virtual PCI circuit, then you can parse the bluetooth alarm!",
"status": "canceled",
"label": "documentation",
"priority": "low"
},
{
"id": "TASK-1571",
"title": "I'll input the neural DRAM circuit, that should protocol the SMTP interface!",
"status": "in progress",
"label": "feature",
"priority": "medium"
},
{
"id": "TASK-9518",
"title": "Compressing the interface won't do anything, we need to compress the online SDD matrix!",
"status": "canceled",
"label": "documentation",
"priority": "medium"
},
{
"id": "TASK-5581",
"title": "I'll synthesize the digital COM pixel, that should transmitter the UTF8 protocol!",
"status": "backlog",
"label": "documentation",
"priority": "high"
},
{
"id": "TASK-2197",
"title": "Parsing the feed won't do anything, we need to copy the bluetooth DRAM bus!",
"status": "todo",
"label": "documentation",
"priority": "low"
},
{
"id": "TASK-8484",
"title": "We need to parse the solid state UDP firewall!",
"status": "in progress",
"label": "bug",
"priority": "low"
},
{
"id": "TASK-9892",
"title": "If we back up the application, we can get to the UDP application through the multi-byte THX capacitor!",
"status": "done",
"label": "documentation",
"priority": "high"
},
{
"id": "TASK-9616",
"title": "We need to synthesize the cross-platform ASCII pixel!",
"status": "in progress",
"label": "feature",
"priority": "medium"
},
{
"id": "TASK-9744",
"title": "Use the back-end IP card, then you can input the solid state hard drive!",
"status": "done",
"label": "documentation",
"priority": "low"
},
{
"id": "TASK-1376",
"title": "Generating the alarm won't do anything, we need to generate the mobile IP capacitor!",
"status": "backlog",
"label": "documentation",
"priority": "low"
},
{
"id": "TASK-7382",
"title": "If we back up the firewall, we can get to the RAM alarm through the primary UTF8 pixel!",
"status": "todo",
"label": "feature",
"priority": "low"
},
{
"id": "TASK-2290",
"title": "I'll compress the virtual JSON panel, that should application the UTF8 bus!",
"status": "canceled",
"label": "documentation",
"priority": "high"
},
{
"id": "TASK-1533",
"title": "You can't input the firewall without overriding the wireless TCP firewall!",
"status": "done",
"label": "bug",
"priority": "high"
},
{
"id": "TASK-4920",
"title": "Bypassing the hard drive won't do anything, we need to input the bluetooth JSON program!",
"status": "in progress",
"label": "bug",
"priority": "high"
},
{
"id": "TASK-5168",
"title": "If we synthesize the bus, we can get to the IP panel through the virtual TLS array!",
"status": "in progress",
"label": "feature",
"priority": "low"
},
{
"id": "TASK-7103",
"title": "We need to parse the multi-byte EXE bandwidth!",
"status": "canceled",
"label": "feature",
"priority": "low"
},
{
"id": "TASK-4314",
"title": "If we compress the program, we can get to the XML alarm through the multi-byte COM matrix!",
"status": "in progress",
"label": "bug",
"priority": "high"
},
{
"id": "TASK-3415",
"title": "Use the cross-platform XML application, then you can quantify the solid state feed!",
"status": "todo",
"label": "feature",
"priority": "high"
},
{
"id": "TASK-8339",
"title": "Try to calculate the DNS interface, maybe it will input the bluetooth capacitor!",
"status": "in progress",
"label": "feature",
"priority": "low"
},
{
"id": "TASK-6995",
"title": "Try to hack the XSS bandwidth, maybe it will override the bluetooth matrix!",
"status": "todo",
"label": "feature",
"priority": "high"
},
{
"id": "TASK-8053",
"title": "If we connect the program, we can get to the UTF8 matrix through the digital UDP protocol!",
"status": "todo",
"label": "feature",
"priority": "medium"
},
{
"id": "TASK-4336",
"title": "If we synthesize the microchip, we can get to the SAS sensor through the optical UDP program!",
"status": "todo",
"label": "documentation",
"priority": "low"
},
{
"id": "TASK-8790",
"title": "I'll back up the optical COM alarm, that should alarm the RSS capacitor!",
"status": "done",
"label": "bug",
"priority": "medium"
},
{
"id": "TASK-8980",
"title": "Try to navigate the SQL transmitter, maybe it will back up the virtual firewall!",
"status": "canceled",
"label": "bug",
"priority": "low"
},
{
"id": "TASK-7342",
"title": "Use the neural CLI card, then you can parse the online port!",
"status": "backlog",
"label": "documentation",
"priority": "low"
},
{
"id": "TASK-5608",
"title": "I'll hack the haptic SSL program, that should bus the UDP transmitter!",
"status": "canceled",
"label": "documentation",
"priority": "low"
},
{
"id": "TASK-1606",
"title": "I'll generate the bluetooth PNG firewall, that should pixel the SSL driver!",
"status": "done",
"label": "feature",
"priority": "medium"
},
{
"id": "TASK-7872",
"title": "Transmitting the circuit won't do anything, we need to reboot the 1080p RSS monitor!",
"status": "canceled",
"label": "feature",
"priority": "medium"
},
{
"id": "TASK-4167",
"title": "Use the cross-platform SMS circuit, then you can synthesize the optical feed!",
"status": "canceled",
"label": "bug",
"priority": "medium"
},
{
"id": "TASK-9581",
"title": "You can't index the port without hacking the cross-platform XSS monitor!",
"status": "backlog",
"label": "documentation",
"priority": "low"
},
{
"id": "TASK-8806",
"title": "We need to bypass the back-end SSL panel!",
"status": "done",
"label": "bug",
"priority": "medium"
},
{
"id": "TASK-6542",
"title": "Try to quantify the RSS firewall, maybe it will quantify the open-source system!",
"status": "done",
"label": "feature",
"priority": "low"
},
{
"id": "TASK-6806",
"title": "The VGA protocol is down, reboot the back-end matrix so we can parse the CSS panel!",
"status": "canceled",
"label": "documentation",
"priority": "low"
},
{
"id": "TASK-9549",
"title": "You can't bypass the bus without connecting the neural JBOD bus!",
"status": "todo",
"label": "feature",
"priority": "high"
},
{
"id": "TASK-1075",
"title": "Backing up the driver won't do anything, we need to parse the redundant RAM pixel!",
"status": "done",
"label": "feature",
"priority": "medium"
},
{
"id": "TASK-1427",
"title": "Use the auxiliary PCI circuit, then you can calculate the cross-platform interface!",
"status": "done",
"label": "documentation",
"priority": "high"
},
{
"id": "TASK-1907",
"title": "Hacking the circuit won't do anything, we need to back up the online DRAM system!",
"status": "todo",
"label": "documentation",
"priority": "high"
},
{
"id": "TASK-4309",
"title": "If we generate the system, we can get to the TCP sensor through the optical GB pixel!",
"status": "backlog",
"label": "bug",
"priority": "medium"
},
{
"id": "TASK-3973",
"title": "I'll parse the back-end ADP array, that should bandwidth the RSS bandwidth!",
"status": "todo",
"label": "feature",
"priority": "medium"
},
{
"id": "TASK-7962",
"title": "Use the wireless RAM program, then you can hack the cross-platform feed!",
"status": "canceled",
"label": "bug",
"priority": "low"
},
{
"id": "TASK-3360",
"title": "You can't quantify the program without synthesizing the neural OCR interface!",
"status": "done",
"label": "feature",
"priority": "medium"
},
{
"id": "TASK-9887",
"title": "Use the auxiliary ASCII sensor, then you can connect the solid state port!",
"status": "backlog",
"label": "bug",
"priority": "medium"
},
{
"id": "TASK-3649",
"title": "I'll input the virtual USB system, that should circuit the DNS monitor!",
"status": "in progress",
"label": "feature",
"priority": "medium"
},
{
"id": "TASK-3586",
"title": "If we quantify the circuit, we can get to the CLI feed through the mobile SMS hard drive!",
"status": "in progress",
"label": "bug",
"priority": "low"
},
{
"id": "TASK-5150",
"title": "I'll hack the wireless XSS port, that should transmitter the IP interface!",
"status": "canceled",
"label": "feature",
"priority": "medium"
},
{
"id": "TASK-3652",
"title": "The SQL interface is down, override the optical bus so we can program the ASCII interface!",
"status": "backlog",
"label": "feature",
"priority": "low"
},
{
"id": "TASK-6884",
"title": "Use the digital PCI circuit, then you can synthesize the multi-byte microchip!",
"status": "canceled",
"label": "feature",
"priority": "high"
},
{
"id": "TASK-1591",
"title": "We need to connect the mobile XSS driver!",
"status": "in progress",
"label": "feature",
"priority": "high"
},
{
"id": "TASK-3802",
"title": "Try to override the ASCII protocol, maybe it will parse the virtual matrix!",
"status": "in progress",
"label": "feature",
"priority": "low"
},
{
"id": "TASK-7253",
"title": "Programming the capacitor won't do anything, we need to bypass the neural IB hard drive!",
"status": "backlog",
"label": "bug",
"priority": "high"
},
{
"id": "TASK-9739",
"title": "We need to hack the multi-byte HDD bus!",
"status": "done",
"label": "documentation",
"priority": "medium"
},
{
"id": "TASK-4424",
"title": "Try to hack the HEX alarm, maybe it will connect the optical pixel!",
"status": "in progress",
"label": "documentation",
"priority": "medium"
},
{
"id": "TASK-3922",
"title": "You can't back up the capacitor without generating the wireless PCI program!",
"status": "backlog",
"label": "bug",
"priority": "low"
},
{
"id": "TASK-4921",
"title": "I'll index the open-source IP feed, that should system the GB application!",
"status": "canceled",
"label": "bug",
"priority": "low"
},
{
"id": "TASK-5814",
"title": "We need to calculate the 1080p AGP feed!",
"status": "backlog",
"label": "bug",
"priority": "high"
},
{
"id": "TASK-2645",
"title": "Synthesizing the system won't do anything, we need to navigate the multi-byte HDD firewall!",
"status": "todo",
"label": "documentation",
"priority": "medium"
},
{
"id": "TASK-4535",
"title": "Try to copy the JSON circuit, maybe it will connect the wireless feed!",
"status": "in progress",
"label": "feature",
"priority": "low"
},
{
"id": "TASK-4463",
"title": "We need to copy the solid state AGP monitor!",
"status": "done",
"label": "documentation",
"priority": "low"
},
{
"id": "TASK-9745",
"title": "If we connect the protocol, we can get to the GB system through the bluetooth PCI microchip!",
"status": "canceled",
"label": "feature",
"priority": "high"
},
{
"id": "TASK-2080",
"title": "If we input the bus, we can get to the RAM matrix through the auxiliary RAM card!",
"status": "todo",
"label": "bug",
"priority": "medium"
},
{
"id": "TASK-3838",
"title": "I'll bypass the online TCP application, that should panel the AGP system!",
"status": "backlog",
"label": "bug",
"priority": "high"
},
{
"id": "TASK-1340",
"title": "We need to navigate the virtual PNG circuit!",
"status": "todo",
"label": "bug",
"priority": "medium"
},
{
"id": "TASK-6665",
"title": "If we parse the monitor, we can get to the SSD hard drive through the cross-platform AGP alarm!",
"status": "canceled",
"label": "feature",
"priority": "low"
},
{
"id": "TASK-7585",
"title": "If we calculate the hard drive, we can get to the SSL program through the multi-byte CSS microchip!",
"status": "backlog",
"label": "feature",
"priority": "low"
},
{
"id": "TASK-6319",
"title": "We need to copy the multi-byte SCSI program!",
"status": "backlog",
"label": "bug",
"priority": "high"
},
{
"id": "TASK-4369",
"title": "Try to input the SCSI bus, maybe it will generate the 1080p pixel!",
"status": "backlog",
"label": "bug",
"priority": "high"
},
{
"id": "TASK-9035",
"title": "We need to override the solid state PNG array!",
"status": "canceled",
"label": "documentation",
"priority": "low"
},
{
"id": "TASK-3970",
"title": "You can't index the transmitter without quantifying the haptic ASCII card!",
"status": "todo",
"label": "documentation",
"priority": "medium"
},
{
"id": "TASK-4473",
"title": "You can't bypass the protocol without overriding the neural RSS program!",
"status": "todo",
"label": "documentation",
"priority": "low"
},
{
"id": "TASK-4136",
"title": "You can't hack the hard drive without hacking the primary JSON program!",
"status": "canceled",
"label": "bug",
"priority": "medium"
},
{
"id": "TASK-3939",
"title": "Use the back-end SQL firewall, then you can connect the neural hard drive!",
"status": "done",
"label": "feature",
"priority": "low"
},
{
"id": "TASK-2007",
"title": "I'll input the back-end USB protocol, that should bandwidth the PCI system!",
"status": "backlog",
"label": "bug",
"priority": "high"
},
{
"id": "TASK-7516",
"title": "Use the primary SQL program, then you can generate the auxiliary transmitter!",
"status": "done",
"label": "documentation",
"priority": "medium"
},
{
"id": "TASK-6906",
"title": "Try to back up the DRAM system, maybe it will reboot the online transmitter!",
"status": "done",
"label": "feature",
"priority": "high"
},
{
"id": "TASK-5207",
"title": "The SMS interface is down, copy the bluetooth bus so we can quantify the VGA card!",
"status": "in progress",
"label": "bug",
"priority": "low"
}
]
================================================
FILE: components/examples/tasks/index.tsx
================================================
import { z } from "zod";
import { columns } from "./components/columns";
import { DataTable } from "./components/data-table";
import { UserNav } from "./components/user-nav";
import { taskSchema } from "./data/schema";
import tasks from "./data/tasks.json";
// Simulate a database read for tasks.
function getTasks() {
return z.array(taskSchema).parse(tasks);
}
export default function TaskPage() {
const tasks = getTasks();
return (
<>
Welcome back!
Here's a list of your tasks for this month!
>
);
}
================================================
FILE: components/examples/typography/blog-post.tsx
================================================
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Bookmark, Calendar, Clock, Heart, MessageCircle, Share2 } from "lucide-react";
import Image from "next/image";
export function BlogPost() {
return (
{/* Header */}
Technology
Web Development
React
The Future of Web Development: Embracing Modern Technologies
Discover how cutting-edge technologies are reshaping the landscape of web development,
from AI-powered tools to revolutionary frameworks that are changing how we build for
the web.
{/* Author and Meta */}
JD
Jane Doe
Senior Developer
{/* Featured Image */}
{/* Content */}
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
The Evolution of Modern Frameworks
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui
officia deserunt mollit anim id est laborum.
"The best way to predict the future is to create it. In web development,
we're not just following trends—we're setting them."
Key Technologies Shaping the Future
Artificial Intelligence and Machine Learning integration
Edge computing and serverless architectures
Progressive Web Applications (PWAs)
WebAssembly for high-performance applications
Advanced CSS features and container queries
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque
laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi
architecto beatae vitae dicta sunt explicabo.
💡 Pro Tip
Always stay updated with the latest web standards and best practices. The web
development landscape evolves rapidly, and continuous learning is key to staying
relevant.
Looking Ahead
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia
consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro
quisquam est, qui dolorem ipsum quia dolor sit amet.
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium
voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint
occaecati cupiditate non provident.
{/* Engagement Actions */}
42
12
Share
Save for later
{/* Author Bio */}
JD
Jane Doe
Senior Developer & Tech Writer
Jane is a passionate developer with over 8 years of experience in web
development. She specializes in React, TypeScript, and modern web technologies.
When she's not coding, you can find her writing about the latest trends in
tech.
Follow
);
}
================================================
FILE: components/examples/typography/font-showcase.tsx
================================================
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
export function DemoFontShowcase() {
return (
Font Showcase
View theme fonts in different styles
Sans-Serif
Light Weight Text
Regular Weight Text
Medium Weight Text
Semibold Weight Text
Bold Weight Text
Serif
Light Weight Text
Regular Weight Text
Medium Weight Text
Semibold Weight Text
Bold Weight Text
Monospace
Light Weight Text
Regular Weight Text
Medium Weight Text
Semibold Weight Text
Bold Weight Text
);
}
================================================
FILE: components/examples/typography/typography-demo.tsx
================================================
import { BlogPost } from "./blog-post";
import { DemoFontShowcase } from "./font-showcase";
export default function TypographyDemo() {
return (
);
}
================================================
FILE: components/figma-export-dialog.tsx
================================================
"use client";
import FigmaIcon from "@/assets/figma.svg";
import Logo from "@/assets/logo.svg";
import Shadcraft from "@/assets/shadcraft.svg";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/revola";
import { ScrollArea } from "@/components/ui/scroll-area";
import { FIGMA_CONSTANTS, redirectToShadcraft } from "@/lib/figma-constants";
import { ArrowUpRight, Cable, Check, Figma, Paintbrush, X } from "lucide-react";
import Link from "next/link";
interface FigmaExportDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function FigmaExportDialog({ open, onOpenChange }: FigmaExportDialogProps) {
const steps = FIGMA_CONSTANTS.steps.map((step, index) => ({
...step,
icon:
index === 0 ? (
) : index === 1 ? (
) : (
),
}));
const handleGetStarted = () => {
redirectToShadcraft();
};
return (
{/* Header */}
Figma Export
Apply your theme to the ultimate Figma UI kit
{/* Hero Section */}
Apply your theme to the ultimate Figma UI kit
Get started
Preview
Trusted by top designers
{FIGMA_CONSTANTS.designers.map((designer, index) => (
{designer.fallback}
))}
{/* How it works */}
How it works
{steps.map((step, index) => (
{step.icon}
{step.step}
{step.title}
))}
{/* Feature Description */}
Top quality Figma UI kit for professionals
Shadcraft is packed with top quality components, true to the shadcn/ui ethos.
{/* Demo UI Preview */}
More on Shadcraft
{/* Pricing */}
Pricing
What you get with Shadcraft
{FIGMA_CONSTANTS.features.map((feature, index) => (
{feature}
))}
Prices in USD
);
}
================================================
FILE: components/figma-header.tsx
================================================
"use client";
import GitHubIcon from "@/assets/github.svg";
import Logo from "@/assets/logo.svg";
import { Button } from "@/components/ui/button";
import { useGithubStars } from "@/hooks/use-github-stars";
import { cn } from "@/lib/utils";
import { formatCompactNumber } from "@/utils/format";
import { Menu, X } from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { ThemeToggle } from "./theme-toggle";
interface FigmaHeaderProps {
isScrolled: boolean;
mobileMenuOpen: boolean;
setMobileMenuOpen: (open: boolean) => void;
}
export function FigmaHeader({ isScrolled, mobileMenuOpen, setMobileMenuOpen }: FigmaHeaderProps) {
const { stargazersCount } = useGithubStars("jnsahaj", "tweakcn");
return (
);
}
================================================
FILE: components/footer.tsx
================================================
import Link from "next/link";
import Logo from "@/assets/logo.svg";
import GitHubIcon from "@/assets/github.svg";
import TwitterIcon from "@/assets/twitter.svg";
import DiscordIcon from "@/assets/discord.svg";
export function Footer() {
return (
tweakcn
A powerful visual theme editor for shadcn/ui components with Tailwind CSS support.
Make your components stand out.
Product
Features
Examples
Roadmap
© {new Date().getFullYear()} tweakcn. All rights reserved.
Privacy Policy
);
}
================================================
FILE: components/get-pro-cta.tsx
================================================
"use client";
import { Button } from "@/components/ui/button";
import { useSubscription } from "@/hooks/use-subscription";
import { cn } from "@/lib/utils";
import { Gem } from "lucide-react";
import Link from "next/link";
interface GetProCTAProps extends React.ComponentProps {}
export function GetProCTA({ className, ...props }: GetProCTAProps) {
const { subscriptionStatus, isPending } = useSubscription();
const isPro = subscriptionStatus?.isSubscribed ?? false;
if (isPending || isPro) {
return null;
}
return (
Get Pro
);
}
================================================
FILE: components/get-pro-dialog-wrapper.tsx
================================================
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useGetProDialogStore } from "@/store/get-pro-dialog-store";
import { PRO_SUB_FEATURES } from "@/utils/subscription";
import { Calendar, Check } from "lucide-react";
import Link from "next/link";
import { NoiseEffect } from "./effects/noise-effect";
import { AIChatDemo } from "./examples/ai-chat-demo";
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "./ui/revola";
export function GetProDialogWrapper() {
const { isOpen, closeGetProDialog } = useGetProDialogStore();
return ;
}
interface GetProDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function GetProDialog({ isOpen, onClose }: GetProDialogProps) {
return (
{/* Left section: content */}
Get Pro
{`Unlock all of tweakcn's features`}
{`Don't worry, full theme customization is still yours, for free. Upgrade to Pro to take it
to the next level, cancel anytime.`}
Upgrade to Pro
Maybe Later
{/* Right section: chat preview, only visible md+ */}
{/* ----Background effects---- */}
{/* ----Background effects---- */}
);
}
================================================
FILE: components/header.tsx
================================================
"use client";
import DiscordIcon from "@/assets/discord.svg";
import FigmaIcon from "@/assets/figma.svg";
import GitHubIcon from "@/assets/github.svg";
import Logo from "@/assets/logo.svg";
import TwitterIcon from "@/assets/twitter.svg";
import { FigmaExportDialog } from "@/components/figma-export-dialog";
import { SocialLink } from "@/components/social-link";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { UserProfileDropdown } from "@/components/user-profile-dropdown";
import { useGithubStars } from "@/hooks/use-github-stars";
import { formatCompactNumber } from "@/utils/format";
import Link from "next/link";
import { useState } from "react";
import { GetProCTA } from "./get-pro-cta";
export function Header() {
const { stargazersCount } = useGithubStars("jnsahaj", "tweakcn");
const [figmaDialogOpen, setFigmaDialogOpen] = useState(false);
return (
tweakcn
{stargazersCount > 0 && formatCompactNumber(stargazersCount)}
setFigmaDialogOpen(true)}
variant="outline"
className="flex h-8 items-center gap-2"
>
Export to Figma
);
}
================================================
FILE: components/home/ai-generation-cta.tsx
================================================
import { AIChatDemo } from "@/components/examples/ai-chat-demo";
import { Button } from "@/components/ui/button";
import { ArrowRight, Check } from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
export function AIGenerationCTA() {
return (
{/* Left Column - Text Content */}
Generate Themes in
Seconds
Just provide an image or text prompt, and our AI will create a stunning,
production-ready theme for you.
Generate with AI
View Pricing
{[
"Theme Preview",
"Checkpoint Restoration",
"Image Extraction",
"Text-to-Theme",
].map((feature) => (
))}
{/* Right Column - Visual Preview */}
{/* Glassmorphism Container */}
{/* Background Glow */}
);
}
================================================
FILE: components/home/cta.tsx
================================================
import { Button } from "@/components/ui/button";
import { motion } from "motion/react";
import { ArrowRight } from "lucide-react";
import Link from "next/link";
export function CTA() {
return (
Ready to Make Your Components Stand Out?
Start customizing your shadcn/ui components today and create a unique
look for your application.
Try It Now
View on GitHub
No login required. Free to use. Open source.
);
}
================================================
FILE: components/home/faq.tsx
================================================
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { motion } from "motion/react";
const faqs = [
{
question: "What is tweakcn?",
answer:
"tweakcn is a visual theme editor for shadcn/ui components. It allows you to customize your theme visually and export the code for your project.",
},
{
question: "Is it free?",
answer:
"Yes, the core features are completely free. We offer a Pro plan for advanced AI features.",
},
{
question: "What's included in Pro?",
answer:
"Pro includes AI theme generation from images and prompts, as well as cloud saving for multiple themes.",
},
{
question: "Supports Tailwind v4?",
answer:
"Yes! We support both Tailwind CSS v3 and v4, along with OKLCH, HSL, and other color formats.",
},
{
question: "Can I use with existing projects?",
answer:
"Absolutely. Just copy the generated configuration into your existing project.",
},
];
export function FAQ() {
return (
FAQ
Got questions? We've got answers. If you can't find what you're looking for, feel free to reach out.
{faqs.map((faq, i) => (
{faq.question}
{faq.answer}
))}
);
}
================================================
FILE: components/home/features.tsx
================================================
import { BrainCircuit, Code, Contrast, FileCode, Gem, Layers, Paintbrush } from "lucide-react";
import { motion } from "motion/react";
const features = [
{
title: "Color Control",
description:
"Customize background, text, and border colors with an intuitive color picker interface.",
icon: ,
},
{
title: "Typography Settings",
description: "Fine-tune font size, weight, and text transform to create the perfect look.",
icon: ,
},
{
title: "Tailwind v4 & v3",
description:
"Seamlessly switch between Tailwind versions with support for OKLCH & HSL formats.",
icon: ,
},
{
title: "Detailed Properties",
description:
"Fine-tune every aspect including radius, spacing, shadows, and other properties.",
icon: ,
},
{
title: "Contrast Checker",
description:
"Ensure designs meet accessibility standards with built-in contrast ratio checking.",
icon: ,
},
{
title: "AI Theme Generation",
description:
"Create stunning, ready-to-use themes in seconds. Just provide an image or prompt.",
icon: ,
pro: true,
},
];
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 },
};
export function Features() {
return (
Powerful Tools
For Total Control
Everything you need to customize your shadcn/ui components and make them unique.
{features.map((feature, i) => (
{feature.icon}
{feature.title}
{feature.pro && (
Pro
)}
{feature.description}
))}
);
}
================================================
FILE: components/home/header.tsx
================================================
"use client";
import GitHubIcon from "@/assets/github.svg";
import Logo from "@/assets/logo.svg";
import { Button } from "@/components/ui/button";
import { useGithubStars } from "@/hooks/use-github-stars";
import { cn } from "@/lib/utils";
import { formatCompactNumber } from "@/utils/format";
import { ChevronRight, Menu, X } from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { ThemeToggle } from "../theme-toggle";
interface HeaderProps {
isScrolled: boolean;
mobileMenuOpen: boolean;
setMobileMenuOpen: (open: boolean) => void;
}
const navbarItems = [
{
label: "Features",
href: "#features",
},
{
label: "AI",
href: "/ai",
},
{
label: "Pricing",
href: "/pricing",
},
{
label: "Community",
href: "/community",
},
{
label: "FAQ",
href: "#faq",
},
];
export function Header({ isScrolled, mobileMenuOpen, setMobileMenuOpen }: HeaderProps) {
const { stargazersCount } = useGithubStars("jnsahaj", "tweakcn");
const handleScrollToSection = (e: React.MouseEvent) => {
e.preventDefault();
const targetId = e.currentTarget.getAttribute("href")?.slice(1);
if (!targetId) return;
const element = document.getElementById(targetId);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
}
};
return (
);
}
================================================
FILE: components/home/hero.tsx
================================================
"use client";
import { Button } from "@/components/ui/button";
import { ArrowRight, Check } from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useEditorStore } from "@/store/editor-store";
import { defaultPresets } from "@/utils/theme-presets";
import { ThemePresetButtons } from "@/components/home/theme-preset-buttons";
const presetNames = Object.keys(defaultPresets);
export function Hero() {
const { themeState, applyThemePreset } = useEditorStore();
const mode = themeState.currentMode;
return (
{/* Background Effects */}
{/* Headline */}
Design Your Perfect {" "}
shadcn/ui
{" "}
Theme
{/* Description */}
Customize colors, typography, and layouts with a real-time preview. No signup
required.
{/* Buttons */}
Start Customizing
Browse Community
{/* Features List */}
{/* Carousel */}
{/* Gradient Masks for Carousel */}
);
}
================================================
FILE: components/home/how-it-works.tsx
================================================
import { motion } from "motion/react";
const steps = [
{
step: "01",
title: "Select Preset",
description: "Start with a pre-made theme from our library.",
},
{
step: "02",
title: "Customize",
description:
"Adjust colors, radius, and typography visually.",
},
{
step: "03",
title: "Export Code",
description: "Copy the Tailwind CSS config to your project.",
},
];
export function HowItWorks() {
return (
Three Steps to
Perfection
We've simplified the theming process so you can focus on building your app.
{steps.map((step, i) => (
{step.title}
{step.description}
))}
);
}
================================================
FILE: components/home/testimonials.tsx
================================================
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { motion, useMotionValue, useReducedMotion } from "motion/react";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
// Testimonials Data
const testimonials = [
{
image: "https://pbs.twimg.com/profile_images/1766632098461253634/2t4wT1TZ_400x400.png",
name: "YiMing",
tag: "yimingdothan",
description: `v0 + tweakcn + chatgpt for graphics
generated a landing page in about 2~ hours
crazy how easy this shit is now`,
href: "https://x.com/yimingdothan/status/1923833970799608086",
},
{
image: "https://pbs.twimg.com/profile_images/1783856060249595904/8TfcCN0r_400x400.jpg",
name: "Guillermo Rauch",
tag: "rauchg",
description: `If you're looking to learn:
▪️ full stack Next.js
▪️ how to build a focused product people love
… look no further than tweakcn[0] by
@iamsahaj_xyz. It's an open-source @shadcn theme builder.
`,
href: "https://x.com/rauchg/status/1938745259204493738",
},
{
image: "https://pbs.twimg.com/profile_images/1593304942210478080/TUYae5z7_400x400.jpg",
name: "shadcn",
tag: "shadcn",
description: `4/n - Finally, a custom theme from tweakcn by @iamsahaj_xyz`,
href: "https://x.com/shadcn/status/1909619407124676701",
},
{
image: "https://pbs.twimg.com/profile_images/1849574174785732608/ltlLcyaT_400x400.jpg",
name: "Kevin Kern",
tag: "kregenrek",
description: `Tweakcn is really cool. Custom shadcn themes on the fly.`,
href: "https://x.com/kregenrek/status/1911892242568216618",
},
{
image: "https://pbs.twimg.com/profile_images/1756766826736893952/6Gvg6jha_400x400.jpg",
name: "OrcDev",
tag: "theorcdev",
description: `Transform your Shadcn app with one click!
@iamsahaj_xyz created a great concept with Tweakcn ⚔️`,
href: "https://x.com/theorcdev/status/1923396394452124081",
},
{
image: "https://pbs.twimg.com/profile_images/1934209156816216064/NZns8Qth_400x400.jpg",
name: "Ciara Wearen",
tag: "nocheerleader",
description: `Create a Custom Theme: Your app instantly looks more intentional.
Build a color palette, typography and layout preview with tweakcn dot com
Grab the CSS → drop into Bolt = cohesive design`,
href: "https://x.com/nocheerleader/status/1934648830315684275",
},
{
image: "https://pbs.twimg.com/profile_images/1937802227672109056/JHRKKC9G_400x400.jpg",
name: "Tanpreet Jolly 🌂",
tag: "JollyTanpreet",
description:
"I just tried tweakcn and seems like you nailed it. This is what I have been looking for, awesome job!",
href: "https://x.com/JollyTanpreet/status/1926923858721808484",
},
{
image: "https://pbs.twimg.com/profile_images/1677359164580929544/jngFF04Y_400x400.jpg",
name: "Code With Antonio",
tag: "YTCodeAntonio",
description: "there is an entire chapter dedicated to tweakcn!! such a cool project",
href: "https://x.com/YTCodeAntonio/status/1938314416497549430",
},
{
image: "https://pbs.twimg.com/profile_images/1942939901994893312/epjxuhCr_400x400.jpg",
name: "Emir",
tag: "emirthedev",
description: "Started using tweakcn for client projects too. This is a real game changer",
href: "https://x.com/emirthedev/status/1919418644183843211",
},
{
image: "https://pbs.twimg.com/profile_images/1903255064149442560/TYvinGL9_400x400.jpg",
name: "Matt Silverlock 🐀",
tag: "elithrar",
description: `used this shadcn theme editor to make it a little less plain: tweakcn.com`,
href: "https://x.com/elithrar/status/1905704716589510889",
},
];
const MarqueeRow = ({
items,
reverse = false,
}: {
items: typeof testimonials;
reverse?: boolean;
}) => {
const shouldReduceMotion = useReducedMotion();
const x = useRef(useMotionValue(0));
const speed = shouldReduceMotion ? 0 : 20;
const containerRef = useRef(null);
const animationFrame = useRef(0);
const lastTime = useRef(performance.now());
const isPaused = useRef(false);
const [duplicateCount, setDuplicateCount] = useState(2);
const [containerWidth, setContainerWidth] = useState(0);
useEffect(() => {
if (typeof window !== "undefined" && containerRef.current) {
const cardWidth = 400;
const screenWidth = window.innerWidth;
const cardsNeeded = Math.ceil(screenWidth / cardWidth) + 3;
const loopCount = Math.ceil(cardsNeeded / items.length);
setDuplicateCount(loopCount);
const totalWidth = cardWidth * items.length * loopCount;
setContainerWidth(totalWidth);
x.current.set(reverse ? -totalWidth / 2 : 0);
}
}, [items.length, reverse]);
useEffect(() => {
if (shouldReduceMotion) return;
const animate = (time: number) => {
const delta = time - lastTime.current;
lastTime.current = time;
if (!isPaused.current && containerRef.current) {
const direction = reverse ? 1 : -1;
const distance = (speed * delta * direction) / 1000;
const currentX = x.current.get();
let newX = currentX + distance;
if (reverse && newX >= 0) {
newX = -containerWidth / 2;
} else if (!reverse && Math.abs(newX) >= containerWidth / 2) {
newX = 0;
}
x.current.set(newX);
}
animationFrame.current = requestAnimationFrame(animate);
};
animationFrame.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationFrame.current);
}, [containerWidth, reverse, shouldReduceMotion, speed]);
const pause = () => (isPaused.current = true);
const resume = () => {
lastTime.current = performance.now();
isPaused.current = false;
};
const repeatedItems = Array(duplicateCount)
.fill(null)
.flatMap(() => items);
return (
{repeatedItems.map((testimonial, i) => (
{testimonial.name.charAt(0)}
{testimonial.name}
@{testimonial.tag}
{testimonial.description}
))}
);
};
// Testimonials Main Section
export function Testimonials() {
return (
Loved by developers
Join thousands of developers building beautiful interfaces.
{/* 🚀 Two Marquee Rows */}
);
}
================================================
FILE: components/home/theme-preset-buttons.tsx
================================================
"use client";
import { AnimationOptions, AnimationPlaybackControls, motion, TargetAndTransition, useAnimate } from "motion/react";
import { Button } from "@/components/ui/button";
import { getPresetThemeStyles } from "@/utils/theme-preset-helper";
import { cn } from "@/lib/utils";
import { colorFormatter } from "@/utils/color-converter";
import { ThemeEditorState } from "@/types/editor";
import { useEffect, useMemo, useRef } from "react";
// ColorBox component remains internal to ThemePresetButtons
const ColorBox = ({ color, radius }: { color: string; radius: string }) => {
return (
);
};
interface ThemePresetButtonsProps {
presetNames: string[];
mode: "light" | "dark";
themeState: ThemeEditorState;
applyThemePreset: (presetName: string) => void;
}
export function ThemePresetButtons({
presetNames,
mode,
themeState,
applyThemePreset,
}: ThemePresetButtonsProps) {
// Use the intended slice of presets
const presetsToShow = presetNames || [];
const numUniquePresets = presetsToShow.length;
// --- Configuration ---
const numRows = 3;
const buttonWidthPx = 200; // Keep consistent with previous style
const gapPx = 16; // space-x-4 -> 1rem = 16px
const rowGapPx = 16; // gap-y-4 -> 1rem = 16px
const duplicationFactor = 4; // Duplicate multiple times for wider screens
const baseDurationPerItem = 5; // Seconds per item for animation speed
// ---------------------
const rowsData = useMemo(() => {
// Distribute presets somewhat evenly across rows
const presetsByRow: string[][] = Array.from({ length: numRows }, () => []);
presetsToShow.forEach((preset, index) => {
presetsByRow[index % numRows].push(preset);
});
// Function to create props for a single row
const createRowProps = (rowIndex: number) => {
const rowPresets = presetsByRow[rowIndex];
const numPresetsInRow = rowPresets.length;
if (numPresetsInRow === 0) return null;
const duplicatedRowPresets = Array(duplicationFactor)
.fill(rowPresets)
.flat();
const totalWidth = numPresetsInRow * (buttonWidthPx + gapPx);
const duration = numPresetsInRow * baseDurationPerItem;
const initialOffset = rowIndex === 1 ? -100 : 0;
return {
key: `row-${rowIndex}`,
presets: duplicatedRowPresets,
numOriginalPresets: numPresetsInRow,
animate: { x: [initialOffset, initialOffset - totalWidth] }, // Animate based on original set width, starting from offset
transition: {
duration,
ease: "linear" as const,
repeat: Infinity,
},
style: { x: initialOffset }, // Apply initial offset
};
};
return Array.from({ length: numRows }, (_, i) =>
createRowProps(i)
).filter(Boolean);
}, [presetsToShow, numRows, buttonWidthPx, gapPx, duplicationFactor, baseDurationPerItem]);
// Avoid rendering if no presets
if (numUniquePresets === 0) {
return null;
}
return (
// Container for the rows with vertical gap
{rowsData.map((rowData) => (
{/* Inner div necessary for spacing when using justify-content */}
{rowData!.presets.map((presetName, index) => {
const themeStyles = getPresetThemeStyles(presetName)[mode];
const bgColor = colorFormatter(themeStyles.primary, "hsl", "4");
const isSelected = presetName === themeState.preset;
return (
// Wrapper for each button
applyThemePreset(presetName)}
>
{presetName.replace(/-/g, " ")}
);
})}
))}
);
}
interface AnimatedRowProps {
children: React.ReactNode;
target: TargetAndTransition;
options: AnimationOptions;
}
function AnimatedRow({ children, target, options }: AnimatedRowProps) {
const [scope, animate] = useAnimate();
const controls = useRef(null);
useEffect(() => {
controls.current = animate(scope.current, target, options);
}, [target, options, animate, scope]);
return (
controls.current?.pause()}
onHoverEnd={() => controls.current?.play()}
>
{children}
);
}
================================================
FILE: components/home/theme-preset-selector.tsx
================================================
"use client";
import { Badge } from "@/components/ui/badge";
import { useEditorStore } from "@/store/editor-store";
import { defaultPresets } from "@/utils/theme-presets";
import { motion } from "motion/react";
import { ThemePresetButtons } from "@/components/home/theme-preset-buttons";
import { lazy, Suspense } from "react";
import { GithubCard } from "../examples/cards/github-card";
import { CardsStats } from "../examples/cards/stats";
import { Loading } from "../loading";
const DemoMail = lazy(() => import("@/components/examples/mail"));
export function ThemePresetSelector() {
const { themeState, applyThemePreset } = useEditorStore();
const mode = themeState.currentMode;
const presetNames = Object.keys(defaultPresets);
return (
✦ Theme Presets
Elevate Your Design Instantly
Apply theme presets with a single click. See how each option enhances the look.
{/* Theme Selector Buttons */}
}>
);
}
================================================
FILE: components/horizontal-scroll-area.tsx
================================================
"use client";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { useScrollStartEnd } from "@/hooks/use-scroll-start-end";
import { cn } from "@/lib/utils";
interface HorizontalScrollAreaProps extends React.ComponentPropsWithoutRef {}
export function HorizontalScrollArea({ className, children, ...props }: HorizontalScrollAreaProps) {
const { isScrollStart, isScrollEnd, scrollStartRef, scrollEndRef } = useScrollStartEnd();
return (
);
}
================================================
FILE: components/icons/tailwind-css.tsx
================================================
import { cn } from "@/lib/utils";
import * as React from "react";
import type { SVGProps } from "react";
export function TailwindCSS({ className, ...props }: SVGProps) {
return (
);
}
export default TailwindCSS;
================================================
FILE: components/icons.tsx
================================================
type IconProps = React.HTMLAttributes;
export const Icons = {
logo: (props: IconProps) => (
),
twitter: (props: IconProps) => (
),
gitHub: (props: IconProps) => (
),
radix: (props: IconProps) => (
),
aria: (props: IconProps) => (
),
npm: (props: IconProps) => (
),
yarn: (props: IconProps) => (
),
pnpm: (props: IconProps) => (
),
react: (props: IconProps) => (
),
tailwind: (props: IconProps) => (
),
google: (props: IconProps) => (
),
apple: (props: IconProps) => (
),
paypal: (props: IconProps) => (
),
spinner: (props: IconProps) => (
),
};
================================================
FILE: components/loader.tsx
================================================
"use client";
import { cn } from "@/lib/utils";
export interface LoaderProps {
variant?:
| "circular"
| "classic"
| "pulse"
| "pulse-dot"
| "dots"
| "typing"
| "wave"
| "bars"
| "terminal"
| "text-blink"
| "text-shimmer"
| "loading-dots";
size?: "sm" | "md" | "lg";
text?: string;
className?: string;
}
export function CircularLoader({
className,
size = "md",
}: {
className?: string;
size?: "sm" | "md" | "lg";
}) {
const sizeClasses = {
sm: "size-4",
md: "size-5",
lg: "size-6",
};
return (
Loading
);
}
export function ClassicLoader({
className,
size = "md",
}: {
className?: string;
size?: "sm" | "md" | "lg";
}) {
const sizeClasses = {
sm: "size-4",
md: "size-5",
lg: "size-6",
};
const barSizes = {
sm: { height: "6px", width: "1.5px" },
md: { height: "8px", width: "2px" },
lg: { height: "10px", width: "2.5px" },
};
return (
{[...Array(12)].map((_, i) => (
))}
Loading
);
}
export function PulseLoader({
className,
size = "md",
}: {
className?: string;
size?: "sm" | "md" | "lg";
}) {
const sizeClasses = {
sm: "size-4",
md: "size-5",
lg: "size-6",
};
return (
);
}
export function PulseDotLoader({
className,
size = "md",
}: {
className?: string;
size?: "sm" | "md" | "lg";
}) {
const sizeClasses = {
sm: "size-1",
md: "size-2",
lg: "size-3",
};
return (
Loading
);
}
export function DotsLoader({
className,
size = "md",
}: {
className?: string;
size?: "sm" | "md" | "lg";
}) {
const dotSizes = {
sm: "h-1.5 w-1.5",
md: "h-2 w-2",
lg: "h-2.5 w-2.5",
};
const containerSizes = {
sm: "h-4",
md: "h-5",
lg: "h-6",
};
return (
{[...Array(3)].map((_, i) => (
))}
Loading
);
}
export function TypingLoader({
className,
size = "md",
}: {
className?: string;
size?: "sm" | "md" | "lg";
}) {
const dotSizes = {
sm: "h-1 w-1",
md: "h-1.5 w-1.5",
lg: "h-2 w-2",
};
const containerSizes = {
sm: "h-4",
md: "h-5",
lg: "h-6",
};
return (
{[...Array(3)].map((_, i) => (
))}
Loading
);
}
export function WaveLoader({
className,
size = "md",
}: {
className?: string;
size?: "sm" | "md" | "lg";
}) {
const barWidths = {
sm: "w-0.5",
md: "w-0.5",
lg: "w-1",
};
const containerSizes = {
sm: "h-4",
md: "h-5",
lg: "h-6",
};
const heights = {
sm: ["6px", "9px", "12px", "9px", "6px"],
md: ["8px", "12px", "16px", "12px", "8px"],
lg: ["10px", "15px", "20px", "15px", "10px"],
};
return (
{[...Array(5)].map((_, i) => (
))}
Loading
);
}
export function BarsLoader({
className,
size = "md",
}: {
className?: string;
size?: "sm" | "md" | "lg";
}) {
const barWidths = {
sm: "w-1",
md: "w-1.5",
lg: "w-2",
};
const containerSizes = {
sm: "h-4 gap-1",
md: "h-5 gap-1.5",
lg: "h-6 gap-2",
};
return (
{[...Array(3)].map((_, i) => (
))}
Loading
);
}
export function TerminalLoader({
className,
size = "md",
}: {
className?: string;
size?: "sm" | "md" | "lg";
}) {
const cursorSizes = {
sm: "h-3 w-1.5",
md: "h-4 w-2",
lg: "h-5 w-2.5",
};
const textSizes = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
};
const containerSizes = {
sm: "h-4",
md: "h-5",
lg: "h-6",
};
return (
);
}
export function TextBlinkLoader({
text = "Thinking",
className,
size = "md",
}: {
text?: string;
className?: string;
size?: "sm" | "md" | "lg";
}) {
const textSizes = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
};
return (
{text}
);
}
export function TextShimmerLoader({
text = "Thinking",
className,
size = "md",
}: {
text?: string;
className?: string;
size?: "sm" | "md" | "lg";
}) {
const textSizes = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
};
return (
{text}
);
}
export function TextDotsLoader({
className,
text = "Thinking",
size = "md",
}: {
className?: string;
text?: string;
size?: "sm" | "md" | "lg";
}) {
const textSizes = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
};
return (
{text}
.
.
.
);
}
function Loader({ variant = "circular", size = "md", text, className }: LoaderProps) {
switch (variant) {
case "circular":
return ;
case "classic":
return ;
case "pulse":
return ;
case "pulse-dot":
return ;
case "dots":
return ;
case "typing":
return ;
case "wave":
return ;
case "bars":
return ;
case "terminal":
return ;
case "text-blink":
return ;
case "text-shimmer":
return ;
case "loading-dots":
return ;
default:
return ;
}
}
export { Loader };
================================================
FILE: components/loading.tsx
================================================
import { cn } from "@/lib/utils";
interface LoadingProps {
className?: string;
}
export function Loading({ className }: LoadingProps) {
return (
);
}
================================================
FILE: components/posthog-init.tsx
================================================
"use client";
import { useEffect } from "react";
import { initPostHog } from "@/lib/posthog";
export function PostHogInit() {
useEffect(() => {
initPostHog();
}, []);
return null;
}
================================================
FILE: components/social-link.tsx
================================================
import { cn } from "@/lib/utils";
import { ArrowUpRight } from "lucide-react";
interface SocialLinkProps extends React.ComponentProps<"a"> {
showIcon?: boolean;
}
export function SocialLink({ href, children, className, showIcon = false }: SocialLinkProps) {
return (
{children}
{showIcon && }
);
}
================================================
FILE: components/tag-selector.tsx
================================================
"use client";
import { Badge } from "@/components/ui/badge";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { COMMUNITY_THEME_TAGS, MAX_TAGS_PER_THEME } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { useState } from "react";
interface TagSelectorProps {
selectedTags: string[];
onTagsChange: (tags: string[]) => void;
disabled?: boolean;
}
export function TagSelector({
selectedTags,
onTagsChange,
disabled,
}: TagSelectorProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const handleSelect = (tag: string) => {
setSearch("");
if (selectedTags.includes(tag)) {
onTagsChange(selectedTags.filter((t) => t !== tag));
} else if (selectedTags.length < MAX_TAGS_PER_THEME) {
onTagsChange([...selectedTags, tag]);
}
};
const handleRemove = (tag: string) => {
if (disabled) return;
onTagsChange(selectedTags.filter((t) => t !== tag));
};
return (
Tags{" "}
({selectedTags.length}/{MAX_TAGS_PER_THEME})
{selectedTags.length > 0 && (
{selectedTags.map((tag) => (
{tag}
handleRemove(tag)}
disabled={disabled}
>
))}
)}
{selectedTags.length >= MAX_TAGS_PER_THEME
? "Max tags selected"
: "Search tags..."}
e.preventDefault()}
>
e.stopPropagation()}
>
No tags found.
{COMMUNITY_THEME_TAGS.map((tag) => {
const isSelected = selectedTags.includes(tag);
const isAtLimit =
!isSelected &&
selectedTags.length >= MAX_TAGS_PER_THEME;
return (
handleSelect(tag)}
className={cn(isAtLimit && "opacity-50")}
>
{tag}
);
})}
);
}
================================================
FILE: components/theme-preview.tsx
================================================
"use client";
import { useEffect, useState } from "react";
import { ThemeStyleProps } from "@/types/theme";
import { cn } from "@/lib/utils";
import { extractFontFamily } from "@/utils/fonts";
import { loadGoogleFont } from "@/utils/fonts/google-fonts";
import { colorFormatter } from "@/utils/color-converter";
interface ThemePreviewProps {
styles: ThemeStyleProps;
name?: string;
className?: string;
}
function computeBoxShadow(styles: ThemeStyleProps): string | undefined {
const shadowColor = styles["shadow-color"];
const shadowOpacity = parseFloat(styles["shadow-opacity"] || "0.1");
const shadowBlur = styles["shadow-blur"] || "3px";
const shadowSpread = styles["shadow-spread"] || "0px";
const offsetX = styles["shadow-offset-x"] || "0";
const offsetY = styles["shadow-offset-y"] || "1px";
try {
const hsl = colorFormatter(shadowColor, "hsl", "3");
const color = `hsl(${hsl} / ${shadowOpacity.toFixed(2)})`;
return `${offsetX} ${offsetY} ${shadowBlur} ${shadowSpread} ${color}`;
} catch {
return undefined;
}
}
export function ThemePreview({ styles, name, className }: ThemePreviewProps) {
const fontSans = styles["font-sans"];
const fontFamily = fontSans ? extractFontFamily(fontSans) : null;
const [fontLoaded, setFontLoaded] = useState(() => {
if (!fontFamily) return true;
if (typeof document !== "undefined" && document.fonts) {
return document.fonts.check(`700 16px "${fontFamily}"`);
}
return false;
});
useEffect(() => {
if (!fontFamily) {
setFontLoaded(true);
return;
}
if (
typeof document !== "undefined" &&
document.fonts?.check(`700 46px "${fontFamily}"`)
) {
setFontLoaded(true);
return;
}
loadGoogleFont(fontFamily, ["400", "700"]);
document.fonts
.load(`700 16px "${fontFamily}"`)
.then(() => setFontLoaded(true))
.catch(() => setFontLoaded(true));
}, [fontFamily]);
const c = {
bg: styles.background || "#ffffff",
primary: styles.primary || "#000000",
secondary: styles.secondary || "#f1f5f9",
accent: styles.accent || "#f1f5f9",
muted: styles.muted || "#f1f5f9",
fg: styles.foreground || "#000000",
primaryFg: styles["primary-foreground"] || "#ffffff",
secondaryFg: styles["secondary-foreground"] || "#000000",
accentFg: styles["accent-foreground"] || "#000000",
mutedFg: styles["muted-foreground"] || "#666666",
destructive: styles.destructive || "#ef4444",
destructiveFg: styles["destructive-foreground"] || "#ffffff",
card: styles.card || "#ffffff",
cardFg: styles["card-foreground"] || "#000000",
border: styles.border || "#e2e8f0",
ring: styles.ring || "#000000",
radius: styles.radius || "0.5",
};
const boxShadow = computeBoxShadow(styles);
const palette = [
c.primary,
c.secondary,
c.accent,
c.muted,
c.border,
c.card,
];
return (
{/* Color Palette - top right */}
{palette.map((color, i) => (
))}
{/* Typography - bottom left */}
{name || "Aa"}
);
}
================================================
FILE: components/theme-provider.tsx
================================================
"use client";
import { createContext, useContext, useEffect } from "react";
import { useEditorStore } from "../store/editor-store";
import { applyThemeToElement } from "@/utils/apply-theme";
import { useThemePresetFromUrl } from "@/hooks/use-theme-preset-from-url";
type Theme = "dark" | "light";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
};
type Coords = { x: number; y: number };
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: (coords?: Coords) => void;
};
const initialState: ThemeProviderState = {
theme: "light",
setTheme: () => null,
toggleTheme: () => null,
};
const ThemeProviderContext = createContext(initialState);
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const { themeState, setThemeState } = useEditorStore();
// Handle theme preset from URL
useThemePresetFromUrl();
useEffect(() => {
const root = document.documentElement;
if (!root) return;
applyThemeToElement(themeState, root);
}, [themeState]);
const handleThemeChange = (newMode: Theme) => {
setThemeState({ ...themeState, currentMode: newMode });
};
const handleThemeToggle = (coords?: Coords) => {
const root = document.documentElement;
const newMode = themeState.currentMode === "light" ? "dark" : "light";
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (!document.startViewTransition || prefersReducedMotion) {
handleThemeChange(newMode);
return;
}
if (coords) {
root.style.setProperty("--x", `${coords.x}px`);
root.style.setProperty("--y", `${coords.y}px`);
}
document.startViewTransition(() => {
handleThemeChange(newMode);
});
};
const value: ThemeProviderState = {
theme: themeState.currentMode,
setTheme: handleThemeChange,
toggleTheme: handleThemeToggle,
};
return (
{children}
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
};
================================================
FILE: components/theme-script.tsx
================================================
"use client";
import { defaultDarkThemeStyles, defaultLightThemeStyles } from "@/config/theme";
export function ThemeScript() {
const scriptContent = `
// ----- FONT LOADING UTILITIES -----
const DEFAULT_FONT_WEIGHTS = ["400"];
function extractFontFamily(fontFamilyValue) {
if (!fontFamilyValue) return null;
const firstFont = fontFamilyValue.split(",")[0].trim();
const cleanFont = firstFont.replace(/['"]/g, "");
const systemFonts = [
"ui-sans-serif", "ui-serif", "ui-monospace", "system-ui",
"sans-serif", "serif", "monospace", "cursive", "fantasy"
];
if (systemFonts.includes(cleanFont.toLowerCase())) {
return null;
}
return cleanFont;
}
function buildFontCssUrl(family, weights) {
weights = weights || DEFAULT_FONT_WEIGHTS;
const encodedFamily = encodeURIComponent(family);
const weightsParam = weights.join(";");
return \`https://fonts.googleapis.com/css2?family=\${encodedFamily}:wght@\${weightsParam}&display=swap\`;
}
function loadGoogleFont(family, weights) {
weights = weights || DEFAULT_FONT_WEIGHTS;
const href = buildFontCssUrl(family, weights);
const existing = document.querySelector(\`link[href="\${href}"]\`);
if (existing) return;
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
document.head.appendChild(link);
}
// ----- THEME INITIALIZATION -----
(function() {
const storageKey = "editor-storage";
const root = document.documentElement;
const defaultLightStyles = ${JSON.stringify(defaultLightThemeStyles)};
const defaultDarkStyles = ${JSON.stringify(defaultDarkThemeStyles)};
let themeState = null;
try {
const persistedStateJSON = localStorage.getItem(storageKey);
if (persistedStateJSON) {
themeState = JSON.parse(persistedStateJSON)?.state?.themeState;
}
} catch (e) {
console.warn("Theme initialization: Failed to read/parse localStorage:", e);
}
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const mode = themeState?.currentMode ?? (prefersDark ? "dark" : "light");
const activeStyles =
mode === "dark"
? themeState?.styles?.dark || defaultDarkStyles
: themeState?.styles?.light || defaultLightStyles;
const stylesToApply = Object.keys(defaultLightStyles);
// Apply Theme Styles properties
for (const styleName of stylesToApply) {
const value = activeStyles[styleName];
if (value !== undefined) {
root.style.setProperty(\`--\${styleName}\`, value);
}
}
// Load Google fonts *immediately*
try {
if (activeStyles) {
const currentFonts = {
sans: activeStyles["font-sans"],
serif: activeStyles["font-serif"],
mono: activeStyles["font-mono"],
};
Object.entries(currentFonts).forEach(([_type, fontValue]) => {
const fontFamily = extractFontFamily(fontValue);
if (fontFamily) {
loadGoogleFont(fontFamily, DEFAULT_FONT_WEIGHTS);
}
});
}
} catch (e) {
console.warn("Theme Script initialization: Failed to load Google fonts:", e);
}
})();
`;
return ;
}
================================================
FILE: components/theme-toggle.tsx
================================================
"use client";
import { useTheme } from "@/components/theme-provider";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Moon, Sun } from "lucide-react";
import { TooltipWrapper } from "./tooltip-wrapper";
interface ThemeToggleProps extends React.ComponentProps {}
export function ThemeToggle({ className, ...props }: ThemeToggleProps) {
const { theme, toggleTheme } = useTheme();
const handleThemeToggle = (event: React.MouseEvent) => {
const { clientX: x, clientY: y } = event;
toggleTheme({ x, y });
};
return (
{theme === "light" ? : }
);
}
================================================
FILE: components/theme-view.tsx
================================================
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { toast } from "@/components/ui/use-toast";
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 type { Theme } from "@/types/theme";
import { cn } from "@/lib/utils";
import { Calendar, Edit, Heart, Moon, Share2, Sun } from "lucide-react";
import { notFound, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { CodeButton } from "./editor/action-bar/components/code-button";
import { CodePanelDialog } from "./editor/code-panel-dialog";
import ThemePreviewPanel from "./editor/theme-preview-panel";
import { DialogActionsProvider } from "@/hooks/use-dialog-actions";
interface CommunityData {
communityThemeId: string;
author: { id: string; name: string; image: string | null };
likeCount: number;
isLikedByMe: boolean;
publishedAt: string;
tags: string[];
}
interface ThemeViewProps {
theme: Theme;
communityData?: CommunityData | null;
}
export default function ThemeView({ theme, communityData }: ThemeViewProps) {
const { themeState, setThemeState, saveThemeCheckpoint, restoreThemeCheckpoint } =
useEditorStore();
const router = useRouter();
const currentMode = themeState.currentMode;
const [codePanelOpen, setCodePanelOpen] = useState(false);
useEffect(() => {
saveThemeCheckpoint();
setThemeState({
...themeState,
styles: theme.styles,
});
return () => {
restoreThemeCheckpoint();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [theme, saveThemeCheckpoint, setThemeState, restoreThemeCheckpoint]);
if (!theme) {
notFound();
}
const toggleTheme = () => {
setThemeState({
...themeState,
currentMode: currentMode === "light" ? "dark" : "light",
});
};
const handleOpenInEditor = () => {
setThemeState({
...themeState,
styles: theme.styles,
});
saveThemeCheckpoint();
router.push("/editor/theme");
};
const handleShare = () => {
const url = `https://tweakcn.com/themes/${theme.id}`;
navigator.clipboard.writeText(url);
toast({
title: "Theme URL copied to clipboard!",
});
};
const publishedDate = communityData
? new Date(communityData.publishedAt).toLocaleDateString("en-US", {
day: "numeric",
month: "long",
year: "numeric",
})
: null;
return (
<>
{theme.name}
{communityData && (
<>
{publishedDate}
{communityData.likeCount}{" "}
{communityData.likeCount === 1 ? "like" : "likes"}
{communityData.tags.length > 0 && (
{communityData.tags.map((tag) => (
{tag}
))}
)}
>
)}
{communityData && }
{currentMode === "dark" ? (
) : (
)}
setCodePanelOpen(true)}
/>
Share
Open in Editor
>
);
}
function CommunityAuthorInfo({
communityData,
}: {
communityData: CommunityData;
}) {
const authorInitials = communityData.author.name
?.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
return (
{communityData.author.image && (
)}
{authorInitials}
{communityData.author.name}
);
}
function LikeButton({ communityData }: { communityData: CommunityData }) {
const toggleLike = useToggleLike();
const { checkValidSession } = useSessionGuard();
const [liked, setLiked] = useState(communityData.isLikedByMe);
const [count, setCount] = useState(communityData.likeCount);
usePostLoginAction("LIKE_THEME", (data?: { communityThemeId: string }) => {
if (data?.communityThemeId === communityData.communityThemeId) {
toggleLike.mutate(communityData.communityThemeId, {
onSuccess: (result) => {
setLiked(result.liked);
setCount(result.likeCount);
},
});
}
});
const handleLike = () => {
if (
!checkValidSession("signin", "LIKE_THEME", {
communityThemeId: communityData.communityThemeId,
})
) {
return;
}
// Optimistic update
setLiked((prev) => !prev);
setCount((prev) => (liked ? prev - 1 : prev + 1));
toggleLike.mutate(communityData.communityThemeId, {
onSuccess: (result) => {
setLiked(result.liked);
setCount(result.likeCount);
},
onError: () => {
// Rollback
setLiked((prev) => !prev);
setCount((prev) => (liked ? prev + 1 : prev - 1));
},
});
};
return (
{count > 0 ? count : "Like"}
);
}
================================================
FILE: components/tooltip-wrapper.tsx
================================================
"use client";
import { cn } from "@/lib/utils";
import { ComponentProps } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export function TooltipWrapper({
label,
command,
className,
children,
...props
}: ComponentProps & {
label: string;
command?: React.ReactNode;
}) {
return (
{children}
{label}
{command && (
{command}
)}
);
}
================================================
FILE: components/ui/accordion.tsx
================================================
"use client"
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps) {
return
}
function AccordionItem({
className,
...props
}: React.ComponentProps) {
return (
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps) {
return (
svg]:rotate-180",
className
)}
{...props}
>
{children}
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps) {
return (
{children}
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
================================================
FILE: components/ui/alert-dialog.tsx
================================================
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps) {
return
}
function AlertDialogTrigger({
...props
}: React.ComponentProps) {
return (
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps) {
return (
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps) {
return (
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps & {
size?: "default" | "sm"
}) {
return (
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps) {
return (
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps) {
return (
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps &
Pick, "variant" | "size">) {
return (
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps &
Pick, "variant" | "size">) {
return (
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
================================================
FILE: components/ui/alert.tsx
================================================
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps) {
return (
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
)
}
export { Alert, AlertTitle, AlertDescription }
================================================
FILE: components/ui/aspect-ratio.tsx
================================================
"use client"
import { AspectRatio as AspectRatioPrimitive } from "radix-ui"
function AspectRatio({
...props
}: React.ComponentProps) {
return
}
export { AspectRatio }
================================================
FILE: components/ui/avatar.tsx
================================================
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps & {
size?: "default" | "sm" | "lg"
}) {
return (
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps) {
return (
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps) {
return (
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
}
================================================
FILE: components/ui/badge.tsx
================================================
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps
& { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
)
}
export { Badge, badgeVariants }
================================================
FILE: components/ui/base-ui-tabs.tsx
================================================
import * as React from "react";
import { Tabs as TabsPrimitive } from "@base-ui-components/react/tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ComponentRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsIndicator = React.forwardRef<
React.ComponentRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
TabsIndicator.displayName = TabsPrimitive.Indicator.displayName;
const TabsTrigger = React.forwardRef<
React.ComponentRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
TabsTrigger.displayName = TabsPrimitive.Tab.displayName;
const TabsContent = React.forwardRef<
React.ComponentRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
));
TabsContent.displayName = TabsPrimitive.Panel.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent, TabsIndicator };
================================================
FILE: components/ui/breadcrumb.tsx
================================================
import * as React from "react"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
svg]:size-3.5", className)}
{...props}
>
{children ?? }
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
More
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
================================================
FILE: components/ui/button.tsx
================================================
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
accent: "bg-accent text-accent-foreground shadow-sm hover:bg-accent/80",
},
size: {
default: "h-9 px-4 py-2",
xs: "h-6 gap-1 rounded-md px-2 text-xs [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3",
lg: "h-10 rounded-md px-8",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
)
}
export { Button, buttonVariants }
================================================
FILE: components/ui/calendar.tsx
================================================
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps & {
buttonVariant?: React.ComponentProps["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
)
}
if (orientation === "right") {
return (
)
}
return (
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
{children}
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
================================================
FILE: components/ui/card.tsx
================================================
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (