change: everything

This commit is contained in:
Xory 2026-02-07 16:12:58 +02:00
parent 1e09c02cb9
commit a49c5d8bcc
15 changed files with 1107 additions and 229 deletions

View file

@ -23,36 +23,36 @@ export interface PostProps {
postId: number;
}
export default function Post({ postId }: PostProps) {
const [postContent, setPostContent] = useState<PostContent>();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchPost = async () => {
const response = await fetch(`http://localhost:8787/posts/${postId}/view`);
const jsonContent: Array<PostContent> = await response.json();
try {
// CHANGED: Fetch from your internal resource route
// This triggers the 'loader' we created in step 2
const response = await fetch(`/api/posts/${postId}`);
if (!response.ok) throw new Error("Failed to fetch");
let newPostContent: PostContent = jsonContent[0] as PostContent;
const jsonContent: Array<PostContent> = await response.json();
let newPostContent: PostContent = jsonContent[0] as PostContent;
if (newPostContent) {
setPostContent(newPostContent);
if (newPostContent) {
setPostContent(newPostContent);
}
} catch (error) {
console.error("Error fetching post:", error);
} finally {
setIsLoading(false);
}
};
fetchPost();
}, []);
}, [postId]); // Added postId to dependency array
if (isLoading) {
return <div>Loading...</div>;
}
if (isLoading) return <div>Loading...</div>;
if (!postContent) return <p>Failed to load!</p>;
if (!postContent) {
return <p>Failed to load!</p>;
}
return (
<StaticPost postContent={postContent} />
)
}
return <StaticPost postContent={postContent} />;
}

View file

@ -30,7 +30,12 @@ export function Layout({ children }: { children: React.ReactNode }) {
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<title>anum</title>
<meta property="og:title" content="anum" />
<meta property="og:type" content="website" />
<meta property="og:description" content="a modern forum made in the 90s' image" />
<meta property="og:locale" content="en_US" />
<meta property="og.locale:alternate" content="en_GB" />
<Links />
</head>
<body className="h-full">

View file

@ -4,5 +4,6 @@ export default [
index("routes/home.tsx"),
route("login", "routes/login.tsx"),
route("create", "routes/create.tsx"),
route("view", "routes/view.tsx")
route("view", "routes/view.tsx"),
route("api/posts/:postId", "routes/api.posts.$postId.ts")
] satisfies RouteConfig;

View file

@ -0,0 +1,16 @@
import { type LoaderFunctionArgs } from "react-router";
export async function loader({ params, context }: LoaderFunctionArgs) {
const { postId } = params;
// 'context.cloudflare.env' contains your bindings
const { API } = context.cloudflare.env;
// Use the Service Binding to talk to the backend
// This is much faster than a standard fetch over the internet
const response = await API.fetch(
`http://internal/posts/${postId}/view`
);
return response;
}

View file

@ -1,92 +1,90 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { Form, useActionData, useNavigate } from "react-router";
import { useLocation } from "react-router";
import { redirect, type ActionFunctionArgs } from "react-router";
// server-side action so we don't have to use clearnet for front-backend comms
// praise cloudflare
export async function action({ request, context }: ActionFunctionArgs) {
const formData = await request.formData();
const env = context.cloudflare.env as { API: Fetcher };
const postTitle = formData.get("title") as string;
const postContent = formData.get("content") as string;
const parentId = formData.get("parentId") as string;
// internal url uses ternary operator
const path = parentId
? `/posts/${parentId}/reply`
: "/posts/add";
const response = await env.API.fetch(new Request(`http://internal${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
// forward cookies or auth won't work
"Cookie": request.headers.get("Cookie") || "",
},
body: JSON.stringify({
title: postTitle,
content: postContent,
}),
}));
if (!response.ok) {
const error = await response.text();
return { error: `Failed to create post: ${response.status} - ${error}` };
}
return redirect("/");
}
export default function CreatePostPage() {
const location = useLocation();
const stateParentId = location.state?.parentId;
const [postTitle, setPostTitle] = useState("");
const [postContent, setPostContent] = useState("");
const [parentId, setParentId] = useState(stateParentId || "");
const navigate = useNavigate();
console.log(stateParentId);
console.log(parentId);
async function createPost() {
if (parentId) {
const response = await fetch(`http://localhost:8787/posts/${parentId}/reply`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: postTitle,
content: postContent // god bless HTTPS
}),
credentials: 'include'
});
if (!response.ok) {
throw new Error(`Got ${response.status} when trying to log in.`)
}
navigate("/");
} else {
const response = await fetch("http://localhost:8787/posts/add", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: postTitle,
content: postContent // god bless HTTPS
}),
credentials: 'include'
});
if (!response.ok) {
throw new Error(`Got ${response.status} when trying to log in.`)
}
navigate("/");
}
}
return (
<div className="w-[60vw] h-[50vh] flex flex-col absolute top-1/2 left-1/2 p-8 -translate-x-1/2 -translate-y-1/2 shadow-black/40 shadow-2xl rounded-2xl bg-[#171717] text-[#fafafa] transition ease-in-out duration-300">
<h1 className="text-2xl font-black text-center mb-4">Create Post</h1>
<div className="w-[92vw] md:w-[60vw] min-h-[60vh] md:min-h-0 md:h-[50vh] flex flex-col absolute top-1/2 left-1/2 p-4 md:p-8 -translate-x-1/2 -translate-y-1/2 shadow-black/40 shadow-2xl rounded-2xl bg-[#171717] text-[#fafafa] transition ease-in-out duration-300">
<h1 className="text-xl md:text-2xl font-black text-center mb-4">Create Post</h1>
<form className="flex-1 flex flex-col gap-4" action={createPost}>
<div className="flex gap-2 items-center">
<Form className="flex-1 flex flex-col gap-4" method="post">
<div className="flex flex-col md:flex-row gap-2 md:items-center">
<label htmlFor="title" className="px-1 font-semibold">Post Title</label>
<input
type="text"
id="title"
name="title"
className="bg-transparent border-[1px] border-white/40 rounded-xl p-2 focus:border-white outline-none flex-1 hover:border-white/60 transition ease-in-out duration-200"
onChange={(e) => setPostTitle(e.target.value)}
required
/>
</div>
<div className="flex-1 flex flex-col gap-2">
<label htmlFor="content" className="px-1 font-semibold">Post Contents</label>
<textarea
id="content"
className="flex-1 bg-transparent border-[1px] border-white/40 rounded-xl p-3 focus:border-white outline-none resize-none hover:border-white/60 transition ease-in-out duration-200"
onChange={(e) => setPostContent(e.target.value)}
name="content"
className="flex-1 min-h-[120px] md:min-h-0 bg-transparent border-[1px] border-white/40 rounded-xl p-3 focus:border-white outline-none resize-none hover:border-white/60 transition ease-in-out duration-200"
required
/>
</div>
<div className="flex gap-2 items-center">
<label htmlFor="title" className="px-1 font-semibold">Parent Post ID (leave empty if none)</label>
<div className="flex flex-col md:flex-row gap-2 md:items-center">
<label htmlFor="parentId" className="px-1 font-semibold">Parent Post ID (leave empty if none)</label>
<input
type="text"
id="title"
name="parentId"
className="bg-transparent border-[1px] border-white/40 rounded-xl p-2 focus:border-white outline-none hover:border-white/60 transition ease-in-out duration-200"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
/>
<button className="bg-white text-black font-bold rounded-xl p-2 ml-auto transition ease-in-out duration-300 hover:scale-125" role="submit">Create Post</button>
<button className="bg-white text-black font-bold rounded-xl p-2 md:ml-auto w-full md:w-auto transition ease-in-out duration-300 hover:scale-125" type="submit">Create Post</button>
</div>
</form>
</Form>
</div>
);
}

View file

@ -1,61 +1,35 @@
import type { Route } from "./+types/home";
import { useLoaderData } from "react-router";
import StaticPost from "../components/staticPost";
import { useState, useEffect } from "react";
import type { PostContent } from "../components/post";
export function meta({ }: Route.MetaArgs) {
return [
{ title: "New React Router App" },
{ name: "description", content: "Welcome to React Router!" },
];
}
export function loader({ context }: Route.LoaderArgs) {
return { message: context.cloudflare.env.VALUE_FROM_CLOUDFLARE };
// Server-side loader - runs in Cloudflare Worker
export async function loader({ context }: { context: { cloudflare: { env: { API: Fetcher } } } }) {
const env = context.cloudflare.env;
// Use service binding instead of fetch()
const response = await env.API.fetch(new Request("http://internal/feed"));
if (!response.ok) {
throw new Response("Failed to load feed", { status: response.status });
}
const feedContent: PostContent[] = await response.json();
return { feedContent };
}
// Client component - no useEffect needed!
export default function Home() {
const [feedContent, setFeedContent] = useState<Array<PostContent>>();
const [isLoading, setIsLoading] = useState(true);
const { feedContent } = useLoaderData<typeof loader>();
useEffect(() => {
const fetchFeed = async () => {
try {
const response = await fetch(`http://localhost:8787/feed`);
// check if response is actually OK (status 200-299)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const jsonContent: PostContent[] = await response.json();
setFeedContent(jsonContent);
} catch (e) {
console.error("Fetch failed:", e);
} finally {
setIsLoading(false);
}
};
fetchFeed();
}, []);
if (isLoading) {
return <div>Loading...</div>;
if (!feedContent || feedContent.length === 0) {
return <p>No posts found.</p>;
}
if (!feedContent) {
return <p>Failed to load!</p>;
}
return (
<>
{feedContent && feedContent.length > 0 ? (
feedContent.map((post) => {
return <StaticPost postContent={post} />
})
) : (
<p>No replies found for this thread.</p>
)}
{feedContent.map((post) => (
<StaticPost key={post.id} postContent={post} />
))}
</>
)
}
);
}

View file

@ -1,92 +1,146 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { Form, redirect, useActionData, type ActionFunctionArgs } from "react-router";
export async function action({ request, context }: ActionFunctionArgs) {
const formData = await request.formData();
const env = context.cloudflare.env as { API: Fetcher };
const mode = formData.get("mode"); // 'login' or 'register'
const email = formData.get("email");
const password = formData.get("password");
const name = formData.get("username");
const display_name = formData.get("displayName");
// Determine path based on the radio selection
const path = mode === "login" ? "/users/login" : "/users/create";
// Construct body based on mode
const body = mode === "login"
? { email, password }
: { email, password, name, display_name };
const response = await env.API.fetch(new Request(`http://internal${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Cookie": request.headers.get("Cookie") || "",
},
body: JSON.stringify(body),
}));
if (!response.ok) {
const errorText = await response.text();
return { error: `Error (${response.status}): ${errorText}` };
}
// Important: We proxy the 'Set-Cookie' header from the internal API
// back to the browser so the user actually gets logged in.
const headers = new Headers();
const setCookie = response.headers.get("Set-Cookie");
if (setCookie) {
headers.append("Set-Cookie", setCookie);
}
return redirect("/", { headers });
}
export default function LoginPage() {
const [loginRegisterSelection, setLoginRegisterSelection] = useState("option1");
const [userEmail, setUserEmail] = useState("");
const [userPassword, setUserPassword] = useState("");
const [userName, setUserName] = useState("");
const [userDisplayName, setUserDisplayName] = useState("");
const navigate = useNavigate();
async function loginOrRegister() {
if (loginRegisterSelection === "login") {
const response = await fetch("http://localhost:8787/users/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: userEmail,
password: userPassword // god bless HTTPS
}),
credentials: 'include'
});
if (!response.ok) {
throw new Error(`Got ${response.status} when trying to log in.`)
}
navigate("/");
} else {
const response = await fetch("http://localhost:8787/users/create", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: userEmail,
password: userPassword,
name: userName,
display_name: userDisplayName
}),
credentials: 'include'
});
if (!response.ok) {
throw new Error(`Got ${response.status} when trying to register.`)
}
navigate("/");
}
}
const [loginRegisterSelection, setLoginRegisterSelection] = useState("login");
const actionData = useActionData() as { error?: string } | undefined;
return (
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 p-5 shadow-none shadow-black/0 hover:shadow-black/40 hover:shadow-2xl rounded-2xl bg-[#151515] text-center transition ease-in-out duration-300 hover:ring-2 hover:ring-[#cccccc]">
<h1 className="text-2xl font-black">Login/Register</h1>
<form action={loginOrRegister}>
<div className="flex gap-4 justify-center">
<h1 className="text-2xl font-black mb-4">Login/Register</h1>
{actionData?.error && (
<div className="bg-red-500/20 border border-red-500 text-red-200 p-2 rounded-xl mb-4 text-sm">
{actionData.error}
</div>
)}
<Form method="post">
{/* We use a hidden input or the radio group to tell the action which mode we are in */}
<div className="flex gap-4 justify-center mb-4">
<div>
<input type="radio" id="loginOption" name="loginRegisterSelection" value="Login" checked={loginRegisterSelection === 'login'} onChange={() => setLoginRegisterSelection('login')} />
<label htmlFor="loginOption">Login</label>
<input
type="radio"
id="loginOption"
name="mode"
value="login"
checked={loginRegisterSelection === 'login'}
onChange={() => setLoginRegisterSelection('login')}
/>
<label htmlFor="loginOption" className="ml-2">Login</label>
</div>
<div>
<input type="radio" id="registerOption" name="loginRegisterSelection" value="Register" checked={loginRegisterSelection === 'register'} onChange={() => setLoginRegisterSelection('register')} />
<label htmlFor="registerOption">Register</label>
<input
type="radio"
id="registerOption"
name="mode"
value="register"
checked={loginRegisterSelection === 'register'}
onChange={() => setLoginRegisterSelection('register')}
/>
<label htmlFor="registerOption" className="ml-2">Register</label>
</div>
</div>
<div className="flex m-2 my-3">
<label htmlFor="email" className="mx-4">Email</label>
<input type="text" id="email" className="border-[1px] border-white rounded-xl text-center" onChange={(e) => setUserEmail(e.target.value)} />
</div>
<div className="flex m-2 my-3">
<label htmlFor="password" className="mx-4">Password</label>
<input type="text" id="password" className="border-[1px] border-white rounded-xl text-center" onChange={(e) => setUserPassword(e.target.value)} />
</div>
{loginRegisterSelection === 'register' &&
<>
<div className="flex m-2 my-3">
<label htmlFor="username" className="mx-4">Username</label>
<input type="text" id="username" className="border-[1px] border-white rounded-xl text-center" onChange={(e) => setUserName(e.target.value)} />
</div>
<div className="flex m-2 my-3">
<label htmlFor="displayName" className="mx-4">Display name</label>
<input type="text" id="displayName" className="border-[1px] border-white rounded-xl text-center" onChange={(e) => setUserDisplayName(e.target.value)} />
</div>
</>
}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<label htmlFor="email" className="mx-4">Email</label>
<input
name="email"
type="email"
id="email"
required
className="bg-transparent border-[1px] border-white/40 rounded-xl text-center p-1 focus:border-white outline-none"
/>
</div>
<div className="flex items-center justify-between">
<label htmlFor="password" className="mx-4">Password</label>
<input
name="password"
type="password"
id="password"
required
className="bg-transparent border-[1px] border-white/40 rounded-xl text-center p-1 focus:border-white outline-none"
/>
</div>
<button className="bg-white text-black font-bold rounded-xl p-2" role="submit">login/register</button>
</form>
{loginRegisterSelection === 'register' && (
<>
<div className="flex items-center justify-between">
<label htmlFor="username" className="mx-4">Username</label>
<input
name="username"
type="text"
id="username"
required
className="bg-transparent border-[1px] border-white/40 rounded-xl text-center p-1 focus:border-white outline-none"
/>
</div>
<div className="flex items-center justify-between">
<label htmlFor="displayName" className="mx-4">Display Name</label>
<input
name="displayName"
type="text"
id="displayName"
required
className="bg-transparent border-[1px] border-white/40 rounded-xl text-center p-1 focus:border-white outline-none"
/>
</div>
</>
)}
</div>
<button
type="submit"
className="mt-6 bg-white text-black font-bold rounded-xl p-2 px-6 hover:scale-105 transition-transform"
>
{loginRegisterSelection === 'login' ? 'Login' : 'Register'}
</button>
</Form>
</div>
)
}
);
}

View file

@ -1,4 +1,4 @@
import Post from '../components/post.tsx';
import Post from '../components/post';
import { useLocation } from "react-router";
export default function ViewPost() {

26
app/util/mobile-opt.tsx Normal file
View file

@ -0,0 +1,26 @@
const isMobile = () => {
const info =
navigator.userAgent ||
navigator.vendor ||
(window as Window & { opera?: string }).opera ||
"";
return (
/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(
info,
) ||
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw-(n|u)|c55\/|capi|ccwa|cdm-|cell|chtm|cldc|cmd-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc-s|devi|dica|dmob|do(c|p)o|ds(12|-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(-|_)|g1 u|g560|gene|gf-5|g-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd-(m|p|t)|hei-|hi(pt|ta)|hp( i|ip)|hs-c|ht(c(-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i-(20|go|ma)|i230|iac( |-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|-[a-w])|libw|lynx|m1-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|-([1-8]|c))|phil|pire|pl(ay|uc)|pn-2|po(ck|rt|se)|prox|psio|pt-g|qa-a|qc(07|12|21|32|60|-[2-7]|i-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h-|oo|p-)|sdk\/|se(c(-|0|1)|47|mc|nd|ri)|sgh-|shar|sie(-|m)|sk-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h-|v-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl-|tdg-|tel(i|m)|tim-|t-mo|to(pl|sh)|ts(70|m-|m3|m5)|tx-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas-|your|zeto|zte-/i.test(
info.substr(0, 4),
)
);
};
interface Props {
children?: React.ReactNode;
}
export const DesktopBrowser: React.FC<Props> = ({ children }) =>
!isMobile() && children;
export const MobileBrowser: React.FC<Props> = ({ children }) =>
isMobile() && children;

View file

@ -12,6 +12,7 @@
"typecheck": "npm run cf-typegen && react-router typegen && tsc -b"
},
"dependencies": {
"@tanstack/react-start": "^1.159.0",
"isbot": "^5.1.31",
"react": "^19.1.1",
"react-dom": "^19.1.1",

837
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View file

@ -17,7 +17,6 @@
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"baseUrl": ".",
"rootDirs": [".", "./.react-router/types"],
"paths": {
"~/*": ["./app/*"]

View file

@ -3,10 +3,12 @@ import { cloudflare } from "@cloudflare/vite-plugin";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { cloudflareDevProxy } from "@react-router/dev/vite/cloudflare";
export default defineConfig({
plugins: [
cloudflare({ viteEnvironment: { name: "ssr" } }),
cloudflareDevProxy(),
tailwindcss(),
reactRouter(),
tsconfigPaths(),

View file

@ -7,12 +7,9 @@
"name": "anum-frontend",
"compatibility_date": "2025-04-04",
"main": "./workers/app.ts",
"vars": {
"VALUE_FROM_CLOUDFLARE": "Hello from Cloudflare"
},
"observability": {
"enabled": true
}
},
/**
* Smart Placement
* https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
@ -34,4 +31,10 @@
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/
// "services": [ { "binding": "MY_SERVICE", "service": "my-service" } ]
}
"services": [
{
"binding": "API",
"service": "anum-backend",
}
]
}