popover link for users
This commit is contained in:
1
TODO.md
1
TODO.md
@@ -33,3 +33,4 @@
|
||||
- [ ] Make unified Avatar file job
|
||||
- [ ] ko-fi domain icon
|
||||
- [ ] Do PCA on user factors table to display a 2D plot of users
|
||||
- [ ] Use links found in descriptions to indicate re-scanning a post? (e.g. for comic next/prev links)
|
||||
|
||||
@@ -27,30 +27,29 @@ module Domain::PostsHelper
|
||||
DOMAIN_DATA =
|
||||
T.let(
|
||||
{
|
||||
Domain::Post::FaPost =>
|
||||
Domain::DomainType::Fa =>
|
||||
DomainData.new(
|
||||
domain_icon_path: "domain-icons/fa.png",
|
||||
domain_icon_title: "Furaffinity",
|
||||
),
|
||||
Domain::Post::E621Post =>
|
||||
Domain::DomainType::E621 =>
|
||||
DomainData.new(
|
||||
domain_icon_path: "domain-icons/e621.png",
|
||||
domain_icon_title: "E621",
|
||||
),
|
||||
Domain::Post::InkbunnyPost =>
|
||||
Domain::DomainType::Inkbunny =>
|
||||
DomainData.new(
|
||||
domain_icon_path: "domain-icons/inkbunny.png",
|
||||
domain_icon_title: "Inkbunny",
|
||||
),
|
||||
},
|
||||
T::Hash[T.class_of(Domain::Post), DomainData],
|
||||
T::Hash[Domain::DomainType, DomainData],
|
||||
)
|
||||
|
||||
sig { params(post: Domain::Post).returns(String) }
|
||||
def domain_post_domain_icon_path(post)
|
||||
post_class = post.class
|
||||
sig { params(model: HasDomainType).returns(String) }
|
||||
def domain_model_icon_path(model)
|
||||
path =
|
||||
if (domain_data = DOMAIN_DATA[post_class])
|
||||
if (domain_data = DOMAIN_DATA[model.domain_type])
|
||||
domain_data.domain_icon_path
|
||||
else
|
||||
DEFAULT_DOMAIN_DATA.domain_icon_path
|
||||
@@ -58,16 +57,6 @@ module Domain::PostsHelper
|
||||
asset_path(path)
|
||||
end
|
||||
|
||||
sig { params(post: Domain::Post).returns(String) }
|
||||
def domain_post_domain_icon_title(post)
|
||||
post_class = post.class
|
||||
if (domain_data = DOMAIN_DATA[post_class])
|
||||
domain_data.domain_icon_title
|
||||
else
|
||||
DEFAULT_DOMAIN_DATA.domain_icon_title
|
||||
end
|
||||
end
|
||||
|
||||
sig { params(post: Domain::Post).returns(T.nilable(Domain::PostFile)) }
|
||||
def gallery_file_for_post(post)
|
||||
file = post.primary_file_for_view
|
||||
|
||||
@@ -19,6 +19,24 @@ module Domain::UsersHelper
|
||||
end
|
||||
end
|
||||
|
||||
sig { params(user: Domain::User).returns(T.nilable(String)) }
|
||||
def domain_user_registered_at_string_for_view(user)
|
||||
ts = domain_user_registered_at_ts_for_view(user)
|
||||
ts ? time_ago_in_words(ts) : nil
|
||||
end
|
||||
|
||||
sig do
|
||||
params(user: Domain::User).returns(T.nilable(ActiveSupport::TimeWithZone))
|
||||
end
|
||||
def domain_user_registered_at_ts_for_view(user)
|
||||
case user
|
||||
when Domain::User::FaUser, Domain::User::E621User
|
||||
user.registered_at
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
sig do
|
||||
params(
|
||||
avatar: T.nilable(Domain::UserAvatar),
|
||||
|
||||
280
app/javascript/bundles/Main/components/UserHoverPreview.tsx
Normal file
280
app/javascript/bundles/Main/components/UserHoverPreview.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface UserHoverPreviewProps {
|
||||
children: React.ReactNode;
|
||||
userName: string;
|
||||
userAvatarPath: string;
|
||||
userAvatarAlt: string;
|
||||
userDomainIcon?: string;
|
||||
userRegisteredAt?: string;
|
||||
userNumPosts?: number;
|
||||
userNumFollowedBy?: number;
|
||||
userNumFollowed?: number;
|
||||
}
|
||||
|
||||
export const UserHoverPreview: React.FC<UserHoverPreviewProps> = ({
|
||||
children,
|
||||
userName,
|
||||
userAvatarPath,
|
||||
userAvatarAlt,
|
||||
userDomainIcon,
|
||||
userRegisteredAt,
|
||||
userNumPosts,
|
||||
userNumFollowedBy,
|
||||
userNumFollowed,
|
||||
}) => {
|
||||
// State and refs
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [previewStyle, setPreviewStyle] = useState<React.CSSProperties>({});
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Create portal container once
|
||||
const portalContainer = useMemo(() => document.createElement('div'), []);
|
||||
|
||||
// Preload the avatar image when component mounts
|
||||
useEffect(() => {
|
||||
if (userAvatarPath) {
|
||||
const img = new Image();
|
||||
img.onload = () => setImageLoaded(true);
|
||||
img.src = userAvatarPath;
|
||||
}
|
||||
}, [userAvatarPath]);
|
||||
|
||||
// Setup portal container
|
||||
useEffect(() => {
|
||||
document.body.appendChild(portalContainer);
|
||||
return () => {
|
||||
document.body.removeChild(portalContainer);
|
||||
};
|
||||
}, [portalContainer]);
|
||||
|
||||
// Position and display the preview
|
||||
const updatePosition = () => {
|
||||
if (!containerRef.current || !previewRef.current) return;
|
||||
|
||||
// Get dimensions
|
||||
const linkRect = containerRef.current.getBoundingClientRect();
|
||||
const previewRect = previewRef.current.getBoundingClientRect();
|
||||
const viewport = {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
|
||||
// Determine horizontal position
|
||||
const spaceRight = viewport.width - linkRect.right;
|
||||
const spaceLeft = linkRect.left;
|
||||
const showOnRight = spaceRight >= 250 || spaceRight > spaceLeft;
|
||||
|
||||
// Calculate vertical center alignment
|
||||
const linkCenter = linkRect.top + linkRect.height / 2;
|
||||
const padding =
|
||||
parseInt(getComputedStyle(document.documentElement).fontSize) * 2; // 2em
|
||||
|
||||
// Calculate preferred vertical position (centered with link)
|
||||
let top = linkCenter - previewRect.height / 2;
|
||||
|
||||
// Adjust if too close to viewport edges
|
||||
if (top < padding) top = padding;
|
||||
if (top + previewRect.height > viewport.height - padding) {
|
||||
top = viewport.height - previewRect.height - padding;
|
||||
}
|
||||
|
||||
// Set position
|
||||
setPreviewStyle({
|
||||
position: 'fixed',
|
||||
zIndex: 9999,
|
||||
top: `${top}px`,
|
||||
overflow: 'auto',
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
...(showOnRight
|
||||
? { left: `${linkRect.right + 10}px` }
|
||||
: { right: `${viewport.width - linkRect.left + 10}px` }),
|
||||
});
|
||||
};
|
||||
|
||||
// Two-step approach: first measure offscreen, then show
|
||||
const handleMouseEnter = () => {
|
||||
// Step 1: Render offscreen for measurement
|
||||
setPreviewStyle({
|
||||
position: 'fixed',
|
||||
left: '-9999px',
|
||||
top: '0',
|
||||
opacity: '0',
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
});
|
||||
setShowPreview(true);
|
||||
|
||||
// Step 2: Position properly after a very short delay
|
||||
setTimeout(() => {
|
||||
updatePosition();
|
||||
}, 20);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setShowPreview(false);
|
||||
};
|
||||
|
||||
// Handle image load to reposition if needed
|
||||
const handleImageLoad = () => {
|
||||
if (showPreview) updatePosition();
|
||||
};
|
||||
|
||||
// Handle window resize
|
||||
useEffect(() => {
|
||||
if (!showPreview) return;
|
||||
|
||||
window.addEventListener('resize', updatePosition);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
};
|
||||
}, [showPreview]);
|
||||
|
||||
const sectionClassName = [
|
||||
'flex items-center overflow-hidden',
|
||||
'border-slate-200 bg-gradient-to-r from-white to-slate-50 p-2',
|
||||
'dark:border-slate-600 dark:from-slate-800 dark:to-slate-700',
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative inline-flex"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
|
||||
{showPreview &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={previewRef}
|
||||
className={[
|
||||
'max-h-[280px] max-w-[270px] rounded-lg border',
|
||||
'border-slate-100 bg-white dark:border-slate-600',
|
||||
'divide-y divide-slate-100 dark:divide-slate-600',
|
||||
'shadow-xl shadow-slate-700/50',
|
||||
].join(' ')}
|
||||
style={{
|
||||
...previewStyle,
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Header: User Name and Domain Icon */}
|
||||
<div className={`${sectionClassName} justify-between`}>
|
||||
<span
|
||||
className="mr-2 min-w-0 truncate text-sm font-semibold tracking-tight text-slate-800 dark:text-slate-300"
|
||||
title={userName}
|
||||
>
|
||||
{userName}
|
||||
</span>
|
||||
{userDomainIcon && (
|
||||
<img
|
||||
src={userDomainIcon}
|
||||
alt="Domain"
|
||||
className="h-5 w-5 rounded-md bg-slate-500 p-1 shadow-sm ring-sky-100 dark:ring-sky-900"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Profile Content */}
|
||||
<div className="bg-slate-50 p-3 dark:bg-slate-900">
|
||||
<div className="flex gap-4">
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0">
|
||||
{userAvatarPath ? (
|
||||
<div className="relative block overflow-hidden rounded-md">
|
||||
{!imageLoaded && (
|
||||
<div className="flex h-[80px] w-[80px] items-center justify-center rounded-md border border-slate-200 bg-slate-100 dark:border-slate-600 dark:bg-slate-800">
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Loading...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={userAvatarPath}
|
||||
alt={userAvatarAlt}
|
||||
className={`h-[80px] w-[80px] rounded-md border border-slate-200 object-cover shadow-sm dark:border-slate-600 ${
|
||||
!imageLoaded ? 'hidden' : ''
|
||||
}`}
|
||||
loading="eager"
|
||||
onLoad={() => {
|
||||
setImageLoaded(true);
|
||||
handleImageLoad();
|
||||
}}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
const parent = target.parentElement;
|
||||
if (parent) {
|
||||
parent.innerHTML =
|
||||
'<span class="text-xs italic text-slate-500 dark:text-slate-400">Avatar error</span>';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="block h-[80px] w-[80px] rounded-md border border-slate-200 bg-slate-100 p-2 text-xs italic text-slate-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-400">
|
||||
No avatar
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Stats */}
|
||||
<div className="flex-grow">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="text-center">
|
||||
<span className="block text-sm font-semibold text-slate-700 dark:text-slate-300">
|
||||
{userNumPosts ?? '-'}
|
||||
</span>
|
||||
<span className="text-2xs text-slate-500 dark:text-slate-400">
|
||||
Posts
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<span className="block text-sm font-semibold text-slate-700 dark:text-slate-300">
|
||||
{userNumFollowedBy ?? '-'}
|
||||
</span>
|
||||
<span className="text-2xs text-slate-500 dark:text-slate-400">
|
||||
Followers
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<span className="block text-sm font-semibold text-slate-700 dark:text-slate-300">
|
||||
{userNumFollowed ?? '-'}
|
||||
</span>
|
||||
<span className="text-2xs text-slate-500 dark:text-slate-400">
|
||||
Following
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
{userRegisteredAt && (
|
||||
<>
|
||||
<span className="text-2xs block font-medium text-slate-700 dark:text-slate-300">
|
||||
Member since
|
||||
</span>
|
||||
<span className="text-2xs leading-tight text-slate-500 dark:text-slate-400">
|
||||
{userRegisteredAt}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
portalContainer,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserHoverPreview;
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as React from 'react';
|
||||
import { UserHoverPreview } from './UserHoverPreview';
|
||||
|
||||
interface UserHoverPreviewWrapperProps {
|
||||
linkText: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
userPath: string;
|
||||
userAvatarPath: string;
|
||||
userAvatarAlt: string;
|
||||
userDomainIcon?: string;
|
||||
userSmallAvatarPath?: string;
|
||||
userRegisteredAt?: string;
|
||||
userNumPosts?: number;
|
||||
userNumFollowedBy?: number;
|
||||
userNumFollowed?: number;
|
||||
}
|
||||
|
||||
export const UserHoverPreviewWrapper: React.FC<
|
||||
UserHoverPreviewWrapperProps
|
||||
> = ({
|
||||
linkText,
|
||||
userName,
|
||||
userPath,
|
||||
userAvatarPath,
|
||||
userAvatarAlt,
|
||||
userDomainIcon,
|
||||
userSmallAvatarPath,
|
||||
userRegisteredAt,
|
||||
userNumPosts,
|
||||
userNumFollowedBy,
|
||||
userNumFollowed,
|
||||
}) => {
|
||||
return (
|
||||
<UserHoverPreview
|
||||
userName={userName}
|
||||
userAvatarPath={userAvatarPath}
|
||||
userAvatarAlt={userAvatarAlt}
|
||||
userDomainIcon={userDomainIcon}
|
||||
userRegisteredAt={userRegisteredAt}
|
||||
userNumPosts={userNumPosts}
|
||||
userNumFollowedBy={userNumFollowedBy}
|
||||
userNumFollowed={userNumFollowed}
|
||||
>
|
||||
<a
|
||||
href={userPath}
|
||||
className="inline-flex items-center gap-1 rounded-md px-1 text-sky-200 transition-all hover:bg-gray-100 hover:text-sky-800"
|
||||
>
|
||||
{userSmallAvatarPath ? (
|
||||
<img
|
||||
src={userSmallAvatarPath}
|
||||
alt={userAvatarAlt}
|
||||
className="h-4 w-4 flex-shrink-0 rounded-sm"
|
||||
/>
|
||||
) : (
|
||||
<i className="fa-regular fa-user h-4 w-4 flex-shrink-0"></i>
|
||||
)}
|
||||
{linkText}
|
||||
</a>
|
||||
</UserHoverPreview>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserHoverPreviewWrapper;
|
||||
@@ -3,6 +3,7 @@ import ReactOnRails from 'react-on-rails';
|
||||
import UserSearchBar from '../bundles/Main/components/UserSearchBar';
|
||||
import { UserMenu } from '../bundles/Main/components/UserMenu';
|
||||
import { PostHoverPreviewWrapper } from '../bundles/Main/components/PostHoverPreviewWrapper';
|
||||
import { UserHoverPreviewWrapper } from '../bundles/Main/components/UserHoverPreviewWrapper';
|
||||
import { initCollapsibleSections } from '../bundles/UI/collapsibleSections';
|
||||
|
||||
// This is how react_on_rails can see the components in the browser.
|
||||
@@ -10,6 +11,7 @@ ReactOnRails.register({
|
||||
UserSearchBar,
|
||||
UserMenu,
|
||||
PostHoverPreviewWrapper,
|
||||
UserHoverPreviewWrapper,
|
||||
});
|
||||
|
||||
// Initialize collapsible sections
|
||||
|
||||
@@ -3,10 +3,12 @@ import ReactOnRails from 'react-on-rails';
|
||||
import UserSearchBar from '../bundles/Main/components/UserSearchBarServer';
|
||||
import { UserMenu } from '../bundles/Main/components/UserMenu';
|
||||
import { PostHoverPreviewWrapper } from '../bundles/Main/components/PostHoverPreviewWrapper';
|
||||
import { UserHoverPreviewWrapper } from '../bundles/Main/components/UserHoverPreviewWrapper';
|
||||
|
||||
// This is how react_on_rails can see the UserSearchBar in the browser.
|
||||
ReactOnRails.register({
|
||||
UserMenu,
|
||||
UserSearchBar,
|
||||
PostHoverPreviewWrapper,
|
||||
UserHoverPreviewWrapper,
|
||||
});
|
||||
|
||||
@@ -189,3 +189,8 @@ class Domain::Post < ReduxApplicationRecord
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# eager load all subclasses
|
||||
Dir[Rails.root.join("app/models/domain/post/**/*.rb")].each do |file|
|
||||
require_dependency file
|
||||
end
|
||||
|
||||
@@ -17,3 +17,8 @@ class Domain::PostGroup < ReduxApplicationRecord
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# eager load all subclasses
|
||||
Dir[Rails.root.join("app/models/domain/post_group/**/*.rb")].each do |file|
|
||||
require_dependency file
|
||||
end
|
||||
|
||||
@@ -9,3 +9,8 @@ class Domain::PostGroupJoin < ReduxApplicationRecord
|
||||
belongs_to :post, class_name: "::Domain::Post"
|
||||
belongs_to :group, class_name: "::Domain::PostGroup"
|
||||
end
|
||||
|
||||
# eager load all subclasses
|
||||
Dir[Rails.root.join("app/models/domain/post_group_join/**/*.rb")].each do |file|
|
||||
require_dependency file
|
||||
end
|
||||
|
||||
@@ -164,10 +164,6 @@ class Domain::User < ReduxApplicationRecord
|
||||
def account_status_for_view
|
||||
end
|
||||
|
||||
sig { abstract.returns(T.nilable(ActiveSupport::TimeWithZone)) }
|
||||
def registered_at_for_view
|
||||
end
|
||||
|
||||
sig { abstract.returns(T.nilable(String)) }
|
||||
def external_url_for_view
|
||||
end
|
||||
@@ -181,3 +177,8 @@ class Domain::User < ReduxApplicationRecord
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
# eager load all subclasses
|
||||
Dir[Rails.root.join("app/models/domain/user/**/*.rb")].each do |file|
|
||||
require_dependency file
|
||||
end
|
||||
|
||||
@@ -50,11 +50,6 @@ class Domain::User::E621User < Domain::User
|
||||
"ok"
|
||||
end
|
||||
|
||||
sig { override.returns(T.nilable(ActiveSupport::TimeWithZone)) }
|
||||
def registered_at_for_view
|
||||
registered_at
|
||||
end
|
||||
|
||||
sig { override.returns(T.nilable(String)) }
|
||||
def external_url_for_view
|
||||
"https://e621.net/users/#{e621_id}" if e621_id.present?
|
||||
|
||||
@@ -154,11 +154,6 @@ class Domain::User::FaUser < Domain::User
|
||||
"https://www.furaffinity.net/user/#{url_name}" if url_name.present?
|
||||
end
|
||||
|
||||
sig { override.returns(T.nilable(ActiveSupport::TimeWithZone)) }
|
||||
def registered_at_for_view
|
||||
registered_at
|
||||
end
|
||||
|
||||
sig { override.returns(T.nilable(String)) }
|
||||
def name_for_view
|
||||
name || url_name
|
||||
|
||||
@@ -58,12 +58,6 @@ class Domain::User::InkbunnyUser < Domain::User
|
||||
"inkbunny.net"
|
||||
end
|
||||
|
||||
# TODO - can we get this from the API? or scrape the user page?
|
||||
sig { override.returns(T.nilable(ActiveSupport::TimeWithZone)) }
|
||||
def registered_at_for_view
|
||||
nil
|
||||
end
|
||||
|
||||
sig { override.returns(T.nilable(String)) }
|
||||
def external_url_for_view
|
||||
"https://inkbunny.net/#{name}" if name.present?
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<%
|
||||
post_thumbnail_path = policy(post).view_file? ? thumbnail_for_post_path(post) : nil
|
||||
%>
|
||||
<%= react_component(
|
||||
"PostHoverPreviewWrapper",
|
||||
{
|
||||
prerender: false,
|
||||
props: {
|
||||
linkText: link_text,
|
||||
postId: post.to_param,
|
||||
postTitle: post.title,
|
||||
postPath: domain_post_path(post),
|
||||
postThumbnailPath: post_thumbnail_path,
|
||||
postThumbnailAlt: "View on #{domain_name_for_model(post)}",
|
||||
postDomainIcon: domain_post_domain_icon_path(post),
|
||||
creatorName: post.primary_creator_for_view&.name_for_view,
|
||||
creatorAvatarPath: post.primary_creator_for_view ? user_avatar_path_for_view(post.primary_creator_for_view) : nil,
|
||||
},
|
||||
html_options: {
|
||||
style: "display:inline-flex"
|
||||
<% cache [post, "popover_inline_link_domain_post"] do %>
|
||||
<% post_thumbnail_path = policy(post).view_file? ? thumbnail_for_post_path(post) : nil %>
|
||||
<%= react_component(
|
||||
"PostHoverPreviewWrapper",
|
||||
{
|
||||
prerender: false,
|
||||
props: {
|
||||
linkText: link_text,
|
||||
postId: post.to_param,
|
||||
postTitle: post.title,
|
||||
postPath: domain_post_path(post),
|
||||
postThumbnailPath: post_thumbnail_path,
|
||||
postThumbnailAlt: "View on #{domain_name_for_model(post)}",
|
||||
postDomainIcon: domain_model_icon_path(post),
|
||||
creatorName: post.primary_creator_for_view&.name_for_view,
|
||||
creatorAvatarPath: post.primary_creator_for_view ? user_avatar_path_for_view(post.primary_creator_for_view) : nil,
|
||||
},
|
||||
html_options: {
|
||||
style: "display:inline-flex"
|
||||
}
|
||||
}
|
||||
}
|
||||
) %>
|
||||
) %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
<%= link_to domain_user_path(user),
|
||||
class:
|
||||
"text-sky-200 transition-all hover:text-sky-800 inline-flex items-center hover:bg-gray-100 rounded-md gap-1 px-1 align-bottom" do %>
|
||||
<%= domain_user_avatar_img_tag(user.avatar, thumb: "32-avatar") %>
|
||||
<span><%= user.name %></span>
|
||||
<% cache [user, "popover_inline_link_domain_user"] do %>
|
||||
<% num_posts = user.has_created_posts? ? user.user_post_creations.count : nil %>
|
||||
<% registered_at = domain_user_registered_at_string_for_view(user) %>
|
||||
<% num_followed_by = user.has_followed_by_users? ? user.user_user_follows_to.count : nil %>
|
||||
<% num_followed = user.has_followed_users? ? user.user_user_follows_from.count : nil %>
|
||||
<%= react_component(
|
||||
"UserHoverPreviewWrapper",
|
||||
{
|
||||
prerender: false,
|
||||
props: {
|
||||
linkText: user.name,
|
||||
userId: user.to_param,
|
||||
userName: user.name,
|
||||
userPath: domain_user_path(user),
|
||||
userSmallAvatarPath: domain_user_avatar_img_src_path(user.avatar, thumb: "32-avatar"),
|
||||
userAvatarPath: domain_user_avatar_img_src_path(user.avatar),
|
||||
userAvatarAlt: "View #{user.name}'s profile",
|
||||
userDomainIcon: domain_model_icon_path(user),
|
||||
userNumPosts: num_posts,
|
||||
userRegisteredAt: registered_at,
|
||||
userNumFollowedBy: num_followed_by,
|
||||
userNumFollowed: num_followed,
|
||||
},
|
||||
html_options: {
|
||||
style: "display:inline-flex",
|
||||
class: 'inline-flex items-center align-bottom'
|
||||
}
|
||||
}
|
||||
) %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<%= image_tag domain_post_domain_icon_path(post),
|
||||
<%= image_tag domain_model_icon_path(post),
|
||||
class: "w-6 h-6",
|
||||
title: domain_post_domain_icon_title(post) %>
|
||||
title: domain_model_icon_path(post) %>
|
||||
<% link_class =
|
||||
"flex items-center text-slate-500 hover:text-slate-700 decoration-dotted underline" %>
|
||||
<% url = post.external_url_for_view %>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<section class="rounded-md border border-slate-300 bg-slate-50 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<%= image_tag domain_post_domain_icon_path(post), class: "w-6 h-6" %>
|
||||
<%= image_tag domain_model_icon_path(post), class: "w-6 h-6" %>
|
||||
<span class="truncate text-lg font-medium">
|
||||
<%= link_to title_for_post_model(post),
|
||||
post.external_url_for_view.to_s,
|
||||
|
||||
@@ -18,13 +18,8 @@
|
||||
<%= render_for_model user, "overview_details", as: :user %>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium italic text-slate-500">Registered</span>
|
||||
<span class="">
|
||||
<% if registered_at = user.registered_at_for_view %>
|
||||
<%= time_ago_in_words(registered_at) %>
|
||||
ago
|
||||
<% else %>
|
||||
unknown
|
||||
<% end %>
|
||||
<span>
|
||||
<%= domain_user_registered_at_string_for_view(user) %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<% fav_posts = user.faved_posts.limit(5) %>
|
||||
<%# nasty hack, otherwise postgres uses a bad query plan %>
|
||||
<% fav_posts = user.faved_posts.limit(50)[0..5] %>
|
||||
<section class="animated-shadow-sky sky-section">
|
||||
<h2 class="section-header">
|
||||
<span class="font-medium text-slate-900">Favorited Posts</span>
|
||||
|
||||
@@ -29,9 +29,9 @@
|
||||
<i class="fa-solid <%= user.account_status_for_view == "ok" ? "fa-check" : "fa-exclamation-triangle" %> me-1"></i>
|
||||
<%= user.account_status_for_view %>
|
||||
</span>
|
||||
<% if user.registered_at_for_view.present? %>
|
||||
<span class="badge bg-light text-dark" title="<%= time_ago_in_words(user.registered_at_for_view) %> ago">
|
||||
<i class="fa-regular fa-clock me-1"></i><%= user.registered_at_for_view.strftime("%Y-%m-%d") %>
|
||||
<% if registered_at = domain_user_registered_at_string_for_view(user) %>
|
||||
<span class="badge bg-light text-dark" title="<%= registered_at %>">
|
||||
<i class="fa-regular fa-clock me-1"></i><%= registered_at %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% if user.avatar.present? %>
|
||||
|
||||
@@ -26,13 +26,6 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper do
|
||||
)
|
||||
end
|
||||
|
||||
let(:domain_post_link_partial) do
|
||||
"domain/has_description_html/inline_link_domain_post"
|
||||
end
|
||||
let(:domain_user_link_partial) do
|
||||
"domain/has_description_html/inline_link_domain_user"
|
||||
end
|
||||
|
||||
# Mock the policy for posts to avoid Devise authentication errors
|
||||
before do
|
||||
# Create a fake policy result that allows view_file?
|
||||
@@ -68,8 +61,8 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper do
|
||||
sanitize_description_html(
|
||||
"my mate <<a href=\"http://www.furaffinity.net/user/starringer/>.\" title=\"http://www.furaffinity.net/user/starringer/>.\" class=\"auto_link\">http://www.furaffinity.net/user/starringer/>.</a> It was fun.",
|
||||
)
|
||||
expect(sanitized).to include_html(
|
||||
render(partial: domain_user_link_partial, locals: { user: user }),
|
||||
expect(sanitized).to include(
|
||||
/UserHoverPreviewWrapper.+#{user.to_param}/,
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -178,8 +171,8 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper do
|
||||
html = '<a href="/user/artistone/">Relative Link</a>'
|
||||
sanitized = sanitize_description_html(html)
|
||||
|
||||
expect(sanitized).to eq_html(
|
||||
render(partial: domain_user_link_partial, locals: { user: user1 }),
|
||||
expect(sanitized).to include(
|
||||
/UserHoverPreviewWrapper.+#{user1.to_param}/,
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -257,14 +250,14 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper do
|
||||
expect(sanitized).to include(
|
||||
/PostHoverPreviewWrapper.+#{post1.to_param}/,
|
||||
)
|
||||
expect(sanitized).to include_html(
|
||||
render(partial: domain_user_link_partial, locals: { user: user1 }),
|
||||
expect(sanitized).to include(
|
||||
/UserHoverPreviewWrapper.+#{user1.to_param}/,
|
||||
)
|
||||
expect(sanitized).to include("FA Post")
|
||||
expect(sanitized).not_to include("FA User")
|
||||
expect(sanitized).to include("Google")
|
||||
expect(sanitized).to include("No href")
|
||||
expect(sanitized.scan(/<a/).length).to eq(1)
|
||||
expect(sanitized.scan(/<a/).length).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -291,12 +284,11 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper do
|
||||
'
|
||||
sanitized = sanitize_description_html(html)
|
||||
|
||||
expect(sanitized).to include_html(
|
||||
render(partial: domain_user_link_partial, locals: { user: user1 }),
|
||||
expect(sanitized).to include(
|
||||
/UserHoverPreviewWrapper.+#{user1.to_param}/,
|
||||
)
|
||||
|
||||
expect(sanitized).to include_html(
|
||||
render(partial: domain_user_link_partial, locals: { user: user2 }),
|
||||
expect(sanitized).to include(
|
||||
/UserHoverPreviewWrapper.+#{user2.to_param}/,
|
||||
)
|
||||
end
|
||||
|
||||
@@ -318,8 +310,8 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper do
|
||||
'<a href="https://www.furaffinity.net/user/artistone/"><b>Bold</b> <i>Text</i></a>'
|
||||
sanitized = sanitize_description_html(html)
|
||||
|
||||
expect(sanitized).to include_html(
|
||||
render(partial: domain_user_link_partial, locals: { user: user1 }),
|
||||
expect(sanitized).to include(
|
||||
/UserHoverPreviewWrapper.+#{user1.to_param}/,
|
||||
)
|
||||
end
|
||||
|
||||
@@ -337,8 +329,8 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper do
|
||||
it "processes #{url}" do
|
||||
html = %(<a href="#{url}">FA User Link</a>)
|
||||
sanitized = sanitize_description_html(html)
|
||||
expect(sanitized).to eq_html(
|
||||
render(partial: domain_user_link_partial, locals: { user: user1 }),
|
||||
expect(sanitized).to include(
|
||||
/UserHoverPreviewWrapper.+#{user1.to_param}/,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user