├── .eslintrc.json ├── src ├── features │ ├── pages │ │ ├── index.tsx │ │ ├── store │ │ │ └── page.ts │ │ └── ui │ │ │ ├── pages.tsx │ │ │ ├── edit.tsx │ │ │ ├── card.tsx │ │ │ ├── add.tsx │ │ │ └── dialog.tsx │ ├── profile │ │ ├── index.ts │ │ └── ui │ │ │ └── profile.tsx │ ├── render │ │ ├── index.ts │ │ └── ui │ │ │ ├── render.tsx │ │ │ └── balance.tsx │ ├── upload │ │ ├── index.ts │ │ └── ui │ │ │ ├── upload.ts │ │ │ └── button.tsx │ ├── theme │ │ ├── index.ts │ │ └── ui │ │ │ ├── set.tsx │ │ │ ├── provider.tsx │ │ │ └── toggle.tsx │ ├── me │ │ └── index.tsx │ ├── auth │ │ ├── index.ts │ │ ├── store │ │ │ └── useUserStore.ts │ │ ├── ui │ │ │ └── loader.tsx │ │ └── hooks │ │ │ ├── useUser.ts │ │ │ ├── useLogin.ts │ │ │ └── useRegister.ts │ ├── brick-edit │ │ ├── index.ts │ │ ├── store │ │ │ ├── preview.ts │ │ │ ├── should-save.ts │ │ │ └── current-editing.ts │ │ ├── ui │ │ │ ├── save.tsx │ │ │ ├── preview.tsx │ │ │ ├── dialog.tsx │ │ │ ├── balance.tsx │ │ │ ├── wrapper.tsx │ │ │ └── add.tsx │ │ ├── hooks │ │ │ ├── useSave.tsx │ │ │ └── useCurrent.tsx │ │ └── editors │ │ │ ├── air.tsx │ │ │ ├── line.tsx │ │ │ ├── picture.tsx │ │ │ ├── click.tsx │ │ │ ├── title.tsx │ │ │ └── text.tsx │ ├── bricks │ │ ├── ui │ │ │ ├── air.tsx │ │ │ ├── line.tsx │ │ │ ├── picture.tsx │ │ │ ├── title.tsx │ │ │ ├── text.tsx │ │ │ └── click.tsx │ │ ├── index.ts │ │ └── svg │ │ │ └── icons.tsx │ ├── react-query │ │ └── index.tsx │ └── check │ │ └── index.tsx ├── api │ ├── index.ts │ ├── auth.ts │ └── page.ts ├── widgets │ ├── edit │ │ ├── index.ts │ │ └── ui │ │ │ ├── header.tsx │ │ │ ├── edit.tsx │ │ │ └── current-bricks.tsx │ ├── auth │ │ ├── index.ts │ │ └── ui │ │ │ ├── login.tsx │ │ │ └── register.tsx │ ├── dashboard │ │ ├── index.ts │ │ └── ui │ │ │ ├── header.tsx │ │ │ └── dashboard.tsx │ └── landing │ │ ├── index.ts │ │ └── ui │ │ ├── header.tsx │ │ ├── what.tsx │ │ └── hero.tsx ├── app │ ├── login │ │ └── page.tsx │ ├── register │ │ └── page.tsx │ ├── page.tsx │ ├── api │ │ └── uploadthing │ │ │ ├── route.ts │ │ │ └── core.ts │ ├── loading.tsx │ ├── edit │ │ └── [id] │ │ │ ├── page.tsx │ │ │ └── layout.tsx │ ├── google │ │ └── calback │ │ │ └── page.tsx │ ├── dashboard │ │ ├── layout.tsx │ │ └── page.tsx │ ├── page │ │ └── [id] │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ ├── error.tsx │ ├── layout.tsx │ └── globals.css ├── shared │ ├── lib │ │ ├── utils.ts │ │ └── token.tsx │ └── ui │ │ ├── textarea.tsx │ │ ├── input.tsx │ │ ├── button.tsx │ │ ├── tabs.tsx │ │ ├── dialog.tsx │ │ ├── select.tsx │ │ ├── carousel.tsx │ │ └── dropdown-menu.tsx ├── entities │ └── pages.ts └── middleware.ts ├── public └── hero.png ├── postcss.config.js ├── next-env.d.ts ├── next.config.mjs ├── components.json ├── .gitignore ├── tsconfig.json ├── README.md ├── package.json └── tailwind.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/features/pages/index.tsx: -------------------------------------------------------------------------------- 1 | export { Pages } from "./ui/pages"; 2 | -------------------------------------------------------------------------------- /src/features/profile/index.ts: -------------------------------------------------------------------------------- 1 | export { Profile } from "./ui/profile"; 2 | -------------------------------------------------------------------------------- /src/features/render/index.ts: -------------------------------------------------------------------------------- 1 | export { Render } from "./ui/render"; 2 | -------------------------------------------------------------------------------- /src/features/upload/index.ts: -------------------------------------------------------------------------------- 1 | export { Upload } from "./ui/button"; 2 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | export const baseUrl = process.env.NEXT_PUBLIC_API_URL; 2 | -------------------------------------------------------------------------------- /public/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethanhamilthon/tap-builder/master/public/hero.png -------------------------------------------------------------------------------- /src/widgets/edit/index.ts: -------------------------------------------------------------------------------- 1 | export { Header } from "./ui/header"; 2 | export { Edit } from "./ui/edit"; 3 | -------------------------------------------------------------------------------- /src/widgets/auth/index.ts: -------------------------------------------------------------------------------- 1 | export { Login } from "./ui/login"; 2 | export { Register } from "./ui/register"; 3 | -------------------------------------------------------------------------------- /src/widgets/dashboard/index.ts: -------------------------------------------------------------------------------- 1 | export { DashBoard } from "./ui/dashboard"; 2 | export { Header } from "./ui/header"; 3 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { Login } from "@/widgets/auth"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/widgets/landing/index.ts: -------------------------------------------------------------------------------- 1 | export { Hero } from "./ui/hero"; 2 | export { Header } from "./ui/header"; 3 | export { What } from "./ui/what"; 4 | -------------------------------------------------------------------------------- /src/app/register/page.tsx: -------------------------------------------------------------------------------- 1 | import { Register } from "@/widgets/auth"; 2 | 3 | export default async function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/features/theme/index.ts: -------------------------------------------------------------------------------- 1 | export { ModeToggle } from "./ui/toggle"; 2 | export { ThemeProvider } from "./ui/provider"; 3 | export { SetTheme } from "./ui/set"; 4 | -------------------------------------------------------------------------------- /src/shared/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/features/me/index.tsx: -------------------------------------------------------------------------------- 1 | import { cookies } from "next/headers"; 2 | import { redirect } from "next/navigation"; 3 | 4 | export function Me({ children }: { children: React.ReactNode }) { 5 | return children; 6 | } 7 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /src/features/auth/index.ts: -------------------------------------------------------------------------------- 1 | export { Loader } from "./ui/loader"; 2 | 3 | export { useUser } from "./hooks/useUser"; 4 | 5 | export { useLogin } from "./hooks/useLogin"; 6 | 7 | export { useRegister } from "./hooks/useRegister"; 8 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Header, Hero, What } from "@/widgets/landing"; 2 | 3 | export default function page() { 4 | return ( 5 | <> 6 |
7 | 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/api/uploadthing/route.ts: -------------------------------------------------------------------------------- 1 | import { createRouteHandler } from "uploadthing/next"; 2 | 3 | import { ourFileRouter } from "./core"; 4 | 5 | // Export routes for Next App Router 6 | export const { GET, POST } = createRouteHandler({ 7 | router: ourFileRouter, 8 | }); 9 | -------------------------------------------------------------------------------- /src/app/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "@/features/auth"; 2 | 3 | export default function Loading() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/features/brick-edit/index.ts: -------------------------------------------------------------------------------- 1 | export { AddNewBrick } from "./ui/add"; 2 | export { Balance } from "./ui/balance"; 3 | export { Save } from "./ui/save"; 4 | export { Preview } from "./ui/preview"; 5 | export { useCurrent } from "./hooks/useCurrent"; 6 | export { usePreview } from "./store/preview"; 7 | -------------------------------------------------------------------------------- /src/features/bricks/ui/air.tsx: -------------------------------------------------------------------------------- 1 | import { Brick } from "@/entities/pages"; 2 | 3 | export function Air(props: Brick) { 4 | let height = Number(props.payload); 5 | if (isNaN(height)) { 6 | height = 20; 7 | } 8 | 9 | return
; 10 | } 11 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: "https", 7 | hostname: "utfs.io", 8 | port: "", 9 | pathname: "/**", 10 | }, 11 | ], 12 | }, 13 | }; 14 | 15 | export default nextConfig; 16 | -------------------------------------------------------------------------------- /src/app/edit/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Edit, Header } from "@/widgets/edit"; 2 | 3 | export const revalidate = 0; 4 | 5 | export default async function Page({ params }: { params: { id: string } }) { 6 | const id = params.id; 7 | 8 | return ( 9 | <> 10 |
11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/features/bricks/index.ts: -------------------------------------------------------------------------------- 1 | export { Air } from "./ui/air"; 2 | export { Title, ParseTitleParams } from "./ui/title"; 3 | export type { TitleParams } from "./ui/title"; 4 | export { Text, ParseTextParams } from "./ui/text"; 5 | export { Line } from "./ui/line"; 6 | export { Picture } from "./ui/picture"; 7 | export { Click, ParseClickParams } from "./ui/click"; 8 | -------------------------------------------------------------------------------- /src/features/upload/ui/upload.ts: -------------------------------------------------------------------------------- 1 | import { 2 | generateUploadButton, 3 | generateUploadDropzone, 4 | } from "@uploadthing/react"; 5 | 6 | import type { OurFileRouter } from "@/app/api/uploadthing/core"; 7 | 8 | export const UploadButton = generateUploadButton(); 9 | export const UploadDropzone = generateUploadDropzone(); 10 | -------------------------------------------------------------------------------- /src/features/theme/ui/set.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTheme } from "next-themes"; 4 | import { useLayoutEffect } from "react"; 5 | 6 | export function SetTheme(props: { theme: string }): JSX.Element { 7 | const { setTheme } = useTheme(); 8 | 9 | useLayoutEffect(() => { 10 | setTheme(props.theme); 11 | }, [props.theme]); 12 | 13 | return <>; 14 | } 15 | -------------------------------------------------------------------------------- /src/app/google/calback/page.tsx: -------------------------------------------------------------------------------- 1 | import { SetTokenComp } from "@/shared/lib/token"; 2 | import { redirect } from "next/navigation"; 3 | 4 | export default function Page({ 5 | searchParams, 6 | }: { 7 | searchParams: { token: string }; 8 | }) { 9 | if (!searchParams.token) { 10 | redirect("/"); 11 | } 12 | 13 | return ; 14 | } 15 | -------------------------------------------------------------------------------- /src/features/react-query/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { QueryClient, QueryClientProvider } from "react-query"; 4 | 5 | export function ReactQueryProvider({ 6 | children, 7 | }: { 8 | children: React.ReactNode; 9 | }) { 10 | const queryClient = new QueryClient(); 11 | return ( 12 | {children} 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/features/theme/ui/provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { type ThemeProviderProps } from "next-themes/dist/types"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /src/features/brick-edit/store/preview.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { devtools } from "zustand/middleware"; 3 | 4 | interface Preview { 5 | isPreview: boolean; 6 | togglePreview: () => void; 7 | } 8 | 9 | export const usePreview = create()( 10 | devtools((set) => ({ 11 | isPreview: false, 12 | togglePreview: () => set((state) => ({ isPreview: !state.isPreview })), 13 | })) 14 | ); 15 | -------------------------------------------------------------------------------- /src/features/brick-edit/store/should-save.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { devtools } from "zustand/middleware"; 3 | 4 | interface Save { 5 | shouldSave: boolean; 6 | setShouldSave: (bool: boolean) => void; 7 | } 8 | 9 | export const useShouldSave = create()( 10 | devtools((set) => ({ 11 | shouldSave: false, 12 | setShouldSave: (bool) => set(() => ({ shouldSave: bool })), 13 | })) 14 | ); 15 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/shared", 15 | "utils": "@/shared/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider } from "@/features/theme"; 2 | 3 | export default function RootLayout({ 4 | children, 5 | }: Readonly<{ 6 | children: React.ReactNode; 7 | }>) { 8 | return ( 9 | 15 | {children} 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/app/edit/[id]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider } from "@/features/theme"; 2 | 3 | export default function RootLayout({ 4 | children, 5 | }: Readonly<{ 6 | children: React.ReactNode; 7 | }>) { 8 | return ( 9 | 15 | {children} 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/app/page/[id]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider } from "@/features/theme"; 2 | 3 | export default function RootLayout({ 4 | children, 5 | }: Readonly<{ 6 | children: React.ReactNode; 7 | }>) { 8 | return ( 9 | 15 | {children} 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/widgets/dashboard/ui/header.tsx: -------------------------------------------------------------------------------- 1 | import { Profile } from "@/features/profile"; 2 | 3 | export function Header() { 4 | return ( 5 |
6 |
7 |

Dashboard

8 | 9 |
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/features/auth/store/useUserStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | export type User = { 4 | email: string; 5 | name: string; 6 | }; 7 | 8 | export type UserStore = { 9 | user: User; 10 | setUser: (user: User) => void; 11 | }; 12 | 13 | export const useUserStore = create((set) => ({ 14 | user: { 15 | email: "", 16 | name: "", 17 | }, 18 | setUser: (newUser) => set(() => ({ user: newUser })), 19 | })); 20 | -------------------------------------------------------------------------------- /src/features/auth/ui/loader.tsx: -------------------------------------------------------------------------------- 1 | export function Loader() { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { getAll } from "@/api/page"; 2 | import { DashBoard, Header } from "@/widgets/dashboard"; 3 | import { headers } from "next/headers"; 4 | 5 | export default async function Pages() { 6 | const token = headers().get("Authorization"); 7 | 8 | if (!token) throw new Error(); 9 | const pages = await getAll(token); 10 | 11 | return ( 12 | <> 13 |
14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/features/bricks/ui/line.tsx: -------------------------------------------------------------------------------- 1 | import { Brick } from "@/entities/pages"; 2 | 3 | export function Line(props: Brick) { 4 | const isDashed = props.payload === "dashed"; 5 | 6 | if (isDashed) { 7 | return ( 8 |
9 | ); 10 | } 11 | return ( 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/entities/pages.ts: -------------------------------------------------------------------------------- 1 | export type PageMeta = { 2 | id: string; 3 | title: string; 4 | address: string; 5 | theme: string; 6 | favicon: string; 7 | }; 8 | 9 | export type Page = PageMeta & { 10 | bricks: Brick[]; 11 | }; 12 | 13 | export type PageUser = Page & { 14 | user: string; 15 | }; 16 | 17 | export type Brick = { 18 | id: string; 19 | type: string; 20 | payload: string; 21 | params: string; 22 | children: string[]; 23 | }; 24 | 25 | export type PageMetrics = { 26 | views: number; 27 | }; 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | 38 | -------------------------------------------------------------------------------- /src/features/brick-edit/ui/save.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/shared/lib/utils"; 2 | import { useSave } from "../hooks/useSave"; 3 | 4 | export function Save() { 5 | const { shouldSave, isSaving, handleSave } = useSave(); 6 | 7 | return ( 8 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/features/brick-edit/ui/preview.tsx: -------------------------------------------------------------------------------- 1 | import { Eye } from "lucide-react"; 2 | import { usePreview } from "../store/preview"; 3 | import { cn } from "@/shared/lib/utils"; 4 | 5 | export function Preview() { 6 | const { isPreview, togglePreview } = usePreview(); 7 | return ( 8 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/features/render/ui/render.tsx: -------------------------------------------------------------------------------- 1 | import { Brick } from "@/entities/pages"; 2 | import { cn } from "@/shared/lib/utils"; 3 | import { Balance } from "./balance"; 4 | 5 | export const Render = (props: { bricks: Brick[]; className?: string }) => { 6 | return ( 7 |
13 | {props.bricks.map((brick) => ( 14 | 15 | ))} 16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/features/render/ui/balance.tsx: -------------------------------------------------------------------------------- 1 | import { Brick } from "@/entities/pages"; 2 | import { Air, Text, Title, Line, Picture, Click } from "@/features/bricks"; 3 | 4 | export const Balance = (props: Brick) => { 5 | switch (props.type) { 6 | case "title": 7 | return ; 8 | case "text": 9 | return <Text {...props} />; 10 | case "air": 11 | return <Air {...props} />; 12 | case "line": 13 | return <Line {...props} />; 14 | case "picture": 15 | return <Picture {...props} />; 16 | case "click": 17 | return <Click {...props} />; 18 | default: 19 | throw new Error("Invalid type"); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /src/features/pages/store/page.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { Page } from "@/entities/pages"; 3 | 4 | type PageStore = { 5 | pages: Page[]; 6 | setPage: (pages: Page[]) => void; 7 | addPage: (page: Page) => void; 8 | updatePage: (page: Page) => void; 9 | }; 10 | 11 | export const usePageStore = create<PageStore>((set) => ({ 12 | pages: [], 13 | setPage: (pages) => set({ pages }), 14 | addPage: (page) => set((state) => ({ pages: [...state.pages, page] })), 15 | updatePage: (page) => 16 | set((state) => { 17 | const newPages = state.pages.map((p) => { 18 | if (p.id === page.id) return page; 19 | return p; 20 | }); 21 | return { pages: newPages }; 22 | }), 23 | })); 24 | -------------------------------------------------------------------------------- /src/features/pages/ui/pages.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from "react-query"; 2 | import { getAll } from "@/api/page"; 3 | import { usePageStore } from "../store/page"; 4 | import { useEffect } from "react"; 5 | import { Add } from "./add"; 6 | import { GetToken } from "@/shared/lib/token"; 7 | import { Card } from "./card"; 8 | import { Page } from "@/entities/pages"; 9 | 10 | export function Pages(props: { pages: Page[] }) { 11 | const { pages, setPage } = usePageStore(); 12 | useEffect(() => { 13 | if (props.pages && pages.length === 0) { 14 | setPage(props.pages); 15 | } 16 | }, [props.pages]); 17 | return ( 18 | <div className="w-full flex flex-wrap gap-4"> 19 | {pages.map((page) => { 20 | return <Card key={page.id} {...page} />; 21 | })} 22 | <Add /> 23 | </div> 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | 5 | export default function Error() { 6 | const router = useRouter(); 7 | return ( 8 | <div className="w-full h-screen flex items-center justify-center"> 9 | <div className="flex flex-col items-center gap-6"> 10 | <h1 className="text-3xl font-bold text-center"> 11 | Something 12 | <br /> 13 | went wrong 14 | </h1> 15 | <p>Please try again later</p> 16 | <button 17 | onClick={() => router.push("/")} 18 | className="px-8 py-3 rounded-3xl hover:opacity-90 transition duration-200 bg-gradient-to-r from-emerald-400 border border-emerald-400 to-emerald-600 font-semibold" 19 | > 20 | Go back 21 | </button> 22 | </div> 23 | </div> 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/features/bricks/ui/picture.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Brick } from "@/entities/pages"; 4 | import Image from "next/image"; 5 | import React, { useEffect } from "react"; 6 | 7 | export function Picture(props: Brick & { maxHight?: number; url?: string }) { 8 | const [error, setError] = React.useState(false); 9 | 10 | useEffect(() => { 11 | setError(false); 12 | }, [props.payload, props.url]); 13 | 14 | if (error) { 15 | return <div>There is no photo or error</div>; 16 | } 17 | return ( 18 | <Image 19 | src={props.url ? props.url : props.payload} 20 | onError={() => setError(true)} 21 | alt={"image"} 22 | width={1280} 23 | height={720} 24 | priority 25 | className="max-w-full object-contain rounded-xl" 26 | style={{ maxHeight: props.maxHight }} 27 | /> 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "bundler", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "incremental": true, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "paths": { 26 | "@/*": [ 27 | "./src/*" 28 | ] 29 | } 30 | }, 31 | "include": [ 32 | "next-env.d.ts", 33 | "**/*.ts", 34 | "**/*.tsx", 35 | ".next/types/**/*.ts" 36 | ], 37 | "exclude": [ 38 | "node_modules" 39 | ] 40 | } -------------------------------------------------------------------------------- /src/features/auth/hooks/useUser.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import { useUserStore } from "../store/useUserStore"; 5 | import { me } from "@/api/auth"; 6 | import { GetToken } from "@/shared/lib/token"; 7 | 8 | export function useUser() { 9 | const { user, setUser } = useUserStore(); 10 | const [isAuth, setIsAuth] = React.useState(false); 11 | 12 | React.useEffect(() => { 13 | if (user.email !== "" && user.name !== "") return; 14 | const token = GetToken(); 15 | if (!token) return; 16 | me(token).then((data) => { 17 | setUser({ 18 | email: data.email, 19 | name: data.name, 20 | }); 21 | }); 22 | }, []); 23 | 24 | React.useEffect(() => { 25 | if (user.email !== "" && user.name !== "") { 26 | setIsAuth(true); 27 | } 28 | }, [user]); 29 | 30 | return { user, setUser, isAuth }; 31 | } 32 | -------------------------------------------------------------------------------- /src/shared/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/shared/lib/utils" 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} 7 | 8 | const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 | <textarea 12 | className={cn( 13 | "flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", 14 | className 15 | )} 16 | ref={ref} 17 | {...props} 18 | /> 19 | ) 20 | } 21 | ) 22 | Textarea.displayName = "Textarea" 23 | 24 | export { Textarea } 25 | -------------------------------------------------------------------------------- /src/app/api/uploadthing/core.ts: -------------------------------------------------------------------------------- 1 | import { createUploadthing, type FileRouter } from "uploadthing/next"; 2 | import { UploadThingError } from "uploadthing/server"; 3 | 4 | const f = createUploadthing(); 5 | 6 | const auth = async (req: Request) => ({ id: "fakeId" }); 7 | 8 | export const ourFileRouter = { 9 | imageUploader: f({ image: { maxFileSize: "2MB" } }) 10 | .middleware(async ({ req }) => { 11 | const user = await auth(req); 12 | 13 | if (!user) throw new UploadThingError("Unauthorized"); 14 | 15 | return { userId: user.id }; 16 | }) 17 | .onUploadComplete(async ({ metadata, file }) => { 18 | console.log("Upload complete for userId:", metadata.userId); 19 | 20 | console.log("file url", file.url); 21 | 22 | return { uploadedBy: metadata.userId }; 23 | }), 24 | } satisfies FileRouter; 25 | 26 | export type OurFileRouter = typeof ourFileRouter; 27 | -------------------------------------------------------------------------------- /src/features/brick-edit/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Dialog, 4 | DialogContent, 5 | DialogHeader, 6 | DialogTitle, 7 | DialogTrigger, 8 | } from "@/shared/ui/dialog"; 9 | import { Pencil } from "lucide-react"; 10 | 11 | export const EditDialog = (props: { 12 | children: React.ReactNode; 13 | title: string; 14 | }) => { 15 | return ( 16 | <Dialog> 17 | <DialogTrigger className="p-2 border border-neutral-800 bg-neutral-900 hover:border-neutral-600 transition rounded-lg absolute top-1/2 -translate-y-1/2 right-2"> 18 | <Pencil size={20} strokeWidth={1} /> 19 | </DialogTrigger> 20 | <DialogContent className="flex flex-col items-start"> 21 | <DialogHeader> 22 | <DialogTitle>{props.title}</DialogTitle> 23 | </DialogHeader> 24 | {props.children} 25 | </DialogContent> 26 | </Dialog> 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/features/upload/ui/button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { UploadDropzone } from "./upload"; 4 | 5 | type UploadProps = { 6 | onUploadComplete: (url: string) => void; 7 | label: string; 8 | }; 9 | 10 | export function Upload(props: UploadProps) { 11 | return ( 12 | <UploadDropzone 13 | content={{ 14 | label: props.label, 15 | }} 16 | className={` text-neutral-100 ut-label:text-white ut-button:bg-white 17 | ut-button:after:bg-emerald-500 18 | ut-button:text-neutral-950 border border-neutral-400 ut-uploading:ut-button:bg-neutral-300`} 19 | endpoint="imageUploader" 20 | onClientUploadComplete={(res) => { 21 | props.onUploadComplete(res[0].url); 22 | }} 23 | onUploadError={(e) => { 24 | alert(`Upload failed, reason: ${e.cause}`); 25 | }} 26 | onUploadProgress={() => {}} 27 | /> 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/features/brick-edit/ui/balance.tsx: -------------------------------------------------------------------------------- 1 | import { TitleEditor } from "../editors/title"; 2 | import { TextEditor } from "../editors/text"; 3 | import { Brick } from "@/entities/pages"; 4 | import { AirEditor } from "../editors/air"; 5 | import { LineEditor } from "../editors/line"; 6 | import { PictureEditor } from "../editors/picture"; 7 | import { ClickEditor } from "../editors/click"; 8 | 9 | export const Balance = (props: Brick) => { 10 | switch (props.type) { 11 | case "title": 12 | return <TitleEditor {...props} />; 13 | case "text": 14 | return <TextEditor {...props} />; 15 | case "air": 16 | return <AirEditor {...props} />; 17 | case "line": 18 | return <LineEditor {...props} />; 19 | case "picture": 20 | return <PictureEditor {...props} />; 21 | case "click": 22 | return <ClickEditor {...props} />; 23 | default: 24 | return null; 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/shared/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/shared/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes<HTMLInputElement> {} 7 | 8 | const Input = React.forwardRef<HTMLInputElement, InputProps>( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | <input 12 | type={type} 13 | className={cn( 14 | "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", 15 | className 16 | )} 17 | ref={ref} 18 | {...props} 19 | /> 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /src/app/page/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getOne } from "@/api/page"; 2 | import { Render } from "@/features/render"; 3 | import { SetTheme } from "@/features/theme"; 4 | 5 | export async function generateMetadata({ params }: { params: { id: string } }) { 6 | const id = params.id; 7 | const page = await getOne(id); 8 | return { 9 | title: page.title, 10 | }; 11 | } 12 | 13 | export default async function Page({ params }: { params: { id: string } }) { 14 | const id = params.id; 15 | const page = await getOne(id); 16 | return ( 17 | <main className="w-full min-h-screen flex flex-col items-center justify-center bg-white dark:bg-neutral-950"> 18 | <div className="w-full min-h-screen sm:max-w-[450px] mx-6 rounded-none sm:rounded-xl sm:min-h-[800px] sm:h-auto bg-white dark:bg-neutral-950 relative px-3 py-12"> 19 | <SetTheme theme={page.theme} /> 20 | <Render bricks={page.bricks} /> 21 | </div> 22 | </main> 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Roboto_Flex } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | import { ReactQueryProvider } from "@/features/react-query"; 6 | 7 | const rf = Roboto_Flex({ 8 | weight: ["1000", "200", "300", "400", "500", "600", "700", "800", "900"], 9 | subsets: ["latin"], 10 | }); 11 | 12 | export const metadata: Metadata = { 13 | title: "Tap builder app", 14 | description: "The builder of tap websites", 15 | keywords: [ 16 | "tap", 17 | "tap builder", 18 | "tap website builder", 19 | "tap website builder", 20 | "taplink", 21 | "website", 22 | ], 23 | }; 24 | 25 | export default function RootLayout({ 26 | children, 27 | }: Readonly<{ 28 | children: React.ReactNode; 29 | }>) { 30 | return ( 31 | <html lang="en" suppressHydrationWarning> 32 | <body className={rf.className}> 33 | <ReactQueryProvider>{children}</ReactQueryProvider> 34 | </body> 35 | </html> 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/widgets/edit/ui/header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Preview, Save, useCurrent } from "@/features/brick-edit"; 4 | import { ArrowLeft } from "lucide-react"; 5 | import Link from "next/link"; 6 | 7 | export const Header = () => { 8 | const { page } = useCurrent(); 9 | return ( 10 | <header className="w-full fixed border-b border-neutral-700 flex justify-center text-neutral-100 z-50 bg-neutral-900"> 11 | <div className="flex w-full justify-between items-center px-4 container py-3"> 12 | <Link 13 | className="flex items-center gap-2 text-neutral-400 hover:text-neutral-100 transition" 14 | href="/dashboard" 15 | > 16 | <ArrowLeft /> 17 | <span className="hidden sm:flex">Dashboard</span> 18 | </Link> 19 | <h1>{page.title || "Editor"}</h1> 20 | <div className="flex items-center gap-4"> 21 | <Save /> 22 | <Preview /> 23 | </div> 24 | </div> 25 | </header> 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/features/brick-edit/hooks/useSave.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { useCurrentPage } from "../store/current-editing"; 5 | import { GetToken } from "@/shared/lib/token"; 6 | import { update } from "@/api/page"; 7 | import { useShouldSave } from "../store/should-save"; 8 | 9 | export function useSave() { 10 | const { bricks, page, setPage, setBricks } = useCurrentPage(); 11 | const { shouldSave, setShouldSave } = useShouldSave(); 12 | const [isSaving, setIsSaving] = useState(false); 13 | 14 | function BricksUpdated() { 15 | setShouldSave(true); 16 | } 17 | 18 | async function handleSave() { 19 | if (!shouldSave) return; 20 | setIsSaving(true); 21 | const token = GetToken(); 22 | if (!token) throw new Error(); 23 | const data = await update(token, { 24 | ...page, 25 | bricks, 26 | }); 27 | setPage(data); 28 | setBricks(data.bricks); 29 | setIsSaving(false); 30 | setShouldSave(false); 31 | } 32 | 33 | return { 34 | shouldSave, 35 | isSaving, 36 | handleSave, 37 | BricksUpdated, 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /src/widgets/landing/ui/header.tsx: -------------------------------------------------------------------------------- 1 | import { headers } from "next/headers"; 2 | import Link from "next/link"; 3 | 4 | export function Header() { 5 | const token = headers().get("Authorization"); 6 | return ( 7 | <header className="w-full flex justify-center py-4 bg-black/50 sticky top-0 z-10 backdrop-blur"> 8 | <div className="container flex justify-between items-center"> 9 | <span className="text-2xl lg:text-3xl font-bold">Logo</span> 10 | <nav className=" hidden lg:flex items-center gap-8 "> 11 | <Link href="#">Home</Link> 12 | <Link href="#">How it works</Link> 13 | <Link href="#">Features</Link> 14 | <Link href="#">Pricing</Link> 15 | <Link href="#">About us</Link> 16 | </nav> 17 | <Link 18 | href="/dashboard" 19 | className="py-2 px-6 lg:px-8 lg:py-3 rounded-3xl hover:opacity-90 transition duration-200 bg-gradient-to-r from-emerald-400 border border-emerald-400 to-emerald-600 font-semibold" 20 | > 21 | Open an account 22 | </Link> 23 | </div> 24 | </header> 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/features/bricks/ui/title.tsx: -------------------------------------------------------------------------------- 1 | import { Brick } from "@/entities/pages"; 2 | import { cn } from "@/shared/lib/utils"; 3 | 4 | export type TitleParams = { 5 | size: string; 6 | align: string; 7 | }; 8 | 9 | export function ParseTitleParams(params: string) { 10 | try { 11 | const parsedParams: TitleParams = JSON.parse(params); 12 | return parsedParams; 13 | } catch (error) { 14 | const parsedParams: TitleParams = { size: "medium", align: "center" }; 15 | return parsedParams; 16 | } 17 | } 18 | 19 | export const Title = (props: Brick) => { 20 | const params = ParseTitleParams(props.params); 21 | return ( 22 | <h1 23 | className={cn( 24 | "text-2xl font-bold w-full text-center text-neutral-800 dark:text-white", 25 | { 26 | "text-left": params.align === "left", 27 | "text-center": params.align === "center", 28 | "text-right": params.align === "right", 29 | "text-3xl": params.size === "large", 30 | "text-2xl": params.size === "medium", 31 | "text-xl": params.size === "small", 32 | } 33 | )} 34 | > 35 | {props.payload} 36 | </h1> 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/features/bricks/ui/text.tsx: -------------------------------------------------------------------------------- 1 | import { Brick } from "@/entities/pages"; 2 | import { cn } from "@/shared/lib/utils"; 3 | 4 | type TextParams = { 5 | size: string; 6 | align: string; 7 | }; 8 | 9 | export function ParseTextParams(params: string) { 10 | try { 11 | const parsedParams: TextParams = JSON.parse(params); 12 | return parsedParams; 13 | } catch (error) { 14 | const parsedParams: TextParams = { size: "medium", align: "center" }; 15 | return parsedParams; 16 | } 17 | } 18 | 19 | export const Text = (props: Brick) => { 20 | const params = ParseTextParams(props.params); 21 | 22 | return ( 23 | <p 24 | className={cn( 25 | "text-sm font-light text-neutral-700 dark:text-white w-full whitespace-pre-line text-center", 26 | { 27 | "text-left": params.align === "left", 28 | "text-center": params.align === "center", 29 | "text-right": params.align === "right", 30 | "text-base": params.size === "large", 31 | "text-sm": params.size === "medium", 32 | "text-xs": params.size === "small", 33 | } 34 | )} 35 | > 36 | {props.payload} 37 | </p> 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/features/check/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { check } from "@/api/page"; 3 | 4 | type AddressCheckerProps = { 5 | address: string; 6 | defaultAddress?: string; 7 | }; 8 | 9 | export function AddressChecker(props: AddressCheckerProps) { 10 | const [exists, setExists] = useState(false); 11 | useEffect(() => { 12 | if (props.address.length < 3) { 13 | setExists(true); 14 | return; 15 | } 16 | if (props.defaultAddress === props.address) { 17 | setExists(false); 18 | return; 19 | } 20 | 21 | const debounceTimer = setTimeout(() => { 22 | check(props.address).then((res) => { 23 | setExists(res.exists); 24 | }); 25 | }, 500); // Задержка в миллисекундах 26 | 27 | // Очистка таймера при каждом изменении адреса 28 | return () => clearTimeout(debounceTimer); 29 | }, [props.address, props.defaultAddress]); 30 | if (exists) { 31 | return ( 32 | <div className="w-4 h-4 rounded-full bg-red-500 absolute top-1/2 -translate-y-1/2 right-2 "></div> 33 | ); 34 | } 35 | return ( 36 | <div className="w-4 h-4 rounded-full bg-emerald-500 absolute top-1/2 -translate-y-1/2 right-2"></div> 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/features/brick-edit/hooks/useCurrent.tsx: -------------------------------------------------------------------------------- 1 | import { Page, Brick } from "@/entities/pages"; 2 | import { useCurrentPage } from "../store/current-editing"; 3 | import { useSave } from "./useSave"; 4 | 5 | export function useCurrent() { 6 | const { 7 | page, 8 | bricks, 9 | setPage, 10 | setBricks, 11 | changeBrick, 12 | deleteBrick, 13 | addBrick, 14 | } = useCurrentPage(); 15 | const { BricksUpdated } = useSave(); 16 | function setInitialPage(page: Page) { 17 | setPage(page); 18 | setBricks(page.bricks); 19 | } 20 | 21 | function handleAddBrick(brick: Brick) { 22 | addBrick(brick); 23 | BricksUpdated(); 24 | } 25 | function setDragBricks(cb: (bricks: Brick[]) => Brick[]) { 26 | const newBricks = cb(bricks); 27 | setBricks(newBricks); 28 | BricksUpdated(); 29 | } 30 | function handleChangeBrick(brick: Brick) { 31 | changeBrick(brick); 32 | BricksUpdated(); 33 | } 34 | 35 | function handleDeleteBrick(id: string) { 36 | deleteBrick(id); 37 | BricksUpdated(); 38 | } 39 | return { 40 | page, 41 | bricks, 42 | setInitialPage, 43 | setDragBricks, 44 | handleChangeBrick, 45 | handleDeleteBrick, 46 | handleAddBrick, 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import type { NextRequest } from "next/server"; 3 | 4 | const tokenRequiredRoutes = ["/dashboard", "/edit"]; 5 | const authRoutes = ["/login", "/register", "/google/calback"]; 6 | 7 | export function middleware(request: NextRequest) { 8 | const token = request.cookies.get("Authorization")?.value; 9 | if (!token) { 10 | const path = request.nextUrl.pathname; 11 | if (tokenRequiredRoutes.includes(path)) { 12 | return NextResponse.redirect(new URL("/login", request.url)); 13 | } 14 | } else { 15 | const path = request.nextUrl.pathname; 16 | if (authRoutes.includes(path)) { 17 | return NextResponse.redirect(new URL("/dashboard", request.url)); 18 | } 19 | } 20 | 21 | const headers = new Headers(request.headers); 22 | if (token) { 23 | headers.set("Authorization", token); 24 | request.headers.set("Authorization", token); 25 | request.headers.set( 26 | "Cache-Control", 27 | `no-store, no-cache, must-revalidate, private` 28 | ); 29 | } 30 | 31 | return NextResponse.next({ 32 | request: request, 33 | }); 34 | } 35 | 36 | // See "Matching Paths" below to learn more 37 | export const config = { 38 | matcher: "/((?!.*\\..*|_next).*)", 39 | }; 40 | -------------------------------------------------------------------------------- /src/api/auth.ts: -------------------------------------------------------------------------------- 1 | import { baseUrl } from "."; 2 | 3 | type meResponse = { 4 | email: string; 5 | name: string; 6 | }; 7 | 8 | type authResponse = meResponse & { 9 | Authorization: string; 10 | }; 11 | 12 | export async function login(email: string, password: string) { 13 | const res = await fetch(`${baseUrl}/login`, { 14 | method: "POST", 15 | headers: { 16 | "Content-Type": "application/json", 17 | }, 18 | body: JSON.stringify({ email, password }), 19 | }); 20 | const data: authResponse = await res.json(); 21 | return data; 22 | } 23 | 24 | export async function register(name: string, email: string, password: string) { 25 | const res = await fetch(`${baseUrl}/register`, { 26 | method: "POST", 27 | headers: { 28 | "Content-Type": "application/json", 29 | }, 30 | body: JSON.stringify({ email: email, name: name, password: password }), 31 | }); 32 | const data: authResponse = await res.json(); 33 | return data; 34 | } 35 | 36 | export async function me(token: string) { 37 | const res = await fetch(`${baseUrl}/me`, { 38 | method: "GET", 39 | headers: { 40 | "Content-Type": "application/json", 41 | Authorization: token, 42 | }, 43 | }); 44 | const data: meResponse = await res.json(); 45 | return data; 46 | } 47 | -------------------------------------------------------------------------------- /src/shared/lib/token.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | import { useLayoutEffect } from "react"; 5 | 6 | export function SetToken(token: string) { 7 | if (typeof document !== "undefined") { 8 | const currentDate = new Date(); 9 | const expirationDate = new Date( 10 | currentDate.getTime() + 30 * 24 * 60 * 60 * 1000 11 | ); 12 | const expires = expirationDate.toUTCString(); 13 | 14 | document.cookie = `Authorization=${token}; expires=${expires}; path=/`; 15 | } 16 | } 17 | 18 | export function DeleteToken() { 19 | if (typeof document !== "undefined") { 20 | document.cookie = 21 | "Authorization=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; 22 | } 23 | } 24 | 25 | export function GetToken() { 26 | if (typeof document !== "undefined") { 27 | const cookie = document.cookie 28 | .split(";") 29 | .find((c) => c.trim().startsWith("Authorization=")); 30 | if (cookie) { 31 | return cookie.split("=")[1]; 32 | } 33 | } 34 | } 35 | 36 | export function SetTokenComp(props: { token: string }) { 37 | const router = useRouter(); 38 | useLayoutEffect(() => { 39 | SetToken(props.token); 40 | router.push("/"); 41 | }, []); 42 | 43 | return <div>Google Authorization</div>; 44 | } 45 | -------------------------------------------------------------------------------- /src/widgets/edit/ui/edit.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Render } from "@/features/render"; 4 | import { Page } from "@/entities/pages"; 5 | import { useQuery } from "react-query"; 6 | import { useCurrent, usePreview } from "@/features/brick-edit"; 7 | import { useEffect } from "react"; 8 | import { CurrentBricks } from "./current-bricks"; 9 | import { getOne } from "@/api/page"; 10 | 11 | export const Edit = (props: { id: string }) => { 12 | const { data, isLoading } = useQuery(["page", props.id], () => { 13 | return getOne(props.id); 14 | }); 15 | const { bricks, setInitialPage } = useCurrent(); 16 | const { isPreview } = usePreview(); 17 | 18 | useEffect(() => { 19 | if (data) { 20 | setInitialPage(data); 21 | } 22 | }, [data]); 23 | 24 | return ( 25 | <main className="pt-24 min-h-dvh w-full flex flex-col items-center justify-start"> 26 | <div className="w-full mx-4 max-w-[500px] px-3 rounded-lg flex flex-col justify-start gap-6"> 27 | {isLoading && <div>Loading...</div>} 28 | {isPreview ? ( 29 | <Render 30 | bricks={bricks} 31 | className="dark:border border-neutral-700 py-12 px-4" 32 | /> 33 | ) : ( 34 | <CurrentBricks /> 35 | )} 36 | </div> 37 | </main> 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/features/brick-edit/store/current-editing.ts: -------------------------------------------------------------------------------- 1 | import { Brick, PageMeta } from "@/entities/pages"; 2 | import { create } from "zustand"; 3 | import { devtools } from "zustand/middleware"; 4 | 5 | interface CurrentPage { 6 | bricks: Brick[]; 7 | page: PageMeta; 8 | setBricks: (bricks: Brick[]) => void; 9 | changeBrick: (brick: Brick) => void; 10 | deleteBrick: (id: string) => void; 11 | addBrick: (brick: Brick) => void; 12 | setPage: (page: PageMeta) => void; 13 | } 14 | 15 | export const useCurrentPage = create<CurrentPage>()( 16 | devtools((set) => ({ 17 | bricks: [], 18 | page: { 19 | id: "", 20 | title: "", 21 | address: "", 22 | theme: "", 23 | favicon: "", 24 | }, 25 | setBricks: (bricks: Brick[]) => set({ bricks }), 26 | changeBrick: (brick: Brick) => { 27 | set((state) => ({ 28 | bricks: state.bricks.map((b) => (b.id === brick.id ? brick : b)), 29 | })); 30 | }, 31 | deleteBrick: (id: string) => { 32 | set((state) => ({ 33 | bricks: state.bricks.filter((b) => b.id !== id), 34 | })); 35 | }, 36 | addBrick: (brick: Brick) => { 37 | set((state) => ({ 38 | bricks: [...state.bricks, brick], 39 | })); 40 | }, 41 | setPage: (page: PageMeta) => set({ page }), 42 | })) 43 | ); 44 | -------------------------------------------------------------------------------- /src/widgets/dashboard/ui/dashboard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Page } from "@/entities/pages"; 4 | import { Pages } from "@/features/pages"; 5 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; 6 | import { useTheme } from "next-themes"; 7 | import { useLayoutEffect } from "react"; 8 | 9 | export function DashBoard(props: { pages: Page[] }) { 10 | const { setTheme } = useTheme(); 11 | 12 | useLayoutEffect(() => { 13 | console.log(process.env.NEXT_PUBLIC_API_URL); 14 | setTheme("dark"); 15 | }, []); 16 | return ( 17 | <div className="w-full flex flex-col"> 18 | <div className="container mt-6"> 19 | <Tabs defaultValue="pages" className="w-full flex flex-col gap-8"> 20 | <TabsList className="w-72"> 21 | <TabsTrigger value="pages" className="w-1/2"> 22 | Pages 23 | </TabsTrigger> 24 | <TabsTrigger value="metrics" className="w-1/2"> 25 | Metrics 26 | </TabsTrigger> 27 | </TabsList> 28 | 29 | <TabsContent value="pages"> 30 | <Pages pages={props.pages} /> 31 | </TabsContent> 32 | <TabsContent value="metrics"> 33 | <p>There is no metric yet</p> 34 | </TabsContent> 35 | </Tabs> 36 | </div> 37 | </div> 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/features/pages/ui/edit.tsx: -------------------------------------------------------------------------------- 1 | import { Page, PageMeta } from "@/entities/pages"; 2 | 3 | import { useState } from "react"; 4 | import { Edit as EditIcon } from "lucide-react"; 5 | import { useMutation } from "react-query"; 6 | import { updateMeta } from "@/api/page"; 7 | import { usePageStore } from "../store/page"; 8 | import { GetToken } from "@/shared/lib/token"; 9 | import { PageDialog } from "./dialog"; 10 | 11 | export function Edit(props: Page) { 12 | const [page, setPage] = useState<PageMeta>(props); 13 | 14 | const mutation = useMutation( 15 | (newPage: PageMeta) => { 16 | const token = GetToken(); 17 | if (!token) throw new Error(); 18 | console.log(newPage); 19 | return updateMeta(token, newPage); 20 | }, 21 | { 22 | onSuccess: (data) => { 23 | updatePage(data); 24 | }, 25 | } 26 | ); 27 | const { updatePage } = usePageStore(); 28 | 29 | function handleEdit(title: string, address: string, theme: string) { 30 | const newPage = { ...page, title, address, theme }; 31 | setPage(newPage); 32 | mutation.mutate(newPage); 33 | } 34 | return ( 35 | <PageDialog page={{ ...page, bricks: [] }} onSave={handleEdit}> 36 | <button className="p-2 rounded text-white hover:bg-neutral-700 flex gap-4 items-center absolute top-2 right-2"> 37 | <EditIcon size={20} /> 38 | </button> 39 | </PageDialog> 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/features/pages/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import { Page } from "@/entities/pages"; 2 | import { ExternalLink } from "lucide-react"; 3 | import Link from "next/link"; 4 | import { Edit } from "./edit"; 5 | 6 | export function Card(props: Page) { 7 | return ( 8 | <div className="rounded-xl border border-neutral-700 flex p-6 flex-col gap-12 justify-between relative flex-1"> 9 | <div className="w-full flex flex-col gap-4"> 10 | <h1 className="text-neutral-100 text-2xl">{props.title}</h1> 11 | <Link 12 | target="_blank" 13 | className="flex items-center gap-4 rounded-xl transition" 14 | href={"/page/" + props.address} 15 | > 16 | <p className="text-xs font-light text-neutral-400 hover:underline"> 17 | /{props.address} 18 | </p> 19 | <ExternalLink 20 | className="text-xs font-light text-neutral-400" 21 | size={16} 22 | /> 23 | </Link> 24 | </div> 25 | <Link 26 | href={"/edit/" + props.address} 27 | prefetch={false} 28 | scroll={false} 29 | className="px-8 py-2 text-nowrap flex justify-center items-center h-full bg-transparent hover:bg-gradient-to-r hover:from-emerald-500 hover:to-emerald-700 rounded text-white border border-neutral-700 hover:border-emarald-400 duration-300 " 30 | > 31 | Open Editor 32 | </Link> 33 | <Edit {...props} /> 34 | </div> 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /src/features/theme/ui/toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { MoonIcon, SunIcon } from "@radix-ui/react-icons"; 5 | import { useTheme } from "next-themes"; 6 | 7 | import { Button } from "@/shared/ui/button"; 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/shared/ui/dropdown-menu"; 14 | 15 | export function ModeToggle() { 16 | const { setTheme } = useTheme(); 17 | 18 | return ( 19 | <DropdownMenu> 20 | <DropdownMenuTrigger asChild> 21 | <Button 22 | variant="outline" 23 | size="icon" 24 | className="absolute top-2 right-2" 25 | > 26 | <SunIcon className="h-4 w-4 rotate-0 scale-100 text-neutral-950 transition-all dark:-rotate-90 dark:scale-0" /> 27 | <MoonIcon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> 28 | <span className="sr-only">Toggle theme</span> 29 | </Button> 30 | </DropdownMenuTrigger> 31 | <DropdownMenuContent align="end"> 32 | <DropdownMenuItem onClick={() => setTheme("light")}> 33 | Light 34 | </DropdownMenuItem> 35 | <DropdownMenuItem onClick={() => setTheme("dark")}> 36 | Dark 37 | </DropdownMenuItem> 38 | <DropdownMenuItem onClick={() => setTheme("system")}> 39 | System 40 | </DropdownMenuItem> 41 | </DropdownMenuContent> 42 | </DropdownMenu> 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 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 | }, 11 | "dependencies": { 12 | "@dnd-kit/core": "^6.1.0", 13 | "@dnd-kit/sortable": "^8.0.0", 14 | "@dnd-kit/utilities": "^3.2.2", 15 | "@radix-ui/react-dialog": "^1.0.5", 16 | "@radix-ui/react-dropdown-menu": "^2.0.6", 17 | "@radix-ui/react-icons": "^1.3.0", 18 | "@radix-ui/react-select": "^2.0.0", 19 | "@radix-ui/react-slot": "^1.0.2", 20 | "@radix-ui/react-tabs": "^1.0.4", 21 | "@uploadthing/react": "^6.2.4", 22 | "class-variance-authority": "^0.7.0", 23 | "clsx": "^2.1.0", 24 | "embla-carousel-react": "8.0.0-rc23", 25 | "framer-motion": "^11.0.6", 26 | "lucide-react": "^0.334.0", 27 | "mini-svg-data-uri": "^1.4.4", 28 | "next": "14.1.0", 29 | "next-themes": "^0.2.1", 30 | "react": "^18", 31 | "react-dom": "^18", 32 | "react-query": "^3.39.3", 33 | "tailwind-merge": "^2.2.1", 34 | "tailwindcss-animate": "^1.0.7", 35 | "uploadthing": "^6.4.1", 36 | "uuid": "^9.0.1", 37 | "zod": "^3.22.4", 38 | "zustand": "^4.5.1" 39 | }, 40 | "devDependencies": { 41 | "@types/node": "^20", 42 | "@types/react": "^18", 43 | "@types/react-dom": "^18", 44 | "@types/uuid": "^9.0.8", 45 | "autoprefixer": "^10.0.1", 46 | "eslint": "^8", 47 | "eslint-config-next": "14.1.0", 48 | "postcss": "^8", 49 | "tailwindcss": "^3.3.0", 50 | "typescript": "^5" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/features/pages/ui/add.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | import { Page } from "@/entities/pages"; 5 | import { useState } from "react"; 6 | import { v4 } from "uuid"; 7 | import { usePageStore } from "../store/page"; 8 | import { create } from "@/api/page"; 9 | import { useMutation } from "react-query"; 10 | import { AddressChecker } from "@/features/check"; 11 | import { GetToken } from "@/shared/lib/token"; 12 | import { PageDialog } from "./dialog"; 13 | 14 | export function Add() { 15 | const [page, setPage] = useState<Page>({ 16 | id: v4(), 17 | title: "", 18 | address: "", 19 | theme: "", 20 | favicon: "", 21 | bricks: [], 22 | }); 23 | const { addPage } = usePageStore(); 24 | const mutation = useMutation( 25 | (newPage: Page) => { 26 | const token = GetToken(); 27 | if (!token) throw new Error(); 28 | return create(token, newPage); 29 | }, 30 | { 31 | onSuccess: (data) => { 32 | addPage(data); 33 | }, 34 | } 35 | ); 36 | 37 | function onSave(title: string, address: string, theme: string) { 38 | const newPage = { ...page, title, address, theme }; 39 | setPage(newPage); 40 | mutation.mutate(newPage); 41 | } 42 | return ( 43 | <PageDialog page={page} onSave={onSave}> 44 | <div className="flex-1 min-w-72 min-h-52 h-full rounded-lg border border-neutral-700 flex justify-center items-center hover:bg-neutral-800 transition cursor-pointer"> 45 | <h1 className="bg-gradient-to-r from-emerald-500 to-emerald-700 bg-clip-text text-transparent font-medium text-3xl"> 46 | Add new page 47 | </h1> 48 | </div> 49 | </PageDialog> 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/features/profile/ui/profile.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useUser } from "@/features/auth"; 4 | import { DeleteToken } from "@/shared/lib/token"; 5 | import { 6 | DropdownMenu, 7 | DropdownMenuContent, 8 | DropdownMenuItem, 9 | DropdownMenuLabel, 10 | DropdownMenuSeparator, 11 | DropdownMenuTrigger, 12 | } from "@/shared/ui/dropdown-menu"; 13 | import { ExitIcon, PersonIcon } from "@radix-ui/react-icons"; 14 | import { User } from "lucide-react"; 15 | import { useRouter } from "next/navigation"; 16 | 17 | export function Profile() { 18 | const { user } = useUser(); 19 | const router = useRouter(); 20 | function handleLogout() { 21 | DeleteToken(); 22 | router.push("/"); 23 | } 24 | return ( 25 | <DropdownMenu> 26 | <DropdownMenuTrigger asChild> 27 | <div className="w-10 h-10 flex justify-center items-center rounded-full bg-neutral-800 text-neutral-200"> 28 | <User size={20} /> 29 | </div> 30 | </DropdownMenuTrigger> 31 | <DropdownMenuContent className="p-2 border-neutral-500"> 32 | <DropdownMenuLabel>Hi {user.name}!</DropdownMenuLabel> 33 | <DropdownMenuSeparator /> 34 | <div className="w-full flex flex-col gap-1"> 35 | <DropdownMenuItem className="cursor-pointer flex gap-2"> 36 | <PersonIcon /> Profile 37 | </DropdownMenuItem> 38 | <DropdownMenuItem 39 | onClick={handleLogout} 40 | className="cursor-pointer flex gap-2 bg-red-600/50 hover:bg-red-600/70 focus:bg-red-600/70" 41 | > 42 | <ExitIcon /> 43 | Log out 44 | </DropdownMenuItem> 45 | </div> 46 | </DropdownMenuContent> 47 | </DropdownMenu> 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/features/brick-edit/ui/wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/shared/lib/utils"; 2 | import { CSSProperties, HtmlHTMLAttributes } from "react"; 3 | import { useSortable } from "@dnd-kit/sortable"; 4 | import { CSS } from "@dnd-kit/utilities"; 5 | 6 | export const Wrapper = ( 7 | props: HtmlHTMLAttributes<HTMLDivElement> & { 8 | id: string; 9 | } 10 | ) => { 11 | const { 12 | attributes, 13 | listeners, 14 | setNodeRef, 15 | transform, 16 | transition, 17 | setActivatorNodeRef, 18 | isDragging, 19 | } = useSortable({ id: props.id }); 20 | 21 | const style: CSSProperties = { 22 | scale: isDragging ? 1.05 : 1, 23 | opacity: isDragging ? 0.4 : undefined, 24 | transform: CSS.Translate.toString(transform), 25 | transition, 26 | }; 27 | return ( 28 | <div 29 | ref={setNodeRef} 30 | style={style} 31 | className={cn( 32 | "w-full px-8 py-4 border select-none border-neutral-700 rounded-xl flex justify-center items-center bg-white dark:bg-neutral-900 relative", 33 | props.className 34 | )} 35 | {...props} 36 | > 37 | <div 38 | ref={setActivatorNodeRef} 39 | {...attributes} 40 | {...listeners} 41 | className="touch-none absolute top-1/2 -translate-y-1/2 left-2 fill-neutral-500" 42 | > 43 | <svg viewBox="0 0 20 20" width="20" height="30"> 44 | <path d="M7 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 2zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 14zm6-8a2 2 0 1 0-.001-4.001A2 2 0 0 0 13 6zm0 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 14z"></path> 45 | </svg> 46 | </div> 47 | {props.children} 48 | </div> 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/widgets/edit/ui/current-bricks.tsx: -------------------------------------------------------------------------------- 1 | import { Balance } from "@/features/brick-edit"; 2 | import { AddNewBrick } from "@/features/brick-edit"; 3 | import { 4 | DndContext, 5 | closestCorners, 6 | DragEndEvent, 7 | useSensor, 8 | useSensors, 9 | PointerSensor, 10 | TouchSensor, 11 | KeyboardSensor, 12 | } from "@dnd-kit/core"; 13 | import { 14 | SortableContext, 15 | arrayMove, 16 | sortableKeyboardCoordinates, 17 | verticalListSortingStrategy, 18 | } from "@dnd-kit/sortable"; 19 | import { useCurrent } from "@/features/brick-edit"; 20 | 21 | export function CurrentBricks() { 22 | const { bricks, setDragBricks } = useCurrent(); 23 | 24 | function getBrickPosition(id: string) { 25 | return bricks.findIndex((brick) => brick.id === id); 26 | } 27 | function handleDragend(event: DragEndEvent) { 28 | const { active, over } = event; 29 | if (active.id === over?.id) return; 30 | setDragBricks((bricks) => { 31 | const oldIndex = getBrickPosition(String(active.id)); 32 | const newIndex = getBrickPosition(String(over?.id)); 33 | 34 | return arrayMove(bricks, oldIndex, newIndex); 35 | }); 36 | } 37 | const sensors = useSensors( 38 | useSensor(PointerSensor), 39 | useSensor(TouchSensor), 40 | useSensor(KeyboardSensor, { 41 | coordinateGetter: sortableKeyboardCoordinates, 42 | }) 43 | ); 44 | return ( 45 | <div className="w-full flex flex-col gap-2 bg-white dark:bg-neutral-900"> 46 | <DndContext 47 | sensors={sensors} 48 | collisionDetection={closestCorners} 49 | onDragEnd={handleDragend} 50 | > 51 | <SortableContext items={bricks} strategy={verticalListSortingStrategy}> 52 | {bricks.map((brick) => ( 53 | <Balance key={brick.id} {...brick} /> 54 | ))} 55 | </SortableContext> 56 | </DndContext> 57 | <AddNewBrick /> 58 | </div> 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/widgets/landing/ui/what.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | RocketIcon, 3 | MagicWandIcon, 4 | MixerVerticalIcon, 5 | } from "@radix-ui/react-icons"; 6 | 7 | type PersonIcon = typeof RocketIcon; 8 | 9 | export function What() { 10 | return ( 11 | <div className="w-full flex flex-col items-center mt-24 gap-16" id="what"> 12 | <h2 className="text-3xl md:text-5xl font-bold "> 13 | <span className="bg-gradient-to-r from-emerald-400 to-emerald-500 bg-clip-text text-transparent"> 14 | Discover 15 | </span>{" "} 16 | Our Product 17 | </h2> 18 | <div className="flex flex-wrap gap-6 container"> 19 | <Feature 20 | title="Lightweight Web Cards" 21 | description="Efficient online presence with our lightweight web cards. Instant loading on any device" 22 | Icon={RocketIcon} 23 | /> 24 | <Feature 25 | title="Beautiful Design" 26 | description="Highlight your uniqueness with our beautifully designed web cards. Stylish and modern templates" 27 | Icon={MagicWandIcon} 28 | /> 29 | <Feature 30 | title="High Functionality" 31 | description="Not just pretty, but also practical. Contact forms, social links, and more to engage your audience" 32 | Icon={MixerVerticalIcon} 33 | /> 34 | </div> 35 | </div> 36 | ); 37 | } 38 | 39 | function Feature({ 40 | title, 41 | description, 42 | Icon, 43 | }: { 44 | title: string; 45 | description: string; 46 | Icon: PersonIcon; 47 | }) { 48 | return ( 49 | <div className="flex flex-1 min-w-64 border border-emerald-600/30 flex-col gap-8 p-6 rounded-xl bg-gradient-to-bl from-emerald-400/0 to-emerald-600/10"> 50 | <Icon className="text-emerald-100/80 w-8 h-8" /> 51 | <div className="flex flex-col gap-4"> 52 | <h3 className="text-xl font-bold">{title}</h3> 53 | <p className="text-balance text-sm font-light text-gray-400"> 54 | {description} 55 | </p> 56 | </div> 57 | </div> 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/shared/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/shared/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ); 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes<HTMLButtonElement>, 38 | VariantProps<typeof buttonVariants> { 39 | asChild?: boolean; 40 | } 41 | 42 | const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button"; 45 | return ( 46 | <Comp 47 | className={cn(buttonVariants({ variant, size, className }))} 48 | ref={ref} 49 | {...props} 50 | /> 51 | ); 52 | } 53 | ); 54 | Button.displayName = "Button"; 55 | 56 | export { Button, buttonVariants }; 57 | -------------------------------------------------------------------------------- /src/shared/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@/shared/lib/utils" 7 | 8 | const Tabs = TabsPrimitive.Root 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef<typeof TabsPrimitive.List>, 12 | React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> 13 | >(({ className, ...props }, ref) => ( 14 | <TabsPrimitive.List 15 | ref={ref} 16 | className={cn( 17 | "inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground", 18 | className 19 | )} 20 | {...props} 21 | /> 22 | )) 23 | TabsList.displayName = TabsPrimitive.List.displayName 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef<typeof TabsPrimitive.Trigger>, 27 | React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> 28 | >(({ className, ...props }, ref) => ( 29 | <TabsPrimitive.Trigger 30 | ref={ref} 31 | className={cn( 32 | "inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm", 33 | className 34 | )} 35 | {...props} 36 | /> 37 | )) 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef<typeof TabsPrimitive.Content>, 42 | React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> 43 | >(({ className, ...props }, ref) => ( 44 | <TabsPrimitive.Content 45 | ref={ref} 46 | className={cn( 47 | "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", 48 | className 49 | )} 50 | {...props} 51 | /> 52 | )) 53 | TabsContent.displayName = TabsPrimitive.Content.displayName 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent } 56 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html{ 6 | @apply bg-neutral-950 text-neutral-100 font-extralight tracking-wide relative 7 | } 8 | 9 | body { 10 | box-sizing: content-box; 11 | margin: 0; 12 | padding: 0; 13 | scroll-behavior: smooth; 14 | } 15 | 16 | .circle-gradient { 17 | width: 100%; 18 | height: 1080px; 19 | background: radial-gradient(#003a2e 0%, #0a0a0a 60%); 20 | background-size: 100%; 21 | background-position: center; 22 | background-repeat: no-repeat; 23 | z-index: -300; 24 | /* Создает круглый элемент */ 25 | } 26 | 27 | 28 | @layer base { 29 | :root { 30 | --background: 0 0% 100%; 31 | --foreground: 0 0% 3.9%; 32 | 33 | --card: 0 0% 100%; 34 | --card-foreground: 0 0% 3.9%; 35 | 36 | --popover: 0 0% 100%; 37 | --popover-foreground: 0 0% 3.9%; 38 | 39 | --primary: 0 0% 9%; 40 | --primary-foreground: 0 0% 98%; 41 | 42 | --secondary: 0 0% 96.1%; 43 | --secondary-foreground: 0 0% 9%; 44 | 45 | --muted: 0 0% 96.1%; 46 | --muted-foreground: 0 0% 45.1%; 47 | 48 | --accent: 0 0% 96.1%; 49 | --accent-foreground: 0 0% 9%; 50 | 51 | --destructive: 0 84.2% 60.2%; 52 | --destructive-foreground: 0 0% 98%; 53 | 54 | --border: 0 0% 89.8%; 55 | --input: 0 0% 89.8%; 56 | --ring: 0 0% 3.9%; 57 | 58 | --radius: 0.5rem; 59 | } 60 | 61 | .dark { 62 | --background: 0 0% 3.9%; 63 | --foreground: 0 0% 98%; 64 | 65 | --card: 0 0% 3.9%; 66 | --card-foreground: 0 0% 98%; 67 | 68 | --popover: 0 0% 3.9%; 69 | --popover-foreground: 0 0% 98%; 70 | 71 | --primary: 0 0% 98%; 72 | --primary-foreground: 0 0% 9%; 73 | 74 | --secondary: 0 0% 14.9%; 75 | --secondary-foreground: 0 0% 98%; 76 | 77 | --muted: 0 0% 14.9%; 78 | --muted-foreground: 0 0% 63.9%; 79 | 80 | --accent: 0 0% 14.9%; 81 | --accent-foreground: 0 0% 98%; 82 | 83 | --destructive: 0 62.8% 30.6%; 84 | --destructive-foreground: 0 0% 98%; 85 | 86 | --border: 0 0% 14.9%; 87 | --input: 0 0% 14.9%; 88 | --ring: 0 0% 83.1%; 89 | } 90 | } 91 | 92 | -------------------------------------------------------------------------------- /src/widgets/landing/ui/hero.tsx: -------------------------------------------------------------------------------- 1 | import { Play } from "lucide-react"; 2 | import Image from "next/image"; 3 | import Link from "next/link"; 4 | 5 | export function Hero() { 6 | return ( 7 | <div className="flex w-full justify-center gap-12 flex-col items-center"> 8 | <div className="circle-gradient absolute left-1/2 -top-32 md:top-0 -translate-x-1/2"></div> 9 | <div className="mt-24 flex flex-col items-center gap-8"> 10 | <h1 className="text-center text-3xl font-extrabold text-balance max-w-3xl leading-tight md:text-5xl lg:text-[60px]"> 11 | <span className="bg-gradient-to-r from-emerald-400 to-emerald-500 bg-clip-text font-bold text-transparent"> 12 | Create 13 | </span>{" "} 14 | a web business card in{" "} 15 | <span className="bg-gradient-to-r from-emerald-400 to-emerald-600 bg-clip-text font-bold text-transparent"> 16 | 30 17 | </span>{" "} 18 | seconds 19 | </h1> 20 | <p className="items-center text-balance max-w-72 text-center text-sm sm:text-base lg:text-xl font-thin text-zinc-400"> 21 | A unique service for your professional online presence 22 | </p> 23 | <div className="flex gap-6"> 24 | <Link 25 | href="/dashboard" 26 | className="rounded-3xl border border-emerald-400 bg-gradient-to-r from-emerald-400 from-20% to-emerald-600 to-100% py-2 px-6 lg:px-8 lg:py-3 font-semibold transition duration-200 hover:opacity-90" 27 | > 28 | Try demo 29 | </Link> 30 | <Link 31 | href="#what" 32 | scroll={true} 33 | className="flex items-center gap-2 rounded-3xl border border-neutral-500 py-2 px-6 lg:px-8 lg:py-3 font-semibold transition duration-200 hover:opacity-90" 34 | > 35 | How it works 36 | <Play size={18} /> 37 | </Link> 38 | </div> 39 | </div> 40 | <Image 41 | alt="hero" 42 | src="/hero.png" 43 | width={500} 44 | height={500} 45 | className="w-5/6 rounded-2xl border-4 border-emerald-300/20" 46 | priority 47 | ></Image> 48 | </div> 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/features/brick-edit/editors/air.tsx: -------------------------------------------------------------------------------- 1 | import { Air } from "@/features/bricks"; 2 | import { Brick } from "@/entities/pages"; 3 | import { Wrapper } from "../ui/wrapper"; 4 | import { EditDialog } from "../ui/dialog"; 5 | import { DialogClose, DialogFooter } from "@/shared/ui/dialog"; 6 | import { Button } from "@/shared/ui/button"; 7 | import { Input } from "@/shared/ui/input"; 8 | import { useCurrent } from "../hooks/useCurrent"; 9 | import { useState } from "react"; 10 | 11 | export const AirEditor = (props: Brick) => { 12 | const { handleChangeBrick, handleDeleteBrick } = useCurrent(); 13 | const [value, setValue] = useState(props.payload); 14 | 15 | const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { 16 | const value = Number(event.target.value); 17 | if (!isNaN(value)) { 18 | setValue(event.target.value); 19 | } 20 | }; 21 | 22 | const handleSave = () => { 23 | const { payload, ...newBrick } = props; 24 | handleChangeBrick({ ...newBrick, payload: value }); 25 | }; 26 | 27 | const handleClose = () => { 28 | setValue(props.payload); 29 | }; 30 | 31 | const handleDelete = () => { 32 | handleDeleteBrick(props.id); 33 | }; 34 | return ( 35 | <Wrapper id={props.id}> 36 | <Air {...props} /> 37 | <EditDialog title="Edit Air"> 38 | <Input value={value} onChange={handleChange} /> 39 | <DialogFooter className="w-full flex flex-row justify-between gap-2 sm:justify-between"> 40 | <DialogClose asChild> 41 | <Button type="button" variant="destructive" onClick={handleDelete}> 42 | Delete 43 | </Button> 44 | </DialogClose> 45 | <div className="flex gap-2"> 46 | <DialogClose asChild> 47 | <Button type="button" variant="secondary" onClick={handleClose}> 48 | Close 49 | </Button> 50 | </DialogClose> 51 | <DialogClose asChild> 52 | <Button type="button" variant="default" onClick={handleSave}> 53 | Save 54 | </Button> 55 | </DialogClose> 56 | </div> 57 | </DialogFooter> 58 | </EditDialog> 59 | </Wrapper> 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/features/brick-edit/editors/line.tsx: -------------------------------------------------------------------------------- 1 | import { Line } from "@/features/bricks"; 2 | import { Brick } from "@/entities/pages"; 3 | import { Wrapper } from "../ui/wrapper"; 4 | import { EditDialog } from "../ui/dialog"; 5 | import { DialogClose, DialogFooter } from "@/shared/ui/dialog"; 6 | import { Button } from "@/shared/ui/button"; 7 | import { useCurrent } from "../hooks/useCurrent"; 8 | import { useState } from "react"; 9 | 10 | export const LineEditor = (props: Brick) => { 11 | const { handleChangeBrick, handleDeleteBrick } = useCurrent(); 12 | const [isDashed, setDashed] = useState(props.payload === "dashed"); 13 | 14 | const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { 15 | setDashed(event.target.checked); 16 | }; 17 | 18 | const handleSave = () => { 19 | const { payload, ...newBrick } = props; 20 | handleChangeBrick({ ...newBrick, payload: isDashed ? "dashed" : "solid" }); 21 | }; 22 | 23 | const handleClose = () => { 24 | setDashed(props.payload === "dashed"); 25 | }; 26 | 27 | const handleDelete = () => { 28 | handleDeleteBrick(props.id); 29 | }; 30 | return ( 31 | <Wrapper id={props.id}> 32 | <Line {...props} /> 33 | <EditDialog title="Edit Line"> 34 | <div className="w-full flex items-center gap-2"> 35 | <input 36 | type="checkbox" 37 | id="isdashed" 38 | className="accent-emerald-500" 39 | onChange={handleChange} 40 | checked={isDashed} 41 | /> 42 | <label htmlFor="isdashed">Is dashed</label> 43 | </div> 44 | <DialogFooter className="w-full flex flex-row justify-between gap-2 sm:justify-between"> 45 | <DialogClose asChild> 46 | <Button type="button" variant="destructive" onClick={handleDelete}> 47 | Delete 48 | </Button> 49 | </DialogClose> 50 | <div className="flex gap-2"> 51 | <DialogClose asChild> 52 | <Button type="button" variant="secondary" onClick={handleClose}> 53 | Close 54 | </Button> 55 | </DialogClose> 56 | <DialogClose asChild> 57 | <Button type="button" variant="default" onClick={handleSave}> 58 | Save 59 | </Button> 60 | </DialogClose> 61 | </div> 62 | </DialogFooter> 63 | </EditDialog> 64 | </Wrapper> 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/features/auth/hooks/useLogin.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { z } from "zod"; 5 | 6 | const EmailSchema = z.string().email(); 7 | const PassSchema = z.string().min(6); 8 | 9 | export function useLogin() { 10 | const [value, setValue] = useState({ 11 | email: "", 12 | password: "", 13 | }); 14 | const [error, setError] = useState({ 15 | email: "", 16 | password: "", 17 | }); 18 | const [blurChecked, setBlurChecked] = useState({ 19 | email: false, 20 | password: false, 21 | }); 22 | 23 | useEffect(() => { 24 | if (blurChecked.email) { 25 | validateEmail(); 26 | } 27 | if (blurChecked.password) { 28 | validatePass(); 29 | } 30 | }, [value]); 31 | 32 | function validateEmail() { 33 | const EmailResult = EmailSchema.safeParse(value.email); 34 | if (!EmailResult.success) { 35 | setError((prev) => ({ 36 | ...prev, 37 | email: EmailResult.error.issues[0].message, 38 | })); 39 | } else { 40 | setError((prev) => ({ 41 | ...prev, 42 | email: "", 43 | })); 44 | } 45 | } 46 | 47 | const validatePass = () => { 48 | const PassResult = PassSchema.safeParse(value.password); 49 | if (!PassResult.success) { 50 | setError((prev) => ({ 51 | ...prev, 52 | password: PassResult.error.issues[0].message, 53 | })); 54 | } else { 55 | setError((prev) => ({ 56 | ...prev, 57 | password: "", 58 | })); 59 | } 60 | }; 61 | 62 | function handleEmailBlur() { 63 | if (value.email === "") return; 64 | validateEmail(); 65 | setBlurChecked((prev) => ({ 66 | ...prev, 67 | email: true, 68 | })); 69 | } 70 | 71 | function handlePassBlur() { 72 | if (value.password === "") return; 73 | validatePass(); 74 | setBlurChecked((prev) => ({ 75 | ...prev, 76 | password: true, 77 | })); 78 | } 79 | 80 | function handleEmailChange(e: React.ChangeEvent<HTMLInputElement>) { 81 | const email = e.target.value; 82 | setValue((prev) => ({ 83 | ...prev, 84 | email, 85 | })); 86 | } 87 | 88 | function handlePasswordChange(e: React.ChangeEvent<HTMLInputElement>) { 89 | const password = e.target.value; 90 | setValue((prev) => ({ 91 | ...prev, 92 | password, 93 | })); 94 | } 95 | 96 | return [ 97 | value, 98 | error, 99 | handleEmailChange, 100 | handlePasswordChange, 101 | handleEmailBlur, 102 | handlePassBlur, 103 | ] as const; 104 | } 105 | -------------------------------------------------------------------------------- /src/api/page.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { baseUrl } from "."; 3 | import { Page, PageMeta, PageUser } from "@/entities/pages"; 4 | 5 | const pageSchema = z.object({ 6 | id: z.string(), 7 | title: z.string(), 8 | address: z.string(), 9 | theme: z.string(), 10 | favicon: z.string(), 11 | bricks: z.array( 12 | z.object({ 13 | id: z.string(), 14 | type: z.string(), 15 | payload: z.string(), 16 | params: z.string(), 17 | children: z.array(z.string()), 18 | }) 19 | ), 20 | user: z.string(), 21 | }); 22 | 23 | const pageMetaSchema = pageSchema.omit({ bricks: true }); 24 | 25 | const pagesSchema = z.array(pageSchema); 26 | 27 | export async function getAll(token: string) { 28 | const res = await fetch(`${baseUrl}/page`, { 29 | method: "GET", 30 | headers: { 31 | "Content-Type": "application/json", 32 | Authorization: token, 33 | }, 34 | cache: "no-store", 35 | }); 36 | const data: PageUser[] = await res.json(); 37 | const pages = data ? data : []; 38 | return pagesSchema.parse(pages); 39 | } 40 | 41 | export async function getOne(id: string) { 42 | const res = await fetch(`${baseUrl}/page/${id}`, { 43 | method: "GET", 44 | headers: { 45 | "Content-Type": "application/json", 46 | }, 47 | cache: "no-store", 48 | }); 49 | 50 | const data = await res.json(); 51 | return pageSchema.parse(data); 52 | } 53 | 54 | export async function create(token: string, page: Page) { 55 | const res = await fetch(`${baseUrl}/page`, { 56 | method: "POST", 57 | headers: { 58 | "Content-Type": "application/json", 59 | Authorization: token, 60 | }, 61 | cache: "no-store", 62 | body: JSON.stringify(page), 63 | }); 64 | const data = await res.json(); 65 | return pageSchema.parse(data); 66 | } 67 | 68 | export async function update(token: string, page: Page) { 69 | const res = await fetch(`${baseUrl}/page`, { 70 | method: "PUT", 71 | headers: { 72 | "Content-Type": "application/json", 73 | Authorization: token, 74 | }, 75 | cache: "no-store", 76 | body: JSON.stringify(page), 77 | }); 78 | const data: Page = await res.json(); 79 | return pageSchema.parse(data); 80 | } 81 | 82 | export async function updateMeta(token: string, page: PageMeta) { 83 | const res = await fetch(`${baseUrl}/page`, { 84 | method: "PATCH", 85 | headers: { 86 | "Content-Type": "application/json", 87 | Authorization: token, 88 | }, 89 | body: JSON.stringify(page), 90 | }); 91 | const data: Page = await res.json(); 92 | return pageMetaSchema.parse(data); 93 | } 94 | 95 | const checkSchema = z.object({ 96 | exists: z.boolean(), 97 | }); 98 | 99 | export async function check(id: string) { 100 | const res = await fetch(`${baseUrl}/address/${id}`, { 101 | method: "GET", 102 | headers: { 103 | "Content-Type": "application/json", 104 | }, 105 | }); 106 | const data = await res.json(); 107 | return checkSchema.parse(data); 108 | } 109 | -------------------------------------------------------------------------------- /src/features/brick-edit/editors/picture.tsx: -------------------------------------------------------------------------------- 1 | import { Picture } from "@/features/bricks"; 2 | import { Brick } from "@/entities/pages"; 3 | import { Wrapper } from "../ui/wrapper"; 4 | import { EditDialog } from "../ui/dialog"; 5 | import { DialogClose, DialogFooter } from "@/shared/ui/dialog"; 6 | import { Button } from "@/shared/ui/button"; 7 | import { useCurrent } from "../hooks/useCurrent"; 8 | import { useEffect, useState } from "react"; 9 | import { Upload } from "@/features/upload"; 10 | import { Trash } from "lucide-react"; 11 | 12 | export const PictureEditor = (props: Brick) => { 13 | const { handleChangeBrick, handleDeleteBrick } = useCurrent(); 14 | const [url, setUrl] = useState(props.payload); 15 | 16 | const handleSave = () => { 17 | const { payload, ...newBrick } = props; 18 | handleChangeBrick({ ...newBrick, payload: url }); 19 | }; 20 | 21 | const handleClose = () => { 22 | setUrl(props.payload); 23 | }; 24 | 25 | const handleDelete = () => { 26 | handleDeleteBrick(props.id); 27 | }; 28 | return ( 29 | <Wrapper id={props.id}> 30 | <Picture {...props} /> 31 | <EditDialog title="Edit Picture"> 32 | {url ? ( 33 | <DeletePicture handleChange={setUrl}> 34 | <Picture {...props} maxHight={500} url={url} /> 35 | </DeletePicture> 36 | ) : ( 37 | <UploadPicture value={url} handleChange={setUrl} /> 38 | )} 39 | <DialogFooter className="w-full flex flex-row justify-between gap-2 sm:justify-between"> 40 | <DialogClose asChild> 41 | <Button type="button" variant="destructive" onClick={handleDelete}> 42 | Delete 43 | </Button> 44 | </DialogClose> 45 | <div className="flex gap-2"> 46 | <DialogClose asChild> 47 | <Button type="button" variant="secondary" onClick={handleClose}> 48 | Close 49 | </Button> 50 | </DialogClose> 51 | <DialogClose asChild> 52 | <Button type="button" variant="default" onClick={handleSave}> 53 | Save 54 | </Button> 55 | </DialogClose> 56 | </div> 57 | </DialogFooter> 58 | </EditDialog> 59 | </Wrapper> 60 | ); 61 | }; 62 | 63 | function UploadPicture({ 64 | value, 65 | handleChange, 66 | }: { 67 | value: string; 68 | handleChange: (value: string) => void; 69 | }) { 70 | return ( 71 | <div className="w-full flex flex-col gap-2"> 72 | <label className="text-neutral-400">Picture</label> 73 | <Upload label="Upload your picture" onUploadComplete={handleChange} /> 74 | </div> 75 | ); 76 | } 77 | 78 | function DeletePicture(props: { 79 | children: React.ReactNode; 80 | handleChange: (value: string) => void; 81 | }) { 82 | return ( 83 | <div className="relative"> 84 | <div 85 | onClick={() => props.handleChange("")} 86 | className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-xl p-2 bg-neutral-700 hover:bg-neutral-900" 87 | > 88 | <Trash className="text-white" /> 89 | </div> 90 | {props.children} 91 | </div> 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/features/brick-edit/editors/click.tsx: -------------------------------------------------------------------------------- 1 | import { Click, ParseClickParams } from "@/features/bricks"; 2 | import { Brick } from "@/entities/pages"; 3 | import { Wrapper } from "../ui/wrapper"; 4 | import { EditDialog } from "../ui/dialog"; 5 | import { DialogClose, DialogFooter } from "@/shared/ui/dialog"; 6 | import { Button } from "@/shared/ui/button"; 7 | import { Input } from "@/shared/ui/input"; 8 | import { useCurrent } from "../hooks/useCurrent"; 9 | import { useEffect, useState } from "react"; 10 | 11 | export const ClickEditor = (props: Brick) => { 12 | const { handleChangeBrick, handleDeleteBrick } = useCurrent(); 13 | const [text, setText] = useState(props.payload); 14 | const [url, setUrl] = useState(""); 15 | 16 | useEffect(() => { 17 | const params = ParseClickParams(props.params); 18 | setUrl(params.url); 19 | }, []); 20 | 21 | const handleSave = () => { 22 | const { payload, params, ...newBrick } = props; 23 | const newParams = JSON.stringify({ url: url }); 24 | handleChangeBrick({ ...newBrick, payload: text, params: newParams }); 25 | }; 26 | 27 | const handleClose = () => { 28 | const params = ParseClickParams(props.params); 29 | setText(props.payload); 30 | setUrl(params.url); 31 | }; 32 | 33 | const handleDelete = () => { 34 | handleDeleteBrick(props.id); 35 | }; 36 | return ( 37 | <Wrapper id={props.id}> 38 | <Click {...props} /> 39 | <EditDialog title="Edit Title"> 40 | <EditText value={text} handleChange={setText} /> 41 | <EditUrl value={url} handleChange={setUrl} /> 42 | <DialogFooter className="w-full flex flex-row justify-between gap-2 sm:justify-between"> 43 | <DialogClose asChild> 44 | <Button type="button" variant="destructive" onClick={handleDelete}> 45 | Delete 46 | </Button> 47 | </DialogClose> 48 | <div className="flex gap-2"> 49 | <DialogClose asChild> 50 | <Button type="button" variant="secondary" onClick={handleClose}> 51 | Close 52 | </Button> 53 | </DialogClose> 54 | <DialogClose asChild> 55 | <Button type="button" variant="default" onClick={handleSave}> 56 | Save 57 | </Button> 58 | </DialogClose> 59 | </div> 60 | </DialogFooter> 61 | </EditDialog> 62 | </Wrapper> 63 | ); 64 | }; 65 | 66 | function EditText({ 67 | value, 68 | handleChange, 69 | }: { 70 | value: string; 71 | handleChange: (value: string) => void; 72 | }) { 73 | return ( 74 | <div className="w-full flex flex-col gap-2"> 75 | <label className="text-neutral-400">Text</label> 76 | <Input value={value} onChange={(e) => handleChange(e.target.value)} /> 77 | </div> 78 | ); 79 | } 80 | 81 | function EditUrl({ 82 | value, 83 | handleChange, 84 | }: { 85 | value: string; 86 | handleChange: (value: string) => void; 87 | }) { 88 | return ( 89 | <div className="w-full flex flex-col gap-2"> 90 | <label className="text-neutral-400">URL address</label> 91 | <Input value={value} onChange={(e) => handleChange(e.target.value)} /> 92 | </div> 93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /src/features/bricks/ui/click.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Brick } from "@/entities/pages"; 4 | import Link from "next/link"; 5 | import { 6 | Github, 7 | Web, 8 | Telegram, 9 | X, 10 | Tiktok, 11 | Instagram, 12 | Twitch, 13 | WhatsApp, 14 | Youtube, 15 | Facebook, 16 | } from "../svg/icons"; 17 | 18 | const iconsLink = { 19 | github: ["https://github.com", "github.com"], 20 | telegram: ["https://t.me", "telegram.me", "t.me"], 21 | tiktok: ["https://www.tiktok.com", "tiktok.com"], 22 | instagram: ["https://www.instagram.com", "instagram.com"], 23 | twitch: ["https://www.twitch.tv", "twitch.tv"], 24 | whatsapp: ["https://wa.me", "whatsapp.com", "wa.me"], 25 | youtube: ["https://www.youtube.com", "youtube.com"], 26 | facebook: ["https://www.facebook.com", "facebook.com"], 27 | x: ["https://x.com", "x.com", "https://twitter.com", "twitter.com"], 28 | }; 29 | 30 | function findIcon(url: string) { 31 | for (const [key, value] of Object.entries(iconsLink)) { 32 | for (let i = 0; i < value.length; i++) { 33 | if (url?.startsWith(value[i])) { 34 | return key; 35 | } 36 | } 37 | } 38 | return "web"; 39 | } 40 | 41 | function Balance(props: { url: string }) { 42 | const icon = findIcon(props.url); 43 | switch (icon) { 44 | case "github": 45 | return <Github className="w-6 h-6" />; 46 | case "web": 47 | return <Web className="w-6 h-6" />; 48 | case "telegram": 49 | return <Telegram className="w-6 h-6" />; 50 | case "tiktok": 51 | return <Tiktok className="w-6 h-6" />; 52 | case "instagram": 53 | return <Instagram className="w-6 h-6" />; 54 | case "twitch": 55 | return <Twitch className="w-6 h-6" />; 56 | case "whatsapp": 57 | return <WhatsApp className="w-6 h-6" />; 58 | case "youtube": 59 | return <Youtube className="w-6 h-6" />; 60 | case "facebook": 61 | return <Facebook className="w-6 h-6" />; 62 | case "x": 63 | return <X className="w-6 h-6" />; 64 | default: 65 | return null; 66 | } 67 | } 68 | 69 | export type ClickParams = { 70 | url: string; 71 | }; 72 | 73 | export function ParseClickParams(params: string) { 74 | try { 75 | const parsedParams: ClickParams = JSON.parse(params); 76 | return parsedParams; 77 | } catch (error) { 78 | const parsedParams: ClickParams = { url: "/" }; 79 | return parsedParams; 80 | } 81 | } 82 | 83 | export function Click(props: Brick) { 84 | const params = ParseClickParams(props.params); 85 | return ( 86 | <Link 87 | href={params.url} 88 | target="_blank" 89 | className="cursor-pointer flex w-full transition duration-200 justify-center relative border border-neutral-600 rounded-full max-w-full px-12 py-4 items-center hover:bg-neutral-100 dark:hover:bg-neutral-900" 90 | > 91 | <div className="p-2 bg-white rounded-full absolute top-1/2 -translate-y-1/2 left-2"> 92 | <Balance url={params.url} /> 93 | </div> 94 | <span className="text-xl font-medium text-neutral-800 dark:text-white"> 95 | {props.payload} 96 | </span> 97 | </Link> 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /src/features/pages/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogClose, 4 | DialogContent, 5 | DialogFooter, 6 | DialogHeader, 7 | DialogTitle, 8 | DialogTrigger, 9 | } from "@/shared/ui/dialog"; 10 | import { 11 | Select, 12 | SelectContent, 13 | SelectItem, 14 | SelectTrigger, 15 | SelectValue, 16 | } from "@/shared/ui/select"; 17 | 18 | import { Input } from "@/shared/ui/input"; 19 | import { Page } from "@/entities/pages"; 20 | import { useState } from "react"; 21 | import { AddressChecker } from "@/features/check"; 22 | 23 | type DialogProps = { 24 | page: Page; 25 | onSave: (title: string, address: string, theme: string) => void; 26 | children: React.ReactNode; 27 | }; 28 | 29 | export function PageDialog(props: DialogProps) { 30 | const [title, setTitle] = useState(props.page.title); 31 | const [address, setAddress] = useState(props.page.address); 32 | const [theme, setTheme] = useState(props.page.theme); 33 | 34 | return ( 35 | <Dialog> 36 | <DialogTrigger asChild>{props.children}</DialogTrigger> 37 | <DialogContent className="flex flex-col gap-6"> 38 | <DialogHeader> 39 | <DialogTitle>Add new page</DialogTitle> 40 | </DialogHeader> 41 | <div className="w-full flex flex-col gap-2"> 42 | <span>Title</span> 43 | <Input 44 | value={title} 45 | onChange={(e) => { 46 | setTitle(e.target.value); 47 | }} 48 | /> 49 | </div> 50 | <div className="w-full flex flex-col gap-2"> 51 | <span>Address</span> 52 | <div className="relative"> 53 | <span className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-500"> 54 | / 55 | </span> 56 | <Input 57 | value={address} 58 | className="pl-8" 59 | onChange={(e) => { 60 | setAddress(e.target.value); 61 | }} 62 | /> 63 | <AddressChecker 64 | address={address} 65 | defaultAddress={props.page.address} 66 | /> 67 | </div> 68 | </div> 69 | <div className="w-full flex flex-col gap-2"> 70 | <span>Theme</span> 71 | <Select 72 | onValueChange={(value) => setTheme(value)} 73 | defaultValue={theme} 74 | > 75 | <SelectTrigger className="w-[180px]"> 76 | <SelectValue placeholder="Theme" /> 77 | </SelectTrigger> 78 | <SelectContent> 79 | <SelectItem value="light">Light</SelectItem> 80 | <SelectItem value="dark">Dark</SelectItem> 81 | </SelectContent> 82 | </Select> 83 | </div> 84 | <DialogFooter className="flex justify-end gap-6 w-full items-center"> 85 | <DialogClose asChild> 86 | <button className="px-8 py-2 rounded bg-neutral-700 text-neutral-100"> 87 | Cancel 88 | </button> 89 | </DialogClose> 90 | <DialogClose asChild> 91 | <button 92 | className="px-8 py-2 rounded bg-white text-neutral-900" 93 | onClick={() => { 94 | props.onSave(title, address, theme); 95 | }} 96 | > 97 | Save 98 | </button> 99 | </DialogClose> 100 | </DialogFooter> 101 | </DialogContent> 102 | </Dialog> 103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /src/features/auth/hooks/useRegister.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { z } from "zod"; 5 | 6 | const EmailSchema = z.string().email(); 7 | const PassSchema = z.string().min(8); 8 | const NameSchema = z.string().min(3); 9 | 10 | export function useRegister() { 11 | const [value, setValue] = useState({ 12 | email: "", 13 | password: "", 14 | name: "", 15 | }); 16 | const [error, setError] = useState({ 17 | email: "", 18 | password: "", 19 | name: "", 20 | }); 21 | const [blurChecked, setBlurChecked] = useState({ 22 | email: false, 23 | password: false, 24 | name: false, 25 | }); 26 | 27 | useEffect(() => { 28 | if (blurChecked.email) { 29 | validateEmail(); 30 | } 31 | if (blurChecked.password) { 32 | validatePass(); 33 | } 34 | if (blurChecked.name) { 35 | validateName(); 36 | } 37 | }, [value]); 38 | 39 | function validateEmail() { 40 | const EmailResult = EmailSchema.safeParse(value.email); 41 | if (!EmailResult.success) { 42 | setError((prev) => ({ 43 | ...prev, 44 | email: EmailResult.error.issues[0].message, 45 | })); 46 | } else { 47 | setError((prev) => ({ 48 | ...prev, 49 | email: "", 50 | })); 51 | } 52 | } 53 | 54 | function validateName() { 55 | const NameResult = NameSchema.safeParse(value.name); 56 | if (!NameResult.success) { 57 | setError((prev) => ({ 58 | ...prev, 59 | name: NameResult.error.issues[0].message, 60 | })); 61 | } else { 62 | setError((prev) => ({ 63 | ...prev, 64 | name: "", 65 | })); 66 | } 67 | } 68 | 69 | const validatePass = () => { 70 | const PassResult = PassSchema.safeParse(value.password); 71 | if (!PassResult.success) { 72 | setError((prev) => ({ 73 | ...prev, 74 | password: PassResult.error.issues[0].message, 75 | })); 76 | } else { 77 | setError((prev) => ({ 78 | ...prev, 79 | password: "", 80 | })); 81 | } 82 | }; 83 | 84 | function handleEmailBlur() { 85 | if (value.email === "") return; 86 | validateEmail(); 87 | setBlurChecked((prev) => ({ 88 | ...prev, 89 | email: true, 90 | })); 91 | } 92 | 93 | function handleNameBlur() { 94 | if (value.name === "") return; 95 | validateName(); 96 | setBlurChecked((prev) => ({ 97 | ...prev, 98 | name: true, 99 | })); 100 | } 101 | 102 | function handlePassBlur() { 103 | if (value.password === "") return; 104 | validatePass(); 105 | setBlurChecked((prev) => ({ 106 | ...prev, 107 | password: true, 108 | })); 109 | } 110 | 111 | function handleEmailChange(e: React.ChangeEvent<HTMLInputElement>) { 112 | const email = e.target.value; 113 | 114 | setValue((prev) => ({ 115 | ...prev, 116 | email, 117 | })); 118 | } 119 | 120 | function handlePasswordChange(e: React.ChangeEvent<HTMLInputElement>) { 121 | const password = e.target.value; 122 | setValue((prev) => ({ 123 | ...prev, 124 | password, 125 | })); 126 | } 127 | 128 | function handleNameChange(e: React.ChangeEvent<HTMLInputElement>) { 129 | const name = e.target.value; 130 | setValue((prev) => ({ 131 | ...prev, 132 | name, 133 | })); 134 | } 135 | 136 | return [ 137 | value, 138 | error, 139 | handleEmailChange, 140 | handlePasswordChange, 141 | handleNameChange, 142 | handleEmailBlur, 143 | handlePassBlur, 144 | handleNameBlur, 145 | ] as const; 146 | } 147 | -------------------------------------------------------------------------------- /src/widgets/auth/ui/login.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useLogin, useUser, Loader } from "@/features/auth"; 4 | import { login } from "@/api/auth"; 5 | import { useMutation } from "react-query"; 6 | import { useRouter } from "next/navigation"; 7 | import Link from "next/link"; 8 | import { SetToken } from "@/shared/lib/token"; 9 | 10 | export function Login() { 11 | const router = useRouter(); 12 | const { mutate, isLoading } = useMutation( 13 | () => login(value.email, value.password), 14 | { 15 | onSuccess: (data) => { 16 | if (!data.Authorization) return; 17 | SetToken(data.Authorization); 18 | setUser({ 19 | email: data.email, 20 | name: data.name, 21 | }); 22 | router.push("/"); 23 | }, 24 | } 25 | ); 26 | 27 | const { setUser } = useUser(); 28 | const [ 29 | value, 30 | error, 31 | handleEmailChange, 32 | handlePasswordChange, 33 | handleEmailBlur, 34 | handlePassBlur, 35 | ] = useLogin(); 36 | 37 | async function handleLogin() { 38 | if (!value.email || !value.password) return; 39 | mutate(); 40 | } 41 | 42 | return ( 43 | <main className="w-full min-h-dvh bg-neutral-900 flex justify-center items-center"> 44 | <div className="max-w-md w-full border border-neutral-700 p-6 flex flex-col items-start gap-8 rounded-xl py-16 mx-4"> 45 | <div className="text-neutral-100 flex flex-col gap-4"> 46 | <h1 className="text-4xl font-bold">Welcome to Tap!</h1> 47 | <p className="text-neutral-400 font-light"> 48 | Enter your email and password 49 | </p> 50 | </div> 51 | <div className="flex flex-col gap-2 w-full"> 52 | <label className="text-zinc-100 font-light">Email</label> 53 | <input 54 | type="text" 55 | className="p-2 pl-4 w-full rounded-lg border border-neutral-700 bg-transparent text-white focus:outline-none placeholder:text-neutral-600 placeholder:font-extralight" 56 | placeholder="Enter your email" 57 | onChange={handleEmailChange} 58 | value={value.email} 59 | onBlur={handleEmailBlur} 60 | /> 61 | <div className="text-red-500 text-xs font-light">{error.email}</div> 62 | </div> 63 | <div className="flex flex-col gap-2 w-full"> 64 | <label className="text-zinc-100 font-light">Password</label> 65 | <input 66 | type="text" 67 | className="p-2 pl-4 w-full rounded-lg border border-neutral-700 bg-transparent text-white focus:outline-none placeholder:text-neutral-600 placeholder:font-extralight" 68 | placeholder="Enter your email" 69 | onChange={handlePasswordChange} 70 | value={value.password} 71 | onBlur={handlePassBlur} 72 | /> 73 | <div className="text-red-500 text-xs font-light"> 74 | {error.password} 75 | </div> 76 | </div> 77 | <div className="w-full flex flex-col gap-3 items-center"> 78 | <button 79 | onClick={handleLogin} 80 | className="hover:bg-emerald-500 bg-emerald-600 transition flex justify-center items-center text-white py-2 w-full border border-emerald-400 rounded-lg disabled:bg-neutral-700 disabled:text-neutral-400 disabled:border-none disabled:hover:bg-neutral-700 disabled:cursor-not-allowed" 81 | disabled={error.email !== "" || error.password !== "" || isLoading} 82 | > 83 | {isLoading ? <Loader /> : "Submit"} 84 | </button> 85 | <Link 86 | className="bg-neutral-600 text-white py-2 w-full border border-neutral-500 rounded-lg flex justify-center items-center" 87 | href="https://tap-backend-awiw.onrender.com/auth/google" 88 | > 89 | Signin with Google 90 | </Link> 91 | </div> 92 | 93 | <span className="flex w-full justify-center text-neutral-500 font-light"> 94 | Don't have an account? 95 | <Link href="/register" className="text-neutral-300 pl-2"> 96 | Register 97 | </Link> 98 | </span> 99 | </div> 100 | </main> 101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /src/shared/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/shared/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef<typeof DialogPrimitive.Overlay>, 19 | React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> 20 | >(({ className, ...props }, ref) => ( 21 | <DialogPrimitive.Overlay 22 | ref={ref} 23 | className={cn( 24 | "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", 25 | className 26 | )} 27 | {...props} 28 | /> 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef<typeof DialogPrimitive.Content>, 34 | React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> 35 | >(({ className, children, ...props }, ref) => ( 36 | <DialogPortal> 37 | <DialogOverlay /> 38 | <DialogPrimitive.Content 39 | ref={ref} 40 | className={cn( 41 | "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", 42 | className 43 | )} 44 | {...props} 45 | > 46 | {children} 47 | <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> 48 | <X className="h-4 w-4" /> 49 | <span className="sr-only">Close</span> 50 | </DialogPrimitive.Close> 51 | </DialogPrimitive.Content> 52 | </DialogPortal> 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes<HTMLDivElement>) => ( 60 | <div 61 | className={cn( 62 | "flex flex-col space-y-1.5 text-center sm:text-left", 63 | className 64 | )} 65 | {...props} 66 | /> 67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes<HTMLDivElement>) => ( 74 | <div 75 | className={cn( 76 | "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", 77 | className 78 | )} 79 | {...props} 80 | /> 81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef<typeof DialogPrimitive.Title>, 86 | React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> 87 | >(({ className, ...props }, ref) => ( 88 | <DialogPrimitive.Title 89 | ref={ref} 90 | className={cn( 91 | "text-lg font-semibold leading-none tracking-tight", 92 | className 93 | )} 94 | {...props} 95 | /> 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef<typeof DialogPrimitive.Description>, 101 | React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> 102 | >(({ className, ...props }, ref) => ( 103 | <DialogPrimitive.Description 104 | ref={ref} 105 | className={cn("text-sm text-muted-foreground", className)} 106 | {...props} 107 | /> 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogClose, 116 | DialogTrigger, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /src/features/brick-edit/editors/title.tsx: -------------------------------------------------------------------------------- 1 | import { Title, TitleParams, ParseTitleParams } from "@/features/bricks"; 2 | import { Brick } from "@/entities/pages"; 3 | import { Wrapper } from "../ui/wrapper"; 4 | import { EditDialog } from "../ui/dialog"; 5 | import { DialogClose, DialogFooter } from "@/shared/ui/dialog"; 6 | import { Button } from "@/shared/ui/button"; 7 | import { Input } from "@/shared/ui/input"; 8 | import { useCurrent } from "../hooks/useCurrent"; 9 | import { useEffect, useState } from "react"; 10 | import { 11 | Select, 12 | SelectContent, 13 | SelectItem, 14 | SelectTrigger, 15 | SelectValue, 16 | } from "@/shared/ui/select"; 17 | 18 | export const TitleEditor = (props: Brick) => { 19 | const { handleChangeBrick, handleDeleteBrick } = useCurrent(); 20 | const [title, setTitle] = useState(props.payload); 21 | const [size, setSize] = useState(""); 22 | const [align, setAlign] = useState(""); 23 | 24 | useEffect(() => { 25 | const params = ParseTitleParams(props.params); 26 | setSize(params.size); 27 | setAlign(params.align); 28 | }, []); 29 | 30 | const handleSave = () => { 31 | const { payload, params, ...newBrick } = props; 32 | const newParams = JSON.stringify({ size, align }); 33 | handleChangeBrick({ ...newBrick, payload: title, params: newParams }); 34 | }; 35 | 36 | const handleClose = () => { 37 | setTitle(props.payload); 38 | }; 39 | 40 | const handleDelete = () => { 41 | handleDeleteBrick(props.id); 42 | }; 43 | return ( 44 | <Wrapper id={props.id}> 45 | <Title {...props} /> 46 | <EditDialog title="Edit Title"> 47 | <EditTitle value={title} handleChange={setTitle} /> 48 | <EditSize value={size} handleChange={setSize} /> 49 | <EditAlign value={align} handleChange={setAlign} /> 50 | <DialogFooter className="w-full flex flex-row justify-between gap-2 sm:justify-between"> 51 | <DialogClose asChild> 52 | <Button type="button" variant="destructive" onClick={handleDelete}> 53 | Delete 54 | </Button> 55 | </DialogClose> 56 | <div className="flex gap-2"> 57 | <DialogClose asChild> 58 | <Button type="button" variant="secondary" onClick={handleClose}> 59 | Close 60 | </Button> 61 | </DialogClose> 62 | <DialogClose asChild> 63 | <Button type="button" variant="default" onClick={handleSave}> 64 | Save 65 | </Button> 66 | </DialogClose> 67 | </div> 68 | </DialogFooter> 69 | </EditDialog> 70 | </Wrapper> 71 | ); 72 | }; 73 | 74 | function EditTitle({ 75 | value, 76 | handleChange, 77 | }: { 78 | value: string; 79 | handleChange: (value: string) => void; 80 | }) { 81 | return ( 82 | <div className="w-full flex flex-col gap-2"> 83 | <label className="text-neutral-400">Title</label> 84 | <Input value={value} onChange={(e) => handleChange(e.target.value)} /> 85 | </div> 86 | ); 87 | } 88 | 89 | function EditSize({ 90 | value, 91 | handleChange, 92 | }: { 93 | value: string; 94 | handleChange: (value: string) => void; 95 | }) { 96 | return ( 97 | <div className="w-full flex flex-col gap-2"> 98 | <label className="text-neutral-400">Size</label> 99 | <Select 100 | value={value} 101 | onValueChange={(e) => handleChange(e)} 102 | defaultValue="small" 103 | > 104 | <SelectTrigger className="w-full"> 105 | <SelectValue placeholder="Select a size" /> 106 | </SelectTrigger> 107 | <SelectContent> 108 | <SelectItem value="small">Small</SelectItem> 109 | <SelectItem value="medium">Medium</SelectItem> 110 | <SelectItem value="large">Large</SelectItem> 111 | </SelectContent> 112 | </Select> 113 | </div> 114 | ); 115 | } 116 | 117 | function EditAlign({ 118 | value, 119 | handleChange, 120 | }: { 121 | value: string; 122 | handleChange: (value: string) => void; 123 | }) { 124 | return ( 125 | <div className="w-full flex flex-col gap-2"> 126 | <label className="text-neutral-400">Align</label> 127 | <Select 128 | value={value} 129 | onValueChange={(e) => handleChange(e)} 130 | defaultValue="center" 131 | > 132 | <SelectTrigger className="w-full"> 133 | <SelectValue placeholder="Select a alignment" /> 134 | </SelectTrigger> 135 | <SelectContent> 136 | <SelectItem value="left">Left</SelectItem> 137 | <SelectItem value="center">Center</SelectItem> 138 | <SelectItem value="right">Right</SelectItem> 139 | </SelectContent> 140 | </Select> 141 | </div> 142 | ); 143 | } 144 | -------------------------------------------------------------------------------- /src/features/brick-edit/editors/text.tsx: -------------------------------------------------------------------------------- 1 | import { ParseTextParams, Text } from "@/features/bricks"; 2 | import { Brick } from "@/entities/pages"; 3 | import { Wrapper } from "../ui/wrapper"; 4 | import { EditDialog } from "../ui/dialog"; 5 | import { DialogClose, DialogFooter } from "@/shared/ui/dialog"; 6 | import { Button } from "@/shared/ui/button"; 7 | import { useCurrent } from "../hooks/useCurrent"; 8 | import { useEffect, useState } from "react"; 9 | import { 10 | Select, 11 | SelectContent, 12 | SelectItem, 13 | SelectTrigger, 14 | SelectValue, 15 | } from "@/shared/ui/select"; 16 | import { Textarea } from "@/shared/ui/textarea"; 17 | 18 | export const TextEditor = (props: Brick) => { 19 | const { handleChangeBrick, handleDeleteBrick } = useCurrent(); 20 | const [text, setText] = useState(props.payload); 21 | const [size, setSize] = useState(""); 22 | const [align, setAlign] = useState(""); 23 | 24 | useEffect(() => { 25 | const params = ParseTextParams(props.params); 26 | setSize(params.size); 27 | setAlign(params.align); 28 | }, []); 29 | 30 | const handleSave = () => { 31 | const { payload, params, ...newBrick } = props; 32 | const newParams = JSON.stringify({ size, align }); 33 | handleChangeBrick({ ...newBrick, payload: text, params: newParams }); 34 | }; 35 | 36 | const handleClose = () => { 37 | setText(props.payload); 38 | const params = ParseTextParams(props.params); 39 | setSize(params.size); 40 | setAlign(params.align); 41 | }; 42 | 43 | const handleDelete = () => { 44 | handleDeleteBrick(props.id); 45 | }; 46 | return ( 47 | <Wrapper id={props.id}> 48 | <Text {...props} /> 49 | <EditDialog title="Edit Text"> 50 | <EditText value={text} handleChange={setText} /> 51 | <EditSize value={size} handleChange={setSize} /> 52 | <EditAlign value={align} handleChange={setAlign} /> 53 | <DialogFooter className="w-full flex flex-row justify-between gap-2 sm:justify-between"> 54 | <DialogClose asChild> 55 | <Button type="button" variant="destructive" onClick={handleDelete}> 56 | Delete 57 | </Button> 58 | </DialogClose> 59 | <div className="flex gap-2"> 60 | <DialogClose asChild> 61 | <Button type="button" variant="secondary" onClick={handleClose}> 62 | Close 63 | </Button> 64 | </DialogClose> 65 | <DialogClose asChild> 66 | <Button type="button" variant="default" onClick={handleSave}> 67 | Save 68 | </Button> 69 | </DialogClose> 70 | </div> 71 | </DialogFooter> 72 | </EditDialog> 73 | </Wrapper> 74 | ); 75 | }; 76 | 77 | function EditText({ 78 | value, 79 | handleChange, 80 | }: { 81 | value: string; 82 | handleChange: (value: string) => void; 83 | }) { 84 | return ( 85 | <div className="w-full flex flex-col gap-2"> 86 | <label className="text-neutral-400">Text</label> 87 | <Textarea value={value} onChange={(e) => handleChange(e.target.value)} /> 88 | </div> 89 | ); 90 | } 91 | 92 | function EditSize({ 93 | value, 94 | handleChange, 95 | }: { 96 | value: string; 97 | handleChange: (value: string) => void; 98 | }) { 99 | return ( 100 | <div className="w-full flex flex-col gap-2"> 101 | <label className="text-neutral-400">Size</label> 102 | <Select 103 | value={value} 104 | onValueChange={(e) => handleChange(e)} 105 | defaultValue="small" 106 | > 107 | <SelectTrigger className="w-full"> 108 | <SelectValue placeholder="Select a size" /> 109 | </SelectTrigger> 110 | <SelectContent> 111 | <SelectItem value="small">Small</SelectItem> 112 | <SelectItem value="medium">Medium</SelectItem> 113 | <SelectItem value="large">Large</SelectItem> 114 | </SelectContent> 115 | </Select> 116 | </div> 117 | ); 118 | } 119 | 120 | function EditAlign({ 121 | value, 122 | handleChange, 123 | }: { 124 | value: string; 125 | handleChange: (value: string) => void; 126 | }) { 127 | return ( 128 | <div className="w-full flex flex-col gap-2"> 129 | <label className="text-neutral-400">Align</label> 130 | <Select 131 | value={value} 132 | onValueChange={(e) => handleChange(e)} 133 | defaultValue="center" 134 | > 135 | <SelectTrigger className="w-full"> 136 | <SelectValue placeholder="Select a alignment" /> 137 | </SelectTrigger> 138 | <SelectContent> 139 | <SelectItem value="left">Left</SelectItem> 140 | <SelectItem value="center">Center</SelectItem> 141 | <SelectItem value="right">Right</SelectItem> 142 | </SelectContent> 143 | </Select> 144 | </div> 145 | ); 146 | } 147 | -------------------------------------------------------------------------------- /src/widgets/auth/ui/register.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRegister, useUser, Loader } from "@/features/auth"; 4 | import { useMutation } from "react-query"; 5 | import { register } from "@/api/auth"; 6 | import { SetToken } from "@/shared/lib/token"; 7 | import { useRouter } from "next/navigation"; 8 | import Link from "next/link"; 9 | 10 | export function Register() { 11 | const { setUser } = useUser(); 12 | const router = useRouter(); 13 | const mutation = useMutation( 14 | () => register(value.name, value.email, value.password), 15 | { 16 | onSuccess: (data) => { 17 | if (!data.Authorization) return; 18 | SetToken(data.Authorization); 19 | setUser({ 20 | email: data.email, 21 | name: data.name, 22 | }); 23 | router.push("/"); 24 | }, 25 | } 26 | ); 27 | const [ 28 | value, 29 | error, 30 | handleEmailChange, 31 | handlePasswordChange, 32 | handleNameChange, 33 | handleEmailBlur, 34 | handlePassBlur, 35 | handleNameBlur, 36 | ] = useRegister(); 37 | 38 | async function handleRegister() { 39 | if (!value.email || !value.password || !value.name) return; 40 | mutation.mutate(); 41 | } 42 | 43 | return ( 44 | <main className="w-full min-h-dvh bg-neutral-900 flex justify-center items-center"> 45 | <div className="max-w-md w-full border border-neutral-700 p-6 flex flex-col items-start gap-4 rounded-xl py-16 mx-4"> 46 | <div className="text-neutral-100 flex flex-col gap-4"> 47 | <h1 className="text-4xl font-bold">Welcome to Tap!</h1> 48 | <p className="text-neutral-400 font-light"> 49 | Enter your name, email and password 50 | </p> 51 | </div> 52 | <div className="flex flex-col gap-2 w-full"> 53 | <label className="text-zinc-100 font-light">Your name</label> 54 | <input 55 | type="text" 56 | className="p-2 pl-4 w-full rounded-lg border border-neutral-700 bg-transparent text-white focus:outline-none placeholder:text-neutral-600 placeholder:font-extralight" 57 | placeholder="Enter your name" 58 | onChange={handleNameChange} 59 | value={value.name} 60 | onBlur={handleNameBlur} 61 | /> 62 | <div className="text-red-500 text-xs font-light">{error.name}</div> 63 | </div> 64 | <div className="flex flex-col gap-2 w-full"> 65 | <label className="text-zinc-100 font-light">Email</label> 66 | <input 67 | type="text" 68 | className="p-2 pl-4 w-full rounded-lg border border-neutral-700 bg-transparent text-white focus:outline-none placeholder:text-neutral-600 placeholder:font-extralight" 69 | placeholder="Enter your email" 70 | onChange={handleEmailChange} 71 | value={value.email} 72 | onBlur={handleEmailBlur} 73 | /> 74 | <div className="text-red-500 text-xs font-light">{error.email}</div> 75 | </div> 76 | <div className="flex flex-col gap-2 w-full"> 77 | <label className="text-zinc-100 font-light">Password</label> 78 | <input 79 | type="text" 80 | className="p-2 pl-4 w-full rounded-lg border border-neutral-700 bg-transparent text-white focus:outline-none placeholder:text-neutral-600 placeholder:font-extralight" 81 | placeholder="Enter your email" 82 | onChange={handlePasswordChange} 83 | value={value.password} 84 | onBlur={handlePassBlur} 85 | /> 86 | <div className="text-red-500 text-xs font-light"> 87 | {error.password} 88 | </div> 89 | </div> 90 | <div className="w-full flex flex-col gap-3 items-center"> 91 | <button 92 | onClick={handleRegister} 93 | className="hover:bg-emerald-500 bg-emerald-600 flex justify-center items-center transition text-white py-2 w-full border border-emerald-400 rounded-lg disabled:bg-neutral-700 disabled:text-neutral-400 disabled:border-none disabled:hover:bg-neutral-700 disabled:cursor-not-allowed" 94 | disabled={ 95 | error.email !== "" || 96 | error.password !== "" || 97 | error.name !== "" || 98 | mutation.isLoading 99 | } 100 | > 101 | {mutation.isLoading ? <Loader /> : "Submit"} 102 | </button> 103 | <Link 104 | className="bg-neutral-600 text-white py-2 w-full border border-neutral-500 rounded-lg flex justify-center items-center" 105 | href="https://tap-backend-awiw.onrender.com/auth/google" 106 | > 107 | Signin with Google 108 | </Link> 109 | </div> 110 | 111 | <span className="flex w-full justify-center text-neutral-500 font-light"> 112 | Already have an account? 113 | <Link href="/login" className="text-neutral-300 pl-2"> 114 | Login 115 | </Link> 116 | </span> 117 | </div> 118 | </main> 119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | import { withUt } from "uploadthing/tw"; 3 | 4 | export default withUt({ 5 | darkMode: ["class"], 6 | content: [ 7 | "./pages/**/*.{ts,tsx,mdx}", 8 | "./shared/**/*.{ts,tsx,mdx}", 9 | "./app/**/*.{ts,tsx,mdx}", 10 | "./src/**/*.{ts,tsx,mdx}", 11 | ], 12 | prefix: "", 13 | theme: { 14 | container: { 15 | center: true, 16 | padding: "2rem", 17 | screens: { 18 | "2xl": "1400px", 19 | }, 20 | }, 21 | extend: { 22 | colors: { 23 | border: "hsl(var(--border))", 24 | input: "hsl(var(--input))", 25 | ring: "hsl(var(--ring))", 26 | background: "hsl(var(--background))", 27 | foreground: "hsl(var(--foreground))", 28 | primary: { 29 | DEFAULT: "hsl(var(--primary))", 30 | foreground: "hsl(var(--primary-foreground))", 31 | }, 32 | secondary: { 33 | DEFAULT: "hsl(var(--secondary))", 34 | foreground: "hsl(var(--secondary-foreground))", 35 | }, 36 | destructive: { 37 | DEFAULT: "hsl(var(--destructive))", 38 | foreground: "hsl(var(--destructive-foreground))", 39 | }, 40 | muted: { 41 | DEFAULT: "hsl(var(--muted))", 42 | foreground: "hsl(var(--muted-foreground))", 43 | }, 44 | accent: { 45 | DEFAULT: "hsl(var(--accent))", 46 | foreground: "hsl(var(--accent-foreground))", 47 | }, 48 | popover: { 49 | DEFAULT: "hsl(var(--popover))", 50 | foreground: "hsl(var(--popover-foreground))", 51 | }, 52 | card: { 53 | DEFAULT: "hsl(var(--card))", 54 | foreground: "hsl(var(--card-foreground))", 55 | }, 56 | }, 57 | borderRadius: { 58 | lg: "var(--radius)", 59 | md: "calc(var(--radius) - 2px)", 60 | sm: "calc(var(--radius) - 4px)", 61 | }, 62 | keyframes: { 63 | "accordion-down": { 64 | from: { height: "0" }, 65 | to: { height: "var(--radix-accordion-content-height)" }, 66 | }, 67 | "accordion-up": { 68 | from: { height: "var(--radix-accordion-content-height)" }, 69 | to: { height: "0" }, 70 | }, 71 | }, 72 | animation: { 73 | "accordion-down": "accordion-down 0.2s ease-out", 74 | "accordion-up": "accordion-up 0.2s ease-out", 75 | }, 76 | }, 77 | }, 78 | plugins: [require("tailwindcss-animate")], 79 | }); 80 | 81 | // const config = { 82 | // darkMode: ["class"], 83 | // content: [ 84 | // "./pages/**/*.{ts,tsx,mdx}", 85 | // "./shared/**/*.{ts,tsx,mdx}", 86 | // "./app/**/*.{ts,tsx,mdx}", 87 | // "./src/**/*.{ts,tsx,mdx}", 88 | // ], 89 | // prefix: "", 90 | // theme: { 91 | // container: { 92 | // center: true, 93 | // padding: "2rem", 94 | // screens: { 95 | // "2xl": "1400px", 96 | // }, 97 | // }, 98 | // extend: { 99 | // colors: { 100 | // border: "hsl(var(--border))", 101 | // input: "hsl(var(--input))", 102 | // ring: "hsl(var(--ring))", 103 | // background: "hsl(var(--background))", 104 | // foreground: "hsl(var(--foreground))", 105 | // primary: { 106 | // DEFAULT: "hsl(var(--primary))", 107 | // foreground: "hsl(var(--primary-foreground))", 108 | // }, 109 | // secondary: { 110 | // DEFAULT: "hsl(var(--secondary))", 111 | // foreground: "hsl(var(--secondary-foreground))", 112 | // }, 113 | // destructive: { 114 | // DEFAULT: "hsl(var(--destructive))", 115 | // foreground: "hsl(var(--destructive-foreground))", 116 | // }, 117 | // muted: { 118 | // DEFAULT: "hsl(var(--muted))", 119 | // foreground: "hsl(var(--muted-foreground))", 120 | // }, 121 | // accent: { 122 | // DEFAULT: "hsl(var(--accent))", 123 | // foreground: "hsl(var(--accent-foreground))", 124 | // }, 125 | // popover: { 126 | // DEFAULT: "hsl(var(--popover))", 127 | // foreground: "hsl(var(--popover-foreground))", 128 | // }, 129 | // card: { 130 | // DEFAULT: "hsl(var(--card))", 131 | // foreground: "hsl(var(--card-foreground))", 132 | // }, 133 | // }, 134 | // borderRadius: { 135 | // lg: "var(--radius)", 136 | // md: "calc(var(--radius) - 2px)", 137 | // sm: "calc(var(--radius) - 4px)", 138 | // }, 139 | // keyframes: { 140 | // "accordion-down": { 141 | // from: { height: "0" }, 142 | // to: { height: "var(--radix-accordion-content-height)" }, 143 | // }, 144 | // "accordion-up": { 145 | // from: { height: "var(--radix-accordion-content-height)" }, 146 | // to: { height: "0" }, 147 | // }, 148 | // }, 149 | // animation: { 150 | // "accordion-down": "accordion-down 0.2s ease-out", 151 | // "accordion-up": "accordion-up 0.2s ease-out", 152 | // }, 153 | // }, 154 | // }, 155 | // plugins: [require("tailwindcss-animate")], 156 | // } satisfies Config; 157 | 158 | // export default config; 159 | -------------------------------------------------------------------------------- /src/features/brick-edit/ui/add.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/shared/lib/utils"; 2 | 3 | import { 4 | Dialog, 5 | DialogTrigger, 6 | DialogContent, 7 | DialogHeader, 8 | DialogClose, 9 | } from "@/shared/ui/dialog"; 10 | import { useCurrent } from "../hooks/useCurrent"; 11 | import { v4 } from "uuid"; 12 | import { ComponentPropsWithoutRef } from "react"; 13 | import { 14 | AlignVerticalSpaceAround, 15 | Minus, 16 | Image, 17 | Link, 18 | Type, 19 | CaseSensitive, 20 | } from "lucide-react"; 21 | 22 | export const AddNewBrick = () => { 23 | return ( 24 | <Dialog> 25 | <DialogTrigger> 26 | <div className="w-full p-8 border border-neutral-700 rounded-xl flex justify-center items-center relative border-none bg-neutral-800 dark:bg-white text-white dark:text-neutral-800"> 27 | <p className="text-xl font-bold">Add new</p> 28 | </div> 29 | </DialogTrigger> 30 | <DialogContent 31 | className="w-full flex flex-col items-center" 32 | onFocus={(e) => e.target.blur()} 33 | > 34 | <DialogHeader> 35 | <span>Click to element to add</span> 36 | </DialogHeader> 37 | <div className="w-full rounded-2xl p-2"> 38 | <DialogClose className="flex gap-4 flex-wrap justify-center"> 39 | <TitleAdd /> 40 | <TextAdd /> 41 | <AirAdd /> 42 | <LineAdd /> 43 | <PictureAdd /> 44 | <ClickAdd /> 45 | </DialogClose> 46 | </div> 47 | </DialogContent> 48 | </Dialog> 49 | ); 50 | }; 51 | 52 | interface INewBrick extends ComponentPropsWithoutRef<"div"> { 53 | children: React.ReactNode; 54 | } 55 | 56 | function NewBrickParent({ children, className, ...props }: INewBrick) { 57 | return ( 58 | <div 59 | className={cn( 60 | "h-24 w-24 rounded-lg border border-neutral-500 p-2 flex items-center justify-center gap-2 flex-col dark:bg-transparent bg-white cursor-pointer", 61 | className 62 | )} 63 | {...props} 64 | > 65 | {children} 66 | </div> 67 | ); 68 | } 69 | 70 | function TextAdd() { 71 | const { handleAddBrick } = useCurrent(); 72 | function AddTitle() { 73 | handleAddBrick({ 74 | id: v4(), 75 | type: "text", 76 | payload: "New Text, example text", 77 | params: '{"size": "medium", "align": "center"}', 78 | children: [], 79 | }); 80 | } 81 | return ( 82 | <NewBrickParent onClick={AddTitle}> 83 | <CaseSensitive size={40} /> 84 | <p className="text-sm font-light text-neutral-800 dark:text-white"> 85 | Default text 86 | </p> 87 | </NewBrickParent> 88 | ); 89 | } 90 | 91 | function TitleAdd() { 92 | const { handleAddBrick } = useCurrent(); 93 | function AddTitle() { 94 | handleAddBrick({ 95 | id: v4(), 96 | type: "title", 97 | payload: "Example title", 98 | params: '{"size": "medium", "align": "center"}', 99 | children: [], 100 | }); 101 | } 102 | return ( 103 | <NewBrickParent onClick={AddTitle}> 104 | <Type size={40} /> 105 | <p className="text-sm font-light text-neutral-800 dark:text-white"> 106 | Title 107 | </p> 108 | </NewBrickParent> 109 | ); 110 | } 111 | 112 | function AirAdd() { 113 | const { handleAddBrick } = useCurrent(); 114 | function AddAir() { 115 | handleAddBrick({ 116 | id: v4(), 117 | type: "air", 118 | payload: "20", 119 | params: "", 120 | children: [], 121 | }); 122 | } 123 | return ( 124 | <NewBrickParent onClick={AddAir}> 125 | <AlignVerticalSpaceAround size={40} /> 126 | <p className="text-sm font-light text-neutral-800 dark:text-white"> 127 | Air 128 | </p> 129 | </NewBrickParent> 130 | ); 131 | } 132 | 133 | function LineAdd() { 134 | const { handleAddBrick } = useCurrent(); 135 | function AddAir() { 136 | handleAddBrick({ 137 | id: v4(), 138 | type: "line", 139 | payload: "solid", 140 | params: "", 141 | children: [], 142 | }); 143 | } 144 | return ( 145 | <NewBrickParent onClick={AddAir}> 146 | <Minus size={40} /> 147 | <p className="text-sm font-light text-neutral-800 dark:text-white"> 148 | Line 149 | </p> 150 | </NewBrickParent> 151 | ); 152 | } 153 | 154 | function PictureAdd() { 155 | const { handleAddBrick } = useCurrent(); 156 | function AddPicture() { 157 | handleAddBrick({ 158 | id: v4(), 159 | type: "picture", 160 | payload: "", 161 | params: "", 162 | children: [], 163 | }); 164 | } 165 | return ( 166 | <NewBrickParent onClick={AddPicture}> 167 | <Image size={40} /> 168 | <p className="text-sm font-light text-neutral-800 dark:text-white"> 169 | Picture 170 | </p> 171 | </NewBrickParent> 172 | ); 173 | } 174 | 175 | function ClickAdd() { 176 | const { handleAddBrick } = useCurrent(); 177 | function AddPicture() { 178 | handleAddBrick({ 179 | id: v4(), 180 | type: "click", 181 | payload: "Example link", 182 | params: "#", 183 | children: [], 184 | }); 185 | } 186 | return ( 187 | <NewBrickParent onClick={AddPicture}> 188 | <Link size={40} /> 189 | <p className="text-sm font-light text-neutral-800 dark:text-white"> 190 | Link 191 | </p> 192 | </NewBrickParent> 193 | ); 194 | } 195 | -------------------------------------------------------------------------------- /src/shared/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SelectPrimitive from "@radix-ui/react-select" 5 | import { Check, ChevronDown, ChevronUp } from "lucide-react" 6 | 7 | import { cn } from "@/shared/lib/utils" 8 | 9 | const Select = SelectPrimitive.Root 10 | 11 | const SelectGroup = SelectPrimitive.Group 12 | 13 | const SelectValue = SelectPrimitive.Value 14 | 15 | const SelectTrigger = React.forwardRef< 16 | React.ElementRef<typeof SelectPrimitive.Trigger>, 17 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> 18 | >(({ className, children, ...props }, ref) => ( 19 | <SelectPrimitive.Trigger 20 | ref={ref} 21 | className={cn( 22 | "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", 23 | className 24 | )} 25 | {...props} 26 | > 27 | {children} 28 | <SelectPrimitive.Icon asChild> 29 | <ChevronDown className="h-4 w-4 opacity-50" /> 30 | </SelectPrimitive.Icon> 31 | </SelectPrimitive.Trigger> 32 | )) 33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 34 | 35 | const SelectScrollUpButton = React.forwardRef< 36 | React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, 37 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> 38 | >(({ className, ...props }, ref) => ( 39 | <SelectPrimitive.ScrollUpButton 40 | ref={ref} 41 | className={cn( 42 | "flex cursor-default items-center justify-center py-1", 43 | className 44 | )} 45 | {...props} 46 | > 47 | <ChevronUp className="h-4 w-4" /> 48 | </SelectPrimitive.ScrollUpButton> 49 | )) 50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName 51 | 52 | const SelectScrollDownButton = React.forwardRef< 53 | React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, 54 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> 55 | >(({ className, ...props }, ref) => ( 56 | <SelectPrimitive.ScrollDownButton 57 | ref={ref} 58 | className={cn( 59 | "flex cursor-default items-center justify-center py-1", 60 | className 61 | )} 62 | {...props} 63 | > 64 | <ChevronDown className="h-4 w-4" /> 65 | </SelectPrimitive.ScrollDownButton> 66 | )) 67 | SelectScrollDownButton.displayName = 68 | SelectPrimitive.ScrollDownButton.displayName 69 | 70 | const SelectContent = React.forwardRef< 71 | React.ElementRef<typeof SelectPrimitive.Content>, 72 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> 73 | >(({ className, children, position = "popper", ...props }, ref) => ( 74 | <SelectPrimitive.Portal> 75 | <SelectPrimitive.Content 76 | ref={ref} 77 | className={cn( 78 | "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 79 | position === "popper" && 80 | "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", 81 | className 82 | )} 83 | position={position} 84 | {...props} 85 | > 86 | <SelectScrollUpButton /> 87 | <SelectPrimitive.Viewport 88 | className={cn( 89 | "p-1", 90 | position === "popper" && 91 | "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" 92 | )} 93 | > 94 | {children} 95 | </SelectPrimitive.Viewport> 96 | <SelectScrollDownButton /> 97 | </SelectPrimitive.Content> 98 | </SelectPrimitive.Portal> 99 | )) 100 | SelectContent.displayName = SelectPrimitive.Content.displayName 101 | 102 | const SelectLabel = React.forwardRef< 103 | React.ElementRef<typeof SelectPrimitive.Label>, 104 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> 105 | >(({ className, ...props }, ref) => ( 106 | <SelectPrimitive.Label 107 | ref={ref} 108 | className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} 109 | {...props} 110 | /> 111 | )) 112 | SelectLabel.displayName = SelectPrimitive.Label.displayName 113 | 114 | const SelectItem = React.forwardRef< 115 | React.ElementRef<typeof SelectPrimitive.Item>, 116 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> 117 | >(({ className, children, ...props }, ref) => ( 118 | <SelectPrimitive.Item 119 | ref={ref} 120 | className={cn( 121 | "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 122 | className 123 | )} 124 | {...props} 125 | > 126 | <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> 127 | <SelectPrimitive.ItemIndicator> 128 | <Check className="h-4 w-4" /> 129 | </SelectPrimitive.ItemIndicator> 130 | </span> 131 | 132 | <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> 133 | </SelectPrimitive.Item> 134 | )) 135 | SelectItem.displayName = SelectPrimitive.Item.displayName 136 | 137 | const SelectSeparator = React.forwardRef< 138 | React.ElementRef<typeof SelectPrimitive.Separator>, 139 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> 140 | >(({ className, ...props }, ref) => ( 141 | <SelectPrimitive.Separator 142 | ref={ref} 143 | className={cn("-mx-1 my-1 h-px bg-muted", className)} 144 | {...props} 145 | /> 146 | )) 147 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 148 | 149 | export { 150 | Select, 151 | SelectGroup, 152 | SelectValue, 153 | SelectTrigger, 154 | SelectContent, 155 | SelectLabel, 156 | SelectItem, 157 | SelectSeparator, 158 | SelectScrollUpButton, 159 | SelectScrollDownButton, 160 | } 161 | -------------------------------------------------------------------------------- /src/shared/ui/carousel.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import useEmblaCarousel, { 5 | type UseEmblaCarouselType, 6 | } from "embla-carousel-react"; 7 | import { ArrowLeft, ArrowRight } from "lucide-react"; 8 | 9 | import { cn } from "@/shared/lib/utils"; 10 | import { Button } from "@/shared/ui/button"; 11 | 12 | type CarouselApi = UseEmblaCarouselType[1]; 13 | type UseCarouselParameters = Parameters<typeof useEmblaCarousel>; 14 | type CarouselOptions = UseCarouselParameters[0]; 15 | type CarouselPlugin = UseCarouselParameters[1]; 16 | 17 | type CarouselProps = { 18 | opts?: CarouselOptions; 19 | plugins?: CarouselPlugin; 20 | orientation?: "horizontal" | "vertical"; 21 | setApi?: (api: CarouselApi) => void; 22 | }; 23 | 24 | type CarouselContextProps = { 25 | carouselRef: ReturnType<typeof useEmblaCarousel>[0]; 26 | api: ReturnType<typeof useEmblaCarousel>[1]; 27 | scrollPrev: () => void; 28 | scrollNext: () => void; 29 | canScrollPrev: boolean; 30 | canScrollNext: boolean; 31 | } & CarouselProps; 32 | 33 | const CarouselContext = React.createContext<CarouselContextProps | null>(null); 34 | 35 | function useCarousel() { 36 | const context = React.useContext(CarouselContext); 37 | 38 | if (!context) { 39 | throw new Error("useCarousel must be used within a <Carousel />"); 40 | } 41 | 42 | return context; 43 | } 44 | 45 | const Carousel = React.forwardRef< 46 | HTMLDivElement, 47 | React.HTMLAttributes<HTMLDivElement> & CarouselProps 48 | >( 49 | ( 50 | { 51 | orientation = "horizontal", 52 | opts, 53 | setApi, 54 | plugins, 55 | className, 56 | children, 57 | ...props 58 | }, 59 | ref 60 | ) => { 61 | const [carouselRef, api] = useEmblaCarousel( 62 | { 63 | ...opts, 64 | axis: orientation === "horizontal" ? "x" : "y", 65 | }, 66 | plugins 67 | ); 68 | const [canScrollPrev, setCanScrollPrev] = React.useState(false); 69 | const [canScrollNext, setCanScrollNext] = React.useState(false); 70 | 71 | const onSelect = React.useCallback((api: CarouselApi) => { 72 | if (!api) { 73 | return; 74 | } 75 | 76 | setCanScrollPrev(api.canScrollPrev()); 77 | setCanScrollNext(api.canScrollNext()); 78 | }, []); 79 | 80 | const scrollPrev = React.useCallback(() => { 81 | api?.scrollPrev(); 82 | }, [api]); 83 | 84 | const scrollNext = React.useCallback(() => { 85 | api?.scrollNext(); 86 | }, [api]); 87 | 88 | const handleKeyDown = React.useCallback( 89 | (event: React.KeyboardEvent<HTMLDivElement>) => { 90 | if (event.key === "ArrowLeft") { 91 | event.preventDefault(); 92 | scrollPrev(); 93 | } else if (event.key === "ArrowRight") { 94 | event.preventDefault(); 95 | scrollNext(); 96 | } 97 | }, 98 | [scrollPrev, scrollNext] 99 | ); 100 | 101 | React.useEffect(() => { 102 | if (!api || !setApi) { 103 | return; 104 | } 105 | 106 | setApi(api); 107 | }, [api, setApi]); 108 | 109 | React.useEffect(() => { 110 | if (!api) { 111 | return; 112 | } 113 | 114 | onSelect(api); 115 | api.on("reInit", onSelect); 116 | api.on("select", onSelect); 117 | 118 | return () => { 119 | api?.off("select", onSelect); 120 | }; 121 | }, [api, onSelect]); 122 | 123 | return ( 124 | <CarouselContext.Provider 125 | value={{ 126 | carouselRef, 127 | api: api, 128 | opts, 129 | orientation: 130 | orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), 131 | scrollPrev, 132 | scrollNext, 133 | canScrollPrev, 134 | canScrollNext, 135 | }} 136 | > 137 | <div 138 | ref={ref} 139 | onKeyDownCapture={handleKeyDown} 140 | className={cn("relative", className)} 141 | role="region" 142 | aria-roledescription="carousel" 143 | {...props} 144 | > 145 | {children} 146 | </div> 147 | </CarouselContext.Provider> 148 | ); 149 | } 150 | ); 151 | Carousel.displayName = "Carousel"; 152 | 153 | const CarouselContent = React.forwardRef< 154 | HTMLDivElement, 155 | React.HTMLAttributes<HTMLDivElement> 156 | >(({ className, ...props }, ref) => { 157 | const { carouselRef, orientation } = useCarousel(); 158 | 159 | return ( 160 | <div ref={carouselRef} className="overflow-hidden"> 161 | <div 162 | ref={ref} 163 | className={cn( 164 | "flex", 165 | orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", 166 | className 167 | )} 168 | {...props} 169 | /> 170 | </div> 171 | ); 172 | }); 173 | CarouselContent.displayName = "CarouselContent"; 174 | 175 | const CarouselItem = React.forwardRef< 176 | HTMLDivElement, 177 | React.HTMLAttributes<HTMLDivElement> 178 | >(({ className, ...props }, ref) => { 179 | const { orientation } = useCarousel(); 180 | 181 | return ( 182 | <div 183 | ref={ref} 184 | role="group" 185 | aria-roledescription="slide" 186 | className={cn( 187 | "min-w-0 shrink-0 grow-0 basis-full", 188 | orientation === "horizontal" ? "pl-4" : "pt-4", 189 | className 190 | )} 191 | {...props} 192 | /> 193 | ); 194 | }); 195 | CarouselItem.displayName = "CarouselItem"; 196 | 197 | const CarouselPrevious = React.forwardRef< 198 | HTMLButtonElement, 199 | React.ComponentProps<typeof Button> 200 | >(({ className, variant = "outline", size = "icon", ...props }, ref) => { 201 | const { orientation, scrollPrev, canScrollPrev } = useCarousel(); 202 | 203 | return ( 204 | <Button 205 | ref={ref} 206 | variant={variant} 207 | size={size} 208 | className={cn( 209 | "absolute h-8 w-8 rounded-full", 210 | orientation === "horizontal" 211 | ? "-left-12 top-1/2 -translate-y-1/2" 212 | : "-top-12 left-1/2 -translate-x-1/2 rotate-90", 213 | className 214 | )} 215 | disabled={!canScrollPrev} 216 | onClick={scrollPrev} 217 | {...props} 218 | > 219 | <ArrowLeft className="h-4 w-4" /> 220 | <span className="sr-only">Previous slide</span> 221 | </Button> 222 | ); 223 | }); 224 | CarouselPrevious.displayName = "CarouselPrevious"; 225 | 226 | const CarouselNext = React.forwardRef< 227 | HTMLButtonElement, 228 | React.ComponentProps<typeof Button> 229 | >(({ className, variant = "outline", size = "icon", ...props }, ref) => { 230 | const { orientation, scrollNext, canScrollNext } = useCarousel(); 231 | 232 | return ( 233 | <Button 234 | ref={ref} 235 | variant={variant} 236 | size={size} 237 | className={cn( 238 | "absolute h-8 w-8 rounded-full", 239 | orientation === "horizontal" 240 | ? "-right-12 top-1/2 -translate-y-1/2" 241 | : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", 242 | className 243 | )} 244 | disabled={!canScrollNext} 245 | onClick={scrollNext} 246 | {...props} 247 | > 248 | <ArrowRight className="h-4 w-4" /> 249 | <span className="sr-only">Next slide</span> 250 | </Button> 251 | ); 252 | }); 253 | CarouselNext.displayName = "CarouselNext"; 254 | 255 | export { 256 | type CarouselApi, 257 | Carousel, 258 | CarouselContent, 259 | CarouselItem, 260 | CarouselPrevious, 261 | CarouselNext, 262 | }; 263 | -------------------------------------------------------------------------------- /src/shared/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/shared/lib/utils" 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, 23 | React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | <DropdownMenuPrimitive.SubTrigger 28 | ref={ref} 29 | className={cn( 30 | "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent", 31 | inset && "pl-8", 32 | className 33 | )} 34 | {...props} 35 | > 36 | {children} 37 | <ChevronRight className="ml-auto h-4 w-4" /> 38 | </DropdownMenuPrimitive.SubTrigger> 39 | )) 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, 45 | React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> 46 | >(({ className, ...props }, ref) => ( 47 | <DropdownMenuPrimitive.SubContent 48 | ref={ref} 49 | className={cn( 50 | "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 51 | className 52 | )} 53 | {...props} 54 | /> 55 | )) 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef<typeof DropdownMenuPrimitive.Content>, 61 | React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | <DropdownMenuPrimitive.Portal> 64 | <DropdownMenuPrimitive.Content 65 | ref={ref} 66 | sideOffset={sideOffset} 67 | className={cn( 68 | "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 69 | className 70 | )} 71 | {...props} 72 | /> 73 | </DropdownMenuPrimitive.Portal> 74 | )) 75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 76 | 77 | const DropdownMenuItem = React.forwardRef< 78 | React.ElementRef<typeof DropdownMenuPrimitive.Item>, 79 | React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { 80 | inset?: boolean 81 | } 82 | >(({ className, inset, ...props }, ref) => ( 83 | <DropdownMenuPrimitive.Item 84 | ref={ref} 85 | className={cn( 86 | "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 87 | inset && "pl-8", 88 | className 89 | )} 90 | {...props} 91 | /> 92 | )) 93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 94 | 95 | const DropdownMenuCheckboxItem = React.forwardRef< 96 | React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, 97 | React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> 98 | >(({ className, children, checked, ...props }, ref) => ( 99 | <DropdownMenuPrimitive.CheckboxItem 100 | ref={ref} 101 | className={cn( 102 | "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 103 | className 104 | )} 105 | checked={checked} 106 | {...props} 107 | > 108 | <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> 109 | <DropdownMenuPrimitive.ItemIndicator> 110 | <Check className="h-4 w-4" /> 111 | </DropdownMenuPrimitive.ItemIndicator> 112 | </span> 113 | {children} 114 | </DropdownMenuPrimitive.CheckboxItem> 115 | )) 116 | DropdownMenuCheckboxItem.displayName = 117 | DropdownMenuPrimitive.CheckboxItem.displayName 118 | 119 | const DropdownMenuRadioItem = React.forwardRef< 120 | React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, 121 | React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> 122 | >(({ className, children, ...props }, ref) => ( 123 | <DropdownMenuPrimitive.RadioItem 124 | ref={ref} 125 | className={cn( 126 | "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 127 | className 128 | )} 129 | {...props} 130 | > 131 | <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> 132 | <DropdownMenuPrimitive.ItemIndicator> 133 | <Circle className="h-2 w-2 fill-current" /> 134 | </DropdownMenuPrimitive.ItemIndicator> 135 | </span> 136 | {children} 137 | </DropdownMenuPrimitive.RadioItem> 138 | )) 139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 140 | 141 | const DropdownMenuLabel = React.forwardRef< 142 | React.ElementRef<typeof DropdownMenuPrimitive.Label>, 143 | React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { 144 | inset?: boolean 145 | } 146 | >(({ className, inset, ...props }, ref) => ( 147 | <DropdownMenuPrimitive.Label 148 | ref={ref} 149 | className={cn( 150 | "px-2 py-1.5 text-sm font-semibold", 151 | inset && "pl-8", 152 | className 153 | )} 154 | {...props} 155 | /> 156 | )) 157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 158 | 159 | const DropdownMenuSeparator = React.forwardRef< 160 | React.ElementRef<typeof DropdownMenuPrimitive.Separator>, 161 | React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> 162 | >(({ className, ...props }, ref) => ( 163 | <DropdownMenuPrimitive.Separator 164 | ref={ref} 165 | className={cn("-mx-1 my-1 h-px bg-muted", className)} 166 | {...props} 167 | /> 168 | )) 169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 170 | 171 | const DropdownMenuShortcut = ({ 172 | className, 173 | ...props 174 | }: React.HTMLAttributes<HTMLSpanElement>) => { 175 | return ( 176 | <span 177 | className={cn("ml-auto text-xs tracking-widest opacity-60", className)} 178 | {...props} 179 | /> 180 | ) 181 | } 182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 183 | 184 | export { 185 | DropdownMenu, 186 | DropdownMenuTrigger, 187 | DropdownMenuContent, 188 | DropdownMenuItem, 189 | DropdownMenuCheckboxItem, 190 | DropdownMenuRadioItem, 191 | DropdownMenuLabel, 192 | DropdownMenuSeparator, 193 | DropdownMenuShortcut, 194 | DropdownMenuGroup, 195 | DropdownMenuPortal, 196 | DropdownMenuSub, 197 | DropdownMenuSubContent, 198 | DropdownMenuSubTrigger, 199 | DropdownMenuRadioGroup, 200 | } 201 | -------------------------------------------------------------------------------- /src/features/bricks/svg/icons.tsx: -------------------------------------------------------------------------------- 1 | export function Web( 2 | props: React.ButtonHTMLAttributes<HTMLOrSVGElement> & { 3 | strokeWidth?: number; 4 | } 5 | ) { 6 | return ( 7 | <svg 8 | width="48" 9 | height="48" 10 | viewBox="0 0 48 48" 11 | fill="none" 12 | xmlns="http://www.w3.org/2000/svg" 13 | {...props} 14 | > 15 | <path 16 | d="M24 4C29.0026 9.47671 31.8455 16.5841 32 24C31.8455 31.4159 29.0026 38.5233 24 44M24 4C18.9974 9.47671 16.1545 16.5841 16 24C16.1545 31.4159 18.9974 38.5233 24 44M24 4C12.9543 4 4 12.9543 4 24C4 35.0457 12.9543 44 24 44M24 4C35.0457 4 44 12.9543 44 24C44 35.0457 35.0457 44 24 44M5.00004 18H43M5 30H43" 17 | stroke="black" 18 | strokeWidth={props.strokeWidth || 3} 19 | strokeLinecap="round" 20 | strokeLinejoin="round" 21 | /> 22 | </svg> 23 | ); 24 | } 25 | 26 | export function Github(props: React.ButtonHTMLAttributes<HTMLOrSVGElement>) { 27 | return ( 28 | <svg 29 | width="48" 30 | height="48" 31 | viewBox="0 0 48 48" 32 | fill="none" 33 | xmlns="http://www.w3.org/2000/svg" 34 | {...props} 35 | > 36 | <g clipPath="url(#clip0_38_342)"> 37 | <path 38 | fillRule="evenodd" 39 | clipRule="evenodd" 40 | d="M24.0199 0C10.7375 0 0 10.8167 0 24.1983C0 34.895 6.87988 43.9495 16.4241 47.1542C17.6174 47.3951 18.0545 46.6335 18.0545 45.9929C18.0545 45.4319 18.0151 43.509 18.0151 41.5055C11.3334 42.948 9.94198 38.6209 9.94198 38.6209C8.86818 35.8164 7.27715 35.0956 7.27715 35.0956C5.09022 33.6132 7.43645 33.6132 7.43645 33.6132C9.86233 33.7735 11.1353 36.0971 11.1353 36.0971C13.2824 39.7827 16.7422 38.7413 18.1341 38.1002C18.3328 36.5377 18.9695 35.456 19.6455 34.8552C14.3163 34.2942 8.70937 32.211 8.70937 22.9161C8.70937 20.2719 9.66321 18.1086 11.1746 16.4261C10.9361 15.8253 10.1008 13.3409 11.4135 10.0157C11.4135 10.0157 13.4417 9.3746 18.0146 12.4996C19.9725 11.9699 21.9916 11.7005 24.0199 11.6982C26.048 11.6982 28.1154 11.979 30.0246 12.4996C34.5981 9.3746 36.6262 10.0157 36.6262 10.0157C37.9389 13.3409 37.1031 15.8253 36.8646 16.4261C38.4158 18.1086 39.3303 20.2719 39.3303 22.9161C39.3303 32.211 33.7234 34.2539 28.3544 34.8552C29.2296 35.6163 29.9848 37.0584 29.9848 39.3421C29.9848 42.5871 29.9454 45.1915 29.9454 45.9924C29.9454 46.6335 30.383 47.3951 31.5758 47.1547C41.12 43.9491 47.9999 34.895 47.9999 24.1983C48.0392 10.8167 37.2624 0 24.0199 0Z" 41 | fill="#24292F" 42 | /> 43 | </g> 44 | <defs> 45 | <clipPath id="clip0_38_342"> 46 | <rect width="48" height="48" fill="white" /> 47 | </clipPath> 48 | </defs> 49 | </svg> 50 | ); 51 | } 52 | 53 | export function Facebook(props: React.ButtonHTMLAttributes<HTMLOrSVGElement>) { 54 | return ( 55 | <svg 56 | width="48" 57 | height="48" 58 | viewBox="0 0 48 48" 59 | fill="none" 60 | xmlns="http://www.w3.org/2000/svg" 61 | {...props} 62 | > 63 | <g clipPath="url(#clip0_38_361)"> 64 | <path 65 | d="M48 24C48 10.7453 37.2547 0 24 0C10.7453 0 0 10.7453 0 24C0 35.255 7.74912 44.6995 18.2026 47.2934V31.3344H13.2538V24H18.2026V20.8397C18.2026 12.671 21.8995 8.8848 29.9194 8.8848C31.44 8.8848 34.0637 9.18336 35.137 9.48096V16.129C34.5706 16.0694 33.5866 16.0397 32.3645 16.0397C28.4294 16.0397 26.9088 17.5306 26.9088 21.4061V24H34.7482L33.4013 31.3344H26.9088V47.8243C38.7926 46.3891 48.001 36.2707 48.001 24H48Z" 66 | fill="#0866FF" 67 | /> 68 | <path 69 | d="M33.4005 31.3344L34.7473 24H26.908V21.4061C26.908 17.5306 28.4286 16.0397 32.3636 16.0397C33.5857 16.0397 34.5697 16.0694 35.1361 16.129V9.48096C34.0628 9.1824 31.4392 8.8848 29.9185 8.8848C21.8987 8.8848 18.2017 12.671 18.2017 20.8397V24H13.2529V31.3344H18.2017V47.2934C20.0584 47.7542 22.0005 48 23.9992 48C24.9832 48 25.9537 47.9395 26.907 47.8243V31.3344H33.3995H33.4005Z" 70 | fill="white" 71 | /> 72 | </g> 73 | <defs> 74 | <clipPath id="clip0_38_361"> 75 | <rect width="48" height="48" fill="white" /> 76 | </clipPath> 77 | </defs> 78 | </svg> 79 | ); 80 | } 81 | 82 | export function Example(props: React.ButtonHTMLAttributes<HTMLOrSVGElement>) { 83 | return; 84 | } 85 | 86 | export function Youtube(props: React.ButtonHTMLAttributes<HTMLOrSVGElement>) { 87 | return ( 88 | <svg 89 | width="48" 90 | height="48" 91 | viewBox="0 0 48 48" 92 | fill="none" 93 | xmlns="http://www.w3.org/2000/svg" 94 | {...props} 95 | > 96 | <g clipPath="url(#clip0_38_326)"> 97 | <path 98 | d="M47.0442 12.3709C46.7728 11.3497 46.238 10.4178 45.4933 9.66822C44.7485 8.91869 43.82 8.37791 42.8006 8.1C39.0479 7.09091 24.0479 7.09091 24.0479 7.09091C24.0479 7.09091 9.04785 7.09091 5.29512 8.1C4.27571 8.37791 3.34717 8.91869 2.60243 9.66822C1.85768 10.4178 1.32286 11.3497 1.05149 12.3709C0.0478517 16.14 0.0478516 24 0.0478516 24C0.0478516 24 0.0478517 31.86 1.05149 35.6291C1.32286 36.6503 1.85768 37.5822 2.60243 38.3318C3.34717 39.0813 4.27571 39.6221 5.29512 39.9C9.04785 40.9091 24.0479 40.9091 24.0479 40.9091C24.0479 40.9091 39.0479 40.9091 42.8006 39.9C43.82 39.6221 44.7485 39.0813 45.4933 38.3318C46.238 37.5822 46.7728 36.6503 47.0442 35.6291C48.0479 31.86 48.0479 24 48.0479 24C48.0479 24 48.0479 16.14 47.0442 12.3709Z" 99 | fill="#FF0302" 100 | /> 101 | <path 102 | d="M19.1387 31.1373V16.8628L31.6841 24.0001L19.1387 31.1373Z" 103 | fill="#FEFEFE" 104 | /> 105 | </g> 106 | <defs> 107 | <clipPath id="clip0_38_326"> 108 | <rect width="48" height="48" fill="white" /> 109 | </clipPath> 110 | </defs> 111 | </svg> 112 | ); 113 | } 114 | 115 | export function X(props: React.ButtonHTMLAttributes<HTMLOrSVGElement>) { 116 | return ( 117 | <svg 118 | width="48" 119 | height="48" 120 | viewBox="0 0 48 48" 121 | fill="none" 122 | xmlns="http://www.w3.org/2000/svg" 123 | {...props} 124 | > 125 | <path 126 | d="M36.6526 3.80782H43.3995L28.6594 20.6548L46 43.5798H32.4225L21.7881 29.6759L9.61989 43.5798H2.86886L18.6349 25.56L2 3.80782H15.9222L25.5348 16.5165L36.6526 3.80782ZM34.2846 39.5414H38.0232L13.8908 7.63408H9.87892L34.2846 39.5414Z" 127 | fill="black" 128 | /> 129 | </svg> 130 | ); 131 | } 132 | 133 | export function WhatsApp(props: React.ButtonHTMLAttributes<HTMLOrSVGElement>) { 134 | return ( 135 | <svg 136 | width="48" 137 | height="48" 138 | viewBox="0 0 48 48" 139 | fill="none" 140 | xmlns="http://www.w3.org/2000/svg" 141 | {...props} 142 | > 143 | <path 144 | d="M0 48L3.374 35.674C1.292 32.066 0.198 27.976 0.2 23.782C0.206 10.67 10.876 0 23.986 0C30.348 0.002 36.32 2.48 40.812 6.976C45.302 11.472 47.774 17.448 47.772 23.804C47.766 36.918 37.096 47.588 23.986 47.588C20.006 47.586 16.084 46.588 12.61 44.692L0 48ZM13.194 40.386C16.546 42.376 19.746 43.568 23.978 43.57C34.874 43.57 43.75 34.702 43.756 23.8C43.76 12.876 34.926 4.02 23.994 4.016C13.09 4.016 4.22 12.884 4.216 23.784C4.214 28.234 5.518 31.566 7.708 35.052L5.71 42.348L13.194 40.386ZM35.968 29.458C35.82 29.21 35.424 29.062 34.828 28.764C34.234 28.466 31.312 27.028 30.766 26.83C30.222 26.632 29.826 26.532 29.428 27.128C29.032 27.722 27.892 29.062 27.546 29.458C27.2 29.854 26.852 29.904 26.258 29.606C25.664 29.308 23.748 28.682 21.478 26.656C19.712 25.08 18.518 23.134 18.172 22.538C17.826 21.944 18.136 21.622 18.432 21.326C18.7 21.06 19.026 20.632 19.324 20.284C19.626 19.94 19.724 19.692 19.924 19.294C20.122 18.898 20.024 18.55 19.874 18.252C19.724 17.956 18.536 15.03 18.042 13.84C17.558 12.682 17.068 12.838 16.704 12.82L15.564 12.8C15.168 12.8 14.524 12.948 13.98 13.544C13.436 14.14 11.9 15.576 11.9 18.502C11.9 21.428 14.03 24.254 14.326 24.65C14.624 25.046 18.516 31.05 24.478 33.624C25.896 34.236 27.004 34.602 27.866 34.876C29.29 35.328 30.586 35.264 31.61 35.112C32.752 34.942 35.126 33.674 35.622 32.286C36.118 30.896 36.118 29.706 35.968 29.458Z" 145 | fill="#25D366" 146 | /> 147 | </svg> 148 | ); 149 | } 150 | 151 | export function Vk(props: React.ButtonHTMLAttributes<HTMLOrSVGElement>) { 152 | return ( 153 | <svg 154 | width="48" 155 | height="48" 156 | viewBox="0 0 48 48" 157 | fill="none" 158 | xmlns="http://www.w3.org/2000/svg" 159 | {...props} 160 | > 161 | <g clipPath="url(#clip0_38_356)"> 162 | <path 163 | d="M0 23.04C0 12.1788 0 6.74826 3.37413 3.37413C6.74826 0 12.1788 0 23.04 0H24.96C35.8212 0 41.2517 0 44.6259 3.37413C48 6.74826 48 12.1788 48 23.04V24.96C48 35.8212 48 41.2517 44.6259 44.6259C41.2517 48 35.8212 48 24.96 48H23.04C12.1788 48 6.74826 48 3.37413 44.6259C0 41.2517 0 35.8212 0 24.96V23.04Z" 164 | fill="#0077FF" 165 | /> 166 | <path 167 | d="M25.54 34.5801C14.6 34.5801 8.3601 27.0801 8.1001 14.6001H13.5801C13.7601 23.7601 17.8 27.6401 21 28.4401V14.6001H26.1602V22.5001C29.3202 22.1601 32.6398 18.5601 33.7598 14.6001H38.9199C38.0599 19.4801 34.4599 23.0801 31.8999 24.5601C34.4599 25.7601 38.5601 28.9001 40.1201 34.5801H34.4399C33.2199 30.7801 30.1802 27.8401 26.1602 27.4401V34.5801H25.54Z" 168 | fill="white" 169 | /> 170 | </g> 171 | <defs> 172 | <clipPath id="clip0_38_356"> 173 | <rect width="48" height="48" fill="white" /> 174 | </clipPath> 175 | </defs> 176 | </svg> 177 | ); 178 | } 179 | 180 | export function Twitch(props: React.ButtonHTMLAttributes<HTMLOrSVGElement>) { 181 | return ( 182 | <svg 183 | width="48" 184 | height="48" 185 | viewBox="0 0 48 48" 186 | fill="none" 187 | xmlns="http://www.w3.org/2000/svg" 188 | {...props} 189 | > 190 | <path 191 | d="M41.1444 22.2857L34.2873 29.1429H27.4301L21.4301 35.1429V29.1429H13.7158V3.42856H41.1444V22.2857Z" 192 | fill="white" 193 | /> 194 | <path 195 | d="M12.0016 0L3.43018 8.57143V39.4286H13.7159V48L22.2873 39.4286H29.1445L44.573 24V0H12.0016ZM41.1445 22.2857L34.2873 29.1429H27.4302L21.4302 35.1429V29.1429H13.7159V3.42857H41.1445V22.2857Z" 196 | fill="#9146FF" 197 | /> 198 | <path 199 | d="M36.0013 9.42857H32.5728V19.7143H36.0013V9.42857Z" 200 | fill="#9146FF" 201 | /> 202 | <path 203 | d="M26.5726 9.42859H23.144V19.7143H26.5726V9.42859Z" 204 | fill="#9146FF" 205 | /> 206 | </svg> 207 | ); 208 | } 209 | 210 | export function Tiktok(props: React.ButtonHTMLAttributes<HTMLOrSVGElement>) { 211 | return ( 212 | <svg 213 | width="48" 214 | height="48" 215 | viewBox="0 0 48 48" 216 | fill="none" 217 | xmlns="http://www.w3.org/2000/svg" 218 | {...props} 219 | > 220 | <path 221 | d="M34.3529 17.327C37.4396 19.5413 41.221 20.8442 45.305 20.8442V12.9573C44.5321 12.9574 43.7612 12.8765 43.005 12.7158V18.9239C38.9213 18.9239 35.1404 17.621 32.0529 15.4068V31.5018C32.0529 39.5533 25.5491 46.0799 17.5267 46.0799C14.5333 46.0799 11.7511 45.1717 9.43994 43.6141C12.0778 46.3209 15.7564 48 19.8262 48C27.8491 48 34.3533 41.4734 34.3533 33.4216V17.327H34.3529V17.327ZM37.1902 9.37002C35.6128 7.64048 34.577 5.40538 34.3529 2.93438V1.91995H32.1733C32.722 5.06059 34.5933 7.74377 37.1902 9.37002ZM14.5141 37.4356C13.6328 36.2759 13.1565 34.8572 13.1586 33.3985C13.1586 29.7161 16.1334 26.7303 19.8035 26.7303C20.4875 26.7301 21.1674 26.8352 21.8192 27.0428V18.9796C21.0575 18.8748 20.2887 18.8303 19.5202 18.8466V25.1226C18.8679 24.9151 18.1877 24.8096 17.5035 24.8103C13.8334 24.8103 10.8588 27.7958 10.8588 31.4787C10.8588 34.0828 12.3457 36.3374 14.5141 37.4356Z" 222 | fill="#FF004F" 223 | /> 224 | <path 225 | d="M32.0529 15.4067C35.1404 17.6209 38.9213 18.9237 43.005 18.9237V12.7156C40.7255 12.2283 38.7075 11.0328 37.1903 9.37002C34.5931 7.74361 32.722 5.06043 32.1733 1.91995H26.4482V33.4213C26.4352 37.0937 23.4655 40.0673 19.8032 40.0673C17.6451 40.0673 15.7279 39.0349 14.5136 37.4356C12.3454 36.3374 10.8585 34.0827 10.8585 31.4789C10.8585 27.7963 13.8331 24.8105 17.5032 24.8105C18.2064 24.8105 18.8842 24.9204 19.5199 25.1228V18.8468C11.6384 19.0102 5.2998 25.473 5.2998 33.4214C5.2998 37.3892 6.87827 40.9861 9.44013 43.6143C11.7513 45.1717 14.5335 46.08 17.5268 46.08C25.5494 46.08 32.0531 39.5531 32.0531 31.5018V15.4067H32.0529Z" 226 | fill="black" 227 | /> 228 | <path 229 | d="M43.0051 12.7156V11.037C40.9495 11.0401 38.9343 10.4624 37.1903 9.36987C38.7342 11.0661 40.7671 12.2357 43.0051 12.7156ZM32.1734 1.91997C32.1211 1.61982 32.0809 1.3177 32.053 1.01443V0H24.148V31.5016C24.1354 35.1735 21.1658 38.1471 17.5033 38.1471C16.428 38.1471 15.4128 37.891 14.5137 37.4358C15.7279 39.0349 17.6452 40.0671 19.8033 40.0671C23.4652 40.0671 26.4354 37.0938 26.4482 33.4214V1.91997H32.1734ZM19.5203 18.8468V17.0598C18.8598 16.9692 18.1938 16.9237 17.5271 16.924C9.50383 16.9239 3 23.4508 3 31.5016C3 36.5491 5.55612 40.9974 9.44034 43.614C6.87848 40.986 5.30002 37.3889 5.30002 33.4213C5.30002 25.473 11.6385 19.0102 19.5203 18.8468Z" 230 | fill="#00F2EA" 231 | /> 232 | </svg> 233 | ); 234 | } 235 | 236 | export function Telegram(props: React.ButtonHTMLAttributes<HTMLOrSVGElement>) { 237 | return ( 238 | <svg 239 | width="48" 240 | height="48" 241 | viewBox="0 0 48 48" 242 | fill="none" 243 | xmlns="http://www.w3.org/2000/svg" 244 | {...props} 245 | > 246 | <g clipPath="url(#clip0_38_366)"> 247 | <path 248 | d="M24 48C37.2548 48 48 37.2548 48 24C48 10.7452 37.2548 0 24 0C10.7452 0 0 10.7452 0 24C0 37.2548 10.7452 48 24 48Z" 249 | fill="url(#paint0_linear_38_366)" 250 | /> 251 | <path 252 | fillRule="evenodd" 253 | clipRule="evenodd" 254 | d="M10.8635 23.7466C17.86 20.6984 22.5255 18.6888 24.8598 17.7179C31.5249 14.9456 32.9098 14.4641 33.8125 14.4482C34.011 14.4447 34.4549 14.4939 34.7425 14.7272C34.9853 14.9242 35.0521 15.1904 35.0841 15.3771C35.116 15.5639 35.1558 15.9895 35.1242 16.3219C34.763 20.1169 33.2002 29.3263 32.4051 33.5767C32.0687 35.3752 31.4062 35.9782 30.7649 36.0373C29.3712 36.1655 28.3129 35.1162 26.963 34.2313C24.8507 32.8467 23.6573 31.9847 21.607 30.6336C19.2374 29.0721 20.7735 28.2139 22.1239 26.8113C22.4773 26.4442 28.6181 20.8587 28.737 20.352C28.7518 20.2886 28.7656 20.0524 28.6253 19.9277C28.485 19.803 28.2778 19.8456 28.1284 19.8795C27.9165 19.9276 24.5421 22.158 18.005 26.5707C17.0472 27.2284 16.1796 27.5489 15.4023 27.5321C14.5454 27.5135 12.897 27.0475 11.6716 26.6492C10.1686 26.1606 8.97407 25.9023 9.07809 25.0726C9.13227 24.6404 9.72742 24.1984 10.8635 23.7466Z" 255 | fill="white" 256 | /> 257 | </g> 258 | <defs> 259 | <linearGradient 260 | id="paint0_linear_38_366" 261 | x1="24" 262 | y1="0" 263 | x2="24" 264 | y2="47.644" 265 | gradientUnits="userSpaceOnUse" 266 | > 267 | <stop stopColor="#2AABEE" /> 268 | <stop offset="1" stopColor="#229ED9" /> 269 | </linearGradient> 270 | <clipPath id="clip0_38_366"> 271 | <rect width="48" height="48" fill="white" /> 272 | </clipPath> 273 | </defs> 274 | </svg> 275 | ); 276 | } 277 | 278 | export function Instagram(props: React.ButtonHTMLAttributes<HTMLOrSVGElement>) { 279 | return ( 280 | <svg 281 | width="48" 282 | height="48" 283 | viewBox="0 0 48 48" 284 | fill="none" 285 | xmlns="http://www.w3.org/2000/svg" 286 | {...props} 287 | > 288 | <g clipPath="url(#clip0_38_309)"> 289 | <path 290 | d="M24 4.32187C30.4125 4.32187 31.1719 4.35 33.6938 4.4625C36.0375 4.56562 37.3031 4.95938 38.1469 5.2875C39.2625 5.71875 40.0688 6.24375 40.9031 7.07812C41.7469 7.92188 42.2625 8.71875 42.6938 9.83438C43.0219 10.6781 43.4156 11.9531 43.5188 14.2875C43.6313 16.8187 43.6594 17.5781 43.6594 23.9813C43.6594 30.3938 43.6313 31.1531 43.5188 33.675C43.4156 36.0188 43.0219 37.2844 42.6938 38.1281C42.2625 39.2438 41.7375 40.05 40.9031 40.8844C40.0594 41.7281 39.2625 42.2438 38.1469 42.675C37.3031 43.0031 36.0281 43.3969 33.6938 43.5C31.1625 43.6125 30.4031 43.6406 24 43.6406C17.5875 43.6406 16.8281 43.6125 14.3063 43.5C11.9625 43.3969 10.6969 43.0031 9.85313 42.675C8.7375 42.2438 7.93125 41.7188 7.09688 40.8844C6.25313 40.0406 5.7375 39.2438 5.30625 38.1281C4.97813 37.2844 4.58438 36.0094 4.48125 33.675C4.36875 31.1438 4.34063 30.3844 4.34063 23.9813C4.34063 17.5688 4.36875 16.8094 4.48125 14.2875C4.58438 11.9437 4.97813 10.6781 5.30625 9.83438C5.7375 8.71875 6.2625 7.9125 7.09688 7.07812C7.94063 6.23438 8.7375 5.71875 9.85313 5.2875C10.6969 4.95938 11.9719 4.56562 14.3063 4.4625C16.8281 4.35 17.5875 4.32187 24 4.32187ZM24 0C17.4844 0 16.6688 0.028125 14.1094 0.140625C11.5594 0.253125 9.80625 0.665625 8.2875 1.25625C6.70313 1.875 5.3625 2.69062 4.03125 4.03125C2.69063 5.3625 1.875 6.70313 1.25625 8.27813C0.665625 9.80625 0.253125 11.55 0.140625 14.1C0.028125 16.6687 0 17.4844 0 24C0 30.5156 0.028125 31.3313 0.140625 33.8906C0.253125 36.4406 0.665625 38.1938 1.25625 39.7125C1.875 41.2969 2.69063 42.6375 4.03125 43.9688C5.3625 45.3 6.70313 46.125 8.27813 46.7344C9.80625 47.325 11.55 47.7375 14.1 47.85C16.6594 47.9625 17.475 47.9906 23.9906 47.9906C30.5063 47.9906 31.3219 47.9625 33.8813 47.85C36.4313 47.7375 38.1844 47.325 39.7031 46.7344C41.2781 46.125 42.6188 45.3 43.95 43.9688C45.2813 42.6375 46.1063 41.2969 46.7156 39.7219C47.3063 38.1938 47.7188 36.45 47.8313 33.9C47.9438 31.3406 47.9719 30.525 47.9719 24.0094C47.9719 17.4938 47.9438 16.6781 47.8313 14.1188C47.7188 11.5688 47.3063 9.81563 46.7156 8.29688C46.125 6.70312 45.3094 5.3625 43.9688 4.03125C42.6375 2.7 41.2969 1.875 39.7219 1.26562C38.1938 0.675 36.45 0.2625 33.9 0.15C31.3313 0.028125 30.5156 0 24 0Z" 291 | fill="#000100" 292 | /> 293 | <path 294 | d="M24 11.6719C17.1938 11.6719 11.6719 17.1938 11.6719 24C11.6719 30.8062 17.1938 36.3281 24 36.3281C30.8062 36.3281 36.3281 30.8062 36.3281 24C36.3281 17.1938 30.8062 11.6719 24 11.6719ZM24 31.9969C19.5844 31.9969 16.0031 28.4156 16.0031 24C16.0031 19.5844 19.5844 16.0031 24 16.0031C28.4156 16.0031 31.9969 19.5844 31.9969 24C31.9969 28.4156 28.4156 31.9969 24 31.9969Z" 295 | fill="#000100" 296 | /> 297 | <path 298 | d="M39.6937 11.1844C39.6937 12.7782 38.4 14.0625 36.8156 14.0625C35.2219 14.0625 33.9375 12.7688 33.9375 11.1844C33.9375 9.59066 35.2313 8.30628 36.8156 8.30628C38.4 8.30628 39.6937 9.60003 39.6937 11.1844Z" 299 | fill="#000100" 300 | /> 301 | </g> 302 | <defs> 303 | <clipPath id="clip0_38_309"> 304 | <rect width="48" height="48" fill="white" /> 305 | </clipPath> 306 | </defs> 307 | </svg> 308 | ); 309 | } 310 | --------------------------------------------------------------------------------