├── 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 |
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 |
9 | {isGrid ? "grid" : "no grid"}
10 |
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 |
9 | {name}
10 |
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 |
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 |
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 | VIDEO
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 |
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 |
22 | cancel
23 |
24 |
25 | delete
26 |
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 |
23 | light
24 | {" "}
25 |
32 | dark
33 |
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 | {
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 |
29 | {label}
30 |
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 |
13 |
18 | account
19 |
20 |
25 | dashboard
26 |
27 |
32 | explore
33 |
34 |
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 |
24 |
25 |
26 |
27 |
35 |
36 |
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 |
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 |
34 | {title}
35 |
36 |
37 |
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 |
49 |
50 |
59 |
60 | show recent projects first
61 |
62 |
70 |
71 | show oldest projects first
72 |
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 |
47 |
52 |
53 |
54 | {exploreBoards.map((board) => (
55 |
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 |
91 | add link
92 |
93 |
97 |
98 | enter a url
99 |
100 | we currently support youtube, pinterest and spotify
101 |
102 |
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 |
77 | new project
78 |
79 |
80 |
81 |
82 |
87 |
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 |
27 |
28 | welcome
29 | to studio.
30 |
31 |
39 |
40 |
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 |
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 |
101 |
102 |
103 | {
105 | setIsIconSelected(true);
106 | }}
107 | onMouseLeave={() => {
108 | setIsIconSelected(false);
109 | }}
110 | src={
111 | isIconSelected
112 | ? "/icons/arrow-N-selected.svg"
113 | : "/icons/arrow-N-default.svg"
114 | }
115 | alt="up arrow"
116 | className="nav__icon"
117 | width={100}
118 | height={100}
119 | />
120 |
121 |
127 |
128 |
129 | {/* collaborate
130 | publish */}
131 |
132 | {isDemo ? (
133 |
134 | ) : (
135 |
136 |
137 | {isLoading ? "loading" : "save"}
138 |
139 |
140 | )}
141 |
142 |
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 |
82 | upload
83 |
84 |
88 |
89 |
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 |
180 |
181 | {isLogin ? "are you new around here?" : "not my first time here"}
182 |
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 |
119 | {type.name}
120 |
121 |
129 |
130 | ))}
131 |
132 |
133 | change password
134 |
135 |
143 |
144 | {isDemo ? (
145 |
154 | ) : (
155 |
163 | save
164 |
165 | )}
166 |
167 |
168 |
171 |
172 |
173 | give us feedback
174 |
175 | log out
176 |
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 |
74 |
75 |
76 |
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 | 
95 |
96 | 
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 | 
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 |
--------------------------------------------------------------------------------