├── .env ├── .env.development ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .prettierrc.cjs ├── LICENSE ├── README.md ├── next.config.js ├── package.json ├── postcss.config.js ├── public ├── favicon.ico ├── tailwind-3.2.6.min.js ├── tailwindhub-editor.png ├── tailwindhub-empty.png ├── tailwindhub-only-title.png ├── tailwindhub-users.png └── tailwindhub.png ├── src ├── app │ ├── (with-header) │ │ ├── components │ │ │ ├── ComponentsList │ │ │ │ ├── ComponentItem │ │ │ │ │ ├── ComponentItem.tsx │ │ │ │ │ ├── ComponentItemNavBar.tsx │ │ │ │ │ └── ComponentItemSkeleton.tsx │ │ │ │ └── ComponentsList.tsx │ │ │ ├── Header │ │ │ │ ├── Header.module.css │ │ │ │ └── Header.tsx │ │ │ ├── Hero.tsx │ │ │ ├── PageFooter.tsx │ │ │ ├── SearchSection.tsx │ │ │ └── shared │ │ │ │ ├── Button.tsx │ │ │ │ ├── Icons.tsx │ │ │ │ ├── Loader │ │ │ │ ├── Loader.module.css │ │ │ │ └── Loader.tsx │ │ │ │ ├── Login.tsx │ │ │ │ ├── Search.tsx │ │ │ │ └── TailwindScript.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── u │ │ │ ├── [username] │ │ │ ├── [component] │ │ │ │ ├── components │ │ │ │ │ ├── ComponentCode.tsx │ │ │ │ │ ├── ComponentPage.tsx │ │ │ │ │ └── ComponentPreview.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── loading.tsx │ │ │ │ ├── not-found.tsx │ │ │ │ └── page.tsx │ │ │ ├── loading.tsx │ │ │ ├── not-found.tsx │ │ │ └── page.tsx │ │ │ ├── components │ │ │ └── UserCard.tsx │ │ │ └── page.tsx │ ├── api │ │ └── og │ │ │ └── route.tsx │ ├── components │ │ ├── LoginModal.tsx │ │ └── shared │ │ │ ├── CopyToClipboardButton.tsx │ │ │ ├── HomeLink.tsx │ │ │ ├── Modal.tsx │ │ │ ├── SpinnerLoader.tsx │ │ │ └── Tabs.tsx │ ├── editor │ │ ├── components │ │ │ ├── BreakpointsMenu.tsx │ │ │ ├── CodeEditor.tsx │ │ │ ├── CodeEditorForm.tsx │ │ │ ├── EditorActionsMenu.tsx │ │ │ ├── EditorLayoutSelector.tsx │ │ │ ├── EditorSection.tsx │ │ │ ├── ImageCropper.tsx │ │ │ ├── LoginToPublishButton.tsx │ │ │ ├── Preview │ │ │ │ ├── IframePreview.tsx │ │ │ │ ├── Preview.tsx │ │ │ │ ├── ResizeIcons.tsx │ │ │ │ └── Resizer.tsx │ │ │ ├── ResizableSection.tsx │ │ │ └── TagsInput.tsx │ │ ├── constants │ │ │ └── index.ts │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── types │ │ │ └── index.ts │ └── layout.tsx ├── constants │ ├── env.ts │ └── index.ts ├── hooks │ ├── useDebounceEffect.ts │ ├── useIsomorphicLayoutEffect.ts │ └── useSupabase.ts ├── lib │ └── supabase.ts ├── middleware.ts ├── services │ ├── create-component.ts │ ├── get-cropped-cloudinary-image-url.ts │ └── upload-image-to-cloudinary.ts ├── store │ └── global.ts ├── styles │ └── globals.css ├── themes │ ├── Blackboard.json │ └── CustomTheme.json ├── types │ ├── db.ts │ ├── index.ts │ └── user.ts └── utils │ ├── constrain-size.ts │ ├── debounce.js │ ├── encode-decode-url.ts │ ├── get-canvas-preview.ts │ ├── get-image-data-url.ts │ ├── get-pointer-position.ts │ ├── get-relative-time.ts │ ├── register-tailwindcss-worker.ts │ ├── tailwindcss.worker.js │ └── url-formatting.ts ├── tailwind.config.js └── tsconfig.json /.env: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET=shape-snap 2 | NEXT_PUBLIC_CLOUDINARY_UPLOAD_API_URL=https://api.cloudinary.com/v1_1/${NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET}/image/upload 3 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_SUPABASE_URL="https://smyzjtxzfoxbuwwgsygv.supabase.co" 2 | NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNteXpqdHh6Zm94YnV3d2dzeWd2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2NzkzNDU1NTcsImV4cCI6MTk5NDkyMTU1N30.EFgpkqpwawpN6P5LaOqDZoBRwGhLV0fd6Di_EoKscZo" 3 | NEXT_PUBLIC_VERCEL_URL="localhost:3000" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | src/types/db.ts -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "no-unused-vars": "error", 5 | "@next/next/no-img-element": "off", 6 | "react/no-unused-prop-types": "error" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | .vscode 39 | todo.md -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | singleQuote: false, 4 | }; 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TailwindHub 2 | 3 | Free, open-source platform to share Tailwind components. 4 | 5 | ## 🔗 Visit the site 6 | 7 | You can visit TailwindHub at [tailwindhub.dev](https://tailwindhub.dev/) 8 | 9 | ## 🧑‍🤝‍🧑 Contributing 10 | 11 | This project is open-source. Any contribution is welcome! 12 | 13 | ### Getting Started 14 | 15 | 1. Clone the repo and move to the project directory: 16 | 17 | ```bash 18 | git clone https://github.com/GenaroIBC/tailwindhub.git && cd tailwindhub 19 | ``` 20 | 21 | 2. Install dependencies and start the development server: 22 | 23 | ```bash 24 | pnpm install && pnpm dev 25 | ``` 26 | 27 | The env variables for development are in `.env.development`. No additional config is required. 28 | 29 | ## 🚀 Stack 30 | 31 | - NextJS 32 | - Supabase 33 | - TypeScript 34 | - Vercel 35 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | hostname: "avatars.githubusercontent.com", 7 | port: "", 8 | protocol: "https", 9 | }, 10 | ], 11 | }, 12 | }; 13 | 14 | module.exports = nextConfig; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tailwindhub", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "generate-types": "npx supabase gen types typescript --project-id=girlifncacsqcbmvbsou > ./src/types/db.ts" 11 | }, 12 | "dependencies": { 13 | "@monaco-editor/react": "4.5.0", 14 | "@supabase/auth-helpers-nextjs": "0.6.0", 15 | "@supabase/supabase-js": "2.17.0", 16 | "@tabler/icons-react": "2.16.0", 17 | "@vercel/analytics": "1.0.0", 18 | "bright": "0.8.2", 19 | "emmet-monaco-es": "5.2.1", 20 | "encoding": "0.1.13", 21 | "html2canvas": "1.4.1", 22 | "js-base64": "3.7.5", 23 | "monaco-editor": "0.37.1", 24 | "monaco-tailwindcss": "0.6.0", 25 | "next": "latest", 26 | "react": "latest", 27 | "react-dom": "latest", 28 | "react-image-crop": "10.0.9", 29 | "react-select": "5.7.3", 30 | "zustand": "4.4.3" 31 | }, 32 | "devDependencies": { 33 | "@types/node": "20.3.1", 34 | "@types/react": "18.2.13", 35 | "@types/react-dom": "18.2.6", 36 | "autoprefixer": "10.4.14", 37 | "eslint": "8.43.0", 38 | "eslint-config-next": "latest", 39 | "postcss": "8.4.21", 40 | "supabase": "1.50.2", 41 | "tailwindcss": "3.3.1", 42 | "typescript": "5.1.3" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/genaroibc/tailwindhub/e373bf91952c61526581fdf98019dbaa3819054d/public/favicon.ico -------------------------------------------------------------------------------- /public/tailwindhub-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/genaroibc/tailwindhub/e373bf91952c61526581fdf98019dbaa3819054d/public/tailwindhub-editor.png -------------------------------------------------------------------------------- /public/tailwindhub-empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/genaroibc/tailwindhub/e373bf91952c61526581fdf98019dbaa3819054d/public/tailwindhub-empty.png -------------------------------------------------------------------------------- /public/tailwindhub-only-title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/genaroibc/tailwindhub/e373bf91952c61526581fdf98019dbaa3819054d/public/tailwindhub-only-title.png -------------------------------------------------------------------------------- /public/tailwindhub-users.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/genaroibc/tailwindhub/e373bf91952c61526581fdf98019dbaa3819054d/public/tailwindhub-users.png -------------------------------------------------------------------------------- /public/tailwindhub.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/genaroibc/tailwindhub/e373bf91952c61526581fdf98019dbaa3819054d/public/tailwindhub.png -------------------------------------------------------------------------------- /src/app/(with-header)/components/ComponentsList/ComponentItem/ComponentItem.tsx: -------------------------------------------------------------------------------- 1 | import { type ComponentItem as TComponentItem } from "@/types"; 2 | import { ComponentItemNavBar } from "@/app/(with-header)/components/ComponentsList/ComponentItem/ComponentItemNavBar"; 3 | import { sluglify } from "@/utils/url-formatting"; 4 | 5 | type Props = TComponentItem; 6 | 7 | export function ComponentItem({ 8 | author_username, 9 | html_code, 10 | id, 11 | likes, 12 | title, 13 | preview_img, 14 | author_avatar_url, 15 | tags, 16 | }: Props) { 17 | return ( 18 |
22 |
23 | 24 | {title} 29 | 30 | 31 |
32 |
33 |
    34 | {tags.map((tag) => ( 35 |
  • 39 | {tag} 40 |
  • 41 | ))} 42 |
43 | 48 |
49 |
50 |
51 | 52 | 70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/app/(with-header)/components/ComponentsList/ComponentItem/ComponentItemNavBar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { CopyToClipboardButton } from "@/app/components/shared/CopyToClipboardButton"; 4 | import { useSupabase } from "@/hooks/useSupabase"; 5 | import { useGlobalStore } from "@/store/global"; 6 | import { ComponentItem } from "@/types"; 7 | import { IconHeart } from "@tabler/icons-react"; 8 | import { useEffect, useState } from "react"; 9 | 10 | type Props = { 11 | textToCopy: string; 12 | likes: ComponentItem["likes"]; 13 | componentId: string; 14 | }; 15 | 16 | export function ComponentItemNavBar({ 17 | textToCopy, 18 | likes: initialLikes, 19 | componentId, 20 | }: Props) { 21 | const { supabase } = useSupabase(); 22 | 23 | const [likes, setLikes] = useState(initialLikes); 24 | const [userLiked, setUserLiked] = useState(false); 25 | const openModal = useGlobalStore(({ openLoginModal: openModal }) => openModal); 26 | 27 | useEffect(() => { 28 | supabase.auth.getSession().then(({ data: { session } }) => { 29 | const username = session?.user?.user_metadata.user_name; 30 | setUserLiked(likes.some((like) => like.author_username === username)); 31 | }); 32 | }, [supabase, likes]); 33 | 34 | const handleLike = async () => { 35 | const { 36 | data: { session }, 37 | } = await supabase.auth.getSession(); 38 | 39 | const username = session?.user?.user_metadata.user_name; 40 | 41 | if (!username) { 42 | openModal(); 43 | return; 44 | } 45 | 46 | if (userLiked) { 47 | supabase 48 | .from("likes") 49 | .delete() 50 | .eq("author_username", username) 51 | .eq("component_id", componentId) 52 | .then(({ error }) => { 53 | if (!error) { 54 | setLikes((currentLikes) => 55 | currentLikes.filter((like) => like.author_username !== username) 56 | ); 57 | } 58 | }); 59 | } else { 60 | supabase 61 | .from("likes") 62 | .insert({ 63 | author_username: username, 64 | component_id: componentId, 65 | }) 66 | .select("*") 67 | .then(({ data, error }) => { 68 | if (!error) { 69 | setLikes((currentLikes) => { 70 | return [...currentLikes, data[0]]; 71 | }); 72 | } 73 | }); 74 | } 75 | }; 76 | 77 | return ( 78 | 97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /src/app/(with-header)/components/ComponentsList/ComponentItem/ComponentItemSkeleton.tsx: -------------------------------------------------------------------------------- 1 | export function ComponentItemSkeleton() { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/app/(with-header)/components/ComponentsList/ComponentsList.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ComponentItem } from "@/app/(with-header)/components/ComponentsList/ComponentItem/ComponentItem"; 4 | import { Search } from "@/app/(with-header)/components/shared/Search"; 5 | import { type ComponentItem as TComponentItem } from "@/types"; 6 | import { useSupabase } from "@/hooks/useSupabase"; 7 | import { useCallback, useState } from "react"; 8 | import { SearchData } from "@/types"; 9 | import { ComponentItemSkeleton } from "@/app/(with-header)/components/ComponentsList/ComponentItem/ComponentItemSkeleton"; 10 | import Link from "next/link"; 11 | 12 | type Props = { 13 | defaultComponents: TComponentItem[]; 14 | authorUsername?: string; 15 | }; 16 | 17 | export function ComponentsList({ defaultComponents, authorUsername }: Props) { 18 | const [components, setComponents] = 19 | useState(defaultComponents); 20 | const [error, setError] = useState(null); 21 | const [loading, setLoading] = useState(false); 22 | 23 | const { supabase } = useSupabase(); 24 | 25 | const handleSearch = useCallback( 26 | async ({ selectedTag, query }: SearchData) => { 27 | setError(null); 28 | 29 | const normalizedQuery = query?.trim().toLowerCase(); 30 | const normalizedSelectedTag = selectedTag?.trim().toLowerCase(); 31 | 32 | if (!normalizedQuery && normalizedSelectedTag === "all") { 33 | return setComponents(defaultComponents); 34 | } 35 | 36 | if (!normalizedQuery && normalizedSelectedTag !== "all") { 37 | return setComponents( 38 | defaultComponents.filter((component) => 39 | component.tags.includes(normalizedSelectedTag) 40 | ) 41 | ); 42 | } 43 | 44 | setLoading(true); 45 | const genericQuery = supabase 46 | .from("components") 47 | .select("likes (author_username),*"); 48 | 49 | const withUsernameQuery = authorUsername 50 | ? genericQuery.eq("author_username", authorUsername) 51 | : genericQuery; 52 | 53 | const res = await withUsernameQuery 54 | .ilike("title", `%${normalizedQuery}%`) 55 | .contains( 56 | "tags", 57 | `{${normalizedSelectedTag === "all" ? "" : normalizedSelectedTag}}` 58 | ); 59 | 60 | if (res.error) { 61 | setLoading(false); 62 | return setError(res.error.message); 63 | } 64 | 65 | setLoading(false); 66 | setComponents(res.data as TComponentItem[]); 67 | }, 68 | [supabase, setComponents, setError, defaultComponents, authorUsername] 69 | ); 70 | 71 | return ( 72 |
73 | 74 | {error &&

{error}

} 75 | 76 | {!loading && !error && !components.length && ( 77 |
78 |

We didn't find any component that matches your search

79 |

Why don't you create it?

80 | 81 | 85 | Go to editor 86 | 87 |
88 | )} 89 | 90 |
91 | {loading 92 | ? Array(10) 93 | .fill(null) 94 | .map((_, index) => ) 95 | : components.map((component) => ( 96 | 97 | ))} 98 |
99 |
100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /src/app/(with-header)/components/Header/Header.module.css: -------------------------------------------------------------------------------- 1 | .page_header { 2 | z-index: 800; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | 7 | max-width: var(--page-max-width); 8 | min-height: 50px; 9 | margin: 0 auto; 10 | padding: 0.5rem 2rem; 11 | 12 | display: flex; 13 | align-items: center; 14 | justify-content: space-between; 15 | } 16 | 17 | @media screen and (min-width: 768px) { 18 | .page_header .menu_visible { 19 | top: unset; 20 | } 21 | 22 | .page_header .hamburger_menu_btn { 23 | display: none; 24 | } 25 | 26 | .page_header .hamburger_menu { 27 | position: unset; 28 | z-index: unset; 29 | width: auto; 30 | height: auto; 31 | 32 | flex-direction: row; 33 | justify-content: flex-end; 34 | background-color: inherit; 35 | 36 | flex-grow: 1; 37 | margin-right: 3rem; 38 | clip-path: unset; 39 | } 40 | } 41 | 42 | /* *** NAVBAR *** */ 43 | .hamburger_menu { 44 | position: fixed; 45 | left: 0; 46 | top: -15vh; 47 | z-index: 900; 48 | 49 | width: 100vw; 50 | height: 100vh; 51 | 52 | display: flex; 53 | flex-direction: column; 54 | justify-content: center; 55 | align-items: center; 56 | gap: 1.8rem; 57 | 58 | clip-path: circle(0 at 50% 0); 59 | transition: clip-path 0.7s ease, top 0.7s ease; 60 | background-color: var(--primary-color); 61 | } 62 | 63 | .menu_visible { 64 | clip-path: circle(100% at 50% 50%); 65 | top: 0; 66 | } 67 | 68 | .hamburger_menu_btn { 69 | position: fixed; 70 | right: 10vw; 71 | top: 87svh; 72 | z-index: 1000; 73 | 74 | width: 4rem; 75 | height: 4rem; 76 | padding: 0.8rem; 77 | border-radius: 5px; 78 | border: 0; 79 | 80 | display: flex; 81 | flex-direction: column; 82 | justify-content: space-around; 83 | 84 | outline: 0; 85 | background: transparent; 86 | background-color: var(--secondary-color); 87 | } 88 | 89 | .hamburger_menu_btn .hamburger_stick { 90 | width: 100%; 91 | height: 3.2px; 92 | border-radius: 5px; 93 | 94 | transition: transform 0.5s, opacity 0.5s; 95 | 96 | transform-origin: left; 97 | 98 | background-color: var(--dimmed-black); 99 | } 100 | 101 | .menu_visible ~ .hamburger_menu_btn .hamburger_stick:first-child { 102 | transform: rotate(34.5deg) scaleX(1.2); 103 | } 104 | 105 | .menu_visible ~ .hamburger_menu_btn .hamburger_stick:nth-child(2) { 106 | opacity: 0; 107 | } 108 | .menu_visible ~ .hamburger_menu_btn .hamburger_stick:nth-child(3) { 109 | transform: rotate(-34.5deg) scaleX(1.2); 110 | } 111 | 112 | .brand_name { 113 | font-weight: bold; 114 | display: flex; 115 | align-items: center; 116 | justify-content: center; 117 | gap: 0.4rem; 118 | } 119 | -------------------------------------------------------------------------------- /src/app/(with-header)/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { Login } from "@/app/(with-header)/components/shared/Login"; 5 | import styles from "./Header.module.css"; 6 | import { useRef } from "react"; 7 | import { HomeLink } from "@/app/components/shared/HomeLink"; 8 | 9 | export function Header() { 10 | const menuRef = useRef(null); 11 | 12 | const toggleMenu = () => { 13 | menuRef.current?.classList.toggle(styles.menu_visible); 14 | }; 15 | 16 | return ( 17 |
18 | 19 | 20 | 21 | 32 | 33 | 34 | 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/app/(with-header)/components/Hero.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | export function Hero() { 4 | return ( 5 |
6 |
7 |

TailwindHub

8 |

9 | Free, open-source platform to share Tailwind components 10 |

11 | 15 | Go to editor 16 | 17 |
18 | 19 |
20 | Timeline component preview 27 | Product card component preview 34 | Button component preview 41 | SignUp form component preview 48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/app/(with-header)/components/PageFooter.tsx: -------------------------------------------------------------------------------- 1 | import { IconBrandGithub } from "@tabler/icons-react"; 2 | import Link from "next/link"; 3 | 4 | export function PageFooter() { 5 | return ( 6 |
7 |
8 | TailwindHub is an open source project 9 | 13 | Collaborate on GitHub 14 | 15 |
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/app/(with-header)/components/SearchSection.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentsList } from "@/app/(with-header)/components/ComponentsList/ComponentsList"; 2 | import { ComponentItem } from "@/types"; 3 | import { createServerComponentSupabaseClient } from "@supabase/auth-helpers-nextjs"; 4 | import { cookies, headers } from "next/headers"; 5 | import { Database } from "@/types/db"; 6 | 7 | export async function SearchSection() { 8 | const supabase = createServerComponentSupabaseClient({ 9 | cookies, 10 | headers, 11 | }); 12 | 13 | const { data } = await supabase 14 | .from("components") 15 | .select("likes (author_username),*"); 16 | 17 | return ( 18 | <> 19 | {Array.isArray(data) && data && ( 20 | 21 | )} 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/app/(with-header)/components/shared/Button.tsx: -------------------------------------------------------------------------------- 1 | type ButtonVariant = "solid" | "outlined" | "secondary"; 2 | 3 | interface Props 4 | extends React.DetailedHTMLProps< 5 | React.ButtonHTMLAttributes, 6 | HTMLButtonElement 7 | > { 8 | variant?: ButtonVariant; 9 | } 10 | 11 | const commonStyles = 12 | "py-1.5 px-3 border-2 transition-colors border-solid disabled:opacity-90 disabled:cursor-not-allowed flex flex-row gap-2 items-center justify-center"; 13 | 14 | const variantStyles: Record = { 15 | solid: 16 | "bg-tailwind-dark border-tailwind-dark disabled:hover:bg-tailwind-dark text-white hover:bg-tailwind-normal", 17 | outlined: 18 | "bg-transparent border-tailwind-dark disabled:hover:bg-transparent text-tailwind-normal hover:border-tailwind-normal", 19 | secondary: 20 | "bg-secondary-color border-secondary-color disabled:hover:bg-secondary-color text-dimmed-black hover:bg-tertiary-color", 21 | }; 22 | 23 | export function Button({ variant = "solid", className, ...props }: Props) { 24 | return ( 25 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/app/(with-header)/components/shared/Icons.tsx: -------------------------------------------------------------------------------- 1 | export function TailwindHubLogo({ width = "2rem", height = "2rem" }) { 2 | return ( 3 | 12 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/(with-header)/components/shared/Loader/Loader.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | --uib-size: 50px; 3 | --uib-speed: 0.9s; 4 | --uib-color: black; 5 | 6 | position: relative; 7 | height: calc(var(--uib-size) / 2); 8 | width: var(--uib-size); 9 | filter: url("#uib-container-ooze"); 10 | animation: rotate calc(var(--uib-speed) * 2) linear infinite; 11 | } 12 | 13 | .container::before, 14 | .container::after { 15 | content: ""; 16 | position: absolute; 17 | top: 0%; 18 | left: 25%; 19 | width: 50%; 20 | height: 100%; 21 | background: var(--uib-color); 22 | border-radius: 100%; 23 | } 24 | 25 | .container::before { 26 | animation: shift-left var(--uib-speed) ease infinite; 27 | } 28 | 29 | .container::after { 30 | animation: shift-right var(--uib-speed) ease infinite; 31 | } 32 | 33 | .container__loader { 34 | width: 0; 35 | height: 0; 36 | position: absolute; 37 | } 38 | 39 | @keyframes rotate { 40 | 0%, 41 | 49.999%, 42 | 100% { 43 | transform: none; 44 | } 45 | 46 | 50%, 47 | 99.999% { 48 | transform: rotate(90deg); 49 | } 50 | } 51 | 52 | @keyframes shift-left { 53 | 0%, 54 | 100% { 55 | transform: translateX(0%); 56 | } 57 | 50% { 58 | transform: scale(0.65) translateX(-75%); 59 | } 60 | } 61 | 62 | @keyframes shift-right { 63 | 0%, 64 | 100% { 65 | transform: translateX(0%); 66 | } 67 | 50% { 68 | transform: scale(0.65) translateX(75%); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/app/(with-header)/components/shared/Loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./Loader.module.css"; 2 | 3 | export function Loader({ color = "#000" }) { 4 | return ( 5 |
13 | 14 | 15 | 16 | 21 | 27 | 28 | 29 | 30 | 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/app/(with-header)/components/shared/Login.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useSupabase } from "@/hooks/useSupabase"; 4 | import { useEffect, useState } from "react"; 5 | import { Button } from "@/app/(with-header)/components/shared/Button"; 6 | import { AuthSession } from "@supabase/supabase-js"; 7 | import { IconLogout } from "@tabler/icons-react"; 8 | 9 | export function Login() { 10 | const { supabase } = useSupabase(); 11 | const [error, setError] = useState(null); 12 | const [session, setSession] = useState(null); 13 | 14 | useEffect(() => { 15 | const { 16 | data: { subscription }, 17 | } = supabase.auth.onAuthStateChange((_, session) => { 18 | setSession(session); 19 | }); 20 | 21 | return () => subscription.unsubscribe(); 22 | }, [supabase]); 23 | 24 | const signIn = async () => { 25 | const { error } = await supabase.auth.signInWithOAuth({ 26 | provider: "github", 27 | options: { 28 | redirectTo: window.location.href, 29 | }, 30 | }); 31 | 32 | if (error) { 33 | return setError(error.message); 34 | } 35 | 36 | setError(null); 37 | }; 38 | 39 | const signOut = async () => { 40 | const { error } = await supabase.auth.signOut(); 41 | 42 | if (error) { 43 | return setError(error.message); 44 | } 45 | 46 | setError(null); 47 | }; 48 | 49 | return ( 50 | 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /src/app/(with-header)/components/shared/Search.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { COMPONENT_TAGS_LIST, SearchData } from "@/types"; 3 | import { IconSearch } from "@tabler/icons-react"; 4 | 5 | type Props = { 6 | // eslint-disable-next-line no-unused-vars 7 | onSearch: (data: SearchData) => void; 8 | }; 9 | 10 | export function Search({ onSearch }: Props) { 11 | const [selectedTag, setSelectedTag] = useState( 12 | COMPONENT_TAGS_LIST[0] 13 | ); 14 | const [query, setQuery] = useState(""); 15 | 16 | useEffect(() => { 17 | const debounceTimeout = setTimeout(() => { 18 | onSearch({ query, selectedTag }); 19 | }, 500); 20 | 21 | return () => { 22 | clearTimeout(debounceTimeout); 23 | }; 24 | }, [query, selectedTag, onSearch]); 25 | 26 | const handleTagFilterChange = (e: React.FormEvent) => { 27 | const selectedTag = (e.target as HTMLSelectElement).value; 28 | setSelectedTag(selectedTag); 29 | }; 30 | 31 | const handleQueryChange = (e: React.FormEvent) => { 32 | const query = (e.target as HTMLInputElement).value; 33 | setQuery(query); 34 | }; 35 | 36 | return ( 37 |
38 | 49 | 50 |
51 | 58 | 61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/app/(with-header)/components/shared/TailwindScript.tsx: -------------------------------------------------------------------------------- 1 | import Script from "next/script"; 2 | 3 | export function TailwindScript() { 4 | return ( 5 | <> 6 | {/* eslint-disable-next-line @next/next/no-before-interactive-script-outside-document */} 7 | 37 | 38 | 39 | ${code} 40 | 41 | `} 42 | /> 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/app/editor/components/Preview/Preview.tsx: -------------------------------------------------------------------------------- 1 | // CREDITS: 2 | // MOST OF THIS COMPONENT COMES FROM THIS CODESANDBOX 3 | // https://codesandbox.io/s/tailwind-css-playground-kkkzc 4 | 5 | import { useEffect, useState, useRef } from "react"; 6 | import { useIsomorphicLayoutEffect } from "@/hooks/useIsomorphicLayoutEffect"; 7 | import { getPointerPosition } from "@/utils/get-pointer-position"; 8 | import { TailwindScript } from "@/app/(with-header)/components/shared/TailwindScript"; 9 | import { 10 | IconResizeBottom, 11 | IconResizeBottomLeft, 12 | IconResizeBottomRight, 13 | IconResizeLeft, 14 | IconResizeRight, 15 | } from "@/app/editor/components/Preview/ResizeIcons"; 16 | import { Resizer } from "@/app/editor/components/Preview/Resizer"; 17 | import { 18 | Breakpoint, 19 | CodePreviewRef, 20 | ResizingData, 21 | Size, 22 | } from "@/app/editor/types"; 23 | import { constrainSize } from "@/utils/constrain-size"; 24 | import { IframePreview } from "./IframePreview"; 25 | import { BreakpointsMenu } from "@/app/editor/components/BreakpointsMenu"; 26 | import { CODE_PREVIEW_CONTAINER_CLASSNAME } from "@/app/editor/constants"; 27 | 28 | const DEFAULT_RESPONSIVE_SIZE: Breakpoint = { width: 540, height: 720 }; 29 | 30 | type Props = { 31 | code: string; 32 | isResizable: boolean; 33 | codePreviewRef?: CodePreviewRef; 34 | toggleIsResizable?: () => void; 35 | }; 36 | 37 | export const Preview = ({ 38 | code, 39 | isResizable, 40 | codePreviewRef, 41 | toggleIsResizable, 42 | }: Props) => { 43 | const containerRef = useRef(null); 44 | const [size, setSize] = useState({ 45 | width: 0, 46 | height: 0, 47 | }); 48 | const [resizing, setResizing] = useState(null); 49 | 50 | const timeout = useRef(null); 51 | const [responsiveSize, setResponsiveSize] = useState( 52 | DEFAULT_RESPONSIVE_SIZE 53 | ); 54 | const constrainedResponsiveSize = constrainSize({ 55 | desiredHeight: responsiveSize.height, 56 | desiredWidth: responsiveSize.width, 57 | size, 58 | }); 59 | 60 | useEffect(() => { 61 | const observer = new ResizeObserver(() => { 62 | if (timeout.current) window.clearTimeout(timeout.current); 63 | const rect = containerRef.current?.getBoundingClientRect() ?? { 64 | width: 0, 65 | height: 0, 66 | }; 67 | 68 | const width = Math.round(rect.width); 69 | const height = Math.round(rect.height); 70 | setSize({ 71 | width, 72 | height, 73 | }); 74 | timeout.current = window.setTimeout(() => { 75 | setSize((size) => ({ ...size, visible: false })); 76 | }, 1000); 77 | }); 78 | if (containerRef.current) observer.observe(containerRef.current); 79 | return () => { 80 | observer.disconnect(); 81 | }; 82 | }, []); 83 | 84 | useIsomorphicLayoutEffect(() => { 85 | if (size.width > 50 && size.height > 50) { 86 | setResponsiveSize(({ width, height }) => ({ width, height })); 87 | } 88 | 89 | if (resizing) { 90 | const onMouseMove = (e: MouseEvent | TouchEvent) => { 91 | e.preventDefault(); 92 | const { x, y } = getPointerPosition(e) ?? { 93 | x: 0, 94 | y: 0, 95 | }; 96 | if (resizing.direction === "bottom") { 97 | setResponsiveSize(({ width }) => ({ 98 | width, 99 | height: resizing.startHeight + (y - resizing.startY), 100 | })); 101 | } else if (resizing.direction === "left") { 102 | setResponsiveSize(({ height }) => ({ 103 | width: resizing.startWidth - (x - resizing.startX) * 2, 104 | height, 105 | })); 106 | } else if (resizing.direction === "right") { 107 | setResponsiveSize(({ height }) => ({ 108 | width: resizing.startWidth + (x - resizing.startX) * 2, 109 | height, 110 | })); 111 | } else if (resizing.direction === "bottom-left") { 112 | setResponsiveSize(() => ({ 113 | width: resizing.startWidth - (x - resizing.startX) * 2, 114 | height: resizing.startHeight + (y - resizing.startY), 115 | })); 116 | } else if (resizing.direction === "bottom-right") { 117 | setResponsiveSize(() => ({ 118 | width: resizing.startWidth + (x - resizing.startX) * 2, 119 | height: resizing.startHeight + (y - resizing.startY), 120 | })); 121 | } 122 | }; 123 | const onMouseUp = (e: MouseEvent | TouchEvent) => { 124 | e.preventDefault(); 125 | setResizing(null); 126 | }; 127 | 128 | window.addEventListener("mousemove", onMouseMove); 129 | window.addEventListener("mouseup", onMouseUp); 130 | window.addEventListener("touchmove", onMouseMove); 131 | window.addEventListener("touchend", onMouseUp); 132 | return () => { 133 | window.removeEventListener("mousemove", onMouseMove); 134 | window.removeEventListener("mouseup", onMouseUp); 135 | window.removeEventListener("touchmove", onMouseMove); 136 | window.removeEventListener("touchend", onMouseUp); 137 | }; 138 | } 139 | }, [resizing, size]); 140 | 141 | const dragFromLeft = (e: React.MouseEvent | React.TouchEvent) => { 142 | const pos = getPointerPosition(e); 143 | if (pos === null) return; 144 | e.preventDefault(); 145 | setResizing({ 146 | direction: "left", 147 | startWidth: constrainedResponsiveSize.width, 148 | startHeight: 0, 149 | startX: pos.x, 150 | startY: 0, 151 | }); 152 | }; 153 | 154 | const dragFromRight = (e: React.MouseEvent | React.TouchEvent) => { 155 | const pos = getPointerPosition(e); 156 | if (pos === null) return; 157 | e.preventDefault(); 158 | setResizing({ 159 | direction: "right", 160 | startWidth: constrainedResponsiveSize.width, 161 | startHeight: 0, 162 | startX: pos.x, 163 | startY: 0, 164 | }); 165 | }; 166 | 167 | const dragFromBottomLeft = (e: React.MouseEvent | React.TouchEvent) => { 168 | const pos = getPointerPosition(e); 169 | if (pos === null) return; 170 | e.preventDefault(); 171 | setResizing({ 172 | direction: "bottom-left", 173 | startWidth: constrainedResponsiveSize.width, 174 | startHeight: constrainedResponsiveSize.height, 175 | startX: pos.x, 176 | startY: pos.y, 177 | }); 178 | }; 179 | 180 | const dragFromBottom = (e: React.MouseEvent | React.TouchEvent) => { 181 | const pos = getPointerPosition(e); 182 | if (pos === null) return; 183 | e.preventDefault(); 184 | setResizing({ 185 | direction: "bottom", 186 | startWidth: 0, 187 | startHeight: constrainedResponsiveSize.height, 188 | startY: pos.y, 189 | startX: 0, 190 | }); 191 | }; 192 | 193 | const dragFromBottomRight = (e: React.MouseEvent | React.TouchEvent) => { 194 | const pos = getPointerPosition(e); 195 | if (pos === null) return; 196 | e.preventDefault(); 197 | setResizing({ 198 | direction: "bottom-right", 199 | startWidth: constrainedResponsiveSize.width, 200 | startHeight: constrainedResponsiveSize.height, 201 | startX: pos.x, 202 | startY: pos.y, 203 | }); 204 | }; 205 | 206 | return ( 207 |
211 | 212 | 213 | {isResizable && ( 214 | { 217 | toggleIsResizable?.(); 218 | setResponsiveSize(breakpoint); 219 | }} 220 | /> 221 | )} 222 | 223 |
234 | {isResizable && ( 235 | 240 | 241 | 242 | )} 243 | 244 |
265 | {isResizable ? ( 266 | 271 | ) : ( 272 |
277 | )} 278 |
279 | 280 | {isResizable && ( 281 | <> 282 | 287 | 288 | 289 | 294 | 295 | 296 | 301 | 302 | 303 | 308 | 309 | 310 | 311 | )} 312 |
313 |
314 | ); 315 | }; 316 | -------------------------------------------------------------------------------- /src/app/editor/components/Preview/ResizeIcons.tsx: -------------------------------------------------------------------------------- 1 | export const IconResizeBottomRight = () => ( 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | 16 | export const IconResizeBottom = () => ( 17 | 24 | 25 | 26 | ); 27 | 28 | export const IconResizeBottomLeft = () => ( 29 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | 44 | export const IconResizeRight = () => ( 45 | 52 | 53 | 54 | ); 55 | 56 | export const IconResizeLeft = () => ( 57 | 64 | 65 | 66 | ); 67 | -------------------------------------------------------------------------------- /src/app/editor/components/Preview/Resizer.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | className?: string; 3 | children?: React.ReactNode; 4 | resizerRef?: React.LegacyRef; 5 | onMouseDown: React.MouseEventHandler; 6 | onTouchStart: React.TouchEventHandler; 7 | }; 8 | 9 | export function Resizer({ 10 | children, 11 | className, 12 | onMouseDown, 13 | onTouchStart, 14 | resizerRef, 15 | }: Props) { 16 | return ( 17 |
23 | {children} 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/app/editor/components/ResizableSection.tsx: -------------------------------------------------------------------------------- 1 | // this component is inspired in [THIS CODEPEN](https://codepen.io/1isten/pen/mdVNqvK) 2 | // I refactored it to use React and Typescript 3 | 4 | "use client"; 5 | 6 | import { useRef, Children, useEffect, useState } from "react"; 7 | import { 8 | IconResizeBottom, 9 | IconResizeLeft, 10 | } from "@/app/editor/components/Preview/ResizeIcons"; 11 | import { Resizer } from "./Preview/Resizer"; 12 | 13 | type Props = { 14 | children: React.ReactElement[]; 15 | desktopLayout?: "rows" | "columns"; 16 | }; 17 | 18 | const RESIZABLE_SECTION_MIN_HEIGHT = 300; 19 | const RESIZABLE_SECTION_MIN_WIDTH = 400; 20 | const MOBILE_MEDIA_QUERY = "(max-width: 1024px)"; 21 | // const MIN_RESIZABLE_SECTION_WIDTH = 300; 22 | // const MIN_RESIZABLE_SECTION_HEIGHT = 300; 23 | 24 | export function ResizableSection({ 25 | children, 26 | desktopLayout = "columns", 27 | }: Props) { 28 | const [layout, setLayout] = useState(desktopLayout); 29 | const resizerX = useRef(null); 30 | const resizerY = useRef(null); 31 | const leftSideRef = useRef(null); 32 | const rightSideRef = useRef(null); 33 | const aboveSideRef = useRef(null); 34 | const belowSideRef = useRef(null); 35 | const clientXRef = useRef(0); 36 | const clientYRef = useRef(0); 37 | 38 | useEffect(() => { 39 | const updateLayoutOnDesktop = () => { 40 | const isMobileScreen = window.matchMedia(MOBILE_MEDIA_QUERY).matches; 41 | 42 | setLayout(isMobileScreen ? "rows" : desktopLayout); 43 | }; 44 | 45 | updateLayoutOnDesktop(); 46 | 47 | window.addEventListener("resize", updateLayoutOnDesktop); 48 | 49 | return () => window.removeEventListener("resize", updateLayoutOnDesktop); 50 | }, [setLayout, desktopLayout]); 51 | 52 | // for mobile 53 | function handleTouchStart(e: React.TouchEvent) { 54 | e.preventDefault(); 55 | resizerX.current?.addEventListener("touchmove", handleTouchMove); 56 | resizerX.current?.addEventListener("touchend", handleTouchEnd); 57 | } 58 | function handleTouchMove(e: TouchEvent) { 59 | e.preventDefault(); 60 | 61 | if (resizerX.current && leftSideRef.current && rightSideRef.current) { 62 | const clientX = e.touches[0].clientX; 63 | const deltaX = clientX - (clientXRef.current || clientX); 64 | clientXRef.current = clientX; 65 | 66 | if (deltaX < 0) { 67 | const w = Math.round( 68 | parseInt(getComputedStyle(leftSideRef.current).width) + deltaX 69 | ); 70 | 71 | // if (w <= MIN_RESIZABLE_SECTION_WIDTH) return; 72 | leftSideRef.current.style.flex = `0 ${ 73 | w < RESIZABLE_SECTION_MIN_WIDTH ? RESIZABLE_SECTION_MIN_WIDTH : w 74 | }px`; 75 | rightSideRef.current.style.flex = "1 0"; 76 | } 77 | // RIGHT 78 | if (deltaX > 0) { 79 | const w = Math.round( 80 | parseInt(getComputedStyle(rightSideRef.current).width) - deltaX 81 | ); 82 | 83 | // if (w <= MIN_RESIZABLE_SECTION_WIDTH) return; 84 | rightSideRef.current.style.flex = `0 ${ 85 | w < RESIZABLE_SECTION_MIN_WIDTH ? RESIZABLE_SECTION_MIN_WIDTH : w 86 | }px`; 87 | leftSideRef.current.style.flex = "1 0"; 88 | } 89 | } 90 | if (resizerY.current && aboveSideRef.current && belowSideRef.current) { 91 | const clientY = e.touches[0].clientY; 92 | const deltaY = clientY - (clientYRef.current || clientY); 93 | clientYRef.current = clientY; 94 | 95 | // UP 96 | if (deltaY < 0) { 97 | const h = Math.round( 98 | parseInt(getComputedStyle(aboveSideRef.current).height) + deltaY 99 | ); 100 | 101 | // if (h <= MIN_RESIZABLE_SECTION_HEIGHT) return; 102 | 103 | aboveSideRef.current.style.flex = `0 ${ 104 | h < RESIZABLE_SECTION_MIN_HEIGHT ? RESIZABLE_SECTION_MIN_HEIGHT : h 105 | }px`; 106 | belowSideRef.current.style.flex = "1 0"; 107 | } 108 | // DOWN 109 | if (deltaY > 0) { 110 | const h = Math.round( 111 | parseInt(getComputedStyle(belowSideRef.current).height) - deltaY 112 | ); 113 | 114 | // if (h <= MIN_RESIZABLE_SECTION_HEIGHT) return; 115 | 116 | belowSideRef.current.style.flex = `0 ${ 117 | h < RESIZABLE_SECTION_MIN_HEIGHT ? RESIZABLE_SECTION_MIN_HEIGHT : h 118 | }px`; 119 | aboveSideRef.current.style.flex = "1 0"; 120 | } 121 | } 122 | } 123 | function handleTouchEnd(e: TouchEvent) { 124 | e.preventDefault(); 125 | resizerX.current?.removeEventListener("touchmove", handleTouchMove); 126 | resizerX.current?.removeEventListener("touchend", handleTouchEnd); 127 | clientXRef.current = null; 128 | clientYRef.current = null; 129 | } 130 | 131 | // for desktop 132 | function handleMouseDown(e: React.MouseEvent) { 133 | e.preventDefault(); 134 | document.addEventListener("mousemove", handleMouseMove); 135 | document.addEventListener("mouseup", handleMouseUp); 136 | } 137 | function handleMouseMove(e: MouseEvent) { 138 | e.preventDefault(); 139 | 140 | if (resizerX.current && leftSideRef.current && rightSideRef.current) { 141 | const clientX = e.clientX; 142 | const deltaX = clientX - (clientXRef.current || clientX); 143 | clientXRef.current = clientX; 144 | 145 | if (deltaX < 0) { 146 | const w = Math.round( 147 | parseInt(getComputedStyle(leftSideRef.current).width) + deltaX 148 | ); 149 | 150 | // if (w <= MIN_RESIZABLE_SECTION_WIDTH) return; 151 | 152 | leftSideRef.current.style.flex = `0 ${ 153 | w < RESIZABLE_SECTION_MIN_WIDTH ? RESIZABLE_SECTION_MIN_WIDTH : w 154 | }px`; 155 | rightSideRef.current.style.flex = "1 0"; 156 | } 157 | 158 | // RIGHT 159 | if (deltaX > 0) { 160 | const w = Math.round( 161 | parseInt(getComputedStyle(rightSideRef.current).width) - deltaX 162 | ); 163 | 164 | // if (w <= MIN_RESIZABLE_SECTION_WIDTH) return; 165 | 166 | rightSideRef.current.style.flex = `0 ${ 167 | w < RESIZABLE_SECTION_MIN_WIDTH ? RESIZABLE_SECTION_MIN_WIDTH : w 168 | }px`; 169 | leftSideRef.current.style.flex = "1 0"; 170 | } 171 | } 172 | if (resizerY.current && aboveSideRef.current && belowSideRef.current) { 173 | const clientY = e.clientY; 174 | const deltaY = clientY - (clientYRef.current || clientY); 175 | clientYRef.current = clientY; 176 | 177 | // UP 178 | if (deltaY < 0) { 179 | const h = Math.round( 180 | parseInt(getComputedStyle(aboveSideRef.current).height) + deltaY 181 | ); 182 | 183 | // if (h <= MIN_RESIZABLE_SECTION_HEIGHT) return; 184 | 185 | aboveSideRef.current.style.flex = `0 ${ 186 | h < RESIZABLE_SECTION_MIN_HEIGHT ? RESIZABLE_SECTION_MIN_HEIGHT : h 187 | }px`; 188 | belowSideRef.current.style.flex = "1 0"; 189 | } 190 | // DOWN 191 | if (deltaY > 0) { 192 | const h = Math.round( 193 | parseInt(getComputedStyle(belowSideRef.current).height) - deltaY 194 | ); 195 | 196 | // if (h <= MIN_RESIZABLE_SECTION_HEIGHT) return; 197 | 198 | belowSideRef.current.style.flex = `0 ${ 199 | h < RESIZABLE_SECTION_MIN_HEIGHT ? RESIZABLE_SECTION_MIN_HEIGHT : h 200 | }px`; 201 | aboveSideRef.current.style.flex = "1 0"; 202 | } 203 | } 204 | } 205 | function handleMouseUp(e: MouseEvent) { 206 | e.preventDefault(); 207 | document.removeEventListener("mousemove", handleMouseMove); 208 | document.removeEventListener("mouseup", handleMouseUp); 209 | clientXRef.current = null; 210 | clientYRef.current = null; 211 | } 212 | 213 | return ( 214 |
215 | {layout === "rows" ? ( 216 |
217 |
218 | {Children.map( 219 | children, 220 | (child) => 221 | (typeof child.type === "string" 222 | ? child.type 223 | : child.type.name) === ResizableLeftSide.name && child 224 | )} 225 |
226 | 227 | 233 | 234 | 235 | 236 |
237 | {Children.map( 238 | children, 239 | (child) => 240 | (typeof child.type === "string" 241 | ? child.type 242 | : child.type.name) === ResizableRightSide.name && child 243 | )} 244 |
245 |
246 | ) : ( 247 |
248 |
249 | {Children.map( 250 | children, 251 | (child) => 252 | (typeof child.type === "string" 253 | ? child.type 254 | : child.type.name) === ResizableLeftSide.name && child 255 | )} 256 |
257 | 258 | 264 | 265 | 266 | 267 |
268 | {Children.map( 269 | children, 270 | (child) => 271 | (typeof child.type === "string" 272 | ? child.type 273 | : child.type.name) === ResizableRightSide.name && child 274 | )} 275 |
276 |
277 | )} 278 |
279 | ); 280 | } 281 | 282 | const ResizableLeftSide: React.FC<{ children: React.ReactNode }> = ({ 283 | children, 284 | }) => <>{children}; 285 | 286 | const ResizableRightSide: React.FC<{ children: React.ReactNode }> = ({ 287 | children, 288 | }) => <>{children}; 289 | 290 | ResizableSection.LeftSide = ResizableLeftSide; 291 | ResizableSection.RightSide = ResizableRightSide; 292 | -------------------------------------------------------------------------------- /src/app/editor/components/TagsInput.tsx: -------------------------------------------------------------------------------- 1 | import { COMPONENT_TAGS_LIST } from "@/types"; 2 | import { useId } from "react"; 3 | import Select from "react-select"; 4 | 5 | type Props = { 6 | // eslint-disable-next-line no-unused-vars 7 | onNewTags: (data: { tags: string[] }) => void; 8 | }; 9 | 10 | export function TagsInput({ onNewTags: onNewTag }: Props) { 11 | const selectID = useId(); 12 | 13 | return ( 14 |