Files
redux-scraper/app/javascript/bundles/Main/components/UserSearchBar.tsx
Dylan Knutson 9f0f6877d9 Update project configuration and enhance OpenTelemetry integration
- Modified `.gitignore` to include and manage `.devcontainer/signoz/data/*` while preserving `.keep` files.
- Updated `.prettierrc` to include the `@prettier/plugin-xml` plugin and configured XML formatting options.
- Added OpenTelemetry SDK and exporter gems to the `Gemfile` for enhanced monitoring capabilities.
- Removed `package-lock.json` as part of the transition to Yarn for dependency management.
- Enhanced `.devcontainer` configuration with new services for SigNoz, including ClickHouse and related configurations.
- Introduced new ClickHouse configuration files for user and cluster settings.
- Updated Nginx and OpenTelemetry collector configurations to support new logging and monitoring features.
- Improved user experience in the `UserSearchBar` component by updating the placeholder text.

These changes aim to improve project maintainability, monitoring capabilities, and user experience.
2025-01-04 00:55:19 +00:00

308 lines
8.4 KiB
TypeScript

import { debounce, isEmpty } from 'lodash';
import * as React from 'react';
import { useCallback, useRef, useState } from 'react';
import Icon from './Icon';
import ListItem from './ListItem';
import Trie, { TrieNode } from '../lib/Trie';
// 1. Group related constants
const CONFIG = {
HOST: '',
LOG: false,
} as const;
const STYLES = {
LIST_ELEM_CLASSNAME: [
'w-full p-2 pl-8 text-xl font-light border-slate-300 border-2',
'group-focus-within:border-slate-400',
],
SVG_BASE_CLASSNAME: `stroke-slate-500 fill-slate-500`,
SVG_FOCUSABLE_CLASSNAME: `stroke-slate-500 fill-slate-500 group-focus-within:stroke-slate-800 group-focus-within:fill-slate-800`,
INPUT_CLASSNAME: `text-slate-500 group-focus-within:text-slate-800 placeholder-slate-500 group-focus-within:placeholder-slate-800 placeholder:font-extralight`,
} as const;
// 2. Simplify logging
const log = {
info: (...args: any[]) => CONFIG.LOG && console.log(...args),
error: (...args: any[]) => CONFIG.LOG && console.error(...args),
};
interface PropTypes {
isServerRendered?: boolean;
}
interface User {
id: number;
name: string;
url_name: string;
thumb?: string;
show_path: string;
num_posts: number;
}
interface ServerResponse {
users: User[];
}
type TrieValue = [number, string];
type TrieType = Trie<TrieValue>;
type TrieNodeType = TrieNode<TrieValue>;
export default function UserSearchBar({ isServerRendered }: PropTypes) {
isServerRendered = !!isServerRendered;
const [pendingRequest, setPendingRequest] = useState<AbortController | null>(
null,
);
const [state, setState] = useState({
userName: '',
userList: [] as ServerResponse['users'],
selectedIdx: null as number | null,
errorMessage: null as string | null,
typingSettled: true,
isFocused: isServerRendered ? false : true,
});
const inputRef = useRef(null);
const clearResults = useCallback(() => {
setState((s) => ({
...s,
userList: [],
errorMessage: null,
selectedIdx: null,
}));
}, []);
const cancelPendingRequest = useCallback(async () => {
if (pendingRequest) {
setPendingRequest(null);
pendingRequest.abort();
}
}, [pendingRequest, setPendingRequest]);
const sendSearchRequest = useCallback(
(userName) => {
cancelPendingRequest();
const controller = new AbortController();
setPendingRequest(controller);
async function sendRequest() {
try {
let req = await fetch(
`${CONFIG.HOST}/api/fa/search_user_names?name=${userName}`,
{
signal: controller.signal,
},
);
setPendingRequest(null);
setState((s) => ({
...s,
errorMessage: null,
}));
if (req.status != 200) {
const error_json = await req.json();
setState((s) => ({
...s,
errorMessage: `error loading users: ${error_json.error || JSON.stringify(error_json)}`,
}));
} else {
let gotUserList = await req.json();
setState((s) => ({
...s,
userList: gotUserList.users,
}));
}
} catch (err) {
if (!err.message.includes('aborted')) {
log.error('error loading user trie: ', err);
setState((s) => ({
...s,
errorMessage: `error loading users: ` + err.message,
}));
}
}
}
sendRequest();
return () => controller.abort();
},
[cancelPendingRequest, setPendingRequest],
);
const searchForUser = useCallback(
(userName: string) => {
setState((s) => ({ ...s, userName }));
if (isEmpty(userName)) {
clearResults();
} else {
sendSearchRequest(userName);
}
},
[clearResults, sendSearchRequest],
);
const searchForUserDebounced = useCallback(
debounce(async (userName) => {
log.info('sending search for ', userName);
setState((s) => ({ ...s, typingSettled: true }));
searchForUser(userName);
}, 250),
[searchForUser],
);
function invokeIdx(idx) {
const user = state.userList[idx];
if (user) {
log.info('selecting user: ', user);
setState((s) => ({ ...s, userName: user.name }));
inputRef.current.value = user.name;
window.location.href = user.show_path;
}
}
function invokeSelected() {
if (state.selectedIdx != null) {
invokeIdx(state.selectedIdx);
}
}
const visibility = {
error: state.isFocused && !isEmpty(state.errorMessage),
info:
state.isFocused &&
!isEmpty(state.userName) &&
!pendingRequest &&
state.typingSettled &&
state.userList.length === 0,
items: !isEmpty(state.userName) && state.userList.length > 0,
};
const anyShown = Object.values(visibility).some(Boolean);
function UserSearchBarItems() {
return (
<div
className={`${anyShown || 'border-b-0'} divide-y divide-inherit rounded-b-lg border border-t-0 border-inherit`}
>
{visibility.error ? (
<ListItem
key="error"
isLast={!visibility.info && state.userList.length == 0}
selected={false}
style="error"
value={state.errorMessage}
/>
) : null}
{visibility.info ? (
<ListItem
key="info"
isLast={!visibility.items}
selected={false}
style="info"
value="No users found"
/>
) : null}
{visibility.items
? state.userList.map(({ name, thumb, show_path, num_posts }, idx) => (
<ListItem
key={'name-' + name}
isLast={idx == state.userList.length - 1}
selected={idx == state.selectedIdx}
style="item"
value={name}
thumb={thumb}
href={show_path}
subtext={`${num_posts.toString()} posts`}
/>
))
: null}
</div>
);
}
const keyHandlers = {
Tab: (shiftKey: boolean) =>
shiftKey ? selectPrevListElem() : selectNextListElem(),
ArrowDown: () => selectNextListElem(),
ArrowUp: () => selectPrevListElem(),
Enter: () => invokeSelected(),
};
function onSearchInputKeyDown(event: React.KeyboardEvent) {
const handler = keyHandlers[event.code];
if (handler) {
event.preventDefault();
handler(event.shiftKey);
}
}
function selectNextListElem() {
setNewIdxTruncated(state.selectedIdx == null ? 0 : state.selectedIdx + 1);
}
function selectPrevListElem() {
setNewIdxTruncated(state.selectedIdx == null ? -1 : state.selectedIdx - 1);
}
function setNewIdxTruncated(newIdx) {
if (state.userList.length == 0) {
newIdx = null;
} else {
if (newIdx >= state.userList.length) {
newIdx = 0;
} else if (newIdx < 0) {
newIdx = state.userList.length - 1;
}
}
setState((s) => ({ ...s, selectedIdx: newIdx }));
}
return (
<div
className={[
'group mx-auto w-full p-2 transition-colors duration-1000 sm:rounded-xl',
'focus-within:border-slate-400 sm:max-w-md',
'border-slate-300 bg-slate-50 p-2 shadow-lg',
].join(' ')}
>
<label className={`relative block ${STYLES.INPUT_CLASSNAME}`}>
<Icon
type="magnifying-glass"
className={`ml-2 ${STYLES.SVG_FOCUSABLE_CLASSNAME}`}
/>
{pendingRequest && (
<Icon
type="spinner"
className={`right-2 ${STYLES.SVG_BASE_CLASSNAME}`}
/>
)}
<input
autoFocus
className={[
STYLES.LIST_ELEM_CLASSNAME,
STYLES.INPUT_CLASSNAME,
'rounded-lg outline-none',
'bg-slate-50 placeholder:italic',
anyShown && 'rounded-b-none',
]
.filter(Boolean)
.join(' ')}
placeholder="Search FurAffinity Users?!?"
defaultValue={state.userName}
onChange={(e) => {
setState((s) => ({ ...s, typingSettled: false }));
searchForUserDebounced(e.target.value);
}}
onKeyDown={onSearchInputKeyDown}
onFocus={() => setState((s) => ({ ...s, isFocused: true }))}
onBlur={() => setState((s) => ({ ...s, isFocused: false }))}
ref={inputRef}
/>
</label>
<UserSearchBarItems />
</div>
);
}