Layout kinda pimped and doots pimped

This commit is contained in:
Fergal Moran
2022-09-30 20:37:39 +01:00
parent b67c6a58b5
commit 653b4dce29
21 changed files with 384 additions and 49 deletions

1
.gitignore vendored
View File

@@ -34,3 +34,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.vscode

2
components/index.ts Normal file
View File

@@ -0,0 +1,2 @@
import GifContainer from "./widgets/GifContainer";
export { GifContainer };

View File

@@ -0,0 +1,48 @@
import React from 'react'
import Image from 'next/image';
const Navbar = () => {
return (
<nav className="bg-white border-b border-gray-200">
<div className="px-4 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex items-center flex-shrink-0 mr-8">
<Image alt="Logo" width={48} height={48}
className="rounded-full"
src={'/img/header-logo.gif'} />
<h1 className='ml-4 text-2xl text-gray-700 text-bold'>Frasier Gifs</h1>
</div>
<div className="hidden sm:-my-px sm:ml-6 sm:flex sm:space-x-8">
<a href="#" className="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-900 border-b-2 border-indigo-500" aria-current="page"> Upload </a>
<a href="#" className="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-500 border-b-2 border-transparent hover:border-gray-300 hover:text-gray-700"> Request </a>
</div>
</div>
<div className="hidden sm:ml-6 sm:flex sm:items-center">
<button type="button" className="p-1 text-gray-400 bg-white rounded-full hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<span className="sr-only">View notifications</span>
<svg className="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</button>
</div>
<div className="flex items-center -mr-2 sm:hidden">
{ /* Mobile menu button */}
<button type="button" className="inline-flex items-center justify-center p-2 text-gray-400 bg-white rounded-md hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" aria-controls="mobile-menu" aria-expanded="false">
<span className="sr-only">Open main menu</span>
<svg className="block w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg className="hidden w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</nav>
)
}
export default Navbar

View File

@@ -0,0 +1,21 @@
import React from 'react'
import Navbar from './Navbar'
interface IPageLayoutProps {
children: React.ReactNode
}
const PageLayout: React.FC<IPageLayoutProps> = ({ children }) => {
return (
<div className="min-h-full">
<Navbar />
<div className="px-10 py-10">
<main>
{children}
</main>
</div>
</div>
)
}
export default PageLayout

View File

@@ -0,0 +1,3 @@
import PageLayout from "./PageLayout";
export { PageLayout };

View File

@@ -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<IGifContainerProps> = ({ gif }) => {
const _doot = async (id: string, isUp: boolean) => {
const result = await fetch(`api/votes?gifId=${id}&isUp=${isUp ? 1 : 0}`, {
method: 'POST'
});
}
return (<>
<div className="group relative h-[17.5rem] transform overflow-hidden rounded-4xl">
<div className="absolute inset-0 bg-indigo-50" >
<Image alt={gif.title}
layout="fill"
objectFit="cover"
className="absolute inset-0 transition duration-300 group-hover:scale-110"
src={`/samples/${gif.fileName}.gif`} />
</div>
</div>
{/* <h3 className="mt-2 text-xl font-bold tracking-tight font-display text-slate-900">{gif.title}</h3> */}
<div className="flex flex-row p-2">
<p className="flex-1 text-base tracking-tight text-slate-500">
<span className="bg-blue-100 text-blue-800 text-xs font-semibold mr-2 px-2.5 py-0.5 rounded dark:bg-blue-200 dark:text-blue-800">
Frasier
</span>
<span className="bg-blue-100 text-blue-800 text-xs font-semibold mr-2 px-2.5 py-0.5 rounded dark:bg-blue-200 dark:text-blue-800">
Roz
</span>
<span className="bg-blue-100 text-blue-800 text-xs font-semibold mr-2 px-2.5 py-0.5 rounded dark:bg-blue-200 dark:text-blue-800">
Martin
</span>
</p>
<div className="flex items-center justify-center space-x-1" >
<div className="flex transition duration-75 ease-in-out delay-150 hover:text-orange-700 hover:cursor-pointer">
<span
onClick={() => _doot(gif.id, true)}>
<HandThumbUpIcon className="w-5" />
</span>
<span className="text-xs">{gif.upVotes?.toString()}</span>
</div>
<div className="flex transition duration-75 ease-in-out delay-150 hover:text-orange-700 hover:cursor-pointer">
<span
onClick={() => _doot(gif.id, false)}
className='pl-2 '>
<HandThumbDownIcon className="w-5" />
</span>
<span className="text-xs">{gif.downVotes?.toString()}</span>
</div>
</div>
</div>
</>
)
}
export default GifContainer

View File

@@ -0,0 +1,2 @@
import GifContainer from "./GifContainer";
export { GifContainer };

View File

@@ -1,9 +1,10 @@
export interface Gif {
id: string;
title: string;
description: string;
fileName: string;
dateCreated: string;
upVotes: Number;
downVotes: Number;
}
export default interface Gif {
id: string;
title: string;
description: string | null;
fileName: string;
dateCreated: string;
upVotes: Number;
downVotes: Number;
hasVoted: Boolean;
}

View File

@@ -1,3 +1,4 @@
import {Gif} from "./Gif";
import type Gif from "./Gif";
import type VoteModel from "./voteModel";
export type {Gif}
export { Gif, VoteModel };

7
models/voteModel.ts Normal file
View File

@@ -0,0 +1,7 @@
export default interface VoteModel {
id: String;
isUp: boolean;
browserId: String;
createdAt: Date;
gifId: String;
}

View File

@@ -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",

View File

@@ -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 <Component {...pageProps} />
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 (
<PageLayout>
<Component {...pageProps} />
</PageLayout>)
}
export default MyApp

View File

@@ -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<Data>
) {
res.status(200).json({ name: 'John Doe' })
}

37
pages/api/votes.ts Normal file
View File

@@ -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);
}

View File

@@ -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<IHomeProps> = ({ gifs }) => {
const Home: NextPage<IHomeProps> = ({gifs}) => {
return (
<div>
<h1 className="text-3xl font-bold text-red-700 underline">
Frasier Gifs
</h1>
<div className="grid grid-cols-3">
<div className="grid grid-cols-1 xl:grid-cols-4 lg:grid-cols-3 md:grid-cols-2">
{gifs.map((gif: Gif) => {
return (
<div key={gif.id}>
<h2>{gif.title}</h2>
<Image alt={gif.title} width={64} height={64} src={`/samples/${gif.fileName}.gif`} />
<div key={gif.id} className="m-2">
<GifContainer gif={gif}/>
</div>
)
})}
@@ -27,12 +24,57 @@ const Home: NextPage<IHomeProps> = ({ 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<Gif> => {
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;

View File

@@ -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;

View File

@@ -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
}

BIN
public/img/header-logo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

View File

@@ -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"]
}

22
utils/browser.ts Normal file
View File

@@ -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;
};

View File

@@ -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"