diff --git a/apps/api/package.json b/apps/api/package.json index f7a1d5e..5224a5a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "wrangler dev", + "dev": "node -e \"require('node:fs').mkdirSync('../web/dist', { recursive: true })\" && wrangler dev", "cf-typegen": "wrangler types", "postinstall": "wrangler types", "db:migrate:local": "wrangler d1 migrations apply cleanhashtags --local", diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index 5535eff..61802e9 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -3,6 +3,13 @@ "name": "cleanhashtags", "main": "src/index.ts", "compatibility_date": "2026-10-10", + // The React app (apps/web) is built to ../web/dist and served as static assets. + // Asset requests are free on the Workers plan; only /api/* invokes the Worker. + "assets": { + "directory": "../web/dist", + "not_found_handling": "single-page-application", + "run_worker_first": ["/api/*"], + }, "observability": { "enabled": true, }, diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..26b618a --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,17 @@ + + + + + + + + CleanHashtags + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..69074ac --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,30 @@ +{ + "name": "@cleanhashtags/web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@cleanhashtags/shared": "workspace:*", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.2", + "jsdom": "^30.1.2", + "vite": "^8.3.4", + "vitest": "^5.0.3" + } +} diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..2d273d1 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx new file mode 100644 index 0000000..a66edc1 --- /dev/null +++ b/apps/web/src/App.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { App } from "./App"; + +describe("App", () => { + it("renders the main heading", () => { + render(); + + expect( + screen.getByRole("heading", { level: 1, name: "CleanHashtags" }), + ).toBeInTheDocument(); + }); + + it("acknowledges a submitted list", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Hashtags"), "travel sunset"); + await user.click(screen.getByRole("button", { name: "Check hashtags" })); + + expect(screen.getByRole("status")).toHaveTextContent( + "Ready to check 2 hashtags.", + ); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..ae820a6 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,29 @@ +import { useState } from "react"; +import { Header } from "./components/Header"; +import { HashtagForm } from "./components/HashtagForm"; + +// A component is a function that returns JSX. React calls it to find out what +// to show; the capitalised name is how JSX tells components from HTML tags. +export function App() { + // Placeholder until the form calls the API: remember what was submitted. + const [submitted, setSubmitted] = useState(null); + + return ( + <> +
+
+ + {submitted && ( +

+ Ready to check {submitted.length}{" "} + {submitted.length === 1 ? "hashtag" : "hashtags"}. Checking against + the list is coming soon. +

+ )} +
+ + ); +} diff --git a/apps/web/src/components/HashtagForm.test.tsx b/apps/web/src/components/HashtagForm.test.tsx new file mode 100644 index 0000000..dfdab57 --- /dev/null +++ b/apps/web/src/components/HashtagForm.test.tsx @@ -0,0 +1,90 @@ +import { MAX_TAGS } from "@cleanhashtags/shared"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { HashtagForm } from "./HashtagForm"; + +function setup() { + const onSubmit = vi.fn<(tags: string[]) => void>(); + const user = userEvent.setup(); + render(); + return { + onSubmit, + user, + textarea: screen.getByLabelText("Hashtags"), + submit: screen.getByRole("button", { name: "Check hashtags" }), + }; +} + +describe("HashtagForm", () => { + it("starts empty with submit disabled", () => { + const { textarea, submit } = setup(); + + expect(textarea).toHaveValue(""); + expect(screen.getByText(`0/${MAX_TAGS} hashtags`)).toBeInTheDocument(); + expect(submit).toBeDisabled(); + }); + + it("shows parsed, deduplicated tags as you type", async () => { + const { user, textarea, submit } = setup(); + + await user.type(textarea, "#Travel, sunset travel"); + + const chips = within( + screen.getByRole("list", { name: "Hashtags to check" }), + ); + expect(chips.getAllByRole("listitem").map((li) => li.textContent)).toEqual([ + "#travel", + "#sunset", + ]); + expect(screen.getByText(`2/${MAX_TAGS} hashtags`)).toBeInTheDocument(); + expect(submit).toBeEnabled(); + }); + + it("lists invalid tags as hints without counting them", async () => { + const { user, textarea } = setup(); + + await user.type(textarea, "ok 1234 bad-tag"); + + const invalid = within( + screen.getByRole("list", { name: "Invalid hashtags" }), + ); + expect( + invalid.getAllByRole("listitem").map((li) => li.textContent), + ).toEqual(["1234", "bad-tag"]); + expect(screen.getByText(`1/${MAX_TAGS} hashtags`)).toBeInTheDocument(); + }); + + it("keeps submit disabled when only invalid tags are entered", async () => { + const { user, textarea, submit } = setup(); + + await user.type(textarea, "123 !!!"); + + expect(submit).toBeDisabled(); + }); + + it("disables submit over the tag limit", async () => { + const { user, textarea, submit } = setup(); + const tooMany = Array.from({ length: MAX_TAGS + 2 }, (_, i) => `tag${i}`); + + await user.click(textarea); + await user.paste(tooMany.join(" ")); + + expect( + screen.getByText( + `${MAX_TAGS + 2}/${MAX_TAGS} hashtags (remove 2 to check)`, + ), + ).toBeInTheDocument(); + expect(textarea).toHaveAttribute("aria-invalid", "true"); + expect(submit).toBeDisabled(); + }); + + it("submits the parsed tags", async () => { + const { user, textarea, submit, onSubmit } = setup(); + + await user.type(textarea, "#one{enter}#two #one"); + await user.click(submit); + + expect(onSubmit).toHaveBeenCalledExactlyOnceWith(["one", "two"]); + }); +}); diff --git a/apps/web/src/components/HashtagForm.tsx b/apps/web/src/components/HashtagForm.tsx new file mode 100644 index 0000000..137928b --- /dev/null +++ b/apps/web/src/components/HashtagForm.tsx @@ -0,0 +1,85 @@ +import { MAX_TAGS, parseHashtags } from "@cleanhashtags/shared"; +import { useId, useState } from "react"; +import type { FormEvent } from "react"; + +type HashtagFormProps = { + onSubmit: (tags: string[]) => void; +}; + +export function HashtagForm({ onSubmit }: HashtagFormProps) { + // The only stored state is what the user typed. React re-renders this + // component every time setText is called with a new value. + const [text, setText] = useState(""); + + // Everything below is derived from `text` on each render instead of being + // kept in separate state, so it can never get out of sync with the textarea. + const { tags, invalid } = parseHashtags(text); + const overLimit = tags.length > MAX_TAGS; + const canSubmit = tags.length > 0 && !overLimit; + + // useId gives stable unique ids to connect the label and hints to the textarea. + const id = useId(); + const hintId = `${id}-hint`; + const counterId = `${id}-counter`; + + function handleSubmit(event: FormEvent) { + // Stop the browser from doing a full-page form submission. + event.preventDefault(); + if (canSubmit) onSubmit(tags); + } + + return ( +
+ + {/* Controlled input: React owns the value, onChange reports each edit. */} +