├── public ├── uploads │ ├── .gitkeep │ ├── 1720541346231YI7eP.jpg │ ├── 17205414821801BUEx.jpg │ ├── 1720541558096JIUXb.jpg │ ├── 1720541623747PBofg.jpg │ └── 1720541638713l7S0z.jpeg ├── thumbnails │ └── demo-1.png ├── icons │ ├── arrow-N-default.svg │ ├── arrow-N-selected.svg │ ├── arrow-white.svg │ └── arrow-white-hover.svg └── logos │ ├── logo-black.svg │ ├── logo-blue.svg │ └── logo-small.svg ├── .env.sample ├── src ├── components │ ├── ImageNode │ │ ├── ImageNode.scss │ │ └── ImageNode.tsx │ ├── Flow │ │ ├── Flow.scss │ │ └── Flow.tsx │ ├── InputError │ │ ├── InputError.scss │ │ ├── InputError.tsx │ │ └── InputError.test.tsx │ ├── TypingAnimation │ │ ├── TypingAnimation.scss │ │ └── TypingAnimation.tsx │ ├── NodeWrapper │ │ ├── NodeWrapper.tsx │ │ └── NodeWrapper.scss │ ├── TextNode │ │ ├── TextNode.scss │ │ └── TextNode.tsx │ ├── GridBtn │ │ ├── GridBtn.tsx │ │ └── GridBtn.scss │ ├── YoutubeVidNode │ │ ├── YoutubeVidNode.scss │ │ └── YoutubeVidNode.tsx │ ├── ColorTools │ │ └── ColorTools.tsx │ ├── MainHeader │ │ ├── MainHeader.scss │ │ └── MainHeader.tsx │ ├── ToolBar │ │ ├── ToolBar.scss │ │ └── ToolBar.tsx │ ├── DemoBtn │ │ ├── DemoBtn.tsx │ │ └── DemoBtn.scss │ ├── ModalDefinition │ │ ├── ModalDefinition.scss │ │ └── ModalDefinition.tsx │ ├── WipBtn │ │ └── WipBtn.tsx │ ├── Input │ │ ├── Input.scss │ │ ├── Input.tsx │ │ └── Input.test.tsx │ ├── LoadingModal │ │ └── LoadingModal.tsx │ ├── ModalGeneral │ │ ├── ModalGeneral.scss │ │ └── ModalGeneral.tsx │ ├── PinterestNode │ │ └── PinterestNode.tsx │ ├── DarkModeBtn │ │ ├── DarkModeBtn.scss │ │ └── DarkModeBtn.tsx │ ├── ContextMenu │ │ ├── ContextMenu.scss │ │ └── ContextMenu.tsx │ ├── BoardContextMenu │ │ └── BoardContextMenu.tsx │ ├── ColorSelectorNode │ │ ├── ColorSelectorNode.scss │ │ └── ColorSelectorNode.tsx │ ├── LoginBox │ │ ├── LoginBox.scss │ │ ├── LoginBox.test.tsx │ │ └── LoginBox.tsx │ ├── SpotifyNode │ │ └── SpotifyNode.tsx │ ├── BoardHeader │ │ ├── BoardHeader.scss │ │ └── BoardHeader.tsx │ ├── ExploreBoardHeader │ │ └── ExploreBoardHeader.tsx │ ├── FilterAside │ │ ├── FilterAside.scss │ │ └── FilterAside.tsx │ ├── ToolMenu │ │ ├── ToolMenu.scss │ │ └── ToolMenu.tsx │ ├── ModalInput │ │ ├── ModalInput.scss │ │ └── ModalInput.tsx │ ├── ProjectCard │ │ ├── ProjectCard.scss │ │ └── ProjectCard.tsx │ └── ModalUpload │ │ ├── ModalUpload.scss │ │ └── ModalUpload.tsx ├── app │ ├── fonts │ │ ├── CorporateAPro-Medium.woff2 │ │ └── CorporateAPro-Regular.woff2 │ ├── page.tsx │ ├── login │ │ └── page.tsx │ ├── account │ │ └── page.tsx │ ├── board │ │ └── [boardId] │ │ │ └── page.tsx │ ├── explore │ │ ├── page.tsx │ │ └── [boardId] │ │ │ └── page.tsx │ ├── dashboard │ │ └── page.tsx │ ├── demo │ │ ├── account │ │ │ └── page.tsx │ │ ├── board │ │ │ └── [boardId] │ │ │ │ └── page.tsx │ │ ├── explore │ │ │ └── page.tsx │ │ └── dashboard │ │ │ └── page.tsx │ ├── layout.tsx │ ├── fonts.ts │ ├── not-found.tsx │ ├── globals.scss │ └── icon.svg ├── utils │ ├── get-random-hex.ts │ ├── __tests__ │ │ ├── format-date.test.ts │ │ ├── get-random-hex.test.ts │ │ ├── get-random-coords.test.ts │ │ ├── color-methods.test.ts │ │ └── get-domain.test.ts │ ├── get-random-coords.ts │ ├── format-date.ts │ ├── get-domain.ts │ └── color-methods.ts ├── pages │ ├── ExploreBoardPage │ │ ├── ExploreBoardPage.scss │ │ └── ExploreBoardPage.tsx │ ├── BoardPage │ │ ├── BoardPage.scss │ │ └── BoardPage.tsx │ ├── HomePage │ │ ├── HomePage.scss │ │ └── HomePage.tsx │ ├── ExplorePage │ │ ├── ExplorePage.scss │ │ └── ExplorePage.tsx │ ├── DashBoardPage │ │ ├── DashBoardPage.scss │ │ └── DashBoardPage.tsx │ ├── ProfilePage │ │ ├── ProfilePage.scss │ │ └── ProfilePage.tsx │ └── LoginPage │ │ ├── LoginPage.scss │ │ └── LoginPage.tsx ├── hooks │ ├── useIsDemo.ts │ ├── useIsGrid.ts │ ├── useModal.ts │ ├── useNodeTypes.ts │ ├── useContextMenu.ts │ ├── useTools.ts │ ├── useFilterAside.ts │ ├── useHandleThumbnail.ts │ ├── useFetchPins.ts │ ├── useColorTools.ts │ └── useWordTools.ts ├── styles │ └── partials │ │ ├── _colors.scss │ │ ├── _mixins.scss │ │ └── _font-mixins.scss └── data │ ├── demo-dashboard.ts │ └── demo-pins.ts ├── next.config.mjs ├── postcss.config.js ├── .eslintrc ├── vitest.config.ts ├── tailwind.config.js ├── .gitignore ├── tsconfig.json ├── package.json └── README.md /public/uploads/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC__BASE_URL= -------------------------------------------------------------------------------- /src/components/ImageNode/ImageNode.scss: -------------------------------------------------------------------------------- 1 | .image-node { 2 | height: 100%; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/Flow/Flow.scss: -------------------------------------------------------------------------------- 1 | .react-flow__node.selected { 2 | background-color: #eee; 3 | } 4 | -------------------------------------------------------------------------------- /public/thumbnails/demo-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afyqzarof/studio-client/HEAD/public/thumbnails/demo-1.png -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/uploads/1720541346231YI7eP.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afyqzarof/studio-client/HEAD/public/uploads/1720541346231YI7eP.jpg -------------------------------------------------------------------------------- /public/uploads/17205414821801BUEx.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afyqzarof/studio-client/HEAD/public/uploads/17205414821801BUEx.jpg -------------------------------------------------------------------------------- /public/uploads/1720541558096JIUXb.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afyqzarof/studio-client/HEAD/public/uploads/1720541558096JIUXb.jpg -------------------------------------------------------------------------------- /public/uploads/1720541623747PBofg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afyqzarof/studio-client/HEAD/public/uploads/1720541623747PBofg.jpg -------------------------------------------------------------------------------- /public/uploads/1720541638713l7S0z.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afyqzarof/studio-client/HEAD/public/uploads/1720541638713l7S0z.jpeg -------------------------------------------------------------------------------- /src/app/fonts/CorporateAPro-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afyqzarof/studio-client/HEAD/src/app/fonts/CorporateAPro-Medium.woff2 -------------------------------------------------------------------------------- /src/app/fonts/CorporateAPro-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/afyqzarof/studio-client/HEAD/src/app/fonts/CorporateAPro-Regular.woff2 -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next", 3 | "rules": { 4 | "react/no-unescaped-entities": "off", 5 | "@next/next/no-page-custom-font": "off" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import HomePage from "../pages/HomePage/HomePage"; 2 | 3 | const page = () => { 4 | return ; 5 | }; 6 | 7 | export default page; 8 | -------------------------------------------------------------------------------- /src/app/login/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import LoginPage from "../../pages/LoginPage/LoginPage"; 3 | 4 | const page = () => { 5 | return ; 6 | }; 7 | 8 | export default page; 9 | -------------------------------------------------------------------------------- /src/utils/get-random-hex.ts: -------------------------------------------------------------------------------- 1 | const getRandomHex = (): string => { 2 | return "#" + ((Math.random() * 0xffffff) << 0).toString(16).padStart(6, "0"); 3 | }; 4 | 5 | export default getRandomHex; 6 | -------------------------------------------------------------------------------- /src/app/account/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import ProfilePage from "../../pages/ProfilePage/ProfilePage"; 3 | 4 | const page = () => { 5 | return ; 6 | }; 7 | 8 | export default page; 9 | -------------------------------------------------------------------------------- /src/app/board/[boardId]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import BoardPage from "../../../pages/BoardPage/BoardPage"; 3 | 4 | const page = () => { 5 | return ; 6 | }; 7 | 8 | export default page; 9 | -------------------------------------------------------------------------------- /src/app/explore/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import ExplorePage from "../../pages/ExplorePage/ExplorePage"; 3 | 4 | const page = () => { 5 | return ; 6 | }; 7 | 8 | export default page; 9 | -------------------------------------------------------------------------------- /src/components/InputError/InputError.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/colors"; 2 | @use "../../styles/partials/font-mixins" as *; 3 | 4 | .error-msg { 5 | @include body; 6 | color: colors.$error; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import DashBoardPage from "../../pages/DashBoardPage/DashBoardPage"; 3 | 4 | const page = () => { 5 | return ; 6 | }; 7 | 8 | export default page; 9 | -------------------------------------------------------------------------------- /src/app/demo/account/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import ProfilePage from "../../../pages/ProfilePage/ProfilePage"; 3 | 4 | const page = () => { 5 | return ; 6 | }; 7 | 8 | export default page; 9 | -------------------------------------------------------------------------------- /src/app/demo/board/[boardId]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import BoardPage from "../../../../pages/BoardPage/BoardPage"; 3 | 4 | const page = () => { 5 | return ; 6 | }; 7 | 8 | export default page; 9 | -------------------------------------------------------------------------------- /src/app/demo/explore/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import ExplorePage from "../../../pages/ExplorePage/ExplorePage"; 3 | 4 | const page = () => { 5 | return ; 6 | }; 7 | 8 | export default page; 9 | -------------------------------------------------------------------------------- /src/app/demo/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import DashBoardPage from "../../../pages/DashBoardPage/DashBoardPage"; 3 | 4 | const page = () => { 5 | return ; 6 | }; 7 | 8 | export default page; 9 | -------------------------------------------------------------------------------- /src/components/TypingAnimation/TypingAnimation.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/font-mixins" as *; 2 | 3 | .type-animation { 4 | @include tagline; 5 | 6 | &--block { 7 | display: block; 8 | margin: 0.2rem 0; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: "jsdom", 7 | setupFiles: "./src/__tests__/setupTest.js", 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/app/explore/[boardId]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import ExploreBoardPage from "../../../pages/ExploreBoardPage/ExploreBoardPage"; 4 | 5 | const page = () => { 6 | return ; 7 | }; 8 | 9 | export default page; 10 | -------------------------------------------------------------------------------- /src/components/NodeWrapper/NodeWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./NodeWrapper.scss"; 3 | 4 | const NodeWrapper = ({ children }: React.PropsWithChildren) => { 5 | return
{children}
; 6 | }; 7 | 8 | export default NodeWrapper; 9 | -------------------------------------------------------------------------------- /src/pages/ExploreBoardPage/ExploreBoardPage.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | 3 | .explore-arrow { 4 | width: 4rem; 5 | position: absolute; 6 | left: 1rem; 7 | top: 1rem; 8 | transform: rotate(-46deg); 9 | z-index: 200; 10 | cursor: pointer; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/NodeWrapper/NodeWrapper.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/colors"; 2 | @use "../../styles/partials/mixins" as *; 3 | .node-wrapper { 4 | padding: 1rem; 5 | width: 100%; 6 | height: 100%; 7 | @include flex; 8 | 9 | &:hover { 10 | border: 1px solid colors.$primary-dark; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/hooks/useIsDemo.ts: -------------------------------------------------------------------------------- 1 | import { usePathname } from "next/navigation"; 2 | 3 | export type IsDemo = boolean; 4 | const useIsDemo = (): IsDemo => { 5 | const pathname = usePathname(); 6 | const isDemo = pathname?.includes("demo"); 7 | 8 | return isDemo || false; 9 | }; 10 | 11 | export default useIsDemo; 12 | -------------------------------------------------------------------------------- /src/components/InputError/InputError.tsx: -------------------------------------------------------------------------------- 1 | import "./InputError.scss"; 2 | type InputErrorProps = { 3 | msg: string; 4 | }; 5 | const InputError = ({ msg }: InputErrorProps) => { 6 | return ( 7 |
8 |

{msg}

9 |
10 | ); 11 | }; 12 | 13 | export default InputError; 14 | -------------------------------------------------------------------------------- /src/hooks/useIsGrid.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | const useIsGrid = (value: boolean) => { 4 | const [isGrid, setIsGrid] = useState(value); 5 | const handleChangeGrid = () => { 6 | setIsGrid(!isGrid); 7 | }; 8 | 9 | return { isGrid, handleChangeGrid }; 10 | }; 11 | 12 | export { useIsGrid }; 13 | -------------------------------------------------------------------------------- /src/pages/BoardPage/BoardPage.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | 3 | .board { 4 | width: 100vw; 5 | flex: 1; 6 | } 7 | 8 | .view-container { 9 | position: absolute; 10 | bottom: 2rem; 11 | right: 2rem; 12 | z-index: 100; 13 | height: 6rem; 14 | @include flex(column, space-between, flex-end); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/TextNode/TextNode.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/font-mixins" as *; 2 | @use "../../styles/partials/colors"; 3 | 4 | .text-area { 5 | border: none; 6 | background-color: transparent; 7 | @include body; 8 | resize: none; 9 | width: 100%; 10 | height: 100%; 11 | 12 | &:focus { 13 | outline: none; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/__tests__/format-date.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import formatDate from "../format-date"; 3 | 4 | describe("date methods", () => { 5 | it("return correct date format", () => { 6 | const date = formatDate("Tue Mar 05 2024 15:57:47 GMT+0000"); 7 | expect(date).toBe("05.03.24"); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/utils/__tests__/get-random-hex.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import getRandomHex from "../get-random-hex"; 3 | 4 | describe("get random hex", () => { 5 | it("return correct format", () => { 6 | const hex = getRandomHex(); 7 | 8 | expect(hex.match(/#/)); 9 | expect(hex.length).toBe(7); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/utils/get-random-coords.ts: -------------------------------------------------------------------------------- 1 | type Coordinate = { 2 | x: number; 3 | y: number; 4 | }; 5 | const getRandomNum = () => { 6 | return Math.floor(Math.random() * 700); 7 | }; 8 | 9 | const getRandomCoords = (): Coordinate => { 10 | return { 11 | x: getRandomNum(), 12 | y: getRandomNum(), 13 | }; 14 | }; 15 | 16 | export default getRandomCoords; 17 | -------------------------------------------------------------------------------- /src/utils/format-date.ts: -------------------------------------------------------------------------------- 1 | const formatDate = (dateInput: string) => { 2 | const date = new Date(dateInput); 3 | const formattedDate = date 4 | .toLocaleDateString("en-GB", { 5 | year: "2-digit", 6 | month: "2-digit", 7 | day: "2-digit", 8 | }) 9 | .replace(/\//g, "."); 10 | return formattedDate; 11 | }; 12 | 13 | export default formatDate; 14 | -------------------------------------------------------------------------------- /src/hooks/useModal.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | const useModal = () => { 4 | const [modalIsOpen, setIsOpen] = useState(false); 5 | 6 | const openModal = () => { 7 | setIsOpen(true); 8 | }; 9 | const closeModal = () => { 10 | setIsOpen(false); 11 | }; 12 | return { openModal, closeModal, modalIsOpen }; 13 | }; 14 | 15 | export default useModal; 16 | -------------------------------------------------------------------------------- /src/components/GridBtn/GridBtn.tsx: -------------------------------------------------------------------------------- 1 | import "./GridBtn.scss"; 2 | type GridBtnProps = { 3 | isGrid: boolean; 4 | handleChangeGrid: () => void; 5 | }; 6 | const GridBtn = ({ isGrid, handleChangeGrid }: GridBtnProps) => { 7 | return ( 8 | 11 | ); 12 | }; 13 | 14 | export default GridBtn; 15 | -------------------------------------------------------------------------------- /src/styles/partials/_colors.scss: -------------------------------------------------------------------------------- 1 | $primary-main: #4c4cff; 2 | $primary-dark: #000; 3 | $primary-light: #fff; 4 | $primary-gray: #fbfbfb; 5 | 6 | $secondary-main: #c1c4f0; 7 | $secondary-dark: #1f2126; 8 | $secondary-light: #ddd; 9 | $secondary-gray: #959595; 10 | 11 | $accent: #fff44d; 12 | $error: #ff554d; 13 | $success: #6ff07d; 14 | 15 | $modal-bg: rgba(0, 0, 0, 0.2); 16 | $modal-overlay: rgba(0, 0, 0, 0.2); 17 | -------------------------------------------------------------------------------- /src/utils/__tests__/get-random-coords.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import getRandomCoords from "../get-random-coords"; 3 | 4 | describe("get random coordinates", () => { 5 | it("return coordinates", () => { 6 | const coordinates = getRandomCoords(); 7 | 8 | expect(typeof coordinates.x).toBe("number"); 9 | expect(typeof coordinates.y).toBe("number"); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"], 4 | theme: { 5 | extend: { 6 | fontFamily: { 7 | serif: ["var(--font-corporate-pro)"], 8 | mono: ["var(--font-ibm-mono)"], 9 | }, 10 | colors: { 11 | primaryMain: "#4c4cff", 12 | }, 13 | }, 14 | }, 15 | plugins: [], 16 | }; 17 | -------------------------------------------------------------------------------- /src/components/InputError/InputError.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { describe, expect, it } from "vitest"; 3 | import InputError from "./InputError"; 4 | 5 | describe("input error tests", () => { 6 | it("should render", () => { 7 | render(); 8 | expect(screen.getByText("test error message")).toBeInTheDocument(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/components/YoutubeVidNode/YoutubeVidNode.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/colors"; 3 | 4 | .yt-container { 5 | width: 100%; 6 | height: 100%; 7 | padding: 1rem; 8 | &:hover { 9 | border: 1px solid colors.$primary-dark; 10 | } 11 | 12 | &__video { 13 | min-height: 15rem; 14 | min-width: 20rem; 15 | width: 100%; 16 | height: 100%; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/GridBtn/GridBtn.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | 5 | .grid-btn { 6 | @include secondary-btn; 7 | color: colors.$primary-light; 8 | background-color: colors.$primary-dark; 9 | padding: 0.5rem; 10 | border-radius: 3px; 11 | 12 | &:hover { 13 | background-color: colors.$secondary-gray; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .env 26 | .hintrc 27 | coverage 28 | 29 | .next 30 | next-env.d.ts 31 | dist -------------------------------------------------------------------------------- /public/icons/arrow-N-default.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icons/arrow-N-selected.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ColorTools/ColorTools.tsx: -------------------------------------------------------------------------------- 1 | import useColorTools from "../../hooks/useColorTools"; 2 | import ToolMenu from "../ToolMenu/ToolMenu"; 3 | 4 | const ColorTools = () => { 5 | const { isColorSelected, colorTools } = useColorTools(); 6 | return ( 7 | 13 | ); 14 | }; 15 | 16 | export default ColorTools; 17 | -------------------------------------------------------------------------------- /src/data/demo-dashboard.ts: -------------------------------------------------------------------------------- 1 | export type Board = { 2 | id: string; 3 | title: string; 4 | created_at: string; 5 | thumbnail: string; 6 | description?: string; 7 | category?: string; 8 | username?: string; 9 | }; 10 | 11 | const demoBoards: Board[] = [ 12 | { 13 | id: "demo-1", 14 | title: "brand identity", 15 | created_at: "2023-03-23", 16 | thumbnail: "/thumbnails/demo-1.png", 17 | description: "trying to communicate the vibe (if you get what i mean)", 18 | }, 19 | ]; 20 | 21 | export default demoBoards; 22 | -------------------------------------------------------------------------------- /src/components/MainHeader/MainHeader.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/font-mixins" as *; 2 | @use "../../styles/partials/mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | .dash-nav { 5 | background-color: colors.$primary-light; 6 | width: 25rem; 7 | padding: 0.625rem; 8 | 9 | &__link { 10 | @include nav-btn; 11 | margin-right: 1.5rem; 12 | color: colors.$secondary-gray; 13 | 14 | &.active { 15 | color: colors.$primary-dark; 16 | } 17 | } 18 | } 19 | 20 | .dash-header { 21 | margin-bottom: 1rem; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/ToolBar/ToolBar.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/font-mixins" as *; 2 | @use "../../styles/partials/mixins" as *; 3 | 4 | .tool-nav { 5 | position: absolute; 6 | bottom: 25%; 7 | left: 2rem; 8 | z-index: 10; 9 | @include flex(column, space-between, flex-start); 10 | height: auto; 11 | } 12 | .btn-container { 13 | margin-bottom: 0.7rem; 14 | 15 | &__btn { 16 | @include primary-btn; 17 | margin-bottom: 0.5rem; 18 | margin-top: 1rem; 19 | } 20 | } 21 | 22 | .tool { 23 | &--hidden { 24 | display: none; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.scss"; 2 | import type { Metadata } from "next"; 3 | import { ibm_mono, corporate_pro } from "./fonts"; 4 | 5 | export const metadata: Metadata = { 6 | title: "studio", 7 | description: "ideation platform", 8 | }; 9 | 10 | export default function RootLayout({ 11 | children, 12 | }: { 13 | children: React.ReactNode; 14 | }) { 15 | return ( 16 | 17 | 18 |
{children}
19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/DemoBtn/DemoBtn.tsx: -------------------------------------------------------------------------------- 1 | import "./DemoBtn.scss"; 2 | 3 | type DemoBtnProps = { 4 | className: string; 5 | isUpload?: boolean; 6 | name: string; 7 | }; 8 | const DemoBtn = ({ className, isUpload, name }: DemoBtnProps) => { 9 | return ( 10 |
11 |

{name}

12 |
15 |

please login to {name} :)

16 |
17 |
18 | ); 19 | }; 20 | 21 | export default DemoBtn; 22 | -------------------------------------------------------------------------------- /src/components/ModalDefinition/ModalDefinition.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | 5 | .def { 6 | max-width: 40rem; 7 | 8 | &__word { 9 | @include sub-header; 10 | font-size: 3.5rem; 11 | } 12 | 13 | &__wrapper { 14 | margin-top: 1.5rem; 15 | } 16 | 17 | &__subtitle { 18 | @include sub-header; 19 | margin-bottom: 0.6rem; 20 | } 21 | 22 | &__meaning { 23 | margin-bottom: 0.5rem; 24 | margin-left: 1rem; 25 | list-style-type: square; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/WipBtn/WipBtn.tsx: -------------------------------------------------------------------------------- 1 | import Popup from "reactjs-popup"; 2 | type WipBtnProps = { 3 | name: string; 4 | }; 5 | const WipBtn = ({ name }: WipBtnProps) => ( 6 | ( 8 | 11 | )} 12 | position="right center" 13 | closeOnDocumentClick 14 | on={["hover"]}> 15 |
16 |

we are working on making lines available

17 |

for our future versions :)

18 |
19 |
20 | ); 21 | 22 | export default WipBtn; 23 | -------------------------------------------------------------------------------- /src/components/Input/Input.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | .input-container { 5 | @include flex(column, center, flex-start); 6 | margin: 1rem 0; 7 | width: 100%; 8 | 9 | &__label { 10 | @include sub-header; 11 | margin-bottom: 0.5rem; 12 | } 13 | 14 | &__input { 15 | padding: 0.5rem; 16 | @include text-input; 17 | border: none; 18 | border-bottom: 1px solid colors.$primary-dark; 19 | width: 100%; 20 | 21 | &:focus { 22 | outline: none; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/fonts.ts: -------------------------------------------------------------------------------- 1 | import { IBM_Plex_Mono } from "next/font/google"; 2 | import localFont from "next/font/local"; 3 | 4 | export const corporate_pro = localFont({ 5 | src: [ 6 | { 7 | path: "./fonts/CorporateAPro-Medium.woff2", 8 | weight: "500", 9 | style: "normal", 10 | }, 11 | { 12 | path: "./fonts/CorporateAPro-Regular.woff2", 13 | weight: "400", 14 | style: "normal", 15 | }, 16 | ], 17 | variable: "--font-corporate-pro", 18 | }); 19 | export const ibm_mono = IBM_Plex_Mono({ 20 | subsets: ["latin"], 21 | weight: ["200", "300", "400"], 22 | variable: "--font-ibm-mono", 23 | }); 24 | -------------------------------------------------------------------------------- /src/styles/partials/_mixins.scss: -------------------------------------------------------------------------------- 1 | @use "../partials/colors"; 2 | @mixin flex( 3 | $flex-direction: row, 4 | $justify-content: center, 5 | $align-items: center 6 | ) { 7 | display: flex; 8 | flex-direction: $flex-direction; 9 | align-items: $align-items; 10 | justify-content: $justify-content; 11 | } 12 | 13 | @mixin cta-btn { 14 | background-color: colors.$secondary-dark; 15 | border-radius: 3px; 16 | 17 | &:hover { 18 | background-color: colors.$primary-main; 19 | } 20 | } 21 | 22 | @mixin cta-underline { 23 | color: colors.$secondary-dark; 24 | 25 | &:hover { 26 | color: colors.$primary-main; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/pages/HomePage/HomePage.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | 5 | .homepage { 6 | @include flex(row, space-between); 7 | padding: 0 2rem; 8 | height: 100vh; 9 | 10 | &__logo-container { 11 | @include flex(column, space-between, flex-start); 12 | height: 17rem; 13 | } 14 | 15 | &__img { 16 | width: 20rem; 17 | } 18 | 19 | &__descr { 20 | @include section-header; 21 | color: colors.$primary-main; 22 | margin-top: 0.5rem; 23 | 24 | &--hidden { 25 | display: none; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /public/icons/arrow-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/pages/ExplorePage/ExplorePage.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | .explore-main { 5 | @include flex(row, space-between, flex-start); 6 | max-height: 100%; 7 | width: 100%; 8 | } 9 | 10 | .explore-nav { 11 | @include flex(column, flex-start, flex-start); 12 | background-color: colors.$primary-light; 13 | height: 80vh; 14 | padding: 1rem 1.5rem; 15 | } 16 | 17 | .explore-boards { 18 | flex: 1; 19 | max-height: 80vh; 20 | margin-left: 1rem; 21 | width: 10rem; 22 | @include flex(column, flex-start, center); 23 | overflow: scroll; 24 | } 25 | -------------------------------------------------------------------------------- /public/logos/logo-black.svg: -------------------------------------------------------------------------------- 1 | studio -------------------------------------------------------------------------------- /src/components/LoadingModal/LoadingModal.tsx: -------------------------------------------------------------------------------- 1 | import Modal from "react-modal"; 2 | import Image from "next/image"; 3 | type LoadingModalProps = { 4 | modalIsOpen: boolean; 5 | }; 6 | 7 | const LoadingModal = ({ modalIsOpen }: LoadingModalProps) => { 8 | return ( 9 | 10 |
11 | studio 18 |
19 |
20 | ); 21 | }; 22 | 23 | export default LoadingModal; 24 | -------------------------------------------------------------------------------- /public/logos/logo-blue.svg: -------------------------------------------------------------------------------- 1 | studio -------------------------------------------------------------------------------- /src/components/DemoBtn/DemoBtn.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | 5 | .tooltip { 6 | position: relative; 7 | cursor: not-allowed; 8 | 9 | &__text { 10 | @include body; 11 | @include flex; 12 | visibility: hidden; 13 | position: absolute; 14 | top: 100%; 15 | right: 0; 16 | background-color: colors.$secondary-dark; 17 | color: colors.$primary-light; 18 | padding: 0.3rem; 19 | margin-top: 0.5rem; 20 | border-radius: 2px; 21 | width: 11rem; 22 | } 23 | } 24 | 25 | .tooltip:hover .tooltip__text { 26 | visibility: visible; 27 | } 28 | -------------------------------------------------------------------------------- /src/hooks/useNodeTypes.ts: -------------------------------------------------------------------------------- 1 | import SpotifyNode from "../components/SpotifyNode/SpotifyNode"; 2 | import YoutubeVidNode from "../components/YoutubeVidNode/YoutubeVidNode"; 3 | import TextNode from "../components/TextNode/TextNode"; 4 | import ColorSelectorNode from "../components/ColorSelectorNode/ColorSelectorNode"; 5 | import PinterestNode from "../components/PinterestNode/PinterestNode"; 6 | import ImageNode from "../components/ImageNode/ImageNode"; 7 | 8 | const useNodeTypes = () => { 9 | return { 10 | SpotifyNode, 11 | YoutubeVidNode, 12 | TextNode, 13 | ColorSelectorNode, 14 | PinterestNode, 15 | ImageNode, 16 | }; 17 | }; 18 | export default useNodeTypes; 19 | -------------------------------------------------------------------------------- /src/components/ModalGeneral/ModalGeneral.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | 5 | .general { 6 | @include flex(column, space-between, flex-start); 7 | padding: 0.6rem; 8 | height: 20rem; 9 | min-width: 30rem; 10 | 11 | &__title { 12 | margin-bottom: 1rem; 13 | } 14 | 15 | &__buttons { 16 | @include flex(column, space-between, flex-start); 17 | height: 4rem; 18 | } 19 | &__btn { 20 | @include primary-btn; 21 | 22 | &--cta { 23 | @include cta-btn; 24 | color: colors.$primary-light; 25 | padding: 0.5rem 0.7rem; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /public/icons/arrow-white-hover.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | const notFound = () => { 5 | return ( 6 |
7 |
8 | studio 14 |
15 |

404 page not found

16 | 17 | Back to home page 18 | 19 |
20 |
21 |
22 | ); 23 | }; 24 | 25 | export default notFound; 26 | -------------------------------------------------------------------------------- /src/hooks/useContextMenu.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, MouseEvent } from "react"; 2 | 3 | const useContextMenu = () => { 4 | const [clicked, setClicked] = useState(false); 5 | const [points, setPoints] = useState({ 6 | x: 0, 7 | y: 0, 8 | }); 9 | useEffect(() => { 10 | const handleClick = () => setClicked(false); 11 | window.addEventListener("click", handleClick); 12 | return () => { 13 | window.removeEventListener("click", handleClick); 14 | }; 15 | }, []); 16 | const handleContext = (e: MouseEvent) => { 17 | e.preventDefault(); 18 | setClicked(true); 19 | setPoints({ x: e.pageX, y: e.pageY }); 20 | }; 21 | 22 | return { clicked, points, handleContext }; 23 | }; 24 | 25 | export default useContextMenu; 26 | -------------------------------------------------------------------------------- /src/components/PinterestNode/PinterestNode.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import { NodeProps, NodeResizer } from "reactflow"; 3 | import NodeWrapper from "../NodeWrapper/NodeWrapper"; 4 | 5 | const PinterestNode = memo(({ selected, data }: NodeProps) => { 6 | return ( 7 | <> 8 | 14 | 15 | 21 | 22 | 23 | ); 24 | }); 25 | 26 | export default PinterestNode; 27 | -------------------------------------------------------------------------------- /src/utils/__tests__/color-methods.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { 3 | getAnalogousColors, 4 | getComplementaryColor, 5 | getTriadicColors, 6 | } from "../color-methods"; 7 | 8 | describe("correct color methods", () => { 9 | it("should return analogous color", () => { 10 | const color = getComplementaryColor("#00ff00"); 11 | expect(color).toBe("#ff00ff"); 12 | }); 13 | 14 | it("should return analogous colors", () => { 15 | const colors = getAnalogousColors("#00ff00"); 16 | expect(colors).toStrictEqual(["#00ff80", "#00ffff", "#0080ff"]); 17 | }); 18 | 19 | it("should return triadic colors", () => { 20 | const colors = getTriadicColors("#00ff00"); 21 | expect(colors).toStrictEqual(["#0000ff", "#ff0000"]); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/components/DarkModeBtn/DarkModeBtn.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | 5 | .mode-container { 6 | @include flex(row, space-between); 7 | width: 10rem; 8 | 9 | &__light { 10 | @include secondary-btn; 11 | padding: 0.4rem 0.7rem; 12 | border: 1px solid colors.$primary-dark; 13 | border-radius: 3px; 14 | 15 | &--dark { 16 | color: colors.$secondary-gray; 17 | border: none; 18 | } 19 | } 20 | &__dark { 21 | @include secondary-btn; 22 | padding: 0.4rem 0.7rem; 23 | color: colors.$secondary-gray; 24 | 25 | &--dark { 26 | color: colors.$primary-light; 27 | background-color: colors.$primary-dark; 28 | border-radius: 3px; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/components/ContextMenu/ContextMenu.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | 5 | .context-menu { 6 | position: absolute; 7 | z-index: 10; 8 | background-color: colors.$secondary-dark; 9 | 10 | &.not-active { 11 | display: none; 12 | } 13 | &.active { 14 | display: block; 15 | } 16 | &__node { 17 | @include context-menu; 18 | color: colors.$primary-gray; 19 | border: none; 20 | display: block; 21 | padding: 0.5em; 22 | text-align: left; 23 | width: 100%; 24 | padding: 0.4rem 0.3rem; 25 | cursor: default; 26 | } 27 | &__btn { 28 | @extend .context-menu__node; 29 | &:hover { 30 | background-color: colors.$accent; 31 | color: colors.$secondary-dark; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/BoardContextMenu/BoardContextMenu.tsx: -------------------------------------------------------------------------------- 1 | type BoardContextMenuProps = { 2 | positionX: number; 3 | positionY: number; 4 | isToggled: boolean; 5 | openModal: () => void; 6 | }; 7 | 8 | const BoardContextMenu = ({ 9 | positionX, 10 | positionY, 11 | isToggled, 12 | openModal, 13 | }: BoardContextMenuProps) => { 14 | const handleDelete = () => { 15 | console.log("delete"); 16 | openModal(); 17 | }; 18 | return ( 19 | 22 |

make public/private

23 |

24 | delete 25 |

26 |
27 | ); 28 | }; 29 | 30 | export default BoardContextMenu; 31 | -------------------------------------------------------------------------------- /src/components/YoutubeVidNode/YoutubeVidNode.tsx: -------------------------------------------------------------------------------- 1 | import "./YoutubeVidNode.scss"; 2 | import { memo } from "react"; 3 | import { NodeProps, NodeResizer } from "reactflow"; 4 | 5 | const YoutubeVidNode = memo(({ selected, data }: NodeProps) => { 6 | return ( 7 | <> 8 | 14 |
15 | 21 |
22 | 23 | ); 24 | }); 25 | 26 | export default YoutubeVidNode; 27 | -------------------------------------------------------------------------------- /src/components/ColorSelectorNode/ColorSelectorNode.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | 5 | .color-select { 6 | @include flex(column); 7 | padding: 1rem; 8 | width: 100%; 9 | height: 100%; 10 | 11 | &:hover { 12 | border: 0.5px solid colors.$primary-dark; 13 | } 14 | &__color { 15 | min-width: 10rem; 16 | min-height: 10rem; 17 | height: 100%; 18 | width: 100%; 19 | border-radius: 3px; 20 | } 21 | 22 | &__container { 23 | margin-top: 1rem; 24 | width: 100%; 25 | @include flex(row, space-between); 26 | } 27 | 28 | &__input { 29 | background: none; 30 | border: none; 31 | border-radius: 3px; 32 | } 33 | &__text-input { 34 | background-color: transparent; 35 | outline: none; 36 | border: none; 37 | width: 50%; 38 | font-size: 1rem; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/components/ImageNode/ImageNode.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import { NodeProps, NodeResizer } from "reactflow"; 3 | import "./ImageNode.scss"; 4 | import NodeWrapper from "../NodeWrapper/NodeWrapper"; 5 | import useIsDemo from "../../hooks/useIsDemo"; 6 | 7 | const ImageNode = memo(({ selected, data }: NodeProps) => { 8 | const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; 9 | const isDemo = useIsDemo(); 10 | 11 | return ( 12 | <> 13 | 20 | 21 | {data.file} 26 | 27 | 28 | ); 29 | }); 30 | 31 | export default ImageNode; 32 | -------------------------------------------------------------------------------- /src/components/LoginBox/LoginBox.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | 5 | .form-wrapper { 6 | background-color: colors.$primary-light; 7 | padding: 1rem; 8 | width: 20rem; 9 | } 10 | 11 | .change-form { 12 | margin-top: 1.5rem; 13 | @include secondary-btn; 14 | @include cta-underline; 15 | background-color: transparent; 16 | border: none; 17 | text-decoration: underline; 18 | } 19 | 20 | .form { 21 | &__btn { 22 | margin-top: 2.5rem; 23 | padding: 0.5rem 1.5rem; 24 | @include primary-btn; 25 | @include cta-btn; 26 | color: colors.$primary-light; 27 | border: none; 28 | } 29 | &--login { 30 | height: 0px; 31 | overflow: hidden; 32 | transition: height 0.3s ease-out; 33 | } 34 | 35 | &--register { 36 | height: 6rem; 37 | transition: height 0.3s ease-out; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/TypingAnimation/TypingAnimation.tsx: -------------------------------------------------------------------------------- 1 | import { TypeAnimation } from "react-type-animation"; 2 | import "./TypingAnimation.scss"; 3 | 4 | const TypingAnimation = () => { 5 | return ( 6 |
7 |

8 | a space for 9 | 29 | to articulate ideas 30 |

31 |
32 | ); 33 | }; 34 | 35 | export default TypingAnimation; 36 | -------------------------------------------------------------------------------- /src/app/globals.scss: -------------------------------------------------------------------------------- 1 | @use "../styles/partials/mixins" as *; 2 | @use "../styles/partials/colors"; 3 | 4 | @tailwind base; 5 | @tailwind components; 6 | @tailwind utilities; 7 | 8 | 9 | *, 10 | *::before, 11 | *::after { 12 | box-sizing: border-box; 13 | margin: 0; 14 | padding: 0; 15 | text-decoration: none; 16 | font-family: var(--font-ibm-mono); 17 | } 18 | 19 | li { 20 | list-style: none; 21 | } 22 | 23 | button { 24 | background: none; 25 | border: none; 26 | cursor: pointer; 27 | } 28 | 29 | html { 30 | scroll-behavior: smooth; 31 | } 32 | 33 | a { 34 | color: black; 35 | } 36 | 37 | .page-wrapper { 38 | @include flex(column, space-between, flex-start); 39 | max-height: 100vh; 40 | width: 100vw; 41 | background-color: colors.$primary-gray; 42 | padding: 3rem 2rem; 43 | min-height: 100vh; 44 | height: 100%; 45 | max-height: 80vh; 46 | } 47 | 48 | .board-wrapper { 49 | height: 100vh; 50 | @include flex(column); 51 | } -------------------------------------------------------------------------------- /src/components/SpotifyNode/SpotifyNode.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import { NodeProps, NodeResizer } from "reactflow"; 3 | import NodeWrapper from "../NodeWrapper/NodeWrapper"; 4 | 5 | const SpotifyNode = memo(({ selected, data }: NodeProps) => { 6 | return ( 7 | <> 8 | 14 | 15 | 24 | 25 | 26 | ); 27 | }); 28 | export default SpotifyNode; 29 | -------------------------------------------------------------------------------- /src/components/ModalGeneral/ModalGeneral.tsx: -------------------------------------------------------------------------------- 1 | import Modal from "react-modal"; 2 | import "./ModalGeneral.scss"; 3 | type ModalGeneralProps = { 4 | modalIsOpen: boolean; 5 | closeModal: () => void; 6 | title: string; 7 | onClick?: () => void; 8 | }; 9 | 10 | const ModalGeneral = ({ 11 | modalIsOpen, 12 | closeModal, 13 | title, 14 | onClick, 15 | }: ModalGeneralProps) => { 16 | return ( 17 | 18 |
19 |

{title}

20 |
21 | 24 | 27 |
28 |
29 |
30 | ); 31 | }; 32 | 33 | export default ModalGeneral; 34 | -------------------------------------------------------------------------------- /src/components/BoardHeader/BoardHeader.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | 5 | .board-header { 6 | background-color: colors.$primary-light; 7 | z-index: 100; 8 | width: 100%; 9 | } 10 | 11 | .nav { 12 | @include flex(row, space-between); 13 | padding: 1rem 3rem; 14 | // border-bottom: 1px solid colors.$primary-dark; 15 | 16 | &__left { 17 | @include flex(row, space-between); 18 | width: 50%; 19 | } 20 | 21 | &__icon { 22 | width: 2.5rem; 23 | transform: rotate(-45deg); 24 | margin-right: 2rem; 25 | cursor: pointer; 26 | } 27 | 28 | &__title { 29 | @include sub-header; 30 | flex: 1; 31 | border: none; 32 | 33 | &:focus { 34 | outline: none; 35 | } 36 | } 37 | 38 | &__right-container { 39 | @include flex(row, space-between); 40 | } 41 | 42 | &__btn { 43 | @include nav-btn; 44 | margin-left: 3rem; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/components/DarkModeBtn/DarkModeBtn.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import "./DarkModeBtn.scss"; 3 | 4 | const DarkModeBtn = () => { 5 | const [isDark, setIsDark] = useState(false); 6 | 7 | const handleToDark = () => { 8 | setIsDark(true); 9 | }; 10 | const handleToLight = () => { 11 | setIsDark(false); 12 | }; 13 | 14 | return ( 15 |
16 | {" "} 25 | 34 |
35 | ); 36 | }; 37 | 38 | export default DarkModeBtn; 39 | -------------------------------------------------------------------------------- /src/pages/ExploreBoardPage/ExploreBoardPage.tsx: -------------------------------------------------------------------------------- 1 | import ReactFlow, { 2 | Background, 3 | ReactFlowProvider, 4 | MiniMap, 5 | BackgroundVariant, 6 | } from "reactflow"; 7 | import "reactflow/dist/base.css"; 8 | import "./ExploreBoardPage.scss"; 9 | import ExploreBoardHeader from "../../components/ExploreBoardHeader/ExploreBoardHeader"; 10 | import useNodeTypes from "../../hooks/useNodeTypes"; 11 | import useFetchPins from "../../hooks/useFetchPins"; 12 | 13 | const nodeTypes = useNodeTypes(); 14 | const ExploreBoardPage = () => { 15 | const { nodes } = useFetchPins(); 16 | return ( 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | ); 27 | }; 28 | 29 | export default ExploreBoardPage; 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": [ 6 | "ES2020", 7 | "DOM", 8 | "DOM.Iterable" 9 | ], 10 | "module": "ESNext", 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "preserve", 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "allowJs": true, 24 | "forceConsistentCasingInFileNames": true, 25 | "incremental": true, 26 | "plugins": [ 27 | { 28 | "name": "next" 29 | } 30 | ] 31 | }, 32 | "include": [ 33 | "./src", 34 | "./dist/types/**/*.ts", 35 | "./next-env.d.ts", 36 | ".next/types/**/*.ts" 37 | ], 38 | "exclude": [ 39 | "./node_modules" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /src/components/ExploreBoardHeader/ExploreBoardHeader.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useRouter } from "next/navigation"; 3 | 4 | const ExploreBoardHeader = () => { 5 | const arrow = "/icons/arrow-N-default.svg"; 6 | const arrowHover = "/icons/arrow-N-selected.svg"; 7 | const router = useRouter(); 8 | const [isHover, setIsHover] = useState(false); 9 | let token: string | null; 10 | useEffect(() => { 11 | token = localStorage.getItem("token"); 12 | }); 13 | 14 | const handleArrowClick = () => { 15 | if (!token) { 16 | router.push("/demo/explore"); 17 | return; 18 | } 19 | router.push("/explore"); 20 | }; 21 | return ( 22 | arrow { 28 | setIsHover(true); 29 | }} 30 | onMouseLeave={() => { 31 | setIsHover(false); 32 | }} 33 | /> 34 | ); 35 | }; 36 | 37 | export default ExploreBoardHeader; 38 | -------------------------------------------------------------------------------- /src/pages/BoardPage/BoardPage.tsx: -------------------------------------------------------------------------------- 1 | import BoardHeader from "../../components/BoardHeader/BoardHeader"; 2 | import "./BoardPage.scss"; 3 | import { ReactFlowProvider } from "reactflow"; 4 | import "reactflow/dist/base.css"; 5 | import Flow from "../../components/Flow/Flow"; 6 | // import GridBtn from "../../components/GridBtn/GridBtn"; 7 | // import DarkModeBtn from "../../components/DarkModeBtn/DarkModeBtn"; 8 | // import { useIsGrid } from "../../hooks/useIsGrid"; 9 | 10 | const BoardPage = () => { 11 | // const { isGrid, handleChangeGrid } = useIsGrid(false); 12 | 13 | return ( 14 | <> 15 |
16 | {/*
17 | 18 | 19 |
*/} 20 | 21 | 22 |
23 | 24 |
25 |
26 |
27 | 28 | ); 29 | }; 30 | 31 | export default BoardPage; 32 | -------------------------------------------------------------------------------- /src/components/FilterAside/FilterAside.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | 5 | .categories { 6 | @include flex(column, flex-start, flex-start); 7 | &__wrapper { 8 | margin-bottom: 0.25rem; 9 | } 10 | &__label { 11 | color: colors.$secondary-gray; 12 | &--all { 13 | margin-bottom: 1.25rem; 14 | } 15 | @include side-nav-btn; 16 | } 17 | 18 | &__radio { 19 | display: none; 20 | &:checked { 21 | & + label { 22 | color: colors.$primary-dark; 23 | font-style: italic; 24 | } 25 | } 26 | } 27 | } 28 | 29 | .filter { 30 | @include flex(column, flex-start, flex-start); 31 | margin-top: 2rem; 32 | &__input { 33 | display: none; 34 | } 35 | &__label { 36 | color: colors.$secondary-gray; 37 | margin-bottom: 0.25rem; 38 | @include side-nav-btn; 39 | } 40 | &__radio { 41 | display: none; 42 | &:checked { 43 | & + label { 44 | color: colors.$primary-dark; 45 | font-style: italic; 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/components/LoginBox/LoginBox.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, screen } from "@testing-library/react"; 2 | import { describe, expect, it, vitest } from "vitest"; 3 | import LoginBox from "./LoginBox"; 4 | import renderWithRouter from "../../__tests__/utils/renderWithRouter"; 5 | import axios from "axios"; 6 | vitest.mock("axios"); 7 | axios.get = vitest.fn(); 8 | 9 | describe("LoginBox tests", () => { 10 | it("should render", () => { 11 | renderWithRouter(); 12 | expect(screen.getByText(/are you new around here?/)).toBeInTheDocument(); 13 | }); 14 | 15 | it("displays 4 errors when 4 empty inputs", () => { 16 | renderWithRouter(); 17 | 18 | const submitBtn = screen.getByText(/login/); 19 | fireEvent.click(submitBtn); 20 | const errors = screen.getAllByText(/this field is required/); 21 | 22 | expect(errors.length).toBe(4); 23 | }); 24 | 25 | it("shows different msg when register", () => { 26 | renderWithRouter(); 27 | 28 | fireEvent.click(screen.getByText(/are you new around here?/)); 29 | 30 | expect(screen.getByText(/not my first time here/)).toBeInTheDocument(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/pages/DashBoardPage/DashBoardPage.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | 5 | .new-project { 6 | @include flex(row, flex-end); 7 | margin-bottom: 1rem; 8 | width: 100%; 9 | &__btn { 10 | @include secondary-btn; 11 | @include cta-btn; 12 | color: colors.$primary-light; 13 | padding: 0.5rem 1rem; 14 | } 15 | } 16 | 17 | .dashboard-main { 18 | @include flex(row, space-between, flex-start); 19 | max-height: 100%; 20 | width: 100%; 21 | 22 | &__nav { 23 | @include flex(column, flex-start, flex-start); 24 | background-color: colors.$primary-light; 25 | height: 80vh; 26 | padding: 1rem 1.5rem; 27 | } 28 | 29 | &__folder { 30 | @include nav-btn; 31 | padding: 1rem 0; 32 | // border-bottom: 1px solid colors.$secondary-gray; 33 | width: 14rem; 34 | text-align: left; 35 | } 36 | 37 | &__projects { 38 | max-height: 80vh; 39 | margin-left: 1rem; 40 | width: 10rem; 41 | flex: 1; 42 | // padding: 0 15rem; 43 | 44 | @include flex(column, flex-start, center); 45 | overflow: scroll; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/components/ToolMenu/ToolMenu.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | .tool { 5 | &__title { 6 | @include primary-btn; 7 | margin-top: 1rem; 8 | 9 | &--active { 10 | font-style: italic; 11 | } 12 | } 13 | 14 | &__item-container { 15 | overflow: hidden; 16 | transition: height 0.1s ease-out; 17 | 18 | &--shown { 19 | overflow: auto; 20 | transition: height 0.1s ease-out; 21 | } 22 | } 23 | 24 | &__item { 25 | margin-top: 0.7rem; 26 | @include secondary-btn; 27 | color: colors.$secondary-gray; 28 | 29 | &--cross-out { 30 | cursor: not-allowed; 31 | &:hover { 32 | text-decoration: line-through; 33 | } 34 | } 35 | } 36 | 37 | &__input { 38 | @include text-input; 39 | width: 7rem; 40 | padding: 0.2rem 0rem; 41 | border: none; 42 | border-bottom: 1px solid colors.$primary-dark; 43 | background-color: transparent; 44 | margin-top: 0.3rem; 45 | 46 | &:focus { 47 | outline: none; 48 | } 49 | &::placeholder { 50 | color: #bbb8b8; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/hooks/useTools.ts: -------------------------------------------------------------------------------- 1 | import getRandomCoords from "../utils/get-random-coords"; 2 | import getRandomHex from "../utils/get-random-hex"; 3 | import { useReactFlow } from "reactflow"; 4 | import { nanoid } from "nanoid"; 5 | 6 | const useTools = () => { 7 | const { addNodes } = useReactFlow(); 8 | const addRandomColor = () => { 9 | addNodes({ 10 | id: nanoid(10), 11 | type: "ColorSelectorNode", 12 | data: { color: getRandomHex() }, 13 | position: getRandomCoords(), 14 | }); 15 | }; 16 | const addTextBox = () => { 17 | addNodes({ 18 | id: nanoid(10), 19 | type: "TextNode", 20 | data: { text: "" }, 21 | position: getRandomCoords(), 22 | }); 23 | }; 24 | 25 | const tools = [ 26 | { 27 | id: "JGoSQH", 28 | name: "color", 29 | onClick: addRandomColor, 30 | }, 31 | { 32 | id: "hx7Fie", 33 | name: "text", 34 | onClick: addTextBox, 35 | }, 36 | { id: "n58mzZ", name: "line", onClick: () => {}, notWorking: true }, 37 | // { 38 | // id: "TbXW6L", 39 | // name: "shape", 40 | // onClick: () => {}, 41 | // }, 42 | ]; 43 | return { tools }; 44 | }; 45 | 46 | export default useTools; 47 | -------------------------------------------------------------------------------- /src/components/Input/Input.tsx: -------------------------------------------------------------------------------- 1 | import InputError from "../InputError/InputError"; 2 | import React from "react"; 3 | import "./Input.scss"; 4 | type InputProps = { 5 | name: string; 6 | label: string; 7 | placeholder: string; 8 | className?: string; 9 | isPassword?: boolean; 10 | handleChange: (e: React.ChangeEvent) => void; 11 | tabIndex?: number; 12 | msg: string; 13 | disabled: boolean; 14 | }; 15 | const Input = ({ 16 | name, 17 | label, 18 | placeholder, 19 | className, 20 | isPassword, 21 | handleChange, 22 | tabIndex, 23 | msg, 24 | disabled, 25 | }: InputProps) => { 26 | return ( 27 |
28 | 31 | 41 | {msg && } 42 |
43 | ); 44 | }; 45 | 46 | export default Input; 47 | -------------------------------------------------------------------------------- /src/components/MainHeader/MainHeader.tsx: -------------------------------------------------------------------------------- 1 | import { usePathname } from "next/navigation"; 2 | import useIsDemo from "../../hooks/useIsDemo"; 3 | import "./MainHeader.scss"; 4 | import Link from "next/link"; 5 | 6 | const MainHeader = () => { 7 | const isDemo = useIsDemo(); 8 | const pathname = usePathname(); 9 | 10 | return ( 11 |
12 | 35 |
36 | ); 37 | }; 38 | 39 | export default MainHeader; 40 | -------------------------------------------------------------------------------- /src/components/TextNode/TextNode.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useState } from "react"; 2 | import { NodeProps, NodeResizer, useReactFlow } from "reactflow"; 3 | import "./TextNode.scss"; 4 | import NodeWrapper from "../NodeWrapper/NodeWrapper"; 5 | 6 | const TextNode = memo(({ selected, data, id }: NodeProps) => { 7 | const [text, setText] = useState(data.text); 8 | const { setNodes, getNodes, getNode } = useReactFlow(); 9 | 10 | const handleTextChange = (e: React.ChangeEvent) => { 11 | setText(e.target.value); 12 | const selectedNode: any = getNode(id); 13 | selectedNode.data.text = e.target.value; 14 | const nodes = getNodes(); 15 | const filteredNodes = nodes.filter((node) => node.id !== id); 16 | setNodes([...filteredNodes, selectedNode]); 17 | }; 18 | return ( 19 | <> 20 | 26 | 27 | 33 | 34 | 35 | ); 36 | }); 37 | 38 | export default TextNode; 39 | -------------------------------------------------------------------------------- /src/components/ModalDefinition/ModalDefinition.tsx: -------------------------------------------------------------------------------- 1 | import "./ModalDefinition.scss"; 2 | import Modal from "react-modal"; 3 | 4 | export type Definition = { 5 | partOfSpeech: string; 6 | definitions: string[]; 7 | }; 8 | type ModalDefinitionProps = { 9 | modalIsOpen: boolean; 10 | closeModal: () => void; 11 | definitions: Definition[]; 12 | chosenWord: string; 13 | }; 14 | 15 | const ModalDefinition = ({ 16 | modalIsOpen, 17 | closeModal, 18 | definitions, 19 | chosenWord, 20 | }: ModalDefinitionProps) => { 21 | return ( 22 | 23 |
24 |

{chosenWord}

25 | {definitions.map((word, index) => ( 26 |
27 |

28 | {word.partOfSpeech} 29 |

30 |
    31 | {word.definitions.map((definition, index) => ( 32 |
  • 33 | {definition} 34 |
  • 35 | ))} 36 |
37 |
38 | ))} 39 |
40 |
41 | ); 42 | }; 43 | 44 | export default ModalDefinition; 45 | -------------------------------------------------------------------------------- /src/components/ModalInput/ModalInput.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | 5 | .modal { 6 | width: 50rem; 7 | background: colors.$primary-light; 8 | @include flex(column, center, flex-start); 9 | border-radius: 2px; 10 | 11 | &__title { 12 | @include section-header; 13 | font-size: 2rem; 14 | margin-bottom: 0.2rem; 15 | } 16 | 17 | &__paragraph { 18 | @include body; 19 | margin-bottom: 1rem; 20 | } 21 | } 22 | .url-form { 23 | @include flex(column); 24 | width: 100%; 25 | 26 | &__input { 27 | padding: 0.5rem; 28 | font-family: var(--font-ibm-mono); 29 | @include text-input; 30 | border: none; 31 | border-bottom: 1px solid colors.$primary-dark; 32 | background-color: transparent; 33 | margin-bottom: 1rem; 34 | width: 100%; 35 | 36 | &:focus { 37 | outline: none; 38 | } 39 | } 40 | &__btn { 41 | @include primary-btn; 42 | @include cta-btn; 43 | color: colors.$primary-light; 44 | padding: 0.6rem 1rem; 45 | margin-top: 1rem; 46 | align-self: flex-start; 47 | } 48 | } 49 | 50 | .popup-overlay { 51 | -webkit-backdrop-filter: blur(5px); 52 | backdrop-filter: blur(5px); 53 | background-color: colors.$modal-overlay; 54 | } 55 | -------------------------------------------------------------------------------- /src/hooks/useFilterAside.ts: -------------------------------------------------------------------------------- 1 | import React, { useState, Dispatch, SetStateAction } from "react"; 2 | import { Board } from "../data/demo-dashboard"; 3 | 4 | const useFilterAside = ( 5 | boards: Board[], 6 | setBoards: Dispatch> 7 | ) => { 8 | const [filterOptions, setFilterOptions] = useState({ 9 | category: "all", 10 | filter: "recent", 11 | }); 12 | 13 | const handleOptionChange = (e: React.ChangeEvent) => { 14 | setFilterOptions({ 15 | ...filterOptions, 16 | [e.target.name]: e.target.value, 17 | }); 18 | 19 | switch (e.target.value) { 20 | case "oldest": 21 | setBoards( 22 | boards.sort((a, b) => { 23 | return ( 24 | new Date(a.created_at).valueOf() - 25 | new Date(b.created_at).valueOf() 26 | ); 27 | }) 28 | ); 29 | break; 30 | case "recent": 31 | setBoards( 32 | boards.sort((a, b) => { 33 | return ( 34 | new Date(b.created_at).valueOf() - 35 | new Date(a.created_at).valueOf() 36 | ); 37 | }) 38 | ); 39 | break; 40 | default: 41 | break; 42 | } 43 | }; 44 | 45 | return { filterOptions, handleOptionChange }; 46 | }; 47 | 48 | export default useFilterAside; 49 | -------------------------------------------------------------------------------- /src/components/ProjectCard/ProjectCard.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | 5 | .project-wrapper { 6 | position: relative; 7 | margin-bottom: 2rem; 8 | cursor: pointer; 9 | } 10 | .project { 11 | margin-right: 2rem; 12 | min-width: 45rem; 13 | height: 35rem; 14 | background-repeat: no-repeat; 15 | background-position: center; 16 | background-size: cover; 17 | @include flex(column, space-between, flex-start); 18 | padding: 1rem; 19 | transition: all 80ms ease-in; 20 | position: relative; 21 | 22 | &:hover { 23 | transform: translateY(2rem); 24 | } 25 | 26 | &__title { 27 | @include sub-header; 28 | z-index: 100; 29 | position: absolute; 30 | font-size: 1.875rem; 31 | } 32 | 33 | &__author { 34 | position: absolute; 35 | bottom: 0.5rem; 36 | left: 0; 37 | @include body; 38 | font-size: 1.5rem; 39 | } 40 | 41 | &__details-container { 42 | width: 30%; 43 | opacity: 0; 44 | 45 | &--shown { 46 | opacity: 1; 47 | } 48 | } 49 | 50 | &__caption { 51 | @include caption; 52 | font-family: var(--font-corporate-pro); 53 | } 54 | 55 | &__category { 56 | @include chip-large; 57 | } 58 | 59 | &__date { 60 | @include chip-large; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/components/ToolBar/ToolBar.tsx: -------------------------------------------------------------------------------- 1 | import ToolMenu from "../ToolMenu/ToolMenu"; 2 | import "./ToolBar.scss"; 3 | import ModalInput from "../ModalInput/ModalInput"; 4 | import useTools from "../../hooks/useTools"; 5 | import ColorTools from "../ColorTools/ColorTools"; 6 | import ModalUpload from "../ModalUpload/ModalUpload"; 7 | import ModalDefinition from "../ModalDefinition/ModalDefinition"; 8 | import useWordTools from "../../hooks/useWordTools"; 9 | 10 | const ToolBar = () => { 11 | const { 12 | wordTools, 13 | chosenWord, 14 | handleWordChange, 15 | closeModal, 16 | definitions, 17 | isModalOpen, 18 | } = useWordTools(); 19 | const { tools } = useTools(); 20 | 21 | return ( 22 | <> 23 | 37 | 43 | 44 | ); 45 | }; 46 | 47 | export default ToolBar; 48 | -------------------------------------------------------------------------------- /src/utils/get-domain.ts: -------------------------------------------------------------------------------- 1 | const isUrlValid = (string: string) => { 2 | try { 3 | new URL(string); 4 | return true; 5 | } catch (err) { 6 | return false; 7 | } 8 | }; 9 | 10 | const getDomain = (url: string) => { 11 | url = url.replace("https://", ""); 12 | url = url.replace("http://", ""); 13 | url = url.replace("www.", ""); 14 | url = url.replace("open.", ""); 15 | const array = url.split("."); 16 | return array[0] === "m" ? array[1] : array[0]; 17 | }; 18 | 19 | const getYoutubeId = (url: string) => { 20 | const urlArray = url.split("?"); 21 | const queryParams = urlArray[1].split("&"); 22 | const index = queryParams.findIndex((el) => el.includes("v")); 23 | 24 | return queryParams[index].split("=")[1]; 25 | }; 26 | 27 | const getYoutuId = (url: string) => { 28 | const domainArray = url.split("?")[0].split("/"); 29 | return domainArray[domainArray.length - 1]; 30 | }; 31 | 32 | //https://open.spotify.com/track/5LsmuKO5tsF8budo3nVbRp?si=e691837d9fd84b58 33 | const getSpotifyId = (url: string) => { 34 | const domainArray = url.split("?")[0].split("/"); 35 | return domainArray[domainArray.length - 1]; 36 | }; 37 | // https://www.pinterest.co.uk/pin/766597167849640881/ 38 | const getPinterestId = (url: string) => { 39 | const array = url.split("/"); 40 | const index = array.findIndex((el) => el === "pin"); 41 | return array[index + 1]; 42 | }; 43 | export { 44 | isUrlValid, 45 | getDomain, 46 | getYoutubeId, 47 | getYoutuId, 48 | getSpotifyId, 49 | getPinterestId, 50 | }; 51 | -------------------------------------------------------------------------------- /src/pages/HomePage/HomePage.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import TypingAnimation from "../../components/TypingAnimation/TypingAnimation"; 3 | import "./HomePage.scss"; 4 | // import logoBlack from "../../assets/logos/logo-black.svg"; 5 | // import logoBlue from "../../assets/logos/logo-blue.svg"; 6 | import { useState } from "react"; 7 | import Link from "next/link"; 8 | import Image from "next/image"; 9 | 10 | const HomePage = () => { 11 | const [isLogoBlack, setIsLogoBlack] = useState(true); 12 | 13 | const handleMouseEnter = () => { 14 | setIsLogoBlack(false); 15 | }; 16 | 17 | const handleMouseLeave = () => { 18 | setIsLogoBlack(true); 19 | }; 20 | 21 | return ( 22 |
23 | 24 |
25 | 26 | studio logo 35 |

41 | (click to enter) 42 |

43 | 44 |
45 |
46 | ); 47 | }; 48 | 49 | export default HomePage; 50 | -------------------------------------------------------------------------------- /src/components/ContextMenu/ContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { useReactFlow } from "reactflow"; 3 | import "./ContextMenu.scss"; 4 | import { nanoid } from "nanoid"; 5 | type ContextMenuProps = { 6 | id: string; 7 | top?: number | undefined; 8 | left?: number | undefined; 9 | right?: number | undefined; 10 | bottom?: number | undefined; 11 | onClick: () => void; 12 | }; 13 | const ContextMenu = ({ 14 | id, 15 | top, 16 | left, 17 | right, 18 | bottom, 19 | ...props 20 | }: ContextMenuProps) => { 21 | const { getNode, setNodes, addNodes, setEdges } = useReactFlow(); 22 | const duplicateNode = useCallback(() => { 23 | const node = getNode(id)!; 24 | 25 | const position = { 26 | x: node.position.x + 50, 27 | y: node.position.y + 50, 28 | }; 29 | addNodes({ ...node, id: nanoid(10), position }); 30 | }, [id, getNode, addNodes]); 31 | 32 | const deleteNode = useCallback(() => { 33 | setNodes((nodes) => nodes.filter((node) => node.id !== id)); 34 | setEdges((edges) => edges.filter((edge) => edge.source !== id)); 35 | }, [id, setNodes, setEdges]); 36 | 37 | return ( 38 |
42 |

node id: {id}

43 |

44 | duplicate 45 |

46 |

47 | delete 48 |

49 |
50 | ); 51 | }; 52 | 53 | export default ContextMenu; 54 | -------------------------------------------------------------------------------- /src/components/Input/Input.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render, screen } from "@testing-library/react"; 2 | import { describe, expect, it, vitest } from "vitest"; 3 | import Input from "./Input"; 4 | 5 | const onChangeMock = vitest.fn(); 6 | const renderDefaultInput = () => { 7 | render( 8 | 16 | ); 17 | }; 18 | 19 | describe("input test", () => { 20 | it("renders default input", () => { 21 | renderDefaultInput(); 22 | expect(screen.getByLabelText(/username/)).toBeInTheDocument(); 23 | expect(screen.getByPlaceholderText(/enter username/)).toBeInTheDocument(); 24 | }); 25 | 26 | it("responds to change event", () => { 27 | renderDefaultInput(); 28 | const event = { 29 | preventDefault() {}, 30 | target: { value: "test username" }, 31 | }; 32 | const input = screen.getByPlaceholderText(/enter username/); 33 | act(() => { 34 | fireEvent.change(input, event); 35 | }); 36 | expect(screen.getByDisplayValue(/test username/)).toBeInTheDocument(); 37 | }); 38 | 39 | it("renders error message", () => { 40 | render( 41 | 49 | ); 50 | expect(screen.getByText(/test error message/)).toBeInTheDocument(); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "studio-scafold", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "next dev", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 11 | "test": "vitest" 12 | }, 13 | "dependencies": { 14 | "@testing-library/user-event": "^14.5.2", 15 | "axios": "^1.6.5", 16 | "html-to-image": "^1.11.11", 17 | "nanoid": "^5.0.4", 18 | "next": "^14.2.4", 19 | "rctx-contextmenu": "^1.4.1", 20 | "react": "^18.2.0", 21 | "react-color": "^2.19.3", 22 | "react-dom": "^18.2.0", 23 | "react-dropzone": "^14.2.3", 24 | "react-modal": "^3.16.1", 25 | "react-type-animation": "^3.2.0", 26 | "reactflow": "^11.10.2", 27 | "reactjs-popup": "^2.0.6" 28 | }, 29 | "devDependencies": { 30 | "@testing-library/jest-dom": "^6.4.2", 31 | "@testing-library/react": "^14.2.1", 32 | "@types/react": "^18.2.55", 33 | "@types/react-dom": "^18.2.19", 34 | "@types/react-modal": "^3.16.3", 35 | "@vitejs/plugin-react-swc": "^3.5.0", 36 | "@vitest/coverage-v8": "^1.3.1", 37 | "@vitest/ui": "^1.3.1", 38 | "autoprefixer": "^10.4.19", 39 | "eslint": "^8.55.0", 40 | "eslint-plugin-react": "^7.33.2", 41 | "eslint-plugin-react-hooks": "^4.6.0", 42 | "eslint-plugin-react-refresh": "^0.4.5", 43 | "jest": "^29.7.0", 44 | "jsdom": "^24.0.0", 45 | "postcss": "^8.4.39", 46 | "postcss-import": "^16.1.0", 47 | "sass": "^1.77.6", 48 | "tailwindcss": "^3.4.4", 49 | "typescript": "^5.3.3", 50 | "vite": "^5.0.8", 51 | "vitest": "^1.3.1" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/pages/ProfilePage/ProfilePage.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | 5 | .profile-main { 6 | width: 100%; 7 | flex: 1; 8 | @include flex(row, space-between, flex-start); 9 | padding-top: 5rem; 10 | padding-left: 0.625rem; 11 | 12 | &__left { 13 | width: 100%; 14 | } 15 | } 16 | 17 | .profile { 18 | @include flex(row, space-between, flex-start); 19 | flex: 1; 20 | width: 80%; 21 | margin-bottom: 3rem; 22 | 23 | &__title { 24 | @include sub-header; 25 | } 26 | 27 | &__inputs { 28 | @include flex(column, space-between, flex-start); 29 | width: 100%; 30 | margin-left: 12rem; 31 | } 32 | &__input-wrapper { 33 | @include flex(row, flex-start); 34 | height: 2rem; 35 | width: 100%; 36 | margin-bottom: 1rem; 37 | } 38 | 39 | &__input { 40 | align-self: flex-end; 41 | flex: 1; 42 | @include text-input; 43 | height: 100%; 44 | border: none; 45 | padding: 0.5rem; 46 | &:hover { 47 | background-color: colors.$accent; 48 | } 49 | &:focus { 50 | outline: none; 51 | } 52 | } 53 | &__label { 54 | @include body; 55 | margin-right: 1rem; 56 | } 57 | 58 | &__btn { 59 | @include secondary-btn; 60 | @include cta-btn; 61 | color: colors.$primary-light; 62 | padding: 0.3rem 0.6rem; 63 | 64 | &--hidden { 65 | display: none; 66 | } 67 | } 68 | } 69 | 70 | .feedback { 71 | flex: 1; 72 | @include flex(column, flex-start, flex-end); 73 | min-width: 18rem; 74 | 75 | &__btn { 76 | @include secondary-btn; 77 | @include cta-underline; 78 | margin-bottom: 2rem; 79 | margin-right: 4rem; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/hooks/useHandleThumbnail.ts: -------------------------------------------------------------------------------- 1 | import { toPng } from "html-to-image"; 2 | import { getViewportForBounds, getNodesBounds, useReactFlow } from "reactflow"; 3 | import axios from "axios"; 4 | 5 | const useHandleThumbnail = () => { 6 | const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; 7 | const { getNodes } = useReactFlow(); 8 | const postThumbnail = async (dataUrl: string) => { 9 | const arr: any = dataUrl.split(","); 10 | const mime = arr[0].match(/:(.*?);/)[1]; 11 | const bstr = atob(arr[arr.length - 1]); 12 | let n = bstr.length; 13 | const u8arr = new Uint8Array(n); 14 | while (n--) { 15 | u8arr[n] = bstr.charCodeAt(n); 16 | } 17 | const thumbnailFile = new File([u8arr], "thumbnail.jpg", { type: mime }); 18 | 19 | const formData = new FormData(); 20 | formData.append("file", thumbnailFile); 21 | const { data } = await axios.post(baseUrl + "/upload/thumbnail", formData); 22 | return data; 23 | }; 24 | const imageWidth = 1024; 25 | const imageHeight = 768; 26 | const handleThumbnail = async () => { 27 | const nodesBounds = getNodesBounds(getNodes()); 28 | const transform: any = getViewportForBounds( 29 | nodesBounds, 30 | imageWidth, 31 | imageHeight, 32 | 0.5, 33 | 2 34 | ); 35 | 36 | const pngFile = await toPng( 37 | document.querySelector(".react-flow__viewport") as HTMLElement, 38 | { 39 | backgroundColor: "#fff", 40 | width: imageWidth, 41 | height: imageHeight, 42 | style: { 43 | width: String(imageWidth), 44 | height: String(imageHeight), 45 | transform: `translate(${transform[0]}px, ${transform[1]}px) scale(${transform[2]})`, 46 | }, 47 | } 48 | ); 49 | const response = postThumbnail(pngFile); 50 | return response; 51 | }; 52 | 53 | return { handleThumbnail }; 54 | }; 55 | 56 | export default useHandleThumbnail; 57 | -------------------------------------------------------------------------------- /src/pages/LoginPage/LoginPage.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | 5 | .login-main { 6 | padding: 4rem; 7 | @include flex(row, space-between); 8 | height: 100vh; 9 | min-height: 100vh; 10 | 11 | &--gray { 12 | background-color: colors.$primary-gray; 13 | } 14 | } 15 | 16 | .login-nav { 17 | @include flex(column, space-between, flex-start); 18 | height: 30%; 19 | &__title { 20 | @include page-header; 21 | } 22 | &__span { 23 | display: block; 24 | margin-bottom: 0.2rem; 25 | } 26 | 27 | &__btn-container { 28 | @include flex(column, space-between, flex-start); 29 | } 30 | 31 | &__btn { 32 | @include primary-btn; 33 | @include cta-btn; 34 | padding: 0.5rem 1.5rem; 35 | margin: 0.3rem 0; 36 | color: colors.$primary-light; 37 | width: 100%; 38 | } 39 | } 40 | 41 | .login-box { 42 | flex: 1; 43 | padding: 0 20%; 44 | display: none; 45 | 46 | &--shown { 47 | display: block; 48 | } 49 | } 50 | 51 | .info { 52 | height: 100vh; 53 | padding: 15rem 4rem; 54 | @include flex(row, flex-start, flex-end); 55 | &__left { 56 | @include flex(column, space-between, flex-start); 57 | height: 100%; 58 | width: 30rem; 59 | } 60 | 61 | &__right { 62 | width: 30rem; 63 | margin-left: 10rem; 64 | padding-bottom: 3rem; 65 | } 66 | 67 | &__title { 68 | @include section-header; 69 | margin-bottom: 1rem; 70 | } 71 | 72 | &__paragraph { 73 | @include body; 74 | margin-bottom: 0.5rem; 75 | } 76 | 77 | &__link-container { 78 | margin-top: 3rem; 79 | } 80 | 81 | &__link { 82 | @include cta-underline; 83 | text-decoration: underline; 84 | } 85 | } 86 | 87 | .login-footer { 88 | @include flex(row, flex-end, flex-end); 89 | padding: 1rem; 90 | 91 | &__name { 92 | margin-right: 1rem; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/hooks/useFetchPins.ts: -------------------------------------------------------------------------------- 1 | import { useNodesState } from "reactflow"; 2 | import { useEffect } from "react"; 3 | import { useParams } from "next/navigation"; 4 | import axios from "axios"; 5 | import useIsDemo, { IsDemo } from "./useIsDemo"; 6 | import demoPins, { Pin } from "../data/demo-pins"; 7 | 8 | const useFetchPins = () => { 9 | const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; 10 | const [nodes, setNodes, onNodesChange] = useNodesState([]); 11 | const params = useParams<{ boardId: string }>(); 12 | const boardId = params?.boardId; 13 | const isDemo: IsDemo = useIsDemo(); 14 | 15 | useEffect(() => { 16 | if (isDemo) { 17 | const filteredDemoPins = demoPins.filter( 18 | (pin) => pin.board_id === boardId 19 | ); 20 | const formattedDemoPins = filteredDemoPins.map((pin) => { 21 | return { 22 | id: pin.id, 23 | type: pin.type, 24 | data: pin.data, 25 | position: { 26 | x: pin.x_coord, 27 | y: pin.y_coord, 28 | }, 29 | style: { 30 | height: pin.height, 31 | width: pin.height, 32 | }, 33 | }; 34 | }); 35 | 36 | setNodes(formattedDemoPins); 37 | return; 38 | } 39 | const fetchPins = async () => { 40 | const { data } = await axios.get( 41 | baseUrl + "/boards/" + boardId + "/pins" 42 | ); 43 | const formattedPins = data.map((pin: Pin) => { 44 | return { 45 | id: pin.id, 46 | type: pin.type, 47 | data: JSON.parse(pin.data), 48 | position: { 49 | x: pin.x_coord, 50 | y: pin.y_coord, 51 | }, 52 | style: { 53 | height: pin.height, 54 | width: pin.height, 55 | }, 56 | }; 57 | }); 58 | setNodes(formattedPins); 59 | }; 60 | fetchPins(); 61 | }, []); 62 | 63 | return { nodes, onNodesChange }; 64 | }; 65 | export default useFetchPins; 66 | -------------------------------------------------------------------------------- /src/app/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 16 | 26 | 36 | 43 | 50 | 57 | 58 | -------------------------------------------------------------------------------- /public/logos/logo-small.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 16 | 26 | 36 | 43 | 50 | 57 | 58 | -------------------------------------------------------------------------------- /src/components/ToolMenu/ToolMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import "./ToolMenu.scss"; 3 | import WipBtn from "../WipBtn/WipBtn"; 4 | type ToolMenuProps = { 5 | title: string; 6 | list: any[]; 7 | heightValue: string; 8 | className?: string; 9 | isWordTool?: boolean; 10 | handleWordChange?: (e: React.ChangeEvent) => void; 11 | chosenWord?: string; 12 | }; 13 | const ToolMenu = ({ 14 | title, 15 | list, 16 | heightValue, 17 | className, 18 | isWordTool, 19 | handleWordChange, 20 | chosenWord, 21 | }: ToolMenuProps) => { 22 | const [isItemShown, setIsItemShown] = useState(false); 23 | 24 | const showItems = () => { 25 | setIsItemShown(!isItemShown); 26 | }; 27 | return ( 28 |
29 | 36 | 37 |
    44 | {isWordTool && ( 45 |
  • 46 | 53 |
  • 54 | )} 55 | {list.map((item) => { 56 | if (item.notWorking) { 57 | return ( 58 |
  • 59 | 60 |
  • 61 | ); 62 | } 63 | return ( 64 |
  • 65 | 71 |
  • 72 | ); 73 | })} 74 |
75 |
76 | ); 77 | }; 78 | 79 | export default ToolMenu; 80 | -------------------------------------------------------------------------------- /src/hooks/useColorTools.ts: -------------------------------------------------------------------------------- 1 | import getRandomCoords from "../utils/get-random-coords"; 2 | import { 3 | getComplementaryColor, 4 | getAnalogousColors, 5 | getTriadicColors, 6 | } from "../utils/color-methods"; 7 | import { useReactFlow, useOnSelectionChange, Node } from "reactflow"; 8 | import { useState } from "react"; 9 | import { nanoid } from "nanoid"; 10 | 11 | const useColorTools = () => { 12 | const { addNodes } = useReactFlow(); 13 | const [isColorSelected, setIsColorSelected] = useState(false); 14 | const [selectedNode, setSelectedNode] = useState(null); 15 | 16 | useOnSelectionChange({ 17 | onChange: ({ nodes }) => { 18 | if (nodes.length === 1 && nodes[0].type === "ColorSelectorNode") { 19 | setSelectedNode(nodes[0]); 20 | setIsColorSelected(true); 21 | } else { 22 | setIsColorSelected(false); 23 | } 24 | }, 25 | }); 26 | 27 | const colorTools = [ 28 | { 29 | id: "5", 30 | name: "complimentary color", 31 | onClick: () => { 32 | addNodes({ 33 | id: nanoid(10), 34 | type: "ColorSelectorNode", 35 | data: { color: getComplementaryColor(selectedNode?.data.color) }, 36 | position: getRandomCoords(), 37 | }); 38 | }, 39 | }, 40 | { 41 | id: "6", 42 | name: "analogous colors", 43 | onClick: () => { 44 | const analogousColors = getAnalogousColors(selectedNode?.data.color); 45 | const newNodes = analogousColors.map((color) => { 46 | return { 47 | id: nanoid(10), 48 | type: "ColorSelectorNode", 49 | data: { color: color }, 50 | position: getRandomCoords(), 51 | }; 52 | }); 53 | addNodes(newNodes); 54 | }, 55 | }, 56 | { 57 | id: "7", 58 | name: "triadic colors", 59 | onClick: () => { 60 | const triadicColors = getTriadicColors(selectedNode?.data.color); 61 | const newNodes = triadicColors.map((color) => { 62 | return { 63 | id: nanoid(10), 64 | type: "ColorSelectorNode", 65 | data: { color: color }, 66 | position: getRandomCoords(), 67 | }; 68 | }); 69 | addNodes(newNodes); 70 | }, 71 | }, 72 | ]; 73 | return { isColorSelected, colorTools }; 74 | }; 75 | 76 | export default useColorTools; 77 | -------------------------------------------------------------------------------- /src/components/FilterAside/FilterAside.tsx: -------------------------------------------------------------------------------- 1 | import "./FilterAside.scss"; 2 | type Category = { 3 | id: string; 4 | label: string; 5 | }; 6 | type FilterAsideProps = { 7 | filterOptions: any; 8 | categories: Category[]; 9 | handleOptionChange: (e: React.ChangeEvent) => void; 10 | }; 11 | const FilterAside = ({ 12 | filterOptions, 13 | categories, 14 | handleOptionChange, 15 | }: FilterAsideProps) => { 16 | return ( 17 | <> 18 |
19 | 28 | 33 | {categories.map((category) => ( 34 |
35 | 43 | 46 |
47 | ))} 48 |
49 |
50 | 59 | 62 | 70 | 73 |
74 | 75 | ); 76 | }; 77 | 78 | export default FilterAside; 79 | -------------------------------------------------------------------------------- /src/components/ColorSelectorNode/ColorSelectorNode.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useState } from "react"; 2 | import { Node, NodeProps, NodeResizer, useReactFlow } from "reactflow"; 3 | import "./ColorSelectorNode.scss"; 4 | 5 | const ColorSelectorNode = memo(({ selected, data, id }: NodeProps) => { 6 | const [color, setColor] = useState(data.color); 7 | const { setNodes, getNodes, getNode } = useReactFlow(); 8 | const handleSaveState = (e: React.ChangeEvent) => { 9 | const selectedNode: Node = getNode(id)!; 10 | selectedNode.data.color = e.target.value; 11 | const nodes = getNodes(); 12 | const filteredNodes = nodes.filter((node) => node.id !== id); 13 | setNodes([...filteredNodes, selectedNode]); 14 | }; 15 | const handleColorChange = (e: React.ChangeEvent) => { 16 | const selectedColor = e.target.value; 17 | setColor(selectedColor); 18 | handleSaveState(e); 19 | }; 20 | 21 | const handleTextColorChange = (e: React.ChangeEvent) => { 22 | const input = e.target.value.slice(1); 23 | 24 | if (input.length > 6) { 25 | return; 26 | } 27 | 28 | if (/^[0-9a-f]+$/.test(input) || input === "") { 29 | setColor("#" + input); 30 | handleSaveState(e); 31 | return; 32 | } 33 | }; 34 | 35 | const checkColorFormat = () => { 36 | const input = color.slice(1); 37 | if (input.length < 6) { 38 | return false; 39 | } 40 | return true; 41 | }; 42 | 43 | return ( 44 | <> 45 | 51 |
52 |
57 |
58 | 64 | 71 |
72 |
73 | 74 | ); 75 | }); 76 | 77 | export default ColorSelectorNode; 78 | -------------------------------------------------------------------------------- /src/components/ModalUpload/ModalUpload.scss: -------------------------------------------------------------------------------- 1 | @use "../../styles/partials/mixins" as *; 2 | @use "../../styles/partials/font-mixins" as *; 3 | @use "../../styles/partials/colors"; 4 | 5 | .ReactModal__Overlay { 6 | -webkit-backdrop-filter: blur(5px); 7 | backdrop-filter: blur(5px); 8 | background-color: colors.$modal-overlay !important; 9 | @include flex; 10 | z-index: 150; 11 | } 12 | .ReactModal__Content { 13 | background-color: transparent !important; 14 | border: none !important; 15 | @include flex(row, space-between); 16 | position: relative !important; 17 | border-radius: 2px !important; 18 | overflow: visible !important; 19 | } 20 | 21 | .dropzone { 22 | background-color: #ffffffc0; 23 | height: 35vh; 24 | @include flex(row, flex-start, flex-end); 25 | padding: 1rem; 26 | border: 1px colors.$primary-main dashed; 27 | width: 100%; 28 | flex: 1; 29 | } 30 | 31 | .file-form { 32 | @include flex(row, space-between, flex-start); 33 | width: 100%; 34 | &__left { 35 | margin-right: 1rem; 36 | } 37 | &__info-left { 38 | height: 21rem; 39 | @include flex(column, space-between, flex-start); 40 | } 41 | &__preview { 42 | height: 35vh; 43 | max-width: 40vw; 44 | object-fit: cover; 45 | object-position: center; 46 | } 47 | &__btn { 48 | @include primary-btn; 49 | @include cta-btn; 50 | color: colors.$primary-light; 51 | padding: 0.6rem; 52 | margin-top: 1rem; 53 | align-self: flex-start; 54 | } 55 | 56 | &__name { 57 | margin-top: 1rem; 58 | align-self: flex-start; 59 | @include body; 60 | } 61 | 62 | &__info { 63 | @include body; 64 | color: colors.$primary-dark; 65 | margin-top: 1rem; 66 | align-self: flex-start; 67 | width: 15rem; 68 | 69 | &--red { 70 | margin-top: 0.5rem; 71 | color: colors.$error; 72 | } 73 | } 74 | } 75 | 76 | .upload { 77 | width: 50rem; 78 | padding: 2rem; 79 | border-radius: 2px; 80 | @include flex(column, center, flex-start); 81 | flex: 1; 82 | &__dnd { 83 | @include body; 84 | } 85 | &__title { 86 | @include section-header; 87 | font-size: 2rem; 88 | color: colors.$primary-dark; 89 | align-self: flex-start; 90 | } 91 | &__cancel { 92 | margin-top: 1rem; 93 | @include secondary-btn; 94 | @include cta-underline; 95 | text-decoration: underline; 96 | color: colors.$primary-dark; 97 | cursor: pointer; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/components/Flow/Flow.tsx: -------------------------------------------------------------------------------- 1 | import ReactFlow, { 2 | Background, 3 | BackgroundVariant, 4 | MiniMap, 5 | SelectionMode, 6 | Node, 7 | } from "reactflow"; 8 | import "reactflow/dist/base.css"; 9 | import "./Flow.scss"; 10 | import { useRef, useCallback, useState } from "react"; 11 | import ContextMenu from "../ContextMenu/ContextMenu"; 12 | import ToolBar from "../ToolBar/ToolBar"; 13 | import useNodeTypes from "../../hooks/useNodeTypes"; 14 | import useFetchPins from "../../hooks/useFetchPins"; 15 | const nodeTypes = useNodeTypes(); 16 | const Flow = () => { 17 | type MenuState = { 18 | id: string; 19 | top?: number; 20 | left?: number; 21 | right?: number; 22 | bottom?: number; 23 | }; 24 | 25 | const { nodes, onNodesChange } = useFetchPins(); 26 | const [menu, setMenu] = useState(null); 27 | const ref = useRef(null); 28 | const onNodeContextMenu = useCallback( 29 | (event: any, node: Node) => { 30 | // Prevent native context menu from showing 31 | event.preventDefault(); 32 | 33 | // Calculate position of the context menu. We want to make sure it 34 | // doesn't get positioned off-screen. 35 | const contextRef = ref.current; 36 | 37 | if (contextRef) { 38 | const pane = contextRef.getBoundingClientRect(); 39 | setMenu({ 40 | id: node.id, 41 | top: event.clientY < pane.height - 200 ? event.clientY : undefined, 42 | left: event.clientX < pane.width - 200 ? event.clientX : undefined, 43 | right: 44 | event.clientX >= pane.width - 200 45 | ? pane.width - event.clientX 46 | : undefined, 47 | bottom: 48 | event.clientY >= pane.height - 200 49 | ? pane.height - event.clientY 50 | : undefined, 51 | }); 52 | } 53 | }, 54 | [setMenu] 55 | ); 56 | const onPaneClick = useCallback(() => setMenu(null), [setMenu]); 57 | const panOnDrag = [1, 2]; 58 | 59 | return ( 60 | <> 61 | 62 | 75 | 76 | 77 | {menu && } 78 | 79 | 80 | ); 81 | }; 82 | export default Flow; 83 | -------------------------------------------------------------------------------- /src/hooks/useWordTools.ts: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { useReactFlow } from "reactflow"; 3 | import axios from "axios"; 4 | import { nanoid } from "nanoid"; 5 | import getRandomCoords from "../utils/get-random-coords"; 6 | import { Definition } from "../components/ModalDefinition/ModalDefinition"; 7 | 8 | const useWordTools = () => { 9 | const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; 10 | const [isModalOpen, setIsModalOpen] = useState(false); 11 | const [definitions, setDefinitions] = useState([]); 12 | const [chosenWord, setChosenWord] = useState(""); 13 | function openModal() { 14 | setIsModalOpen(true); 15 | } 16 | 17 | function closeModal() { 18 | setIsModalOpen(false); 19 | } 20 | const { addNodes } = useReactFlow(); 21 | 22 | const handleWordChange = (e: React.ChangeEvent) => { 23 | const word = e.target.value; 24 | setChosenWord(word.replace(/ |[0-9]|[^\w\s]|_/g, "")); 25 | }; 26 | const addWordNode = async (type: string) => { 27 | if (!chosenWord) { 28 | return; 29 | } 30 | const { data: words } = await axios.get( 31 | baseUrl + "/word/" + chosenWord + "/" + type 32 | ); 33 | 34 | if (words.length === 0) { 35 | return; 36 | } 37 | 38 | addNodes({ 39 | id: nanoid(10), 40 | type: "TextNode", 41 | data: { text: words.join(", ") }, 42 | position: getRandomCoords(), 43 | }); 44 | }; 45 | 46 | const getWordDefinition = async () => { 47 | if (!chosenWord) { 48 | return; 49 | } 50 | try { 51 | const { data } = await axios.get( 52 | baseUrl + "/word/" + chosenWord + "/definition" 53 | ); 54 | setDefinitions(data); 55 | openModal(); 56 | } catch (error) { 57 | setDefinitions([ 58 | { partOfSpeech: "could not find definitions", definitions: [] }, 59 | ]); 60 | openModal(); 61 | } 62 | }; 63 | 64 | const wordTools = [ 65 | { id: 1, name: "look up word", onClick: getWordDefinition }, 66 | { 67 | id: 2, 68 | name: "find rhyme", 69 | onClick: () => { 70 | addWordNode("rhyme"); 71 | }, 72 | }, 73 | { 74 | id: 3, 75 | name: "find synonym", 76 | onClick: () => { 77 | addWordNode("synonym"); 78 | }, 79 | }, 80 | { 81 | id: 4, 82 | name: "find antonym", 83 | onClick: () => { 84 | addWordNode("antonym"); 85 | }, 86 | }, 87 | ]; 88 | 89 | return { 90 | wordTools, 91 | chosenWord, 92 | handleWordChange, 93 | closeModal, 94 | definitions, 95 | isModalOpen, 96 | }; 97 | }; 98 | 99 | export default useWordTools; 100 | -------------------------------------------------------------------------------- /src/utils/__tests__/get-domain.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getDomain, 3 | getPinterestId, 4 | getSpotifyId, 5 | getYoutuId, 6 | getYoutubeId, 7 | isUrlValid, 8 | } from "../get-domain"; 9 | import { describe, test, expect, it } from "vitest"; 10 | 11 | describe("is url valid", () => { 12 | it("returns true", () => { 13 | const result = isUrlValid( 14 | "https://open.spotify.com/track/5LsmuKO5tsF8budo3nVbRp?si=e691837d9fd84b58" 15 | ); 16 | expect(result).toBe(true); 17 | }); 18 | it("returns false", () => { 19 | const result = isUrlValid("not valid"); 20 | expect(result).toBe(false); 21 | }); 22 | }); 23 | describe("check domain functions", () => { 24 | test("gets youtube domain", () => { 25 | expect(getDomain("https://www.youtube.com/")).toBe("youtube"); 26 | }); 27 | test("gets youtube domain 2", () => { 28 | expect( 29 | getDomain( 30 | "http://m.youtube.com/watch?v=yZv2daTWRZU&feature=em-uploademail" 31 | ) 32 | ).toBe("youtube"); 33 | }); 34 | test("gets short domain", () => { 35 | expect(getDomain("https://youtu.be/MSRaPMaup0g?si=olGN-nnRp-MqfYIg")).toBe( 36 | "youtu" 37 | ); 38 | }); 39 | test("gets pinterest domain", () => { 40 | expect(getDomain("https://www.pinterest.co.uk/")).toBe("pinterest"); 41 | }); 42 | test("gets pinterest domain short", () => { 43 | expect(getDomain("https://pin.it/6FtDWdc")).toBe("pin"); 44 | }); 45 | test("gets insta domain", () => { 46 | expect(getDomain("https://www.instagram.com/p/CL4HvpajtDh/?hl=en")).toBe( 47 | "instagram" 48 | ); 49 | }); 50 | test("gets insta domain short", () => { 51 | expect( 52 | getDomain( 53 | "https://www.instagram.com/p/CL4HvpajtDh/?utm_source=ig_web_button_share_sheet&igsh=MzRlODBiNWFlZA==" 54 | ) 55 | ).toBe("instagram"); 56 | }); 57 | test("gets spotify domain short", () => { 58 | expect( 59 | getDomain( 60 | "https://open.spotify.com/track/6UFivO2zqqPFPoQYsEMuCc?si=3981156d45604abe" 61 | ) 62 | ).toBe("spotify"); 63 | }); 64 | }); 65 | 66 | describe("get id from urls", () => { 67 | it("returns youtube id", () => { 68 | const id = getYoutubeId("https://www.youtube.com/watch?v=W13Ydr_AcjI"); 69 | expect(id).toBe("W13Ydr_AcjI"); 70 | }); 71 | 72 | it("returns youtu id", () => { 73 | const id = getYoutuId("https://youtu.be/QO4SK9yZ84E?si=lolFiSQC22nNZ6HN"); 74 | expect(id).toBe("QO4SK9yZ84E"); 75 | }); 76 | 77 | it("returns spotify id", () => { 78 | const id = getSpotifyId( 79 | "https://open.spotify.com/track/5LsmuKO5tsF8budo3nVbRp?si=e691837d9fd84b58" 80 | ); 81 | expect(id).toBe("5LsmuKO5tsF8budo3nVbRp"); 82 | }); 83 | it("returns pinterest id", () => { 84 | const id = getPinterestId( 85 | "https://www.pinterest.co.uk/pin/766597167849640881/" 86 | ); 87 | expect(id).toBe("766597167849640881"); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/components/ProjectCard/ProjectCard.tsx: -------------------------------------------------------------------------------- 1 | import "./ProjectCard.scss"; 2 | import { useState } from "react"; 3 | import { useRouter } from "next/navigation"; 4 | import useIsDemo from "../../hooks/useIsDemo"; 5 | import BoardContextMenu from "../BoardContextMenu/BoardContextMenu"; 6 | import useContextMenu from "../../hooks/useContextMenu"; 7 | import ModalGeneral from "../ModalGeneral/ModalGeneral"; 8 | import useModal from "../../hooks/useModal"; 9 | import { StaticImageData } from "next/image"; 10 | type ProjectCardProps = { 11 | title: string; 12 | imgSrc: string | StaticImageData; 13 | description: string | undefined; 14 | date: string; 15 | category: string | undefined; 16 | boardId: string; 17 | author: any; 18 | handleDelete?: () => void; 19 | }; 20 | const ProjectCard = ({ 21 | title, 22 | imgSrc, 23 | description, 24 | date, 25 | category, 26 | boardId, 27 | author, 28 | handleDelete, 29 | }: ProjectCardProps) => { 30 | const [isShown, setIsShown] = useState(false); 31 | const router = useRouter(); 32 | const isDemo = useIsDemo(); 33 | const { clicked, points, handleContext } = useContextMenu(); 34 | const { openModal, closeModal, modalIsOpen } = useModal(); 35 | 36 | const handleClick = () => { 37 | if (author) { 38 | router.push("/explore/" + boardId); 39 | return; 40 | } 41 | if (isDemo) { 42 | router.push("/demo/board/" + boardId); 43 | return; 44 | } 45 | 46 | router.push("/board/" + boardId); 47 | }; 48 | 49 | return ( 50 | <> 51 | {clicked && !author && ( 52 | 58 | )} 59 |
60 |

{!title ? "untitled" : title}

61 |
{ 65 | setIsShown(true); 66 | }} 67 | onMouseLeave={() => { 68 | setIsShown(false); 69 | }} 70 | onClick={handleClick}> 71 | {author &&

{author}

} 72 |
73 |
79 |

{description}

80 |

{date}

81 |

{category}

82 |
83 |
84 | {modalIsOpen && ( 85 | 91 | )} 92 | 93 | ); 94 | }; 95 | 96 | export default ProjectCard; 97 | -------------------------------------------------------------------------------- /src/pages/ExplorePage/ExplorePage.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import MainHeader from "../../components/MainHeader/MainHeader"; 3 | import "./ExplorePage.scss"; 4 | import ProjectCard from "../../components/ProjectCard/ProjectCard"; 5 | import axios from "axios"; 6 | import formatDate from "../../utils/format-date"; 7 | import FilterAside from "../../components/FilterAside/FilterAside"; 8 | import useFilterAside from "../../hooks/useFilterAside"; 9 | import useIsDemo from "../../hooks/useIsDemo"; 10 | import { Board } from "../../data/demo-dashboard"; 11 | 12 | const ExplorePage = () => { 13 | const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; 14 | const isDemo = useIsDemo(); 15 | const [exploreBoards, setExploreBoards] = useState([]); 16 | const { filterOptions, handleOptionChange } = useFilterAside( 17 | exploreBoards, 18 | setExploreBoards 19 | ); 20 | 21 | const categories = [ 22 | { label: "music", id: "music" }, 23 | { label: "graphic design", id: "graphicDesign" }, 24 | { label: "fashion", id: "fashion" }, 25 | { label: "photography", id: "photography" }, 26 | { label: "arts", id: "arts" }, 27 | { label: "commercial", id: "commercial" }, 28 | { label: "interior design", id: "interiorDesign" }, 29 | ]; 30 | useEffect(() => { 31 | const getExploreBoards = async () => { 32 | const token = isDemo ? "demo" : localStorage.getItem("token"); 33 | const { data } = await axios.get(baseUrl + "/boards/public", { 34 | headers: { Authorization: `Bearer ${token}` }, 35 | }); 36 | 37 | setExploreBoards(data); 38 | }; 39 | getExploreBoards(); 40 | }, []); 41 | 42 | return ( 43 |
44 | 45 |
46 | 53 |
54 | {exploreBoards.map((board) => ( 55 |
56 | 65 |
66 | ))} 67 | {/* 74 | */} 81 |
82 |
83 |
84 | ); 85 | }; 86 | 87 | export default ExplorePage; 88 | -------------------------------------------------------------------------------- /src/styles/partials/_font-mixins.scss: -------------------------------------------------------------------------------- 1 | @use "./colors"; 2 | 3 | @mixin tagline { 4 | font-size: 3.25rem; 5 | line-height: 100%; 6 | font-style: italic; 7 | font-weight: 200; 8 | } 9 | 10 | @mixin page-header { 11 | font-weight: 300; 12 | font-size: 2.125rem; 13 | line-height: 100%; 14 | font-style: italic; 15 | } 16 | 17 | @mixin section-header { 18 | font-weight: 300; 19 | font-size: 1.5rem; 20 | line-height: 120%; 21 | } 22 | 23 | @mixin sub-header { 24 | font-family: var(--font-corporate-pro); 25 | font-weight: 500; 26 | font-size: 1.5rem; 27 | line-height: 120%; 28 | } 29 | 30 | @mixin body { 31 | font-family: var(--font-corporate-pro); 32 | font-weight: 500; 33 | font-size: 1.125rem; 34 | line-height: 120%; 35 | } 36 | 37 | @mixin system-message { 38 | font-family: var(--font-corporate-pro); 39 | font-weight: 500; 40 | font-size: 1.125rem; 41 | line-height: 120%; 42 | } 43 | 44 | @mixin caption { 45 | font-weight: 300; 46 | font-size: 14px; 47 | line-height: 120%; 48 | } 49 | 50 | @mixin footer { 51 | font-weight: 200; 52 | font-size: 0.875rem; 53 | line-height: 120%; 54 | font-style: italic; 55 | } 56 | 57 | @mixin text-input { 58 | font-family: var(--font-corporate-pro); 59 | font-size: 1.25rem; 60 | line-height: 100%; 61 | font-weight: 400; 62 | } 63 | @mixin text-input-small { 64 | font-family: var(--font-corporate-pro); 65 | font-size: 1rem; 66 | line-height: 100%; 67 | } 68 | 69 | @mixin chip-large { 70 | font-family: var(--font-corporate-pro); 71 | font-size: 1rem; 72 | line-height: 140%; 73 | } 74 | 75 | @mixin chip-small { 76 | font-family: var(--font-corporate-pro); 77 | font-size: 0.875rem; 78 | line-height: 140%; 79 | } 80 | 81 | @mixin nav-btn { 82 | font-family: var(--font-corporate-pro); 83 | font-size: 1.25rem; 84 | line-height: 120%; 85 | 86 | &:hover { 87 | color: colors.$primary-main; 88 | } 89 | } 90 | 91 | @mixin side-nav-btn { 92 | font-family: var(--font-corporate-pro); 93 | font-size: 1.125rem; 94 | line-height: 120%; 95 | &:hover { 96 | color: colors.$primary-main; 97 | } 98 | } 99 | 100 | @mixin small-nav-btn { 101 | font-family: var(--font-corporate-pro); 102 | font-size: 0.75rem; 103 | line-height: 120%; 104 | &:hover { 105 | color: colors.$primary-main; 106 | } 107 | } 108 | 109 | @mixin context-menu { 110 | font-size: 0.75rem; 111 | line-height: 100%; 112 | font-weight: 300; 113 | } 114 | 115 | @mixin primary-btn { 116 | font-family: var(--font-ibm-mono); 117 | font-size: 1.25rem; 118 | font-style: normal; 119 | line-height: 100%; 120 | &:hover { 121 | font-style: italic; 122 | } 123 | } 124 | 125 | @mixin secondary-btn { 126 | font-family: var(--font-ibm-mono); 127 | font-size: 1.125rem; 128 | font-style: normal; 129 | line-height: 100%; 130 | &:hover { 131 | font-style: italic; 132 | } 133 | } 134 | 135 | @mixin tertiary-btn { 136 | font-family: var(--font-ibm-mono); 137 | font-size: 1rem; 138 | font-style: normal; 139 | line-height: 100%; 140 | text-decoration: underline; 141 | &:hover { 142 | font-style: italic; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/components/ModalInput/ModalInput.tsx: -------------------------------------------------------------------------------- 1 | import "./ModalInput.scss"; 2 | import { 3 | getDomain, 4 | getYoutubeId, 5 | getYoutuId, 6 | isUrlValid, 7 | getSpotifyId, 8 | getPinterestId, 9 | } from "../../utils/get-domain"; 10 | import { useReactFlow } from "reactflow"; 11 | import { nanoid } from "nanoid"; 12 | import getRandomCoords from "../../utils/get-random-coords"; 13 | import { FormEvent, useState } from "react"; 14 | import Modal from "react-modal"; 15 | 16 | const ModalInput = () => { 17 | const [modalIsOpen, setIsOpen] = useState(false); 18 | const { addNodes } = useReactFlow(); 19 | const handleCreateUrlPin = (e: FormEvent) => { 20 | e.preventDefault(); 21 | const formElement = e.target as HTMLFormElement; 22 | const url = formElement.url.value as string; 23 | if (!isUrlValid(url)) { 24 | // console.log("not valid url"); 25 | 26 | return; 27 | } 28 | const domain = getDomain(url); 29 | if (domain === "youtube") { 30 | const youtubeId = getYoutubeId(url); 31 | addNodes({ 32 | id: nanoid(10), 33 | type: "YoutubeVidNode", 34 | position: getRandomCoords(), 35 | data: { 36 | youtube_id: youtubeId, 37 | }, 38 | }); 39 | return; 40 | } 41 | if (domain === "youtu") { 42 | const youtuId = getYoutuId(url); 43 | addNodes({ 44 | id: nanoid(10), 45 | type: "YoutubeVidNode", 46 | position: getRandomCoords(), 47 | data: { 48 | youtube_id: youtuId, 49 | }, 50 | }); 51 | } 52 | if (domain === "spotify") { 53 | const trackId = getSpotifyId(url); 54 | addNodes({ 55 | id: nanoid(10), 56 | type: "SpotifyNode", 57 | position: getRandomCoords(), 58 | data: { 59 | track_id: trackId, 60 | }, 61 | }); 62 | return; 63 | } 64 | if (domain === "pinterest") { 65 | const pinterestId = getPinterestId(url); 66 | addNodes({ 67 | id: nanoid(10), 68 | type: "PinterestNode", 69 | position: getRandomCoords(), 70 | data: { 71 | id: pinterestId, 72 | }, 73 | style: { 74 | width: 400, 75 | height: 600, 76 | }, 77 | }); 78 | } 79 | }; 80 | const openModal = () => { 81 | setIsOpen(true); 82 | }; 83 | 84 | const closeModal = () => { 85 | setIsOpen(false); 86 | }; 87 | 88 | return ( 89 | <> 90 | 93 | 97 |
98 |

enter a url

99 |

100 | we currently support youtube, pinterest and spotify 101 |

102 |
{ 105 | handleCreateUrlPin(e); 106 | closeModal(); 107 | }}> 108 | 114 | 117 |
118 |
119 |
120 | 121 | ); 122 | }; 123 | 124 | export default ModalInput; 125 | -------------------------------------------------------------------------------- /src/pages/DashBoardPage/DashBoardPage.tsx: -------------------------------------------------------------------------------- 1 | import ProjectCard from "../../components/ProjectCard/ProjectCard"; 2 | import "./DashBoardPage.scss"; 3 | import MainHeader from "../../components/MainHeader/MainHeader"; 4 | import { useEffect, useState } from "react"; 5 | import axios from "axios"; 6 | import { useRouter } from "next/navigation"; 7 | import useFilterAside from "../../hooks/useFilterAside"; 8 | import FilterAside from "../../components/FilterAside/FilterAside"; 9 | import demoBoards, { Board } from "../../data/demo-dashboard"; 10 | import useIsDemo from "../../hooks/useIsDemo"; 11 | 12 | const DashBoardPage = () => { 13 | const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; 14 | const [boards, setBoards] = useState([]); 15 | const router = useRouter(); 16 | const { filterOptions, handleOptionChange } = useFilterAside( 17 | boards, 18 | setBoards 19 | ); 20 | const isDemo = useIsDemo(); 21 | const fetchUserBoards = async () => { 22 | const token = localStorage.getItem("token"); 23 | if (!token) { 24 | router.push("/login"); 25 | return; 26 | } 27 | try { 28 | const { data } = await axios.get(baseUrl + "/users/boards", { 29 | headers: { Authorization: `Bearer ${token}` }, 30 | }); 31 | setBoards(data); 32 | } catch (error) { 33 | localStorage.removeItem("token"); 34 | router.push("/login"); 35 | } 36 | }; 37 | 38 | useEffect(() => { 39 | if (isDemo) { 40 | setBoards(demoBoards); 41 | return; 42 | } 43 | 44 | fetchUserBoards(); 45 | }, []); 46 | 47 | const handleNewProject = async () => { 48 | if (isDemo) { 49 | router.push("/demo/board/new-board"); 50 | return; 51 | } 52 | 53 | const token = localStorage.getItem("token"); 54 | try { 55 | const { data } = await axios.post( 56 | baseUrl + "/boards/new", 57 | {}, 58 | { 59 | headers: { Authorization: `Bearer ${token}` }, 60 | } 61 | ); 62 | const { id: boardId } = data; 63 | router.push("/board/" + boardId); 64 | } catch (error) { 65 | console.log(error); 66 | } 67 | }; 68 | const handleDelete = async (boardId: string) => { 69 | await axios.delete(baseUrl + "/boards/" + boardId); 70 | fetchUserBoards(); 71 | }; 72 | return ( 73 |
74 | 75 |
76 | 79 |
80 |
81 | 88 |
    89 | {boards.map((board) => { 90 | const date = new Date(board.created_at); 91 | const formattedDate = date.toLocaleDateString().replace(/\//g, "."); 92 | return ( 93 |
  • 94 | { 107 | handleDelete(board.id); 108 | }} 109 | /> 110 |
  • 111 | ); 112 | })} 113 |
114 |
115 |
116 | ); 117 | }; 118 | 119 | export default DashBoardPage; 120 | -------------------------------------------------------------------------------- /src/pages/LoginPage/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import "./LoginPage.scss"; 3 | import LoginBox from "../../components/LoginBox/LoginBox"; 4 | import { useEffect, useState } from "react"; 5 | import Link from "next/link"; 6 | import { useRouter } from 'next/navigation' 7 | import Image from "next/image"; 8 | 9 | const LoginPage = () => { 10 | const [isLoginShown, setIsLoginShown] = useState(false); 11 | const showLogin = () => { 12 | setIsLoginShown(true); 13 | }; 14 | const router = useRouter(); 15 | useEffect(() => { 16 | const token = localStorage.getItem("token"); 17 | if (token) { 18 | router.push("/dashboard"); 19 | } 20 | }); 21 | 22 | return ( 23 | <> 24 |
26 | 40 |
43 | 44 |
45 |
46 |
47 |
48 |
49 |

50 | about 51 |

52 |

53 | the intention behind studio is to create space for unrestricted 54 | ideation and creative processes. 55 |

56 |
57 |
58 |

59 | project boards 60 |

61 |

62 | project boards can be used for storing references from various 63 | platforms and sources, as well as developing creative ideas. 64 |

65 |

66 | boards can be individual or private, but you can use the 67 | 'collaborate' feature to work on a board with others. 68 |

69 |

70 | boards can be published for others to view, as well. 71 |

72 |
73 | 74 | view example 75 | 76 |
77 |
78 |
79 |
80 |
81 |

82 | explore page 83 |

84 |

85 | use the explore page to see what other creatives are doing. this 86 | is great if you're looking to collaborate, or if you feel like you 87 | need some inspiration 88 |

89 |
90 | 91 | explore a bit 92 | 93 |
94 |
95 |
96 |
97 |
98 |

studio

99 | studio logo 105 |
106 | 107 | ); 108 | }; 109 | 110 | export default LoginPage; 111 | -------------------------------------------------------------------------------- /src/utils/color-methods.ts: -------------------------------------------------------------------------------- 1 | const hexToHSL = (H: string) => { 2 | // Convert hex to RGB first 3 | let r: any = 0, 4 | g: any = 0, 5 | b: any = 0; 6 | if (H.length == 4) { 7 | r = "0x" + H[1] + H[1]; 8 | g = "0x" + H[2] + H[2]; 9 | b = "0x" + H[3] + H[3]; 10 | } else if (H.length == 7) { 11 | r = "0x" + H[1] + H[2]; 12 | g = "0x" + H[3] + H[4]; 13 | b = "0x" + H[5] + H[6]; 14 | } 15 | // Then to HSL 16 | r /= 255; 17 | g /= 255; 18 | b /= 255; 19 | let cmin = Math.min(r, g, b), 20 | cmax = Math.max(r, g, b), 21 | delta = cmax - cmin, 22 | h = 0, 23 | s = 0, 24 | l = 0; 25 | 26 | if (delta == 0) h = 0; 27 | else if (cmax == r) h = ((g - b) / delta) % 6; 28 | else if (cmax == g) h = (b - r) / delta + 2; 29 | else h = (r - g) / delta + 4; 30 | 31 | h = Math.round(h * 60); 32 | 33 | if (h < 0) h += 360; 34 | 35 | l = (cmax + cmin) / 2; 36 | s = delta == 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); 37 | s = +(s * 100).toFixed(0); 38 | l = +(l * 100).toFixed(0); 39 | 40 | return "hsl(" + h + "," + s + "%," + l + "%)"; 41 | }; 42 | 43 | const HSLToHex = (h: number, s: number, l: number) => { 44 | s /= 100; 45 | l /= 100; 46 | 47 | let c = (1 - Math.abs(2 * l - 1)) * s, 48 | x = c * (1 - Math.abs(((h / 60) % 2) - 1)), 49 | m = l - c / 2, 50 | r: any = 0, 51 | g: any = 0, 52 | b: any = 0; 53 | 54 | if (0 <= h && h < 60) { 55 | r = c; 56 | g = x; 57 | b = 0; 58 | } else if (60 <= h && h < 120) { 59 | r = x; 60 | g = c; 61 | b = 0; 62 | } else if (120 <= h && h < 180) { 63 | r = 0; 64 | g = c; 65 | b = x; 66 | } else if (180 <= h && h < 240) { 67 | r = 0; 68 | g = x; 69 | b = c; 70 | } else if (240 <= h && h < 300) { 71 | r = x; 72 | g = 0; 73 | b = c; 74 | } else if (300 <= h && h < 360) { 75 | r = c; 76 | g = 0; 77 | b = x; 78 | } 79 | // Having obtained RGB, convert channels to hex 80 | r = Math.round((r + m) * 255).toString(16); 81 | g = Math.round((g + m) * 255).toString(16); 82 | b = Math.round((b + m) * 255).toString(16); 83 | 84 | // Prepend 0s, if necessary 85 | if (r.length == 1) r = "0" + r; 86 | if (g.length == 1) g = "0" + g; 87 | if (b.length == 1) b = "0" + b; 88 | 89 | return "#" + r + g + b; 90 | }; 91 | type ParseOutput = [number, number, number]; 92 | 93 | const parseHSL = (str: string): ParseOutput => { 94 | let hsl, h, s, l; 95 | hsl = str.replace(/[^\d,]/g, "").split(","); // strip non digits ('%') 96 | h = Number(hsl[0]); // convert to number 97 | s = Number(hsl[1]); 98 | l = Number(hsl[2]); 99 | return [h, s, l]; // return parts 100 | }; 101 | 102 | const harmonize = ( 103 | color: string, 104 | start: number, 105 | end: number, 106 | interval: number 107 | ) => { 108 | const colors = [color]; 109 | const [h, s, l] = parseHSL(color); 110 | 111 | for (let i = start; i <= end; i += interval) { 112 | const h1 = (h + i) % 360; 113 | const c1 = `hsl(${h1}, ${s}%, ${l}%)`; 114 | colors.push(c1); 115 | } 116 | 117 | return colors; 118 | }; 119 | 120 | const getComplementaryColor = (hexColor: string) => { 121 | const hslColor = hexToHSL(hexColor); 122 | const hslComplement = harmonize(hslColor, 180, 180, 1); 123 | const hexComplement = hslComplement.map((color) => 124 | HSLToHex(...parseHSL(color)) 125 | ); 126 | return hexComplement[1]; 127 | }; 128 | 129 | const getAnalogousColors = (hexColor: string) => { 130 | const hslColor = hexToHSL(hexColor); 131 | const hslAnalogous = harmonize(hslColor, 30, 90, 30); 132 | const hexAnalogous = hslAnalogous.map((color) => 133 | HSLToHex(...parseHSL(color)) 134 | ); 135 | return hexAnalogous.slice(1); 136 | }; 137 | 138 | const getTriadicColors = (hexColor: string) => { 139 | const hslColor = hexToHSL(hexColor); 140 | const hslTriad = harmonize(hslColor, 120, 240, 120); 141 | const hexTriad = hslTriad.map((color) => HSLToHex(...parseHSL(color))); 142 | return hexTriad.slice(1); 143 | }; 144 | 145 | export { getComplementaryColor, getAnalogousColors, getTriadicColors }; 146 | -------------------------------------------------------------------------------- /src/components/BoardHeader/BoardHeader.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/navigation"; 2 | import "./BoardHeader.scss"; 3 | // import upIconDefault from "../../assets/icons/arrow-N-default.svg"; 4 | // import upIconSelected from "/icons/arrow-N-selected.svg"; 5 | import React, { useEffect, useState } from "react"; 6 | import { useReactFlow } from "reactflow"; 7 | import axios from "axios"; 8 | import { useParams } from "next/navigation"; 9 | import useHandleThumbnail from "../../hooks/useHandleThumbnail"; 10 | import useIsDemo from "../../hooks/useIsDemo"; 11 | import DemoBtn from "../DemoBtn/DemoBtn"; 12 | import demoBoards from "../../data/demo-dashboard"; 13 | import LoadingModal from "../LoadingModal/LoadingModal"; 14 | import Image from "next/image"; 15 | 16 | const BoardHeader = () => { 17 | const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; 18 | const [isIconSelected, setIsIconSelected] = useState(false); 19 | const [isLoading, setIsLoading] = useState(false); 20 | const [title, setTitle] = useState(""); 21 | const { getNodes } = useReactFlow(); 22 | const params = useParams<{ boardId: string }>(); 23 | const boardId = params?.boardId; 24 | const { handleThumbnail } = useHandleThumbnail(); 25 | const router = useRouter(); 26 | const isDemo = useIsDemo(); 27 | 28 | useEffect(() => { 29 | if (isDemo) { 30 | const demoBoard = demoBoards.find((board) => board.id === boardId); 31 | setTitle(!demoBoard ? "" : demoBoard.title); 32 | return; 33 | } 34 | const token = localStorage.getItem("token"); 35 | if (!token) { 36 | router.push("/login"); 37 | return; 38 | } 39 | const fetchBoard = async () => { 40 | try { 41 | const { data } = await axios.get(baseUrl + "/boards/" + boardId, { 42 | headers: { Authorization: `Bearer ${token}` }, 43 | }); 44 | setTitle(data.title); 45 | } catch (error) { 46 | router.push("/dashboard"); 47 | console.log(error); 48 | } 49 | }; 50 | fetchBoard(); 51 | }, []); 52 | const handleSave = async () => { 53 | if (isDemo) { 54 | return; 55 | } 56 | setIsLoading(true); 57 | const pins = getNodes(); 58 | const formattedPins = pins.map((pin) => { 59 | return { 60 | board_id: boardId, 61 | width: pin.width, 62 | height: pin.height, 63 | id: pin.id, 64 | type: pin.type, 65 | data: JSON.stringify(pin.data), 66 | x_coord: Math.floor(pin.position.x), 67 | y_coord: Math.floor(pin.position.y), 68 | }; 69 | }); 70 | 71 | const { filename } = await handleThumbnail(); 72 | if (title) { 73 | const boardBody = { 74 | boardId, 75 | title, 76 | filename, 77 | }; 78 | 79 | await axios.patch(baseUrl + "/boards/save", boardBody); 80 | } 81 | 82 | await axios.patch(baseUrl + "/boards/" + boardId + "/pins", { 83 | newPins: formattedPins, 84 | }); 85 | setIsLoading(false); 86 | }; 87 | const handleTitleChange = (e: React.ChangeEvent) => { 88 | setTitle(e.target.value); 89 | }; 90 | const handleBack = async () => { 91 | if (isDemo) { 92 | router.push("/demo/dashboard"); 93 | return; 94 | } 95 | await handleSave(); 96 | router.push("/dashboard"); 97 | }; 98 | return ( 99 |
100 | 143 | 144 |
145 | ); 146 | }; 147 | 148 | export default BoardHeader; 149 | -------------------------------------------------------------------------------- /src/components/ModalUpload/ModalUpload.tsx: -------------------------------------------------------------------------------- 1 | import "./ModalUpload.scss"; 2 | import useIsDemo from "../../hooks/useIsDemo"; 3 | import axios from "axios"; 4 | import { useDropzone } from "react-dropzone"; 5 | import React, { useState, useCallback } from "react"; 6 | import { useReactFlow } from "reactflow"; 7 | import { nanoid } from "nanoid"; 8 | import getRandomCoords from "../../utils/get-random-coords"; 9 | import Modal from "react-modal"; 10 | import DemoBtn from "../DemoBtn/DemoBtn"; 11 | 12 | const ModalUpload = () => { 13 | const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; 14 | const [myFiles, setMyFiles] = useState(null); 15 | const [modalIsOpen, setIsOpen] = useState(false); 16 | const isDemo = useIsDemo(); 17 | const [isFileBig, setIsFileBig] = useState(false); 18 | 19 | const onDrop = useCallback( 20 | (acceptedFiles: any) => { 21 | if (acceptedFiles.length !== 0) { 22 | setIsFileBig(false); 23 | setMyFiles( 24 | acceptedFiles.map((file: MediaSource) => 25 | Object.assign(file, { 26 | preview: URL.createObjectURL(file), 27 | }) 28 | ) 29 | ); 30 | return; 31 | } 32 | setIsFileBig(true); 33 | }, 34 | [myFiles] 35 | ); 36 | 37 | const { addNodes } = useReactFlow(); 38 | const handleCreateUploadPin = async (e: React.FormEvent) => { 39 | e.preventDefault(); 40 | 41 | const file = myFiles[0]; 42 | 43 | const formData = new FormData(); 44 | formData.append("file", file); 45 | const { data } = await axios.post(baseUrl + "/upload", formData); 46 | addNodes({ 47 | id: nanoid(10), 48 | type: "ImageNode", 49 | position: getRandomCoords(), 50 | data: { 51 | file: data.filename, 52 | }, 53 | }); 54 | setMyFiles(null); 55 | closeModal(); 56 | }; 57 | const { getRootProps, getInputProps } = useDropzone({ 58 | onDrop, 59 | maxFiles: 1, 60 | accept: { 61 | "image/jpeg": [".jpg", ".jpeg"], 62 | "image/png": [".png"], 63 | }, 64 | maxSize: 500000, 65 | }); 66 | const handleCancel = () => { 67 | setIsFileBig(false); 68 | setMyFiles(null); 69 | }; 70 | 71 | const openModal = () => { 72 | setIsOpen(true); 73 | }; 74 | 75 | const closeModal = () => { 76 | setIsFileBig(false); 77 | setIsOpen(false); 78 | }; 79 | return ( 80 | <> 81 | 84 | 88 |
89 |
{ 93 | handleCreateUploadPin(e); 94 | close(); 95 | }}> 96 | {!myFiles ? ( 97 | <> 98 |
99 |

upload a file

100 |

101 | please make sure your file does not exceed 500kb. we support 102 | .jpg, .png, .jpeg 103 |

104 |

105 | {isFileBig && "file is too large"} 106 |

107 |
108 |
109 | 110 |

111 | drag and drop or click to select files 112 |

113 |
114 | 115 | ) : ( 116 | <> 117 |
118 |
119 |

file preview

120 |
121 |
122 |

123 | cancel 124 |

125 | {isDemo ? ( 126 | 131 | ) : ( 132 | 135 | )} 136 |
137 |
138 |
139 | {myFiles[0].path} 144 |

{myFiles[0].path}

145 |
146 | 147 | )} 148 |
149 |
150 |
151 | 152 | ); 153 | }; 154 | 155 | export default ModalUpload; 156 | -------------------------------------------------------------------------------- /src/components/LoginBox/LoginBox.tsx: -------------------------------------------------------------------------------- 1 | import "./LoginBox.scss"; 2 | import { useRouter } from "next/navigation"; 3 | import { useState } from "react"; 4 | import Input from "../Input/Input"; 5 | import axios from "axios"; 6 | 7 | const LoginBox = () => { 8 | const router = useRouter(); 9 | const [isLogin, setIsLogin] = useState(true); 10 | const [formFields, setFormFields] = useState({ 11 | username: "", 12 | email: "", 13 | password: "", 14 | confirmPassword: "", 15 | }); 16 | const [formErrors, setFormErrors] = useState({ 17 | username: "", 18 | email: "", 19 | password: "", 20 | confirmPassword: "", 21 | }); 22 | const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; 23 | 24 | const handleClick = () => { 25 | setIsLogin(!isLogin); 26 | }; 27 | 28 | const handleChange = (e: React.ChangeEvent) => { 29 | setFormFields({ 30 | ...formFields, 31 | [e.target.name]: e.target.value.trim(), 32 | }); 33 | 34 | setFormErrors({ 35 | ...formErrors, 36 | [e.target.name]: "", 37 | }); 38 | }; 39 | 40 | const isFormValid = () => { 41 | setFormErrors({ 42 | username: !formFields.username ? "this field is required" : "", 43 | email: !formFields.email ? "this field is required" : "", 44 | password: !formFields.password ? "this field is required" : "", 45 | confirmPassword: !formFields.confirmPassword 46 | ? "this field is required" 47 | : "", 48 | }); 49 | 50 | if (!isLogin && formFields.password !== formFields.confirmPassword) { 51 | setFormErrors({ 52 | ...formErrors, 53 | password: "passwords must match", 54 | confirmPassword: "passwords must match", 55 | }); 56 | return false; 57 | } 58 | 59 | if ( 60 | !!formFields.email && 61 | !formFields.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) 62 | ) { 63 | setFormErrors({ 64 | ...formErrors, 65 | email: "please enter a valid email address", 66 | }); 67 | return false; 68 | } 69 | if (isLogin && !!formFields.username && !!formFields.password) { 70 | return true; 71 | } 72 | 73 | if ( 74 | !isLogin && 75 | !!formFields.username && 76 | !!formFields.password && 77 | !!formFields.email && 78 | !!formFields.confirmPassword 79 | ) { 80 | return true; 81 | } 82 | return false; 83 | }; 84 | const handleSubmit = async (e: React.FormEvent) => { 85 | e.preventDefault(); 86 | // navigate("/dashboard"); 87 | if (!isFormValid()) { 88 | return; 89 | } 90 | 91 | if (isLogin) { 92 | const userDetails = { 93 | username: formFields.username, 94 | password: formFields.password, 95 | }; 96 | try { 97 | const { data } = await axios.post( 98 | baseUrl + "/users/login", 99 | userDetails 100 | ); 101 | 102 | localStorage.setItem("token", data.token); 103 | router.push("/dashboard"); 104 | return; 105 | } catch (error) { 106 | setFormErrors({ 107 | ...formErrors, 108 | password: "unrecognized password", 109 | }); 110 | } 111 | } 112 | 113 | //REGISTER 114 | const newUser = { 115 | email: formFields.email, 116 | username: formFields.username, 117 | password: formFields.password, 118 | }; 119 | 120 | try { 121 | await axios.post(baseUrl + "/users/register", newUser); 122 | const user = { 123 | username: formFields.username, 124 | password: formFields.password, 125 | }; 126 | const { data } = await axios.post(baseUrl + "/users/login", user); 127 | localStorage.setItem("token", data.token); 128 | router.push("/dashboard"); 129 | } catch (error) { 130 | console.log(error); 131 | } 132 | }; 133 | return ( 134 |
135 |
136 | 144 | 145 | 155 | 156 | 165 | 176 | 179 |
180 | 183 |
184 | ); 185 | }; 186 | 187 | export default LoginBox; 188 | -------------------------------------------------------------------------------- /src/pages/ProfilePage/ProfilePage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import MainHeader from "../../components/MainHeader/MainHeader"; 3 | import "./ProfilePage.scss"; 4 | import { useRouter } from "next/navigation"; 5 | import axios from "axios"; 6 | import useIsDemo from "../../hooks/useIsDemo"; 7 | import DemoBtn from "../../components/DemoBtn/DemoBtn"; 8 | 9 | const ProfilePage = () => { 10 | type FormFields = { 11 | name: string; 12 | bio: string; 13 | link: string; 14 | email: string; 15 | password: string; 16 | }; 17 | const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; 18 | const router = useRouter(); 19 | const [isSave, setIsSave] = useState(false); 20 | const [formFields, setFormFields] = useState({ 21 | name: "", 22 | bio: "", 23 | link: "", 24 | email: "", 25 | password: "password goes here", 26 | }); 27 | const isDemo = useIsDemo(); 28 | 29 | useEffect(() => { 30 | if (isDemo) { 31 | setFormFields({ 32 | name: "your username here", 33 | bio: "", 34 | link: "", 35 | email: "demo@email.com", 36 | password: "password goes here", 37 | }); 38 | return; 39 | } 40 | const token = localStorage.getItem("token"); 41 | if (!token) { 42 | router.push("/login"); 43 | return; 44 | } 45 | const fetchUserDetails = async () => { 46 | try { 47 | const { data } = await axios.get(baseUrl + "/users", { 48 | headers: { Authorization: `Bearer ${token}` }, 49 | }); 50 | setFormFields({ 51 | ...formFields, 52 | name: data.username, 53 | bio: data.bio ?? "", 54 | link: data.link ?? "", 55 | email: data.email, 56 | }); 57 | } catch (error) { 58 | console.log(error); 59 | } 60 | }; 61 | fetchUserDetails(); 62 | }, []); 63 | 64 | const handleUpdateProfile = async (e: any) => { 65 | const token = localStorage.getItem("token"); 66 | e.preventDefault(); 67 | const updatedDetails = { 68 | username: formFields.name, 69 | bio: formFields.bio, 70 | link: formFields.link, 71 | email: formFields.email, 72 | }; 73 | await axios.patch(baseUrl + "/users", updatedDetails, { 74 | headers: { Authorization: `Bearer ${token}` }, 75 | }); 76 | setIsSave(false); 77 | }; 78 | const profileInputs = [ 79 | { 80 | name: "username", 81 | id: "name", 82 | }, 83 | { 84 | name: "bio", 85 | id: "bio", 86 | }, 87 | { 88 | name: "website/links", 89 | id: "link", 90 | }, 91 | { 92 | name: "email", 93 | id: "email", 94 | }, 95 | ] as const; 96 | 97 | const handleChange = (e: React.ChangeEvent) => { 98 | setIsSave(true); 99 | setFormFields({ 100 | ...formFields, 101 | [e.target.name]: e.target.value, 102 | }); 103 | }; 104 | const handleLogout = () => { 105 | localStorage.removeItem("token"); 106 | router.push("/"); 107 | }; 108 | return ( 109 |
110 | 111 |
112 |
113 |
114 |

profile

115 |
116 | {profileInputs.map((type) => ( 117 |
118 | 121 | 129 |
130 | ))} 131 |
132 | 135 | 143 |
144 | {isDemo ? ( 145 | 154 | ) : ( 155 | 165 | )} 166 |
167 |
168 |
169 |

settings

170 |
171 |
172 |
173 | 174 | 177 |
178 |
179 |
180 | ); 181 | }; 182 | 183 | export default ProfilePage; 184 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # studio 2 | 3 | Front End github repository 4 | 5 | ### Links: 6 | 7 | - [website](https://ideation-studio.dev/) 8 | - [Back End GitHub Repository](https://github.com/afyqzarof/studio-server) 9 | 10 | ## To run 11 | 12 | 1. ensure .env file is filled out with the backend url. See .env.sample 13 | 2. Install dependencies: 14 | 15 | ```bash 16 | npm install 17 | ``` 18 | 19 | 3. Run development server: 20 | 21 | ```bash 22 | npm run dev 23 | ``` 24 | 25 | ## To test 26 | 27 | ```bash 28 | npm test 29 | ``` 30 | 31 | ## Overview 32 | 33 | An inspiration board where the user can add pictures, videos and text from various different sources e.g. Youtube, Instagram, TikTok, Pinterest, Spotify 34 | 35 | ### Problem 36 | 37 | - To aid with ideations in various creative industries 38 | - Not enough practical tools available to navigate complex creative ideas 39 | - It is difficult to communicate creative ideas only with text and inspiration may be on different platforms. 40 | - To aid with 'writer's block' by enabling users to view public inspiration boards 41 | 42 | ### User Profile 43 | 44 | People who work in the creative industry which would include: 45 | 46 | - Artist 47 | - Musicians 48 | - Marketing Agencies 49 | - Editors 50 | - Creative Directors 51 | - Graphic Designers 52 | - Interior Designers 53 | - Copy Writer 54 | - Creative Writers 55 | 56 | People that need space for articulating ideas 57 | 58 | ### Features 59 | 60 | - Add videos from video platforms e.g. Youtube, TikTok 61 | - Add pictures from picture platforms e.g Instagram, Pinterest 62 | - Resize elements on the board 63 | - Drag and drop elements around the board freely 64 | - Add text to inspiration board 65 | - Add colors with hex code on board 66 | - Users are able to share inspiration boards with other users 67 | - Built-in ideation help e.g. rhyme/synonym generation, color palette generation 68 | 69 | ## Implementation 70 | 71 | ### Tech Stack 72 | 73 | react 74 | node.js 75 | express.js 76 | node.js 77 | 78 | ### Third Party Libraries 79 | 80 | - [sass](https://sass-lang.com/) 81 | - [react-route](https://reactrouter.com/en/main) 82 | - [nanoid](https://www.npmjs.com/package/nanoid) 83 | - [reactflow](https://reactflow.dev/) 84 | - [react-popup](https://react-popup.elazizi.com/react-tooltip/) 85 | - [react-type-animation](https://www.npmjs.com/package/react-type-animation) 86 | 87 | ### APIs 88 | 89 | - [rhyme API](https://rhymebrain.com/api.html) 90 | - [dictionary API](https://dictionaryapi.dev/) 91 | 92 | ### Sitemap 93 | 94 | ![architecture](https://github.com/afyqzarof/studio-client/assets/83950596/3816177c-5632-4bd3-96e4-20810457fa27) 95 | 96 | ![user flow](https://github.com/afyqzarof/linked-list/assets/83950596/73877b19-ede9-4f41-bfdf-9b23394e9042) 97 | 98 | ### Mockups 99 | 100 | https://github.com/afyqzarof/capstone-proposal/assets/83950596/62170701-9ea5-46cf-94c7-ef987aaecfc6 101 | 102 | https://github.com/afyqzarof/capstone-proposal/assets/83950596/f51e77c2-09c8-4148-b73d-dc760437b031 103 | 104 | https://github.com/afyqzarof/capstone-proposal/assets/83950596/1d96122c-522f-4939-9ebe-d1d7e7034902 105 | 106 | https://github.com/afyqzarof/capstone-proposal/assets/83950596/46ce49c4-df9e-4e64-b125-c7a33a26983e 107 | 108 | ### Data 109 | 110 | ![image](https://github.com/afyqzarof/linked-list/assets/83950596/5bd95460-1ce6-488b-91a8-679608ec59cb) 111 | 112 | ### Endpoints 113 | 114 | `GET` `/api/users/:user-id` 115 | 116 | - Fetch use details for a given user 117 | - Example Response: 118 | 119 | ```json 120 | { 121 | "id": 1, 122 | "username": "nuclear.instruments", 123 | "email": "user@example.com" 124 | } 125 | ``` 126 | 127 | `GET` `/api/users/:user-id/boards` 128 | 129 | - Fetch board details for a specific user 130 | - Example response: 131 | 132 | ```json 133 | [ 134 | { 135 | "id": 1, 136 | "board_name": "My First Board", 137 | "is_public": false 138 | }, 139 | { 140 | "id": 2, 141 | "board_name": "Example Board", 142 | "is_public": false 143 | }, 144 | { 145 | "id": 3, 146 | "board_name": "music video inspo", 147 | "is_public": true 148 | } 149 | ] 150 | ``` 151 | 152 | `GET` `/api/boards/:board-id/pins` 153 | 154 | - Fetch pins for a specific board 155 | - Example response: 156 | 157 | ```json 158 | [ 159 | { 160 | "id": "xFLA-XMirt", 161 | "type": "YoutubeVidNode", 162 | "data": { "youtube_id": "sDENI1Zx7Wc" }, 163 | "position": { "x": 300, "y": 200 } 164 | }, 165 | { 166 | "id": "mB_6kTKt3Y", 167 | "type": "TextNode", 168 | "data": { "text": "this is a text box" }, 169 | "position": { "x": 250, "y": 100 } 170 | }, 171 | { 172 | "id": "WVQoDv6ewX", 173 | "type": "ColorSelectorNode", 174 | "data": { "color": "#4c4cff" }, 175 | "position": { "x": 0, "y": 0 } 176 | } 177 | ] 178 | ``` 179 | 180 | `PUT` `/api/boards/:board-id/pins` 181 | 182 | - Update pins on a specific board when a user saves 183 | 184 | `post` `/api/users` 185 | 186 | - Initialize a new user upon registration 187 | 188 | `post` `/api/users/:user-id/boards` 189 | 190 | - Initialize a new board for a given user upon creation 191 | 192 | `get` `/api/boards/public` 193 | 194 | - get all boards that are set to "public" 195 | 196 | ### Auth 197 | 198 | Yes, depending on how difficult the implementation of authorization is. 199 | 200 | ## Roadmap 201 | 202 | - Front-end 203 | 204 | - Build all pages defined above 205 | - add drag and drop functionality 206 | - implement adding pin functionality for text, colors, pictures and videos 207 | - implement custom iframe for external sites i.e. youtube, tiktok, pinterest 208 | 209 | - Back-end 210 | - Build end points specified above 211 | - implement authorization if not too difficult 212 | 213 | ## Nice-to-haves 214 | 215 | - Organize elements on the board by relevance/concept/media type 216 | - Create a repository of references that are interlinked 217 | - Draw lines and arrows on inspiration board 218 | - Draw free-hand on board 219 | - Collaboration between users and sharing inspirations boards to be publicly viewed 220 | -------------------------------------------------------------------------------- /src/data/demo-pins.ts: -------------------------------------------------------------------------------- 1 | const image1 = "/uploads/demo-1.jpeg"; 2 | const image2 = "/uploads/demo-2.jpeg"; 3 | const image3 = "/uploads/demo-3.jpeg"; 4 | 5 | export type Pin = { 6 | board_id: string; 7 | width: number; 8 | height: number; 9 | id: string; 10 | type: string; 11 | data: T; 12 | x_coord: number; 13 | y_coord: number; 14 | }; 15 | 16 | const demoPins: Pin[] = [ 17 | { 18 | board_id: "demo-1", 19 | width: 197, 20 | height: 197, 21 | id: "bpFsl8IbVK", 22 | type: "TextNode", 23 | data: { 24 | text: "our, power, hour, outer, counter, tower, powder, encounter, flour, shower, founder, sour, louder, devour, browser, bower", 25 | }, 26 | x_coord: -969, 27 | y_coord: -728, 28 | }, 29 | { 30 | board_id: "demo-1", 31 | width: 621, 32 | height: 621, 33 | id: "wklK59BkCa", 34 | type: "ImageNode", 35 | data: { file: "1720541638713l7S0z.jpeg" }, 36 | x_coord: -622, 37 | y_coord: 637, 38 | }, 39 | { 40 | board_id: "demo-1", 41 | width: 743, 42 | height: 743, 43 | id: "BbSNGlSgiy", 44 | type: "ImageNode", 45 | data: { file: "1720541623747PBofg.jpg" }, 46 | x_coord: -110, 47 | y_coord: 635, 48 | }, 49 | { 50 | board_id: "demo-1", 51 | width: 450, 52 | height: 450, 53 | id: "GTRYtRHwMG", 54 | type: "YoutubeVidNode", 55 | data: { youtube_id: "VTpmhEiYuVk" }, 56 | x_coord: 474, 57 | y_coord: 160, 58 | }, 59 | { 60 | board_id: "demo-1", 61 | width: 663, 62 | height: 663, 63 | id: "FXWzkZkJts", 64 | type: "ImageNode", 65 | data: { file: "1720541558096JIUXb.jpg" }, 66 | x_coord: -1186, 67 | y_coord: 189, 68 | }, 69 | { 70 | board_id: "demo-1", 71 | width: 707, 72 | height: 707, 73 | id: "WiI-_9hz-O", 74 | type: "ImageNode", 75 | data: { file: "17205414821801BUEx.jpg" }, 76 | x_coord: -174, 77 | y_coord: -978, 78 | }, 79 | { 80 | board_id: "demo-1", 81 | width: 272, 82 | height: 272, 83 | id: "74ZYUKRRIv", 84 | type: "YoutubeVidNode", 85 | data: { youtube_id: "Gia9cX6gReo" }, 86 | x_coord: -645, 87 | y_coord: 317, 88 | }, 89 | { 90 | board_id: "demo-1", 91 | width: 272, 92 | height: 272, 93 | id: "KHES3SRxfQ", 94 | type: "YoutubeVidNode", 95 | data: { youtube_id: "atklpvgaBWA" }, 96 | x_coord: -990, 97 | y_coord: -153, 98 | }, 99 | { 100 | board_id: "demo-1", 101 | width: 272, 102 | height: 272, 103 | id: "vJce-frpjw", 104 | type: "YoutubeVidNode", 105 | data: { youtube_id: "C23gZcAfUj8" }, 106 | x_coord: -516, 107 | y_coord: -603, 108 | }, 109 | { 110 | board_id: "demo-1", 111 | width: 311, 112 | height: 311, 113 | id: "iF2ItUn-Q9", 114 | type: "YoutubeVidNode", 115 | data: { youtube_id: "EvD2h5UTFWA" }, 116 | x_coord: -979, 117 | y_coord: -522, 118 | }, 119 | { 120 | board_id: "demo-1", 121 | width: 235, 122 | height: 235, 123 | id: "aOG5fGByQ8", 124 | type: "ColorSelectorNode", 125 | data: { color: "#88fc9d" }, 126 | x_coord: -579, 127 | y_coord: -214, 128 | }, 129 | { 130 | board_id: "demo-1", 131 | width: 235, 132 | height: 235, 133 | id: "GIAEWSf4pA", 134 | type: "ColorSelectorNode", 135 | data: { color: "#9d88fc" }, 136 | x_coord: 89, 137 | y_coord: 352, 138 | }, 139 | { 140 | board_id: "demo-1", 141 | width: 235, 142 | height: 235, 143 | id: "0QYB92jJsw", 144 | type: "ColorSelectorNode", 145 | data: { color: "#fcd788" }, 146 | x_coord: 152, 147 | y_coord: 49, 148 | }, 149 | { 150 | board_id: "demo-1", 151 | width: 235, 152 | height: 235, 153 | id: "OeUY7H93EG", 154 | type: "ColorSelectorNode", 155 | data: { color: "#e7fc88" }, 156 | x_coord: -237, 157 | y_coord: 298, 158 | }, 159 | { 160 | board_id: "demo-1", 161 | width: 235, 162 | height: 235, 163 | id: "PbeOZrWW0d", 164 | type: "ColorSelectorNode", 165 | data: { color: "#acfc88" }, 166 | x_coord: 99, 167 | y_coord: -225, 168 | }, 169 | { 170 | board_id: "demo-1", 171 | width: 235, 172 | height: 235, 173 | id: "DZ8Lr5BmaP", 174 | type: "ColorSelectorNode", 175 | data: { color: "#88e7fc" }, 176 | x_coord: -583, 177 | y_coord: 49, 178 | }, 179 | { 180 | board_id: "demo-1", 181 | width: 329, 182 | height: 329, 183 | id: "yZi8yavGcG", 184 | type: "ColorSelectorNode", 185 | data: { color: "#fc9d88" }, 186 | x_coord: -249, 187 | y_coord: -72, 188 | }, 189 | { 190 | board_id: "demo-1", 191 | width: 757, 192 | height: 757, 193 | id: "GMFQ4Gf45O", 194 | type: "ImageNode", 195 | data: { file: "1720541346231YI7eP.jpg" }, 196 | x_coord: 469, 197 | y_coord: -701, 198 | }, 199 | { 200 | board_id: "demo-1", 201 | width: 100, 202 | height: 100, 203 | id: "S4rG3iOf5Z", 204 | type: "TextNode", 205 | data: { text: "PLAY ALL THE VIDEOS AT ONCE !!!" }, 206 | x_coord: -277, 207 | y_coord: -225, 208 | }, 209 | { 210 | board_id: "demo-1", 211 | width: 100, 212 | height: 100, 213 | id: "eknLoqyfb5", 214 | type: "TextNode", 215 | data: { text: "flower" }, 216 | x_coord: -964, 217 | y_coord: -828, 218 | }, 219 | 220 | { 221 | board_id: "demo-2", 222 | width: 596, 223 | height: 596, 224 | id: "-cFgaF47WA", 225 | type: "ImageNode", 226 | x_coord: 512, 227 | y_coord: 270, 228 | data: { 229 | file: image3, 230 | }, 231 | }, 232 | { 233 | board_id: "demo-2", 234 | width: 664, 235 | height: 467, 236 | id: "sNmHdBcFPy", 237 | type: "YoutubeVidNode", 238 | x_coord: -326, 239 | y_coord: -376, 240 | data: { 241 | youtube_id: "dHUq9xJcaZs", 242 | }, 243 | }, 244 | { 245 | board_id: "demo-2", 246 | width: 338, 247 | height: 700, 248 | id: "4KZozsrb40", 249 | type: "TextNode", 250 | data: { 251 | text: "your oversize clothes\nthe smile that you show\nme when\nu laugh\n\nyou obsession with food\nif i only could \nshare it \nwith you\n\ngoing walks\nyou giving your talks\non why and how\n\nand believing its true\ndon’t know how i found you\n(in this life)", 252 | }, 253 | 254 | x_coord: 412, 255 | y_coord: -367, 256 | }, 257 | 258 | { 259 | board_id: "demo-2", 260 | width: 192, 261 | height: 235, 262 | id: "qjPTLOFQgz", 263 | type: "ColorSelectorNode", 264 | data: { 265 | color: "#1beece", 266 | }, 267 | 268 | x_coord: -248, 269 | y_coord: 458, 270 | }, 271 | { 272 | board_id: "demo-2", 273 | width: 192, 274 | height: 235, 275 | id: "Re5lcAPv9U", 276 | type: "ColorSelectorNode", 277 | data: { 278 | color: "#ce1bee", 279 | }, 280 | 281 | x_coord: -11, 282 | y_coord: 460, 283 | }, 284 | 285 | { 286 | board_id: "demo-2", 287 | width: 218, 288 | height: 245, 289 | id: "slQ8bqjNiT", 290 | type: "ColorSelectorNode", 291 | data: { 292 | color: "#eece1d", 293 | }, 294 | 295 | x_coord: -249, 296 | y_coord: 151, 297 | }, 298 | 299 | { 300 | board_id: "demo-3", 301 | width: 582, 302 | height: 721, 303 | id: "WAV6IwtZiJ", 304 | type: "ImageNode", 305 | data: { 306 | file: image1, 307 | }, 308 | x_coord: 881, 309 | y_coord: 548, 310 | }, 311 | { 312 | board_id: "demo-3", 313 | width: 192, 314 | height: 235, 315 | id: "_dZbMA53TD", 316 | type: "ColorSelectorNode", 317 | data: { 318 | color: "#30fde2", 319 | }, 320 | x_coord: 1737, 321 | y_coord: 180, 322 | }, 323 | { 324 | board_id: "demo-3", 325 | width: 192, 326 | height: 235, 327 | id: "ga3lCXN6Q-", 328 | type: "ColorSelectorNode", 329 | data: { 330 | color: "#e230fd", 331 | }, 332 | x_coord: 1543, 333 | y_coord: 172, 334 | }, 335 | { 336 | board_id: "demo-3", 337 | width: 192, 338 | height: 235, 339 | id: "wZoeXPH_nA-copy", 340 | type: "ColorSelectorNode", 341 | data: { 342 | color: "#30fd7b", 343 | }, 344 | x_coord: 1341, 345 | y_coord: 172, 346 | }, 347 | { 348 | board_id: "demo-3", 349 | width: 596, 350 | height: 816, 351 | id: "0WS0qnJicp", 352 | type: "ImageNode", 353 | data: { 354 | file: image2, 355 | }, 356 | x_coord: 192, 357 | y_coord: 201, 358 | }, 359 | { 360 | board_id: "demo-3", 361 | width: 192, 362 | height: 235, 363 | id: "hwunvYLOFa", 364 | type: "ColorSelectorNode", 365 | data: { 366 | color: "#fde230", 367 | }, 368 | x_coord: 859, 369 | y_coord: 172, 370 | }, 371 | ]; 372 | 373 | export default demoPins; 374 | --------------------------------------------------------------------------------