diff --git a/.gitignore b/.gitignore index c87c9b3..77828db 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +.vscode diff --git a/components/index.ts b/components/index.ts new file mode 100644 index 0000000..88a6949 --- /dev/null +++ b/components/index.ts @@ -0,0 +1,2 @@ +import GifContainer from "./widgets/GifContainer"; +export { GifContainer }; diff --git a/components/layout/Navbar.tsx b/components/layout/Navbar.tsx new file mode 100644 index 0000000..0b3d515 --- /dev/null +++ b/components/layout/Navbar.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import Image from 'next/image'; + +const Navbar = () => { + return ( + + + + + + + Frasier Gifs + + + Upload + Request + + + + + View notifications + + + + + + + { /* Mobile menu button */} + + Open main menu + + + + + + + + + + + + + ) +} + +export default Navbar \ No newline at end of file diff --git a/components/layout/PageLayout.tsx b/components/layout/PageLayout.tsx new file mode 100644 index 0000000..40ca919 --- /dev/null +++ b/components/layout/PageLayout.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import Navbar from './Navbar' + +interface IPageLayoutProps { + children: React.ReactNode +} +const PageLayout: React.FC = ({ children }) => { + return ( + + + + + {children} + + + + + ) +} + +export default PageLayout \ No newline at end of file diff --git a/components/layout/index.ts b/components/layout/index.ts new file mode 100644 index 0000000..0580357 --- /dev/null +++ b/components/layout/index.ts @@ -0,0 +1,3 @@ +import PageLayout from "./PageLayout"; + +export { PageLayout }; diff --git a/components/widgets/GifContainer.tsx b/components/widgets/GifContainer.tsx new file mode 100644 index 0000000..1eb84c6 --- /dev/null +++ b/components/widgets/GifContainer.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import Image from 'next/image'; +import { HandThumbUpIcon, HandThumbDownIcon } from '@heroicons/react/24/outline'; +import { Gif } from 'models'; + +interface IGifContainerProps { + gif: Gif; +} +const GifContainer: React.FC = ({ gif }) => { + const _doot = async (id: string, isUp: boolean) => { + const result = await fetch(`api/votes?gifId=${id}&isUp=${isUp ? 1 : 0}`, { + method: 'POST' + }); + } + return (<> + + + + + + {/* {gif.title} */} + + + + + Frasier + + + Roz + + + Martin + + + + + _doot(gif.id, true)}> + + + {gif.upVotes?.toString()} + + + _doot(gif.id, false)} + className='pl-2 '> + + + {gif.downVotes?.toString()} + + + + > + ) +} + +export default GifContainer \ No newline at end of file diff --git a/components/widgets/index.ts b/components/widgets/index.ts new file mode 100644 index 0000000..aad3bb3 --- /dev/null +++ b/components/widgets/index.ts @@ -0,0 +1,2 @@ +import GifContainer from "./GifContainer"; +export { GifContainer }; diff --git a/models/Gif.ts b/models/Gif.ts index 1d2acdf..5032d7f 100644 --- a/models/Gif.ts +++ b/models/Gif.ts @@ -1,9 +1,10 @@ -export interface Gif { - id: string; - title: string; - description: string; - fileName: string; - dateCreated: string; - upVotes: Number; - downVotes: Number; -} \ No newline at end of file +export default interface Gif { + id: string; + title: string; + description: string | null; + fileName: string; + dateCreated: string; + upVotes: Number; + downVotes: Number; + hasVoted: Boolean; +} diff --git a/models/index.ts b/models/index.ts index 9ae63cd..476786c 100644 --- a/models/index.ts +++ b/models/index.ts @@ -1,3 +1,4 @@ -import {Gif} from "./Gif"; +import type Gif from "./Gif"; +import type VoteModel from "./voteModel"; -export type {Gif} \ No newline at end of file +export { Gif, VoteModel }; diff --git a/models/voteModel.ts b/models/voteModel.ts new file mode 100644 index 0000000..81949fa --- /dev/null +++ b/models/voteModel.ts @@ -0,0 +1,7 @@ +export default interface VoteModel { + id: String; + isUp: boolean; + browserId: String; + createdAt: Date; + gifId: String; +} diff --git a/package.json b/package.json index 5f1d7e0..ac85451 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,19 @@ "lint": "next lint" }, "dependencies": { + "@heroicons/react": "^2.0.11", "@prisma/client": "4.4.0", + "cookie": "^0.5.0", + "js-cookie": "^3.0.1", "next": "12.3.1", "react": "18.2.0", - "react-dom": "18.2.0" + "react-dom": "18.2.0", + "request-ip": "^3.3.0", + "uuid": "^9.0.0" }, "devDependencies": { + "@types/cookie": "^0.5.1", + "@types/js-cookie": "^3.0.2", "@types/node": "18.7.23", "@types/react": "18.0.21", "@types/react-dom": "18.0.6", diff --git a/pages/_app.tsx b/pages/_app.tsx index 3f5c9d5..4b25ef2 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,8 +1,26 @@ +import React from 'react' import '../styles/globals.css' -import type { AppProps } from 'next/app' +import type {AppProps} from 'next/app' +import {PageLayout} from 'components/layout' +import {generateBrowserId} from 'utils/browser' +import Cookies from 'js-cookie' -function MyApp({ Component, pageProps }: AppProps) { - return +function MyApp({Component, pageProps}: AppProps) { + React.useEffect(() => { + const checkBrowserId = async () => { + const storedId = localStorage.getItem('__effp') + if (!storedId) { + localStorage.setItem('__effp', generateBrowserId()) + } + Cookies.set("bid", localStorage.getItem('__effp') as string) + } + checkBrowserId() + .catch(console.error); + }, []) + return ( + + + ) } export default MyApp diff --git a/pages/api/hello.ts b/pages/api/hello.ts deleted file mode 100644 index f8bcc7e..0000000 --- a/pages/api/hello.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Next.js API route support: https://nextjs.org/docs/api-routes/introduction -import type { NextApiRequest, NextApiResponse } from 'next' - -type Data = { - name: string -} - -export default function handler( - req: NextApiRequest, - res: NextApiResponse -) { - res.status(200).json({ name: 'John Doe' }) -} diff --git a/pages/api/votes.ts b/pages/api/votes.ts new file mode 100644 index 0000000..3e7d4e8 --- /dev/null +++ b/pages/api/votes.ts @@ -0,0 +1,37 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type {NextApiRequest, NextApiResponse} from "next"; +import {PrismaClient} from "@prisma/client"; +import {getBrowserId} from "utils/browser"; +import qs from 'querystring' + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + const { + query: {gifId, isUp}, + } = req; + const prisma = new PrismaClient(); + const browserId = req.cookies.bid; + + const exists = await prisma.votes.count({ + where: { + gifId: gifId as string, + browserId: browserId, + }, + }); + + if (exists !== 0) { + res.status(403).json({ + message: "You have already voted on this gif", + }); + } + const result = await prisma.votes.create({ + data: { + isUp: isUp === "1", + browserId: browserId as string, + gifId: gifId as string, + }, + }); + res.status(200).json(result); +} diff --git a/pages/index.tsx b/pages/index.tsx index ff46457..3b13acf 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,24 +1,21 @@ -import { PrismaClient } from "@prisma/client"; -import type { GetServerSideProps, NextPage } from "next"; -import { Gif } from "../models"; -import Image from "next/image"; +import {PrismaClient} from "@prisma/client"; +import type {GetServerSideProps, NextPage} from "next"; +import {Gif} from "models" +import {GifContainer} from "components"; +import {getBrowserId} from "../utils/browser"; interface IHomeProps { gifs: Gif[] } -const Home: NextPage = ({ gifs }) => { +const Home: NextPage = ({gifs}) => { return ( - - Frasier Gifs - - + {gifs.map((gif: Gif) => { return ( - - {gif.title} - + + ) })} @@ -27,12 +24,57 @@ const Home: NextPage = ({ gifs }) => { ); }; -export const getServerSideProps: GetServerSideProps = async ({ req }) => { +export const getServerSideProps: GetServerSideProps = async ({req}) => { + const browserId = getBrowserId(req.headers.cookie || ''); const prisma = new PrismaClient(); - const gifs = await prisma.gif.findMany({ - take: 12, orderBy: { title: 'asc' }, - }); - return { props: { gifs: JSON.parse(JSON.stringify(gifs)) } }; + const results = await prisma.gif.findMany({ + take: 12, orderBy: {title: 'asc'}, + include: { + _count: { + select: { + votes: {where: {isUp: true}}, + // votes: { where: { isUp: false } }, //how to achieve + } + } + } + }); + const gifs = await Promise.all(results.map(async (gif): Promise => { + const votes = await prisma.votes.count({ + where: { + gifId: gif.id as string, + browserId: browserId, + }, + }) + const upVotes = await prisma.votes.count({ + where: { + gifId: gif.id as string, + browserId: browserId, + isUp: true + }, + }) + const downVotes = await prisma.votes.count({ + where: { + gifId: gif.id as string, + browserId: browserId, + isUp: false + }, + }) + return { + id: gif.id, + title: gif.title, + description: gif.description, + fileName: gif.fileName, + dateCreated: gif.createdAt.toISOString(), + upVotes: upVotes, + downVotes: downVotes, + hasVoted: votes !== 0 + } + })) + return { + props: { + gifs + } + }; }; export default Home; diff --git a/prisma/migrations/20220928221643_add_doots/migration.sql b/prisma/migrations/20220928221643_add_doots/migration.sql new file mode 100644 index 0000000..c06390d --- /dev/null +++ b/prisma/migrations/20220928221643_add_doots/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - You are about to drop the column `downVotes` on the `Gif` table. All the data in the column will be lost. + - You are about to drop the column `upVotes` on the `Gif` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Gif" DROP COLUMN "downVotes", +DROP COLUMN "upVotes"; + +-- CreateTable +CREATE TABLE "Votes" ( + "id" TEXT NOT NULL, + "isUp" BOOLEAN NOT NULL, + "browserId" VARCHAR(1000) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "gifId" TEXT NOT NULL, + + CONSTRAINT "Votes_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Votes" ADD CONSTRAINT "Votes_gifId_fkey" FOREIGN KEY ("gifId") REFERENCES "Gif"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index adee240..2259c2b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2,7 +2,8 @@ // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + previewFeatures = ["filteredRelationCount"] } datasource db { @@ -18,8 +19,18 @@ model Gif { searchTerms String? @db.VarChar(2000) //this is temporary, filenames should always match the GUID above fileName String @db.VarChar(100) - upVotes Int @default(0) - downVotes Int @default(0) + + votes Votes[] createdAt DateTime @default(now()) } + +model Votes { + id String @id @default(uuid()) + isUp Boolean @db.Boolean() + browserId String @db.VarChar(1000) + + createdAt DateTime @default(now()) + gif Gif @relation(fields: [gifId], references: [id]) + gifId String +} diff --git a/public/img/header-logo.gif b/public/img/header-logo.gif new file mode 100644 index 0000000..784ed89 Binary files /dev/null and b/public/img/header-logo.gif differ diff --git a/tsconfig.json b/tsconfig.json index 99710e8..5be23dd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,10 @@ { "compilerOptions": { + "baseUrl": "./", + "paths": { + "@models/*": ["./models/*"], + "@components/*": ["./components/*"] + }, "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, @@ -15,6 +20,6 @@ "jsx": "preserve", "incremental": true }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "include": ["src", "next-env.d.ts", "**/*.ts", "**/*.tsx"], "exclude": ["node_modules"] } diff --git a/utils/browser.ts b/utils/browser.ts new file mode 100644 index 0000000..fb25b11 --- /dev/null +++ b/utils/browser.ts @@ -0,0 +1,22 @@ +import cookie from "cookie" +import {ParsedUrlQuery} from "querystring"; + +export const generateBrowserId = () => { + // always start with a letter (for DOM friendlyness) + var idstr = String.fromCharCode(Math.floor(Math.random() * 25 + 65)); + do { + // between numbers and characters (48 is 0 and 90 is Z (42-48 = 90) + var ascicode = Math.floor(Math.random() * 42 + 48); + if (ascicode < 58 || ascicode > 64) { + // exclude all chars between : (58) and @ (64) + idstr += String.fromCharCode(ascicode); + } + } while (idstr.length < 256); + + return idstr; +}; + +export const getBrowserId = (c: string) => { + const parsed = cookie.parse(c); + return parsed.bid; +}; diff --git a/yarn.lock b/yarn.lock index 319e961..7194a59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -32,6 +32,11 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@heroicons/react@^2.0.11": + version "2.0.11" + resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-2.0.11.tgz#2c6cf4c66d81142ec87c102502407d8c353558bb" + integrity sha512-bASjOgSSaYj8HqXWsOqaBiB6ZLalE/g90WYGgZ5lPm4KCCG7wPXntY4kzHf5NrLh6UBAcnPwvbiw1Ne9GYfJtw== + "@humanwhocodes/config-array@^0.10.5": version "0.10.5" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.5.tgz#bb679745224745fff1e9a41961c1d45a49f81c04" @@ -183,6 +188,16 @@ dependencies: tslib "^2.4.0" +"@types/cookie@^0.5.1": + version "0.5.1" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.5.1.tgz#b29aa1f91a59f35e29ff8f7cb24faf1a3a750554" + integrity sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g== + +"@types/js-cookie@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-3.0.2.tgz#451eaeece64c6bdac8b2dde0caab23b085899e0d" + integrity sha512-6+0ekgfusHftJNYpihfkMu8BWdeHs9EOJuGcSofErjstGPfPGEu9yTu4t460lTzzAMl2cM5zngQJqPMHbbnvYA== + "@types/json5@^0.0.29": version "0.0.29" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" @@ -501,6 +516,11 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== +cookie@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" + integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== + core-js-pure@^3.25.1: version "3.25.3" resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.25.3.tgz#66ac5bfa5754b47fdfd14f3841c5ed21c46db608" @@ -1251,6 +1271,11 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +js-cookie@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.1.tgz#9e39b4c6c2f56563708d7d31f6f5f21873a92414" + integrity sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw== + js-sdsl@^4.1.4: version "4.1.4" resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.1.4.tgz#78793c90f80e8430b7d8dc94515b6c77d98a26a6" @@ -1731,6 +1756,11 @@ regexpp@^3.2.0: resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== +request-ip@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/request-ip/-/request-ip-3.3.0.tgz#863451e8fec03847d44f223e30a5d63e369fa611" + integrity sha512-cA6Xh6e0fDBBBwH77SLJaJPBmD3nWVAcF9/XAcsrIHdjhFzFiB5aNQFytdjCGPezU3ROwrR11IddKAM08vohxA== + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -2017,6 +2047,11 @@ util-deprecate@^1.0.2: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +uuid@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5" + integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg== + which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"
+ + Frasier + + + Roz + + + Martin + +