refactor UserSearchBar, js formatting
This commit is contained in:
@@ -11,4 +11,5 @@ install_extension dbaeumer.vscode-eslint
|
||||
install_extension aliariff.vscode-erb-beautify
|
||||
install_extension bradlc.vscode-tailwindcss
|
||||
install_extension KoichiSasada.vscode-rdbg
|
||||
install_extension qwtel.sqlite-viewer
|
||||
install_extension qwtel.sqlite-viewer
|
||||
install_extension esbenp.prettier-vscode
|
||||
8
.prettierrc
Normal file
8
.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"trailingComma": "all",
|
||||
"arrowParens": "always",
|
||||
"singleQuote": true,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
75
.vscode/settings.json
vendored
75
.vscode/settings.json
vendored
@@ -1,28 +1,49 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"workbench.editor.titleScrollbarSizing": "large",
|
||||
"window.title": "${activeEditorMedium}${separator}${rootName}${separator}${profileName}",
|
||||
"workbench.preferredDarkColorTheme": "Spinel",
|
||||
"workbench.preferredLightColorTheme": "Spinel Light",
|
||||
"rubyLsp.formatter": "syntax_tree",
|
||||
"[ruby]": {
|
||||
"editor.defaultFormatter": "Shopify.ruby-lsp"
|
||||
},
|
||||
"[erb]": {
|
||||
"editor.defaultFormatter": "aliariff.vscode-erb-beautify"
|
||||
},
|
||||
"tailwindCSS.includeLanguages": {
|
||||
"erb": "html",
|
||||
"typescript": "javascript"
|
||||
},
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
"\\bclass:\\s*'([^']*)'",
|
||||
"\\bclass:\\s*\"([^\"]*)\""
|
||||
],
|
||||
"editor.quickSuggestions": {
|
||||
"other": "on",
|
||||
"comments": "off",
|
||||
"strings": "on"
|
||||
},
|
||||
"sqliteViewer.maxFileSize": 4000
|
||||
}
|
||||
"editor.formatOnSave": true,
|
||||
"workbench.editor.titleScrollbarSizing": "large",
|
||||
"window.title": "${activeEditorMedium}${separator}${rootName}${separator}${profileName}",
|
||||
"workbench.preferredDarkColorTheme": "Spinel",
|
||||
"workbench.preferredLightColorTheme": "Spinel Light",
|
||||
"rubyLsp.formatter": "syntax_tree",
|
||||
"[ruby]": {
|
||||
"editor.defaultFormatter": "Shopify.ruby-lsp"
|
||||
},
|
||||
"[erb]": {
|
||||
"editor.defaultFormatter": "aliariff.vscode-erb-beautify"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"tailwindCSS.includeLanguages": {
|
||||
"erb": "html",
|
||||
"typescript": "javascript"
|
||||
},
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
"\\bclass:\\s*'([^']*)'",
|
||||
"\\bclass:\\s*\"([^\"]*)\"",
|
||||
"[\"'`]([^\"'`]*).*?,?\\s?"
|
||||
],
|
||||
"editor.quickSuggestions": {
|
||||
"other": "on",
|
||||
"comments": "off",
|
||||
"strings": "on"
|
||||
},
|
||||
"tailwindCSS.experimental.configFile": "config/tailwind.config.js",
|
||||
"sqliteViewer.maxFileSize": 4000,
|
||||
"files.insertFinalNewline": true
|
||||
}
|
||||
|
||||
4
Gemfile
4
Gemfile
@@ -69,7 +69,9 @@ group :development, :staging do
|
||||
|
||||
# Add speed badges [https://github.com/MiniProfiler/rack-mini-profiler]
|
||||
gem "memory_profiler"
|
||||
gem "rack-mini-profiler", require: %w[enable_rails_patches rack-mini-profiler]
|
||||
gem "rack-mini-profiler",
|
||||
"~> 3.3",
|
||||
require: %w[enable_rails_patches rack-mini-profiler]
|
||||
gem "stackprof"
|
||||
|
||||
gem "rails_live_reload",
|
||||
|
||||
@@ -257,7 +257,7 @@ GEM
|
||||
rack (2.2.10)
|
||||
rack-cors (2.0.1)
|
||||
rack (>= 2.0.0)
|
||||
rack-mini-profiler (3.0.0)
|
||||
rack-mini-profiler (3.3.1)
|
||||
rack (>= 1.2.0)
|
||||
rack-proxy (0.7.6)
|
||||
rack
|
||||
@@ -447,7 +447,7 @@ DEPENDENCIES
|
||||
pry-stack_explorer
|
||||
puma (~> 5.0)
|
||||
rack-cors
|
||||
rack-mini-profiler
|
||||
rack-mini-profiler (~> 3.3)
|
||||
rails (~> 7.2)
|
||||
rails_live_reload!
|
||||
rails_semantic_logger (~> 4.17)
|
||||
|
||||
@@ -339,6 +339,9 @@ class Domain::Fa::ApiController < ApplicationController
|
||||
)
|
||||
.includes(:avatar)
|
||||
.select(:id, :state, :state_detail, :log_entry_detail, :name, :url_name)
|
||||
.select(
|
||||
"(SELECT COUNT(*) FROM domain_fa_posts WHERE creator_id = domain_fa_users.id) as num_posts"
|
||||
)
|
||||
.order(name: :asc)
|
||||
.limit(10)
|
||||
|
||||
@@ -348,7 +351,8 @@ class Domain::Fa::ApiController < ApplicationController
|
||||
name: user.name,
|
||||
url_name: user.url_name,
|
||||
thumb: helpers.fa_user_avatar_path(user, thumb: "64-avatar"),
|
||||
show_path: domain_fa_user_path(user.url_name)
|
||||
show_path: domain_fa_user_path(user.url_name),
|
||||
num_posts: user.num_posts
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import * as React from 'react';
|
||||
|
||||
interface PropTypes {
|
||||
type: "magnifying-glass" | "exclamation-circle" | "spinner";
|
||||
type: 'magnifying-glass' | 'exclamation-circle' | 'spinner';
|
||||
className?: string;
|
||||
}
|
||||
export default function Icon(props: PropTypes) {
|
||||
@@ -10,13 +10,13 @@ export default function Icon(props: PropTypes) {
|
||||
transform top-1/2 -translate-y-1/2 ${props.className}`;
|
||||
|
||||
switch (type) {
|
||||
case "magnifying-glass":
|
||||
case 'magnifying-glass':
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={"2"}
|
||||
strokeWidth={'2'}
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
@@ -26,7 +26,7 @@ export default function Icon(props: PropTypes) {
|
||||
</svg>
|
||||
);
|
||||
|
||||
case "spinner":
|
||||
case 'spinner':
|
||||
return (
|
||||
<svg
|
||||
version="1.1"
|
||||
@@ -49,7 +49,7 @@ export default function Icon(props: PropTypes) {
|
||||
</svg>
|
||||
);
|
||||
|
||||
case "exclamation-circle":
|
||||
case 'exclamation-circle':
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -57,7 +57,7 @@ export default function Icon(props: PropTypes) {
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
className={`w-6 h-6 ${className}`}
|
||||
className={`h-6 w-6 ${className}`}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
@@ -67,6 +67,4 @@ export default function Icon(props: PropTypes) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
throw `unhandled icon type ${type}`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import Icon from "./Icon";
|
||||
import * as React from 'react';
|
||||
import Icon from './Icon';
|
||||
|
||||
const COMMON_LIST_ELEM_CLASSES = `
|
||||
w-full p-2
|
||||
@@ -10,11 +10,12 @@ const COMMON_LIST_ELEM_CLASSES = `
|
||||
|
||||
interface PropTypes {
|
||||
value: string;
|
||||
subtext?: string;
|
||||
thumb?: string;
|
||||
isLast: boolean;
|
||||
selected: boolean;
|
||||
href?: string;
|
||||
style: "item" | "info" | "error";
|
||||
style: 'item' | 'info' | 'error';
|
||||
}
|
||||
|
||||
export default function ListItem({
|
||||
@@ -24,20 +25,20 @@ export default function ListItem({
|
||||
selected,
|
||||
style,
|
||||
href,
|
||||
subtext,
|
||||
}: PropTypes) {
|
||||
const iconClassName = ["ml-2"];
|
||||
const iconClassName = ['ml-2'];
|
||||
const textClassName = [
|
||||
COMMON_LIST_ELEM_CLASSES,
|
||||
"relative",
|
||||
"border-t-0",
|
||||
isLast && "rounded-b-lg",
|
||||
style === "item" && selected && "bg-slate-700 text-slate-100",
|
||||
style === "info" && "text-slate-500 italic",
|
||||
style === "error" && "text-red-500",
|
||||
"hover:bg-slate-600 hover:text-slate-200"
|
||||
'relative flex items-center justify-between',
|
||||
'border-t-0',
|
||||
isLast && 'rounded-b-lg',
|
||||
style === 'item' && selected && 'bg-slate-700 text-slate-100',
|
||||
style === 'info' && 'text-slate-500 italic',
|
||||
style === 'error' && 'text-red-500',
|
||||
'hover:bg-slate-600 hover:text-slate-200',
|
||||
].filter(Boolean);
|
||||
|
||||
|
||||
return (
|
||||
<a
|
||||
className="relative block"
|
||||
@@ -48,14 +49,21 @@ export default function ListItem({
|
||||
}}
|
||||
href={href}
|
||||
>
|
||||
{style === "error" && (
|
||||
<Icon type="exclamation-circle" className={iconClassName.join(" ")} />
|
||||
{style === 'error' && (
|
||||
<Icon type="exclamation-circle" className={iconClassName.join(' ')} />
|
||||
)}
|
||||
<div className={textClassName.join(" ")}>
|
||||
<div className={textClassName.join(' ')}>
|
||||
<div className="inline-block w-8">
|
||||
{thumb && <img src={thumb} alt="thumbnail" className="inline w-full" />}
|
||||
{thumb && (
|
||||
<img src={thumb} alt="thumbnail" className="inline w-full" />
|
||||
)}
|
||||
</div>
|
||||
<div className="inline-block pl-1">{value}</div>
|
||||
<div className="inline-block flex-grow pl-1">{value}</div>
|
||||
{subtext && (
|
||||
<div className="vertical-align-middle float-right inline-block pl-1 text-sm italic text-slate-500">
|
||||
{subtext}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -1,81 +1,76 @@
|
||||
import { debounce, isEmpty } from "lodash";
|
||||
import * as React from "react";
|
||||
import { StrictMode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import Icon from "./Icon";
|
||||
import ListItem from "./ListItem";
|
||||
import Trie, { TrieNode } from "../lib/Trie";
|
||||
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';
|
||||
|
||||
// staging
|
||||
// const HOST = "http://scraper.local:3001";
|
||||
const LOG = false;
|
||||
const HOST = "";
|
||||
const COMMON_LIST_ELEM_CLASSES = `
|
||||
w-full p-2 pl-8
|
||||
text-xl font-light
|
||||
border-slate-200 border-2
|
||||
group-focus-within:border-slate-300
|
||||
`;
|
||||
const SVG_ELEM_CLASSES = `
|
||||
stroke-slate-500 fill-slate-500
|
||||
`;
|
||||
const FOCUSABLE_SVG_ELEM_CLASSES = `
|
||||
${SVG_ELEM_CLASSES}
|
||||
group-focus-within:stroke-slate-800 group-focus-within:fill-slate-800
|
||||
`;
|
||||
const INPUT_ELEM_CLASSES = `
|
||||
text-slate-500 group-focus-within:text-slate-800
|
||||
placeholder-slate-500 group-focus-within:placeholder-slate-800
|
||||
placeholder:font-extralight
|
||||
`;
|
||||
// 1. Group related constants
|
||||
const CONFIG = {
|
||||
HOST: '',
|
||||
LOG: false,
|
||||
} as const;
|
||||
|
||||
function log_info(...args) {
|
||||
if (LOG) {
|
||||
console.log(...args);
|
||||
}
|
||||
}
|
||||
function log_error(...args) {
|
||||
if (LOG) {
|
||||
console.error(...args);
|
||||
}
|
||||
}
|
||||
const STYLES = {
|
||||
LIST_ELEM_CLASSNAME: [
|
||||
'w-full p-2 pl-8 text-xl font-light border-slate-200 border-2',
|
||||
'group-focus-within:border-slate-300',
|
||||
],
|
||||
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 ServerResponse {
|
||||
users: {
|
||||
id: number;
|
||||
name: string;
|
||||
url_name: string;
|
||||
thumb?: string;
|
||||
show_path: string;
|
||||
}[];
|
||||
|
||||
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 [userName, setUserName] = useState("");
|
||||
const [userList, setUserList] = useState<ServerResponse["users"]>([]);
|
||||
const [selectedIdx, setSelectedIdx] = useState<number | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [pendingRequest, setPendingRequest] = useState<AbortController | null>(
|
||||
null
|
||||
null,
|
||||
);
|
||||
const [typingSettled, setTypingSettled] = useState(true);
|
||||
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 [isFocused, setIsFocused] = useState(
|
||||
isServerRendered
|
||||
? false
|
||||
: document.activeElement == inputRef.current || true
|
||||
);
|
||||
|
||||
const clearResults = useCallback(() => {
|
||||
setUserList([]);
|
||||
setErrorMessage(null);
|
||||
setSelectedIdx(null);
|
||||
setState((s) => ({
|
||||
...s,
|
||||
userList: [],
|
||||
errorMessage: null,
|
||||
selectedIdx: null,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const cancelPendingRequest = useCallback(async () => {
|
||||
@@ -95,23 +90,38 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
||||
async function sendRequest() {
|
||||
try {
|
||||
let req = await fetch(
|
||||
`${HOST}/api/fa/search_user_names?name=${userName}`,
|
||||
`${CONFIG.HOST}/api/fa/search_user_names?name=${userName}`,
|
||||
{
|
||||
signal: controller.signal,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
setPendingRequest(null);
|
||||
setState((s) => ({
|
||||
...s,
|
||||
errorMessage: null,
|
||||
}));
|
||||
|
||||
if (req.status != 200) {
|
||||
setErrorMessage(`error loading users: ${await req.body}`);
|
||||
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();
|
||||
setPendingRequest(null);
|
||||
setUserList(gotUserList.users);
|
||||
setState((s) => ({
|
||||
...s,
|
||||
userList: gotUserList.users,
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!err.message.includes("aborted")) {
|
||||
log_error("error loading user trie: ", err);
|
||||
setPendingRequest(null);
|
||||
setErrorMessage(`error loading users: ` + err.message);
|
||||
if (!err.message.includes('aborted')) {
|
||||
log.error('error loading user trie: ', err);
|
||||
setState((s) => ({
|
||||
...s,
|
||||
errorMessage: `error loading users: ` + err.message,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,137 +129,90 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
||||
|
||||
return () => controller.abort();
|
||||
},
|
||||
[cancelPendingRequest, setPendingRequest]
|
||||
[cancelPendingRequest, setPendingRequest],
|
||||
);
|
||||
|
||||
const searchForUser = useCallback(
|
||||
(userName: string) => {
|
||||
setUserName(userName);
|
||||
setState((s) => ({ ...s, userName }));
|
||||
if (isEmpty(userName)) {
|
||||
clearResults();
|
||||
} else {
|
||||
sendSearchRequest(userName);
|
||||
}
|
||||
},
|
||||
[clearResults, sendSearchRequest]
|
||||
[clearResults, sendSearchRequest],
|
||||
);
|
||||
|
||||
const searchForUserDebounced = useCallback(
|
||||
debounce(async (userName) => {
|
||||
log_info("sending search for ", userName);
|
||||
setTypingSettled(true);
|
||||
log.info('sending search for ', userName);
|
||||
setState((s) => ({ ...s, typingSettled: true }));
|
||||
searchForUser(userName);
|
||||
}, 250),
|
||||
[setTypingSettled, searchForUser]
|
||||
[searchForUser],
|
||||
);
|
||||
|
||||
function invokeIdx(idx) {
|
||||
const user = userList[idx];
|
||||
const user = state.userList[idx];
|
||||
if (user) {
|
||||
log_info("selecting user: ", user);
|
||||
setUserName(user.name);
|
||||
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 (selectedIdx != null) {
|
||||
invokeIdx(selectedIdx);
|
||||
if (state.selectedIdx != null) {
|
||||
invokeIdx(state.selectedIdx);
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchInputKeyDown(event) {
|
||||
const { code, shiftKey } = event;
|
||||
switch (code) {
|
||||
case "Tab":
|
||||
if (shiftKey) {
|
||||
selectPrevListElem();
|
||||
} else {
|
||||
selectNextListElem();
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
selectNextListElem();
|
||||
break;
|
||||
case "ArrowUp":
|
||||
selectPrevListElem();
|
||||
break;
|
||||
case "Enter":
|
||||
invokeSelected();
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function selectNextListElem() {
|
||||
setNewIdxTruncated(selectedIdx == null ? 0 : selectedIdx + 1);
|
||||
}
|
||||
|
||||
function selectPrevListElem() {
|
||||
setNewIdxTruncated(selectedIdx == null ? -1 : selectedIdx - 1);
|
||||
}
|
||||
|
||||
function setNewIdxTruncated(newIdx) {
|
||||
if (userList.length == 0) {
|
||||
newIdx = null;
|
||||
} else {
|
||||
if (newIdx >= userList.length) {
|
||||
newIdx = 0;
|
||||
} else if (newIdx < 0) {
|
||||
newIdx = userList.length - 1;
|
||||
}
|
||||
}
|
||||
setSelectedIdx(newIdx);
|
||||
}
|
||||
|
||||
const errorShown = isFocused && !isEmpty(errorMessage);
|
||||
const infoShown =
|
||||
isFocused &&
|
||||
!isEmpty(userName) &&
|
||||
pendingRequest == null &&
|
||||
typingSettled &&
|
||||
userList.length == 0;
|
||||
const itemsShown =
|
||||
!isEmpty(userName) && userList.length > 0;
|
||||
const anyShown = infoShown || errorShown || itemsShown;
|
||||
|
||||
console.log("any shown", {anyShown});
|
||||
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>
|
||||
{errorShown ? (
|
||||
{visibility.error ? (
|
||||
<ListItem
|
||||
key="error"
|
||||
isLast={!infoShown && userList.length == 0}
|
||||
isLast={!visibility.info && state.userList.length == 0}
|
||||
selected={false}
|
||||
style="error"
|
||||
value={errorMessage}
|
||||
value={state.errorMessage}
|
||||
/>
|
||||
) : null}
|
||||
{infoShown ? (
|
||||
{visibility.info ? (
|
||||
<ListItem
|
||||
key="info"
|
||||
isLast={!itemsShown}
|
||||
isLast={!visibility.items}
|
||||
selected={false}
|
||||
style="info"
|
||||
value="No users found"
|
||||
/>
|
||||
) : null}
|
||||
{itemsShown
|
||||
? userList.map(({ name, thumb, show_path }, idx) => (
|
||||
{visibility.items
|
||||
? state.userList.map(({ name, thumb, show_path, num_posts }, idx) => (
|
||||
<ListItem
|
||||
key={"name-" + name}
|
||||
isLast={idx == userList.length - 1}
|
||||
selected={idx == selectedIdx}
|
||||
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}
|
||||
@@ -257,43 +220,82 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
sm:p-2 sm:border-2 sm:border-slate-100 sm:max-w-md rounded-xl
|
||||
focus-within:border-slate-200 focus-within:shadow-md
|
||||
transition-all duration-1000
|
||||
"
|
||||
className={[
|
||||
'group mx-auto w-full rounded-xl p-2 transition-all duration-1000',
|
||||
'focus-within:border-slate-200 focus-within:shadow-md sm:max-w-md',
|
||||
'sm:border-2 sm:border-slate-100 sm:p-2',
|
||||
].join(' ')}
|
||||
>
|
||||
<label className={`relative block ${INPUT_ELEM_CLASSES}`}>
|
||||
<label className={`relative block ${STYLES.INPUT_CLASSNAME}`}>
|
||||
<Icon
|
||||
type="magnifying-glass"
|
||||
className={`ml-2 ${FOCUSABLE_SVG_ELEM_CLASSES}`}
|
||||
className={`ml-2 ${STYLES.SVG_FOCUSABLE_CLASSNAME}`}
|
||||
/>
|
||||
{pendingRequest != null ? (
|
||||
<Icon type="spinner" className={`right-2 ${SVG_ELEM_CLASSES}`} />
|
||||
) : null}
|
||||
{pendingRequest && (
|
||||
<Icon
|
||||
type="spinner"
|
||||
className={`right-2 ${STYLES.SVG_BASE_CLASSNAME}`}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
autoFocus
|
||||
className={[
|
||||
COMMON_LIST_ELEM_CLASSES,
|
||||
INPUT_ELEM_CLASSES,
|
||||
"outline-none rounded-lg",
|
||||
"placeholder:italic bg-slate-100",
|
||||
anyShown ? "rounded-b-none" : null,
|
||||
].join(" ")}
|
||||
placeholder={"Search FurAffinity Users"}
|
||||
defaultValue={userName}
|
||||
onChange={(v) => {
|
||||
setTypingSettled(false);
|
||||
searchForUserDebounced(v.target.value);
|
||||
STYLES.LIST_ELEM_CLASSNAME,
|
||||
STYLES.INPUT_CLASSNAME,
|
||||
'rounded-lg outline-none',
|
||||
'bg-slate-100 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={(v) => {
|
||||
setIsFocused(true);
|
||||
onSearchInputKeyDown(v);
|
||||
}}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onKeyDown={onSearchInputKeyDown}
|
||||
onFocus={() => setState((s) => ({ ...s, isFocused: true }))}
|
||||
onBlur={() => setState((s) => ({ ...s, isFocused: false }))}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import UserSearchBar from "./UserSearchBar";
|
||||
import * as React from 'react';
|
||||
import UserSearchBar from './UserSearchBar';
|
||||
|
||||
export default function (props) {
|
||||
return <UserSearchBar {...props} isServerRendered={true} />;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { exact } from "prop-types";
|
||||
|
||||
interface SerializedTrie<T> {
|
||||
// terminal node?
|
||||
t: 1 | 0;
|
||||
@@ -44,11 +42,11 @@ export default class Trie<T> {
|
||||
let remaining = key;
|
||||
while (node && remaining.length > 0) {
|
||||
let exactChild = null;
|
||||
console.log("remaining: ", remaining);
|
||||
console.log('remaining: ', remaining);
|
||||
|
||||
for (const [childKey, child] of node.children.entries()) {
|
||||
if (remaining.startsWith(childKey)) {
|
||||
console.log("exact match for: ", childKey);
|
||||
console.log('exact match for: ', childKey);
|
||||
exactChild = child;
|
||||
chain.push(childKey);
|
||||
remaining = remaining.slice(childKey.length);
|
||||
@@ -62,32 +60,32 @@ export default class Trie<T> {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log("looking for partial match for ", remaining);
|
||||
console.log('looking for partial match for ', remaining);
|
||||
for (const [childKey, child] of node.children.entries()) {
|
||||
const startsWith = childKey.startsWith(remaining);
|
||||
console.log(
|
||||
"test ",
|
||||
'test ',
|
||||
childKey,
|
||||
" against ",
|
||||
' against ',
|
||||
remaining,
|
||||
": ",
|
||||
': ',
|
||||
startsWith,
|
||||
" ",
|
||||
child.serialized
|
||||
' ',
|
||||
child.serialized,
|
||||
);
|
||||
if (startsWith) {
|
||||
console.log("partial match for: ", remaining, ": ", child.serialized);
|
||||
console.log('partial match for: ', remaining, ': ', child.serialized);
|
||||
chain.push(childKey);
|
||||
return { chain, node: child };
|
||||
}
|
||||
}
|
||||
|
||||
console.log("did not find partial, bailing!");
|
||||
console.log('did not find partial, bailing!');
|
||||
return { chain, node: null };
|
||||
}
|
||||
|
||||
// // return remaining.length === 0 && node && node.terminal ? node : null;
|
||||
console.log("returning child ", node, " for remaining ", remaining);
|
||||
console.log('returning child ', node, ' for remaining ', remaining);
|
||||
return { chain, node };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ReactOnRails from "react-on-rails";
|
||||
import ReactOnRails from 'react-on-rails';
|
||||
|
||||
import UserSearchBar from "../bundles/Main/components/UserSearchBar";
|
||||
import UserSearchBar from '../bundles/Main/components/UserSearchBar';
|
||||
|
||||
// This is how react_on_rails can see the UserSearchBar in the browser.
|
||||
ReactOnRails.register({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ReactOnRails from "react-on-rails";
|
||||
import ReactOnRails from 'react-on-rails';
|
||||
|
||||
import UserSearchBar from "../bundles/Main/components/UserSearchBarServer";
|
||||
import UserSearchBar from '../bundles/Main/components/UserSearchBarServer';
|
||||
|
||||
// This is how react_on_rails can see the UserSearchBar in the browser.
|
||||
ReactOnRails.register({
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<a href="<%= domain_fa_user_path(user) %>">
|
||||
<img src="<%= fa_user_avatar_path(user, thumb: "64-avatar") %>" class="inline w-8 h-8"/>
|
||||
<span class='underline decoration-dashed'><%= user.url_name %></span>
|
||||
</a>
|
||||
<div class="flex items-center w-full">
|
||||
<a href="<%= domain_fa_user_path(user) %>" class="grow flex items-center gap-2">
|
||||
<img src="<%= fa_user_avatar_path(user, thumb: "64-avatar") %>" class="w-8 h-8"/>
|
||||
<span class='underline decoration-dashed text-slate-600 hover:text-slate-900'><%= user.url_name %></span>
|
||||
</a>
|
||||
<span class="text-slate-500 text-sm"><%= pluralize(number_with_delimiter(user.posts.count, delimiter: ","), "post") %></span>
|
||||
</div>
|
||||
|
||||
@@ -1,50 +1,24 @@
|
||||
<% posts = user.posts.limit(5).order(fa_id: :desc) %>
|
||||
<% return unless posts.any? %>
|
||||
<section class='border-2 border-slate-300 rounded-md mb-2'>
|
||||
<div class='border-b-2 border-b-slate-300 text-slate-600 p-1 italic flex'>
|
||||
<span class='text-lg grow'>
|
||||
Recent Posts <span class='text-sm'>(<%= link_to "#{user.posts.count} total", domain_fa_user_posts_path(user), class: "underline" %>)</span>
|
||||
</span>
|
||||
<span class='text-sm self-center'>Posted at</span>
|
||||
<section class='border border-slate-300 rounded-lg overflow-hidden bg-white shadow-sm'>
|
||||
<div class='px-4 py-3 border-b border-slate-200'>
|
||||
<h2 class='text-lg font-medium text-slate-900'>Recent Posts</h2>
|
||||
</div>
|
||||
<ul>
|
||||
<% posts.each do |post| %>
|
||||
<li class='p-1 border-b last:border-b-0 border-slate-300 flex'>
|
||||
<span class='grow'>
|
||||
<% post_link_opts = { class: "underline decoration-dashed text-slate-700" } %>
|
||||
<% fa_post_link = "https://www.furaffinity.net/view/#{post.fa_id}" %>
|
||||
<% fa_post_link_opts = post_link_opts.merge({
|
||||
target: "_blank",
|
||||
rel: "noreferrer,nofollow",
|
||||
}) %>
|
||||
<% if can_see_hosted_post?(post) %>
|
||||
<%= link_to(
|
||||
post.title,
|
||||
domain_fa_post_path(post.fa_id),
|
||||
post_link_opts
|
||||
) %> -
|
||||
<%= link_to(fa_post_link, fa_post_link_opts) do %>
|
||||
<% image_tag(
|
||||
image_path("arrow-top-right-on-square.svg"),
|
||||
class: "w-4 inline",
|
||||
) %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= link_to(
|
||||
post.title,
|
||||
fa_post_link,
|
||||
fa_post_link_opts
|
||||
) %>
|
||||
<% end %>
|
||||
</span>
|
||||
<span class='text-sm'>
|
||||
<% if post.posted_at %>
|
||||
<%= time_ago_in_words(post.posted_at) %> ago
|
||||
<% else %>
|
||||
<i><%= post.state %></i>
|
||||
<% end %>
|
||||
</span>
|
||||
</li>
|
||||
<div>
|
||||
<% if user.posts.any? %>
|
||||
<div class="divide-y divide-slate-200">
|
||||
<% user.posts.order(created_at: :desc).limit(5).each do |post| %>
|
||||
<div class='flex items-center px-4 py-2'>
|
||||
<span class="grow text-slate-600">
|
||||
<%= link_to post.title, "https://www.furaffinity.net/view/#{post.fa_id}/",
|
||||
class: "underline decoration-slate-300 hover:decoration-slate-600 hover:text-slate-900 transition-all",
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer" %>
|
||||
</span>
|
||||
<span class="text-slate-900"><%= time_ago_in_words(post.created_at) %> ago</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class='px-4 py-3 text-slate-500'>No posts found</div>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<div id="<%= dom_id user %>" class="w-full max-w-2xl mt-4 mx-auto p-2">
|
||||
<section class='flex flex-row border-2 border-slate-300 rounded-md p-1 mb-2'>
|
||||
<div class="grow flex ">
|
||||
<img src="<%= fa_user_avatar_path(user) %>" class="inline w-10 h-10 self-center mx-2"/>
|
||||
<div class='inline-block'>
|
||||
<div class="text-xl"><%= user.url_name %></div>
|
||||
<div class='text-slate-400 text-sm font-mono'>
|
||||
<span><%= fa_user_account_status(user) %></span>,
|
||||
<div id="<%= dom_id user %>" class="w-full max-w-2xl mt-6 mx-auto space-y-6">
|
||||
<section class='flex flex-row border border-slate-300 rounded-lg p-4 bg-white shadow-sm'>
|
||||
<div class="grow flex items-center gap-4">
|
||||
<img src="<%= fa_user_avatar_path(user) %>" class="w-12 h-12 rounded-lg"/>
|
||||
<div>
|
||||
<div class="text-lg font-medium text-slate-900"><%= user.url_name %></div>
|
||||
<div class='text-slate-500 text-sm'>
|
||||
<span><%= fa_user_account_status(user) %></span> •
|
||||
<span><%= user.state %></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,63 +13,61 @@
|
||||
<a
|
||||
href="https://www.furaffinity.net/user/<%= user.url_name %>/"
|
||||
target="_blank" rel='noopener noreferrer'
|
||||
class="underline decoration-dashed text-slate-600 self-center"
|
||||
class="flex items-center gap-2 text-slate-600 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
FurAffinity <img src="<%= image_path("fa-logo.png") %>" class='w-5 inline' />
|
||||
<span class="font-medium">FurAffinity</span>
|
||||
<img src="<%= image_path("fa-logo.png") %>" class='w-5 h-5' />
|
||||
</a>
|
||||
</section>
|
||||
<section class='border-2 border-slate-300 rounded-md p-1 mb-2'>
|
||||
<div class='flex'>
|
||||
<span class="grow">Posts</span>
|
||||
<span><%= number_with_delimiter(user.posts.count, delimiter: ",") %></span>
|
||||
</div>
|
||||
<div class='flex'>
|
||||
<span class="grow">Favorites</span>
|
||||
<span><%= number_with_delimiter(user.num_favorites, delimiter: ",") %></span>
|
||||
</div>
|
||||
<div class='flex'>
|
||||
<span class="grow">Following</span>
|
||||
<span><%= number_with_delimiter(user.follower_joins.count, delimiter: ",") %></span>
|
||||
</div>
|
||||
<div class='flex'>
|
||||
<span class="grow">Followed by</span>
|
||||
<span><%= number_with_delimiter(user.followed_joins.count, delimiter: ",") %></span>
|
||||
</div>
|
||||
<div class='flex'>
|
||||
<span class="grow">Gallery scanned</span>
|
||||
<span><%= user.time_ago_for_gallery_scan %></span>
|
||||
</div>
|
||||
<div class='flex'>
|
||||
<span class="grow">Page scanned</span>
|
||||
<span><%= user.time_ago_for_page_scan %></span>
|
||||
</div>
|
||||
<section class='border border-slate-300 rounded-lg bg-white shadow-sm divide-y divide-slate-200'>
|
||||
<% [
|
||||
["Posts", user.posts.count],
|
||||
["Favorites", user.num_favorites],
|
||||
["Following", user.follower_joins.count],
|
||||
["Followed by", user.followed_joins.count],
|
||||
["Gallery scanned", user.time_ago_for_gallery_scan],
|
||||
["Page scanned", user.time_ago_for_page_scan]
|
||||
].each do |label, value| %>
|
||||
<div class='flex items-center px-4 py-2'>
|
||||
<span class="grow text-slate-600"><%= label %></span>
|
||||
<span class="text-slate-900"><%= value.is_a?(Integer) ? number_with_delimiter(value, delimiter: ",") : value %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
<%= render "recent_posts_section", user: user %>
|
||||
<section class='border-2 border-slate-300 rounded-md mb-2 overflow-clip'>
|
||||
<section class='border border-slate-300 rounded-lg overflow-hidden bg-white shadow-sm'>
|
||||
<% if (profile_html = user.profile_html) %>
|
||||
<div class='text-lg border-b-2 border-b-slate-300 text-slate-600 p-1 italic'>Profile Description</div>
|
||||
<div class='p-2 bg-slate-800 text-slate-200'>
|
||||
<div class='px-4 py-3 border-b border-slate-200 bg-white'>
|
||||
<h2 class='text-lg font-medium text-slate-900'>Profile Description</h2>
|
||||
</div>
|
||||
<div class='p-4 bg-slate-800 text-slate-200'>
|
||||
<% cache(user, expires_in: 12.hours) do %>
|
||||
<%= sanitized_fa_user_profile_html(profile_html) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div>(No page description)</div>
|
||||
<div class="px-4 py-3 text-slate-500">No profile description available</div>
|
||||
<% end %>
|
||||
</section>
|
||||
<section class='border-2 border-slate-300 rounded-md'>
|
||||
<div class='text-lg border-b-2 border-b-slate-300 text-slate-600 p-1 italic'>Similar Users</div>
|
||||
<div class='bg-slate-100'>
|
||||
<section class='border border-slate-300 rounded-lg overflow-hidden bg-white shadow-sm'>
|
||||
<div class='px-4 py-3 border-b border-slate-200'>
|
||||
<h2 class='text-lg font-medium text-slate-900'>Similar Users</h2>
|
||||
</div>
|
||||
<div>
|
||||
<% cache(user.disco, expires_in: 12.hours) do %>
|
||||
<% similar = similar_users_by_followed(user)&.includes(:avatar) %>
|
||||
<% if similar %>
|
||||
<% similar.each do |user| %>
|
||||
<div class='flex flex-row py-1 px-2 border-b-2 last:border-b-0'>
|
||||
<%= render "inline_link", user: user %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="divide-y divide-slate-200">
|
||||
<% similar.each do |user| %>
|
||||
<div class='flex items-center px-4 py-2'>
|
||||
<span class="grow">
|
||||
<%= render "inline_link", user: user %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class='p-2'>No similar users</div>
|
||||
<div class='px-4 py-3 text-slate-500'>No similar users found</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
const defaultTheme = require("tailwindcss/defaultTheme");
|
||||
const defaultTheme = require('tailwindcss/defaultTheme');
|
||||
|
||||
module.exports = {
|
||||
mode: "jit",
|
||||
mode: 'jit',
|
||||
content: [
|
||||
"./public/*.html",
|
||||
"./app/helpers/**/*.rb",
|
||||
"./app/javascript/**/*.{jsx,js,tsx,ts}",
|
||||
"./app/views/**/*.{erb,haml,html,slim}",
|
||||
'./public/*.html',
|
||||
'./app/helpers/**/*.rb',
|
||||
'./app/javascript/**/*.{jsx,js,tsx,ts}',
|
||||
'./app/views/**/*.{erb,haml,html,slim}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["Inter var", ...defaultTheme.fontFamily.sans],
|
||||
sans: ['Inter var', ...defaultTheme.fontFamily.sans],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require("@tailwindcss/forms"),
|
||||
require("@tailwindcss/aspect-ratio"),
|
||||
require("@tailwindcss/typography"),
|
||||
require("@tailwindcss/container-queries"),
|
||||
require('@tailwindcss/forms'),
|
||||
require('@tailwindcss/aspect-ratio'),
|
||||
require('@tailwindcss/typography'),
|
||||
require('@tailwindcss/container-queries'),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"@types/lodash": "^4.14.192",
|
||||
"@types/react": "^18.0.33",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||
"react-refresh": "^0.14.0",
|
||||
"typescript": "^5.0.3",
|
||||
"webpack": "5",
|
||||
|
||||
@@ -3986,6 +3986,11 @@ postcss@^8.4.19, postcss@^8.4.21, postcss@^8.4.47, postcss@^8.4.49:
|
||||
picocolors "^1.1.1"
|
||||
source-map-js "^1.2.1"
|
||||
|
||||
prettier-plugin-tailwindcss@^0.6.9:
|
||||
version "0.6.9"
|
||||
resolved "https://registry.yarnpkg.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.9.tgz#db84c32918eae9b44e5a5f0aa4d1249cc39fa739"
|
||||
integrity sha512-r0i3uhaZAXYP0At5xGfJH876W3HHGHDp+LCRUJrs57PBeQ6mYHMwr25KH8NPX44F2yGTvdnH7OqCshlQx183Eg==
|
||||
|
||||
prettier@^3.4.2:
|
||||
version "3.4.2"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.4.2.tgz#a5ce1fb522a588bf2b78ca44c6e6fe5aa5a2b13f"
|
||||
|
||||
Reference in New Issue
Block a user