popover for inline links
This commit is contained in:
2
TODO.md
2
TODO.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
46
app/helpers/domain/domain_model_helper.rb
Normal file
46
app/helpers/domain/domain_model_helper.rb
Normal 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
|
||||
9
app/helpers/domain/domain_type.rb
Normal file
9
app/helpers/domain/domain_type.rb
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
257
app/javascript/bundles/Main/components/PostHoverPreview.tsx
Normal file
257
app/javascript/bundles/Main/components/PostHoverPreview.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
27
app/models/concerns/has_domain_type.rb
Normal file
27
app/models/concerns/has_domain_type.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -6,6 +6,7 @@ class Domain::User < ReduxApplicationRecord
|
||||
include HasViewPrefix
|
||||
include HasDescriptionHtmlForView
|
||||
include HasTimestampsWithDueAt
|
||||
include HasDomainType
|
||||
|
||||
self.table_name = "domain_users"
|
||||
abstract!
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
) %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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>
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 } %>
|
||||
@@ -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
|
||||
|
||||
@@ -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?/,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
2
sorbet/rbi/dsl/application_controller.rbi
generated
2
sorbet/rbi/dsl/application_controller.rbi
generated
@@ -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
|
||||
|
||||
2
sorbet/rbi/dsl/devise_controller.rbi
generated
2
sorbet/rbi/dsl/devise_controller.rbi
generated
@@ -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
|
||||
|
||||
38
sorbet/rbi/dsl/generated_path_helpers_module.rbi
generated
38
sorbet/rbi/dsl/generated_path_helpers_module.rbi
generated
@@ -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
|
||||
|
||||
|
||||
38
sorbet/rbi/dsl/generated_url_helpers_module.rbi
generated
38
sorbet/rbi/dsl/generated_url_helpers_module.rbi
generated
@@ -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
|
||||
|
||||
2
sorbet/rbi/dsl/rails/application_controller.rbi
generated
2
sorbet/rbi/dsl/rails/application_controller.rbi
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
2
sorbet/rbi/dsl/rails/health_controller.rbi
generated
2
sorbet/rbi/dsl/rails/health_controller.rbi
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user