feat: improve mobile UX (#155)

This commit is contained in:
Ben Senescu 2026-05-06 12:52:05 -04:00 committed by GitHub
parent b9ad31704f
commit ca260f544b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 120 additions and 191 deletions

View File

@ -67,6 +67,7 @@ export function makeSelectionColumn<TData>(
className="checkbox checkbox-xs [--radius-selector:0.25rem]" className="checkbox checkbox-xs [--radius-selector:0.25rem]"
checked={table.getIsAllRowsSelected()} checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()} onChange={table.getToggleAllRowsSelectedHandler()}
aria-label="Select all rows"
/> />
), ),
cell: ({ row, table }) => ( cell: ({ row, table }) => (
@ -90,6 +91,7 @@ function SelectionCheckbox<TData>({
type="checkbox" type="checkbox"
className="checkbox checkbox-xs [--radius-selector:0.25rem]" className="checkbox checkbox-xs [--radius-selector:0.25rem]"
checked={row.getIsSelected()} checked={row.getIsSelected()}
aria-label="Select row"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
rangeHandledRef.current = applyShiftRangeSelection( rangeHandledRef.current = applyShiftRangeSelection(

View File

@ -34,12 +34,12 @@ export function DomainSearchCard({
return ( return (
<label <label
className={`input input-bordered flex flex-1 items-center gap-2 ${domainError ? "input-error" : ""}`} className={`input input-bordered flex items-center gap-2 w-full lg:flex-1 lg:min-w-0 lg:max-w-md ${domainError ? "input-error" : ""}`}
> >
<Search className="size-4 text-base-content/60" /> <Search className="size-4 text-base-content/60" />
<input <input
className="grow" className="grow min-w-0"
placeholder="Enter a domain (e.g. coolify.io or example.com/blog)" placeholder="Enter a domain"
value={field.state.value} value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)} onChange={(event) => field.handleChange(event.target.value)}
aria-invalid={domainError ? true : undefined} aria-invalid={domainError ? true : undefined}

View File

@ -14,7 +14,7 @@ export function getDomainSearchValidationErrors(value: DomainControlsValues) {
if (!normalizeDomainTarget(value.domain)) { if (!normalizeDomainTarget(value.domain)) {
return createFormValidationErrors({ return createFormValidationErrors({
fields: { fields: {
domain: "Please enter a valid URL or domain (e.g. browserbase.com)", domain: "Please enter a valid URL or domain (e.g. example.com)",
}, },
}); });
} }

View File

@ -47,71 +47,6 @@ export function OverviewStats({ keyword }: { keyword: KeywordResearchRow }) {
); );
} }
export function KeywordCard({
row,
isSelected,
isActive,
onToggle,
onClick,
}: {
row: KeywordResearchRow;
isSelected: boolean;
isActive: boolean;
onToggle: () => void;
onClick: () => void;
}) {
return (
<div
className={`bg-base-100 border border-base-300 rounded-lg p-3 space-y-2 cursor-pointer transition-colors ${
isActive ? "border-primary bg-primary/5" : "hover:bg-base-200/50"
}`}
onClick={onClick}
>
<div className="flex items-start gap-2">
<input
type="checkbox"
className="checkbox checkbox-sm shrink-0 mt-0.5"
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
onToggle();
}}
onClick={(e) => e.stopPropagation()}
/>
<span className="flex-1 font-semibold text-sm capitalize leading-tight">
{row.keyword}
</span>
<ScoreBadge value={row.keywordDifficulty} size="sm" />
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="text-center">
<p className="text-base-content/50">Volume</p>
<p className="font-medium tabular-nums">
{formatNumber(row.searchVolume)}
</p>
</div>
<div className="text-center">
<p className="text-base-content/50">CPC</p>
<p className="font-medium tabular-nums">
{row.cpc == null ? "-" : `$${row.cpc.toFixed(2)}`}
</p>
</div>
<div className="text-center">
<p className="text-base-content/50">Comp.</p>
<p className="font-medium tabular-nums">
{row.competition == null ? "-" : row.competition.toFixed(2)}
</p>
</div>
</div>
<div className="flex items-center justify-between pt-1">
<IntentBadge intent={row.intent} />
</div>
</div>
);
}
function ScoreBadge({ function ScoreBadge({
value, value,
size = "sm", size = "sm",

View File

@ -4,7 +4,11 @@ function getNextSelectionSet(
current: Set<string>, current: Set<string>,
allVisibleKeywords: string[], allVisibleKeywords: string[],
): Set<string> { ): Set<string> {
if (current.size === allVisibleKeywords.length) { const allVisibleSelected =
allVisibleKeywords.length > 0 &&
allVisibleKeywords.every((keyword) => current.has(keyword));
if (allVisibleSelected) {
return new Set(); return new Set();
} }

View File

@ -16,7 +16,7 @@ import {
type SortDir, type SortDir,
type SortField, type SortField,
} from "@/client/features/keywords/components"; } from "@/client/features/keywords/components";
import { formatNumber } from "@/client/features/keywords/utils"; import { formatNumber, scoreTierClass } from "@/client/features/keywords/utils";
import type { KeywordResearchRow } from "@/types/keywords"; import type { KeywordResearchRow } from "@/types/keywords";
import { EmptyFilterResults } from "./keywordResearchDesktopFilters"; import { EmptyFilterResults } from "./keywordResearchDesktopFilters";
@ -66,18 +66,21 @@ export function KeywordResearchDesktopTable({
current={sortField} current={sortField}
dir={sortDir} dir={sortDir}
onToggle={toggleSort} onToggle={toggleSort}
className="min-w-0" className="min-w-48 md:min-w-0"
/> />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span <span
className="block truncate font-medium capitalize" className="block min-w-48 whitespace-normal break-words font-medium capitalize md:min-w-0 md:truncate"
title={row.original.keyword} title={row.original.keyword}
> >
{row.original.keyword} {row.original.keyword}
</span> </span>
), ),
meta: { cellClassName: "min-w-0" }, meta: {
headerClassName: "min-w-48 md:min-w-0",
cellClassName: "min-w-48 md:min-w-0",
},
}), }),
keywordColumnHelper.accessor("searchVolume", { keywordColumnHelper.accessor("searchVolume", {
header: () => ( header: () => (
@ -93,7 +96,8 @@ export function KeywordResearchDesktopTable({
cell: ({ getValue }) => formatNumber(getValue()), cell: ({ getValue }) => formatNumber(getValue()),
meta: { meta: {
headerClassName: "text-right", headerClassName: "text-right",
cellClassName: "text-right tabular-nums text-base-content/70", cellClassName:
"whitespace-nowrap text-right tabular-nums text-base-content/70",
}, },
}), }),
keywordColumnHelper.accessor("cpc", { keywordColumnHelper.accessor("cpc", {
@ -114,7 +118,8 @@ export function KeywordResearchDesktopTable({
}, },
meta: { meta: {
headerClassName: "text-right", headerClassName: "text-right",
cellClassName: "text-right tabular-nums text-base-content/70", cellClassName:
"whitespace-nowrap text-right tabular-nums text-base-content/70",
}, },
}), }),
keywordColumnHelper.accessor("competition", { keywordColumnHelper.accessor("competition", {
@ -135,7 +140,8 @@ export function KeywordResearchDesktopTable({
}, },
meta: { meta: {
headerClassName: "text-right", headerClassName: "text-right",
cellClassName: "text-right tabular-nums text-base-content/70", cellClassName:
"whitespace-nowrap text-right tabular-nums text-base-content/70",
}, },
}), }),
keywordColumnHelper.accessor("keywordDifficulty", { keywordColumnHelper.accessor("keywordDifficulty", {
@ -158,7 +164,7 @@ export function KeywordResearchDesktopTable({
cell: ({ getValue }) => <IntentBadge intent={getValue()} />, cell: ({ getValue }) => <IntentBadge intent={getValue()} />,
meta: { meta: {
headerClassName: "text-center", headerClassName: "text-center",
cellClassName: "text-center", cellClassName: "whitespace-nowrap text-center",
}, },
}), }),
], ],
@ -184,7 +190,7 @@ export function KeywordResearchDesktopTable({
}); });
return ( return (
<div className="flex-1 overflow-y-auto"> <div className="flex-1 min-h-0">
{filteredRows.length === 0 ? ( {filteredRows.length === 0 ? (
<EmptyFilterResults <EmptyFilterResults
activeFilterCount={activeFilterCount} activeFilterCount={activeFilterCount}
@ -193,8 +199,8 @@ export function KeywordResearchDesktopTable({
) : ( ) : (
<AppDataTable <AppDataTable
table={table} table={table}
className="table table-xs w-full" className="table table-xs min-w-max md:w-full"
wrapperClassName="h-full overflow-y-auto" wrapperClassName="h-full overflow-auto"
getRowProps={(row) => ({ getRowProps={(row) => ({
className: `cursor-pointer border-b border-base-200 hover:bg-base-200/50 ${ className: `cursor-pointer border-b border-base-200 hover:bg-base-200/50 ${
overviewKeyword?.keyword === row.original.keyword overviewKeyword?.keyword === row.original.keyword
@ -211,12 +217,10 @@ export function KeywordResearchDesktopTable({
function ScoreCell({ value }: { value: number | null }) { function ScoreCell({ value }: { value: number | null }) {
if (value == null) return null; if (value == null) return null;
let tierClass = "bg-success/20 text-success"; const tierClass = scoreTierClass(value);
if (value > 60) tierClass = "bg-error/20 text-error";
else if (value > 30) tierClass = "bg-warning/20 text-warning";
return ( return (
<span <span
className={`inline-flex size-6 items-center justify-center rounded-full text-[10px] font-semibold ${tierClass}`} className={`score-badge ${tierClass} inline-flex size-6 items-center justify-center rounded-full text-[10px] font-semibold`}
> >
{value} {value}
</span> </span>

View File

@ -9,10 +9,8 @@ import {
} from "lucide-react"; } from "lucide-react";
import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions"; import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions";
import { exportTableToSheets } from "@/client/lib/exportToSheets"; import { exportTableToSheets } from "@/client/lib/exportToSheets";
import { import { SerpAnalysisCard } from "@/client/features/keywords/components";
KeywordCard, import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable";
SerpAnalysisCard,
} from "@/client/features/keywords/components";
import type { KeywordResearchControllerState } from "./types"; import type { KeywordResearchControllerState } from "./types";
type Props = { type Props = {
@ -48,7 +46,7 @@ export function KeywordResearchMobileResults({ controller }: Props) {
</div> </div>
{mobileTab === "keywords" ? ( {mobileTab === "keywords" ? (
<MobileKeywordCards controller={controller} /> <MobileKeywordResults controller={controller} />
) : ( ) : (
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
<SerpAnalysisCard <SerpAnalysisCard
@ -67,7 +65,7 @@ export function KeywordResearchMobileResults({ controller }: Props) {
); );
} }
function MobileKeywordCards({ controller }: Props) { function MobileKeywordResults({ controller }: Props) {
const { const {
activeFilterCount, activeFilterCount,
filteredRows, filteredRows,
@ -162,34 +160,18 @@ function MobileKeywordCards({ controller }: Props) {
{showFilters ? <MobileFilters controller={controller} /> : null} {showFilters ? <MobileFilters controller={controller} /> : null}
<div className="flex-1 overflow-y-auto p-4 space-y-3"> <KeywordResearchDesktopTable
{filteredRows.length === 0 ? ( activeFilterCount={controller.activeFilterCount}
<div className="h-full min-h-48 flex flex-col items-center justify-center text-center px-4 text-base-content/50 gap-3"> filteredRows={controller.filteredRows}
<p className="text-sm font-medium"> overviewKeyword={controller.overviewKeyword}
No keywords match your current filters. selectedRows={controller.selectedRows}
</p> setSelectedRows={controller.setSelectedRows}
{activeFilterCount > 0 ? ( sortDir={controller.sortDir}
<button sortField={controller.sortField}
className="btn btn-ghost btn-sm" toggleSort={controller.toggleSort}
onClick={controller.resetFilters} resetFilters={controller.resetFilters}
> handleRowClick={controller.handleRowClick}
Clear filters />
</button>
) : null}
</div>
) : (
filteredRows.map((row) => (
<KeywordCard
key={row.keyword}
row={row}
isSelected={selectedRows.has(row.keyword)}
isActive={controller.overviewKeyword?.keyword === row.keyword}
onToggle={() => controller.toggleRowSelection(row.keyword)}
onClick={() => controller.handleRowClick(row)}
/>
))
)}
</div>
</div> </div>
); );
} }

View File

@ -19,7 +19,7 @@ export function KeywordResearchSearchBar({ controller }: Props) {
<div className="card border border-base-300 bg-base-100"> <div className="card border border-base-300 bg-base-100">
<div className="card-body gap-2"> <div className="card-body gap-2">
<form <form
className="w-full flex flex-wrap items-center gap-2" className="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-center lg:gap-2"
onSubmit={handleSearchSubmit} onSubmit={handleSearchSubmit}
> >
<controlsForm.Field name="keyword"> <controlsForm.Field name="keyword">
@ -28,12 +28,12 @@ export function KeywordResearchSearchBar({ controller }: Props) {
return ( return (
<label <label
className={`input input-bordered flex items-center gap-2 flex-1 min-w-0 max-w-md ${keywordError ? "input-error" : ""}`} className={`input input-bordered flex items-center gap-2 w-full lg:flex-1 lg:min-w-0 lg:max-w-md ${keywordError ? "input-error" : ""}`}
> >
<Search className="size-4 shrink-0 text-base-content/60" /> <Search className="size-4 shrink-0 text-base-content/60" />
<input <input
className="grow min-w-0" className="grow min-w-0"
placeholder="Enter Keyword" placeholder="Enter keyword"
value={field.state.value} value={field.state.value}
onChange={(event) => field.handleChange(event.target.value)} onChange={(event) => field.handleChange(event.target.value)}
/> />
@ -42,67 +42,69 @@ export function KeywordResearchSearchBar({ controller }: Props) {
}} }}
</controlsForm.Field> </controlsForm.Field>
<controlsForm.Field name="locationCode"> <div className="grid grid-cols-2 gap-2 lg:contents">
{(field) => ( <controlsForm.Field name="locationCode">
<select {(field) => (
className="select select-bordered select-sm w-auto" <select
value={field.state.value} className="select select-bordered w-full lg:w-auto lg:shrink-0"
onChange={(event) => value={field.state.value}
field.handleChange(Number(event.target.value)) onChange={(event) =>
} field.handleChange(Number(event.target.value))
> }
{LOCATION_OPTIONS.map((option) => ( >
<option key={option.code} value={option.code}> {LOCATION_OPTIONS.map((option) => (
{option.label} <option key={option.code} value={option.code}>
</option> {option.label}
))} </option>
</select> ))}
)} </select>
</controlsForm.Field> )}
</controlsForm.Field>
<controlsForm.Field name="resultLimit"> <controlsForm.Field name="resultLimit">
{(field) => ( {(field) => (
<select <select
className="select select-bordered select-sm w-auto" className="select select-bordered w-full lg:w-auto lg:shrink-0"
value={field.state.value} value={field.state.value}
onChange={(event) => { onChange={(event) => {
const next = Number(event.target.value); const next = Number(event.target.value);
field.handleChange(isResultLimit(next) ? next : 150); field.handleChange(isResultLimit(next) ? next : 150);
}} }}
> >
{RESULT_LIMITS.map((limit) => ( {RESULT_LIMITS.map((limit) => (
<option key={limit} value={limit}> <option key={limit} value={limit}>
{limit} results {limit} results
</option> </option>
))} ))}
</select> </select>
)} )}
</controlsForm.Field> </controlsForm.Field>
<controlsForm.Field name="mode"> <controlsForm.Field name="mode">
{(field) => ( {(field) => (
<select <select
className="select select-bordered select-sm w-auto" className="select select-bordered w-full lg:w-auto lg:shrink-0"
value={field.state.value} value={field.state.value}
onChange={(event) => onChange={(event) =>
field.handleChange(normalizeKeywordMode(event.target.value)) field.handleChange(normalizeKeywordMode(event.target.value))
} }
> >
<option value="auto">Auto</option> <option value="auto">Auto</option>
<option value="related">Related keywords</option> <option value="related">Related keywords</option>
<option value="suggestions">Suggestions</option> <option value="suggestions">Suggestions</option>
<option value="ideas">Ideas</option> <option value="ideas">Ideas</option>
</select> </select>
)} )}
</controlsForm.Field> </controlsForm.Field>
<button <button
type="submit" type="submit"
className="btn btn-primary btn-sm px-6 font-semibold" className="btn btn-primary w-full px-6 font-semibold lg:w-auto lg:shrink-0"
disabled={isLoading} disabled={isLoading}
> >
{isLoading ? "Searching..." : "Search"} {isLoading ? "Searching..." : "Search"}
</button> </button>
</div>
</form> </form>
<controlsForm.Field name="keyword"> <controlsForm.Field name="keyword">
{(field) => { {(field) => {

View File

@ -6,7 +6,7 @@ describe("safeHttpUrl", () => {
"https://example.com", "https://example.com",
"http://example.com", "http://example.com",
"https://example.com/path?q=1#frag", "https://example.com/path?q=1#frag",
"https://sub.example.io", "https://sub.example.com",
])("accepts %s", (input) => { ])("accepts %s", (input) => {
expect(safeHttpUrl(input)).toBe(input); expect(safeHttpUrl(input)).toBe(input);
}); });
@ -35,7 +35,7 @@ describe("safeHttpUrl", () => {
describe("safeHostname", () => { describe("safeHostname", () => {
it("strips protocol and www prefix", () => { it("strips protocol and www prefix", () => {
expect(safeHostname("https://www.example.com/path")).toBe("example.com"); expect(safeHostname("https://www.example.com/path")).toBe("example.com");
expect(safeHostname("http://sub.example.io")).toBe("sub.example.io"); expect(safeHostname("http://sub.example.com")).toBe("sub.example.com");
}); });
it("returns null for unsafe schemes", () => { it("returns null for unsafe schemes", () => {

View File

@ -3,17 +3,17 @@ import { detectTarget } from "./targetDetection";
describe("detectTarget", () => { describe("detectTarget", () => {
it.each([ it.each([
["opus.pro", "opus.pro"], ["example.com", "example.com"],
["https://opus.pro", "opus.pro"], ["https://example.com", "example.com"],
["https://www.opus.pro/features", "opus.pro"], ["https://www.example.com/features", "example.com"],
["WWW.Example.COM", "example.com"], ["WWW.Example.COM", "example.com"],
["sub.example.io", "sub.example.io"], ["sub.example.com", "sub.example.com"],
])("treats %s as a domain", (input, expected) => { ])("treats %s as a domain", (input, expected) => {
expect(detectTarget(input)).toEqual({ type: "domain", value: expected }); expect(detectTarget(input)).toEqual({ type: "domain", value: expected });
}); });
it.each([ it.each([
"Opus Clip", "Example Brand",
"best ai video clipper", "best ai video clipper",
"OpenAI", "OpenAI",
"GPT-5", "GPT-5",
@ -40,9 +40,9 @@ describe("detectTarget", () => {
}); });
it("trims whitespace before classification", () => { it("trims whitespace before classification", () => {
expect(detectTarget(" opus.pro ")).toEqual({ expect(detectTarget(" example.com ")).toEqual({
type: "domain", type: "domain",
value: "opus.pro", value: "example.com",
}); });
}); });
}); });

View File

@ -6,8 +6,8 @@ type DetectedTarget = {
}; };
/** /**
* Decide whether free-text input is a domain (e.g. "opus.pro") or a brand * Decide whether free-text input is a domain (e.g. "example.com") or a brand
* keyword (e.g. "Opus Clip"). Heuristic: no whitespace + contains a dot + * keyword (e.g. "Example Brand"). Heuristic: no whitespace + contains a dot +
* `normalizeDomain` produces a valid hostname. * `normalizeDomain` produces a valid hostname.
*/ */
export function detectTarget(rawInput: string): DetectedTarget { export function detectTarget(rawInput: string): DetectedTarget {