Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions apps/api/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
17 changes: 17 additions & 0 deletions apps/web/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Check Instagram hashtags against a curated list of banned and restricted tags."
/>
<title>CleanHashtags</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
30 changes: 30 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
1 change: 1 addition & 0 deletions apps/web/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 26 additions & 0 deletions apps/web/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<App />);

expect(
screen.getByRole("heading", { level: 1, name: "CleanHashtags" }),
).toBeInTheDocument();
});

it("acknowledges a submitted list", async () => {
const user = userEvent.setup();
render(<App />);

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.",
);
});
});
29 changes: 29 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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<string[] | null>(null);

return (
<>
<Header
title="CleanHashtags"
tagline="Check your Instagram hashtags before you post."
/>
<main>
<HashtagForm onSubmit={setSubmitted} />
{submitted && (
<p role="status">
Ready to check {submitted.length}{" "}
{submitted.length === 1 ? "hashtag" : "hashtags"}. Checking against
the list is coming soon.
</p>
)}
</main>
</>
);
}
90 changes: 90 additions & 0 deletions apps/web/src/components/HashtagForm.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<HashtagForm onSubmit={onSubmit} />);
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"]);
});
});
85 changes: 85 additions & 0 deletions apps/web/src/components/HashtagForm.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLFormElement>) {
// Stop the browser from doing a full-page form submission.
event.preventDefault();
if (canSubmit) onSubmit(tags);
}

return (
<form onSubmit={handleSubmit}>
<label htmlFor={id}>Hashtags</label>
{/* Controlled input: React owns the value, onChange reports each edit. */}
<textarea
id={id}
value={text}
onChange={(event) => setText(event.target.value)}
rows={5}
placeholder="#travel #sunset #foodie"
aria-describedby={`${hintId} ${counterId}`}
aria-invalid={overLimit}
/>
<p id={hintId}>
Separate hashtags with spaces, commas or new lines. The # is optional.
</p>

<p id={counterId} aria-live="polite">
{tags.length}/{MAX_TAGS} hashtags
{overLimit && ` (remove ${tags.length - MAX_TAGS} to check)`}
</p>

{tags.length > 0 && (
<ul aria-label="Hashtags to check">
{/* Each tag is unique after parsing, so it works as a stable key. */}
{tags.map((tag) => (
<li key={tag}>#{tag}</li>
))}
</ul>
)}

{invalid.length > 0 && (
<div>
<p>
{invalid.length === 1
? "This is not a valid hashtag and will be skipped:"
: "These are not valid hashtags and will be skipped:"}
</p>
<ul aria-label="Invalid hashtags">
{invalid.map((token) => (
<li key={token}>{token}</li>
))}
</ul>
<p>
Hashtags use letters, numbers and _ only, and cannot be all numbers.
</p>
</div>
)}

<button type="submit" disabled={!canSubmit}>
Check hashtags
</button>
</form>
);
}
17 changes: 17 additions & 0 deletions apps/web/src/components/Header.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Header } from "./Header";

describe("Header", () => {
it("renders the tagline when given", () => {
render(<Header title="Title" tagline="A tagline" />);

expect(screen.getByText("A tagline")).toBeInTheDocument();
});

it("omits the tagline when not given", () => {
const { container } = render(<Header title="Title" />);

expect(container.querySelector("p")).toBeNull();
});
});
16 changes: 16 additions & 0 deletions apps/web/src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Props are a component's inputs. Declaring them as a TypeScript type means
// <Header /> without a title is a compile error, not a runtime surprise.
type HeaderProps = {
title: string;
tagline?: string;
};

export function Header({ title, tagline }: HeaderProps) {
return (
<header>
<h1>{title}</h1>
{/* `&&` renders the paragraph only when a tagline was passed. */}
{tagline && <p>{tagline}</p>}
</header>
);
}
18 changes: 18 additions & 0 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";

// Entry point: Vite loads this file from index.html. createRoot takes over the
// <div id="root"> element and React renders the component tree inside it.
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Missing #root element in index.html");
}

// StrictMode only affects development: it double-invokes some functions to
// surface bugs such as side effects during rendering.
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
);
7 changes: 7 additions & 0 deletions apps/web/src/test/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";

afterEach(() => {
cleanup();
});
9 changes: 9 additions & 0 deletions apps/web/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client", "@testing-library/jest-dom/vitest"]
},
"include": ["src", "vite.config.ts"]
}
16 changes: 16 additions & 0 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";

export default defineConfig({
plugins: [react()],
server: {
proxy: {
// `wrangler dev` (apps/api) listens on 8787.
"/api": "http://localhost:8787",
},
},
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
},
});
Loading
Loading