feat: improve mobile UX (#155)
This commit is contained in:
parent
b9ad31704f
commit
ca260f544b
@ -67,6 +67,7 @@ export function makeSelectionColumn<TData>(
|
||||
className="checkbox checkbox-xs [--radius-selector:0.25rem]"
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
aria-label="Select all rows"
|
||||
/>
|
||||
),
|
||||
cell: ({ row, table }) => (
|
||||
@ -90,6 +91,7 @@ function SelectionCheckbox<TData>({
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-xs [--radius-selector:0.25rem]"
|
||||
checked={row.getIsSelected()}
|
||||
aria-label="Select row"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
rangeHandledRef.current = applyShiftRangeSelection(
|
||||
|
||||
@ -34,12 +34,12 @@ export function DomainSearchCard({
|
||||
|
||||
return (
|
||||
<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" />
|
||||
<input
|
||||
className="grow"
|
||||
placeholder="Enter a domain (e.g. coolify.io or example.com/blog)"
|
||||
className="grow min-w-0"
|
||||
placeholder="Enter a domain"
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
aria-invalid={domainError ? true : undefined}
|
||||
|
||||
@ -14,7 +14,7 @@ export function getDomainSearchValidationErrors(value: DomainControlsValues) {
|
||||
if (!normalizeDomainTarget(value.domain)) {
|
||||
return createFormValidationErrors({
|
||||
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)",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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({
|
||||
value,
|
||||
size = "sm",
|
||||
|
||||
@ -4,7 +4,11 @@ function getNextSelectionSet(
|
||||
current: Set<string>,
|
||||
allVisibleKeywords: string[],
|
||||
): Set<string> {
|
||||
if (current.size === allVisibleKeywords.length) {
|
||||
const allVisibleSelected =
|
||||
allVisibleKeywords.length > 0 &&
|
||||
allVisibleKeywords.every((keyword) => current.has(keyword));
|
||||
|
||||
if (allVisibleSelected) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ import {
|
||||
type SortDir,
|
||||
type SortField,
|
||||
} 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 { EmptyFilterResults } from "./keywordResearchDesktopFilters";
|
||||
|
||||
@ -66,18 +66,21 @@ export function KeywordResearchDesktopTable({
|
||||
current={sortField}
|
||||
dir={sortDir}
|
||||
onToggle={toggleSort}
|
||||
className="min-w-0"
|
||||
className="min-w-48 md:min-w-0"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<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}
|
||||
>
|
||||
{row.original.keyword}
|
||||
</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", {
|
||||
header: () => (
|
||||
@ -93,7 +96,8 @@ export function KeywordResearchDesktopTable({
|
||||
cell: ({ getValue }) => formatNumber(getValue()),
|
||||
meta: {
|
||||
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", {
|
||||
@ -114,7 +118,8 @@ export function KeywordResearchDesktopTable({
|
||||
},
|
||||
meta: {
|
||||
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", {
|
||||
@ -135,7 +140,8 @@ export function KeywordResearchDesktopTable({
|
||||
},
|
||||
meta: {
|
||||
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", {
|
||||
@ -158,7 +164,7 @@ export function KeywordResearchDesktopTable({
|
||||
cell: ({ getValue }) => <IntentBadge intent={getValue()} />,
|
||||
meta: {
|
||||
headerClassName: "text-center",
|
||||
cellClassName: "text-center",
|
||||
cellClassName: "whitespace-nowrap text-center",
|
||||
},
|
||||
}),
|
||||
],
|
||||
@ -184,7 +190,7 @@ export function KeywordResearchDesktopTable({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 min-h-0">
|
||||
{filteredRows.length === 0 ? (
|
||||
<EmptyFilterResults
|
||||
activeFilterCount={activeFilterCount}
|
||||
@ -193,8 +199,8 @@ export function KeywordResearchDesktopTable({
|
||||
) : (
|
||||
<AppDataTable
|
||||
table={table}
|
||||
className="table table-xs w-full"
|
||||
wrapperClassName="h-full overflow-y-auto"
|
||||
className="table table-xs min-w-max md:w-full"
|
||||
wrapperClassName="h-full overflow-auto"
|
||||
getRowProps={(row) => ({
|
||||
className: `cursor-pointer border-b border-base-200 hover:bg-base-200/50 ${
|
||||
overviewKeyword?.keyword === row.original.keyword
|
||||
@ -211,12 +217,10 @@ export function KeywordResearchDesktopTable({
|
||||
|
||||
function ScoreCell({ value }: { value: number | null }) {
|
||||
if (value == null) return null;
|
||||
let tierClass = "bg-success/20 text-success";
|
||||
if (value > 60) tierClass = "bg-error/20 text-error";
|
||||
else if (value > 30) tierClass = "bg-warning/20 text-warning";
|
||||
const tierClass = scoreTierClass(value);
|
||||
return (
|
||||
<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}
|
||||
</span>
|
||||
|
||||
@ -9,10 +9,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions";
|
||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||
import {
|
||||
KeywordCard,
|
||||
SerpAnalysisCard,
|
||||
} from "@/client/features/keywords/components";
|
||||
import { SerpAnalysisCard } from "@/client/features/keywords/components";
|
||||
import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable";
|
||||
import type { KeywordResearchControllerState } from "./types";
|
||||
|
||||
type Props = {
|
||||
@ -48,7 +46,7 @@ export function KeywordResearchMobileResults({ controller }: Props) {
|
||||
</div>
|
||||
|
||||
{mobileTab === "keywords" ? (
|
||||
<MobileKeywordCards controller={controller} />
|
||||
<MobileKeywordResults controller={controller} />
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<SerpAnalysisCard
|
||||
@ -67,7 +65,7 @@ export function KeywordResearchMobileResults({ controller }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function MobileKeywordCards({ controller }: Props) {
|
||||
function MobileKeywordResults({ controller }: Props) {
|
||||
const {
|
||||
activeFilterCount,
|
||||
filteredRows,
|
||||
@ -162,34 +160,18 @@ function MobileKeywordCards({ controller }: Props) {
|
||||
|
||||
{showFilters ? <MobileFilters controller={controller} /> : null}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{filteredRows.length === 0 ? (
|
||||
<div className="h-full min-h-48 flex flex-col items-center justify-center text-center px-4 text-base-content/50 gap-3">
|
||||
<p className="text-sm font-medium">
|
||||
No keywords match your current filters.
|
||||
</p>
|
||||
{activeFilterCount > 0 ? (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={controller.resetFilters}
|
||||
>
|
||||
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>
|
||||
<KeywordResearchDesktopTable
|
||||
activeFilterCount={controller.activeFilterCount}
|
||||
filteredRows={controller.filteredRows}
|
||||
overviewKeyword={controller.overviewKeyword}
|
||||
selectedRows={controller.selectedRows}
|
||||
setSelectedRows={controller.setSelectedRows}
|
||||
sortDir={controller.sortDir}
|
||||
sortField={controller.sortField}
|
||||
toggleSort={controller.toggleSort}
|
||||
resetFilters={controller.resetFilters}
|
||||
handleRowClick={controller.handleRowClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ export function KeywordResearchSearchBar({ controller }: Props) {
|
||||
<div className="card border border-base-300 bg-base-100">
|
||||
<div className="card-body gap-2">
|
||||
<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}
|
||||
>
|
||||
<controlsForm.Field name="keyword">
|
||||
@ -28,12 +28,12 @@ export function KeywordResearchSearchBar({ controller }: Props) {
|
||||
|
||||
return (
|
||||
<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" />
|
||||
<input
|
||||
className="grow min-w-0"
|
||||
placeholder="Enter Keyword"
|
||||
placeholder="Enter keyword"
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
@ -42,67 +42,69 @@ export function KeywordResearchSearchBar({ controller }: Props) {
|
||||
}}
|
||||
</controlsForm.Field>
|
||||
|
||||
<controlsForm.Field name="locationCode">
|
||||
{(field) => (
|
||||
<select
|
||||
className="select select-bordered select-sm w-auto"
|
||||
value={field.state.value}
|
||||
onChange={(event) =>
|
||||
field.handleChange(Number(event.target.value))
|
||||
}
|
||||
>
|
||||
{LOCATION_OPTIONS.map((option) => (
|
||||
<option key={option.code} value={option.code}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</controlsForm.Field>
|
||||
<div className="grid grid-cols-2 gap-2 lg:contents">
|
||||
<controlsForm.Field name="locationCode">
|
||||
{(field) => (
|
||||
<select
|
||||
className="select select-bordered w-full lg:w-auto lg:shrink-0"
|
||||
value={field.state.value}
|
||||
onChange={(event) =>
|
||||
field.handleChange(Number(event.target.value))
|
||||
}
|
||||
>
|
||||
{LOCATION_OPTIONS.map((option) => (
|
||||
<option key={option.code} value={option.code}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</controlsForm.Field>
|
||||
|
||||
<controlsForm.Field name="resultLimit">
|
||||
{(field) => (
|
||||
<select
|
||||
className="select select-bordered select-sm w-auto"
|
||||
value={field.state.value}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value);
|
||||
field.handleChange(isResultLimit(next) ? next : 150);
|
||||
}}
|
||||
>
|
||||
{RESULT_LIMITS.map((limit) => (
|
||||
<option key={limit} value={limit}>
|
||||
{limit} results
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</controlsForm.Field>
|
||||
<controlsForm.Field name="resultLimit">
|
||||
{(field) => (
|
||||
<select
|
||||
className="select select-bordered w-full lg:w-auto lg:shrink-0"
|
||||
value={field.state.value}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value);
|
||||
field.handleChange(isResultLimit(next) ? next : 150);
|
||||
}}
|
||||
>
|
||||
{RESULT_LIMITS.map((limit) => (
|
||||
<option key={limit} value={limit}>
|
||||
{limit} results
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</controlsForm.Field>
|
||||
|
||||
<controlsForm.Field name="mode">
|
||||
{(field) => (
|
||||
<select
|
||||
className="select select-bordered select-sm w-auto"
|
||||
value={field.state.value}
|
||||
onChange={(event) =>
|
||||
field.handleChange(normalizeKeywordMode(event.target.value))
|
||||
}
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="related">Related keywords</option>
|
||||
<option value="suggestions">Suggestions</option>
|
||||
<option value="ideas">Ideas</option>
|
||||
</select>
|
||||
)}
|
||||
</controlsForm.Field>
|
||||
<controlsForm.Field name="mode">
|
||||
{(field) => (
|
||||
<select
|
||||
className="select select-bordered w-full lg:w-auto lg:shrink-0"
|
||||
value={field.state.value}
|
||||
onChange={(event) =>
|
||||
field.handleChange(normalizeKeywordMode(event.target.value))
|
||||
}
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="related">Related keywords</option>
|
||||
<option value="suggestions">Suggestions</option>
|
||||
<option value="ideas">Ideas</option>
|
||||
</select>
|
||||
)}
|
||||
</controlsForm.Field>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-sm px-6 font-semibold"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Searching..." : "Search"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-full px-6 font-semibold lg:w-auto lg:shrink-0"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Searching..." : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<controlsForm.Field name="keyword">
|
||||
{(field) => {
|
||||
|
||||
@ -6,7 +6,7 @@ describe("safeHttpUrl", () => {
|
||||
"https://example.com",
|
||||
"http://example.com",
|
||||
"https://example.com/path?q=1#frag",
|
||||
"https://sub.example.io",
|
||||
"https://sub.example.com",
|
||||
])("accepts %s", (input) => {
|
||||
expect(safeHttpUrl(input)).toBe(input);
|
||||
});
|
||||
@ -35,7 +35,7 @@ describe("safeHttpUrl", () => {
|
||||
describe("safeHostname", () => {
|
||||
it("strips protocol and www prefix", () => {
|
||||
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", () => {
|
||||
|
||||
@ -3,17 +3,17 @@ import { detectTarget } from "./targetDetection";
|
||||
|
||||
describe("detectTarget", () => {
|
||||
it.each([
|
||||
["opus.pro", "opus.pro"],
|
||||
["https://opus.pro", "opus.pro"],
|
||||
["https://www.opus.pro/features", "opus.pro"],
|
||||
["example.com", "example.com"],
|
||||
["https://example.com", "example.com"],
|
||||
["https://www.example.com/features", "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) => {
|
||||
expect(detectTarget(input)).toEqual({ type: "domain", value: expected });
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Opus Clip",
|
||||
"Example Brand",
|
||||
"best ai video clipper",
|
||||
"OpenAI",
|
||||
"GPT-5",
|
||||
@ -40,9 +40,9 @@ describe("detectTarget", () => {
|
||||
});
|
||||
|
||||
it("trims whitespace before classification", () => {
|
||||
expect(detectTarget(" opus.pro ")).toEqual({
|
||||
expect(detectTarget(" example.com ")).toEqual({
|
||||
type: "domain",
|
||||
value: "opus.pro",
|
||||
value: "example.com",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,8 +6,8 @@ type DetectedTarget = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide whether free-text input is a domain (e.g. "opus.pro") or a brand
|
||||
* keyword (e.g. "Opus Clip"). Heuristic: no whitespace + contains a dot +
|
||||
* Decide whether free-text input is a domain (e.g. "example.com") or a brand
|
||||
* keyword (e.g. "Example Brand"). Heuristic: no whitespace + contains a dot +
|
||||
* `normalizeDomain` produces a valid hostname.
|
||||
*/
|
||||
export function detectTarget(rawInput: string): DetectedTarget {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user