Light theme and wysiwyg

This commit is contained in:
Ategon 2025-01-19 13:38:18 -05:00
parent 469c16fac6
commit 50ee8098c4
30 changed files with 4565 additions and 173 deletions

3653
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,18 +11,60 @@
"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-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",

View file

@ -1,25 +1,25 @@
"use client";
import Editor from "@/components/editor";
import { getCookie, hasCookie } from "@/helpers/cookie";
import { Button, Form, Input, Textarea } from "@nextui-org/react";
import { Button, Form, Input } from "@nextui-org/react";
import { LoaderCircle } from "lucide-react";
import { redirect } from "next/navigation";
import { useState } from "react";
import { toast } from "react-toastify";
import sanitizeHtml from "sanitize-html";
export default function CreatePostPage() {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [errors, setErrors] = useState({});
const [waitingPost, setWaitingPost] = useState(false);
return (
<div className="absolute flex items-center 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 +28,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 +39,7 @@ export default function CreatePostPage() {
if (!content) {
setErrors({ content: "Please enter valid content" });
toast.error("Please enter valid content");
return;
}
@ -46,6 +48,9 @@ export default function CreatePostPage() {
return;
}
const sanitizedHtml = sanitizeHtml(content);
setWaitingPost(true);
const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? "https://d2jam.com/api/v1/post"
@ -53,7 +58,7 @@ export default function CreatePostPage() {
{
body: JSON.stringify({
title: title,
content: content,
content: sanitizedHtml,
username: getCookie("user"),
}),
method: "POST",
@ -61,17 +66,24 @@ export default function CreatePostPage() {
"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,21 +97,15 @@ 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} />
<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>

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,10 +2,10 @@ 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 { Spacer } from "@nextui-org/react";
import { NextUIProvider, Spacer } from "@nextui-org/react";
import Footer from "@/components/footer";
import { ThemeProvider } from "next-themes";
const inter = Inter({ subsets: ["latin"] });
@ -20,21 +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-gradient-to-br from-[#181818] to-[#222] min-h-screen flex flex-col">
<Navbar />
<Spacer y={5} />
<div className="max-w-8xl mx-auto flex-grow w-full">
{children}
<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>
<Footer />
<ToastContainer />
</div>
</div>
</Providers>
</ThemeProvider>
</NextUIProvider>
</body>
</html>
);

View file

@ -105,7 +105,7 @@ export default function UserPage() {
Reset
</Button>
</div>
<p>
<p className="text-[#333] dark:text-white transition-color duration-500 ease-in-out">
Don&apos;t have an account? <Link href="/signup">Sign up</Link>
</p>
</Form>

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

View file

@ -129,7 +129,7 @@ export default function UserPage() {
Reset
</Button>
</div>
<p>
<p className="text-[#333] dark:text-white transition-color duration-500 ease-in-out">
Already have an account? <Link href="/login">Log In</Link>
</p>
</Form>

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,159 @@
"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;
};
const limit = 200;
export default function Editor({ content, setContent }: 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 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",
},
},
});
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-500 ease-in-out 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="transform-all duration-500 ease-in-out"
/>
<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="transform-all duration-500 ease-in-out"
/>
</svg>
{editor.storage.characterCount.characters()} / {limit} characters
<br />
{editor.storage.characterCount.words()} words
</div>
)}
</div>
);
}

View file

@ -3,7 +3,7 @@ import { SiDiscord, SiForgejo, SiGithub } from "@icons-pack/react-simple-icons";
export default function Footer() {
return (
<div className="p-8 bg-[#222] mt-8 border-t-2 border-white/15">
<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">

View file

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

View file

@ -15,7 +15,7 @@ export default function ButtonAction({
return (
<Button
endContent={icon}
className="text-white 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 hover:scale-110 transition-all transform duration-500 ease-in-out"
variant="bordered"
onPress={onPress}
>

View file

@ -11,11 +11,11 @@ export default function ButtonLink({ icon, href, name }: ButtonLinkProps) {
return (
<Link
href={href}
className="text-white flex justify-center duration-500 ease-in-out transition-all transform hover:scale-110 ease-in-out"
className="flex justify-center duration-500 ease-in-out transition-all transform hover:scale-110"
>
<Button
endContent={icon}
className="text-white 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"
variant="bordered"
>
{name}

View file

@ -10,7 +10,7 @@ export default function IconLink({ icon, href }: IconLinkProps) {
return (
<Link
href={href}
className="text-white flex justify-center duration-500 ease-in-out transition-all transform hover:scale-125"
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"
>
{icon}
</Link>

View file

@ -9,7 +9,7 @@ export default function Link({ name, href }: LinkProps) {
return (
<BaseLink
href={href}
className="text-white flex justify-center duration-500 ease-in-out transition-all transform hover:scale-110"
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"
>
{name}
</BaseLink>

View file

@ -29,7 +29,13 @@ export default function MobileNavbarUser({
<NavbarItem>
<Dropdown>
<DropdownTrigger>
<Avatar src={user.profilePicture} className="cursor-pointer" />
<Avatar
src={user.profilePicture}
className="cursor-pointer"
classNames={{
base: "bg-transparent",
}}
/>
</DropdownTrigger>
<DropdownMenu>
{jam && isInJam ? (

View file

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

View file

@ -12,10 +12,12 @@ 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";
@ -28,6 +30,8 @@ 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();
@ -76,7 +80,12 @@ export default function PCNavbar() {
}, [pathname]);
return (
<NavbarBase maxWidth="2xl" className="bg-[#222] p-1" isBordered height={80}>
<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">
@ -134,6 +143,11 @@ export default function PCNavbar() {
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" />

View file

@ -17,7 +17,13 @@ export default function PCNavbarUser({ user }: NavbarUserProps) {
<NavbarItem>
<Dropdown>
<DropdownTrigger>
<Avatar src={user.profilePicture} className="cursor-pointer" />
<Avatar
src={user.profilePicture}
className="cursor-pointer"
classNames={{
base: "bg-transparent",
}}
/>
</DropdownTrigger>
<DropdownMenu>
<DropdownItem

View file

@ -2,20 +2,37 @@
import { Button } from "@nextui-org/react";
import { PostType } from "@/types/PostType";
import { Heart } from "lucide-react";
import { Heart, LoaderCircle } from "lucide-react";
import { toast } from "react-toastify";
import { getCookie } from "@/helpers/cookie";
import { redirect } from "next/navigation";
import { useState } from "react";
import { useTheme } from "next-themes";
export default function LikeButton({ post }: { post: PostType }) {
const [likes, setLikes] = useState<number>(post.likes.length);
const [loading, setLoading] = useState<boolean>(false);
const [liked, setLiked] = useState<boolean>(false);
const { theme } = useTheme();
return (
<Button
size="sm"
variant="bordered"
style={{
color: post.hasLiked ? (theme == "dark" ? "#5ed4f7" : "#05b7eb") : "",
borderColor: post.hasLiked
? theme == "dark"
? "#5ed4f744"
: "#05b7eb44"
: "",
}}
onPress={async () => {
if (loading || liked) {
return;
}
setLoading(true);
const response = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? "https://d2jam.com/api/v1/like"
@ -35,6 +52,7 @@ export default function LikeButton({ post }: { post: PostType }) {
);
if (!response.ok) {
setLoading(false);
if (response.status == 401) {
redirect("/login");
} else {
@ -42,11 +60,36 @@ export default function LikeButton({ post }: { post: PostType }) {
return;
}
} else {
setLikes(parseInt(await response.text()));
const data = await response.json();
setLikes(parseInt(data.likes));
post.hasLiked = data.action === "like";
setLoading(false);
setLiked(data.action === "like");
setTimeout(() => setLiked(false), 1000);
}
}}
>
<Heart size={16} /> {likes}
{loading ? (
<LoaderCircle className="animate-spin" size={16} />
) : (
<div
className="flex gap-2 items-center"
style={{ position: "relative" }}
>
<Heart size={16} />
<Heart
size={16}
className={liked ? "animate-ping absolute top-0 left-0" : ""}
style={{
position: "absolute",
top: "0",
left: "0",
zIndex: "10",
}}
/>
<p>{likes}</p>
</div>
)}
</Button>
);
}

View file

@ -1,59 +1,285 @@
import { Avatar, Button, Card, CardBody, Spacer } from "@nextui-org/react";
import {
Avatar,
Button,
Card,
CardBody,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownSection,
DropdownTrigger,
Spacer,
} from "@nextui-org/react";
import { formatDistance } from "date-fns";
import Link from "next/link";
import { PostType } from "@/types/PostType";
import { MessageCircle } from "lucide-react";
import {
Flag,
MessageCircle,
Minus,
MoreVertical,
Plus,
Shield,
ShieldAlert,
ShieldX,
Trash,
X,
} from "lucide-react";
import LikeButton from "./LikeButton";
import { PostStyle } from "@/types/PostStyle";
import { UserType } from "@/types/UserType";
import { useState } from "react";
import { getCookie } from "@/helpers/cookie";
import { toast } from "react-toastify";
export default function PostCard({
post,
style,
user,
}: {
post: PostType;
style: PostStyle;
user?: UserType;
}) {
const [minimized, setMinimized] = useState<boolean>(false);
const [hidden, setHidden] = useState<boolean>(false);
return (
<Card className="bg-opacity-60">
<Card
className="bg-opacity-60 !duration-500 ease-in-out !transition-all"
style={{ display: hidden ? "none" : "flex" }}
>
<CardBody className="p-5">
{style == "cozy" && (
<div>
<p className="text-2xl">{post.title}</p>
{(style == "cozy" || style == "adaptive") &&
(minimized ? (
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<p>{post.title}</p>
<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"
<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>
</div>
<Button
size="sm"
variant="light"
isIconOnly
onPress={() => setMinimized(false)}
>
<Avatar
size="sm"
className="w-6 h-6"
src={post.author.profilePicture}
/>
<p>{post.author.name}</p>
</Link>
<p>
{formatDistance(new Date(post.createdAt), new Date(), {
addSuffix: true,
})}
</p>
</div>
<Spacer y={4} />
<p>{post.content}</p>
<Spacer y={4} />
<div className="flex gap-3">
<LikeButton post={post} />
<Button size="sm" variant="bordered">
<MessageCircle size={16} /> {0}
<Plus size={16} />
</Button>
</div>
</div>
)}
) : (
<div>
<div className="flex justify-between items-center">
<p className="text-2xl">{post.title}</p>
<Button
size="sm"
variant="light"
isIconOnly
onPress={() => setMinimized(true)}
>
<Minus size={16} />
</Button>
</div>
<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 transition-all !duration-500 ease-in-out"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
<Spacer y={4} />
<div className="flex gap-3">
<LikeButton post={post} />
<Button
size="sm"
variant="bordered"
onPress={() => {
toast.warning("Comment functionality coming soon");
}}
>
<MessageCircle size={16} /> {0}
</Button>
<Dropdown>
<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");
setHidden(true);
} else {
toast.error("Error while deleting post");
}
}}
>
Delete
</DropdownItem>
) : (
<></>
)}
</DropdownSection>
<DropdownSection title="Mod Zone">
{user?.mod ? (
<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");
setHidden(true);
} else {
toast.error("Error while removing post");
}
}}
>
Remove
</DropdownItem>
) : (
<></>
)}
{user?.admin && !post.author.mod ? (
<DropdownItem
key="promote-mod"
startContent={<Shield />}
description="Promote user to Mod"
>
Appoint as mod
</DropdownItem>
) : (
<></>
)}
{user?.admin &&
post.author.mod &&
post.author.id !== user.id ? (
<DropdownItem
key="demote-mod"
startContent={<ShieldX />}
description="Demote user from Mod"
>
Remove as mod
</DropdownItem>
) : (
<></>
)}
{user?.admin && !post.author.admin ? (
<DropdownItem
key="promote-admin"
startContent={<ShieldAlert />}
description="Promote user to Admin"
>
Appoint as admin
</DropdownItem>
) : (
<></>
)}
</DropdownSection>
</DropdownMenu>
</Dropdown>
</div>
</div>
))}
{style == "compact" && (
<div>
<p className="text-2xl">{post.title}</p>
@ -68,6 +294,9 @@ export default function PostCard({
size="sm"
className="w-6 h-6"
src={post.author.profilePicture}
classNames={{
base: "bg-transparent",
}}
/>
<p>{post.author.name}</p>
</Link>
@ -93,6 +322,9 @@ export default function PostCard({
size="sm"
className="w-6 h-6"
src={post.author.profilePicture}
classNames={{
base: "bg-transparent",
}}
/>
<p>{post.author.name}</p>
</Link>
@ -104,44 +336,6 @@ export default function PostCard({
</div>
</div>
)}
{style == "adaptive" && (
<div>
<p className="text-2xl">{post.title}</p>
<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}
/>
<p>{post.author.name}</p>
</Link>
<p>
{formatDistance(new Date(post.createdAt), new Date(), {
addSuffix: true,
})}
</p>
</div>
<Spacer y={4} />
<p>{post.content}</p>
<Spacer y={4} />
<div className="flex gap-3">
<LikeButton post={post} />
<Button size="sm" variant="bordered">
<MessageCircle size={16} /> {0}
</Button>
</div>
</div>
)}
</CardBody>
</Card>
);

View file

@ -12,23 +12,59 @@ import {
} from "@nextui-org/react";
import { PostSort } from "@/types/PostSort";
import { PostStyle } from "@/types/PostStyle";
import { getCookie } from "@/helpers/cookie";
import { UserType } from "@/types/UserType";
import { LoaderCircle } from "lucide-react";
import { toast } from "react-toastify";
export default function Posts() {
const [posts, setPosts] = useState<PostType[]>();
const [sort, setSort] = useState<PostSort>("newest");
const [style, setStyle] = useState<PostStyle>("cozy");
const [user, setUser] = useState<UserType>();
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
const fetchPosts = async () => {
const response = await fetch(
const loadUserAndPosts = async () => {
setLoading(true);
// Fetch the user
const userResponse = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? `https://d2jam.com/api/v1/posts?sort=${sort}`
: `http://localhost:3005/api/v1/posts?sort=${sort}`
? `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",
}
);
setPosts(await response.json());
if (userResponse.ok) {
const userData = await userResponse.json();
setUser(userData);
// Fetch posts with userSlug if user is available
const postsResponse = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? `https://d2jam.com/api/v1/posts?sort=${sort}&user=${userData.slug}`
: `http://localhost:3005/api/v1/posts?sort=${sort}&user=${userData.slug}`
);
setPosts(await postsResponse.json());
setLoading(false);
} else {
setUser(undefined);
// Fetch posts without userSlug if user is not available
const postsResponse = await fetch(
process.env.NEXT_PUBLIC_MODE === "PROD"
? `https://d2jam.com/api/v1/posts?sort=${sort}`
: `http://localhost:3005/api/v1/posts?sort=${sort}`
);
setPosts(await postsResponse.json());
setLoading(false);
}
};
fetchPosts();
loadUserAndPosts();
}, [sort]);
return (
@ -37,7 +73,11 @@ export default function Posts() {
<div className="flex gap-2">
<Dropdown>
<DropdownTrigger>
<Button size="sm" className="text-xs" variant="faded">
<Button
size="sm"
className="text-xs bg-white dark:bg-[#252525] transition-all !duration-500 ease-in-out text-[#333] dark:text-white"
variant="faded"
>
{sort.charAt(0).toUpperCase() + sort.slice(1)}
</Button>
</DropdownTrigger>
@ -45,21 +85,32 @@ export default function Posts() {
onAction={(key) => {
setSort(key as PostSort);
}}
className="text-black"
className="text-[#333] dark:text-white"
>
<DropdownItem key="newest">Newest</DropdownItem>
<DropdownItem key="top">Top</DropdownItem>
<DropdownItem key="oldest">Oldest</DropdownItem>
</DropdownMenu>
</Dropdown>
<Button size="sm" className="text-xs" variant="faded">
<Button
size="sm"
className="text-xs bg-white dark:bg-[#252525] transition-all !duration-500 ease-in-out text-[#333] dark:text-white"
variant="faded"
onPress={() => {
toast.warning("Flair filtering functionality coming soon");
}}
>
All Tags
</Button>
</div>
<div>
<Dropdown>
<DropdownTrigger>
<Button size="sm" className="text-xs" variant="faded">
<Button
size="sm"
className="text-xs bg-white dark:bg-[#252525] transition-all !duration-500 ease-in-out text-[#333] dark:text-white"
variant="faded"
>
{style.charAt(0).toUpperCase() + style.slice(1)}
</Button>
</DropdownTrigger>
@ -67,7 +118,7 @@ export default function Posts() {
onAction={(key) => {
setStyle(key as PostStyle);
}}
className="text-black"
className="text-[#333] dark:text-white"
>
<DropdownItem key="cozy">Cozy</DropdownItem>
<DropdownItem key="compact">Compact</DropdownItem>
@ -77,14 +128,22 @@ export default function Posts() {
</Dropdown>
</div>
</div>
<div className="flex flex-col gap-3 p-4">
{posts &&
posts.map((post) => (
<div key={post.id}>
<PostCard post={post} style={style} />
</div>
))}
</div>
{loading ? (
<div className="flex justify-center p-6">
<LoaderCircle
className="animate-spin text-[#333] dark:text-[#999]"
size={24}
/>
</div>
) : (
<div className="flex flex-col gap-3 p-4">
{posts &&
posts.map((post) => (
<PostCard key={post.id} post={post} style={style} user={user} />
))}
</div>
)}
</div>
);
return <div></div>;

View file

@ -0,0 +1,42 @@
"use client";
import { useState, useEffect } from "react";
import { MoonIcon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
export default function ThemeToggle() {
const [isSpinning, setIsSpinning] = useState<boolean>(false);
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState<boolean>(false);
useEffect(() => {
setMounted(true);
}, []);
const handleToggle = () => {
if (isSpinning) return;
setIsSpinning(true);
setTimeout(() => setIsSpinning(false), 500);
console.log(theme);
setTheme(theme === "dark" ? "light" : "dark");
};
if (!mounted) {
return null;
}
return (
<div
onClick={handleToggle}
style={{ cursor: "pointer" }}
className={`${isSpinning && "animate-[spin_0.5s_ease-out]"} `}
>
<div className="duration-500 ease-in-out transition-all transform text-[#333] dark:text-white hover:scale-125">
{theme === "dark" && <MoonIcon />}
{theme === "light" && <Sun />}
</div>
</div>
);
}

View file

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

View file

@ -1,12 +1,11 @@
import { UserType } from "./UserType";
export interface PostType {
id: number;
title: string;
content: string;
author: {
slug: string;
profilePicture: string;
name: string;
};
author: UserType;
createdAt: Date;
likes: [];
hasLiked: boolean;
}

View file

@ -4,4 +4,6 @@ export interface UserType {
name: string;
profilePicture: string;
createdAt: Date;
mod: boolean;
admin: boolean;
}

View file

@ -21,6 +21,6 @@ const config: Config = {
},
},
darkMode: "class",
plugins: [nextui()],
plugins: [nextui(), require("@tailwindcss/typography")],
};
export default config;