refactor UserSearchBar, js formatting

This commit is contained in:
Dylan Knutson
2024-12-22 19:10:33 +00:00
parent bded4ba2bf
commit 9265b55876
20 changed files with 387 additions and 364 deletions

View File

@@ -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
View File

@@ -0,0 +1,8 @@
{
"tabWidth": 2,
"useTabs": false,
"trailingComma": "all",
"arrowParens": "always",
"singleQuote": true,
"plugins": ["prettier-plugin-tailwindcss"]
}

75
.vscode/settings.json vendored
View File

@@ -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
}

View File

@@ -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",

View File

@@ -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)

View File

@@ -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

View File

@@ -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}`;
}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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} />;

View File

@@ -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 };
}
}

View File

@@ -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({

View File

@@ -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({

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'),
],
};

View File

@@ -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",

View File

@@ -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"