Compare commits

...

9 commits

Author SHA1 Message Date
Ategon
87878f270a Add link icon to editor 2025-02-10 22:58:26 -05:00
Ategon
a2a1323e01 Add toggle to hide email 2025-02-10 22:30:41 -05:00
Ategon
eb54bd588a Add email to sign up 2025-02-10 21:57:57 -05:00
Ategon
7a5cbfdfba Fix chromium opening images in a new tab 2025-02-10 21:12:33 -05:00
Ategon
922d89424b Add image uploading to game page 2025-02-10 21:06:23 -05:00
Ategon
21bb6b7188 Allow images in posts 2025-02-10 20:57:13 -05:00
Ategon
333bb8b387 Add links 2025-02-10 19:48:34 -05:00
Ategon
f8b2ca69d9 Adjust banner picture wording 2025-02-10 19:23:38 -05:00
Ategon
8939141f76 Add image uploads 2025-02-10 18:25:42 -05:00
12 changed files with 703 additions and 346 deletions

View file

@ -7,6 +7,14 @@ const nextConfig: NextConfig = {
protocol: "https", protocol: "https",
hostname: "**", hostname: "**",
}, },
...(process.env.NEXT_PUBLIC_MODE === "DEV"
? [
{
protocol: "http" as "http",
hostname: "localhost",
},
]
: []),
], ],
}, },
}; };

36
package-lock.json generated
View file

@ -25,6 +25,7 @@
"@tiptap/extension-horizontal-rule": "^2.11.2", "@tiptap/extension-horizontal-rule": "^2.11.2",
"@tiptap/extension-image": "^2.11.2", "@tiptap/extension-image": "^2.11.2",
"@tiptap/extension-italic": "^2.11.2", "@tiptap/extension-italic": "^2.11.2",
"@tiptap/extension-link": "^2.11.5",
"@tiptap/extension-list-item": "^2.11.2", "@tiptap/extension-list-item": "^2.11.2",
"@tiptap/extension-ordered-list": "^2.11.2", "@tiptap/extension-ordered-list": "^2.11.2",
"@tiptap/extension-paragraph": "^2.11.2", "@tiptap/extension-paragraph": "^2.11.2",
@ -58,6 +59,7 @@
"react-select": "^5.9.0", "react-select": "^5.9.0",
"react-toastify": "^11.0.3", "react-toastify": "^11.0.3",
"sanitize-html": "^2.14.0", "sanitize-html": "^2.14.0",
"tiptap-extension-resize-image": "^1.2.1",
"tiptap-markdown": "^0.8.10" "tiptap-markdown": "^0.8.10"
}, },
"devDependencies": { "devDependencies": {
@ -4361,6 +4363,23 @@
"@tiptap/core": "^2.7.0" "@tiptap/core": "^2.7.0"
} }
}, },
"node_modules/@tiptap/extension-link": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.11.5.tgz",
"integrity": "sha512-4Iu/aPzevbYpe50xDI0ZkqRa6nkZ9eF270Ue2qaF3Ab47nehj+9Jl78XXzo8+LTyFMnrETI73TAs1aC/IGySeQ==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-list-item": { "node_modules/@tiptap/extension-list-item": {
"version": "2.11.2", "version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.11.2.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.11.2.tgz",
@ -7980,6 +7999,12 @@
"uc.micro": "^2.0.0" "uc.micro": "^2.0.0"
} }
}, },
"node_modules/linkifyjs": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.2.0.tgz",
"integrity": "sha512-pCj3PrQyATaoTYKHrgWRF3SJwsm61udVh+vuls/Rl6SptiDhgE7ziUIudAedRY9QEfynmM7/RmLEfPUyw1HPCw==",
"license": "MIT"
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -12630,6 +12655,17 @@
"@popperjs/core": "^2.9.0" "@popperjs/core": "^2.9.0"
} }
}, },
"node_modules/tiptap-extension-resize-image": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/tiptap-extension-resize-image/-/tiptap-extension-resize-image-1.2.1.tgz",
"integrity": "sha512-SLMAujDa+0LN/6Iv2HtU4Uk0BL6LMh4b/r85frpdnjFDW2i6pIOfTVG8jzJQ8T1EgYHNn2YG1U2HoVAGuwLc3Q==",
"license": "MIT",
"peerDependencies": {
"@tiptap/core": "^2.0.0",
"@tiptap/extension-image": "^2.0.0",
"@tiptap/pm": "^2.0.0"
}
},
"node_modules/tiptap-markdown": { "node_modules/tiptap-markdown": {
"version": "0.8.10", "version": "0.8.10",
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.8.10.tgz", "resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.8.10.tgz",

View file

@ -26,6 +26,7 @@
"@tiptap/extension-horizontal-rule": "^2.11.2", "@tiptap/extension-horizontal-rule": "^2.11.2",
"@tiptap/extension-image": "^2.11.2", "@tiptap/extension-image": "^2.11.2",
"@tiptap/extension-italic": "^2.11.2", "@tiptap/extension-italic": "^2.11.2",
"@tiptap/extension-link": "^2.11.5",
"@tiptap/extension-list-item": "^2.11.2", "@tiptap/extension-list-item": "^2.11.2",
"@tiptap/extension-ordered-list": "^2.11.2", "@tiptap/extension-ordered-list": "^2.11.2",
"@tiptap/extension-paragraph": "^2.11.2", "@tiptap/extension-paragraph": "^2.11.2",
@ -59,6 +60,7 @@
"react-select": "^5.9.0", "react-select": "^5.9.0",
"react-toastify": "^11.0.3", "react-toastify": "^11.0.3",
"sanitize-html": "^2.14.0", "sanitize-html": "^2.14.0",
"tiptap-extension-resize-image": "^1.2.1",
"tiptap-markdown": "^0.8.10" "tiptap-markdown": "^0.8.10"
}, },
"devDependencies": { "devDependencies": {

View file

@ -2,22 +2,19 @@
import Editor from "@/components/editor"; import Editor from "@/components/editor";
import { getCookie } from "@/helpers/cookie"; import { getCookie } from "@/helpers/cookie";
import { Button, Form, Input, Spacer } from "@nextui-org/react"; import { Button, Form, Input, Spacer } from "@nextui-org/react";
import { LoaderCircle } from "lucide-react"; import { LoaderCircle } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import sanitizeHtml from "sanitize-html";
import { Select, SelectItem } from "@nextui-org/react"; import { Select, SelectItem } from "@nextui-org/react";
import Timers from "@/components/timers"; import Timers from "@/components/timers";
import Streams from "@/components/streams"; import Streams from "@/components/streams";
import { UserType } from "@/types/UserType"; import { UserType } from "@/types/UserType";
import { useRouter } from 'next/navigation'; import { useRouter } from "next/navigation";
import { GameType } from "@/types/GameType"; import { GameType } from "@/types/GameType";
import { PlatformType, DownloadLinkType } from "@/types/DownloadLinkType"; import { PlatformType, DownloadLinkType } from "@/types/DownloadLinkType";
import { sanitize } from "@/helpers/sanitize";
import Image from "next/image";
export default function CreateGamePage() { export default function CreateGamePage() {
const router = useRouter(); const router = useRouter();
@ -39,7 +36,7 @@ export default function CreateGamePage() {
const [gameSlug, setGameSlug] = useState(""); const [gameSlug, setGameSlug] = useState("");
const [prevSlug, setPrevGameSlug] = useState(""); const [prevSlug, setPrevGameSlug] = useState("");
const [game, setGame] = useState<GameType>(); const [game, setGame] = useState<GameType>();
const [thumbnailUrl, setThumbnailUrl] = useState(""); const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const [authorSearch, setAuthorSearch] = useState(""); const [authorSearch, setAuthorSearch] = useState("");
const [selectedAuthors, setSelectedAuthors] = useState<Array<UserType>>([]); const [selectedAuthors, setSelectedAuthors] = useState<Array<UserType>>([]);
const [searchResults, setSearchResults] = useState<Array<UserType>>([]); const [searchResults, setSearchResults] = useState<Array<UserType>>([]);
@ -53,8 +50,8 @@ export default function CreateGamePage() {
const sanitizeSlug = (value: string): string => { const sanitizeSlug = (value: string): string => {
return value return value
.toLowerCase() // Convert to lowercase .toLowerCase() // Convert to lowercase
.replace(/\s+/g, '-') // Replace whitespace with hyphens .replace(/\s+/g, "-") // Replace whitespace with hyphens
.replace(/[^a-z0-9-]/g, '') // Only allow lowercase letters, numbers, and hyphens .replace(/[^a-z0-9-]/g, "") // Only allow lowercase letters, numbers, and hyphens
.substring(0, 50); // Limit length to 50 characters .substring(0, 50); // Limit length to 50 characters
}; };
@ -150,15 +147,18 @@ export default function CreateGamePage() {
*/ */
}; };
load(); load();
},[]); }, []);
useEffect(() => { useEffect(() => {
const checkExistingGame = async () => { const checkExistingGame = async () => {
const response = await fetch( const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD" process.env.NEXT_PUBLIC_MODE === "PROD"
? `https://d2jam.com/api/v1/self/current-game?username=${getCookie("user")}` ? `https://d2jam.com/api/v1/self/current-game?username=${getCookie(
: `http://localhost:3005/api/v1/self/current-game?username=${getCookie("user")}`, "user"
)}`
: `http://localhost:3005/api/v1/self/current-game?username=${getCookie(
"user"
)}`,
{ {
headers: { authorization: `Bearer ${getCookie("token")}` }, headers: { authorization: `Bearer ${getCookie("token")}` },
credentials: "include", credentials: "include",
@ -177,21 +177,18 @@ export default function CreateGamePage() {
setThumbnailUrl(gameData.thumbnail); setThumbnailUrl(gameData.thumbnail);
setDownloadLinks(gameData.downloadLinks); setDownloadLinks(gameData.downloadLinks);
setGame(gameData); setGame(gameData);
const uniqueAuthors = [gameData.author, ...gameData.contributors] const uniqueAuthors = [
.filter((author, index, self) => gameData.author,
...gameData.contributors,
].filter(
(author, index, self) =>
index === self.findIndex((a) => a.id === author.id) index === self.findIndex((a) => a.id === author.id)
); );
setSelectedAuthors(uniqueAuthors); setSelectedAuthors(uniqueAuthors);
} else {
}
else
{
setSelectedAuthors(user ? [user] : []); setSelectedAuthors(user ? [user] : []);
} }
} } else {
else
{
setEditGame(false); setEditGame(false);
setTitle(""); setTitle("");
setGameSlug(""); setGameSlug("");
@ -199,318 +196,386 @@ export default function CreateGamePage() {
setEditorKey((prev) => prev + 1); setEditorKey((prev) => prev + 1);
setThumbnailUrl(""); setThumbnailUrl("");
setDownloadLinks([]); setDownloadLinks([]);
} }
}; };
if (mounted && user) { if (mounted && user) {
checkExistingGame(); checkExistingGame();
} }
}, [user, mounted]);
},[user,mounted]);
return ( return (
<div className="static flex items-top mt-20 justify-center top-0 left-0"> <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 if (!title && !content) {
className="w-full max-w-2xl flex flex-col gap-4" setErrors({
validationErrors={errors} title: "Please enter a valid title",
onSubmit={async (e) => { content: "Please enter valid content",
e.preventDefault(); });
toast.error("Please enter valid content");
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 (!content) {
setErrors({ content: "Please enter valid content" });
toast.error("Please enter valid content");
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);
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${endpoint}`
: `http://localhost:3005/api/v1${endpoint}`,
{
body: JSON.stringify({
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: requestMethod,
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${getCookie("token")}`,
},
credentials: "include",
}
);
if (response.status === 401) {
setErrors({ content: "Invalid user" });
setWaitingPost(false);
return; return;
} }
if (response.ok) { if (!title) {
toast.success(gameSlug ? "Game updated successfully!" : "Game created successfully!"); setErrors({ title: "Please enter a valid title" });
setWaitingPost(false); return;
router.push(`/games/${gameSlug || sanitizeSlug(title)}`);
} else {
const error = await response.text();
toast.error(error || "Failed to create game");
setWaitingPost(false);
} }
} catch (error) {
console.error("Error creating game:", error); if (!content) {
toast.error("Failed to create game."); setErrors({ content: "Please enter valid content" });
} toast.error("Please enter valid content");
}} return;
>
<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 const userSlug = getCookie("user"); // Retrieve user slug from cookies
label="Game Slug" if (!userSlug) {
labelPlacement="outside" toast.error("You are not logged in.");
placeholder="your-game-name" return;
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"> const sanitizedHtml = sanitize(content);
<label className="text-sm font-medium">Add Authors</label> setWaitingPost(true);
<Input
placeholder="Search users..." try {
value={authorSearch} const requestMethod = editGame ? "PUT" : "POST";
onValueChange={(value) => { const endpoint = editGame ? `/games/${prevSlug}` : "/games/create";
setAuthorSearch(value);
handleAuthorSearch(value); const response = await fetch(
}} process.env.NEXT_PUBLIC_MODE === "PROD"
/> ? `https://d2jam.com/api/v1${endpoint}`
{searchResults.length > 0 && ( : `http://localhost:3005/api/v1${endpoint}`,
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-2"> {
{searchResults.map((user) => ( body: JSON.stringify({
<div name: title,
key={user.id} slug: gameSlug,
className="flex justify-between items-center p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded cursor-pointer" description: sanitizedHtml,
onClick={() => { thumbnail: thumbnailUrl,
if (!selectedAuthors.some(a => a.id === user.id)) { downloadLinks: downloadLinks.map((link) => ({
setSelectedAuthors([...selectedAuthors, user]); url: link.url,
platform: link.platform,
})),
userSlug,
contributors: selectedAuthors.map((author) => author.id),
}),
method: requestMethod,
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${getCookie("token")}`,
},
credentials: "include",
} }
setSearchResults([]); );
setAuthorSearch("");
if (response.status === 401) {
setErrors({ content: "Invalid user" });
setWaitingPost(false);
return;
}
if (response.ok) {
toast.success(
gameSlug
? "Game updated successfully!"
: "Game created successfully!"
);
setWaitingPost(false);
router.push(`/games/${gameSlug || sanitizeSlug(title)}`);
} else {
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
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>
<Input
placeholder="Search users..."
value={authorSearch}
onValueChange={(value) => {
setAuthorSearch(value);
handleAuthorSearch(value);
}} }}
> />
<span>{user.name}</span> {searchResults.length > 0 && (
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-2">
{searchResults.map((user) => (
<div
key={user.id}
className="flex justify-between items-center p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded cursor-pointer"
onClick={() => {
if (!selectedAuthors.some((a) => a.id === user.id)) {
setSelectedAuthors([...selectedAuthors, user]);
}
setSearchResults([]);
setAuthorSearch("");
}}
>
<span>{user.name}</span>
</div>
))}
</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"
>
<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"
>
×
</button>
)}
</div>
))}
</div> </div>
))} </div>
</div> <label className="text-sm font-medium">Game Description</label>
)} <Editor
<div className="flex flex-wrap gap-2 mt-2"> key={editorKey}
{selectedAuthors.map((author) => ( content={content}
<div setContent={setContent}
key={author.id} gameEditor
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"
>
×
</button>
)}
</div>
))}
</div>
</div>
<label className="text-sm font-medium">Game Description</label>
<Editor key={editorKey} content={content} setContent={setContent} gameEditor />
<Spacer /> <Spacer />
<div className="flex flex-col gap-4">
<p>Thumbnail</p>
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append("upload", file);
<div className="flex flex-col gap-4"> try {
const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
<Input ? "https://d2jam.com/api/v1/image"
label="Thumbnail URL" : "http://localhost:3005/api/v1/image",
labelPlacement="outside" {
placeholder="https://example.com/thumbnail.png" method: "POST",
value={thumbnailUrl} body: formData,
onValueChange={setThumbnailUrl} headers: {
/> authorization: `Bearer ${getCookie("token")}`,
},
<div className="flex flex-col gap-4"> credentials: "include",
<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;
}
} }
);
if (response.ok) {
const data = await response.json();
setThumbnailUrl(data.data);
toast.success(data.message);
} else {
toast.error("Failed to upload image");
} }
}} } catch (error) {
/> console.error(error);
<Select toast.error("Error uploading image");
className="w-96" }
defaultSelectedKeys={["Windows"]} }}
aria-label="Select platform" // Add this to fix accessibility warning />
onSelectionChange={(value) => {
const newLinks = [...downloadLinks]; {thumbnailUrl && (
newLinks[index].platform = value as unknown as PlatformType; <div className="w-full">
setDownloadLinks(newLinks); <div className="bg-[#222222] h-28 w-full relative">
}} <Image
> src={thumbnailUrl}
<SelectItem key="Windows" value="Windows"> alt={`${title}'s thumbnail`}
Windows className="object-cover"
</SelectItem> fill
<SelectItem key="MacOS" value="MacOS"> />
MacOS </div>
</SelectItem> <Spacer y={3} />
<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 <Button
color="danger" color="danger"
variant="light" size="sm"
onPress={() => { onPress={() => {
setDownloadLinks(downloadLinks.filter((l) => l.id !== link.id)); setThumbnailUrl(null);
}} }}
> >
× Remove Banner Picture
</Button> </Button>
</div> </div>
))}
</div>
<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 className="flex flex-col gap-4">
</div> <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;
}
}
}
}}
/>
<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>
<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>
</Form> </Form>
{!isMobile && ( {!isMobile && (
<div className="flex flex-col gap-4 px-8 items-end"> <div className="flex flex-col gap-4 px-8 items-end">
<Timers /> <Timers />
<Streams /> <Streams />
</div> </div>
)} )}
</div> </div>
); );
} }

View file

@ -14,12 +14,12 @@ import { LoaderCircle } from "lucide-react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ReactNode, useEffect, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import sanitizeHtml from "sanitize-html";
import Select, { MultiValue, StylesConfig } from "react-select"; import Select, { MultiValue, StylesConfig } from "react-select";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import Timers from "@/components/timers"; import Timers from "@/components/timers";
import Streams from "@/components/streams"; import Streams from "@/components/streams";
import { UserType } from "@/types/UserType"; import { UserType } from "@/types/UserType";
import { sanitize } from "@/helpers/sanitize";
export default function CreatePostPage() { export default function CreatePostPage() {
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
@ -118,7 +118,6 @@ export default function CreatePostPage() {
return () => window.removeEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize);
}, []); }, []);
const styles: StylesConfig< const styles: StylesConfig<
{ {
value: string; value: string;
@ -213,7 +212,7 @@ export default function CreatePostPage() {
return; return;
} }
const sanitizedHtml = sanitizeHtml(content); const sanitizedHtml = sanitize(content);
setWaitingPost(true); setWaitingPost(true);
const tags = []; const tags = [];
@ -320,9 +319,9 @@ export default function CreatePostPage() {
</Form> </Form>
{!isMobile && ( {!isMobile && (
<div className="flex flex-col gap-4 px-8 items-end"> <div className="flex flex-col gap-4 px-8 items-end">
<Timers /> <Timers />
<Streams /> <Streams />
</div> </div>
)} )}
</div> </div>
); );

View file

@ -37,8 +37,8 @@ import { redirect, useParams } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import Editor from "@/components/editor"; import Editor from "@/components/editor";
import sanitizeHtml from "sanitize-html";
import CommentCard from "@/components/posts/CommentCard"; import CommentCard from "@/components/posts/CommentCard";
import { sanitize } from "@/helpers/sanitize";
export default function PostPage() { export default function PostPage() {
const [post, setPost] = useState<PostType>(); const [post, setPost] = useState<PostType>();
@ -579,7 +579,7 @@ export default function PostPage() {
return; return;
} }
const sanitizedHtml = sanitizeHtml(content); const sanitizedHtml = sanitize(content);
setWaitingPost(true); setWaitingPost(true);
const response = await fetch( const response = await fetch(

View file

@ -1,25 +1,27 @@
"use client"; "use client";
import Editor from "@/components/editor"; import Editor from "@/components/editor";
import sanitizeHtml from "sanitize-html";
import { getCookie, hasCookie } from "@/helpers/cookie"; import { getCookie, hasCookie } from "@/helpers/cookie";
import { UserType } from "@/types/UserType"; import { UserType } from "@/types/UserType";
import { Avatar, Button, Form, Input } from "@nextui-org/react"; import { Avatar, Button, Form, Input, Spacer } from "@nextui-org/react";
import { redirect, usePathname } from "next/navigation"; import { redirect, usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { LoaderCircle } from "lucide-react"; import { LoaderCircle } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { sanitize } from "@/helpers/sanitize";
export default function UserPage() { export default function UserPage() {
const [user, setUser] = useState<UserType>(); const [user, setUser] = useState<UserType>();
const [profilePicture, setProfilePicture] = useState(""); const [profilePicture, setProfilePicture] = useState<string | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [bannerPicture, setBannerPicture] = useState(""); const [email, setEmail] = useState("");
const [bannerPicture, setBannerPicture] = useState<string | null>(null);
const [bio, setBio] = useState(""); const [bio, setBio] = useState("");
const [errors] = useState({}); const [errors] = useState({});
const pathname = usePathname(); const pathname = usePathname();
const [waitingSave, setWaitingSave] = useState(false); const [waitingSave, setWaitingSave] = useState(false);
const [showEmail, setShowEmail] = useState(false);
useEffect(() => { useEffect(() => {
loadUser(); loadUser();
@ -48,6 +50,7 @@ export default function UserPage() {
setBannerPicture(data.bannerPicture ?? ""); setBannerPicture(data.bannerPicture ?? "");
setBio(data.bio ?? ""); setBio(data.bio ?? "");
setName(data.name ?? ""); setName(data.name ?? "");
setEmail(data.email ?? "");
} else { } else {
setUser(undefined); setUser(undefined);
} }
@ -71,7 +74,7 @@ export default function UserPage() {
onSubmit={async (e) => { onSubmit={async (e) => {
e.preventDefault(); e.preventDefault();
const sanitizedBio = sanitizeHtml(bio); const sanitizedBio = sanitize(bio);
if (!name) { if (!name) {
toast.error("You need to enter a name"); toast.error("You need to enter a name");
@ -92,6 +95,7 @@ export default function UserPage() {
profilePicture: profilePicture, profilePicture: profilePicture,
bannerPicture: bannerPicture, bannerPicture: bannerPicture,
targetUserSlug: user.slug, targetUserSlug: user.slug,
email: email,
}), }),
method: "PUT", method: "PUT",
headers: { headers: {
@ -104,7 +108,7 @@ export default function UserPage() {
if (response.ok) { if (response.ok) {
toast.success("Changed settings"); toast.success("Changed settings");
setUser(await response.json()); setUser((await response.json()).data);
setWaitingSave(false); setWaitingSave(false);
} else { } else {
toast.error("Failed to update settings"); toast.error("Failed to update settings");
@ -124,34 +128,122 @@ export default function UserPage() {
onValueChange={setName} onValueChange={setName}
/> />
<p>Email</p>
{showEmail && (
<Input
label="Email"
name="email"
placeholder="Enter an email"
type="text"
value={email}
onValueChange={setEmail}
/>
)}
<Button size="sm" onPress={() => setShowEmail(!showEmail)}>
{showEmail ? "Hide Email" : "Show Email"}
</Button>
<p>Bio</p> <p>Bio</p>
<Editor content={bio} setContent={setBio} /> <Editor content={bio} setContent={setBio} />
<Input <p>Profile Picture</p>
label="Profile Picture" <input
labelPlacement="outside" type="file"
name="profilePicture" accept="image/*"
placeholder="Enter a url to an image" onChange={async (e) => {
type="text" const file = e.target.files?.[0];
value={profilePicture} if (!file) return;
onValueChange={setProfilePicture}
const formData = new FormData();
formData.append("upload", file);
try {
const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? "https://d2jam.com/api/v1/image"
: "http://localhost:3005/api/v1/image",
{
method: "POST",
body: formData,
headers: {
authorization: `Bearer ${getCookie("token")}`,
},
credentials: "include",
}
);
if (response.ok) {
const data = await response.json();
setProfilePicture(data.data);
toast.success(data.message);
} else {
toast.error("Failed to upload image");
}
} catch (error) {
console.error(error);
toast.error("Error uploading image");
}
}}
/> />
{profilePicture && <Avatar src={profilePicture} />} {profilePicture && (
<div>
<Avatar src={profilePicture} />
<Spacer y={3} />
<Button
color="danger"
size="sm"
onPress={() => {
setProfilePicture(null);
}}
>
Remove Profile Picture
</Button>
</div>
)}
<Input <p>Banner Picture</p>
label="Banner Picture" <input
labelPlacement="outside" type="file"
name="bannerPicture" accept="image/*"
placeholder="Enter a url to an image" onChange={async (e) => {
type="text" const file = e.target.files?.[0];
value={bannerPicture} if (!file) return;
onValueChange={setBannerPicture}
const formData = new FormData();
formData.append("upload", file);
try {
const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? "https://d2jam.com/api/v1/image"
: "http://localhost:3005/api/v1/image",
{
method: "POST",
body: formData,
headers: {
authorization: `Bearer ${getCookie("token")}`,
},
credentials: "include",
}
);
if (response.ok) {
const data = await response.json();
setBannerPicture(data.data);
toast.success(data.message);
} else {
toast.error("Failed to upload image");
}
} catch (error) {
console.error(error);
toast.error("Error uploading image");
}
}}
/> />
{bannerPicture && {bannerPicture && (
bannerPicture.startsWith("https://") && <div className="w-full">
bannerPicture.length > 8 && (
<div className="bg-[#222222] h-28 w-full relative"> <div className="bg-[#222222] h-28 w-full relative">
<Image <Image
src={bannerPicture} src={bannerPicture}
@ -160,7 +252,18 @@ export default function UserPage() {
fill fill
/> />
</div> </div>
)} <Spacer y={3} />
<Button
color="danger"
size="sm"
onPress={() => {
setBannerPicture(null);
}}
>
Remove Banner Picture
</Button>
</div>
)}
<div className="flex gap-2"> <div className="flex gap-2">
<Button color="primary" type="submit"> <Button color="primary" type="submit">

View file

@ -10,6 +10,7 @@ export default function UserPage() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [password2, setPassword2] = useState(""); const [password2, setPassword2] = useState("");
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const [email, setEmail] = useState("");
return ( return (
<div className="absolute flex items-center justify-center top-0 left-0 w-screen h-screen"> <div className="absolute flex items-center justify-center top-0 left-0 w-screen h-screen">
@ -66,12 +67,24 @@ export default function UserPage() {
return; return;
} }
if (email) {
const regex = /.+@.+\..+/;
if (!email.match(regex)) {
setErrors({ email: "Invalid email" });
return;
}
}
const response = await fetch( const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD" process.env.NEXT_PUBLIC_MODE === "PROD"
? "https://d2jam.com/api/v1/user" ? "https://d2jam.com/api/v1/user"
: "http://localhost:3005/api/v1/user", : "http://localhost:3005/api/v1/user",
{ {
body: JSON.stringify({ username: username, password: password }), body: JSON.stringify({
username: username,
password: password,
email: email,
}),
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
@ -104,6 +117,16 @@ export default function UserPage() {
onValueChange={setUsername} onValueChange={setUsername}
/> />
<Input
label="Email"
labelPlacement="outside"
name="email"
placeholder="Optional"
type="text"
value={email}
onValueChange={setEmail}
/>
<Input <Input
isRequired isRequired
label="Password" label="Password"

View file

@ -10,6 +10,7 @@ import {
Code, Code,
Highlighter, Highlighter,
Italic, Italic,
LinkIcon,
Minus, Minus,
Quote, Quote,
Redo, Redo,
@ -28,6 +29,18 @@ type EditorMenuProps = {
export default function EditorMenuBar({ editor }: EditorMenuProps) { export default function EditorMenuBar({ editor }: EditorMenuProps) {
if (!editor) return null; if (!editor) return null;
const addLink = () => {
const url = prompt("Enter link URL:");
if (url) {
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href: url })
.run();
}
};
const buttons = [ const buttons = [
{ {
icon: <Bold size={20} />, icon: <Bold size={20} />,
@ -71,6 +84,12 @@ export default function EditorMenuBar({ editor }: EditorMenuProps) {
disabled: false, disabled: false,
isActive: editor.isActive("superscript"), isActive: editor.isActive("superscript"),
}, },
{
icon: <LinkIcon size={20} />,
onClick: addLink,
disabled: false,
isActive: editor.isActive("link"),
},
{ {
icon: <Minus size={20} />, icon: <Minus size={20} />,
onClick: () => editor.chain().focus().setHorizontalRule().run(), onClick: () => editor.chain().focus().setHorizontalRule().run(),

View file

@ -36,6 +36,10 @@ import Youtube from "@tiptap/extension-youtube";
import CodeBlock from "@tiptap/extension-code-block"; import CodeBlock from "@tiptap/extension-code-block";
import { Spacer } from "@nextui-org/react"; import { Spacer } from "@nextui-org/react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import Link from "@tiptap/extension-link";
import ImageResize from "tiptap-extension-resize-image";
import { toast } from "react-toastify";
import { getCookie } from "@/helpers/cookie";
type EditorProps = { type EditorProps = {
content: string; content: string;
@ -45,7 +49,11 @@ type EditorProps = {
const limit = 32767; const limit = 32767;
export default function Editor({ content, setContent,gameEditor }: EditorProps) { export default function Editor({
content,
setContent,
gameEditor,
}: EditorProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const editor = useEditor({ const editor = useEditor({
@ -89,6 +97,8 @@ export default function Editor({ content, setContent,gameEditor }: EditorProps)
TableCell, TableCell,
Youtube, Youtube,
CodeBlock, CodeBlock,
Link,
ImageResize,
], ],
content: content, content: content,
immediatelyRender: false, immediatelyRender: false,
@ -104,6 +114,77 @@ export default function Editor({ content, setContent,gameEditor }: EditorProps)
: "min-h-[150px] max-h-[400px]") + : "min-h-[150px] max-h-[400px]") +
" overflow-y-auto cursor-text rounded-md border p-5 focus-within:outline-none focus-within:border-blue-500 !duration-250 !ease-linear !transition-all", " overflow-y-auto cursor-text rounded-md border p-5 focus-within:outline-none focus-within:border-blue-500 !duration-250 !ease-linear !transition-all",
}, },
handleDrop: (view, event, slice, moved) => {
if (
!moved &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files[0]
) {
event.preventDefault();
const file = event.dataTransfer.files[0];
const filesize = parseInt((file.size / 1024 / 1024).toFixed(4));
const allowedTypes = [
"image/jpeg", // JPEG images
"image/png", // PNG images
"image/gif", // GIF images
"image/webp", // WebP images
"image/svg+xml", // SVG images
];
if (!allowedTypes.includes(file.type)) {
toast.error("Invalid file format");
return false;
}
if (filesize > 8) {
toast.error("Image is too big");
return false;
}
const formData = new FormData();
formData.append("upload", event.dataTransfer.files[0]);
fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? "https://d2jam.com/api/v1/image"
: "http://localhost:3005/api/v1/image",
{
method: "POST",
body: formData,
headers: {
authorization: `Bearer ${getCookie("token")}`,
},
credentials: "include",
}
).then((response) => {
if (response.ok) {
response.json().then((data) => {
toast.success(data.message);
const { schema } = view.state;
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!coordinates) {
toast.error("Error getting coordinates");
return;
}
const node = schema.nodes.image.create({ src: data.data });
const transaction = view.state.tr.insert(coordinates.pos, node);
return view.dispatch(transaction);
});
} else {
toast.error("Failed to upload image");
}
});
}
return false;
},
}, },
}); });

View file

@ -7,8 +7,8 @@ import { useState } from "react";
import Editor from "../editor"; import Editor from "../editor";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { getCookie, hasCookie } from "@/helpers/cookie"; import { getCookie, hasCookie } from "@/helpers/cookie";
import sanitizeHtml from "sanitize-html";
import LikeButton from "./LikeButton"; import LikeButton from "./LikeButton";
import { sanitize } from "@/helpers/sanitize";
export default function CommentCard({ comment }: { comment: CommentType }) { export default function CommentCard({ comment }: { comment: CommentType }) {
const [creatingReply, setCreatingReply] = useState<boolean>(false); const [creatingReply, setCreatingReply] = useState<boolean>(false);
@ -90,7 +90,7 @@ export default function CommentCard({ comment }: { comment: CommentType }) {
return; return;
} }
const sanitizedHtml = sanitizeHtml(content); const sanitizedHtml = sanitize(content);
setWaitingPost(true); setWaitingPost(true);
const response = await fetch( const response = await fetch(

21
src/helpers/sanitize.ts Normal file
View file

@ -0,0 +1,21 @@
import sanitizeHtml from "sanitize-html";
export function sanitize(content: string) {
return sanitizeHtml(content, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
allowedAttributes: {
img: ["src", "style"],
p: ["style"],
},
allowedStyles: {
img: {
width: [/^\d+(px|%)?|auto$/],
height: [/^\d+(px|%)?|auto$/],
margin: [/^\d+(px|%)?|auto$/],
},
p: {
"text-align": [/^right|left|center$/],
},
},
});
}