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
69 changes: 27 additions & 42 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,78 +1,63 @@
import type { CheckResponse } from "@cleanhashtags/shared";
import { useState } from "react";
import { checkHashtags, errorMessage } from "./api/client";
import { Footer } from "./components/Footer";
import { HashtagForm } from "./components/HashtagForm";
import { Header } from "./components/Header";
import { ResultsList } from "./components/ResultsList";

// A discriminated union: `status` says which of the four situations we are in,
// and TypeScript only lets us read `response` or `message` when they exist.
type CheckState =
| { status: "idle" }
| { status: "loading"; count: number }
| { status: "success"; response: CheckResponse }
| { status: "error"; message: string };
import { useHashtagCheck } from "./hooks/useHashtagCheck";

// 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() {
// Lifted state: the textarea's text used to live inside HashtagForm. Reset
// (in ResultsList) must clear it, so it moves up to the closest common
// parent of both components and is passed down as props.
// Lifted state: Reset (in ResultsList) must clear the textarea, so the text
// lives here, in the closest common parent, and is passed down as props.
const [text, setText] = useState("");
const [check, setCheck] = useState<CheckState>({ status: "idle" });

// The request starts because the user submitted the form, so it lives in the
// event handler. No useEffect needed: nothing has to "sync" after rendering.
async function runCheck(tags: string[]) {
setCheck({ status: "loading", count: tags.length });
try {
const response = await checkHashtags(tags);
setCheck({ status: "success", response });
} catch (error) {
setCheck({ status: "error", message: errorMessage(error) });
}
}
// All request state (loading, results, errors) now lives in a custom hook.
const { state, check, reset } = useHashtagCheck();

function reset() {
function handleReset() {
setText("");
setCheck({ status: "idle" });
reset();
}

return (
<>
<div className="page">
<Header
title="CleanHashtags"
tagline="Check your Instagram hashtags before you post."
/>
<main>
<main className="stack">
<HashtagForm
text={text}
onTextChange={setText}
// `void` marks the returned promise as deliberately not awaited;
// runCheck handles its own errors.
onSubmit={(tags) => void runCheck(tags)}
busy={check.status === "loading"}
// `check` handles its own errors.
onSubmit={(tags) => void check(tags)}
busy={state.status === "loading"}
/>

{check.status === "loading" && (
<p role="status">
Checking {check.count} {check.count === 1 ? "hashtag" : "hashtags"}…
{state.status === "loading" && (
<p role="status" className="notice">
Checking {state.count} {state.count === 1 ? "hashtag" : "hashtags"}…
</p>
)}

{check.status === "error" && <p role="alert">{check.message}</p>}
{state.status === "error" && (
<p role="alert" className="notice notice-error">
{state.message}
</p>
)}

{check.status === "success" && (
{state.status === "success" && (
// A new key per response makes React start ResultsList fresh, so
// its local "Copied" message does not carry over to the next check.
<ResultsList
key={check.response.checkedAt}
response={check.response}
onReset={reset}
key={state.response.checkedAt}
response={state.response}
onReset={handleReset}
/>
)}
</main>
</>
<Footer />
</div>
);
}
13 changes: 13 additions & 0 deletions apps/web/src/components/Footer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Footer } from "./Footer";

describe("Footer", () => {
it("explains how fresh and reliable verdicts are", () => {
render(<Footer />);

const footer = screen.getByRole("contentinfo");
expect(footer).toHaveTextContent(/can be out of date/);
expect(footer).toHaveTextContent(/not that it is guaranteed safe/);
});
});
14 changes: 14 additions & 0 deletions apps/web/src/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export function Footer() {
return (
<footer className="footer">
<p>
Results come from a curated list of community reports and manual checks.
Instagram does not publish which hashtags it limits, so any result can
be out of date: check the &ldquo;verified&rdquo; date.
&ldquo;Unknown&rdquo; means a hashtag is not in the list, not that it is
guaranteed safe.
</p>
<p>Not affiliated with Instagram or Meta.</p>
</footer>
);
}
35 changes: 25 additions & 10 deletions apps/web/src/components/HashtagForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,13 @@ export function HashtagForm({
}

return (
<form onSubmit={handleSubmit}>
<label htmlFor={id}>Hashtags</label>
<form className="card stack" onSubmit={handleSubmit}>
<label className="label" htmlFor={id}>
Hashtags
</label>
{/* Controlled input: React owns the value, onChange reports each edit. */}
<textarea
className="input"
id={id}
value={text}
onChange={(event) => onTextChange(event.target.value)}
Expand All @@ -48,34 +51,42 @@ export function HashtagForm({
aria-describedby={`${hintId} ${counterId}`}
aria-invalid={overLimit}
/>
<p id={hintId}>
<p className="hint" id={hintId}>
Separate hashtags with spaces, commas or new lines. The # is optional.
</p>

<p id={counterId} aria-live="polite">
<p
id={counterId}
className={overLimit ? "hint hint-error" : "hint"}
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">
<ul className="chips" 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>
<li key={tag} className="chip">
#{tag}
</li>
))}
</ul>
)}

{invalid.length > 0 && (
<div>
<div className="invalid">
<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">
<ul className="chips" aria-label="Invalid hashtags">
{invalid.map((token) => (
<li key={token}>{token}</li>
<li key={token} className="chip chip-invalid">
{token}
</li>
))}
</ul>
<p>
Expand All @@ -84,7 +95,11 @@ export function HashtagForm({
</div>
)}

<button type="submit" disabled={!canSubmit}>
<button
className="button button-primary"
type="submit"
disabled={!canSubmit}
>
{busy ? "Checking…" : "Check hashtags"}
</button>
</form>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ type HeaderProps = {

export function Header({ title, tagline }: HeaderProps) {
return (
<header>
<header className="header">
<h1>{title}</h1>
{/* `&&` renders the paragraph only when a tagline was passed. */}
{tagline && <p>{tagline}</p>}
Expand Down
24 changes: 14 additions & 10 deletions apps/web/src/components/ResultsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ export function ResultsList({ response, onReset }: ResultsListProps) {
}

return (
<section aria-labelledby="results-heading">
<section className="card stack" aria-labelledby="results-heading">
<h2 id="results-heading">Results</h2>

<p>
<p className="summary">
Checked {results.length} {results.length === 1 ? "hashtag" : "hashtags"}
:{" "}
{counts
Expand All @@ -54,30 +54,33 @@ export function ResultsList({ response, onReset }: ResultsListProps) {
.
</p>

<ul aria-label="Results">
<ul className="results" aria-label="Results">
{/* key: the hashtag is unique within one response and stable. */}
{results.map((result) => {
const fresh = freshness(result, checkedAt);
return (
<li key={result.hashtag}>
<strong>#{result.hashtag}</strong>{" "}
<li key={result.hashtag} className="result">
<strong className="result-tag">#{result.hashtag}</strong>{" "}
<StatusBadge status={result.status} />
{result.source && <span> Source: {result.source}.</span>}
{fresh && <span> {fresh}.</span>}
<span className="result-meta">
{result.source && <span> Source: {result.source}.</span>}
{fresh && <span> {fresh}.</span>}
</span>
</li>
);
})}
</ul>

<div>
<div className="actions">
<button
className="button button-primary"
type="button"
onClick={() => void handleCopy()}
disabled={safeTags.length === 0}
>
Copy safe hashtags ({safeTags.length})
</button>{" "}
<button type="button" onClick={onReset}>
<button className="button" type="button" onClick={onReset}>
Reset
</button>
</div>
Expand All @@ -89,12 +92,13 @@ export function ResultsList({ response, onReset }: ResultsListProps) {
</p>
{copyState === "failed" && (
<textarea
className="input"
aria-label="Safe hashtags"
readOnly
value={safeTags.join(" ")}
/>
)}
<p>
<p className="hint">
Safe means Clear or Unknown. Unknown hashtags are not in our list, which
is not a guarantee.
</p>
Expand Down
22 changes: 2 additions & 20 deletions apps/web/src/components/StatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,12 @@
import type { HashtagStatus } from "@cleanhashtags/shared";
import type { CSSProperties } from "react";
import { STATUS_INFO } from "../lib/status";

// Inline styles are plain objects. Fine for a few values; step 14 moves
// styling into a stylesheet.
const COLOURS: Record<HashtagStatus, CSSProperties> = {
banned: { background: "#fde2e1", color: "#8a1c14" },
restricted: { background: "#fdeccf", color: "#7a4500" },
reported: { background: "#fff6c7", color: "#5c4a00" },
clear: { background: "#dcf5e3", color: "#14532d" },
unknown: { background: "#e8eaee", color: "#33373d" },
};

// The label is always written out, so the status never relies on colour alone.
// The colours come from `.badge-<status>` in styles.css.
export function StatusBadge({ status }: { status: HashtagStatus }) {
const { label, description } = STATUS_INFO[status];
return (
<span
title={description}
style={{
...COLOURS[status],
borderRadius: "999px",
padding: "0.1em 0.6em",
fontWeight: 600,
}}
>
<span className={`badge badge-${status}`} title={description}>
{label}
</span>
);
Expand Down
Loading
Loading