use popover links in most places
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
150
app/javascript/bundles/Main/utils/hoverPreviewUtils.ts
Normal file
150
app/javascript/bundles/Main/utils/hoverPreviewUtils.ts
Normal 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(' ');
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
) %>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
) %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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") %>
|
||||
|
||||
Reference in New Issue
Block a user