diff --git a/components/gradientLayout.tsx b/components/gradientLayout.tsx new file mode 100644 index 0000000..2af75d8 --- /dev/null +++ b/components/gradientLayout.tsx @@ -0,0 +1,58 @@ +import { Box, Text, Flex } from "@chakra-ui/layout"; +import { Image, Skeleton } from "@chakra-ui/react"; +import React from "react"; + +type Props = { + children?: React.ReactNode; + color?: string; + image?: string; + title?: string; + description?: string; + roundImage?: boolean; + subtitle?: string; + isLoading?: boolean; +}; + +const GradientLayout: React.FC = ({ + color, + image, + roundImage, + title, + subtitle, + description, + children, + isLoading +}) => { + return ( + + + + + + + + + + {subtitle} + + + {title} + {description} + + + {children} + + + ); +}; + +export default GradientLayout; diff --git a/components/player.tsx b/components/player.tsx new file mode 100644 index 0000000..748d98c --- /dev/null +++ b/components/player.tsx @@ -0,0 +1,131 @@ +import { + ButtonGroup, + Box, + IconButton, + RangeSlider, + RangeSliderFilledTrack, + RangeSliderTrack, + RangeSliderThumb, + Center, + Flex, + Text, +} from "@chakra-ui/react"; +import ReactHowler from "react-howler"; +import React from "react"; + +import { + MdShuffle, + MdSkipPrevious, + MdSkipNext, + MdOutlinePlayCircleFilled, + MdOutlinePauseCircleFilled, + MdOutlineRepeat, +} from "react-icons/md"; + +import { useStoreActions } from "easy-peasy"; + +const Player = ({ songs, activeSong }) => { + const [playing, setPlaying] = React.useState(true); + const [index, setIndex] = React.useState(0); + const [seek, setSeek] = React.useState(0); + const [repeat, setRepeat] = React.useState(false); + const [shuffle, setShuffle] = React.useState(false); + const [duration, setDuration] = React.useState(0); + + const setPlayState = (val: boolean) => { + setPlaying(val); + }; + + return ( + + + + +
+ + } + outline="none" + variant="link" + aria-label="shuffle" + fontSize="24px" + color={shuffle ? "white" : "gray.600"} + onClick={() => setShuffle(!shuffle)} + /> + } + outline="none" + variant="link" + aria-label="skip" + fontSize="24px" + /> + {!playing ? ( + } + outline="none" + variant="link" + aria-label="play" + fontSize="40px" + color="white" + onClick={() => setPlayState(true)} + /> + ) : ( + } + outline="none" + variant="link" + aria-label="pause" + fontSize="40px" + color="white" + onClick={() => setPlayState(false)} + /> + )} + + } + outline="none" + variant="link" + aria-label="next" + fontSize="24px" + /> + } + outline="none" + variant="link" + aria-label="repeat" + fontSize="24px" + color={repeat ? "white" : "gray.600"} + onClick={() => setRepeat(!repeat)} + /> + +
+ + + + + 1:21 + + + + + + + + + + + 321 + + + +
+ ); +}; + +export default Player; diff --git a/components/playerBar.tsx b/components/playerBar.tsx new file mode 100644 index 0000000..af067d8 --- /dev/null +++ b/components/playerBar.tsx @@ -0,0 +1,28 @@ +import { Box, Flex, Text } from "@chakra-ui/layout"; +import { useStoreState } from "easy-peasy"; +import Player from "./player"; + +const PlayerBar = () => { + const songs = useStoreState(state => state.activeSongs); + const activeSong = useStoreState(state => state.activeSong); + + console.log(activeSong); + + return ( + + + {activeSong ? ( + + {activeSong.name} + {activeSong.artist.name} + + ) : null} + + {activeSong ? : null} + + + + ); +}; + +export default PlayerBar; diff --git a/components/playerLayout.tsx b/components/playerLayout.tsx index 370a2f7..ffdbc1b 100644 --- a/components/playerLayout.tsx +++ b/components/playerLayout.tsx @@ -1,5 +1,6 @@ import { Box } from "@chakra-ui/layout"; import React from "react"; +import PlayerBar from "./playerBar"; import Sidebar from "./sidebar"; type Props = { @@ -13,10 +14,10 @@ const PlayerLayout: React.FC = ({ children }) => { - {children} + {children} - player + ); diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 01fb579..4e39390 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -19,6 +19,7 @@ import NextImage from "next/image"; import NextLink from "next/link"; import { IconType } from "react-icons/lib"; +import { usePlaylists } from "../lib/hooks"; type navProps = { name: string; @@ -60,6 +61,7 @@ const musicMenu = [ const playlists = new Array(30).fill(1).map((_, i) => `Playlist ${i + 1}`); const Sidebar = () => { + const { playlists } = usePlaylists(); return ( { - {playlists.map((playlist) => ( - + {playlists?.map((playlist) => ( + - - {playlist} + + {playlist.name} diff --git a/components/songsTable.tsx b/components/songsTable.tsx new file mode 100644 index 0000000..9700186 --- /dev/null +++ b/components/songsTable.tsx @@ -0,0 +1,68 @@ +import { Box } from "@chakra-ui/layout"; +import { useStoreActions } from "easy-peasy"; +import { Table, Thead, Tbody, Td, Tr, Th, IconButton } from "@chakra-ui/react"; +import { BsFillPlayFill } from "react-icons/bs"; +import { AiOutlineClockCircle } from "react-icons/ai"; +import { formatDate, formatTime } from "../lib/formatter"; + +const SongsTable = ({ songs }) => { + const playSongs = useStoreActions((store: any) => store.changeActiveSongs); + const setActiveSong = useStoreActions((store: any) => store.changeActiveSong); + + const handlePlay = (activeSong?) => { + setActiveSong(activeSong || songs[0]); + playSongs(songs); + }; + + return ( + + + + } + colorScheme="green" + size="lg" + isRound + aria-label="Play" + onClick={() => handlePlay()} + /> + + + + + + + + + + + + + {songs.map((song, index) => ( + handlePlay(song)} + > + + + + + + ))} + +
#TitleDate Added + +
{index + 1}{song?.name}{formatDate(song?.createdAt)}{formatTime(song?.duration)}
+
+
+ ); +}; + +export default SongsTable; diff --git a/lib/auth.ts b/lib/auth.ts index 6743824..0db8eb6 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -2,7 +2,7 @@ import jwt from "jsonwebtoken"; import { NextApiRequest, NextApiResponse } from "next"; import prisma from "./prisma"; -export const validateRoutes = async (handler: any) => { +export const validateRoutes = (handler) => { return async (req: NextApiRequest, res: NextApiResponse) => { const { token } = req.cookies; @@ -13,21 +13,30 @@ export const validateRoutes = async (handler: any) => { const { id } = jwt.verify(token, String(process.env.JWT_SECRET)) as { id: number; }; - user = await prisma.user.findUnique({ where: { id }, }); if (!user) { - throw new Error("User not found"); + throw new Error("Not real user"); } } catch (error) { - return res.status(401).json({ error: "Not Authorized" }); + res.status(401); + res.json({ error: "Not Authorized" }); + return; } return handler(req, res, user); } - return res.status(401).json({ error: "Not Authorized" }); + res.status(401); + res.json({ error: "Not Authorizied" }); + }; +}; + +export const validateToken = (token: string) => { + const user = jwt.verify(token, String(process.env.JWT_SECRET)) as { + id: number }; + return user; }; diff --git a/lib/fetcher.ts b/lib/fetcher.ts index 0bae01c..d55fe24 100644 --- a/lib/fetcher.ts +++ b/lib/fetcher.ts @@ -6,5 +6,10 @@ export default function fetcher(url: string, data?: any) { "Content-Type": "application/json", }, body: data ? JSON.stringify(data) : null, - }); + }).then((res) => { + if (!res.ok) { + throw new Error('fetch failed') + } + return res.json() + }) } diff --git a/lib/formatter.ts b/lib/formatter.ts new file mode 100644 index 0000000..10438c4 --- /dev/null +++ b/lib/formatter.ts @@ -0,0 +1,13 @@ +import formatDuration from "format-duration"; + +export const formatTime = (timeInSeconds: number = 0) => { + return formatDuration(timeInSeconds * 1000); +}; + +export const formatDate = (date: Date) => { + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +}; diff --git a/lib/hooks.ts b/lib/hooks.ts new file mode 100644 index 0000000..d433280 --- /dev/null +++ b/lib/hooks.ts @@ -0,0 +1,27 @@ +import useSWR from "swr"; +import fetcher from "./fetcher"; + +export const useMe = () => { + const { data, error } = useSWR("/me", fetcher); + + return { + user: data, + isLoading: !data && !error, + isError: error, + }; +}; + +type Playlist = { + id: number, + name: string, +} + +export const usePlaylists = () => { + const { data, error } = useSWR("/playlists", fetcher); + + return { + playlists: data as Playlist[] || [], + isLoading: !data && !error, + isError: error, + }; +}; diff --git a/lib/store.ts b/lib/store.ts new file mode 100644 index 0000000..4cc9346 --- /dev/null +++ b/lib/store.ts @@ -0,0 +1,12 @@ +import { createStore, action } from "easy-peasy"; + +export const store = createStore({ + activeSongs: [], + activeSong: null, + changeActiveSongs: action((state: any, payload) => { + state.activeSongs = payload; + }), + changeActiveSong: action((state: any, payload) => { + state.activeSong = payload; + }), +}); diff --git a/package.json b/package.json index ca61c86..92b5977 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "react-howler": "^5.2.0", "react-icons": "^4.3.1", "reset-css": "^5.0.1", + "superjson": "^1.8.2", "swr": "^1.1.1" }, "devDependencies": { diff --git a/pages/_app.tsx b/pages/_app.tsx index 07bd6dc..09880c1 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -3,6 +3,8 @@ import "reset-css"; import { AppProps } from "next/app"; import PlayerLayout from "../components/playerLayout"; import { NextComponentType } from "next"; +import { StoreProvider } from "easy-peasy"; +import { store } from "../lib/store"; const theme = extendTheme({ colors: { @@ -39,13 +41,15 @@ type CustomAppProps = AppProps & { function MyApp({ Component, pageProps }: CustomAppProps) { return ( - {Component.authPage ? ( - - ) : ( - + + {Component.authPage ? ( - - )} + ) : ( + + + + )} + ); } diff --git a/pages/api/me.ts b/pages/api/me.ts index 4cf2a6d..6f6b3d7 100644 --- a/pages/api/me.ts +++ b/pages/api/me.ts @@ -1,6 +1,15 @@ import { NextApiRequest, NextApiResponse } from "next"; import { validateRoutes } from "../../lib/auth"; +import prisma from "../../lib/prisma"; -export default validateRoutes((req: NextApiRequest, res: NextApiResponse, user: any) => { - return res.json(user) -}) \ No newline at end of file +export default validateRoutes( + async (req: NextApiRequest, res: NextApiResponse, user: any) => { + const playlistCount = await prisma.playlist.count({ + where: { + userId: user.id, + }, + }); + + return res.json({ ...user, playlistCount }); + } +); diff --git a/pages/api/playlists.ts b/pages/api/playlists.ts new file mode 100644 index 0000000..26b390b --- /dev/null +++ b/pages/api/playlists.ts @@ -0,0 +1,18 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { validateRoutes } from "../../lib/auth"; +import prisma from "../../lib/prisma"; + +export default validateRoutes( + async (req: NextApiRequest, res: NextApiResponse, user: any) => { + const playlist = await prisma.playlist.findMany({ + where: { + userId: user.id, + }, + orderBy: { + name: 'asc' + } + }); + + return res.json(playlist); + } +); diff --git a/pages/index.tsx b/pages/index.tsx index 1971bf9..9cca5d9 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,9 +1,62 @@ -import Head from "next/head"; -import Image from "next/image"; -import styles from "../styles/Home.module.css"; +import GradientLayout from "../components/gradientLayout"; +import { GetServerSidePropsContext } from "next"; +import prisma from "../lib/prisma"; +import { Box, Flex, Text } from "@chakra-ui/layout"; +import { Image } from "@chakra-ui/react"; +import { useMe } from "../lib/hooks"; +import React from "react"; + +const Home = ({ artists }) => { + const { user, isLoading } = useMe(); -export default function Home() { return ( -
Home
+ + + + + Top artists this month + + + + {artists?.map((artist: { id: number; name: string }) => ( + + + + {artist.name} + Artist + + + ))} + + + ); -} +}; + +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { + const artists = await prisma.artist.findMany({}); + return { + props: { + artists, + }, + }; +}; + +export default Home; diff --git a/pages/playlist/[id].tsx b/pages/playlist/[id].tsx new file mode 100644 index 0000000..5e69f75 --- /dev/null +++ b/pages/playlist/[id].tsx @@ -0,0 +1,75 @@ +import { GetServerSidePropsContext } from "next"; +import GradientLayout from "../../components/gradientLayout"; +import { validateToken } from "../../lib/auth"; +import prisma from "../../lib/prisma"; +import SongsTable from "../../components/songsTable"; + +const getBgColor = (id) => { + const colors = [ + "red", + "orange", + "yellow", + "green", + "blue", + "gray", + "purple", + "teal", + ]; + + return colors[id - 1] || colors[Math.floor(Math.random() * colors.length)]; +}; + +const PlaylistDetail = ({ playlists }) => { + const color = getBgColor(playlists?.id); + return ( + + + + ); +}; + +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { + let user; + try { + user = validateToken(ctx.req.cookies.token); + } catch (error) { + return { + redirect: { + permanent: false, + destination: "/signin", + }, + }; + } + const [playlists] = await prisma.playlist.findMany({ + where: { + id: Number(ctx.query.id), + userId: user.id, + }, + include: { + songs: { + include: { + artist: { + select: { + id: true, + name: true, + }, + }, + }, + }, + }, + }); + + return { + props: { + playlists, + }, + }; +}; + +export default PlaylistDetail; diff --git a/prisma/migrations/20220422101556_user_names/migration.sql b/prisma/migrations/20220422101556_user_names/migration.sql new file mode 100644 index 0000000..06108a6 --- /dev/null +++ b/prisma/migrations/20220422101556_user_names/migration.sql @@ -0,0 +1,26 @@ +/* + Warnings: + + - Made the column `userId` on table `Playlist` required. This step will fail if there are existing NULL values in that column. + - Added the required column `duration` to the `Song` table without a default value. This is not possible if the table is not empty. + - Added the required column `url` to the `Song` table without a default value. This is not possible if the table is not empty. + - Added the required column `firstName` to the `User` table without a default value. This is not possible if the table is not empty. + - Added the required column `lastName` to the `User` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "Playlist" DROP CONSTRAINT "Playlist_userId_fkey"; + +-- AlterTable +ALTER TABLE "Playlist" ALTER COLUMN "userId" SET NOT NULL; + +-- AlterTable +ALTER TABLE "Song" ADD COLUMN "duration" INTEGER NOT NULL, +ADD COLUMN "url" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "firstName" TEXT NOT NULL, +ADD COLUMN "lastName" TEXT NOT NULL; + +-- AddForeignKey +ALTER TABLE "Playlist" ADD CONSTRAINT "Playlist_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5d5dce1..583a8fb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,6 +17,8 @@ model User { updatedAt DateTime @updatedAt email String @unique password String + firstName String + lastName String playlists Playlist[] } diff --git a/prisma/seed.ts b/prisma/seed.ts index 061d374..17857c0 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -30,6 +30,8 @@ const run = async () => { create: { email: "user@test.com", password: bcrypt.hashSync("password", salt), + firstName: "John", + lastName: "Doe" }, }); diff --git a/yarn.lock b/yarn.lock index 2d9c912..32cff4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1529,7 +1529,7 @@ damerau-levenshtein@^1.0.7: resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA== -debug@4, debug@^4.1.1, debug@^4.3.2: +debug@4, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -2580,6 +2580,11 @@ locate-path@^2.0.0: p-locate "^2.0.0" path-exists "^3.0.0" +lodash.clonedeep@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" + integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= + lodash.includes@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" @@ -3322,6 +3327,14 @@ stylis@4.0.13: resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.0.13.tgz#f5db332e376d13cc84ecfe5dace9a2a51d954c91" integrity sha512-xGPXiFVl4YED9Jh7Euv2V220mriG9u4B2TA6Ybjc1catrstKD2PpIdU3U0RKpkVBC2EhmL/F0sPCr9vrFTNRag== +superjson@^1.8.2: + version "1.8.2" + resolved "https://registry.yarnpkg.com/superjson/-/superjson-1.8.2.tgz#6245e598baeba029902d0cdc28ceaaad2ee1e1b7" + integrity sha512-gVZEvpmOC11DfnLtm6i/iUNxaozU5ZI4tM7WsBsXmFJAnwj8KQPRQAYfQ4iPMquKNrG6un9VfyNf0F0iD2IWGQ== + dependencies: + debug "^4.3.1" + lodash.clonedeep "^4.5.0" + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"