popover for inline links

This commit is contained in:
Dylan Knutson
2025-03-01 20:23:44 +00:00
parent f5f05c9267
commit 7843f0faa5
53 changed files with 715 additions and 438 deletions

View File

@@ -31,3 +31,5 @@
- [ ] Sofurry implmentation
- [ ] Make unified Static file job
- [ ] Make unified Avatar file job
- [ ] ko-fi domain icon
- [ ] Do PCA on user factors table to display a 2D plot of users

View File

@@ -19,8 +19,8 @@ class Domain::PostGroupsController < DomainController
def self.param_config
DomainController::DomainParamConfig.new(
post_group_id_param: :id,
post_id_param: :post_id,
user_id_param: :user_id,
post_id_param: :domain_post_id,
user_id_param: :domain_user_id,
)
end
end

View File

@@ -105,8 +105,8 @@ class Domain::PostsController < DomainController
def self.param_config
DomainController::DomainParamConfig.new(
post_id_param: :id,
user_id_param: :user_id,
post_group_id_param: :post_group_id,
user_id_param: :domain_user_id,
post_group_id_param: :domain_post_group_id,
)
end

View File

@@ -173,8 +173,8 @@ class Domain::UsersController < DomainController
def self.param_config
DomainController::DomainParamConfig.new(
user_id_param: :id,
post_id_param: :post_id,
post_group_id_param: :post_group_id,
post_id_param: :domain_post_id,
post_group_id_param: :domain_post_group_id,
)
end

View File

@@ -59,30 +59,38 @@ class DomainController < ApplicationController
klass:
T.all(
T.class_of(ReduxApplicationRecord),
T::Class[T.type_parameter(:Klass)],
T::Class[HasCompositeToParam],
HasCompositeToParam::ClassMethods,
T::Class[T.type_parameter(:Klass)],
),
param: T.nilable(String),
)
.returns(T.nilable(T.type_parameter(:Klass)))
end
def self.find_model_from_param(klass, param)
return nil if param.nil?
prefix_param, id = param.split("/")
if prefix_param.blank? || id.blank?
raise ActionController::BadRequest, "invalid param: #{param}"
composite_param = HasCompositeToParam.parse_composite_param(param)
if composite_param.nil?
raise ActionController::BadRequest, "invalid id: #{param.inspect}"
end
klass_param, id = composite_param
group_klass =
klass.subclasses.find do |klass|
param_prefix, _ = T.unsafe(klass).param_prefix_and_attribute
param_prefix == prefix_param
klass.subclasses.find do |subclass|
param_prefix, _ =
T.cast(
subclass,
HasCompositeToParam::ClassMethods,
).param_prefix_and_attribute
param_prefix == klass_param
end
if group_klass.nil?
raise ActionController::BadRequest,
"unknown post group type: #{prefix_param}"
raise ActionController::BadRequest, "unknown model type: #{klass_param}"
end
_, param_attribute = T.unsafe(group_klass).param_prefix_and_attribute
_, param_attribute =
T.cast(
group_klass,
HasCompositeToParam::ClassMethods,
).param_prefix_and_attribute
group_klass.find_by(param_attribute => id)
end
end

View File

@@ -43,6 +43,18 @@ module Domain::DescriptionsHelper
["", "http://", "https://"].any? { |prefix| "#{prefix}#{text}" == url }
end
sig { params(model: HasDescriptionHtmlForView).returns(T.nilable(String)) }
def description_section_class_for_model(model)
case model
when Domain::Post::FaPost, Domain::User::FaUser
"bg-slate-700 p-4 text-slate-200 text-sm"
when Domain::Post::E621Post, Domain::User::E621User
"bg-slate-700 p-4 text-slate-200 text-sm"
else
nil
end
end
sig { params(model: HasDescriptionHtmlForView).returns(T.nilable(String)) }
def sanitize_description_html(model)
html = model.description_html_for_view
@@ -140,7 +152,13 @@ module Domain::DescriptionsHelper
end
replacements[node] = Nokogiri::HTML5.fragment(
render(partial: partial, locals: { as => found_model }),
render(
partial:,
locals: {
as => found_model,
:link_text => node.text.strip,
},
),
)
next { node_whitelist: [node] }
@@ -157,7 +175,7 @@ module Domain::DescriptionsHelper
replacements.each { |node, replacement| node.replace(replacement) }
raw fragment.to_html(preserve_newline: true)
rescue StandardError
raise if Rails.env == "staging"
raise if Rails.env == "staging" || Rails.env.test? || Rails.env.development?
# if anything goes wrong in production, bail out and don't display anything
"(error generating description)"
end

View File

@@ -0,0 +1,46 @@
# typed: strict
module Domain::DomainModelHelper
extend T::Sig
extend T::Helpers
include HelpersInterface
abstract!
HasDomainTypeType =
T.type_alias { T.any(HasDomainType, HasDomainType::ClassMethods) }
sig { params(model: HasDomainTypeType).returns(String) }
def domain_name_for_model(model)
case model.domain_type
when Domain::DomainType::Fa
"FurAffinity"
when Domain::DomainType::E621
"E621"
when Domain::DomainType::Inkbunny
"Inkbunny"
end
end
sig { params(model: HasDomainTypeType).returns(String) }
def domain_abbreviation_for_model(model)
case model.domain_type
when Domain::DomainType::Fa
"FA"
when Domain::DomainType::E621
"E621"
when Domain::DomainType::Inkbunny
"IB"
end
end
sig { params(model: Domain::Post).returns(String) }
def title_for_post_model(model)
case model
when Domain::Post::FaPost
model.title
when Domain::Post::E621Post
model.title
when Domain::Post::InkbunnyPost
model.title
end || "(unknown)"
end
end

View File

@@ -0,0 +1,9 @@
# typed: strict
# Enum represents the domain of a post or user, e.g. "FurAffinity", "E621", "Inkbunny"
class Domain::DomainType < T::Enum
enums do
Fa = new
E621 = new
Inkbunny = new
end
end

View File

@@ -7,6 +7,8 @@ module Domain::PostsHelper
include Domain::UsersHelper
include PathsHelper
include Domain::DomainsHelper
include Domain::DomainModelHelper
include Pundit::Authorization
abstract!
class DomainData < T::Struct
@@ -88,6 +90,18 @@ module Domain::PostsHelper
pretty_content_type(content_type)
end
sig { params(post: Domain::Post).returns(T.nilable(String)) }
def thumbnail_for_post_path(post)
file = gallery_file_for_post(post)
return nil unless file.present?
return nil unless file.state_ok?
return nil unless file.log_entry_id.present?
if (log_entry = file.log_entry) &&
(response_sha256 = log_entry.response_sha256)
blob_path(HexUtil.bin2hex(response_sha256), format: "jpg", thumb: "small")
end
end
sig { params(content_type: String).returns(String) }
def pretty_content_type(content_type)
case content_type
@@ -212,7 +226,7 @@ module Domain::PostsHelper
const :find_proc,
T
.proc
.params(match: MatchData, url: String)
.params(helper: Domain::PostsHelper, match: MatchData, url: String)
.returns(T.nilable(SourceResult))
end
@@ -232,9 +246,12 @@ module Domain::PostsHelper
SourceMatcher.new(
hosts: FA_HOSTS,
patterns: [%r{/view/(\d+)/?}],
find_proc: ->(match, _) do
find_proc: ->(helper, match, _) do
if post = Domain::Post::FaPost.find_by(fa_id: match[1])
SourceResult.new(model: post, title: post.title_for_view)
SourceResult.new(
model: post,
title: helper.title_for_post_model(post),
)
end
end,
),
@@ -242,7 +259,7 @@ module Domain::PostsHelper
SourceMatcher.new(
hosts: FA_CDN_HOSTS,
patterns: [//],
find_proc: ->(_, url) do
find_proc: ->(helper, _, url) do
url = Addressable::URI.parse(url)
post_file =
@@ -257,7 +274,9 @@ module Domain::PostsHelper
).first
if post_file && (post = post_file.post)
SourceResult.new(model: post, title: post.title_for_view)
title =
T.bind(self, Domain::PostsHelper).title_for_post_model(post)
SourceResult.new(model: post, title:)
end
end,
),
@@ -265,7 +284,7 @@ module Domain::PostsHelper
SourceMatcher.new(
hosts: FA_HOSTS,
patterns: [%r{/user/([^/]+)/?}],
find_proc: ->(match, _) do
find_proc: ->(helper, match, _) do
if user = Domain::User::FaUser.find_by(url_name: match[1])
SourceResult.new(
model: user,
@@ -278,9 +297,10 @@ module Domain::PostsHelper
SourceMatcher.new(
hosts: IB_HOSTS,
patterns: [%r{/s/(\d+)/?}, %r{/submissionview\.php\?id=(\d+)/?}],
find_proc: ->(match, _) do
find_proc: ->(helper, match, _) do
if post = Domain::Post::InkbunnyPost.find_by(ib_id: match[1])
SourceResult.new(model: post, title: post.title_for_view)
title = helper.title_for_post_model(post)
SourceResult.new(model: post, title:)
end
end,
),
@@ -288,7 +308,7 @@ module Domain::PostsHelper
SourceMatcher.new(
hosts: IB_CDN_HOSTS,
patterns: [//],
find_proc: ->(_, url) do
find_proc: ->(helper, _, url) do
url = Addressable::URI.parse(url)
if post_file =
Domain::PostFile.where(
@@ -296,7 +316,8 @@ module Domain::PostsHelper
"ib.metapix.net#{url.path}",
).first
if post = post_file.post
SourceResult.new(model: post, title: post.title_for_view)
title = helper.title_for_post_model(post)
SourceResult.new(model: post, title:)
end
end
end,
@@ -305,7 +326,7 @@ module Domain::PostsHelper
SourceMatcher.new(
hosts: IB_HOSTS,
patterns: [%r{/(\w+)/?$}],
find_proc: ->(match, _) do
find_proc: ->(_, match, _) do
if user =
Domain::User::InkbunnyUser.where(
"lower(json_attributes->>'name') = lower(?)",
@@ -343,14 +364,16 @@ module Domain::PostsHelper
}
for pattern in matcher.patterns
if (match = pattern.match(uri.to_s))
object = matcher.find_proc.call(match, uri.to_s)
object = matcher.find_proc.call(self, match, uri.to_s)
return nil unless object
model = object.model
if model.is_a?(Domain::Post)
model_path = domain_post_path(model)
model_path =
Rails.application.routes.url_helpers.domain_post_path(model)
elsif model.is_a?(Domain::User)
model_path = domain_user_path(model)
model_path =
Rails.application.routes.url_helpers.domain_user_path(model)
icon_path =
domain_user_avatar_img_src_path(
model.avatar,

View File

@@ -5,6 +5,20 @@ module Domain::UsersHelper
include HelpersInterface
abstract!
sig { params(user: Domain::User).returns(T.nilable(String)) }
def user_avatar_path_for_view(user)
if avatar = user.avatar
domain_user_avatar_img_src_path(avatar, thumb: "32-avatar")
end
end
sig { params(user: Domain::User).returns(T.nilable(String)) }
def user_name_for_view(user)
if avatar = user.avatar
domain_user_avatar_img_src_path(avatar, thumb: "32-avatar")
end
end
sig do
params(
avatar: T.nilable(Domain::UserAvatar),

View File

@@ -6,53 +6,53 @@ module PathsHelper
include HelpersInterface
abstract!
sig do
params(post: Domain::Post, params: T::Hash[Symbol, T.untyped]).returns(
String,
)
end
def domain_post_path(post, params = {})
to_path("#{domain_posts_path}/#{post.to_param}", params)
end
# sig do
# params(post: Domain::Post, params: T::Hash[Symbol, T.untyped]).returns(
# String,
# )
# end
# def domain_post_path(post, params = {})
# to_path("#{domain_posts_path}/#{post.to_param}", params)
# end
sig do
params(post: Domain::Post, params: T::Hash[Symbol, T.untyped]).returns(
String,
)
end
def domain_post_faved_by_path(post, params = {})
to_path("#{domain_post_path(post)}/faved_by", params)
end
# sig do
# params(post: Domain::Post, params: T::Hash[Symbol, T.untyped]).returns(
# String,
# )
# end
# def domain_post_faved_by_path(post, params = {})
# to_path("#{domain_post_path(post)}/faved_by", params)
# end
sig { params(params: T::Hash[Symbol, T.untyped]).returns(String) }
def domain_posts_path(params = {})
to_path("/posts", params)
end
# sig { params(params: T::Hash[Symbol, T.untyped]).returns(String) }
# def domain_posts_path(params = {})
# to_path("/posts", params)
# end
sig do
params(
post_group: Domain::PostGroup,
params: T::Hash[Symbol, T.untyped],
).returns(String)
end
def domain_post_group_posts_path(post_group, params = {})
to_path("#{domain_post_group_path(post_group)}/posts", params)
end
# sig do
# params(
# post_group: Domain::PostGroup,
# params: T::Hash[Symbol, T.untyped],
# ).returns(String)
# end
# def domain_post_group_posts_path(post_group, params = {})
# to_path("#{domain_post_group_path(post_group)}/posts", params)
# end
sig do
params(
post_group: Domain::PostGroup,
params: T::Hash[Symbol, T.untyped],
).returns(String)
end
def domain_post_group_path(post_group, params = {})
to_path("#{domain_post_groups_path}/#{post_group.to_param}", params)
end
# sig do
# params(
# post_group: Domain::PostGroup,
# params: T::Hash[Symbol, T.untyped],
# ).returns(String)
# end
# def domain_post_group_path(post_group, params = {})
# to_path("#{domain_post_groups_path}/#{post_group.to_param}", params)
# end
sig { params(params: T::Hash[Symbol, T.untyped]).returns(String) }
def domain_post_groups_path(params = {})
to_path("/pools", params)
end
# sig { params(params: T::Hash[Symbol, T.untyped]).returns(String) }
# def domain_post_groups_path(params = {})
# to_path("/pools", params)
# end
private

View File

@@ -0,0 +1,257 @@
import * as React from 'react';
import { useState, useRef, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
interface PostHoverPreviewProps {
children: React.ReactNode;
postTitle: string;
postThumbnailPath?: string;
postThumbnailAlt?: string;
postDomainIcon?: string;
creatorName?: string;
creatorAvatarPath?: string;
}
export const PostHoverPreview: React.FC<PostHoverPreviewProps> = ({
children,
postTitle,
postThumbnailPath,
postThumbnailAlt,
postDomainIcon,
creatorName,
creatorAvatarPath,
}) => {
// 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 thumbnail image when component mounts
useEffect(() => {
if (postThumbnailPath) {
const img = new Image();
img.onload = () => setImageLoaded(true);
img.src = postThumbnailPath;
}
}, [postThumbnailPath]);
// Preload creator avatar if available
useEffect(() => {
if (creatorAvatarPath) {
const img = new Image();
img.src = creatorAvatarPath;
}
}, [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',
'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-[500px] max-w-[300px] 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: Title */}
<div className={headerFooterClassName}>
<span
className="mr-2 min-w-0 truncate text-sm font-medium tracking-tight text-slate-800 dark:text-slate-300"
title={postTitle}
>
{postTitle}
</span>
</div>
{/* Image Content */}
<div className="flex items-center justify-center bg-slate-50 p-2 dark:bg-slate-900">
{postThumbnailPath ? (
<div className="relative block overflow-hidden rounded-md transition-transform hover:scale-[1.02]">
{!imageLoaded && (
<div className="flex h-[200px] w-[200px] items-center justify-center rounded-md border border-slate-200 bg-slate-100 dark:border-slate-600 dark:bg-slate-800">
<span className="text-sm text-slate-500 dark:text-slate-400">
Loading...
</span>
</div>
)}
<img
src={postThumbnailPath}
alt={postTitle}
className={`max-h-[250px] max-w-full rounded-md border border-slate-200 object-contain shadow-md 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-sm italic text-slate-500 dark:text-slate-400">Image could not be loaded</span>';
}
}}
/>
</div>
) : (
<span className="block px-4 py-6 text-sm italic text-slate-500 dark:text-slate-400">
{postThumbnailAlt || 'No file available'}
</span>
)}
</div>
{/* Footer: Domain icon & Creator */}
{creatorName && (
<div className={headerFooterClassName}>
{(postDomainIcon && (
<img
src={postDomainIcon}
alt={postTitle}
className="h-6 w-6 rounded-md bg-slate-500 p-1 shadow-sm ring-sky-100 dark:ring-sky-900"
/>
)) || (
<span className="h-6 w-6 grow rounded-md shadow-sm ring-sky-100 dark:ring-sky-900"></span>
)}
<span className="flex items-center gap-2 justify-self-end">
<span
className="truncate text-xs font-medium text-slate-500 dark:text-slate-400"
title={creatorName}
>
{creatorName}
</span>
{creatorAvatarPath && (
<img
src={creatorAvatarPath}
alt={creatorName}
className="h-6 w-6 rounded-md shadow-sm ring-sky-100 dark:ring-sky-900"
/>
)}
</span>
</div>
)}
</div>,
portalContainer,
)}
</div>
);
};
export default PostHoverPreview;

View File

@@ -0,0 +1,47 @@
import * as React from 'react';
import PostHoverPreview from './PostHoverPreview';
interface PostHoverPreviewWrapperProps {
linkText: string;
postTitle: string;
postPath: string;
postThumbnailPath: string;
postThumbnailAlt: string;
postDomainIcon: string;
creatorName?: string;
creatorAvatarPath?: string;
}
export const PostHoverPreviewWrapper: React.FC<
PostHoverPreviewWrapperProps
> = ({
linkText,
postTitle,
postPath,
postThumbnailPath,
postThumbnailAlt,
postDomainIcon,
creatorName,
creatorAvatarPath,
}) => {
return (
<PostHoverPreview
postTitle={postTitle}
postThumbnailPath={postThumbnailPath}
postThumbnailAlt={postThumbnailAlt}
postDomainIcon={postDomainIcon}
creatorName={creatorName}
creatorAvatarPath={creatorAvatarPath}
>
<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"
>
<i className="fa-regular fa-image h-4 w-4 flex-shrink-0"></i>
{linkText}
</a>
</PostHoverPreview>
);
};
export default PostHoverPreviewWrapper;

View File

@@ -2,12 +2,14 @@ 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 { initCollapsibleSections } from '../bundles/UI/collapsibleSections';
// This is how react_on_rails can see the components in the browser.
ReactOnRails.register({
UserSearchBar,
UserMenu,
PostHoverPreviewWrapper,
});
// Initialize collapsible sections

View File

@@ -2,8 +2,11 @@ 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';
// This is how react_on_rails can see the UserSearchBar in the browser.
ReactOnRails.register({
UserMenu,
UserSearchBar,
PostHoverPreviewWrapper,
});

View File

@@ -8,8 +8,8 @@ module Domain::NeighborFinder
.returns(ActiveRecord::Relation)
end
def self.find_neighbors(factors)
Domain::Factors.connection.execute("SET ivfflat.max_probes = 32")
Domain::Factors.connection.execute("SET ivfflat.probes = 32")
Domain::Factors.connection.execute("SET ivfflat.max_probes = 10")
Domain::Factors.connection.execute("SET ivfflat.probes = 10")
factors.nearest_neighbors(:embedding, distance: "euclidean")
end
end

View File

@@ -4,11 +4,13 @@ class Scraper::Metrics::GoodJobMetricsWithQueues < PrometheusExporter::Instrumen
sig do
params(
args: T.untyped,
client: T.nilable(PrometheusExporter::Client),
frequency: Numeric,
kwargs: T.untyped,
).void
end
def self.start(client: nil, frequency: 5)
def self.start(*args, client: nil, frequency: 5, **kwargs)
client ||= PrometheusExporter::Client.default
gauge =

View File

@@ -7,9 +7,17 @@ module HasCompositeToParam
sig(:final) { returns(T.nilable(String)) }
def to_param
prefix, param = self.class.param_prefix_and_attribute
klass, param = self.class.param_prefix_and_attribute
param_value = send(param)
param_value.present? ? "#{prefix}/#{param_value}" : nil
param_value.present? ? "#{klass}@#{param_value}" : nil
end
sig { params(param: T.nilable(String)).returns(T.nilable([String, String])) }
def self.parse_composite_param(param)
return nil unless param.present?
klass, param_value = param.split("@", 2)
return nil unless klass.present? && param_value.present?
[klass, param_value]
end
module ClassMethods

View File

@@ -0,0 +1,27 @@
# typed: strict
module HasDomainType
extend T::Sig
extend T::Helpers
include HelpersInterface
abstract!
requires_ancestor { Object }
sig(:final) { returns(Domain::DomainType) }
def domain_type
T.unsafe(self.class).domain_type
end
module ClassMethods
extend T::Sig
extend T::Helpers
include HelpersInterface
abstract!
sig { abstract.returns(Domain::DomainType) }
def domain_type
end
end
mixes_in_class_methods(ClassMethods)
end

View File

@@ -5,6 +5,7 @@ class Domain::Post < ReduxApplicationRecord
include HasViewPrefix
include AttrJsonRecordAliases
include HasDescriptionHtmlForView
include HasDomainType
self.table_name = "domain_posts"
abstract!
@@ -146,14 +147,6 @@ class Domain::Post < ReduxApplicationRecord
def domain_id_for_view
end
sig { abstract.returns(String) }
def domain_abbreviation_for_view
end
sig { abstract.returns(String) }
def self.domain_name_for_view
end
sig { abstract.returns(T.nilable(Addressable::URI)) }
def external_url_for_view
end

View File

@@ -91,6 +91,11 @@ class Domain::Post::E621Post < Domain::Post
"e621"
end
sig { override.returns(Domain::DomainType) }
def self.domain_type
Domain::DomainType::E621
end
sig { override.returns(T.nilable(String)) }
def title
self
@@ -150,21 +155,11 @@ class Domain::Post::E621Post < Domain::Post
self.e621_id
end
sig { override.returns(String) }
def domain_abbreviation_for_view
"E621"
end
sig { override.returns(T.nilable(HttpLogEntry)) }
def scanned_post_log_entry_for_view
self.scan_log_entry || self.last_index_page || self.caused_by_entry
end
sig { override.returns(String) }
def self.domain_name_for_view
"E621"
end
sig { override.returns(String) }
def description_html_base_domain
"e621.net"

View File

@@ -58,6 +58,11 @@ class Domain::Post::FaPost < Domain::Post
:fa_id
end
sig { override.returns(Domain::DomainType) }
def self.domain_type
Domain::DomainType::Fa
end
sig { override.returns(T.nilable(String)) }
def title
super
@@ -73,16 +78,6 @@ class Domain::Post::FaPost < Domain::Post
self.fa_id
end
sig { override.returns(String) }
def domain_abbreviation_for_view
"FA"
end
sig { override.returns(String) }
def self.domain_name_for_view
"FurAffinity"
end
sig { override.returns(T.nilable(Addressable::URI)) }
def external_url_for_view
if self.fa_id.present?

View File

@@ -95,6 +95,11 @@ class Domain::Post::InkbunnyPost < Domain::Post
"inkbunny.net"
end
sig { override.returns(Domain::DomainType) }
def self.domain_type
Domain::DomainType::Inkbunny
end
sig { override.returns(T.nilable(String)) }
def description_html_for_view
self.description
@@ -136,16 +141,6 @@ class Domain::Post::InkbunnyPost < Domain::Post
end
end
sig { override.returns(String) }
def domain_abbreviation_for_view
"IB"
end
sig { override.returns(String) }
def self.domain_name_for_view
"Inkbunny"
end
sig { override.returns(T.nilable(Addressable::URI)) }
def external_url_for_view
if self.ib_id.present?

View File

@@ -6,6 +6,7 @@ class Domain::User < ReduxApplicationRecord
include HasViewPrefix
include HasDescriptionHtmlForView
include HasTimestampsWithDueAt
include HasDomainType
self.table_name = "domain_users"
abstract!

View File

@@ -35,6 +35,11 @@ class Domain::User::E621User < Domain::User
"e621"
end
sig { override.returns(Domain::DomainType) }
def self.domain_type
Domain::DomainType::E621
end
sig { override.returns(String) }
def description_html_base_domain
"e621.net"

View File

@@ -73,6 +73,11 @@ class Domain::User::FaUser < Domain::User
"fa"
end
sig { override.returns(Domain::DomainType) }
def self.domain_type
Domain::DomainType::Fa
end
sig { override.returns(String) }
def description_html_base_domain
"furaffinity.net"

View File

@@ -43,6 +43,11 @@ class Domain::User::InkbunnyUser < Domain::User
"ib"
end
sig { override.returns(Domain::DomainType) }
def self.domain_type
Domain::DomainType::Inkbunny
end
sig { override.returns(String) }
def account_status_for_view
"ok"

View File

@@ -1,6 +1,23 @@
<%= link_to domain_post_path(post),
class:
"text-sky-200 transition-all hover:text-sky-800 inline-flex items-center hover:bg-gray-100 rounded-md gap-1 px-1" do %>
<i class="fa-regular fa-image h-4 w-4 flex-shrink-0"></i>
<span><%= post.title %></span>
<% end %>
<%
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"
}
}
) %>

View File

@@ -4,15 +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 policy(post).view_file? %>
<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 blob_path(
HexUtil.bin2hex(thumbnail_file.log_entry.response_sha256),
format: "jpg",
thumb: "small",
),
<%= image_tag thumbnail_for_post_path(post),
class:
"max-h-[300px] max-w-[300px] rounded-md border border-slate-300 object-contain shadow-md",
alt: post.title_for_view %>
@@ -26,12 +22,12 @@
<% end %>
<div class="">
<h2 class="p-4 text-center text-lg">
<%= link_to post.title_for_view, domain_post_path(post), class: "blue-link" %>
<%= link_to title_for_post_model(post), domain_post_path(post), class: "blue-link" %>
</h2>
<div class="px-4 pb-4 text-sm text-slate-500">
<div class="flex justify-between gap-2">
<% if @posts_index_view_config.show_creator_links %>
<% if creator = post.primary_creator_for_view%>
<% if creator = post.primary_creator_for_view %>
<%= render "domain/users/by_inline_link", user: creator %>
<% elsif creator = post.primary_creator_name_fallback_for_view %>
<%= creator %>

View File

@@ -8,7 +8,7 @@
<% if url.present? %>
<%= link_to url.to_s, target: "_blank", rel: "noopener noreferrer", class: link_class do %>
<span
><%= post.domain_abbreviation_for_view %>
><%= domain_abbreviation_for_model(post) %>
#<%= post.domain_id_for_view %></span
>
<%= render partial: "shared/icons/external_link",
@@ -18,7 +18,7 @@
<% end %>
<% else %>
<span
><%= post.domain_abbreviation_for_view %>
><%= domain_abbreviation_for_model(post) %>
#<%= post.domain_id_for_view %></span
>
<% end %>

View File

@@ -3,7 +3,7 @@
<div class="flex min-w-0 items-center gap-1">
<%= image_tag domain_post_domain_icon_path(post), class: "w-6 h-6" %>
<span class="truncate text-lg font-medium">
<%= link_to post.title_for_view,
<%= link_to title_for_post_model(post),
post.external_url_for_view.to_s,
class: "text-blue-600 hover:underline",
target: "_blank",
@@ -36,7 +36,7 @@
Favorites: <%= num_favorites %>
<% if policy(post).view_faved_by? %>
(<%= link_to "#{pluralize(post.faving_users.count, "fav")} known",
domain_post_faved_by_path(post),
faved_by_domain_post_users_path(post),
class: "text-blue-600 hover:underline" %>)
<% end %>
<% end %>

View File

@@ -38,7 +38,7 @@
target: "_blank",
rel: "noopener noreferrer",
class: "section-header flex items-center gap-2 hover:text-slate-600" do %>
<span>View Post on <%= post.class.domain_name_for_view %></span>
<span>View Post on <%= domain_name_for_model(post) %></span>
<i class="fa-solid fa-arrow-up-right-from-square"></i>
<% end %>
</section>

View File

@@ -1,5 +1,5 @@
<% description_html = sanitize_description_html(post) %>
<%= sky_section_tag("Description", collapsible: true, class: description_html ? "bg-slate-700 p-4 text-slate-200" : nil) do %>
<%= sky_section_tag("Description", collapsible: true, class: description_html ? "bg-slate-700 p-4 text-slate-200 text-sm" : nil) do %>
<% if description_html %>
<%= description_html %>
<% else %>

View File

@@ -0,0 +1,8 @@
<% description_html = sanitize_description_html(user) %>
<%= sky_section_tag("Description", class: description_section_class_for_model(user)) do %>
<% if description_html %>
<%= description_html %>
<% else %>
<div class="p-4 text-center text-slate-500">No description available</div>
<% end %>
<% end %>

View File

@@ -14,10 +14,5 @@
<% end %>
</div>
</div>
<% cache [@user, "profile_description"], expires_in: 1.hour do %>
<%= render "domain/has_description_html/section_description_sanitized",
model: @user,
description_title: "Profile",
no_description_text: "No description available" %>
<% end %>
<%= render_for_model @user, "section_description", as: :user %>
</div>

View File

@@ -27,7 +27,7 @@
class: "badge bg-secondary text-truncate-link",
target: "_blank",
rel: "noopener noreferrer nofollow" do %>
<i class="fa-solid fa-link me-1"></i><%= post.domain_abbreviation_for_view %>
<i class="fa-solid fa-link me-1"></i><%= domain_abbreviation_for_model(post) %>
<% end %>
<% end %>
<% if post.primary_creator_for_view.present? %>

View File

@@ -1,50 +0,0 @@
<div
class="m-4 flex h-fit flex-col rounded-lg border border-slate-300 bg-slate-50 shadow-sm"
>
<div class="flex justify-between border-b border-slate-300 p-4">
<div>
<%= render partial: "inline_postable_domain_link", locals: { post: post } %>
</div>
<div>
<% if post.artist_path.present? %>
<%= link_to post.artist_name,
post.artist_path,
class: "text-blue-600 hover:text-blue-800" %>
<% else %>
<%= post.artist_name %>
<% end %>
</div>
</div>
<div class="flex items-center justify-center p-4">
<% if post.file_sha256.present? %>
<%= link_to show_path(post) do %>
<%= image_tag blob_path(
HexUtil.bin2hex(post.file_sha256),
format: "jpg",
thumb: "small",
),
class:
"max-h-[300px] max-w-[300px] rounded-md border border-slate-300 object-contain shadow-md",
alt: post.title %>
<% end %>
<% else %>
<span>No file available</span>
<% end %>
</div>
<div class="border-t border-slate-300">
<h2 class="p-4 text-center text-lg">
<%= link_to post.title, show_path(post), class: "sky-link" %>
</h2>
<div class="px-4 pb-4 text-sm text-slate-500">
<div class="flex justify-end">
<% if post.posted_at %>
Posted <%= time_ago_in_words(post.posted_at) %> ago
<% else %>
Post date unknown
<% end %>
</div>
</div>
</div>
</div>

View File

@@ -1,40 +0,0 @@
<div class="grid-row contents">
<div class="grid-cell">
<% if post.file_sha256.present? %>
<%= image_tag blob_path(
HexUtil.bin2hex(post.file_sha256),
format: "jpg",
thumb: "tiny",
),
class: "h-16 w-16 object-cover rounded",
alt: post.title %>
<% else %>
(none)
<% end %>
</div>
<div class="grid-cell min-w-0">
<%= link_to post.title, show_path(post), class: "text-blue-600 hover:text-blue-800" %>
</div>
<div class="grid-cell text-center">
<% if post.artist_path.present? %>
<%= link_to post.artist_name,
post.artist_path,
class: "text-blue-600 hover:text-blue-800" %>
<% else %>
<%= post.artist_name %>
<% end %>
</div>
<div class="grid-cell text-center">
<%= link_to post.external_link_url,
class: "text-blue-600 hover:text-blue-800",
target: "_blank",
rel: "noreferrer" do %>
<%= post.external_link_title %>
<i class="fas fa-external-link-alt ml-1"></i>
<% end %>
</div>
<div class="grid-cell text-right">
<%= post.posted_at ? time_ago_in_words(post.posted_at) : "Unknown" %>
</div>
</div>
<div class="col-span-full border-b border-slate-300"></div>

View File

@@ -1,36 +0,0 @@
<% case post.postable_type %>
<% when "Domain::Fa::Post" %>
<% domain_icon = asset_path("domain-icons/fa.png") %>
<% icon_title = "Furaffinity" %>
<% external_url = "https://www.furaffinity.net/view/#{post.postable.fa_id}" %>
<% link_text = "FA ##{post.postable.fa_id}" %>
<% when "Domain::E621::Post" %>
<% domain_icon = asset_path("domain-icons/e621.png") %>
<% icon_title = "E621" %>
<% external_url = "https://e621.net/posts/#{post.postable.e621_id}" %>
<% link_text = "E621 ##{post.postable.e621_id}" %>
<% when "Domain::Inkbunny::Post" %>
<% domain_icon = asset_path("domain-icons/inkbunny.png") %>
<% icon_title = "Inkbunny" %>
<% external_url = "https://inkbunny.net/post/#{post.postable.ib_post_id}" %>
<% link_text = "IB ##{post.postable.ib_post_id}" %>
<% else %>
<% domain_icon = nil %>
<% external_url = nil %>
<% link_text = "Unknown postable #{post.postable_type}" %>
<% end %>
<div class="flex w-full items-center justify-between">
<% if domain_icon.present? %>
<%= image_tag domain_icon, class: "w-6 h-6", title: icon_title %>
<% end %>
<% link_class =
"flex items-center text-slate-500 hover:text-slate-700 decoration-dotted underline" %>
<%= link_to external_url, target: "_blank", rel: "noopener", class: link_class do %>
<span><%= link_text %></span>
<%= render partial: "shared/icons/external_link",
locals: {
class_name: "w-4 h-4 ml-1",
} %>
<% end %>
</div>

View File

@@ -1,105 +0,0 @@
<% content_for :head do %>
<style>
.grid-cell {
padding: 0.25rem;
border-right: 1px solid #e2e8f0;
}
.grid-cell:last-child {
padding-left: 0;
padding-right: 1rem;
border-right: none;
}
.grid-cell:first-child {
padding-left: 1rem;
}
.grid-row:hover .grid-cell {
background-color: #f1f5f9;
}
</style>
<% end %>
<div class="mx-auto mt-4 text-center sm:mt-6">
<h1 class="text-2xl">All Posts <%= page_str(params) %></h1>
</div>
<div class="mb-6 bg-white shadow">
<div class="mx-auto px-4 sm:px-6 lg:px-8">
<div
class="flex flex-col items-center justify-between gap-4 py-4 sm:flex-row"
>
<!-- Domain Filters -->
<div class="flex flex-wrap gap-2">
<span class="my-auto font-medium text-gray-700">Sources:</span>
<% active_sources = (params[:sources] || SourceHelper.all_source_names).uniq %>
<% SourceHelper.all_source_names.each do |source| %>
<% is_active = active_sources.include?(source) %>
<% link_sources =
(
if is_active
active_sources - [source]
else
active_sources + [source]
end
) %>
<% posts_path_url =
if SourceHelper.has_all_sources?(link_sources)
indexed_posts_path(view: params[:view])
else
indexed_posts_path(sources: link_sources, view: params[:view])
end %>
<%= link_to(
source.titleize,
posts_path_url,
class:
"px-3 py-1 rounded-full text-sm #{is_active ? "bg-blue-500 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"}",
) %>
<% end %>
</div>
<!-- View Type Selector -->
<div class="flex items-center gap-2">
<span class="font-medium text-gray-700">View:</span>
<%= link_to(
indexed_posts_path(view: "gallery", sources: params[:sources]),
class:
"px-3 py-1 rounded-full text-sm #{params[:view] != "table" ? "bg-blue-500 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"}",
) do %>
<i class="fas fa-th-large mr-1"></i> Gallery
<% end %>
<%= link_to(
indexed_posts_path(view: "table", sources: params[:sources]),
class:
"px-3 py-1 rounded-full text-sm #{params[:view] == "table" ? "bg-blue-500 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"}",
) do %>
<i class="fas fa-list mr-1"></i> Table
<% end %>
</div>
</div>
</div>
</div>
<%= render partial: "shared/pagination_controls", locals: { collection: @posts } %>
<% if params[:view] == "table" %>
<div
class="mx-auto grid grid-cols-[auto_1fr_auto_auto_auto] border-b border-slate-300 text-sm"
>
<div class="grid-row contents">
<div class="grid-cell text-center font-semibold">Thumbnail</div>
<div class="grid-cell text-left font-semibold">Title</div>
<div class="grid-cell text-center font-semibold">Artist</div>
<div class="grid-cell text-center font-semibold">Source</div>
<div class="grid-cell text-right font-semibold">Posted</div>
</div>
<div class="col-span-full border-b border-slate-300"></div>
<% @posts.each do |post| %>
<%= render partial: "as_table_row_item", locals: { post: post } %>
<% end %>
</div>
<% else %>
<div class="mx-auto flex flex-wrap justify-center">
<% @posts.each do |post| %>
<%= render partial: "as_gallery_item", locals: { post: post } %>
<% end %>
</div>
<% end %>
<%= render partial: "shared/pagination_controls", locals: { collection: @posts } %>

View File

@@ -22,11 +22,9 @@ Rails.application.routes.draw do
end
resources :users,
as: :domain_users,
only: %i[show],
controller: "domain/users",
constraints: {
id: %r{[^/]+/[^/]+},
} do
controller: "domain/users" do
get :search_by_name, on: :collection
get "followed_by", on: :member, action: :followed_by
@@ -39,23 +37,19 @@ Rails.application.routes.draw do
end
resources :posts,
as: :domain_posts,
only: %i[index show],
controller: "domain/posts",
constraints: {
id: %r{[^/]+/[^/]+},
} do
controller: "domain/posts" do
resources :users, only: %i[], controller: "domain/users", path: "" do
get :faved_by, on: :collection, action: :users_faving_post
end
end
resources :post_groups,
as: :domain_post_groups,
path: "pools",
only: %i[],
controller: "domain/post_groups",
constraints: {
id: %r{[^/]+/[^/]+},
} do
controller: "domain/post_groups" do
resources :posts, only: %i[], controller: "domain/posts" do
get "/", on: :collection, action: :posts_in_group
end

View File

@@ -21,4 +21,12 @@ module.exports = {
require('@tailwindcss/typography'),
require('@tailwindcss/container-queries'),
],
// Custom class regex patterns to match those in VSCode settings
experimental: {
classRegex: [
/\\bclass:\s*'([^']*)'/,
/\\bclass:\s*"([^"]*)"/,
/["'`]([^"'`]*).*?,?\s?/,
],
},
};

View File

@@ -36,6 +36,7 @@ class ApplicationController
include ::Domain::DomainsHelper
include ::Domain::PostsHelper
include ::Domain::DescriptionsHelper
include ::Domain::DomainModelHelper
include ::Domain::E621::PostsHelper
include ::Domain::Fa::PostsHelper
include ::Domain::Fa::UsersHelper
@@ -46,6 +47,7 @@ class ApplicationController
include ::GoodJobHelper
include ::SourceHelper
include ::TimestampHelper
include ::UiHelper
include ::DeviseHelper
include ::ReactOnRails::Utils::Required
include ::ReactOnRails::Helper

View File

@@ -33,6 +33,7 @@ class DeviseController
include ::Domain::DomainsHelper
include ::Domain::PostsHelper
include ::Domain::DescriptionsHelper
include ::Domain::DomainModelHelper
include ::Domain::E621::PostsHelper
include ::Domain::Fa::PostsHelper
include ::Domain::Fa::UsersHelper
@@ -43,6 +44,7 @@ class DeviseController
include ::GoodJobHelper
include ::SourceHelper
include ::TimestampHelper
include ::UiHelper
include ::DeviseHelper
include ::ReactOnRails::Utils::Required
include ::ReactOnRails::Helper

View File

@@ -24,6 +24,18 @@ module GeneratedPathHelpersModule
sig { params(args: T.untyped).returns(String) }
def destroy_user_session_path(*args); end
sig { params(args: T.untyped).returns(String) }
def domain_post_group_posts_path(*args); end
sig { params(args: T.untyped).returns(String) }
def domain_post_path(*args); end
sig { params(args: T.untyped).returns(String) }
def domain_posts_path(*args); end
sig { params(args: T.untyped).returns(String) }
def domain_user_path(*args); end
sig { params(args: T.untyped).returns(String) }
def edit_global_state_path(*args); end
@@ -40,10 +52,16 @@ module GeneratedPathHelpersModule
def fa_cookies_global_states_path(*args); end
sig { params(args: T.untyped).returns(String) }
def faved_by_post_users_path(*args); end
def faved_by_domain_post_users_path(*args); end
sig { params(args: T.untyped).returns(String) }
def favorites_user_posts_path(*args); end
def favorites_domain_user_posts_path(*args); end
sig { params(args: T.untyped).returns(String) }
def followed_by_domain_user_path(*args); end
sig { params(args: T.untyped).returns(String) }
def following_domain_user_path(*args); end
sig { params(args: T.untyped).returns(String) }
def furecs_user_script_path(*args); end
@@ -94,16 +112,7 @@ module GeneratedPathHelpersModule
def pg_hero_path(*args); end
sig { params(args: T.untyped).returns(String) }
def post_group_posts_path(*args); end
sig { params(args: T.untyped).returns(String) }
def post_path(*args); end
sig { params(args: T.untyped).returns(String) }
def posts_path(*args); end
sig { params(args: T.untyped).returns(String) }
def posts_user_posts_path(*args); end
def posts_domain_user_posts_path(*args); end
sig { params(args: T.untyped).returns(String) }
def prometheus_path(*args); end
@@ -187,7 +196,7 @@ module GeneratedPathHelpersModule
def root_path(*args); end
sig { params(args: T.untyped).returns(String) }
def search_by_name_users_path(*args); end
def search_by_name_domain_users_path(*args); end
sig { params(args: T.untyped).returns(String) }
def stats_log_entries_path(*args); end
@@ -207,9 +216,6 @@ module GeneratedPathHelpersModule
sig { params(args: T.untyped).returns(String) }
def user_password_path(*args); end
sig { params(args: T.untyped).returns(String) }
def user_path(*args); end
sig { params(args: T.untyped).returns(String) }
def user_registration_path(*args); end

View File

@@ -24,6 +24,18 @@ module GeneratedUrlHelpersModule
sig { params(args: T.untyped).returns(String) }
def destroy_user_session_url(*args); end
sig { params(args: T.untyped).returns(String) }
def domain_post_group_posts_url(*args); end
sig { params(args: T.untyped).returns(String) }
def domain_post_url(*args); end
sig { params(args: T.untyped).returns(String) }
def domain_posts_url(*args); end
sig { params(args: T.untyped).returns(String) }
def domain_user_url(*args); end
sig { params(args: T.untyped).returns(String) }
def edit_global_state_url(*args); end
@@ -40,10 +52,16 @@ module GeneratedUrlHelpersModule
def fa_cookies_global_states_url(*args); end
sig { params(args: T.untyped).returns(String) }
def faved_by_post_users_url(*args); end
def faved_by_domain_post_users_url(*args); end
sig { params(args: T.untyped).returns(String) }
def favorites_user_posts_url(*args); end
def favorites_domain_user_posts_url(*args); end
sig { params(args: T.untyped).returns(String) }
def followed_by_domain_user_url(*args); end
sig { params(args: T.untyped).returns(String) }
def following_domain_user_url(*args); end
sig { params(args: T.untyped).returns(String) }
def furecs_user_script_url(*args); end
@@ -94,16 +112,7 @@ module GeneratedUrlHelpersModule
def pg_hero_url(*args); end
sig { params(args: T.untyped).returns(String) }
def post_group_posts_url(*args); end
sig { params(args: T.untyped).returns(String) }
def post_url(*args); end
sig { params(args: T.untyped).returns(String) }
def posts_url(*args); end
sig { params(args: T.untyped).returns(String) }
def posts_user_posts_url(*args); end
def posts_domain_user_posts_url(*args); end
sig { params(args: T.untyped).returns(String) }
def prometheus_url(*args); end
@@ -187,7 +196,7 @@ module GeneratedUrlHelpersModule
def root_url(*args); end
sig { params(args: T.untyped).returns(String) }
def search_by_name_users_url(*args); end
def search_by_name_domain_users_url(*args); end
sig { params(args: T.untyped).returns(String) }
def stats_log_entries_url(*args); end
@@ -212,7 +221,4 @@ module GeneratedUrlHelpersModule
sig { params(args: T.untyped).returns(String) }
def user_session_url(*args); end
sig { params(args: T.untyped).returns(String) }
def user_url(*args); end
end

View File

@@ -36,6 +36,7 @@ class Rails::ApplicationController
include ::Domain::DomainsHelper
include ::Domain::PostsHelper
include ::Domain::DescriptionsHelper
include ::Domain::DomainModelHelper
include ::Domain::E621::PostsHelper
include ::Domain::Fa::PostsHelper
include ::Domain::Fa::UsersHelper
@@ -46,6 +47,7 @@ class Rails::ApplicationController
include ::GoodJobHelper
include ::SourceHelper
include ::TimestampHelper
include ::UiHelper
include ::DeviseHelper
include ::ReactOnRails::Utils::Required
include ::ReactOnRails::Helper

View File

@@ -36,6 +36,7 @@ class Rails::Conductor::BaseController
include ::Domain::DomainsHelper
include ::Domain::PostsHelper
include ::Domain::DescriptionsHelper
include ::Domain::DomainModelHelper
include ::Domain::E621::PostsHelper
include ::Domain::Fa::PostsHelper
include ::Domain::Fa::UsersHelper
@@ -46,6 +47,7 @@ class Rails::Conductor::BaseController
include ::GoodJobHelper
include ::SourceHelper
include ::TimestampHelper
include ::UiHelper
include ::DeviseHelper
include ::ReactOnRails::Utils::Required
include ::ReactOnRails::Helper

View File

@@ -36,6 +36,7 @@ class Rails::HealthController
include ::Domain::DomainsHelper
include ::Domain::PostsHelper
include ::Domain::DescriptionsHelper
include ::Domain::DomainModelHelper
include ::Domain::E621::PostsHelper
include ::Domain::Fa::PostsHelper
include ::Domain::Fa::UsersHelper
@@ -46,6 +47,7 @@ class Rails::HealthController
include ::GoodJobHelper
include ::SourceHelper
include ::TimestampHelper
include ::UiHelper
include ::DeviseHelper
include ::ReactOnRails::Utils::Required
include ::ReactOnRails::Helper

View File

@@ -33,6 +33,14 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper 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?
policy_double = double("PostPolicy", view_file?: true)
# Allow any call to policy() with any Post class, including subclasses
allow(helper).to receive(:policy).and_return(policy_double)
end
describe "#sanitize_description_html" do
describe "basic HTML sanitization" do
it "works" do
@@ -85,8 +93,8 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper do
create(:domain_post_fa_post, fa_id: "123456", title: "Post Title")
html = %(<a href="#{url}">FA Link</a>)
sanitized = sanitize_description_html(html)
expect(sanitized).to eq_html(
render(partial: domain_post_link_partial, locals: { post: post }),
expect(sanitized).to include(
/PostHoverPreviewWrapper.+#{post.to_param}/,
)
end
end
@@ -117,11 +125,11 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper do
'
sanitized = sanitize_description_html(html)
expect(sanitized).to include_html(
render(partial: domain_post_link_partial, locals: { post: post1 }),
expect(sanitized).to include(
/PostHoverPreviewWrapper.+#{post1.to_param}/,
)
expect(sanitized).to include_html(
render(partial: domain_post_link_partial, locals: { post: post2 }),
expect(sanitized).to include(
/PostHoverPreviewWrapper.+#{post2.to_param}/,
)
end
@@ -141,8 +149,8 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper do
'<a href="https://www.furaffinity.net/view/123/"><b>Bold</b> <i>Text</i></a>'
sanitized = sanitize_description_html(html)
expect(sanitized).to eq_html(
render(partial: domain_post_link_partial, locals: { post: post1 }),
expect(sanitized).to include(
/PostHoverPreviewWrapper.+#{post1.to_param}/,
)
end
end
@@ -155,8 +163,8 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper do
html = '<a href="/view/123456/">Relative Link</a>'
sanitized = sanitize_description_html(html)
expect(sanitized).to eq_html(
render(partial: domain_post_link_partial, locals: { post: post1 }),
expect(sanitized).to include(
/PostHoverPreviewWrapper.+#{post1.to_param}/,
)
end
@@ -246,17 +254,17 @@ RSpec.describe Domain::DescriptionsHelper, type: :helper do
'
sanitized = sanitize_description_html(html)
expect(sanitized).to include_html(
render(partial: domain_post_link_partial, locals: { post: post1 }),
expect(sanitized).to include(
/PostHoverPreviewWrapper.+#{post1.to_param}/,
)
expect(sanitized).to include_html(
render(partial: domain_user_link_partial, locals: { user: user1 }),
)
expect(sanitized).not_to include("FA Post")
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(2)
expect(sanitized.scan(/<a/).length).to eq(1)
end
end

View File

@@ -136,7 +136,7 @@ RSpec.describe Domain::PostsHelper, type: :helper do
link_for_source =
helper.link_for_source("https://www.furaffinity.net/view/123456/")
expect(link_for_source).to be_present
expect(link_for_source.model_path).to eq("/posts/fa/123456")
expect(link_for_source.model_path).to eq("/posts/fa@123456")
end
end
end

View File

@@ -120,7 +120,7 @@ RSpec.describe Domain::Post::E621Post, type: :model do
end
it "returns formatted string when e621_id is present" do
expect(post.to_param).to eq("e621/12345")
expect(post.to_param).to eq("e621@12345")
end
end
end

View File

@@ -99,7 +99,7 @@ RSpec.describe Domain::User::FaUser, type: :model do
end
it "returns fa/url_name when url_name is present" do
expect(user.to_param).to eq("fa/artist123")
expect(user.to_param).to eq("fa@artist123")
end
end