diff --git a/src/app/create-game/page.tsx b/src/app/create-game/page.tsx index 7510a04..5528fee 100644 --- a/src/app/create-game/page.tsx +++ b/src/app/create-game/page.tsx @@ -1,28 +1,41 @@ "use client"; import Editor from "@/components/editor"; -import { getCookie, hasCookie } from "@/helpers/cookie"; +import { getCookie } from "@/helpers/cookie"; import { Avatar, Button, Form, Input, Spacer } from "@nextui-org/react"; import { LoaderCircle } from "lucide-react"; -import { redirect } from "next/navigation"; import { ReactNode, useEffect, useState } from "react"; import { toast } from "react-toastify"; import sanitizeHtml from "sanitize-html"; -import Select, { MultiValue, StylesConfig } from "react-select"; +import ReactSelect, { MultiValue, StylesConfig } from "react-select"; +import { Select, SelectItem } from "@nextui-org/react"; import { useTheme } from "next-themes"; import Timers from "@/components/timers"; import Streams from "@/components/streams"; +import { UserType } from "@/types/UserType"; +import { useRouter } from 'next/navigation'; +import { GameType } from "@/types/GameType"; +import { PlatformType, DownloadLinkType } from "@/types/DownloadLinkType"; + + + + export default function CreateGamePage() { + const router = useRouter(); + const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const [errors, setErrors] = useState({}); const [waitingPost, setWaitingPost] = useState(false); + const [editGame, setEditGame] = useState(false); + /* const [selectedTags, setSelectedTags] = useState<MultiValue<{ value: string; label: ReactNode; isFixed: boolean; }> | null>(null); + */ const [mounted, setMounted] = useState<boolean>(false); const [options, setOptions] = useState< { @@ -33,40 +46,59 @@ export default function CreateGamePage() { }[] >(); const { theme } = useTheme(); - // Add these state variables after existing useState declarations -const [windowsLink, setWindowsLink] = useState(""); -const [linuxLink, setLinuxLink] = useState(""); -const [macLink, setMacLink] = useState(""); -const [webGLLink, setWebGLLink] = useState(""); -const [gameSlug, setGameSlug] = useState(""); -const [thumbnailUrl, setThumbnailUrl] = useState(""); -const [authorSearch, setAuthorSearch] = useState(""); -const [selectedAuthors, setSelectedAuthors] = useState<Array<{id: number, name: string}>>([]); -const [searchResults, setSearchResults] = useState<Array<{ id: number; name: string }>>([]); + const [gameSlug, setGameSlug] = useState(""); + const [prevSlug, setPrevGameSlug] = useState(""); + const [game, setGame] = useState<GameType>(); + const [thumbnailUrl, setThumbnailUrl] = useState(""); + const [authorSearch, setAuthorSearch] = useState(""); + const [selectedAuthors, setSelectedAuthors] = useState<Array<UserType>>([]); + const [searchResults, setSearchResults] = useState<Array<UserType>>([]); + const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false); + const [user, setUser] = useState<UserType>(); + const [downloadLinks, setDownloadLinks] = useState<DownloadLinkType[]>([]); + const [editorKey, setEditorKey] = useState(0); + const [isMobile, setIsMobile] = useState<boolean>(false); + const urlRegex = /^(https?:\/\/)/; -// Add this function to handle author search -const handleAuthorSearch = async (query: string) => { - if (query.length < 2) return; - - const response = await fetch( - process.env.NEXT_PUBLIC_MODE === "PROD" - ? `https://d2jam.com/api/v1/users/search?q=${query}` - : `http://localhost:3005/api/v1/users/search?q=${query}`, - { - headers: { authorization: `Bearer ${getCookie("token")}` }, - credentials: "include", + const sanitizeSlug = (value: string): string => { + return value + .toLowerCase() // Convert to lowercase + .replace(/\s+/g, '-') // Replace whitespace with hyphens + .replace(/[^a-z0-9-]/g, '') // Only allow lowercase letters, numbers, and hyphens + .substring(0, 50); // Limit length to 50 characters + }; + + const handleAuthorSearch = async (query: string) => { + if (query.length < 3) return; + + const response = await fetch( + process.env.NEXT_PUBLIC_MODE === "PROD" + ? `https://d2jam.com/api/v1/user/search?q=${query}` + : `http://localhost:3005/api/v1/user/search?q=${query}`, + { + headers: { authorization: `Bearer ${getCookie("token")}` }, + credentials: "include", + } + ); + + if (response.ok) { + const data = await response.json(); + setSearchResults(data); } - ); - - if (response.ok) { - const data = await response.json(); - setSearchResults(data); - } -}; + }; + + useEffect(() => { + const handleResize = () => { + setIsMobile(window.innerWidth <= 768); + }; + handleResize(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); useEffect(() => { setMounted(true); - + const load = async () => { const response = await fetch( process.env.NEXT_PUBLIC_MODE === "PROD" @@ -77,9 +109,10 @@ const handleAuthorSearch = async (query: string) => { credentials: "include", } ); - - const user = await response.json(); - + const localuser = await response.json(); + setUser(localuser); + + /* const tagResponse = await fetch( process.env.NEXT_PUBLIC_MODE === "PROD" ? `https://d2jam.com/api/v1/tags` @@ -95,7 +128,7 @@ const handleAuthorSearch = async (query: string) => { }[] = []; for (const tag of await tagResponse.json()) { - if (tag.modOnly && !user.mod) { + if (tag.modOnly && localuser && !localuser.mod) { continue; } newoptions.push({ @@ -124,9 +157,69 @@ const handleAuthorSearch = async (query: string) => { setOptions(newoptions); setSelectedTags(newoptions.filter((tag) => tag.isFixed)); } + */ }; load(); - }, []); + },[]); + + useEffect(() => { + const checkExistingGame = async () => { + + const response = await fetch( + process.env.NEXT_PUBLIC_MODE === "PROD" + ? `https://d2jam.com/api/v1/self/current-game?username=${getCookie("user")}` + : `http://localhost:3005/api/v1/self/current-game?username=${getCookie("user")}`, + { + headers: { authorization: `Bearer ${getCookie("token")}` }, + credentials: "include", + } + ); + console.log("say"); + if (response.ok) { + const gameData = await response.json(); + if (gameData) { + setEditGame(true); + setTitle(gameData.name); + setGameSlug(gameData.slug); + setPrevGameSlug(gameData.slug); + setContent(gameData.description); + setEditorKey((prev) => prev + 1); + setThumbnailUrl(gameData.thumbnail); + setDownloadLinks(gameData.downloadLinks); + setGame(gameData); + const uniqueAuthors = [gameData.author, ...gameData.contributors] + .filter((author, index, self) => + index === self.findIndex((a) => a.id === author.id) + ); + setSelectedAuthors(uniqueAuthors); + + } + else + { + setSelectedAuthors(user ? [user] : []); + } + } + else + { + + setEditGame(false); + setTitle(""); + setGameSlug(""); + setContent(""); + setEditorKey((prev) => prev + 1); + setThumbnailUrl(""); + setDownloadLinks([]); + + } + }; + + if (mounted && user) { + checkExistingGame(); + } + + },[user,mounted]); + + const styles: StylesConfig< { @@ -191,69 +284,64 @@ const handleAuthorSearch = async (query: string) => { return ( <div className="static flex items-top mt-20 justify-center top-0 left-0"> - <Form - className="w-full max-w-2xl flex flex-col gap-4" - validationErrors={errors} - onSubmit={async (e) => { - e.preventDefault(); + + <Form + className="w-full max-w-2xl flex flex-col gap-4" + validationErrors={errors} + onSubmit={async (e) => { + e.preventDefault(); - if (!title && !content) { - setErrors({ - title: "Please enter a valid title", - content: "Please enter valid content", - }); - toast.error("Please enter valid content"); - return; - } + if (!title && !content) { + setErrors({ + title: "Please enter a valid title", + content: "Please enter valid content", + }); + toast.error("Please enter valid content"); + return; + } - if (!title) { - setErrors({ title: "Please enter a valid title" }); - return; - } + if (!title) { + setErrors({ title: "Please enter a valid title" }); + return; + } - if (!content) { - setErrors({ content: "Please enter valid content" }); - toast.error("Please enter valid content"); - return; - } + if (!content) { + setErrors({ content: "Please enter valid content" }); + toast.error("Please enter valid content"); + return; + } - if (!hasCookie("token")) { - setErrors({ content: "You are not logged in" }); - return; - } + const userSlug = getCookie("user"); // Retrieve user slug from cookies + if (!userSlug) { + toast.error("You are not logged in."); + return; + } - const sanitizedHtml = sanitizeHtml(content); - setWaitingPost(true); + const sanitizedHtml = sanitizeHtml(content); + setWaitingPost(true); - const tags = []; - - if (selectedTags) { - for (const tag of selectedTags) { - tags.push( - options?.filter((option) => option.value == tag.value)[0].id - ); - } - } + try { + const requestMethod = editGame ? "PUT" : "POST"; + const endpoint = editGame ? `/games/${prevSlug}` : "/games/create"; const response = await fetch( process.env.NEXT_PUBLIC_MODE === "PROD" - ? "https://d2jam.com/api/v1/post" - : "http://localhost:3005/api/v1/post", + ? `https://d2jam.com/api/v1${endpoint}` + : `http://localhost:3005/api/v1${endpoint}`, { body: JSON.stringify({ - title: title, - content: sanitizedHtml, - username: getCookie("user"), - tags, - gameSlug, - thumbnailUrl, - windowsLink, - linuxLink, - macLink, - webGLLink, - authors: selectedAuthors.map(author => author.id) + name: title, + slug: gameSlug, + description: sanitizedHtml, + thumbnail: thumbnailUrl, + downloadLinks: downloadLinks.map((link) => ({ + url: link.url, + platform: link.platform, + })), + userSlug, + contributors: selectedAuthors.map((author) => author.id), }), - method: "POST", + method: requestMethod, headers: { "Content-Type": "application/json", authorization: `Bearer ${getCookie("token")}`, @@ -262,41 +350,59 @@ const handleAuthorSearch = async (query: string) => { } ); - if (response.status == 401) { + if (response.status === 401) { setErrors({ content: "Invalid user" }); setWaitingPost(false); return; } if (response.ok) { - toast.success("Successfully created post"); + toast.success(gameSlug ? "Game updated successfully!" : "Game created successfully!"); setWaitingPost(false); - redirect("/"); + router.push(`/games/${gameSlug || sanitizeSlug(title)}`); } else { - toast.error("An error occured"); + const error = await response.text(); + toast.error(error || "Failed to create game"); setWaitingPost(false); } + } catch (error) { + console.error("Error creating game:", error); + toast.error("Failed to create game."); + } + }} + > + <div> + <h1 className="text-2xl font-bold mb-4 flex"> + {gameSlug ? "Edit Game" : "Create New Game"} + </h1> + </div> + <Input + isRequired + label="Game Name" + labelPlacement="outside" + name="title" + placeholder="Enter your game name" + type="text" + value={title} + onValueChange={(value) => { + setTitle(value); + if (!isSlugManuallyEdited) { + setGameSlug(sanitizeSlug(value)); + } }} - > - <Input - isRequired - label="Game Name" - labelPlacement="outside" - name="title" - placeholder="Enter your game name" - type="text" - value={title} - onValueChange={setTitle} - /> + /> - <Input - label="Game Slug" - labelPlacement="outside" - placeholder="your-game-name" - value={gameSlug} - onValueChange={setGameSlug} - description="This will be used in the URL: d2jam.com/games/your-game-name" - /> + <Input + label="Game Slug" + labelPlacement="outside" + placeholder="your-game-name" + value={gameSlug} + onValueChange={(value) => { + setGameSlug(sanitizeSlug(value)); + setIsSlugManuallyEdited(true); + }} + description="This will be used in the URL: d2jam.com/games/your-game-name" + /> <div className="flex flex-col gap-2"> <label className="text-sm font-medium">Add Authors</label> @@ -315,7 +421,9 @@ const handleAuthorSearch = async (query: string) => { key={user.id} className="flex justify-between items-center p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded cursor-pointer" onClick={() => { - setSelectedAuthors([...selectedAuthors, user]); + if (!selectedAuthors.some(a => a.id === user.id)) { + setSelectedAuthors([...selectedAuthors, user]); + } setSearchResults([]); setAuthorSearch(""); }} @@ -326,121 +434,155 @@ const handleAuthorSearch = async (query: string) => { </div> )} <div className="flex flex-wrap gap-2 mt-2"> - {selectedAuthors.map((author) => ( - <div - key={author.id} - className="bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-100 px-3 py-1 rounded-full flex items-center gap-2" + {selectedAuthors.map((author) => ( + <div + key={author.id} + className="bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-100 px-3 py-1 rounded-full flex items-center gap-2" + > + <span>{author.name}</span> + {((game && author.id !== game.authorId) || (!game && author.id !== user?.id)) && ( + <button + onClick={() => setSelectedAuthors(selectedAuthors.filter(a => a.id !== author.id))} + className="text-sm hover:text-red-500" > - <span>{author.name}</span> - <button - onClick={() => setSelectedAuthors(selectedAuthors.filter(a => a.id !== author.id))} - className="text-sm hover:text-red-500" - > - × - </button> - </div> - ))} + × + </button> + )} </div> + ))} +</div> </div> <label className="text-sm font-medium">Game Description</label> - <Editor content={content} setContent={setContent} gameEditor /> - - <Spacer /> - - {mounted && ( - <Select - styles={styles} - isMulti - value={selectedTags} - onChange={(value) => setSelectedTags(value)} - options={options} - isClearable={false} - isOptionDisabled={() => - selectedTags != null && selectedTags.length >= 5 - } - /> - )} + <Editor key={editorKey} content={content} setContent={setContent} gameEditor /> <Spacer /> -<div className="flex flex-col gap-4"> + <div className="flex flex-col gap-4"> - <Input - label="Thumbnail URL" - labelPlacement="outside" - placeholder="https://example.com/thumbnail.png" - value={thumbnailUrl} - onValueChange={setThumbnailUrl} - /> + <Input + label="Thumbnail URL" + labelPlacement="outside" + placeholder="https://example.com/thumbnail.png" + value={thumbnailUrl} + onValueChange={setThumbnailUrl} + /> - <div className="grid grid-cols-2 gap-4"> - <Input - label="Windows Download" - placeholder="https://example.com/game-windows.zip" - value={windowsLink} - onValueChange={setWindowsLink} - /> - <Input - label="Linux Download" - placeholder="https://example.com/game-linux.zip" - value={linuxLink} - onValueChange={setLinuxLink} - /> - <Input - label="Mac Download" - placeholder="https://example.com/game-mac.zip" - value={macLink} - onValueChange={setMacLink} - /> - <Input - label="WebGL Link" - placeholder="https://example.com/game-webgl" - value={webGLLink} - onValueChange={setWebGLLink} - /> - </div> + <div className="flex flex-col gap-4"> + <div className="flex flex-col gap-2"> + {Array.isArray(downloadLinks) && + downloadLinks.map((link, index) => ( + <div key={link.id} className="flex gap-2"> + <Input + className="flex-grow" + placeholder="https://example.com/download" + value={link.url} + onValueChange={(value) => { + const newLinks = [...downloadLinks]; + newLinks[index].url = value; + setDownloadLinks(newLinks); + }} + onBlur={() => { + if (!urlRegex.test(downloadLinks[index].url)) { + toast.error("Please enter a valid URL starting with http:// or https://"); + + if (!downloadLinks[index].url.startsWith("http://") && !downloadLinks[index].url.startsWith("https://")) { + const newUrl = "https://" + downloadLinks[index].url; + const newLinks = [...downloadLinks]; + newLinks[index].url = newUrl; + setDownloadLinks(newLinks); + const input = document.querySelector<HTMLInputElement>( + `#download-link-${index}` + ); + if (input) { + input.value = newUrl; + } + } - - <div className="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg"> - <h3 className="text-lg font-bold mb-4">Game Metrics</h3> - <div className="grid grid-cols-2 gap-4"> - <div className="bg-white dark:bg-gray-900 p-3 rounded-lg"> - <p className="text-sm text-gray-600 dark:text-gray-400">Views</p> - <p className="text-2xl font-bold">0</p> + } + }} + /> + <Select + className="w-96" + defaultSelectedKeys={["Windows"]} + aria-label="Select platform" // Add this to fix accessibility warning + onSelectionChange={(value) => { + const newLinks = [...downloadLinks]; + newLinks[index].platform = value as unknown as PlatformType; + setDownloadLinks(newLinks); + }} + > + <SelectItem key="Windows" value="Windows"> + Windows + </SelectItem> + <SelectItem key="MacOS" value="MacOS"> + MacOS + </SelectItem> + <SelectItem key="Linux" value="Linux"> + Linux + </SelectItem> + <SelectItem key="Web" value="Web"> + Web + </SelectItem> + <SelectItem key="Mobile" value="Mobile"> + Mobile + </SelectItem> + <SelectItem key="Other" value="Other"> + Other + </SelectItem> + </Select> + <Button + color="danger" + variant="light" + onPress={() => { + setDownloadLinks(downloadLinks.filter((l) => l.id !== link.id)); + }} + > + × + </Button> + </div> + ))} </div> - <div className="bg-white dark:bg-gray-900 p-3 rounded-lg"> - <p className="text-sm text-gray-600 dark:text-gray-400">Downloads</p> - <p className="text-2xl font-bold">0</p> - </div> - <div className="bg-white dark:bg-gray-900 p-3 rounded-lg"> - <p className="text-sm text-gray-600 dark:text-gray-400">Rating</p> - <p className="text-2xl font-bold">N/A</p> - </div> - <div className="bg-white dark:bg-gray-900 p-3 rounded-lg"> - <p className="text-sm text-gray-600 dark:text-gray-400">Comments</p> - <p className="text-2xl font-bold">0</p> + + <Button + color="primary" + variant="solid" + onPress={() => { + setDownloadLinks([ + ...downloadLinks, + { + id: Date.now(), + url: "", + platform: "Windows", + }, + ]); + }} + > + Add Download Link + </Button> + </div> + + <div className="flex gap-2"> + <Button color="primary" type="submit"> + {waitingPost ? ( + <LoaderCircle className="animate-spin" size={16} /> + ) : ( + <p>{editGame ? "Update" : "Create"}</p> + )} + </Button> </div> </div> - </div> - <div className="flex gap-2"> - <Button color="primary" type="submit"> - {waitingPost ? ( - <LoaderCircle className="animate-spin" size={16} /> - ) : ( - <p>Create</p> - )} - </Button> - </div> -</div> </Form> - <div className="flex flex-col gap-4 px-8 items-end"> + {!isMobile && ( + <div className="flex flex-col gap-4 px-8 items-end"> <Timers /> <Streams /> </div> + )} + </div> ); } diff --git a/src/app/create-post/page.tsx b/src/app/create-post/page.tsx index b10f524..8db48d6 100644 --- a/src/app/create-post/page.tsx +++ b/src/app/create-post/page.tsx @@ -43,6 +43,7 @@ export default function CreatePostPage() { const { theme } = useTheme(); const [user, setUser] = useState<UserType>(); const [sticky, setSticky] = useState(false); + const [isMobile, setIsMobile] = useState<boolean>(false); useEffect(() => { setMounted(true); @@ -108,6 +109,16 @@ export default function CreatePostPage() { load(); }, []); + useEffect(() => { + const handleResize = () => { + setIsMobile(window.innerWidth <= 768); + }; + handleResize(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + const styles: StylesConfig< { value: string; @@ -307,10 +318,12 @@ export default function CreatePostPage() { </Button> </div> </Form> - <div className="flex flex-col gap-4 px-8 items-end"> + {!isMobile && ( + <div className="flex flex-col gap-4 px-8 items-end"> <Timers /> <Streams /> </div> + )} </div> ); } diff --git a/src/app/games/[gameSlug]/page.tsx b/src/app/games/[gameSlug]/page.tsx new file mode 100644 index 0000000..4b54120 --- /dev/null +++ b/src/app/games/[gameSlug]/page.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { use } from 'react'; +import { useState, useEffect } from 'react'; +import { getCookie } from '@/helpers/cookie'; +import Link from 'next/link'; +import { Button } from '@nextui-org/react'; +import { useRouter } from 'next/navigation'; +import { GameType } from '@/types/GameType'; +import { UserType } from '@/types/UserType'; +import { DownloadLinkType } from '@/types/DownloadLinkType'; + +export default function GamePage({ params }: { params: Promise<{ gameSlug: string }> }) { + const resolvedParams = use(params); + const gameSlug = resolvedParams.gameSlug; + const [game, setGame] = useState<GameType | null>(null); + const [user, setUser] = useState<UserType | null>(null); + const router = useRouter(); + + useEffect(() => { + const fetchGameAndUser = async () => { + // Fetch the game data + const gameResponse = await fetch( + process.env.NEXT_PUBLIC_MODE === "PROD" + ? `https://d2jam.com/api/v1/games/${gameSlug}` + : `http://localhost:3005/api/v1/games/${gameSlug}`, + { + headers: { authorization: `Bearer ${getCookie("token")}` }, + credentials: "include", + } + ); + + if (gameResponse.ok) { + const gameData = await gameResponse.json(); + + const filteredContributors = gameData.contributors.filter( + (contributor: UserType) => contributor.id !== gameData.author.id + ); + + const updatedGameData = { + ...gameData, + contributors: filteredContributors, + }; + + setGame(updatedGameData); + } + + // Fetch the logged-in user data + if (getCookie("token")) { + const userResponse = await fetch( + process.env.NEXT_PUBLIC_MODE === "PROD" + ? `https://d2jam.com/api/v1/self?username=${getCookie("user")}` + : `http://localhost:3005/api/v1/self?username=${getCookie("user")}`, + { + headers: { authorization: `Bearer ${getCookie("token")}` }, + credentials: "include", + } + ); + + if (userResponse.ok) { + const userData = await userResponse.json(); + setUser(userData); + } + } + }; + + fetchGameAndUser(); + }, [gameSlug]); + + if (!game) return <div>Loading...</div>; + + // Check if the logged-in user is the creator or a contributor + const isEditable = + user && + (user.id === game.author.id || + game.contributors.some((contributor: UserType) => contributor.id === user.id)); + + return ( + <div className="max-w-4xl mx-auto px-4 py-8"> + {/* Game Name and Edit Button */} + <div className="flex items-center justify-between mb-4"> + <h1 className="text-4xl font-bold">{game.name}</h1> + {isEditable && ( + <Button + color="primary" + variant="solid" + onPress={() => router.push(`/create-game`)} + > + Edit + </Button> + )} + </div> + + {/* Authors */} + <div className="mb-8"> + <p className="text-gray-600"> + Created by{' '} + <Link href={`/users/${game.author.slug}`} className="text-blue-500 hover:underline"> + {game.author.name} + </Link> + {game.contributors.length > 0 && ( + <> + {' '}with{' '} + {game.contributors.map((contributor: UserType, index: number) => ( + <span key={contributor.id}> + <Link href={`/users/${contributor.slug}`} className="text-blue-500 hover:underline"> + {contributor.name} + </Link> + {index < game.contributors.length - 1 ? ', ' : ''} + </span> + ))} + </> + )} + </p> + </div> + + <div className="mb-8"> + <h2 className="text-2xl font-semibold mb-4">About</h2> + <div className="prose-neutral prose-lg" dangerouslySetInnerHTML={{ __html: game.description ?? '' }} /> + </div> + + <div className="mb-8"> + <h2 className="text-2xl font-semibold mb-4">Downloads</h2> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {game.downloadLinks.map((link: DownloadLinkType) => ( + <Button + key={link.id} + as="a" + href={link.url} + target="_blank" + rel="noopener noreferrer" + className="w-full" + color="primary" + variant="bordered" + > + Download for {link.platform} + </Button> + ))} + </div> + </div> + + {/* Game Metrics */} + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> + <div className="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg text-center"> + <p className="text-sm text-gray-600 dark:text-gray-400">Views</p> + <p className="text-2xl font-bold">0</p> + </div> + <div className="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg text-center"> + <p className="text-sm text-gray-600 dark:text-gray-400">Downloads</p> + <p className="text-2xl font-bold">0</p> + </div> + <div className="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg text-center"> + <p className="text-sm text-gray-600 dark:text-gray-400">Rating</p> + <p className="text-2xl font-bold">N/A</p> + </div> + <div className="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg text-center"> + <p className="text-sm text-gray-600 dark:text-gray-400">Comments</p> + <p className="text-2xl font-bold">0</p> + </div> + </div> + </div> + ); +} diff --git a/src/components/jam-header/index.tsx b/src/components/jam-header/index.tsx index 1f39dc2..2ae1ae2 100644 --- a/src/components/jam-header/index.tsx +++ b/src/components/jam-header/index.tsx @@ -14,9 +14,9 @@ export default function JamHeader() { const fetchData = async () => { const jamData = await getCurrentJam(); setActiveJamResponse(jamData); - + console.log(jamData); // If we're in Jamming phase, fetch top themes and pick the first one - if (jamData?.phase === "Jamming" && jamData.jam) { + if ((jamData?.phase === "Jamming" || jamData?.phase === "Rating") && jamData.jam) { try { const response = await fetch( process.env.NEXT_PUBLIC_MODE === "PROD" diff --git a/src/components/navbar/MobileNavbar.tsx b/src/components/navbar/MobileNavbar.tsx index 149af5e..92198af 100644 --- a/src/components/navbar/MobileNavbar.tsx +++ b/src/components/navbar/MobileNavbar.tsx @@ -17,57 +17,60 @@ import { getCurrentJam } from "@/helpers/jam"; import { JamType } from "@/types/JamType"; import { UserType } from "@/types/UserType"; import MobileNavbarUser from "./MobileNavbarUser"; +import ThemeToggle from "../theme-toggle"; + export default function MobileNavbar() { const pathname = usePathname(); const [jam, setJam] = useState<JamType | null>(); const [isInJam, setIsInJam] = useState<boolean>(); + const [user, setUser] = useState<UserType>(); useEffect(() => { - loadUser(); - async function loadUser() { - const currentJamResponse = await getCurrentJam(); - const currentJam = currentJamResponse?.jam; - setJam(currentJam); - - if (!hasCookie("token")) { - setUser(undefined); - return; - } - - const response = await fetch( - process.env.NEXT_PUBLIC_MODE === "PROD" - ? `https://d2jam.com/api/v1/self?username=${getCookie("user")}` - : `http://localhost:3005/api/v1/self?username=${getCookie("user")}`, - { - headers: { authorization: `Bearer ${getCookie("token")}` }, - credentials: "include", + loadUser(); + async function loadUser() { + const jamResponse = await getCurrentJam(); + const currentJam = jamResponse?.jam; + setJam(currentJam); + + if (!hasCookie("token")) { + setUser(undefined); + return; + } + + const response = await fetch( + process.env.NEXT_PUBLIC_MODE === "PROD" + ? `https://d2jam.com/api/v1/self?username=${getCookie("user")}` + : `http://localhost:3005/api/v1/self?username=${getCookie("user")}`, + { + headers: { authorization: `Bearer ${getCookie("token")}` }, + credentials: "include", + } + ); + + const user = await response.json(); + + + if ( + currentJam && + user.jams.filter((jam: JamType) => jam.id == currentJam.id).length > 0 + ) { + setIsInJam(true); + } else { + setIsInJam(false); + } + + if (response.status == 200) { + setUser(user); + } else { + setUser(undefined); } - ); - - const user = await response.json(); - - if ( - currentJam && - user.jams.filter((jam: JamType) => jam.id == currentJam.id).length > 0 - ) { - setIsInJam(true); - } else { - setIsInJam(false); } - - if (response.status == 200) { - setUser(user); - } else { - setUser(undefined); - } - } - }, [pathname]); + }, [pathname]); return ( <NavbarBase maxWidth="2xl" className="bg-[#222] p-1" isBordered height={80}> - {/* Left side navbar items */} <NavbarContent justify="start" className="gap-10"> <NavbarBrand className="flex-grow-0"> <Link @@ -85,9 +88,9 @@ export default function MobileNavbar() { </Link> </NavbarBrand> </NavbarContent> - - {/* Right side navbar items */} + <NavbarContent justify="end" className="gap-4"> + <ThemeToggle /> {!user && ( <NavbarButtonLink icon={<LogInIcon />} name="Log In" href="/login" /> )} diff --git a/src/components/navbar/MobileNavbarUser.tsx b/src/components/navbar/MobileNavbarUser.tsx index ecb80ed..8edee1e 100644 --- a/src/components/navbar/MobileNavbarUser.tsx +++ b/src/components/navbar/MobileNavbarUser.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Avatar, Dropdown, @@ -7,9 +9,9 @@ import { NavbarItem, } from "@nextui-org/react"; import { UserType } from "@/types/UserType"; -import { getCurrentJam, joinJam } from "@/helpers/jam"; import { JamType } from "@/types/JamType"; -import { Dispatch, SetStateAction } from "react"; +import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import { getCurrentJam, joinJam } from "@/helpers/jam"; import { toast } from "react-toastify"; interface NavbarUserProps { @@ -19,12 +21,27 @@ interface NavbarUserProps { isInJam?: boolean; } -export default async function MobileNavbarUser({ +export default function MobileNavbarUser({ user, jam, setIsInJam, isInJam, }: NavbarUserProps) { + const [currentJam, setCurrentJam] = useState<JamType | null>(null); + + useEffect(() => { + const fetchCurrentJam = async () => { + try { + const response = await getCurrentJam(); + setCurrentJam(response?.jam || null); + } catch (error) { + console.error("Error fetching current jam:", error); + } + }; + + fetchCurrentJam(); + }, []); + return ( <NavbarItem> <Dropdown> @@ -38,7 +55,7 @@ export default async function MobileNavbarUser({ /> </DropdownTrigger> <DropdownMenu> - {jam && (await getCurrentJam())?.jam && isInJam ? ( + {jam && currentJam && isInJam ? ( <DropdownItem key="create-game" href="/create-game" @@ -47,20 +64,18 @@ export default async function MobileNavbarUser({ Create Game </DropdownItem> ) : null} - {jam && (await getCurrentJam())?.jam && !isInJam ? ( + {jam && currentJam && !isInJam ? ( <DropdownItem key="join-event" className="text-black" onPress={async () => { try { - const currentJam = await getCurrentJam(); - - if (!currentJam || !currentJam.jam) { + if (!currentJam) { toast.error("There is no jam to join"); return; } - if (await joinJam(currentJam.jam.id)) { + if (await joinJam(currentJam.id)) { setIsInJam(true); } } catch (error) { @@ -105,4 +120,4 @@ export default async function MobileNavbarUser({ </Dropdown> </NavbarItem> ); -} +} \ No newline at end of file diff --git a/src/components/navbar/PCNavbar.tsx b/src/components/navbar/PCNavbar.tsx index 1e14e58..25476c3 100644 --- a/src/components/navbar/PCNavbar.tsx +++ b/src/components/navbar/PCNavbar.tsx @@ -26,6 +26,7 @@ import { usePathname } from "next/navigation"; import { getCookie, hasCookie } from "@/helpers/cookie"; import { getCurrentJam, joinJam } from "@/helpers/jam"; import { JamType } from "@/types/JamType"; +import { GameType } from "@/types/GameType"; import { UserType } from "@/types/UserType"; import NavbarUser from "./PCNavbarUser"; import NavbarButtonAction from "./NavbarButtonAction"; @@ -39,6 +40,7 @@ export default function PCNavbar() { const [isInJam, setIsInJam] = useState<boolean>(); const [user, setUser] = useState<UserType>(); const [reduceMotion, setReduceMotion] = useState<boolean>(false); + const [hasGame, setHasGame] = useState<GameType | null>(); useEffect(() => { const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); @@ -60,12 +62,12 @@ export default function PCNavbar() { const jamResponse = await getCurrentJam(); const currentJam = jamResponse?.jam; setJam(currentJam); - + if (!hasCookie("token")) { setUser(undefined); return; } - + const response = await fetch( process.env.NEXT_PUBLIC_MODE === "PROD" ? `https://d2jam.com/api/v1/self?username=${getCookie("user")}` @@ -75,9 +77,41 @@ export default function PCNavbar() { credentials: "include", } ); - + const user = await response.json(); - + + // Check if user has a game in current jam + const gameResponse = await fetch( + process.env.NEXT_PUBLIC_MODE === "PROD" + ? `https://d2jam.com/api/v1/self/current-game?username=${getCookie("user")}` + : `http://localhost:3005/api/v1/self/current-game?username=${getCookie("user")}`, + { + headers: { authorization: `Bearer ${getCookie("token")}` }, + credentials: "include", + } + ); + + if (gameResponse.ok) { + const gameData = await gameResponse.json(); + console.log("Game Data:", gameData); // Log game data + console.log("User Data:", user); // Log user data + + if (gameData) { + // Check if the logged-in user is either the creator or a contributor + const isContributor = + gameData.author?.id === user.id || // Check if logged-in user is the author + gameData.contributors?.some((contributor: UserType) => contributor.id === user.id); // Check if logged-in user is a contributor + + console.log("Is Contributor:", isContributor); // Log whether the user is a contributor + + if (isContributor) { + setHasGame(gameData); // Set the game data for "My Game" + } else { + setHasGame(null); // No game associated with this user + } + } + } + if ( currentJam && user.jams.filter((jam: JamType) => jam.id == currentJam.id).length > 0 @@ -86,7 +120,7 @@ export default function PCNavbar() { } else { setIsInJam(false); } - + if (response.status == 200) { setUser(user); } else { @@ -94,6 +128,7 @@ export default function PCNavbar() { } } }, [pathname]); + return ( <NavbarBase @@ -126,15 +161,15 @@ export default function PCNavbar() { <NavbarLink href="/games" name="Games" /> </NavbarContent> - {/* Right side navbar items */} + <NavbarContent justify="end" className="gap-4"> <NavbarSearchbar /> {user && <Divider orientation="vertical" className="h-1/2" />} {user && jam && isInJam && ( <NavbarButtonLink icon={<Gamepad2 />} - name="Create Game" - href="/create-game" + name={hasGame ? "My Game" : "Create Game"} + href={hasGame ? "/games/"+hasGame.slug : "/create-game"} /> )} {user && jam && !isInJam && ( diff --git a/src/components/themes/theme-slaughter.tsx b/src/components/themes/theme-slaughter.tsx index 0fb57fa..99b6178 100644 --- a/src/components/themes/theme-slaughter.tsx +++ b/src/components/themes/theme-slaughter.tsx @@ -20,6 +20,7 @@ export default function ThemeSlaughter() { null ); const [phaseLoading, setPhaseLoading] = useState(true); + const [themeLoading, setThemeLoading] = useState<{ [key: number]: boolean }>({}); // Fetch token on the client side useEffect(() => { @@ -108,8 +109,10 @@ export default function ThemeSlaughter() { // Handle voting const handleVote = async (voteType: string) => { if (!randomTheme) return; - - setLoading(true); + + // Set loading for the current random theme + setThemeLoading((prev) => ({ ...prev, [randomTheme.id]: true })); + try { const response = await fetch( process.env.NEXT_PUBLIC_MODE === "PROD" @@ -128,7 +131,7 @@ export default function ThemeSlaughter() { }), } ); - + if (response.ok) { // Refresh data after voting fetchRandomTheme(); @@ -139,7 +142,8 @@ export default function ThemeSlaughter() { } catch (error) { console.error("Error submitting vote:", error); } finally { - setLoading(false); + // Remove loading state for the current random theme + setThemeLoading((prev) => ({ ...prev, [randomTheme.id]: false })); } }; @@ -229,68 +233,78 @@ export default function ThemeSlaughter() { return ( <div className="flex h-screen"> - {/* Left Side */} - <div className="w-1/2 p-6 bg-gray-100 dark:bg-gray-800 flex flex-col justify-start items-center"> - {randomTheme ? ( - <> - <h2 className="text-2xl font-bold text-gray-800 dark:text-white mb-4"> - {randomTheme.suggestion} - </h2> - <div className="flex gap-4"> - <button - onClick={() => handleVote("YES")} - className="px-6 py-3 bg-green-500 text-white font-bold rounded-lg hover:bg-green-600" - disabled={loading} - > - YES - </button> - <button - onClick={() => handleVote("NO")} - className="px-6 py-3 bg-red-500 text-white font-bold rounded-lg hover:bg-red-600" - disabled={loading} - > - NO - </button> - <button - onClick={() => handleVote("SKIP")} - className="px-6 py-3 bg-gray-500 text-white font-bold rounded-lg hover:bg-gray-600" - disabled={loading} - > - SKIP - </button> - </div> - </> - ) : ( - <p className="text-gray-600 dark:text-gray-400"> - No themes available. - </p> - )} - </div> - - {/* Right Side */} - <div className="w-1/2 p-6 bg-white dark:bg-gray-900 overflow-y-auto"> - <h3 className="text-xl font-bold text-gray-800 dark:text-white mb-4"> - Your Votes - </h3> - <div className="grid grid-cols-4 gap-4"> - {votedThemes.map((theme) => ( - <div - key={theme.id} - onClick={() => handleResetVote(theme.id)} - className={`p-4 rounded-lg cursor-pointer ${ - theme.slaughterScore > 0 - ? "bg-green-500 text-white" - : theme.slaughterScore < 0 - ? "bg-red-500 text-white" - : "bg-gray-300 text-black" + {/* Left Side */} + <div className="w-1/2 p-6 bg-gray-100 dark:bg-gray-800 flex flex-col justify-start items-center"> + {randomTheme ? ( + <> + <h2 className="text-2xl font-bold text-gray-800 dark:text-white mb-4"> + {randomTheme.suggestion} + </h2> + <div className="flex gap-4"> + <button + onClick={() => handleVote("YES")} + className={`px-6 py-3 font-bold rounded-lg ${ + themeLoading[randomTheme?.id || -1] + ? "bg-gray-400 text-white cursor-not-allowed" + : "bg-green-500 text-white hover:bg-green-600" }`} + disabled={themeLoading[randomTheme?.id || -1]} > - {theme.suggestion} - </div> - ))} - </div> + YES + </button> + <button + onClick={() => handleVote("NO")} + className={`px-6 py-3 font-bold rounded-lg ${ + themeLoading[randomTheme?.id || -1] + ? "bg-gray-400 text-white cursor-not-allowed" + : "bg-red-500 text-white hover:bg-red-600" + }`} + disabled={themeLoading[randomTheme?.id || -1]} + > + NO + </button> + <button + onClick={() => handleVote("SKIP")} + className={`px-6 py-3 font-bold rounded-lg ${ + themeLoading[randomTheme?.id || -1] + ? "bg-gray-400 text-white cursor-not-allowed" + : "bg-gray-500 text-white hover:bg-gray-600" + }`} + disabled={themeLoading[randomTheme?.id || -1]} + > + SKIP + </button> + </div> + </> + ) : ( + <p className="text-gray-600 dark:text-gray-400">No themes available.</p> + )} + </div> + + {/* Right Side */} + <div className="w-1/2 p-6 bg-white dark:bg-gray-900 overflow-y-auto"> + <h3 className="text-xl font-bold text-gray-800 dark:text-white mb-4"> + Your Votes + </h3> + <div className="grid grid-cols-4 gap-4"> + {votedThemes.map((theme) => ( + <div + key={theme.id} + onClick={() => handleResetVote(theme.id)} + className={`p-4 rounded-lg cursor-pointer ${ + theme.slaughterScore > 0 + ? "bg-green-500 text-white" + : theme.slaughterScore < 0 + ? "bg-red-500 text-white" + : "bg-gray-300 text-black" + }`} + > + {theme.suggestion} + </div> + ))} </div> </div> + </div> ); return <></>; } diff --git a/src/components/themes/theme-vote.tsx b/src/components/themes/theme-vote.tsx index 840bd9e..7cb1900 100644 --- a/src/components/themes/theme-vote.tsx +++ b/src/components/themes/theme-vote.tsx @@ -96,7 +96,12 @@ export default function VotingPage() { // Handle voting const handleVote = async (themeId: number, votingScore: number) => { - setLoading(true); + setThemes((prevThemes) => + prevThemes.map((theme) => + theme.id === themeId ? { ...theme, loading: true } : theme + ) + ); + try { const response = await fetch( process.env.NEXT_PUBLIC_MODE === "PROD" @@ -112,21 +117,30 @@ export default function VotingPage() { body: JSON.stringify({ suggestionId: themeId, votingScore }), } ); - + if (response.ok) { - // Just update the local state instead of re-fetching all themes setThemes((prevThemes) => prevThemes.map((theme) => - theme.id === themeId ? { ...theme, votingScore } : theme + theme.id === themeId + ? { ...theme, votingScore, loading: false } + : theme ) ); } else { console.error("Failed to submit vote."); + setThemes((prevThemes) => + prevThemes.map((theme) => + theme.id === themeId ? { ...theme, loading: false } : theme + ) + ); } } catch (error) { console.error("Error submitting vote:", error); - } finally { - setLoading(false); + setThemes((prevThemes) => + prevThemes.map((theme) => + theme.id === themeId ? { ...theme, loading: false } : theme + ) + ); } }; diff --git a/src/components/timers/index.tsx b/src/components/timers/index.tsx index e2533c1..32d659b 100644 --- a/src/components/timers/index.tsx +++ b/src/components/timers/index.tsx @@ -23,24 +23,55 @@ export default function Timers() { fetchCurrentJamPhase(); }, []); - if (activeJamResponse && activeJamResponse.jam) { - return ( - <div className="text-[#333] dark:text-white transition-color duration-250"> - <Timer - name="Jam Start" - targetDate={new Date(activeJamResponse.jam.startTime)} - /> - <Spacer y={8} /> - <p>Site under construction</p> - </div> - ); - } else { - return ( - <div className="text-[#333] dark:text-white transition-color duration-250"> - No upcoming jams - <Spacer y={8} /> - <p>Site under construction</p> - </div> - ); + + + if(activeJamResponse && activeJamResponse.jam) + { + const startTimeUTC = new Date(activeJamResponse.jam.startTime).toISOString(); + console.log(startTimeUTC); + + if (activeJamResponse.phase == "Suggestion" || activeJamResponse.phase == "Survival" || activeJamResponse.phase == "Voting") { + return ( + <div className="text-[#333] dark:text-white transition-color duration-250"> + <Timer + name="Jam starts in" + targetDate={new Date(activeJamResponse.jam.startTime) } + /> + <Spacer y={8} /> + <p>Site under construction</p> + </div> + ); + } else if (activeJamResponse.phase == "Jamming") { + return ( + <div className="text-[#333] dark:text-white transition-color duration-250"> + <Timer + name="Jam ends in" + targetDate={ new Date(new Date(activeJamResponse.jam.startTime).getTime() + (activeJamResponse.jam.jammingHours * 60 * 60 * 1000))} + /> + <Spacer y={8} /> + <p>Site under construction</p> + </div> + ); + } else if (activeJamResponse.phase == "Rating") { + return ( + <div className="text-[#333] dark:text-white transition-color duration-250"> + <Timer + name="Rating ends in" + targetDate={new Date(new Date(activeJamResponse.jam.startTime).getTime() + (activeJamResponse.jam.jammingHours * 60 * 60 * 1000) + (activeJamResponse.jam.ratingHours * 60 * 60 * 1000))} + /> + <Spacer y={8} /> + <p>Site under construction</p> + </div> + ); + } else { + return ( + <div className="text-[#333] dark:text-white transition-color duration-250"> + No upcoming jams + <Spacer y={8} /> + <p>Site under construction</p> + </div> + ); + } } + } diff --git a/src/helpers/jam.ts b/src/helpers/jam.ts index 0190585..8740a87 100644 --- a/src/helpers/jam.ts +++ b/src/helpers/jam.ts @@ -25,10 +25,8 @@ export async function getCurrentJam(): Promise<ActiveJamResponse | null> { : "http://localhost:3005/api/v1/jams/active" ); - // Parse JSON response const data = await response.json(); - - // Return the phase and jam details + return { phase: data.phase, jam: data.futureJam, diff --git a/src/types/DownloadLinkType.ts b/src/types/DownloadLinkType.ts new file mode 100644 index 0000000..f52c2f5 --- /dev/null +++ b/src/types/DownloadLinkType.ts @@ -0,0 +1,6 @@ +export type PlatformType = "Windows" | "MacOS" | "Linux" | "Web" | "Mobile" | "Other"; +export interface DownloadLinkType { + id: number; + url: string; + platform: PlatformType; +} \ No newline at end of file diff --git a/src/types/GameType.ts b/src/types/GameType.ts new file mode 100644 index 0000000..4a86deb --- /dev/null +++ b/src/types/GameType.ts @@ -0,0 +1,17 @@ +import { DownloadLinkType } from "./DownloadLinkType"; +import { UserType } from "./UserType"; + +export interface GameType { + id: number; + slug: string, + name: string; + authorId: number; + author: UserType; + description?: string; + thumbnail?: string; + createdAt: Date; + updatedAt: Date; + downloadLinks: DownloadLinkType[]; + contributors: UserType[]; + } + \ No newline at end of file