From 50980b60dc96c13480b81bbd69150daf31dd9504 Mon Sep 17 00:00:00 2001 From: Carlos Silva Date: Tue, 3 Sep 2024 17:05:11 -0300 Subject: [PATCH] refactor website --- app/package.json | 19 ++- app/src/components/logo.tsx | 22 --- app/src/components/search.tsx | 162 -------------------- app/src/components/search/filterOptions.tsx | 48 ++++++ app/src/components/search/index.tsx | 113 ++++++++++++++ app/src/components/{ => search}/result.tsx | 6 +- app/src/components/search/results.tsx | 65 ++++++++ app/src/components/search/searchInput.tsx | 67 ++++++++ app/src/components/search/state.ts | 85 ++++++++++ app/src/components/state.ts | 18 --- app/src/components/ui/button.tsx | 48 ++++++ app/src/components/ui/kbd.tsx | 22 +++ app/src/components/ui/logo.tsx | 38 +++++ app/src/env.d.ts | 1 + app/src/pages/index.astro | 11 +- app/src/utils.ts | 29 +--- app/tsconfig.json | 9 +- 17 files changed, 513 insertions(+), 250 deletions(-) delete mode 100644 app/src/components/logo.tsx delete mode 100644 app/src/components/search.tsx create mode 100644 app/src/components/search/filterOptions.tsx create mode 100644 app/src/components/search/index.tsx rename app/src/components/{ => search}/result.tsx (74%) create mode 100644 app/src/components/search/results.tsx create mode 100644 app/src/components/search/searchInput.tsx create mode 100644 app/src/components/search/state.ts delete mode 100644 app/src/components/state.ts create mode 100644 app/src/components/ui/button.tsx create mode 100644 app/src/components/ui/kbd.tsx create mode 100644 app/src/components/ui/logo.tsx diff --git a/app/package.json b/app/package.json index acf4cec..ff7a174 100644 --- a/app/package.json +++ b/app/package.json @@ -10,18 +10,23 @@ "astro": "astro" }, "dependencies": { - "@astrojs/check": "^0.9.1", - "@astrojs/solid-js": "^4.4.0", + "@astrojs/check": "^0.9.3", + "@astrojs/solid-js": "^4.4.1", "@astrojs/tailwind": "^5.1.0", - "@tanstack/solid-virtual": "^3.8.4", - "astro": "^4.13.1", + "@tanstack/solid-virtual": "^3.10.6", + "astro": "^4.15.2", + "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "date-fns": "^3.6.0", + "debounce": "^2.1.0", + "lucide-solid": "^0.438.0", "minisearch": "^7.1.0", "nanoid": "^5.0.7", - "solid-js": "^1.8.19", - "tailwind-merge": "^2.4.0", - "tailwindcss": "^3.4.7", + "query-string": "^9.1.0", + "solid-js": "^1.8.22", + "tailwind-merge": "^2.5.2", + "tailwindcss": "^3.4.10", + "tinykeys": "^3.0.0", "typescript": "^5.5.3" } } diff --git a/app/src/components/logo.tsx b/app/src/components/logo.tsx deleted file mode 100644 index b117d0d..0000000 --- a/app/src/components/logo.tsx +++ /dev/null @@ -1,22 +0,0 @@ -export function Logo() { - return ( -
- - - -
-
- Newsletter - Scraper -
-
- ); -} diff --git a/app/src/components/search.tsx b/app/src/components/search.tsx deleted file mode 100644 index 5f23f8d..0000000 --- a/app/src/components/search.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { For, onCleanup, Show } from "solid-js"; -import { createVirtualizer } from "@tanstack/solid-virtual"; - -import { SearchWorker } from "../scripts/search"; -import { Result } from "./result"; -import { setStore, store, type ResultType } from "./state"; -import { defined, getSearchParams, updateURLSearchParams } from "../utils"; - -const search = new SearchWorker(); - -function submitSearch(query: string) { - updateURLSearchParams({ q: query }); - performance.mark("search-start"); - search.search({ query, source: [] }, (results) => { - performance.mark("search-end"); - const duration = performance.measure( - "search-duration", - "search-start", - "search-end", - ); - setStore({ - results, - search_time: Math.round(duration.duration), - }); - }); -} - -(() => { - const param = getSearchParams("q"); - console.log({ param }); - if (!defined(param)) return; - submitSearch(param); -})(); - -export function Search() { - return ( - <> -
-
- - - 0}> -
- Found {store.results.length} items in {store.search_time} ms -
-
-
-
- - - - ); -} - -let listContainer: HTMLDivElement | undefined; - -function Results() { - const virtual = createVirtualizer({ - estimateSize: () => 96, - getScrollElement: () => listContainer ?? null, - paddingEnd: 48, - get count() { - return store.results.length; - }, - }); - - const resizeObserver = new ResizeObserver((entries) => { - const [entry] = entries; - if (!entry?.target) return; - const parentHeight = entry.target?.clientHeight; - if (parentHeight && listContainer?.style) { - listContainer?.style.setProperty("height", `${parentHeight}px`); - } - }); - - const observeSize = (el: HTMLElement) => resizeObserver.observe(el); - onCleanup(() => resizeObserver.disconnect()); - - return ( - <> -
-
-
    - - {(item) => { - return ( - -
  • { - queueMicrotask(() => virtual.measureElement(el)); - }} - > - -
  • -
    - ); - }} -
    -
-
-
- - ); -} - -function SearchInput() { - return ( -
{ - e.preventDefault(); - e.stopPropagation(); - - const form = new FormData(e.currentTarget); - const searchQuery = (form.get("search") as string)?.trim(); - - listContainer?.scrollTo({ top: 0 }); - - if (searchQuery === "") { - updateURLSearchParams(); - setStore("results", []); - } - - if (searchQuery) { - submitSearch(searchQuery); - } - }} - > - - - - -
- ); -} diff --git a/app/src/components/search/filterOptions.tsx b/app/src/components/search/filterOptions.tsx new file mode 100644 index 0000000..a56617c --- /dev/null +++ b/app/src/components/search/filterOptions.tsx @@ -0,0 +1,48 @@ +import { createSelector, For } from "solid-js"; +import { Button } from "../ui/button"; +import { searchStore } from "./state"; +import { RotateCcw } from "lucide-solid"; + +interface OptionsFiltersProps { + options: Array<{ label: string; value: string }>; + onChange?: (options: string[]) => void; +} + +export function FilterOptions(props: OptionsFiltersProps) { + const isSelected = createSelector( + () => searchStore.searchFilter, + (value, options) => new Set(options).has(value), + ); + + const onChange = (option: string) => { + const selected = new Set(searchStore.searchFilter); + selected.has(option) ? selected.delete(option) : selected.add(option); + props.onChange?.([...selected]); + }; + + return ( +
+ + {(item) => ( + + )} + + + +
+ ); +} diff --git a/app/src/components/search/index.tsx b/app/src/components/search/index.tsx new file mode 100644 index 0000000..95ce173 --- /dev/null +++ b/app/src/components/search/index.tsx @@ -0,0 +1,113 @@ +import { onMount, Show } from "solid-js"; +import qs from "query-string"; + +import { Results } from "./results"; +import { + setSearchStore, + resultStore, + searchStore, + debouncedSearch, +} from "./state"; +import { defined } from "../../utils"; +import { Logo } from "../ui/logo"; +import { FilterOptions } from "./filterOptions"; +import { SearchInput } from "./searchInput"; + +export function Search() { + onMount(() => { + const { q, s = [] } = qs.parse(window.location.search, { + types: { + q: "string", + s: "string[]", + }, + }) as { q: string; s: string[] }; + + if (defined(q) && q !== "") { + setSearchStore({ + searchQuery: q, + searchFilter: s, + searchView: true, + }); + + debouncedSearch.trigger(); + } + }); + + return ( + }> + + + ); +} + +function SearchEmpty() { + return ( +
+ + +

+ Search curated content from 7 programming newsletter +

+ + +
+ ); +} + +function SearchResult() { + return ( +
+
+
+ + + +
+
+ +
+
+
+ { + setSearchStore("searchFilter", filters); + debouncedSearch.trigger(); + }} + options={[ + { label: "Node Weekly", value: "nodeweekly" }, + { label: "JavaScript Weekly", value: "javascriptweekly" }, + { label: "Frontend Focus", value: "frontendfocus" }, + { label: "React Status", value: "reactstatus" }, + { label: "This Week in React", value: "thisweekinreact" }, + { label: "Golang Weekly", value: "golangweekly" }, + { label: "Ruby Weekly", value: "rubyweekly" }, + { label: "Postgres Weekly", value: "postgresweekly" }, + ]} + /> +
+ +
+ 0}> +
+ Found{" "} + {resultStore.results.length}{" "} + items in {resultStore.searchTime} ms +
+
+ + +
+
🫠
+ + Nothing Found for: '{searchStore.searchQuery}' + +
+
+ + +
+
+
+
+ ); +} diff --git a/app/src/components/result.tsx b/app/src/components/search/result.tsx similarity index 74% rename from app/src/components/result.tsx rename to app/src/components/search/result.tsx index af57fb4..60231db 100644 --- a/app/src/components/result.tsx +++ b/app/src/components/search/result.tsx @@ -8,10 +8,10 @@ export function Result(props: { href={props.result.url} target="_blank" rel="noreferrer" - class="p-4 py-5 hover:underline underline-offset-4 decoration-slate-200" + class="p-4 py-5 hover:underline underline-offset-4 decoration-zinc-400" > -

{props.result.description}

-
+

{props.result.description}

+
{props.result.date} | {props.result.source}
diff --git a/app/src/components/search/results.tsx b/app/src/components/search/results.tsx new file mode 100644 index 0000000..edf7274 --- /dev/null +++ b/app/src/components/search/results.tsx @@ -0,0 +1,65 @@ +import { createVirtualizer } from "@tanstack/solid-virtual"; +import { resultStore } from "./state"; +import { For, Show, onCleanup } from "solid-js"; +import { Result } from "./result"; + +let listContainer: HTMLDivElement | undefined; + +export function Results() { + const virtual = createVirtualizer({ + estimateSize: () => 96, + getScrollElement: () => listContainer ?? null, + paddingEnd: 48, + + get count() { + return resultStore.results.length; + }, + }); + + const resizeObserver = new ResizeObserver((entries) => { + const [entry] = entries; + if (!entry?.target) return; + const parentHeight = entry.target?.clientHeight; + if (parentHeight && listContainer?.style) { + listContainer?.style.setProperty("height", `${parentHeight}px`); + } + }); + + const observeSize = (el: HTMLElement) => resizeObserver.observe(el); + onCleanup(() => resizeObserver.disconnect()); + + return ( +
+
+
    + + {(item) => { + return ( + +
  • { + queueMicrotask(() => virtual.measureElement(el)); + }} + > + +
  • +
    + ); + }} +
    +
+
+
+ ); +} diff --git a/app/src/components/search/searchInput.tsx b/app/src/components/search/searchInput.tsx new file mode 100644 index 0000000..e511d28 --- /dev/null +++ b/app/src/components/search/searchInput.tsx @@ -0,0 +1,67 @@ +import { onCleanup, onMount, splitProps, type JSX } from "solid-js"; +import { SearchIcon } from "lucide-solid"; +import { tinykeys } from "tinykeys"; + +import { debouncedSearch, searchStore, setSearchStore } from "./state"; +import { cn, defined } from "../../utils"; +import { Kbd } from "../ui/kbd"; + +interface SearchInputProps extends JSX.ButtonHTMLAttributes {} + +export function SearchInput(props: SearchInputProps) { + const [local, defaultProps] = splitProps(props, ["class"]); + + let inputRef: HTMLInputElement | undefined; + const unSubscribe = tinykeys( + window, + { + "/": () => { + const hasRef = defined(inputRef); + const hasInputFocused = + document.activeElement?.tagName?.toLowerCase() === "input"; + const alreadyFocused = + hasRef && document.activeElement?.isSameNode(inputRef); + if (hasRef && !hasInputFocused && !alreadyFocused) { + inputRef.focus(); + } + }, + }, + { event: "keyup" }, + ); + + onCleanup(unSubscribe); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + + setSearchStore("searchView", true); + debouncedSearch.flush(); + }} + > + + + { + setSearchStore("searchQuery", e.target.value); + debouncedSearch(); + }} + /> + + / + + ); +} diff --git a/app/src/components/search/state.ts b/app/src/components/search/state.ts new file mode 100644 index 0000000..0251210 --- /dev/null +++ b/app/src/components/search/state.ts @@ -0,0 +1,85 @@ +import { createStore, unwrap } from "solid-js/store"; +import qs from "query-string"; +import { defined } from "../../utils"; +import { SearchWorker } from "../../scripts/search"; +import debounce from "debounce"; + +const search = new SearchWorker(); + +export interface ResultType { + description: string; + source: string; + date: string; + url: string; +} + +interface ResultStore { + results: ResultType[]; + searchTime: number; + empty: boolean; +} + +export const [resultStore, setResultStore] = createStore({ + results: [], + searchTime: 0, + empty: false, +}); + +interface SearchStore { + searchView: boolean; + searchQuery: string; + searchFilter: string[]; +} + +export const [searchStore, setSearchStore] = createStore({ + searchView: false, + searchQuery: "", + searchFilter: [], +}); + +interface QueryStringState { + q: string; // search query + s: string[]; // search filter +} + +export function updateURLSearchParams(state?: QueryStringState) { + const url = new URL(window.location.href); + + if (!defined(state)) { + url.search = ""; + window.history.replaceState(null, "", url); + return; + } + + url.search = qs.stringify(state); + window.history.replaceState(null, "", url); +} + +export function submitSearch() { + const { searchQuery, searchFilter } = unwrap(searchStore); + const query = searchQuery.trim(); + + updateURLSearchParams({ q: query, s: searchFilter }); + + if (query === "") { + setResultStore({ results: [], empty: true }); + return; + } + + performance.mark("search-start"); + search.search({ query, source: searchFilter }, (results) => { + performance.mark("search-end"); + const duration = performance.measure( + "search-duration", + "search-start", + "search-end", + ); + setResultStore({ + results, + empty: results.length === 0, + searchTime: Math.round(duration.duration), + }); + }); +} + +export const debouncedSearch = debounce(submitSearch, 700); diff --git a/app/src/components/state.ts b/app/src/components/state.ts deleted file mode 100644 index 8f35b32..0000000 --- a/app/src/components/state.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createStore } from "solid-js/store"; - -export interface ResultType { - description: string; - source: string; - date: string; - url: string; -} - -interface Store { - results: ResultType[]; - search_time: number; -} - -export const [store, setStore] = createStore({ - results: [], - search_time: 0, -}); diff --git a/app/src/components/ui/button.tsx b/app/src/components/ui/button.tsx new file mode 100644 index 0000000..954746a --- /dev/null +++ b/app/src/components/ui/button.tsx @@ -0,0 +1,48 @@ +import { mergeProps, splitProps, type JSX } from "solid-js"; +import { cva, type VariantProps } from "class-variance-authority"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-1 px-4 py-2 text-sm font-medium tracking-wide", + { + variants: { + intent: { + neutral: + "transition-colors duration-100 rounded-md text-neutral-500 bg-neutral-50 focus:ring-2 focus:ring-offset-2 focus:ring-neutral-100 hover:text-neutral-600 hover:bg-neutral-100", + info: "text-blue-500 transition-colors duration-100 rounded-md focus:ring-2 focus:ring-offset-2 focus:ring-blue-100 bg-blue-50 hover:text-blue-600 hover:bg-blue-100", + danger: + "text-red-500 transition-colors duration-100 rounded-md focus:ring-2 focus:ring-offset-2 focus:ring-red-100 bg-red-50 hover:text-red-600 hover:bg-red-100", + success: + "text-green-500 transition-colors duration-100 rounded-md focus:ring-2 focus:ring-offset-2 focus:ring-green-100 bg-green-50 hover:text-green-600 hover:bg-green-100", + warning: + "text-yellow-600 transition-colors duration-100 rounded-md focus:ring-2 focus:ring-offset-2 focus:ring-yellow-100 bg-yellow-50 hover:text-yellow-700 hover:bg-yellow-100", + }, + }, + defaultVariants: { + intent: "neutral", + }, + }, +); + +interface ButtonProps + extends JSX.ButtonHTMLAttributes, + VariantProps { + children?: JSX.Element; +} + +export function Button(props: ButtonProps) { + const merged = mergeProps({ type: "button" } as ButtonProps, props); + const [local, buttonProps] = splitProps(merged, [ + "children", + "class", + "intent", + ]); + + return ( + + ); +} diff --git a/app/src/components/ui/kbd.tsx b/app/src/components/ui/kbd.tsx new file mode 100644 index 0000000..32fecd4 --- /dev/null +++ b/app/src/components/ui/kbd.tsx @@ -0,0 +1,22 @@ +import { splitProps, type JSX } from "solid-js"; +import { cn } from "../../utils"; + +interface keysProps extends JSX.ButtonHTMLAttributes { + children: JSX.Element; +} + +export function Kbd(props: keysProps) { + const [local, defaultProps] = splitProps(props, ["class", "children"]); + + return ( + + {local.children} + + ); +} diff --git a/app/src/components/ui/logo.tsx b/app/src/components/ui/logo.tsx new file mode 100644 index 0000000..dcc9e72 --- /dev/null +++ b/app/src/components/ui/logo.tsx @@ -0,0 +1,38 @@ +import { mergeProps } from "solid-js"; +import { cn } from "../../utils"; + +interface LogoProps { + size?: "sm" | "lg"; +} + +export function Logo(props: LogoProps) { + const merged = mergeProps({ size: "sm" }, props); + const iconSize = merged.size === "sm" ? "64px" : "72px"; + + return ( +
+ + + +
+
+ Newsletter + Scraper +
+
+ ); +} diff --git a/app/src/env.d.ts b/app/src/env.d.ts index f964fe0..acef35f 100644 --- a/app/src/env.d.ts +++ b/app/src/env.d.ts @@ -1 +1,2 @@ +/// /// diff --git a/app/src/pages/index.astro b/app/src/pages/index.astro index 94728f4..ad903c3 100644 --- a/app/src/pages/index.astro +++ b/app/src/pages/index.astro @@ -1,6 +1,5 @@ --- import { Search } from "../components/search"; -import { Logo } from "../components/logo"; --- @@ -15,19 +14,15 @@ import { Logo } from "../components/logo"; Newsletter Scraper - -
- -
- + diff --git a/app/src/utils.ts b/app/src/utils.ts index fcea401..1ceaeac 100644 --- a/app/src/utils.ts +++ b/app/src/utils.ts @@ -2,34 +2,9 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return twMerge(clsx(inputs)); } export function defined(item: T): item is Exclude { - return item !== undefined && item !== null; -} - -export function updateURLSearchParams(inputParams?: Record) { - const url = new URL(window.location.href); - const params = new URLSearchParams(url.searchParams); - - if (!defined(inputParams)) { - url.search = ""; - window.history.replaceState(null, "", url); - return; - } - - for (const key in inputParams) { - params.set(key, inputParams[key]); - } - - url.search = params.toString(); - window.history.replaceState(null, "", url); -} - -export function getSearchParams(paramKey: string) { - const url = new URL(window.location.href); - const params = new URLSearchParams(url.searchParams); - - return params.get(paramKey); + return item !== undefined && item !== null; } diff --git a/app/tsconfig.json b/app/tsconfig.json index bc684d4..9f3898d 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -2,7 +2,10 @@ "extends": "astro/tsconfigs/strict", "compilerOptions": { "jsx": "preserve", - "jsxImportSource": "solid-js" + "jsxImportSource": "solid-js", + "module": "ESNext", }, - "exclude": ["dist"] -} + "exclude": [ + "dist" + ] +} \ No newline at end of file