Compare commits

...

67 commits

Author SHA1 Message Date
Vitaliy
32d3549ef1
Merge 6d857ffc49 into a5c8ba0e34 2025-02-09 02:14:09 +02:00
Ategon
a5c8ba0e34 Add image previews 2025-02-07 22:52:13 -05:00
Ategon
6a1f3abaa5 Add more user settings 2025-02-07 22:41:10 -05:00
Ategon
7ff860f3cc Fix bug where tags couldnt sort when logged out 2025-02-07 15:38:15 -05:00
Ategon
d980d11be0 Fix timer 2025-01-27 22:01:19 -05:00
Ategon
7eb95b8ce0 Add comments 2025-01-27 21:40:12 -05:00
Ategon
2d6fead452 Remove extra loading 2025-01-27 19:36:09 -05:00
Ategon
2f71aa12ae Add instant feedback to like 2025-01-27 19:36:00 -05:00
Ategon
28461e257c Add new game asset icon 2025-01-27 19:22:43 -05:00
2a89f75ac6 fixed minor bugs 2025-01-25 05:42:08 +03:00
0cb2a40eba game creation page
theme suggestion
2025-01-25 05:35:21 +03:00
Ategon
3ca54b39a1 Fix jam request 2025-01-24 18:44:45 -05:00
Ategon
f6c1ce75a6 Add promotions and demotions 2025-01-24 04:57:24 -05:00
d8894cd0eb reenabled phase pages 2025-01-23 12:18:49 +03:00
505fb56df8 Fixed all the build errors and fixed participation 2025-01-23 12:00:07 +03:00
74bc8014e9 Auto stash before merge of "main" and "origin/main" 2025-01-23 11:10:27 +03:00
Ategon
6791e36401 Fix empty sticky post buffer 2025-01-22 22:16:38 -05:00
Ategon
63d7cd49d2 Fix text when no stickied posts 2025-01-22 22:12:25 -05:00
Ategon
42c730e073 Add stickied posts 2025-01-22 22:08:09 -05:00
Ategon
94a3d31e8d Fix prod link for getCurrentJam 2025-01-22 20:44:05 -05:00
Ategon
5462af1a5a Fix build errors 2025-01-22 20:27:17 -05:00
Ategon
a2307ec34e Fix linting issues 2025-01-22 20:12:54 -05:00
Ategon
0807ce5a67 Merge branch 'main' of https://github.com/Ategon/Jamjar 2025-01-22 19:50:28 -05:00
Ategon
3b4f1f0476 Add post get route 2025-01-22 19:50:08 -05:00
f3007ed6cc Join Jam warnings 2025-01-22 22:59:09 +03:00
1237e8a008 Jam Phases frontend
+ Suggestion Phase
+ Survival Phase
+ Voting Phase
2025-01-22 17:56:15 +03:00
7f7736920a Merge remote-tracking branch 'origin/main' 2025-01-22 12:52:12 +03:00
Ategon
6137049518 Add more icons 2025-01-22 02:42:40 -05:00
Ategon
8d23f58a10 Add mod only tags 2025-01-21 04:10:38 -05:00
e2a436bda3 Auto stash before merge of "main" and "origin/main" 2025-01-21 11:53:14 +03:00
Ategon
440b9d685d Add tags 2025-01-21 03:15:02 -05:00
Ategon
aefb9e2018 Add dropdown backdrop 2025-01-20 01:02:02 -05:00
Ategon
b11ad9354b Add prefers reduced motion handling 2025-01-20 00:57:59 -05:00
Ategon
feb80916cc Add post times 2025-01-20 00:37:30 -05:00
Ategon
3df39a4405 Fix sign up 2025-01-19 20:25:14 -05:00
Ategon
7de5732596 Fix logout to actually logout 2025-01-19 20:18:47 -05:00
Ategon
b816523cf9 Only show mod zone if user is a mod 2025-01-19 19:57:50 -05:00
Ategon
743c217ab2 Update icon to more polished version 2025-01-19 19:30:01 -05:00
Ategon
2325d648d9 Fix timer hydration 2025-01-19 19:26:18 -05:00
Ategon
ce224af649 Fix color fading 2025-01-19 19:03:37 -05:00
Ategon
373aff3911 adjust streamers font color 2025-01-19 18:28:11 -05:00
Ategon
d9daea6ece Merge branch 'main' of https://github.com/Ategon/Jamjar 2025-01-19 18:06:35 -05:00
Ategon
68b45a57b8 fix icon on light theme 2025-01-19 18:06:32 -05:00
f26af125b8 Temp visuals for Featured Streamers on sidebar 2025-01-20 02:05:48 +03:00
Ategon
c8ccb82dce Update favicon 2025-01-19 17:56:08 -05:00
Ategon
8c611cbb86 Update site logo 2025-01-19 17:54:50 -05:00
Ategon
eb58c6c65a Increase limit 2025-01-19 14:00:11 -05:00
Ategon
166c9ac876 Handle system theme properly 2025-01-19 13:54:16 -05:00
Ategon
095e8d73c0 Swap moonicon to moon 2025-01-19 13:48:53 -05:00
Ategon
50ee8098c4 Light theme and wysiwyg 2025-01-19 13:38:18 -05:00
Ategon
469c16fac6 Force white text 2025-01-18 13:49:54 -05:00
Ategon
3cc2d95007 Add post styles 2025-01-18 13:47:40 -05:00
Ategon
e5aa7c2a2a Add post sorts 2025-01-18 13:31:28 -05:00
Ategon
32705c8662 Set likes to number 2025-01-18 12:52:16 -05:00
Ategon
bc7ed52662 Fix PostType 2025-01-18 12:50:39 -05:00
Ategon
7730f17490 Fix linting 2025-01-18 12:48:38 -05:00
Ategon
a258772f7e Merge branch 'main' of https://github.com/Ategon/Jamjar 2025-01-18 12:44:42 -05:00
Ategon
54e5cb6495 Add likes 2025-01-18 12:41:30 -05:00
296491241b Featured Streamers page
discarded my changes on frontpage. Add <Streams /> anywhere you want.
2025-01-17 21:27:52 +03:00
Ategon
a3870224fa Create new site design 2025-01-17 05:19:57 -05:00
Ategon
e908237d84 Merge branch 'main' of https://github.com/Edikoyo-Jam/Jamjar 2025-01-16 16:36:20 -05:00
Ategon
1280002a52 Add settings and jam joining 2025-01-16 16:35:49 -05:00
cef03390d4 updated github link to point https://github.com/Dare2Jam/ 2025-01-16 23:24:24 +03:00
kuviman
6d857ffc49 link comment about mounting .ssh 2025-01-15 23:42:12 +04:00
kuviman
606a0695d8 mount .ssh in devcontainer 2025-01-15 23:42:12 +04:00
kuviman
e63f40c69b devcontainer typescript-node template 2025-01-15 23:42:12 +04:00
kuviman
2523a1052b vscode launch option 2025-01-15 19:37:42 +00:00
130 changed files with 10250 additions and 639 deletions

View file

@ -0,0 +1,21 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
{
"name": "Node.js & TypeScript",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
// https://github.com/csci430-os/vscode-remote-devcontainer/issues/2#issuecomment-1939950604
"mounts": [
"type=bind,source=${localEnv:HOME}/.ssh,target=/home/node/.ssh,readonly"
]
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

18
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,18 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Run dev",
"runtimeExecutable": "next",
"cwd": "${workspaceFolder}",
"args": [
"dev"
]
}
]
}

View file

@ -1,7 +1,14 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**",
},
],
},
};
export default nextConfig;

4138
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,18 +11,61 @@
"dependencies": {
"@icons-pack/react-simple-icons": "^11.0.1",
"@nextui-org/react": "^2.4.8",
"@tailwindcss/typography": "^0.5.16",
"@tiptap/extension-blockquote": "^2.11.2",
"@tiptap/extension-bold": "^2.11.2",
"@tiptap/extension-bullet-list": "^2.11.2",
"@tiptap/extension-character-count": "^2.11.2",
"@tiptap/extension-code-block": "^2.11.2",
"@tiptap/extension-document": "^2.11.2",
"@tiptap/extension-dropcursor": "^2.11.2",
"@tiptap/extension-hard-break": "^2.11.2",
"@tiptap/extension-heading": "^2.11.2",
"@tiptap/extension-highlight": "^2.11.2",
"@tiptap/extension-history": "^2.11.2",
"@tiptap/extension-horizontal-rule": "^2.11.2",
"@tiptap/extension-image": "^2.11.2",
"@tiptap/extension-italic": "^2.11.2",
"@tiptap/extension-list-item": "^2.11.2",
"@tiptap/extension-ordered-list": "^2.11.2",
"@tiptap/extension-paragraph": "^2.11.2",
"@tiptap/extension-strike": "^2.11.2",
"@tiptap/extension-subscript": "^2.11.2",
"@tiptap/extension-superscript": "^2.11.2",
"@tiptap/extension-table": "^2.11.2",
"@tiptap/extension-table-cell": "^2.11.2",
"@tiptap/extension-table-header": "^2.11.2",
"@tiptap/extension-table-row": "^2.11.2",
"@tiptap/extension-task-item": "^2.11.2",
"@tiptap/extension-task-list": "^2.11.2",
"@tiptap/extension-text": "^2.11.2",
"@tiptap/extension-text-align": "^2.11.2",
"@tiptap/extension-typography": "^2.11.2",
"@tiptap/extension-underline": "^2.11.2",
"@tiptap/extension-youtube": "^2.11.2",
"@tiptap/pm": "^2.11.2",
"@tiptap/react": "^2.11.2",
"@tiptap/starter-kit": "^2.11.2",
"date-fns": "^4.1.0",
"framer-motion": "^11.11.9",
"i": "^0.3.7",
"install": "^0.13.0",
"lucide-react": "^0.453.0",
"next": "15.0.1",
"next-themes": "^0.4.4",
"npm": "^11.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-toastify": "^11.0.3"
"react-select": "^5.9.0",
"react-toastify": "^11.0.3",
"sanitize-html": "^2.14.0",
"tiptap-markdown": "^0.8.10"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/sanitize-html": "^2.13.0",
"eslint": "^8",
"eslint-config-next": "15.0.1",
"postcss": "^8",

BIN
public/images/D2J_Icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

BIN
public/images/tags/art.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
public/images/tags/bevy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

BIN
public/images/tags/glsl.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View file

@ -0,0 +1 @@
<svg height="1024" width="1024" xmlns="http://www.w3.org/2000/svg"><path d="m0 0s-.325 1.994-.515 1.976l-36.182-3.491c-2.879-.278-5.115-2.574-5.317-5.459l-.994-14.247L-71-23.218l-1.904 12.912c-.424 2.872-2.932 5.037-5.835 5.037h-38.188c-2.902.0-5.41-2.165-5.834-5.037l-1.905-12.912-27.992 1.997-.994 14.247c-.202 2.886-2.438 5.182-5.317 5.46l-36.2 3.49c-.187.018-.324-1.978-.511-1.978l-.049-7.83 30.658-4.944 1.004-14.374c.203-2.91 2.551-5.263 5.463-5.472l38.551-2.75c.146-.01.29-.016.434-.016 2.897.0 5.401 2.166 5.825 5.038l1.959 13.286h28.005l1.959-13.286c.423-2.871 2.93-5.037 5.831-5.037.142.0.284.005.423.015l38.556 2.75c2.911.209 5.26 2.562 5.463 5.472l1.003 14.374 30.645 4.966z" fill="#fff" transform="matrix(4.162611 0 0 -4.162611 919.24059 673.152141)"/><path d="m0 0v-47.514-6.035-5.492c.108-.001.216-.005.323-.015l36.196-3.49c1.896-.183 3.382-1.709 3.514-3.609l1.116-15.978 31.574-2.253 2.175 14.747c.282 1.912 1.922 3.329 3.856 3.329h38.188c1.933.0 3.573-1.417 3.855-3.329l2.175-14.747 31.575 2.253 1.115 15.978c.133 1.9 1.618 3.425 3.514 3.609l36.182 3.49c.107.01.214.014.322.015v4.711l.015.005v54.325c5.09692 6.4164715 9.92323 13.494208 13.621 19.449-5.651 9.62-12.575 18.217-19.976 26.182-6.864-3.455-13.531-7.369-19.828-11.534-3.151 3.132-6.7 5.694-10.186 8.372-3.425 2.751-7.285 4.768-10.946 7.118 1.09 8.117 1.629 16.108 1.846 24.448-9.446 4.754-19.519 7.906-29.708 10.17-4.068-6.837-7.788-14.241-11.028-21.479-3.842.642-7.702.88-11.567.926v.006c-.027.0-.052-.006-.075-.006-.024.0-.049.006-.073.006v-.006c-3.872-.046-7.729-.284-11.572-.926-3.238 7.238-6.956 14.642-11.03 21.479-10.184-2.264-20.258-5.416-29.703-10.17.216-8.34.755-16.331 1.848-24.448-3.668-2.35-7.523-4.367-10.949-7.118-3.481-2.678-7.036-5.24-10.188-8.372-6.297 4.165-12.962 8.079-19.828 11.534-7.401-7.965-14.321-16.562-19.974-26.182 4.4426579-6.973692 9.2079702-13.9828876 13.621-19.449z" fill="#478cbf" transform="matrix(4.162611 0 0 -4.162611 104.69892 427.387251)"/><path d="m0 0-1.121-16.063c-.135-1.936-1.675-3.477-3.611-3.616l-38.555-2.751c-.094-.007-.188-.01-.281-.01-1.916.0-3.569 1.406-3.852 3.33l-2.211 14.994H-81.09l-2.211-14.994c-.297-2.018-2.101-3.469-4.133-3.32l-38.555 2.751c-1.936.139-3.476 1.68-3.611 3.616L-130.721.0l-32.547 3.138c.015-3.498.06-7.33.06-8.093.0-34.374 43.605-50.896 97.781-51.086h.066.067c54.176.19 97.766 16.712 97.766 51.086.0.777.047 4.593.063 8.093z" fill="#478cbf" transform="matrix(4.162611 0 0 -4.162611 784.07144 718.723121)"/><path d="m0 0c0-12.052-9.765-21.815-21.813-21.815-12.042.0-21.81 9.763-21.81 21.815.0 12.044 9.768 21.802 21.81 21.802C-9.765 21.802.0 12.044.0.0" fill="#fff" transform="matrix(4.162611 0 0 -4.162611 389.21484 527.151321)"/><path d="m0 0c0-7.994-6.479-14.473-14.479-14.473-7.996.0-14.479 6.479-14.479 14.473s6.483 14.479 14.479 14.479C-6.479 14.479.0 7.994.0.0" fill="#414042" transform="matrix(4.162611 0 0 -4.162611 367.36686 532.537071)"/><path d="m0 0c-3.878.0-7.021 2.858-7.021 6.381v20.081c0 3.52 3.143 6.381 7.021 6.381s7.028-2.861 7.028-6.381V6.381c0-3.523-3.15-6.381-7.028-6.381" fill="#fff" transform="matrix(4.162611 0 0 -4.162611 511.99336 626.219821)"/><path d="m0 0c0-12.052 9.765-21.815 21.815-21.815 12.041.0 21.808 9.763 21.808 21.815.0 12.044-9.767 21.802-21.808 21.802-12.05.0-21.815-9.758-21.815-21.802" fill="#fff" transform="matrix(4.162611 0 0 -4.162611 634.78706 527.151321)"/><path d="m0 0c0-7.994 6.477-14.473 14.471-14.473C22.473-14.473 28.95-7.994 28.95.0s-6.477 14.479-14.479 14.479C6.477 14.479.0 7.994.0.0" fill="#414042" transform="matrix(4.162611 0 0 -4.162611 656.64056 532.537071)"/></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
public/images/tags/haxe.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
public/images/tags/itch.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

BIN
public/images/tags/love.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
public/images/tags/meme.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

BIN
public/images/tags/rust.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

3
src/app/about/page.tsx Normal file
View file

@ -0,0 +1,3 @@
export default function AboutPage() {
return <p>About page coming soon</p>;
}

View file

@ -0,0 +1,516 @@
"use client";
import Editor from "@/components/editor";
import { getCookie } from "@/helpers/cookie";
import { Button, Form, Input, Spacer } from "@nextui-org/react";
import { LoaderCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import sanitizeHtml from "sanitize-html";
import { Select, SelectItem } from "@nextui-org/react";
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 [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?:\/\/)/;
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);
}
};
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"
? `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 localuser = await response.json();
setUser(localuser);
/*
const tagResponse = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? `https://d2jam.com/api/v1/tags`
: `http://localhost:3005/api/v1/tags`
);
if (tagResponse.ok) {
const newoptions: {
value: string;
label: ReactNode;
id: number;
isFixed: boolean;
}[] = [];
for (const tag of await tagResponse.json()) {
if (tag.modOnly && localuser && !localuser.mod) {
continue;
}
newoptions.push({
value: tag.name,
id: tag.id,
label: (
<div className="flex gap-2 items-center">
{tag.icon && (
<Avatar
className="w-6 h-6 min-w-6 min-h-6"
size="sm"
src={tag.icon}
classNames={{ base: "bg-transparent" }}
/>
)}
<p>
{tag.name}
{tag.modOnly ? " (Mod Only)" : ""}
</p>
</div>
),
isFixed: tag.alwaysAdded,
});
}
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]);
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();
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;
}
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);
}}
/>
{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>
<label className="text-sm font-medium">Game Description</label>
<Editor key={editorKey} content={content} setContent={setContent} gameEditor />
<Spacer />
<div className="flex flex-col gap-4">
<Input
label="Thumbnail URL"
labelPlacement="outside"
placeholder="https://example.com/thumbnail.png"
value={thumbnailUrl}
onValueChange={setThumbnailUrl}
/>
<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;
}
}
}
}}
/>
<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>
{!isMobile && (
<div className="flex flex-col gap-4 px-8 items-end">
<Timers />
<Streams />
</div>
)}
</div>
);
}

View file

@ -1,25 +1,190 @@
"use client";
import Editor from "@/components/editor";
import { getCookie, hasCookie } from "@/helpers/cookie";
import { Button, Form, Input, Textarea } from "@nextui-org/react";
import {
Avatar,
Button,
Checkbox,
Form,
Input,
Spacer,
} from "@nextui-org/react";
import { LoaderCircle } from "lucide-react";
import { redirect } from "next/navigation";
import { useState } from "react";
import { ReactNode, useEffect, useState } from "react";
import { toast } from "react-toastify";
import sanitizeHtml from "sanitize-html";
import Select, { MultiValue, StylesConfig } from "react-select";
import { useTheme } from "next-themes";
import Timers from "@/components/timers";
import Streams from "@/components/streams";
import { UserType } from "@/types/UserType";
export default function CreatePostPage() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [errors, setErrors] = useState({});
const [waitingPost, setWaitingPost] = 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<
{
value: string;
label: ReactNode;
id: number;
isFixed: boolean;
}[]
>();
const { theme } = useTheme();
const [user, setUser] = useState<UserType>();
const [sticky, setSticky] = useState(false);
const [isMobile, setIsMobile] = useState<boolean>(false);
useEffect(() => {
setMounted(true);
const load = async () => {
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 localuser = await response.json();
setUser(localuser);
const tagResponse = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? `https://d2jam.com/api/v1/tags`
: `http://localhost:3005/api/v1/tags`
);
if (tagResponse.ok) {
const newoptions: {
value: string;
label: ReactNode;
id: number;
isFixed: boolean;
}[] = [];
for (const tag of await tagResponse.json()) {
if (tag.modOnly && !localuser.mod) {
continue;
}
newoptions.push({
value: tag.name,
id: tag.id,
label: (
<div className="flex gap-2 items-center">
{tag.icon && (
<Avatar
className="w-6 h-6 min-w-6 min-h-6"
size="sm"
src={tag.icon}
classNames={{ base: "bg-transparent" }}
/>
)}
<p>
{tag.name}
{tag.modOnly ? " (Mod Only)" : ""}
</p>
</div>
),
isFixed: tag.alwaysAdded,
});
}
setOptions(newoptions);
}
};
load();
}, []);
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth <= 768);
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const styles: StylesConfig<
{
value: string;
label: ReactNode;
isFixed: boolean;
},
true
> = {
multiValue: (base, state) => {
return {
...base,
backgroundColor: state.data.isFixed
? theme == "dark"
? "#222"
: "#ddd"
: theme == "dark"
? "#444"
: "#eee",
};
},
multiValueLabel: (base, state) => {
return {
...base,
color: state.data.isFixed
? theme == "dark"
? "#ddd"
: "#222"
: theme == "dark"
? "#fff"
: "#444",
fontWeight: state.data.isFixed ? "normal" : "bold",
paddingRight: state.data.isFixed ? "8px" : "2px",
};
},
multiValueRemove: (base, state) => {
return {
...base,
display: state.data.isFixed ? "none" : "flex",
color: theme == "dark" ? "#ddd" : "#222",
};
},
control: (styles) => ({
...styles,
backgroundColor: theme == "dark" ? "#181818" : "#fff",
minWidth: "300px",
}),
menu: (styles) => ({
...styles,
backgroundColor: theme == "dark" ? "#181818" : "#fff",
color: theme == "dark" ? "#fff" : "#444",
}),
option: (styles, { isFocused }) => ({
...styles,
backgroundColor: isFocused
? theme == "dark"
? "#333"
: "#ddd"
: undefined,
}),
};
return (
<div className="absolute flex items-center justify-center top-0 left-0 w-screen h-screen">
<div className="absolute flex items-top mt-40 justify-center top-0 left-0 w-screen h-screen">
<Form
className="w-full max-w-xs flex flex-col gap-4"
className="w-full max-w-2xl flex flex-col gap-4"
validationErrors={errors}
onReset={() => {
setTitle("");
setContent("");
}}
onSubmit={async (e) => {
e.preventDefault();
@ -28,6 +193,7 @@ export default function CreatePostPage() {
title: "Please enter a valid title",
content: "Please enter valid content",
});
toast.error("Please enter valid content");
return;
}
@ -38,6 +204,7 @@ export default function CreatePostPage() {
if (!content) {
setErrors({ content: "Please enter valid content" });
toast.error("Please enter valid content");
return;
}
@ -46,6 +213,19 @@ export default function CreatePostPage() {
return;
}
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
);
}
}
const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? "https://d2jam.com/api/v1/post"
@ -53,25 +233,39 @@ export default function CreatePostPage() {
{
body: JSON.stringify({
title: title,
content: content,
content: sanitizedHtml,
sticky,
username: getCookie("user"),
tags: [
...tags,
...(options
? options.filter((tag) => tag.isFixed).map((tag) => tag.id)
: []),
],
}),
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${getCookie("token")}`,
},
credentials: "include",
}
);
if (response.status == 401) {
setErrors({ content: "Invalid user" });
setWaitingPost(false);
return;
}
toast.success("Successfully created post");
redirect("/");
if (response.ok) {
toast.success("Successfully created post");
setWaitingPost(false);
redirect("/");
} else {
toast.error("An error occured");
setWaitingPost(false);
}
}}
>
<Input
@ -85,24 +279,51 @@ export default function CreatePostPage() {
onValueChange={setTitle}
/>
<Textarea
isRequired
label="Content"
labelPlacement="outside"
name="content"
placeholder="Enter the post body"
value={content}
onValueChange={setContent}
/>
<Editor content={content} setContent={setContent} />
<Spacer />
{mounted && (
<Select
styles={styles}
isMulti
value={selectedTags}
onChange={(value) => setSelectedTags(value)}
options={options}
isClearable={false}
isOptionDisabled={() =>
selectedTags != null && selectedTags.length >= 5
}
/>
)}
{user && user.mod && (
<div>
<Spacer />
<Checkbox isSelected={sticky} onValueChange={setSticky}>
Sticky
</Checkbox>
</div>
)}
<Spacer />
<div className="flex gap-2">
<Button color="primary" type="submit">
Create
</Button>
<Button type="reset" variant="flat">
Reset
{waitingPost ? (
<LoaderCircle className="animate-spin" size={16} />
) : (
<p>Create</p>
)}
</Button>
</div>
</Form>
</div>
{!isMobile && (
<div className="flex flex-col gap-4 px-8 items-end">
<Timers />
<Streams />
</div>
)}
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

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

3
src/app/games/page.tsx Normal file
View file

@ -0,0 +1,3 @@
export default function GamesPage() {
return <p>Games page coming soon</p>;
}

3
src/app/inbox/page.tsx Normal file
View file

@ -0,0 +1,3 @@
export default function InboxPage() {
return <p>Inbox page coming soon</p>;
}

View file

@ -2,13 +2,15 @@ import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Navbar from "../components/navbar";
import Providers from "./providers";
import { ToastContainer } from "react-toastify";
import { NextUIProvider, Spacer } from "@nextui-org/react";
import Footer from "@/components/footer";
import { ThemeProvider } from "next-themes";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Dare2Jam",
title: "Down2Jam",
description: "A community built game jam!",
};
@ -18,17 +20,23 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<Providers>
<div className="dark">
<div className="bg-zinc-100 dark:bg-zinc-950 min-h-screen">
<Navbar />
<div className="max-w-8xl mx-auto">{children}</div>
<ToastContainer />
<NextUIProvider>
<ThemeProvider attribute="class">
<div className="">
<div className="bg-[#fff] dark:bg-[#181818] min-h-screen flex flex-col ease-in-out transition-color duration-500">
<Navbar />
<Spacer y={5} />
<div className="max-w-6xl xl:max-w-7xl 2xl:max-w-8xl mx-auto flex-grow w-full">
{children}
</div>
<Footer />
<ToastContainer />
</div>
</div>
</div>
</Providers>
</ThemeProvider>
</NextUIProvider>
</body>
</html>
);

View file

@ -1,6 +1,6 @@
"use client";
import { Button, Form, Input } from "@nextui-org/react";
import { Button, Form, Input, Link } from "@nextui-org/react";
import { redirect } from "next/navigation";
import { useState } from "react";
import { toast } from "react-toastify";
@ -48,6 +48,7 @@ export default function UserPage() {
body: JSON.stringify({ username: username, password: password }),
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
}
);
@ -57,7 +58,14 @@ export default function UserPage() {
return;
}
const { token, user } = await response.json();
const { user } = await response.json();
const token = response.headers.get("Authorization");
if (!token) {
toast.error("Failed to retreive access token");
setPassword("");
return;
}
document.cookie = `token=${token}`;
document.cookie = `user=${user.slug}`;
@ -96,6 +104,9 @@ export default function UserPage() {
Reset
</Button>
</div>
<p className="text-[#333] dark:text-white transition-color duration-250">
Don&apos;t have an account? <Link href="/signup">Sign up</Link>
</p>
</Form>
</div>
);

View file

@ -6,11 +6,28 @@ import { toast } from "react-toastify";
export default function UserPage() {
useEffect(() => {
document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
async function logout() {
const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? "https://d2jam.com/api/v1/logout"
: "http://localhost:3005/api/v1/logout",
{ method: "POST", credentials: "include" }
);
toast.success("Successfully logged out");
if (response.ok) {
document.cookie =
"token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
document.cookie =
"user=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
redirect("/");
toast.success("Successfully logged out");
redirect("/");
} else {
toast.error("Error while trying to log out");
}
}
logout();
});
return (

639
src/app/p/[slug]/page.tsx Normal file
View file

@ -0,0 +1,639 @@
"use client";
import LikeButton from "@/components/posts/LikeButton";
import { getCookie, hasCookie } from "@/helpers/cookie";
import { PostType } from "@/types/PostType";
import { TagType } from "@/types/TagType";
import { UserType } from "@/types/UserType";
import Link from "next/link";
import {
Avatar,
Button,
Card,
CardBody,
Chip,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownSection,
DropdownTrigger,
Spacer,
} from "@nextui-org/react";
import { formatDistance } from "date-fns";
import {
Flag,
LoaderCircle,
MessageCircle,
MoreVertical,
Shield,
ShieldAlert,
ShieldX,
Star,
StarOff,
Trash,
X,
} from "lucide-react";
import { redirect, useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import Editor from "@/components/editor";
import sanitizeHtml from "sanitize-html";
import CommentCard from "@/components/posts/CommentCard";
export default function PostPage() {
const [post, setPost] = useState<PostType>();
const { slug } = useParams();
const [reduceMotion, setReduceMotion] = useState<boolean>(false);
const [user, setUser] = useState<UserType>();
const [loading, setLoading] = useState<boolean>(true);
const [content, setContent] = useState("");
const [waitingPost, setWaitingPost] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduceMotion(mediaQuery.matches);
const handleChange = (event: MediaQueryListEvent) => {
setReduceMotion(event.matches);
};
mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, []);
useEffect(() => {
const loadUserAndPosts = async () => {
setLoading(true);
// Fetch the user
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);
const postResponse = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? `https://d2jam.com/api/v1/post?slug=${slug}&user=${userData.slug}`
: `http://localhost:3005/api/v1/post?slug=${slug}&user=${userData.slug}`
);
setPost(await postResponse.json());
setLoading(false);
} else {
setUser(undefined);
const postResponse = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? `https://d2jam.com/api/v1/post?slug=${slug}`
: `http://localhost:3005/api/v1/post?slug=${slug}`
);
setPost(await postResponse.json());
setLoading(false);
}
};
loadUserAndPosts();
}, [slug]);
return (
<>
{loading ? (
<div className="flex justify-center p-6">
<LoaderCircle
className="animate-spin text-[#333] dark:text-[#999]"
size={24}
/>
</div>
) : (
<>
<Card className="bg-opacity-60 !duration-250 !ease-linear !transition-all">
<CardBody className="p-5">
<div>
{post && (
<div>
<Link href={`/p/${post.slug}`}>
<p className="text-2xl">{post.title}</p>
</Link>
<div className="flex items-center gap-3 text-xs text-default-500 pt-1">
<p>By</p>
<Link
href={`/u/${post.author.slug}`}
className="flex items-center gap-2"
>
<Avatar
size="sm"
className="w-6 h-6"
src={post.author.profilePicture}
classNames={{
base: "bg-transparent",
}}
/>
<p>{post.author.name}</p>
</Link>
<p>
{formatDistance(new Date(post.createdAt), new Date(), {
addSuffix: true,
})}
</p>
</div>
<Spacer y={4} />
<div
className="prose dark:prose-invert !duration-250 !ease-linear !transition-all"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
<Spacer y={4} />
{post.tags.filter((tag) => tag.name != "D2Jam").length >
0 ? (
<div className="flex gap-1">
{post.tags
.filter((tag) => tag.name != "D2Jam")
.map((tag: TagType) => (
<Link
href="/"
key={tag.id}
className={`transition-all transform duration-500 ease-in-out ${
!reduceMotion ? "hover:scale-110" : ""
}`}
>
<Chip
radius="sm"
size="sm"
className="!duration-250 !ease-linear !transition-all"
variant="faded"
avatar={
tag.icon && (
<Avatar
src={tag.icon}
classNames={{ base: "bg-transparent" }}
/>
)
}
>
{tag.name}
</Chip>
</Link>
))}
</div>
) : (
<></>
)}
{post.tags.length > 0 && <Spacer y={4} />}
<div className="flex gap-3">
<LikeButton
likes={post.likes.length}
liked={post.hasLiked}
parentId={post.id}
/>
<Link href="#create-comment">
<Button size="sm" variant="bordered">
<MessageCircle size={16} /> {post.comments.length}
</Button>
</Link>
<Dropdown backdrop="opaque">
<DropdownTrigger>
<Button size="sm" variant="bordered" isIconOnly>
<MoreVertical size={16} />
</Button>
</DropdownTrigger>
<DropdownMenu className="text-[#333] dark:text-white">
<DropdownSection
showDivider={user?.mod}
title="Actions"
>
<DropdownItem
key="report"
startContent={<Flag />}
description="Report this post to moderators to handle"
onPress={() => {
toast.warning(
"Report functionality coming soon"
);
}}
>
Create Report
</DropdownItem>
{user?.slug == post.author.slug ? (
<DropdownItem
key="delete"
startContent={<Trash />}
description="Delete your post"
onPress={async () => {
const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? "https://d2jam.com/api/v1/post"
: "http://localhost:3005/api/v1/post",
{
body: JSON.stringify({
postId: post.id,
username: getCookie("user"),
}),
method: "DELETE",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${getCookie(
"token"
)}`,
},
credentials: "include",
}
);
if (response.ok) {
toast.success("Deleted post");
redirect("/");
} else {
toast.error("Error while deleting post");
}
}}
>
Delete
</DropdownItem>
) : (
<></>
)}
</DropdownSection>
{user?.mod ? (
<DropdownSection title="Mod Zone">
<DropdownItem
key="remove"
startContent={<X />}
description="Remove this post"
onPress={async () => {
const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? "https://d2jam.com/api/v1/post"
: "http://localhost:3005/api/v1/post",
{
body: JSON.stringify({
postId: post.id,
username: getCookie("user"),
}),
method: "DELETE",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${getCookie(
"token"
)}`,
},
credentials: "include",
}
);
if (response.ok) {
toast.success("Removed post");
redirect("/");
} else {
toast.error("Error while removing post");
}
}}
>
Remove
</DropdownItem>
{post.sticky ? (
<DropdownItem
key="unsticky"
startContent={<StarOff />}
description="Unsticky post"
onPress={async () => {
const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? "https://d2jam.com/api/v1/post/sticky"
: "http://localhost:3005/api/v1/post/sticky",
{
body: JSON.stringify({
postId: post.id,
sticky: false,
username: getCookie("user"),
}),
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${getCookie(
"token"
)}`,
},
credentials: "include",
}
);
if (response.ok) {
toast.success("Unsticked post");
redirect("/");
} else {
toast.error("Error while removing post");
}
}}
>
Unsticky
</DropdownItem>
) : (
<DropdownItem
key="sticky"
startContent={<Star />}
description="Sticky post"
onPress={async () => {
const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? "https://d2jam.com/api/v1/post/sticky"
: "http://localhost:3005/api/v1/post/sticky",
{
body: JSON.stringify({
postId: post.id,
sticky: true,
username: getCookie("user"),
}),
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${getCookie(
"token"
)}`,
},
credentials: "include",
}
);
if (response.ok) {
toast.success("Unsticked post");
redirect("/");
} else {
toast.error("Error while removing post");
}
}}
>
Sticky
</DropdownItem>
)}
{user?.admin && !post.author.mod ? (
<DropdownItem
key="promote-mod"
startContent={<Shield />}
description="Promote user to Mod"
onPress={async () => {
const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? "https://d2jam.com/api/v1/mod"
: "http://localhost:3005/api/v1/mod",
{
body: JSON.stringify({
targetname: post.author.slug,
mod: true,
username: getCookie("user"),
}),
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${getCookie(
"token"
)}`,
},
credentials: "include",
}
);
if (response.ok) {
toast.success("Promoted User to Mod");
window.location.reload();
} else {
toast.error(
"Error while promoting user to Mod"
);
}
}}
>
Appoint as mod
</DropdownItem>
) : (
<></>
)}
{user?.admin &&
post.author.mod &&
!post.author.admin ? (
<DropdownItem
key="demote-mod"
startContent={<ShieldX />}
description="Demote user from Mod"
onPress={async () => {
const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? "https://d2jam.com/api/v1/mod"
: "http://localhost:3005/api/v1/mod",
{
body: JSON.stringify({
targetname: post.author.slug,
username: getCookie("user"),
}),
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${getCookie(
"token"
)}`,
},
credentials: "include",
}
);
if (response.ok) {
toast.success("Demoted User");
window.location.reload();
} else {
toast.error("Error while demoting user");
}
}}
>
Remove as mod
</DropdownItem>
) : (
<></>
)}
{user?.admin && !post.author.admin ? (
<DropdownItem
key="promote-admin"
startContent={<ShieldAlert />}
description="Promote user to Admin"
onPress={async () => {
const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? "https://d2jam.com/api/v1/mod"
: "http://localhost:3005/api/v1/mod",
{
body: JSON.stringify({
targetname: post.author.slug,
admin: true,
username: getCookie("user"),
}),
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${getCookie(
"token"
)}`,
},
credentials: "include",
}
);
if (response.ok) {
toast.success("Promoted User to Admin");
window.location.reload();
} else {
toast.error(
"Error while promoting user to Admin"
);
}
}}
>
Appoint as admin
</DropdownItem>
) : (
<></>
)}
{user?.admin &&
post.author.admin &&
post.author.id !== user.id ? (
<DropdownItem
key="demote-admin"
startContent={<ShieldX />}
description="Demote user to mod"
onPress={async () => {
const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? "https://d2jam.com/api/v1/mod"
: "http://localhost:3005/api/v1/mod",
{
body: JSON.stringify({
targetname: post.author.slug,
mod: true,
username: getCookie("user"),
}),
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${getCookie(
"token"
)}`,
},
credentials: "include",
}
);
if (response.ok) {
toast.success("Demoted User to Mod");
window.location.reload();
} else {
toast.error(
"Error while demoting user to mod"
);
}
}}
>
Remove as admin
</DropdownItem>
) : (
<></>
)}
</DropdownSection>
) : (
<></>
)}
</DropdownMenu>
</Dropdown>
</div>
</div>
)}
</div>
</CardBody>
</Card>
<div id="create-comment" />
<Spacer y={10} />
<Editor content={content} setContent={setContent} />
<Spacer />
<Button
color="primary"
onPress={async () => {
if (!content) {
toast.error("Please enter valid content");
return;
}
if (!hasCookie("token")) {
toast.error("You are not logged in");
return;
}
const sanitizedHtml = sanitizeHtml(content);
setWaitingPost(true);
const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? "https://d2jam.com/api/v1/comment"
: "http://localhost:3005/api/v1/comment",
{
body: JSON.stringify({
content: sanitizedHtml,
username: getCookie("user"),
postId: post?.id,
}),
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${getCookie("token")}`,
},
credentials: "include",
}
);
if (response.status == 401) {
toast.error("Invalid User");
setWaitingPost(false);
return;
}
if (response.ok) {
toast.success("Successfully created comment");
setWaitingPost(false);
window.location.reload();
} else {
toast.error("An error occured");
setWaitingPost(false);
}
}}
>
{waitingPost ? (
<LoaderCircle className="animate-spin" size={16} />
) : (
<p>Create Comment</p>
)}
</Button>
<Spacer y={10} />
<div className="flex flex-col gap-3">
{post?.comments.map((comment) => (
<div key={comment.id}>
<CommentCard comment={comment} />
</div>
))}
</div>
</>
)}
</>
);
}

View file

@ -1,46 +1,18 @@
import { Image } from "@nextui-org/image";
import { Button } from "@nextui-org/button";
import { SiDiscord } from "@icons-pack/react-simple-icons";
import { Link } from "@nextui-org/react";
import Posts from "@/components/posts";
import Timers from "@/components/timers";
import Streams from "@/components/streams";
import JamHeader from "@/components/jam-header";
export default async function Home() {
return (
<div className="w-full">
<div className="fixed left-0 top-0 w-full h-full z-0">
<Image
src="/images/bg.jpg"
alt="Home background"
className="object-cover w-full h-full"
radius="none"
loading="eager"
removeWrapper
/>
<div className="absolute left-0 top-0 w-full h-full bg-gradient-to-r from-black/50 to-transparent z-10" />
<div className="flex justify-between flex-wrap">
<div className="md:w-2/3">
<JamHeader />
<Posts />
</div>
<div className="z-10 relative flex w-full flex-wrap">
<div>
<div className="flex flex-col gap-4 py-4 sm:py-8 md:py-12 pl-16">
<h1 className="text-3xl sm:text-4xl md:text-5xl">Dare2Jam</h1>
<p className="text-lg sm:text-xl">April 4th - 7th</p>
<div className="flex gap-2">
<Link href="https://discord.gg/rfmKzM6ASw" target="_blank">
<Button
variant="bordered"
className="border-white/50 text-white"
startContent={<SiDiscord />}
>
Join the Discord
</Button>
</Link>
</div>
</div>
<Posts />
</div>
<div className="w-1/3 flex justify-end py-4 sm:py-8 md:py-12 flex-grow">
<Timers />
</div>
<div>
<Timers />
<Streams />
</div>
</div>
);

View file

@ -1,12 +0,0 @@
"use client";
import {NextUIProvider} from "@nextui-org/react";
import {ReactNode} from "react";
export default function Providers({ children }: { children: ReactNode }) {
return (
<NextUIProvider>
{children}
</NextUIProvider>
);
}

3
src/app/reports/page.tsx Normal file
View file

@ -0,0 +1,3 @@
export default function ReportsPage() {
return <p>Reports page coming soon</p>;
}

179
src/app/settings/page.tsx Normal file
View file

@ -0,0 +1,179 @@
"use client";
import Editor from "@/components/editor";
import sanitizeHtml from "sanitize-html";
import { getCookie, hasCookie } from "@/helpers/cookie";
import { UserType } from "@/types/UserType";
import { Avatar, Button, Form, Input } from "@nextui-org/react";
import { redirect, usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import { LoaderCircle } from "lucide-react";
import Image from "next/image";
export default function UserPage() {
const [user, setUser] = useState<UserType>();
const [profilePicture, setProfilePicture] = useState("");
const [name, setName] = useState("");
const [bannerPicture, setBannerPicture] = useState("");
const [bio, setBio] = useState("");
const [errors] = useState({});
const pathname = usePathname();
const [waitingSave, setWaitingSave] = useState(false);
useEffect(() => {
loadUser();
async function loadUser() {
if (!hasCookie("token")) {
setUser(undefined);
redirect("/");
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",
}
);
if (response.status == 200) {
const data = await response.json();
setUser(data);
setProfilePicture(data.profilePicture ?? "");
setBannerPicture(data.bannerPicture ?? "");
setBio(data.bio ?? "");
setName(data.name ?? "");
} else {
setUser(undefined);
}
}
}, [pathname]);
return (
<div className="flex items-center justify-center">
{!user ? (
"Loading settings..."
) : (
<Form
className="w-full max-w-2xl flex flex-col gap-4"
validationErrors={errors}
onReset={() => {
setProfilePicture(user.profilePicture ?? "");
setBannerPicture(user.bannerPicture ?? "");
setBio(user.bio ?? "");
setName(user.name ?? "");
}}
onSubmit={async (e) => {
e.preventDefault();
const sanitizedBio = sanitizeHtml(bio);
if (!name) {
toast.error("You need to enter a name");
return;
}
setWaitingSave(true);
const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? "https://d2jam.com/api/v1/user"
: "http://localhost:3005/api/v1/user",
{
body: JSON.stringify({
slug: user.slug,
name: name,
bio: sanitizedBio,
profilePicture: profilePicture,
bannerPicture: bannerPicture,
}),
method: "PUT",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${getCookie("token")}`,
},
}
);
if (response.ok) {
toast.success("Changed settings");
setUser(await response.json());
setWaitingSave(false);
} else {
toast.error("Failed to update settings");
setWaitingSave(false);
}
}}
>
<p className="text-3xl">Settings</p>
<Input
label="Name"
labelPlacement="outside"
name="name"
placeholder="Enter a name"
type="text"
value={name}
onValueChange={setName}
/>
<p>Bio</p>
<Editor content={bio} setContent={setBio} />
<Input
label="Profile Picture"
labelPlacement="outside"
name="profilePicture"
placeholder="Enter a url to an image"
type="text"
value={profilePicture}
onValueChange={setProfilePicture}
/>
{profilePicture && <Avatar src={profilePicture} />}
<Input
label="Banner Picture"
labelPlacement="outside"
name="bannerPicture"
placeholder="Enter a url to an image"
type="text"
value={bannerPicture}
onValueChange={setBannerPicture}
/>
{bannerPicture &&
bannerPicture.startsWith("https://") &&
bannerPicture.length > 8 && (
<div className="bg-[#222222] h-28 w-full relative">
<Image
src={bannerPicture}
alt={`${user.name}'s profile banner`}
className="object-cover"
fill
/>
</div>
)}
<div className="flex gap-2">
<Button color="primary" type="submit">
{waitingSave ? (
<LoaderCircle className="animate-spin" size={16} />
) : (
<p>Save</p>
)}
</Button>
<Button type="reset" variant="flat">
Reset
</Button>
</div>
</Form>
)}
</div>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { Button, Form, Input } from "@nextui-org/react";
import { Button, Form, Input, Link } from "@nextui-org/react";
import { redirect } from "next/navigation";
import { useState } from "react";
import { toast } from "react-toastify";
@ -74,6 +74,7 @@ export default function UserPage() {
body: JSON.stringify({ username: username, password: password }),
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
}
);
@ -131,6 +132,9 @@ export default function UserPage() {
Reset
</Button>
</div>
<p className="text-[#333] dark:text-white transition-color duration-250">
Already have an account? <Link href="/login">Log In</Link>
</p>
</Form>
</div>
);

View file

@ -0,0 +1,17 @@
import Timers from "@/components/timers";
import Streams from "@/components/streams";
import ThemeSlaughter from "@/components/themes/theme-slaughter";
export default async function Home() {
return (
<div className="flex justify-between flex-wrap">
<div className="md:w-2/3">
<ThemeSlaughter />
</div>
<div>
<Timers />
<Streams />
</div>
</div>
);
}

View file

@ -0,0 +1,17 @@
import Timers from "@/components/timers";
import Streams from "@/components/streams";
import ThemeSuggestions from "@/components/themes/theme-suggest";
export default async function Home() {
return (
<div className="flex justify-between flex-wrap">
<div className="md:w-2/3">
<ThemeSuggestions />
</div>
<div>
<Timers />
<Streams />
</div>
</div>
);
}

View file

@ -0,0 +1,17 @@
import Timers from "@/components/timers";
import Streams from "@/components/streams";
import ThemeVoting from "@/components/themes/theme-vote";
export default async function Home() {
return (
<div className="flex justify-between flex-wrap">
<div className="md:w-2/3">
<ThemeVoting />
</div>
<div>
<Timers />
<Streams />
</div>
</div>
);
}

View file

@ -1,9 +1,59 @@
import User from "../../../components/user";
"use client";
import { UserType } from "@/types/UserType";
import { Avatar } from "@nextui-org/react";
import Image from "next/image";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
export default function UserPage() {
const [user, setUser] = useState<UserType>();
const { slug } = useParams();
useEffect(() => {
const fetchUser = async () => {
const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? `https://d2jam.com/api/v1/user?slug=${slug}`
: `http://localhost:3005/api/v1/user?slug=${slug}`
);
setUser(await response.json());
};
fetchUser();
}, [slug]);
return (
<div>
<User />
{user && (
<div className="border-2 border-[#222224] relative rounded-xl overflow-hidden bg-[#18181a]">
<div className="bg-[#222222] h-28 relative">
{user.bannerPicture && (
<Image
src={user.bannerPicture}
alt={`${user.name}'s profile banner`}
className="object-cover"
fill
/>
)}
</div>
<Avatar
className="absolute rounded-full left-16 top-16 h-24 w-24 bg-transparent"
src={user.profilePicture}
/>
<div className="p-8 mt-8">
<p className="text-3xl">{user.name}</p>
<div
className="prose dark:prose-invert !duration-250 !ease-linear !transition-all"
dangerouslySetInnerHTML={{
__html:
user.bio && user.bio != "<p></p>" ? user.bio : "No user bio",
}}
/>
</div>
</div>
)}
</div>
);
return <div></div>;
}

View file

@ -1,8 +0,0 @@
"use client";
export default function Announcements() {
return (
<div></div>
);
}

View file

@ -0,0 +1,144 @@
"use client";
import { Editor } from "@tiptap/react";
import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
Bold,
Code,
Highlighter,
Italic,
Minus,
Quote,
Redo,
Strikethrough,
Subscript,
Superscript,
Underline,
Undo,
} from "lucide-react";
import EditorMenuButton from "./EditorMenuButton";
type EditorMenuProps = {
editor: Editor | null;
};
export default function EditorMenuBar({ editor }: EditorMenuProps) {
if (!editor) return null;
const buttons = [
{
icon: <Bold size={20} />,
onClick: () => editor.chain().focus().toggleBold().run(),
disabled: false,
isActive: editor.isActive("bold"),
},
{
icon: <Italic size={20} />,
onClick: () => editor.chain().focus().toggleItalic().run(),
disabled: false,
isActive: editor.isActive("italic"),
},
{
icon: <Underline size={20} />,
onClick: () => editor.chain().focus().toggleUnderline().run(),
disabled: false,
isActive: editor.isActive("underline"),
},
{
icon: <Highlighter size={20} />,
onClick: () => editor.chain().focus().toggleHighlight().run(),
disabled: false,
isActive: editor.isActive("highlight"),
},
{
icon: <Strikethrough size={20} />,
onClick: () => editor.chain().focus().toggleStrike().run(),
disabled: false,
isActive: editor.isActive("strike"),
},
{
icon: <Subscript size={20} />,
onClick: () => editor.chain().focus().toggleSubscript().run(),
disabled: false,
isActive: editor.isActive("subscript"),
},
{
icon: <Superscript size={20} />,
onClick: () => editor.chain().focus().toggleSuperscript().run(),
disabled: false,
isActive: editor.isActive("superscript"),
},
{
icon: <Minus size={20} />,
onClick: () => editor.chain().focus().setHorizontalRule().run(),
disabled: !editor.can().setHorizontalRule(),
isActive: false,
},
{
icon: <Quote size={20} />,
onClick: () => editor.chain().focus().toggleBlockquote().run(),
disabled: !editor.can().toggleBlockquote(),
isActive: editor.isActive("blockquote"),
},
{
icon: <Code size={20} />,
onClick: () => editor.chain().focus().toggleCodeBlock().run(),
disabled: !editor.can().toggleCodeBlock(),
isActive: editor.isActive("codeblock"),
},
{
icon: <AlignLeft size={20} />,
onClick: () => editor.chain().focus().setTextAlign("left").run(),
disabled: !editor.can().setTextAlign("left"),
isActive: editor.isActive("textalign"),
},
{
icon: <AlignRight size={20} />,
onClick: () => editor.chain().focus().setTextAlign("right").run(),
disabled: !editor.can().setTextAlign("right"),
isActive: editor.isActive("textalign"),
},
{
icon: <AlignCenter size={20} />,
onClick: () => editor.chain().focus().setTextAlign("center").run(),
disabled: !editor.can().setTextAlign("center"),
isActive: editor.isActive("textalign"),
},
{
icon: <AlignJustify size={20} />,
onClick: () => editor.chain().focus().setTextAlign("justify").run(),
disabled: !editor.can().setTextAlign("justify"),
isActive: editor.isActive("textalign"),
},
{
icon: <Undo size={20} />,
onClick: () => editor.chain().focus().undo().run(),
disabled: !editor.can().undo(),
isActive: editor.isActive("undo"),
},
{
icon: <Redo size={20} />,
onClick: () => editor.chain().focus().redo().run(),
disabled: !editor.can().redo(),
isActive: editor.isActive("redo"),
},
];
return (
<div className="mb-2 flex space-x-2">
{buttons.map(({ icon, onClick, disabled, isActive }, index) => (
<EditorMenuButton
key={index}
onClick={onClick}
isActive={isActive}
disabled={disabled}
>
{icon}
</EditorMenuButton>
))}
</div>
);
}

View file

@ -0,0 +1,32 @@
"use client";
import { Button } from "@nextui-org/react";
type EditorMenuButtonProps = {
onClick: () => void;
isActive: boolean;
disabled?: boolean;
children: React.ReactNode;
};
export default function EditorMenuButton({
onClick,
isActive,
disabled,
children,
}: EditorMenuButtonProps) {
return (
<Button
variant="light"
onPress={onClick}
isDisabled={disabled}
size="sm"
isIconOnly
className={`${
isActive ? "bg-blue-500 data-[hover=true]:bg-blue-400" : ""
}`}
>
{children}
</Button>
);
}

View file

@ -0,0 +1,164 @@
"use client";
import CharacterCount from "@tiptap/extension-character-count";
import Document from "@tiptap/extension-document";
import Paragraph from "@tiptap/extension-paragraph";
import Text from "@tiptap/extension-text";
import { useEditor, EditorContent } from "@tiptap/react";
import EditorMenuBar from "./EditorMenuBar";
import Bold from "@tiptap/extension-bold";
import Italic from "@tiptap/extension-italic";
import Underline from "@tiptap/extension-underline";
import Highlight from "@tiptap/extension-highlight";
import Strike from "@tiptap/extension-strike";
import Subscript from "@tiptap/extension-subscript";
import Superscript from "@tiptap/extension-superscript";
import History from "@tiptap/extension-history";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import Blockquote from "@tiptap/extension-blockquote";
import Heading from "@tiptap/extension-heading";
import ListItem from "@tiptap/extension-list-item";
import OrderedList from "@tiptap/extension-ordered-list";
import BulletList from "@tiptap/extension-bullet-list";
import HardBreak from "@tiptap/extension-hard-break";
import { Markdown } from "tiptap-markdown";
import TextAlign from "@tiptap/extension-text-align";
import Typography from "@tiptap/extension-typography";
import Dropcursor from "@tiptap/extension-dropcursor";
import Image from "@tiptap/extension-image";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import Table from "@tiptap/extension-table";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import TableRow from "@tiptap/extension-table-row";
import Youtube from "@tiptap/extension-youtube";
import CodeBlock from "@tiptap/extension-code-block";
import { Spacer } from "@nextui-org/react";
import { useTheme } from "next-themes";
type EditorProps = {
content: string;
setContent: (content: string) => void;
gameEditor?: boolean;
};
const limit = 32767;
export default function Editor({ content, setContent,gameEditor }: EditorProps) {
const { theme } = useTheme();
const editor = useEditor({
extensions: [
Document,
Paragraph,
Text,
CharacterCount.configure({
limit,
}),
Bold,
Italic,
Underline,
Highlight,
Strike,
Subscript,
Superscript,
History,
HorizontalRule,
Blockquote,
Heading,
ListItem,
OrderedList,
BulletList,
HardBreak,
Markdown.configure({
transformCopiedText: true,
transformPastedText: true,
}),
Typography,
TextAlign.configure({
types: ["heading", "paragraph"],
}),
Dropcursor,
Image,
TaskItem,
TaskList,
Table,
TableHeader,
TableRow,
TableCell,
Youtube,
CodeBlock,
],
content: content,
immediatelyRender: false,
onUpdate: ({ editor }) => {
setContent(editor.getHTML());
},
editorProps: {
attributes: {
class:
"prose dark:prose-invert " +
(gameEditor
? "min-h-[600px] max-h-[600px]"
: "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",
},
},
});
return (
<div className="w-full">
<EditorMenuBar editor={editor} />
<EditorContent editor={editor} />
<Spacer y={3} />
{editor && (
<div
className={`${
editor.storage.characterCount.characters() === limit
? "text-red-500"
: editor.storage.characterCount.characters() > limit / 2
? "text-yellow-500"
: "text-[#888] dark:text-[#555]"
} transform-color duration-250 ease-linear flex items-center gap-3`}
>
<svg width="30" height="30" viewBox="0 0 36 36">
<circle
cx="18"
cy="18"
r="15.915"
fill="none"
stroke={theme === "dark" ? "#333" : "#eee"}
strokeWidth="3"
className="!duration-250 !ease-linear !transition-all"
/>
<circle
id="progress-circle"
cx="18"
cy="18"
r="15.915"
fill="none"
stroke={
editor.storage.characterCount.characters() === limit
? "#de362a"
: editor.storage.characterCount.characters() > limit / 2
? "#eab308"
: "#26d1ff"
}
strokeWidth="3"
strokeDasharray="100, 100"
strokeDashoffset={
(1 - editor.storage.characterCount.characters() / limit) * 100
}
transform="rotate(-90 18 18)"
className="!duration-250 !ease-linear !transition-all"
/>
</svg>
{editor.storage.characterCount.characters()} / {limit} characters
<br />
{editor.storage.characterCount.words()} words
</div>
)}
</div>
);
}

View file

@ -0,0 +1,20 @@
import IconLink from "../link-components/IconLink";
import { SiDiscord, SiForgejo, SiGithub } from "@icons-pack/react-simple-icons";
export default function Footer() {
return (
<div className="p-8 bg-[#fff] dark:bg-[#222] mt-8 border-t-2 dark:border-white/15 border-black/15 transition-color duration-500 ease-in-out">
<div className="flex justify-between">
<div></div>
<div className="flex gap-3">
<IconLink icon={<SiGithub />} href="https://github.com/Dare2Jam" />
<IconLink
icon={<SiForgejo />}
href="https://git.edikoyo.com/Ategon/Jamjar"
/>
<IconLink icon={<SiDiscord />} href="https://discord.d2jam.com" />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,170 @@
"use client";
import { Calendar } from "lucide-react";
import { useEffect, useState } from "react";
import { getCurrentJam, ActiveJamResponse } from "../../helpers/jam";
export default function JamHeader() {
const [activeJamResponse, setActiveJamResponse] =
useState<ActiveJamResponse | null>(null);
const [topTheme, setTopTheme] = useState<string | null>(null);
// Fetch active jam details
useEffect(() => {
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?.phase === "Rating") && jamData.jam) {
try {
const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? "https://d2jam.com/api/v1/themes/top-themes"
: "http://localhost:3005/api/v1/themes/top-themes"
);
if (response.ok) {
const themes = await response.json();
if (themes.length > 0) {
setTopTheme(themes[0].suggestion);
}
} else {
console.error("Failed to fetch top themes.", response.status);
}
} catch (error) {
console.error("Error fetching top themes:", error);
}
}
};
fetchData();
}, []);
// Helper function to get ordinal suffix
const getOrdinalSuffix = (day: number): string => {
if (day > 3 && day < 21) return "th";
switch (day % 10) {
case 1:
return "st";
case 2:
return "nd";
case 3:
return "rd";
default:
return "th";
}
};
return (
<div className="bg-[#7090b9] dark:bg-[#124a88] flex flex-col rounded-2xl overflow-hidden text-white transition-color duration-250">
{/* Jam Header */}
<div className="flex">
<div className="bg-[#85bdd2] dark:bg-[#1892b3] p-4 px-6 flex items-center gap-2 font-bold transition-color duration-250">
<Calendar />
<p>
{activeJamResponse?.jam && activeJamResponse.phase ? (
<span className="text-sm font-normal">
{activeJamResponse.jam.name} - {activeJamResponse.phase} Phase
</span>
) : (
<span className="text-sm font-normal">(No Active Jams)</span>
)}
</p>
</div>
<div className="p-4 px-6 font-bold">
<p>
{activeJamResponse?.jam ? (
<>
{new Date(activeJamResponse.jam.startTime).toLocaleDateString(
"en-US",
{
month: "long",
}
)}{" "}
{new Date(activeJamResponse.jam.startTime).getDate()}
{getOrdinalSuffix(
new Date(activeJamResponse.jam.startTime).getDate()
)}
{" - "}
{new Date(
new Date(activeJamResponse.jam.startTime).getTime() +
activeJamResponse.jam.jammingHours * 60 * 60 * 1000
).toLocaleDateString("en-US", {
month: "long",
})}{" "}
{new Date(
new Date(activeJamResponse.jam.startTime).getTime() +
activeJamResponse.jam.jammingHours * 60 * 60 * 1000
).getDate()}
{getOrdinalSuffix(
new Date(
new Date(activeJamResponse.jam.startTime).getTime() +
activeJamResponse.jam.jammingHours * 60 * 60 * 1000
).getDate()
)}
</>
) : (
"Dates TBA"
)}
</p>
</div>
</div>
{activeJamResponse?.phase === "Suggestion" && (
<div className="bg-gray-100 dark:bg-gray-800 p-4 text-center rounded-b-2x">
<a
href="/theme-suggestions"
className="text-blue-300 dark:text-blue-500 hover:underline font-semibold"
>
Go to Theme Suggestion
</a>
</div>
)}
{activeJamResponse?.phase === "Survival" && (
<div className="bg-gray-100 dark:bg-gray-800 p-4 text-center rounded-b-2x">
<a
href="/theme-slaughter"
className="text-blue-300 dark:text-blue-500 hover:underline font-semibold"
>
Go to Theme Survival
</a>
</div>
)}
{activeJamResponse?.phase === "Voting" && (
<div className="bg-gray-100 dark:bg-gray-800 p-4 text-center rounded-b-2x">
<a
href="/theme-voting"
className="text-blue-300 dark:text-blue-500 hover:underline font-semibold"
>
Go to Theme Voting
</a>
</div>
)}
{activeJamResponse?.phase === "Jamming" && (
<div className="bg-gray-100 dark:bg-gray-800 p-4 text-center rounded-b-2x">
{topTheme ? (
<p className="text-xl font-bold text-blue-500">THEME: {topTheme}</p>
) : (
<p>No top-scoring theme available.</p>
)}
</div>
)}
{activeJamResponse?.phase === "Rating" && (
<div className="bg-gray-100 dark:bg-gray-800 p-4 text-center rounded-b-2x">
{topTheme ? (
<p className="text-xl font-bold text-blue-500">
THEME: {topTheme} RESULTS
</p>
) : (
<p>No top-scoring theme available.</p>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,45 @@
"use client";
import { Button } from "@nextui-org/react";
import { ReactNode, useEffect, useState } from "react";
interface ButtonActionProps {
icon?: ReactNode;
onPress: () => void;
name: string;
}
export default function ButtonAction({
icon,
onPress,
name,
}: ButtonActionProps) {
const [reduceMotion, setReduceMotion] = useState<boolean>(false);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduceMotion(mediaQuery.matches);
const handleChange = (event: MediaQueryListEvent) => {
setReduceMotion(event.matches);
};
mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, []);
return (
<Button
endContent={icon}
className={`text-[#333] dark:text-white border-[#333]/50 dark:border-white/50 transition-all transform duration-500 ease-in-out ${
!reduceMotion ? "hover:scale-110" : ""
}`}
variant="bordered"
onPress={onPress}
>
{name}
</Button>
);
}

View file

@ -0,0 +1,47 @@
"use client";
import { Button, Link } from "@nextui-org/react";
import { ReactNode, useEffect, useState } from "react";
interface ButtonLinkProps {
icon?: ReactNode;
href: string;
name: string;
}
export default function ButtonLink({ icon, href, name }: ButtonLinkProps) {
const [reduceMotion, setReduceMotion] = useState<boolean>(false);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduceMotion(mediaQuery.matches);
const handleChange = (event: MediaQueryListEvent) => {
setReduceMotion(event.matches);
};
mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, []);
return (
<Link
href={href}
className={`flex justify-center duration-500 ease-in-out transition-all transform ${
!reduceMotion ? "hover:scale-110" : ""
}`}
>
<Button
endContent={icon}
className={`text-[#333] dark:text-white border-[#333]/50 dark:border-white/50 transition-all transform !duration-500 ease-in-out ${
!reduceMotion ? "hover:scale-110" : ""
}`}
variant="bordered"
>
{name}
</Button>
</Link>
);
}

View file

@ -0,0 +1,38 @@
"use client";
import { Link } from "@nextui-org/react";
import { ReactNode, useEffect, useState } from "react";
interface IconLinkProps {
icon: ReactNode;
href: string;
}
export default function IconLink({ icon, href }: IconLinkProps) {
const [reduceMotion, setReduceMotion] = useState<boolean>(false);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduceMotion(mediaQuery.matches);
const handleChange = (event: MediaQueryListEvent) => {
setReduceMotion(event.matches);
};
mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, []);
return (
<Link
href={href}
className={`text-[#333] dark:text-white flex justify-center duration-500 ease-in-out transition-all transform ${
!reduceMotion ? "hover:scale-125" : ""
} transition-color`}
>
{icon}
</Link>
);
}

View file

@ -0,0 +1,38 @@
"use client";
import { Link as BaseLink } from "@nextui-org/react";
import { useEffect, useState } from "react";
interface LinkProps {
name: string;
href: string;
}
export default function Link({ name, href }: LinkProps) {
const [reduceMotion, setReduceMotion] = useState<boolean>(false);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduceMotion(mediaQuery.matches);
const handleChange = (event: MediaQueryListEvent) => {
setReduceMotion(event.matches);
};
mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, []);
return (
<BaseLink
href={href}
className={`text-[#333] dark:text-white flex justify-center duration-500 ease-in-out transition-all transform ${
!reduceMotion ? "hover:scale-110" : ""
} transition-color`}
>
{name}
</BaseLink>
);
}

View file

@ -0,0 +1,115 @@
"use client";
import {
Image,
Link,
NavbarBrand,
Navbar as NavbarBase,
NavbarContent,
} from "@nextui-org/react";
import NavbarButtonLink from "./NavbarButtonLink";
import { LogInIcon, NotebookPen } from "lucide-react";
import NextImage from "next/image";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { getCookie, hasCookie } from "@/helpers/cookie";
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 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);
}
}
}, [pathname]);
return (
<NavbarBase maxWidth="2xl" className="bg-[#222] p-1" isBordered height={80}>
<NavbarContent justify="start" className="gap-10">
<NavbarBrand className="flex-grow-0">
<Link
href="/"
className="duration-500 ease-in-out transition-all transform hover:scale-110"
>
<Image
as={NextImage}
src="/images/D2J_Icon.png"
className="min-w-[70px]"
alt="Down2Jam logo"
width={70}
height={59.7}
/>
</Link>
</NavbarBrand>
</NavbarContent>
<NavbarContent justify="end" className="gap-4">
<ThemeToggle />
{!user && (
<NavbarButtonLink icon={<LogInIcon />} name="Log In" href="/login" />
)}
{!user && (
<NavbarButtonLink
icon={<NotebookPen />}
name="Sign Up"
href="/signup"
/>
)}
{user && (
<MobileNavbarUser
user={user}
isInJam={isInJam}
setIsInJam={setIsInJam}
jam={jam}
/>
)}
</NavbarContent>
</NavbarBase>
);
}

View file

@ -0,0 +1,123 @@
"use client";
import {
Avatar,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger,
NavbarItem,
} from "@nextui-org/react";
import { UserType } from "@/types/UserType";
import { JamType } from "@/types/JamType";
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { getCurrentJam, joinJam } from "@/helpers/jam";
import { toast } from "react-toastify";
interface NavbarUserProps {
user: UserType;
jam?: JamType | null;
setIsInJam: Dispatch<SetStateAction<boolean | undefined>>;
isInJam?: boolean;
}
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>
<DropdownTrigger>
<Avatar
src={user.profilePicture}
className="cursor-pointer"
classNames={{
base: "bg-transparent",
}}
/>
</DropdownTrigger>
<DropdownMenu>
{jam && currentJam && isInJam ? (
<DropdownItem
key="create-game"
href="/create-game"
className="text-black"
>
Create Game
</DropdownItem>
) : null}
{jam && currentJam && !isInJam ? (
<DropdownItem
key="join-event"
className="text-black"
onPress={async () => {
try {
if (!currentJam) {
toast.error("There is no jam to join");
return;
}
if (await joinJam(currentJam.id)) {
setIsInJam(true);
}
} catch (error) {
console.error("Error during join process:", error);
}
}}
>
Join Event
</DropdownItem>
) : null}
<DropdownItem
key="create-post"
href="/create-post"
className="text-black"
>
Create Post
</DropdownItem>
<DropdownItem
key="profile"
className="text-black"
href={`/u/${user.slug}`}
>
Profile
</DropdownItem>
<DropdownItem
showDivider
key="settings"
className="text-black"
href="/settings"
>
Settings
</DropdownItem>
<DropdownItem
key="logout"
color="danger"
className="text-danger"
href="/logout"
>
Logout
</DropdownItem>
</DropdownMenu>
</Dropdown>
</NavbarItem>
);
}

View file

@ -0,0 +1,21 @@
import { NavbarItem } from "@nextui-org/react";
import { ReactNode } from "react";
import ButtonAction from "../link-components/ButtonAction";
interface NavbarButtonActionProps {
icon?: ReactNode;
onPress: () => void;
name: string;
}
export default function NavbarButtonAction({
icon,
onPress,
name,
}: NavbarButtonActionProps) {
return (
<NavbarItem>
<ButtonAction icon={icon} onPress={onPress} name={name} />
</NavbarItem>
);
}

View file

@ -0,0 +1,21 @@
import { NavbarItem } from "@nextui-org/react";
import { ReactNode } from "react";
import ButtonLink from "../link-components/ButtonLink";
interface NavbarButtonLinkProps {
icon?: ReactNode;
href: string;
name: string;
}
export default function NavbarButtonLink({
icon,
href,
name,
}: NavbarButtonLinkProps) {
return (
<NavbarItem>
<ButtonLink icon={icon} href={href} name={name} />
</NavbarItem>
);
}

View file

@ -0,0 +1,16 @@
import { NavbarItem } from "@nextui-org/react";
import { ReactNode } from "react";
import IconLink from "../link-components/IconLink";
interface NavbarIconLinkProps {
icon: ReactNode;
href: string;
}
export default function NavbarIconLink({ icon, href }: NavbarIconLinkProps) {
return (
<NavbarItem>
<IconLink icon={icon} href={href} />
</NavbarItem>
);
}

View file

@ -0,0 +1,15 @@
import { NavbarItem } from "@nextui-org/react";
import Link from "../link-components/Link";
interface NavbarLinkProps {
name: string;
href: string;
}
export default function NavbarLink({ name, href }: NavbarLinkProps) {
return (
<NavbarItem>
<Link name={name} href={href} />
</NavbarItem>
);
}

View file

@ -0,0 +1,14 @@
import { Input, NavbarItem } from "@nextui-org/react";
export default function NavbarSearchbar() {
return (
<NavbarItem>
<Input
placeholder="Search"
classNames={{
inputWrapper: "!duration-500 ease-in-out transition-all",
}}
/>
</NavbarItem>
);
}

View file

@ -0,0 +1,220 @@
"use client";
import {
Image,
Link,
NavbarBrand,
Navbar as NavbarBase,
NavbarContent,
Divider,
} from "@nextui-org/react";
import NavbarLink from "./NavbarLink";
import NavbarSearchbar from "./NavbarSearchbar";
import NavbarButtonLink from "./NavbarButtonLink";
import {
Bell,
CalendarPlus,
Gamepad2,
LogInIcon,
NotebookPen,
Shield,
SquarePen,
} from "lucide-react";
import NextImage from "next/image";
import { useEffect, useState } from "react";
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";
import { toast } from "react-toastify";
import NavbarIconLink from "./NavbarIconLink";
import ThemeToggle from "../theme-toggle";
export default function PCNavbar() {
const pathname = usePathname();
const [jam, setJam] = useState<JamType | null>();
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)");
setReduceMotion(mediaQuery.matches);
const handleChange = (event: MediaQueryListEvent) => {
setReduceMotion(event.matches);
};
mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, []);
useEffect(() => {
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();
// 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
) {
setIsInJam(true);
} else {
setIsInJam(false);
}
if (response.status == 200) {
setUser(user);
} else {
setUser(undefined);
}
}
}, [pathname]);
return (
<NavbarBase
maxWidth="2xl"
className="bg-[#fff] dark:bg-[#222] p-1 duration-500 ease-in-out transition-color"
isBordered
height={80}
>
{/* Left side navbar items */}
<NavbarContent justify="start" className="gap-10">
<NavbarBrand className="flex-grow-0">
<Link
href="/"
className={`duration-500 ease-in-out transition-all transform ${
reduceMotion ? "" : "hover:scale-110"
}`}
>
<Image
as={NextImage}
src="/images/D2J_Icon.png"
className="min-w-[70px]"
alt="Down2Jam logo"
width={70}
height={70}
/>
</Link>
</NavbarBrand>
<NavbarLink href="/about" name="About" />
<NavbarLink href="/games" name="Games" />
</NavbarContent>
<NavbarContent justify="end" className="gap-4">
<NavbarSearchbar />
{user && <Divider orientation="vertical" className="h-1/2" />}
{user && jam && isInJam && (
<NavbarButtonLink
icon={<Gamepad2 />}
name={hasGame ? "My Game" : "Create Game"}
href={hasGame ? "/games/"+hasGame.slug : "/create-game"}
/>
)}
{user && jam && !isInJam && (
<NavbarButtonAction
icon={<CalendarPlus />}
name="Join jam"
onPress={async () => {
const currentJamResponse = await getCurrentJam();
const currentJam = currentJamResponse?.jam;
if (!currentJam) {
toast.error("There is no jam to join");
return;
}
if (await joinJam(currentJam.id)) {
setIsInJam(true);
}
}}
/>
)}
{user && (
<NavbarButtonLink
icon={<SquarePen />}
name="Create Post"
href="/create-post"
/>
)}
{user && <NavbarIconLink icon={<Bell />} href="/inbox" />}
{user && user.mod && (
<NavbarIconLink icon={<Shield />} href="/reports" />
)}
<ThemeToggle />
<Divider orientation="vertical" className="h-1/2" />
{!user && (
<NavbarButtonLink icon={<LogInIcon />} name="Log In" href="/login" />
)}
{!user && (
<NavbarButtonLink
icon={<NotebookPen />}
name="Sign Up"
href="/signup"
/>
)}
{user && <NavbarUser user={user} />}
</NavbarContent>
</NavbarBase>
);
}

View file

@ -0,0 +1,59 @@
import {
Avatar,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownSection,
DropdownTrigger,
NavbarItem,
} from "@nextui-org/react";
import { UserType } from "@/types/UserType";
interface NavbarUserProps {
user: UserType;
}
export default function PCNavbarUser({ user }: NavbarUserProps) {
return (
<NavbarItem>
<Dropdown backdrop="opaque">
<DropdownTrigger>
<Avatar
src={user.profilePicture}
className="cursor-pointer"
classNames={{
base: "bg-transparent",
}}
/>
</DropdownTrigger>
<DropdownMenu>
<DropdownSection title={user.name}>
<DropdownItem
key="profile"
className="text-[#333] dark:text-white"
href={`/u/${user.slug}`}
>
Profile
</DropdownItem>
<DropdownItem
showDivider
key="settings"
className="text-[#333] dark:text-white"
href="/settings"
>
Settings
</DropdownItem>
</DropdownSection>
<DropdownItem
key="logout"
color="danger"
className="text-danger"
href="/logout"
>
Logout
</DropdownItem>
</DropdownMenu>
</Dropdown>
</NavbarItem>
);
}

Some files were not shown because too many files have changed in this diff Show more