use popover links in most places

This commit is contained in:
Dylan Knutson
2025-03-02 00:58:24 +00:00
parent 93d304cdf8
commit 8fdb78e8f3
16 changed files with 321 additions and 268 deletions

View File

@@ -141,24 +141,32 @@ module Domain::DescriptionsHelper
end
found_model = found_link.model
partial, as =
partial, locals =
case found_model
when Domain::Post
["domain/has_description_html/inline_link_domain_post", :post]
[
"domain/has_description_html/inline_link_domain_post",
{
post: found_model,
link_text: node.text,
visual_style: "description-section-link",
},
]
when Domain::User
["domain/has_description_html/inline_link_domain_user", :user]
[
"domain/has_description_html/inline_link_domain_user",
{
user: found_model,
link_text: node.text,
visual_style: "description-section-link",
},
]
else
raise "Unknown model type: #{found_link.model.class}"
end
replacements[node] = Nokogiri::HTML5.fragment(
render(
partial:,
locals: {
as => found_model,
:link_text => node.text.strip,
},
),
render(partial:, locals:),
)
next { node_whitelist: [node] }
@@ -179,4 +187,21 @@ module Domain::DescriptionsHelper
# if anything goes wrong in production, bail out and don't display anything
"(error generating description)"
end
sig { params(visual_style: String).returns(String) }
def link_classes_for_visual_style(visual_style)
case visual_style
when "sky-link"
"blue-link truncate"
when "description-section-link"
[
"text-sky-200 border-slate-200",
"border border-transparent hover:border-slate-300 hover:text-sky-800 hover:bg-slate-100",
"rounded-md px-1 transition-all",
"inline-flex items-center align-bottom",
].join(" ")
else
"blue-link"
end
end
end

View File

@@ -81,6 +81,7 @@ module Domain::PostsHelper
sig { params(post: Domain::Post).returns(T.nilable(String)) }
def thumbnail_for_post_path(post)
return nil unless policy(post).view_file?
file = gallery_file_for_post(post)
return nil unless file.present?
return nil unless file.state_ok?

View File

@@ -1,6 +1,7 @@
import * as React from 'react';
import { useState, useRef, useEffect, useMemo } from 'react';
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useHoverPreview } from '../utils/hoverPreviewUtils';
interface PostHoverPreviewProps {
children: React.ReactNode;
@@ -21,15 +22,20 @@ export const PostHoverPreview: React.FC<PostHoverPreviewProps> = ({
creatorName,
creatorAvatarPath,
}) => {
// State and refs
const [showPreview, setShowPreview] = useState(false);
const [previewStyle, setPreviewStyle] = useState<React.CSSProperties>({});
// State for image loading
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'), []);
// Use shared hover preview hook
const {
containerRef,
previewRef,
portalContainer,
showPreview,
previewStyle,
handleMouseEnter,
handleMouseLeave,
updatePosition,
} = useHoverPreview();
// Preload the thumbnail image when component mounts
useEffect(() => {
@@ -48,95 +54,11 @@ export const PostHoverPreview: React.FC<PostHoverPreviewProps> = ({
}
}, [creatorAvatarPath]);
// 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 >= 300 || 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 headerFooterClassName = [
'flex items-center justify-between overflow-hidden',
'border-slate-200 bg-gradient-to-r from-white to-slate-50 p-3',
@@ -144,9 +66,8 @@ export const PostHoverPreview: React.FC<PostHoverPreviewProps> = ({
].join(' ');
return (
<div
<span
ref={containerRef}
className="relative inline-flex"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
@@ -250,7 +171,7 @@ export const PostHoverPreview: React.FC<PostHoverPreviewProps> = ({
</div>,
portalContainer,
)}
</div>
</span>
);
};

View File

@@ -1,7 +1,9 @@
import * as React from 'react';
import PostHoverPreview from './PostHoverPreview';
import { anchorClassNamesForVisualStyle } from '../utils/hoverPreviewUtils';
interface PostHoverPreviewWrapperProps {
visualStyle: 'sky-link' | 'description-section-link';
linkText: string;
postTitle: string;
postPath: string;
@@ -15,6 +17,7 @@ interface PostHoverPreviewWrapperProps {
export const PostHoverPreviewWrapper: React.FC<
PostHoverPreviewWrapperProps
> = ({
visualStyle,
linkText,
postTitle,
postPath,
@@ -35,9 +38,12 @@ export const PostHoverPreviewWrapper: React.FC<
>
<a
href={postPath}
className="inline-flex items-center gap-1 rounded-md px-1 text-sky-200 transition-all hover:bg-gray-100 hover:text-sky-800"
// className="inline-flex items-center gap-1 rounded-md px-1"
className={anchorClassNamesForVisualStyle(visualStyle)}
>
<i className="fa-regular fa-image h-4 w-4 flex-shrink-0"></i>
{visualStyle === 'description-section-link' && (
<i className="fa-regular fa-image h-4 w-4 flex-shrink-0"></i>
)}
{linkText}
</a>
</PostHoverPreview>

View File

@@ -1,6 +1,7 @@
import * as React from 'react';
import { useState, useRef, useEffect, useMemo } from 'react';
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useHoverPreview } from '../utils/hoverPreviewUtils';
interface UserHoverPreviewProps {
children: React.ReactNode;
@@ -25,15 +26,20 @@ export const UserHoverPreview: React.FC<UserHoverPreviewProps> = ({
userNumFollowedBy,
userNumFollowed,
}) => {
// State and refs
const [showPreview, setShowPreview] = useState(false);
const [previewStyle, setPreviewStyle] = useState<React.CSSProperties>({});
// State for image loading
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'), []);
// Use shared hover preview hook
const {
containerRef,
previewRef,
portalContainer,
showPreview,
previewStyle,
handleMouseEnter,
handleMouseLeave,
updatePosition,
} = useHoverPreview();
// Preload the avatar image when component mounts
useEffect(() => {
@@ -44,95 +50,11 @@ export const UserHoverPreview: React.FC<UserHoverPreviewProps> = ({
}
}, [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',
@@ -140,9 +62,8 @@ export const UserHoverPreview: React.FC<UserHoverPreviewProps> = ({
].join(' ');
return (
<div
<span
ref={containerRef}
className="relative inline-flex"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
@@ -153,7 +74,7 @@ export const UserHoverPreview: React.FC<UserHoverPreviewProps> = ({
<div
ref={previewRef}
className={[
'max-h-[280px] max-w-[270px] rounded-lg border',
'max-h-[280px] max-w-[400px] 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',
@@ -183,7 +104,7 @@ export const UserHoverPreview: React.FC<UserHoverPreviewProps> = ({
{/* Profile Content */}
<div className="bg-slate-50 p-3 dark:bg-slate-900">
<div className="flex gap-4">
<div className="flex items-center gap-4">
{/* Avatar */}
<div className="flex-shrink-0">
{userAvatarPath ? (
@@ -273,7 +194,7 @@ export const UserHoverPreview: React.FC<UserHoverPreviewProps> = ({
</div>,
portalContainer,
)}
</div>
</span>
);
};

View File

@@ -1,7 +1,13 @@
import * as React from 'react';
import { UserHoverPreview } from './UserHoverPreview';
import { anchorClassNamesForVisualStyle } from '../utils/hoverPreviewUtils';
type VisualStyle = 'sky-link' | 'description-section-link';
type IconSize = 'small' | 'large';
interface UserHoverPreviewWrapperProps {
visualStyle: VisualStyle;
iconSize: IconSize;
linkText: string;
userId: string;
userName: string;
@@ -16,9 +22,20 @@ interface UserHoverPreviewWrapperProps {
userNumFollowed?: number;
}
function iconClassNamesForSize(size: IconSize) {
switch (size) {
case 'large':
return 'h-8 w-8 flex-shrink-0 rounded-md';
case 'small':
return 'h-4 w-4 flex-shrink-0 rounded-md';
}
}
export const UserHoverPreviewWrapper: React.FC<
UserHoverPreviewWrapperProps
> = ({
visualStyle,
iconSize,
linkText,
userName,
userPath,
@@ -44,16 +61,19 @@ export const UserHoverPreviewWrapper: React.FC<
>
<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"
className={anchorClassNamesForVisualStyle(
visualStyle,
!!userSmallAvatarPath,
)}
>
{userSmallAvatarPath ? (
<img
src={userSmallAvatarPath}
alt={userAvatarAlt}
className="h-4 w-4 flex-shrink-0 rounded-sm"
className={iconClassNamesForSize(iconSize)}
/>
) : (
<i className="fa-regular fa-user h-4 w-4 flex-shrink-0"></i>
<i className={iconClassNamesForSize(iconSize)}></i>
)}
{linkText}
</a>

View File

@@ -0,0 +1,150 @@
import {
RefObject,
CSSProperties,
useState,
useRef,
useEffect,
useMemo,
} from 'react';
/**
* Calculate the position for a hover preview popup
* @param containerRef The reference to the element containing the hover trigger
* @param previewRef The reference to the popup element
* @returns CSS properties for positioning the popup or undefined if refs are not available
*/
export const calculatePreviewPosition = (
containerRef: RefObject<HTMLElement>,
previewRef: RefObject<HTMLElement>,
): CSSProperties | undefined => {
if (!containerRef.current || !previewRef.current) return undefined;
// 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 > 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;
}
// Return position
return {
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` }),
};
};
/**
* Get initial offscreen position style for measurement
*/
export const getOffscreenMeasurementStyle = (): CSSProperties => {
return {
position: 'fixed',
left: '-9999px',
top: '0',
opacity: '0',
transition: 'opacity 0.2s ease-in-out',
};
};
/**
* Custom hook for handling hover preview functionality
*/
export const useHoverPreview = () => {
const [showPreview, setShowPreview] = useState(false);
const [previewStyle, setPreviewStyle] = useState<CSSProperties>({});
const containerRef = useRef<HTMLDivElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
// Create portal container once
const portalContainer = useMemo(() => document.createElement('div'), []);
// Setup portal container
useEffect(() => {
document.body.appendChild(portalContainer);
return () => {
document.body.removeChild(portalContainer);
};
}, [portalContainer]);
// Position and display the preview
const updatePosition = () => {
const newPosition = calculatePreviewPosition(containerRef, previewRef);
if (newPosition) {
setPreviewStyle(newPosition);
}
};
// Two-step approach: first measure offscreen, then show
const handleMouseEnter = () => {
// Step 1: Render offscreen for measurement
setPreviewStyle(getOffscreenMeasurementStyle());
setShowPreview(true);
// Step 2: Position properly after a very short delay
setTimeout(() => {
updatePosition();
}, 20);
};
const handleMouseLeave = () => {
setShowPreview(false);
};
// Handle window resize
useEffect(() => {
if (!showPreview) return;
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('resize', updatePosition);
};
}, [showPreview]);
return {
containerRef,
previewRef,
portalContainer,
showPreview,
previewStyle,
handleMouseEnter,
handleMouseLeave,
updatePosition,
};
};
export function anchorClassNamesForVisualStyle(
visualStyle: string,
hasIcon: boolean = false,
) {
let classNames = ['truncate', 'gap-1'];
if (hasIcon) {
classNames.push('flex items-center');
}
return classNames.join(' ');
}

View File

@@ -1,22 +1,27 @@
<% cache [post, "popover_inline_link_domain_post"] do %>
<% post_thumbnail_path = policy(post).view_file? ? thumbnail_for_post_path(post) : nil %>
<%# `visual_style` options are: %>
<%# sky-link (default, normal blue link) %>
<%# description-section-link (smaller and has a border, for use in description section) %>
<% visual_style = local_assigns[:visual_style] || "sky-link" %>
<% cache [post, "popover_inline_link_domain_post", visual_style] do %>
<% link_classes = link_classes_for_visual_style(visual_style) %>
<%= react_component(
"PostHoverPreviewWrapper",
{
prerender: false,
props: {
visualStyle: visual_style,
linkText: link_text,
postId: post.to_param,
postTitle: post.title,
postPath: domain_post_path(post),
postThumbnailPath: post_thumbnail_path,
postThumbnailPath: thumbnail_for_post_path(post),
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"
class: link_classes
}
}
) %>

View File

@@ -1,18 +1,30 @@
<% cache [user, "popover_inline_link_domain_user"] do %>
<%# `visual_style` options are: %>
<%# sky-link (default, normal blue link) %>
<%# description-section-link (smaller and has a border, for use in description section) %>
<%# `icon_size` options are: %>
<%# small (default) %>
<%# large %>
<% visual_style = local_assigns[:visual_style] || "sky-link" %>
<% icon_size = local_assigns[:icon_size] || "small" %>
<% cache [user, "popover_inline_link_domain_user", visual_style, icon_size] do %>
<% link_classes = link_classes_for_visual_style(visual_style) %>
<% 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 %>
<% avatar_thumb_size = icon_size == "large" ? "64-avatar" : "32-avatar" %>
<%= react_component(
"UserHoverPreviewWrapper",
{
prerender: false,
props: {
linkText: user.name,
visualStyle: visual_style,
iconSize: icon_size,
linkText: user.name_for_view,
userId: user.to_param,
userName: user.name,
userPath: domain_user_path(user),
userSmallAvatarPath: domain_user_avatar_img_src_path(user.avatar, thumb: "32-avatar"),
userSmallAvatarPath: domain_user_avatar_img_src_path(user.avatar, thumb: avatar_thumb_size),
userAvatarPath: domain_user_avatar_img_src_path(user.avatar),
userAvatarAlt: "View #{user.name}'s profile",
userDomainIcon: domain_model_icon_path(user),
@@ -22,8 +34,7 @@
userNumFollowed: num_followed,
},
html_options: {
style: "display:inline-flex",
class: 'inline-flex items-center align-bottom'
class: link_classes
}
}
) %>

View File

@@ -4,11 +4,11 @@
<div class="flex justify-between border-b border-slate-300 p-4">
<%= render "domain/posts/inline_postable_domain_link", post: post %>
</div>
<% if policy(post).view_file? %>
<% if thumbnail_path = thumbnail_for_post_path(post) %>
<div class="flex items-center justify-center p-4 border-b border-slate-300">
<% if thumbnail_file = gallery_file_for_post(post) %>
<%= link_to domain_post_path(post) do %>
<%= image_tag thumbnail_for_post_path(post),
<%= image_tag thumbnail_path,
class:
"max-h-[300px] max-w-[300px] rounded-md border border-slate-300 object-contain shadow-md",
alt: post.title_for_view %>
@@ -28,7 +28,10 @@
<div class="flex justify-between gap-2">
<% if @posts_index_view_config.show_creator_links %>
<% if creator = post.primary_creator_for_view %>
<%= render "domain/users/by_inline_link", user: creator %>
<span class="flex gap-1 items-center">
<span class="text-slate-500 italic text-sm">by</span>
<%= render "domain/has_description_html/inline_link_domain_user", user: creator, visual_style: "sky-link", size: "small" %>
</span>
<% elsif creator = post.primary_creator_name_fallback_for_view %>
<%= creator %>
<% end %>

View File

@@ -12,9 +12,16 @@
<i class="fa-solid fa-arrow-up-right-from-square text-slate-400"></i>
</div>
<div class="flex items-center gap-2 whitespace-nowrap text-slate-600 float-right">
by
<% if creator = post.primary_creator_for_view %>
<%= render "domain/users/inline_link", user: creator, with_post_count: false %>
<span class="flex gap-1 items-center text-lg">
<span class="text-slate-500 italic text-sm">by</span>
<%= render(
"domain/has_description_html/inline_link_domain_user",
user: creator,
visual_style: "sky-link",
icon_size: "large"
) %>
</span>
<% else %>
<%= post.primary_creator_name_fallback_for_view %>
<% end %>

View File

@@ -1,40 +1,29 @@
<section class="sky-section">
<div class="section-header">Similar Posts</div>
<div
class="grid grid-cols-[1fr_auto_auto] items-center divide-y divide-slate-300 bg-slate-100"
>
<div class="grid grid-cols-[1fr,auto] bg-slate-100">
<% factors = Domain::Factors::UserPostFavPostFactors.find_by(post: post) %>
<% if factors %>
<% neighbors = Domain::NeighborFinder.find_neighbors(factors).includes(:post).limit(10) %>
<% neighbors.each do |neighbor| %>
<% num_neighbors = neighbors.size %>
<% neighbors.each_with_index do |neighbor, index| %>
<% border_classes = index < num_neighbors - 1 ? "border-b border-slate-300" : "" %>
<% post = neighbor.post %>
<% creator = post.class.has_creators? ? post.creator : nil %>
<div class="col-span-3 grid grid-cols-subgrid items-center">
<span class="text-md truncate px-4 py-2">
<%= link_to post.title, domain_post_path(post), class: "underline italic" %>
</span>
<% if creator %>
<a href="<%= domain_user_path(creator) %>" class="group flex items-center gap-2 mr-4">
<div class="flex items-center">
<% if creator.avatar %>
<%= image_tag domain_user_avatar_img_src_path(creator.avatar, thumb: "64-avatar"), class: "h-8 w-8 flex-shrink-0 rounded-md shadow-sm transition-all duration-200 group-hover:brightness-110 group-hover:scale-105 group-hover:shadow-md" %>
<% else %>
<div class="h-8 w-8 flex-shrink-0 rounded-md bg-gradient-to-br from-slate-300 to-slate-400 shadow-sm transition-all duration-200 group-hover:from-slate-400 group-hover:to-slate-500 group-hover:scale-105 group-hover:shadow-md"></div>
<% end %>
</div>
<span class="blue-link truncate transition-all duration-200 group-hover:underline">
<%= creator.url_name %>
</span>
</a>
<% else %>
<div class="px-2 py-2 flex items-center">
</div>
<span class="truncate px-4 py-2"><%= post.primary_creator_name_fallback_for_view %></span>
<% end %>
<div class="text-md flex items-center px-4 py-1 <%= border_classes %>">
<%= render "domain/has_description_html/inline_link_domain_post", post: post, link_text: post.title, visual_style: "sky-link" %>
</div>
<% if creator %>
<div class="text-md items-center px-4 py-1 <%= border_classes %>">
<%= render "domain/has_description_html/inline_link_domain_user", user: creator, visual_style: "sky-link", icon_size: "large" %>
</div>
<% else %>
<div class="text-md truncate px-4 py-1 <%= border_classes %>">
<%= post.primary_creator_name_fallback_for_view %>
</div>
<% end %>
<% end %>
<% else %>
<div class="col-span-3 p-4 text-center text-slate-500">No similar posts found</div>
<div class="col-span-2 p-4 text-center text-slate-500">No similar posts found</div>
<% end %>
</div>
</section>

View File

@@ -1,11 +0,0 @@
<span class="flex gap-1 items-center">
<span class="text-slate-500 italic">by</span>
<span>
<%= link_to domain_user_path(user), class: "blue-link flex gap-1 items-center" do %>
<% if avatar = user.avatar%>
<%= image_tag domain_user_avatar_img_src_path(avatar, thumb: "32-avatar"), class: "h-4 w-4 rounded-md" %>
<% end %>
<%= user.name_for_view %>
<% end %>
</span>
</span>

View File

@@ -8,9 +8,16 @@
<% recent_posts = user.posts.limit(5).to_a %>
<% if recent_posts.any? %>
<% recent_posts.each do |post| %>
<div class="flex items-center px-4 py-2">
<div class="flex items-center px-4 py-2 gap-1">
<span class="grow truncate">
<%= link_to post.title, domain_post_path(post), class: "blue-link block truncate" %>
<%= render(
partial: "domain/has_description_html/inline_link_domain_post",
locals: {
post: post,
link_text: post.title,
visual_style: "sky-link"
}
) %>
</span>
<span class="whitespace-nowrap text-slate-500">
<% if posted_at = post.posted_at %>

View File

@@ -11,7 +11,7 @@
<% fav_posts.each do |post| %>
<div class="flex flex-col px-4 py-2">
<span class="flex gap-2">
<%= link_to post.title, domain_post_path(post), class: "blue-link block truncate" %>
<%= render "domain/has_description_html/inline_link_domain_post", post: post, link_text: post.title, visual_style: "sky-link" %>
<span class="whitespace-nowrap flex-grow text-right text-slate-500">
<% if posted_at = post.posted_at %>
<%= time_ago_in_words(posted_at) %> ago
@@ -21,8 +21,9 @@
</span>
</span>
<% if creator = post.primary_creator_for_view %>
<span class="text-sm truncate">
<%= render "domain/users/by_inline_link", user: creator %>
<span class="text-sm flex gap-1 items-center">
<span class="text-slate-500 italic">by</span>
<%= render "domain/has_description_html/inline_link_domain_user", user: creator, visual_style: "sky-link" %>
</span>
<% end %>
</div>

View File

@@ -8,10 +8,7 @@
<% nearest_neighbors.each do |neighbor| %>
<% user = neighbor.user %>
<div class="flex items-center gap-2 whitespace-nowrap text-slate-600 justify-between w-full px-4 py-2">
<%= link_to(domain_user_path(user), class: "flex grow items-center gap-2") do %>
<%= image_tag domain_user_avatar_img_src_path(user.avatar, thumb: "64-avatar"), class: "h-6 w-6 rounded-md" %>
<span class="blue-link"><%= user.name_for_view %></span>
<% end %>
<%= render "domain/has_description_html/inline_link_domain_user", user: user, visual_style: "sky-link" %>
<% if !defined?(with_post_count) || with_post_count %>
<span class="ml-2 text-slate-500">
<%= pluralize(number_with_delimiter(user.posts.count, delimiter: ","), "post") %>