Compare commits

...

12 commits

Author SHA1 Message Date
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
25 changed files with 469 additions and 114 deletions

View file

@ -1,7 +1,17 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ images: {
remotePatterns: [
{
protocol: "https",
hostname: "static-cdn.jtvnw.net",
port: "",
pathname: "/**",
search: "",
},
],
},
}; };
export default nextConfig; export default nextConfig;

BIN
public/images/D2J_Icon.png Normal file

Binary file not shown.

After

(image error) Size: 24 KiB

Binary file not shown.

Before

(image error) Size: 24 KiB

Binary file not shown.

Before

Width: 48px  |  Height: 48px  |  Size: 15 KiB

After

Width: 48px  |  Height: 48px  |  Size: 15 KiB

View file

@ -60,7 +60,6 @@ export default function UserPage() {
const { user } = await response.json(); const { user } = await response.json();
const token = response.headers.get("Authorization"); const token = response.headers.get("Authorization");
console.log(response.headers);
if (!token) { if (!token) {
toast.error("Failed to retreive access token"); toast.error("Failed to retreive access token");
@ -105,7 +104,7 @@ export default function UserPage() {
Reset Reset
</Button> </Button>
</div> </div>
<p className="text-[#333] dark:text-white transition-color duration-500 ease-in-out"> <p className="text-[#333] dark:text-white transition-color duration-250">
Don&apos;t have an account? <Link href="/signup">Sign up</Link> Don&apos;t have an account? <Link href="/signup">Sign up</Link>
</p> </p>
</Form> </Form>

View file

@ -6,11 +6,28 @@ import { toast } from "react-toastify";
export default function UserPage() { export default function UserPage() {
useEffect(() => { 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 ( return (

View file

@ -1,7 +1,9 @@
"use client"; "use client";
import { Button, Form, Input, Link } from "@nextui-org/react"; import { Button, Form, Input, Link } from "@nextui-org/react";
import { redirect } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { toast } from "react-toastify";
export default function UserPage() { export default function UserPage() {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@ -72,6 +74,7 @@ export default function UserPage() {
body: JSON.stringify({ username: username, password: password }), body: JSON.stringify({ username: username, password: password }),
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include",
} }
); );
@ -81,13 +84,13 @@ export default function UserPage() {
return; return;
} }
// const { token, user } = await response.json(); const { token, user } = await response.json();
// document.cookie = `token=${token}`; document.cookie = `token=${token}`;
// document.cookie = `user=${user.slug}`; document.cookie = `user=${user.slug}`;
// toast.success("Successfully signed up"); toast.success("Successfully signed up");
// redirect("/"); redirect("/");
}} }}
> >
<Input <Input
@ -129,7 +132,7 @@ export default function UserPage() {
Reset Reset
</Button> </Button>
</div> </div>
<p className="text-[#333] dark:text-white transition-color duration-500 ease-in-out"> <p className="text-[#333] dark:text-white transition-color duration-250">
Already have an account? <Link href="/login">Log In</Link> Already have an account? <Link href="/login">Log In</Link>
</p> </p>
</Form> </Form>

View file

@ -97,7 +97,7 @@ export default function Editor({ content, setContent }: EditorProps) {
editorProps: { editorProps: {
attributes: { attributes: {
class: class:
"prose dark:prose-invert 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 transform-all duration-500 ease-in-out", "prose dark:prose-invert 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",
}, },
}, },
}); });
@ -115,7 +115,7 @@ export default function Editor({ content, setContent }: EditorProps) {
: editor.storage.characterCount.characters() > limit / 2 : editor.storage.characterCount.characters() > limit / 2
? "text-yellow-500" ? "text-yellow-500"
: "text-[#888] dark:text-[#555]" : "text-[#888] dark:text-[#555]"
} transform-color duration-500 ease-in-out flex items-center gap-3`} } transform-color duration-250 ease-linear flex items-center gap-3`}
> >
<svg width="30" height="30" viewBox="0 0 36 36"> <svg width="30" height="30" viewBox="0 0 36 36">
<circle <circle
@ -125,7 +125,7 @@ export default function Editor({ content, setContent }: EditorProps) {
fill="none" fill="none"
stroke={theme === "dark" ? "#333" : "#eee"} stroke={theme === "dark" ? "#333" : "#eee"}
strokeWidth="3" strokeWidth="3"
className="transform-all duration-500 ease-in-out" className="!duration-250 !ease-linear !transition-all"
/> />
<circle <circle
id="progress-circle" id="progress-circle"
@ -146,7 +146,7 @@ export default function Editor({ content, setContent }: EditorProps) {
(1 - editor.storage.characterCount.characters() / limit) * 100 (1 - editor.storage.characterCount.characters() / limit) * 100
} }
transform="rotate(-90 18 18)" transform="rotate(-90 18 18)"
className="transform-all duration-500 ease-in-out" className="!duration-250 !ease-linear !transition-all"
/> />
</svg> </svg>
{editor.storage.characterCount.characters()} / {limit} characters {editor.storage.characterCount.characters()} / {limit} characters

View file

@ -2,8 +2,8 @@ import { Calendar } from "lucide-react";
export default function JamHeader() { export default function JamHeader() {
return ( return (
<div className="bg-[#7090b9] dark:bg-[#124a88] flex rounded-2xl overflow-hidden text-white transition-color duration-500 ease-in-out"> <div className="bg-[#7090b9] dark:bg-[#124a88] flex rounded-2xl overflow-hidden text-white transition-color duration-250">
<div className="bg-[#85bdd2] dark:bg-[#1892b3] p-4 px-6 flex items-center gap-2 font-bold transition-color duration-500 ease-in-out"> <div className="bg-[#85bdd2] dark:bg-[#1892b3] p-4 px-6 flex items-center gap-2 font-bold transition-color duration-250">
<Calendar /> <Calendar />
<p>Dare2Jam 1</p> <p>Dare2Jam 1</p>
</div> </div>

View file

@ -1,5 +1,7 @@
"use client";
import { Button } from "@nextui-org/react"; import { Button } from "@nextui-org/react";
import { ReactNode } from "react"; import { ReactNode, useEffect, useState } from "react";
interface ButtonActionProps { interface ButtonActionProps {
icon?: ReactNode; icon?: ReactNode;
@ -12,10 +14,28 @@ export default function ButtonAction({
onPress, onPress,
name, name,
}: ButtonActionProps) { }: 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 ( return (
<Button <Button
endContent={icon} endContent={icon}
className="text-[#333] dark:text-white border-[#333]/50 dark:border-white/50 hover:scale-110 transition-all transform duration-500 ease-in-out" 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" variant="bordered"
onPress={onPress} onPress={onPress}
> >

View file

@ -1,5 +1,7 @@
"use client";
import { Button, Link } from "@nextui-org/react"; import { Button, Link } from "@nextui-org/react";
import { ReactNode } from "react"; import { ReactNode, useEffect, useState } from "react";
interface ButtonLinkProps { interface ButtonLinkProps {
icon?: ReactNode; icon?: ReactNode;
@ -8,14 +10,34 @@ interface ButtonLinkProps {
} }
export default function ButtonLink({ icon, href, name }: ButtonLinkProps) { 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 ( return (
<Link <Link
href={href} href={href}
className="flex justify-center duration-500 ease-in-out transition-all transform hover:scale-110" className={`flex justify-center duration-500 ease-in-out transition-all transform ${
!reduceMotion ? "hover:scale-110" : ""
}`}
> >
<Button <Button
endContent={icon} endContent={icon}
className="text-[#333] dark:text-white border-[#333]/50 dark:border-white/50 transition-all transform !duration-500 ease-in-out" 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" variant="bordered"
> >
{name} {name}

View file

@ -1,5 +1,7 @@
"use client";
import { Link } from "@nextui-org/react"; import { Link } from "@nextui-org/react";
import { ReactNode } from "react"; import { ReactNode, useEffect, useState } from "react";
interface IconLinkProps { interface IconLinkProps {
icon: ReactNode; icon: ReactNode;
@ -7,10 +9,28 @@ interface IconLinkProps {
} }
export default function IconLink({ icon, href }: IconLinkProps) { 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 ( return (
<Link <Link
href={href} href={href}
className="text-[#333] dark:text-white flex justify-center duration-500 ease-in-out transition-all transform hover:scale-125 duration-500 ease-in-out transition-color" className={`text-[#333] dark:text-white flex justify-center duration-500 ease-in-out transition-all transform ${
!reduceMotion ? "hover:scale-125" : ""
} transition-color`}
> >
{icon} {icon}
</Link> </Link>

View file

@ -1,4 +1,7 @@
"use client";
import { Link as BaseLink } from "@nextui-org/react"; import { Link as BaseLink } from "@nextui-org/react";
import { useEffect, useState } from "react";
interface LinkProps { interface LinkProps {
name: string; name: string;
@ -6,10 +9,28 @@ interface LinkProps {
} }
export default function Link({ name, href }: LinkProps) { 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 ( return (
<BaseLink <BaseLink
href={href} href={href}
className="text-[#333] dark:text-white flex justify-center duration-500 ease-in-out transition-all transform hover:scale-110 duration-500 ease-in-out transition-color" className={`text-[#333] dark:text-white flex justify-center duration-500 ease-in-out transition-all transform ${
!reduceMotion ? "hover:scale-110" : ""
} transition-color`}
> >
{name} {name}
</BaseLink> </BaseLink>

View file

@ -75,7 +75,7 @@ export default function MobileNavbar() {
> >
<Image <Image
as={NextImage} as={NextImage}
src="/images/hereyougopomo.png" src="/images/D2J_Icon.png"
className="min-w-[70px]" className="min-w-[70px]"
alt="Dare2Jam logo" alt="Dare2Jam logo"
width={70} width={70}

View file

@ -38,6 +38,21 @@ export default function PCNavbar() {
const [jam, setJam] = useState<JamType | null>(); const [jam, setJam] = useState<JamType | null>();
const [isInJam, setIsInJam] = useState<boolean>(); const [isInJam, setIsInJam] = useState<boolean>();
const [user, setUser] = useState<UserType>(); const [user, setUser] = useState<UserType>();
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);
};
}, []);
useEffect(() => { useEffect(() => {
loadUser(); loadUser();
@ -91,11 +106,13 @@ export default function PCNavbar() {
<NavbarBrand className="flex-grow-0"> <NavbarBrand className="flex-grow-0">
<Link <Link
href="/" href="/"
className="duration-500 ease-in-out transition-all transform hover:scale-110" className={`duration-500 ease-in-out transition-all transform ${
reduceMotion ? "" : "hover:scale-110"
}`}
> >
<Image <Image
as={NextImage} as={NextImage}
src="/images/hereyougopomo.png" src="/images/D2J_Icon.png"
className="min-w-[70px]" className="min-w-[70px]"
alt="Dare2Jam logo" alt="Dare2Jam logo"
width={70} width={70}

View file

@ -15,7 +15,7 @@ interface NavbarUserProps {
export default function PCNavbarUser({ user }: NavbarUserProps) { export default function PCNavbarUser({ user }: NavbarUserProps) {
return ( return (
<NavbarItem> <NavbarItem>
<Dropdown> <Dropdown backdrop="opaque">
<DropdownTrigger> <DropdownTrigger>
<Avatar <Avatar
src={user.profilePicture} src={user.profilePicture}
@ -28,7 +28,7 @@ export default function PCNavbarUser({ user }: NavbarUserProps) {
<DropdownMenu> <DropdownMenu>
<DropdownItem <DropdownItem
key="profile" key="profile"
className="text-black" className="text-[#333] dark:text-white"
href={`/u/${user.slug}`} href={`/u/${user.slug}`}
> >
Profile Profile
@ -36,7 +36,7 @@ export default function PCNavbarUser({ user }: NavbarUserProps) {
<DropdownItem <DropdownItem
showDivider showDivider
key="settings" key="settings"
className="text-black" className="text-[#333] dark:text-white"
href="/settings" href="/settings"
> >
Settings Settings

View file

@ -6,7 +6,7 @@ import { Heart, LoaderCircle } from "lucide-react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { getCookie } from "@/helpers/cookie"; import { getCookie } from "@/helpers/cookie";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { useState } from "react"; import { useState, useEffect } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
export default function LikeButton({ post }: { post: PostType }) { export default function LikeButton({ post }: { post: PostType }) {
@ -14,6 +14,21 @@ export default function LikeButton({ post }: { post: PostType }) {
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [liked, setLiked] = useState<boolean>(false); const [liked, setLiked] = useState<boolean>(false);
const { theme } = useTheme(); const { theme } = useTheme();
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 ( return (
<Button <Button
@ -56,7 +71,7 @@ export default function LikeButton({ post }: { post: PostType }) {
if (response.status == 401) { if (response.status == 401) {
redirect("/login"); redirect("/login");
} else { } else {
toast.error("An error occured"); toast.error("An error occurred");
return; return;
} }
} else { } else {
@ -79,7 +94,11 @@ export default function LikeButton({ post }: { post: PostType }) {
<Heart size={16} /> <Heart size={16} />
<Heart <Heart
size={16} size={16}
className={liked ? "animate-ping absolute top-0 left-0" : ""} className={
liked && !reduceMotion
? "animate-ping absolute top-0 left-0"
: "absolute top-0 left-0"
}
style={{ style={{
position: "absolute", position: "absolute",
top: "0", top: "0",

View file

@ -46,7 +46,7 @@ export default function PostCard({
return ( return (
<Card <Card
className="bg-opacity-60 !duration-500 ease-in-out !transition-all" className="bg-opacity-60 !duration-250 !ease-linear !transition-all"
style={{ display: hidden ? "none" : "flex" }} style={{ display: hidden ? "none" : "flex" }}
> >
<CardBody className="p-5"> <CardBody className="p-5">
@ -128,7 +128,7 @@ export default function PostCard({
<Spacer y={4} /> <Spacer y={4} />
<div <div
className="prose dark:prose-invert transition-all !duration-500 ease-in-out" className="prose dark:prose-invert !duration-250 !ease-linear !transition-all"
dangerouslySetInnerHTML={{ __html: post.content }} dangerouslySetInnerHTML={{ __html: post.content }}
/> />
@ -145,7 +145,7 @@ export default function PostCard({
> >
<MessageCircle size={16} /> {0} <MessageCircle size={16} /> {0}
</Button> </Button>
<Dropdown> <Dropdown backdrop="opaque">
<DropdownTrigger> <DropdownTrigger>
<Button size="sm" variant="bordered" isIconOnly> <Button size="sm" variant="bordered" isIconOnly>
<MoreVertical size={16} /> <MoreVertical size={16} />
@ -201,8 +201,8 @@ export default function PostCard({
<></> <></>
)} )}
</DropdownSection> </DropdownSection>
<DropdownSection title="Mod Zone"> {user?.mod ? (
{user?.mod ? ( <DropdownSection title="Mod Zone">
<DropdownItem <DropdownItem
key="remove" key="remove"
startContent={<X />} startContent={<X />}
@ -236,45 +236,45 @@ export default function PostCard({
> >
Remove Remove
</DropdownItem> </DropdownItem>
) : ( {user?.admin && !post.author.mod ? (
<></> <DropdownItem
)} key="promote-mod"
{user?.admin && !post.author.mod ? ( startContent={<Shield />}
<DropdownItem description="Promote user to Mod"
key="promote-mod" >
startContent={<Shield />} Appoint as mod
description="Promote user to Mod" </DropdownItem>
> ) : (
Appoint as mod <></>
</DropdownItem> )}
) : ( {user?.admin &&
<></> post.author.mod &&
)} post.author.id !== user.id ? (
{user?.admin && <DropdownItem
post.author.mod && key="demote-mod"
post.author.id !== user.id ? ( startContent={<ShieldX />}
<DropdownItem description="Demote user from Mod"
key="demote-mod" >
startContent={<ShieldX />} Remove as mod
description="Demote user from Mod" </DropdownItem>
> ) : (
Remove as mod <></>
</DropdownItem> )}
) : ( {user?.admin && !post.author.admin ? (
<></> <DropdownItem
)} key="promote-admin"
{user?.admin && !post.author.admin ? ( startContent={<ShieldAlert />}
<DropdownItem description="Promote user to Admin"
key="promote-admin" >
startContent={<ShieldAlert />} Appoint as admin
description="Promote user to Admin" </DropdownItem>
> ) : (
Appoint as admin <></>
</DropdownItem> )}
) : ( </DropdownSection>
<></> ) : (
)} <></>
</DropdownSection> )}
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
</div> </div>

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
import PostCard from "./PostCard"; import PostCard from "./PostCard";
import { PostType } from "@/types/PostType"; import { PostType } from "@/types/PostType";
import { import {
@ -14,12 +14,31 @@ import { PostSort } from "@/types/PostSort";
import { PostStyle } from "@/types/PostStyle"; import { PostStyle } from "@/types/PostStyle";
import { getCookie } from "@/helpers/cookie"; import { getCookie } from "@/helpers/cookie";
import { UserType } from "@/types/UserType"; import { UserType } from "@/types/UserType";
import { LoaderCircle } from "lucide-react"; import {
Calendar,
Calendar1,
CalendarArrowDown,
CalendarCog,
CalendarDays,
CalendarFold,
CalendarRange,
Clock1,
Clock2,
Clock3,
Clock4,
ClockArrowDown,
ClockArrowUp,
LoaderCircle,
Sparkles,
Trophy,
} from "lucide-react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { PostTime } from "@/types/PostTimes";
export default function Posts() { export default function Posts() {
const [posts, setPosts] = useState<PostType[]>(); const [posts, setPosts] = useState<PostType[]>();
const [sort, setSort] = useState<PostSort>("newest"); const [sort, setSort] = useState<PostSort>("newest");
const [time, setTime] = useState<PostTime>("all");
const [style, setStyle] = useState<PostStyle>("cozy"); const [style, setStyle] = useState<PostStyle>("cozy");
const [user, setUser] = useState<UserType>(); const [user, setUser] = useState<UserType>();
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
@ -45,8 +64,8 @@ export default function Posts() {
// Fetch posts with userSlug if user is available // Fetch posts with userSlug if user is available
const postsResponse = await fetch( const postsResponse = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD" process.env.NEXT_PUBLIC_MODE === "PROD"
? `https://d2jam.com/api/v1/posts?sort=${sort}&user=${userData.slug}` ? `https://d2jam.com/api/v1/posts?sort=${sort}&user=${userData.slug}&time=${time}`
: `http://localhost:3005/api/v1/posts?sort=${sort}&user=${userData.slug}` : `http://localhost:3005/api/v1/posts?sort=${sort}&user=${userData.slug}&time=${time}`
); );
setPosts(await postsResponse.json()); setPosts(await postsResponse.json());
setLoading(false); setLoading(false);
@ -56,8 +75,8 @@ export default function Posts() {
// Fetch posts without userSlug if user is not available // Fetch posts without userSlug if user is not available
const postsResponse = await fetch( const postsResponse = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD" process.env.NEXT_PUBLIC_MODE === "PROD"
? `https://d2jam.com/api/v1/posts?sort=${sort}` ? `https://d2jam.com/api/v1/posts?sort=${sort}&time=${time}`
: `http://localhost:3005/api/v1/posts?sort=${sort}` : `http://localhost:3005/api/v1/posts?sort=${sort}&time=${time}`
); );
setPosts(await postsResponse.json()); setPosts(await postsResponse.json());
setLoading(false); setLoading(false);
@ -65,20 +84,107 @@ export default function Posts() {
}; };
loadUserAndPosts(); loadUserAndPosts();
}, [sort]); }, [sort, time]);
const sorts: Record<
PostSort,
{ name: string; icon: ReactNode; description: string }
> = {
top: {
name: "Top",
icon: <Trophy />,
description: "Shows the most liked posts first",
},
newest: {
name: "Newest",
icon: <ClockArrowUp />,
description: "Shows the newest posts first",
},
oldest: {
name: "Oldest",
icon: <ClockArrowDown />,
description: "Shows the oldest posts first",
},
};
const times: Record<
PostTime,
{ name: string; icon: ReactNode; description: string }
> = {
hour: {
name: "Hour",
icon: <Clock1 />,
description: "Shows posts from the last hour",
},
three_hours: {
name: "Three Hours",
icon: <Clock2 />,
description: "Shows posts from the last three hours",
},
six_hours: {
name: "Six Hours",
icon: <Clock3 />,
description: "Shows posts from the last six hours",
},
twelve_hours: {
name: "Twelve Hours",
icon: <Clock4 />,
description: "Shows posts from the last twelve hours",
},
day: {
name: "Day",
icon: <Calendar />,
description: "Shows posts from the last day",
},
week: {
name: "Week",
icon: <CalendarDays />,
description: "Shows posts from the last week",
},
month: {
name: "Month",
icon: <CalendarRange />,
description: "Shows posts from the last month",
},
three_months: {
name: "Three Months",
icon: <CalendarFold />,
description: "Shows posts from the last three months",
},
six_months: {
name: "Six Months",
icon: <CalendarCog />,
description: "Shows posts from the last six months",
},
nine_months: {
name: "Nine Months",
icon: <CalendarArrowDown />,
description: "Shows posts from the last nine months",
},
year: {
name: "Year",
icon: <Calendar1 />,
description: "Shows posts from the last year",
},
all: {
name: "All Times",
icon: <Sparkles />,
description: "Shows all posts",
},
};
return ( return (
<div> <div>
<div className="flex justify-between p-4 pb-0"> <div className="flex justify-between p-4 pb-0">
<div className="flex gap-2"> <div className="flex gap-2">
<Dropdown> <Dropdown backdrop="opaque">
<DropdownTrigger> <DropdownTrigger>
<Button <Button
size="sm" size="sm"
className="text-xs bg-white dark:bg-[#252525] transition-all !duration-500 ease-in-out text-[#333] dark:text-white" className="text-xs bg-white dark:bg-[#252525] !duration-250 !ease-linear !transition-all text-[#333] dark:text-white"
variant="faded" variant="faded"
> >
{sort.charAt(0).toUpperCase() + sort.slice(1)} {sorts[sort]?.name}
</Button> </Button>
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu <DropdownMenu
@ -87,14 +193,47 @@ export default function Posts() {
}} }}
className="text-[#333] dark:text-white" className="text-[#333] dark:text-white"
> >
<DropdownItem key="newest">Newest</DropdownItem> {Object.entries(sorts).map(([key, sort]) => (
<DropdownItem key="top">Top</DropdownItem> <DropdownItem
<DropdownItem key="oldest">Oldest</DropdownItem> key={key}
startContent={sort.icon}
description={sort.description}
>
{sort.name}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
<Dropdown backdrop="opaque">
<DropdownTrigger>
<Button
size="sm"
className="text-xs bg-white dark:bg-[#252525] !duration-250 !ease-linear !transition-all text-[#333] dark:text-white"
variant="faded"
>
{times[time]?.name}
</Button>
</DropdownTrigger>
<DropdownMenu
onAction={(key) => {
setTime(key as PostTime);
}}
className="text-[#333] dark:text-white"
>
{Object.entries(times).map(([key, sort]) => (
<DropdownItem
key={key}
startContent={sort.icon}
description={sort.description}
>
{sort.name}
</DropdownItem>
))}
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
<Button <Button
size="sm" size="sm"
className="text-xs bg-white dark:bg-[#252525] transition-all !duration-500 ease-in-out text-[#333] dark:text-white" className="text-xs bg-white dark:bg-[#252525] !duration-250 !ease-linear !transition-all text-[#333] dark:text-white"
variant="faded" variant="faded"
onPress={() => { onPress={() => {
toast.warning("Flair filtering functionality coming soon"); toast.warning("Flair filtering functionality coming soon");
@ -104,11 +243,11 @@ export default function Posts() {
</Button> </Button>
</div> </div>
<div> <div>
<Dropdown> <Dropdown backdrop="opaque">
<DropdownTrigger> <DropdownTrigger>
<Button <Button
size="sm" size="sm"
className="text-xs bg-white dark:bg-[#252525] transition-all !duration-500 ease-in-out text-[#333] dark:text-white" className="text-xs bg-white dark:bg-[#252525] !duration-250 !ease-linear !transition-all text-[#333] dark:text-white"
variant="faded" variant="faded"
> >
{style.charAt(0).toUpperCase() + style.slice(1)} {style.charAt(0).toUpperCase() + style.slice(1)}
@ -138,10 +277,15 @@ export default function Posts() {
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-3 p-4"> <div className="flex flex-col gap-3 p-4">
{posts && {posts && posts.length > 0 ? (
posts.map((post) => ( posts.map((post) => (
<PostCard key={post.id} post={post} style={style} user={user} /> <PostCard key={post.id} post={post} style={style} user={user} />
))} ))
) : (
<p className="text-center text-[#333] dark:text-white transition-color duration-250 ease-linear">
No posts match your filters
</p>
)}
</div> </div>
)} )}
</div> </div>

View file

@ -2,7 +2,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FeaturedStreamerType } from "@/types/FeaturedStreamerType"; import { FeaturedStreamerType } from "@/types/FeaturedStreamerType";
import { Image } from "@nextui-org/react";
import NextImage from "next/image";
export default function Streams() { export default function Streams() {
const [streamers, setStreamers] = useState<FeaturedStreamerType[]>([]); const [streamers, setStreamers] = useState<FeaturedStreamerType[]>([]);
@ -51,7 +52,7 @@ export default function Streams() {
const currentStreamer = streamers[currentIndex]; // Get the currently displayed streamer const currentStreamer = streamers[currentIndex]; // Get the currently displayed streamer
return ( return (
<div style={{ textAlign: "center", padding: "20px" }}> <div className="text-[#333] dark:text-white text-center p-6 transition-color duration-250">
<h1>Featured Streamer</h1> <h1>Featured Streamer</h1>
<div <div
style={{ style={{
@ -65,15 +66,30 @@ export default function Streams() {
margin: "0 auto", margin: "0 auto",
}} }}
> >
<img <Image
as={NextImage}
src={currentStreamer.thumbnailUrl} src={currentStreamer.thumbnailUrl}
alt={`${currentStreamer.userName}'s thumbnail`} alt={`${currentStreamer.userName}'s thumbnail`}
style={{ width: "100%", borderRadius: "4px", marginBottom: "10px" }} style={{ width: "100%", borderRadius: "4px", marginBottom: "10px" }}
width={320}
height={180}
/> />
<a href={`https://twitch.tv/${currentStreamer.userName}`} target="_blank" ><div style={{height:"100px",display:"flex", flexDirection:"column",justifyContent:"center"}}> <a
<h3>{currentStreamer.userName}</h3> href={`https://twitch.tv/${currentStreamer.userName}`}
<p>{currentStreamer.streamTitle}</p> target="_blank"
</div></a> >
<div
style={{
height: "100px",
display: "flex",
flexDirection: "column",
justifyContent: "center",
}}
>
<h3>{currentStreamer.userName}</h3>
<p>{currentStreamer.streamTitle}</p>
</div>
</a>
<div> <div>
{currentStreamer.streamTags.map((tag, index) => ( {currentStreamer.streamTags.map((tag, index) => (
<span <span

View file

@ -8,16 +8,31 @@ export default function ThemeToggle() {
const [isSpinning, setIsSpinning] = useState<boolean>(false); const [isSpinning, setIsSpinning] = useState<boolean>(false);
const { setTheme, resolvedTheme } = useTheme(); const { setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState<boolean>(false); const [mounted, setMounted] = useState<boolean>(false);
const [reduceMotion, setReduceMotion] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
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);
};
}, []); }, []);
const handleToggle = () => { const handleToggle = () => {
if (isSpinning) return; if (isSpinning) return;
setIsSpinning(true); if (!reduceMotion) {
setTimeout(() => setIsSpinning(false), 500); setIsSpinning(true);
setTimeout(() => setIsSpinning(false), 500);
}
setTheme(resolvedTheme === "dark" ? "light" : "dark"); setTheme(resolvedTheme === "dark" ? "light" : "dark");
}; };
@ -30,9 +45,15 @@ export default function ThemeToggle() {
<div <div
onClick={handleToggle} onClick={handleToggle}
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
className={`${isSpinning && "animate-[spin_0.5s_ease-out]"} `} className={`${
isSpinning && !reduceMotion ? "animate-[spin_0.5s_ease-out]" : ""
}`}
> >
<div className="duration-500 ease-in-out transition-all transform text-[#333] dark:text-white hover:scale-125"> <div
className={`!duration-250 !ease-linear !transition-all transform text-[#333] dark:text-white ${
!reduceMotion ? "hover:scale-125" : ""
}`}
>
{resolvedTheme === "dark" && <Moon />} {resolvedTheme === "dark" && <Moon />}
{resolvedTheme === "light" && <Sun />} {resolvedTheme === "light" && <Sun />}
</div> </div>

View file

@ -10,6 +10,11 @@ export default function Timer({
targetDate: Date; targetDate: Date;
}) { }) {
const [timeLeft, setTimeLeft] = useState(targetDate.getTime() - Date.now()); const [timeLeft, setTimeLeft] = useState(targetDate.getTime() - Date.now());
const [mounted, setMounted] = useState<boolean>(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
@ -29,16 +34,24 @@ export default function Timer({
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000)); const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
const days = Math.floor(totalSeconds / 86400); const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600); const hours = Math.floor((totalSeconds % 86400) / 3600);
// const minutes = Math.floor((totalSeconds % 3600) / 60); const minutes = Math.floor((totalSeconds % 3600) / 60);
// const seconds = totalSeconds % 60; const seconds = totalSeconds % 60;
return `${days} days ${hours.toString()} hours`; return [
`${days} days ${hours.toString()} hours`,
`${minutes.toString()} minutes ${seconds.toString()} seconds`,
];
}; };
if (!mounted) {
return null;
}
return ( return (
<div> <div>
<p>{name}</p> <p>{name}</p>
<p className="text-4xl">{formatTime(timeLeft)}</p> <p className="text-4xl text-wrap">{formatTime(timeLeft)[0]}</p>
<p className="text-4xl text-wrap">{formatTime(timeLeft)[1]}</p>
</div> </div>
); );
} }

View file

@ -3,7 +3,7 @@ import Timer from "./Timer";
export default function Timers() { export default function Timers() {
return ( return (
<div className="text-[#333] dark:text-white ease-in-out transition-color duration-500"> <div className="text-[#333] dark:text-white transition-color duration-250">
<Timer <Timer
name="Jam Start" name="Jam Start"
targetDate={new Date("2025-04-04T18:00:00-05:00")} targetDate={new Date("2025-04-04T18:00:00-05:00")}

View file

@ -1 +1 @@
export type PostSort = "newest" | "oldest"; export type PostSort = "newest" | "oldest" | "top";

13
src/types/PostTimes.ts Normal file
View file

@ -0,0 +1,13 @@
export type PostTime =
| "hour"
| "three_hours"
| "six_hours"
| "twelve_hours"
| "day"
| "week"
| "month"
| "three_months"
| "six_months"
| "nine_months"
| "year"
| "all";