Compare commits
4 Commits
fb436e1b75
...
430247a3ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
430247a3ad | ||
|
|
19fc98e4ef | ||
|
|
ad229fbd4e | ||
|
|
8333a1bb3f |
@@ -35,8 +35,8 @@
|
||||
// "postCreateCommand": "bundle install && rake db:setup",
|
||||
"postCreateCommand": ".devcontainer/post-create.sh",
|
||||
"forwardPorts": [
|
||||
3000, // rails development
|
||||
3001, // rails staging
|
||||
3000, // rails
|
||||
3001, // thrust
|
||||
9394, // prometheus exporter
|
||||
"pgadmin:8080", // pgadmin
|
||||
"grafana:3100", // grafana
|
||||
|
||||
14
.prettierrc
14
.prettierrc
@@ -4,6 +4,10 @@
|
||||
"trailingComma": "all",
|
||||
"arrowParens": "always",
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"printWidth": 80,
|
||||
"plugins": [
|
||||
"prettier-plugin-tailwindcss",
|
||||
"@prettier/plugin-ruby",
|
||||
@@ -11,5 +15,13 @@
|
||||
"@4az/prettier-plugin-html-erb"
|
||||
],
|
||||
"xmlQuoteAttributes": "double",
|
||||
"xmlWhitespaceSensitivity": "ignore"
|
||||
"xmlWhitespaceSensitivity": "ignore",
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"options": {
|
||||
"parser": "typescript"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
2
Gemfile
2
Gemfile
@@ -144,11 +144,11 @@ gem "attr_json"
|
||||
|
||||
group :production, :staging do
|
||||
gem "rails_semantic_logger", "~> 4.17"
|
||||
gem "cloudflare-rails"
|
||||
end
|
||||
|
||||
group :production do
|
||||
gem "sd_notify"
|
||||
gem "cloudflare-rails"
|
||||
end
|
||||
|
||||
gem "rack", "~> 2.2"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
rails: RAILS_ENV=staging HTTP_PORT=3001 TARGET_PORT=3002 bundle exec thrust ./bin/rails server -p 3002
|
||||
rails: RAILS_ENV=staging HTTP_PORT=3001 bundle exec thrust ./bin/rails server
|
||||
wp-client: RAILS_ENV=development HMR=true ./bin/webpacker-dev-server
|
||||
wp-server: RAILS_ENV=development HMR=true SERVER_BUNDLE_ONLY=yes ./bin/webpacker --watch
|
||||
css: RAILS_ENV=development yarn "build:css[debug]" --watch
|
||||
|
||||
@@ -29,11 +29,11 @@ export default function ListItem({
|
||||
subtext,
|
||||
domainIcon,
|
||||
}: PropTypes) {
|
||||
const groupHoverClassName = 'group-hover:text-slate-200';
|
||||
const iconClassName = ['ml-2'];
|
||||
const textClassName = [
|
||||
COMMON_LIST_ELEM_CLASSES,
|
||||
'relative flex items-center justify-between',
|
||||
'border-t-0',
|
||||
'group flex items-center justify-between',
|
||||
isLast && 'rounded-b-lg',
|
||||
style === 'item' && selected && 'bg-slate-700 text-slate-100',
|
||||
style === 'info' && 'text-slate-500 italic',
|
||||
@@ -66,12 +66,21 @@ export default function ListItem({
|
||||
</div>
|
||||
<div className="inline-block flex-grow pl-1">{value}</div>
|
||||
{subtext && (
|
||||
<div className="vertical-align-middle float-right inline-block pl-1 text-sm italic text-slate-500">
|
||||
<div
|
||||
className={[
|
||||
'vertical-align-middle float-right inline-block pl-1 text-sm italic',
|
||||
!selected && 'text-slate-500',
|
||||
selected && 'text-slate-300',
|
||||
groupHoverClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{subtext}
|
||||
</div>
|
||||
)}
|
||||
{domainIcon && (
|
||||
<img src={domainIcon} alt="domain icon" className="inline w-6 pl-1" />
|
||||
<img src={domainIcon} alt="domain icon" className="inline w-6" />
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -44,9 +44,9 @@ export const PostHoverPreviewWrapper: React.FC<
|
||||
className={anchorClassNamesForVisualStyle(visualStyle, true)}
|
||||
>
|
||||
{postDomainIcon && (
|
||||
<img
|
||||
src={postDomainIcon}
|
||||
alt={postThumbnailAlt}
|
||||
<img
|
||||
src={postDomainIcon}
|
||||
alt={postThumbnailAlt}
|
||||
className={iconClassNamesForSize('small')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const SortableTable: React.FC<SortableTableProps> = ({
|
||||
};
|
||||
|
||||
const sortedData = React.useMemo(() => {
|
||||
const headerIndex = headers.findIndex(h => h.key === sortKey);
|
||||
const headerIndex = headers.findIndex((h) => h.key === sortKey);
|
||||
if (headerIndex === -1) return data;
|
||||
|
||||
return [...data].sort((a, b) => {
|
||||
@@ -92,7 +92,13 @@ export const SortableTable: React.FC<SortableTableProps> = ({
|
||||
|
||||
const getSortIndicator = (headerKey: string) => {
|
||||
if (sortKey !== headerKey) {
|
||||
return <span style={{ opacity: 0.5, marginLeft: '0.25rem', fontSize: '0.75rem' }}>▼</span>;
|
||||
return (
|
||||
<span
|
||||
style={{ opacity: 0.5, marginLeft: '0.25rem', fontSize: '0.75rem' }}
|
||||
>
|
||||
▼
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span style={{ marginLeft: '0.25rem', fontSize: '0.75rem' }}>
|
||||
@@ -112,7 +118,9 @@ export const SortableTable: React.FC<SortableTableProps> = ({
|
||||
style={{
|
||||
...headerStyle,
|
||||
textAlign: header.align,
|
||||
...(index === headers.length - 1 ? { borderRight: 'none' } : {}),
|
||||
...(index === headers.length - 1
|
||||
? { borderRight: 'none' }
|
||||
: {}),
|
||||
}}
|
||||
onClick={() => handleSort(header.key)}
|
||||
onMouseEnter={(e) => {
|
||||
@@ -130,7 +138,7 @@ export const SortableTable: React.FC<SortableTableProps> = ({
|
||||
|
||||
{/* Data Rows */}
|
||||
{sortedData.map((row) => (
|
||||
<div key={row.id} className="contents group">
|
||||
<div key={row.id} className="group contents">
|
||||
{row.cells.map((cell, cellIndex) => (
|
||||
<div
|
||||
key={cellIndex}
|
||||
@@ -140,9 +148,11 @@ export const SortableTable: React.FC<SortableTableProps> = ({
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: cellIndex === 0 ? 500 : 400,
|
||||
color: cellIndex === 0 ? '#0f172a' : '#64748b',
|
||||
...(cellIndex === row.cells.length - 1 ? { borderRight: 'none' } : {}),
|
||||
...(cellIndex === row.cells.length - 1
|
||||
? { borderRight: 'none' }
|
||||
: {}),
|
||||
}}
|
||||
className="group-hover:bg-slate-50 transition-colors duration-150"
|
||||
className="transition-colors duration-150 group-hover:bg-slate-50"
|
||||
>
|
||||
{cell.value}
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,8 @@ export const StatsCard: React.FC<StatsCardProps> = ({
|
||||
<div className="text-xl font-bold text-slate-900">
|
||||
{requestCount} requests
|
||||
<span className="text-base font-normal text-slate-600">
|
||||
{' '}in last {timeWindow}
|
||||
{' '}
|
||||
in last {timeWindow}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-slate-600">
|
||||
|
||||
@@ -42,117 +42,137 @@ export const StatsPage: React.FC<StatsPageProps> = ({
|
||||
byDomainCounts,
|
||||
availableTimeWindows,
|
||||
}) => {
|
||||
const contentTypeData: TableData[] = contentTypeCounts.map(item => ({
|
||||
const contentTypeData: TableData[] = contentTypeCounts.map((item) => ({
|
||||
id: item.content_type,
|
||||
cells: [
|
||||
{ value: item.content_type, sortKey: item.content_type },
|
||||
{ value: item.countFormatted, sortKey: item.count },
|
||||
{ value: item.bytesFormatted, sortKey: item.bytes }
|
||||
]
|
||||
{ value: item.bytesFormatted, sortKey: item.bytes },
|
||||
],
|
||||
}));
|
||||
|
||||
const domainData: TableData[] = byDomainCounts.map(item => ({
|
||||
const domainData: TableData[] = byDomainCounts.map((item) => ({
|
||||
id: item.domain,
|
||||
cells: [
|
||||
{ value: item.domain, sortKey: item.domain },
|
||||
{ value: item.countFormatted, sortKey: item.count },
|
||||
{ value: item.bytesFormatted, sortKey: item.bytes }
|
||||
]
|
||||
{ value: item.bytesFormatted, sortKey: item.bytes },
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 mt-8">
|
||||
<div className="mx-auto mt-8 max-w-7xl px-4">
|
||||
{/* Header Section */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
{/* Top Bar */}
|
||||
<div className="bg-slate-50 px-6 py-4 border-b border-slate-200">
|
||||
<div className="border-b border-slate-200 bg-slate-50 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-900">HTTP Request Analytics</h1>
|
||||
<a
|
||||
href="/log_entries"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 hover:bg-white border border-slate-300 rounded-lg transition-colors hover:shadow-sm"
|
||||
>
|
||||
<i className="fas fa-arrow-left" />
|
||||
Back to Log Entries
|
||||
</a>
|
||||
<h1 className="text-2xl font-bold text-slate-900">
|
||||
HTTP Request Analytics
|
||||
</h1>
|
||||
<a
|
||||
href="/log_entries"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-white hover:text-slate-900 hover:shadow-sm"
|
||||
>
|
||||
<i className="fas fa-arrow-left" />
|
||||
Back to Log Entries
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="px-6 py-6 bg-gradient-to-br from-blue-50 to-indigo-50 border-t border-slate-200">
|
||||
<div className="text-center mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-1">Summary for {timeWindowFormatted}</h3>
|
||||
<div className="border-t border-slate-200 bg-gradient-to-br from-blue-50 to-indigo-50 px-6 py-6">
|
||||
<div className="mb-4 text-center">
|
||||
<h3 className="mb-1 text-lg font-semibold text-slate-900">
|
||||
Summary for {timeWindowFormatted}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Total Requests */}
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm border border-slate-200">
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Total Requests</p>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{lastWindowCount.toLocaleString()}</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
Total Requests
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-bold text-slate-900">
|
||||
{lastWindowCount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
|
||||
<i className="fas fa-chart-bar text-blue-600" />
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<i className="fas fa-chart-bar text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requests per Second */}
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm border border-slate-200">
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Requests/sec</p>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{requestsPerSecond}</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
Requests/sec
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-bold text-slate-900">
|
||||
{requestsPerSecond}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
|
||||
<i className="fas fa-bolt text-green-600" />
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<i className="fas fa-bolt text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Data */}
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm border border-slate-200">
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Total Data</p>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{totalBytesFormatted}</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
Total Data
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-bold text-slate-900">
|
||||
{totalBytesFormatted}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100">
|
||||
<i className="fas fa-database text-purple-600" />
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<i className="fas fa-database text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data per Second */}
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm border border-slate-200">
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Data/sec</p>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{bytesPerSecondFormatted}</p>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
Data/sec
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-bold text-slate-900">
|
||||
{bytesPerSecondFormatted}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-100">
|
||||
<i className="fas fa-download text-orange-600" />
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<i className="fas fa-download text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Window Selector */}
|
||||
<div className="px-4 py-4 bg-gradient-to-br from-blue-50 to-indigo-50">
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 px-4 py-4">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex flex-wrap justify-center bg-white/70 backdrop-blur-sm rounded-lg p-1 shadow-md border border-white/50 gap-1">
|
||||
<div className="inline-flex flex-wrap justify-center gap-1 rounded-lg border border-white/50 bg-white/70 p-1 shadow-md backdrop-blur-sm">
|
||||
{availableTimeWindows.map((timeWindowOption, index) => (
|
||||
<React.Fragment key={timeWindowOption.seconds}>
|
||||
{timeWindowOption.active ? (
|
||||
<span className="px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-md shadow-sm">
|
||||
<span className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm">
|
||||
{timeWindowOption.label}
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={timeWindowOption.path}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 hover:bg-white hover:shadow-sm rounded-md transition-colors border border-transparent hover:border-slate-200"
|
||||
className="rounded-md border border-transparent px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:border-slate-200 hover:bg-white hover:text-slate-900 hover:shadow-sm"
|
||||
>
|
||||
{timeWindowOption.label}
|
||||
</a>
|
||||
@@ -165,14 +185,16 @@ export const StatsPage: React.FC<StatsPageProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Tables Grid - 2 columns */}
|
||||
<div className="my-8 grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="my-8 grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 mb-3">By Content Type</h2>
|
||||
<h2 className="mb-3 text-xl font-bold text-slate-900">
|
||||
By Content Type
|
||||
</h2>
|
||||
<SortableTable
|
||||
headers={[
|
||||
{ label: 'Content Type', key: 'content_type', align: 'left' },
|
||||
{ label: 'Requests', key: 'count', align: 'right' },
|
||||
{ label: 'Transferred', key: 'bytes', align: 'right' }
|
||||
{ label: 'Transferred', key: 'bytes', align: 'right' },
|
||||
]}
|
||||
data={contentTypeData}
|
||||
defaultSortKey="count"
|
||||
@@ -181,12 +203,12 @@ export const StatsPage: React.FC<StatsPageProps> = ({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 mb-3">By Domain</h2>
|
||||
<h2 className="mb-3 text-xl font-bold text-slate-900">By Domain</h2>
|
||||
<SortableTable
|
||||
headers={[
|
||||
{ label: 'Domain', key: 'domain', align: 'left' },
|
||||
{ label: 'Requests', key: 'count', align: 'right' },
|
||||
{ label: 'Transferred', key: 'bytes', align: 'right' }
|
||||
{ label: 'Transferred', key: 'bytes', align: 'right' },
|
||||
]}
|
||||
data={domainData}
|
||||
defaultSortKey="bytes"
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as React from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import Icon from './Icon';
|
||||
import ListItem from './ListItem';
|
||||
import Trie, { TrieNode } from '../lib/Trie';
|
||||
|
||||
// 1. Group related constants
|
||||
const CONFIG = {
|
||||
@@ -18,7 +17,7 @@ const STYLES = {
|
||||
],
|
||||
SVG_BASE_CLASSNAME: `stroke-slate-500 fill-slate-500`,
|
||||
SVG_FOCUSABLE_CLASSNAME: `stroke-slate-500 fill-slate-500 group-focus-within:stroke-slate-800 group-focus-within:fill-slate-800`,
|
||||
INPUT_CLASSNAME: `text-slate-500 group-focus-within:text-slate-800 placeholder-slate-500 group-focus-within:placeholder-slate-800 placeholder:font-extralight`,
|
||||
INPUT_CLASSNAME: `text-slate-500 focus:text-slate-800 placeholder-slate-500 group-focus-within:placeholder-slate-800 placeholder:font-extralight`,
|
||||
} as const;
|
||||
|
||||
// 2. Simplify logging
|
||||
@@ -46,10 +45,6 @@ interface ServerResponse {
|
||||
users: User[];
|
||||
}
|
||||
|
||||
type TrieValue = [number, string];
|
||||
type TrieType = Trie<TrieValue>;
|
||||
type TrieNodeType = TrieNode<TrieValue>;
|
||||
|
||||
export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
||||
isServerRendered = !!isServerRendered;
|
||||
const [pendingRequest, setPendingRequest] = useState<AbortController | null>(
|
||||
@@ -119,7 +114,6 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
||||
}
|
||||
} catch (err) {
|
||||
if (!err.message.includes('aborted')) {
|
||||
log.error('error loading user trie: ', err);
|
||||
setState((s) => ({
|
||||
...s,
|
||||
errorMessage: `error loading users: ` + err.message,
|
||||
@@ -148,7 +142,6 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
||||
|
||||
const searchForUserDebounced = useCallback(
|
||||
debounce(async (userName) => {
|
||||
log.info('sending search for ', userName);
|
||||
setState((s) => ({ ...s, typingSettled: true }));
|
||||
searchForUser(userName);
|
||||
}, 250),
|
||||
@@ -158,7 +151,6 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
||||
function invokeIdx(idx) {
|
||||
const user = state.userList[idx];
|
||||
if (user) {
|
||||
log.info('selecting user: ', user);
|
||||
setState((s) => ({ ...s, userName: user.name }));
|
||||
inputRef.current.value = user.name;
|
||||
window.location.href = user.show_path;
|
||||
@@ -185,9 +177,7 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
||||
|
||||
function UserSearchBarItems() {
|
||||
return (
|
||||
<div
|
||||
className={`${anyShown || 'border-b-0'} divide-y divide-inherit rounded-b-lg border border-t-0 border-inherit`}
|
||||
>
|
||||
<div className="divide-y divide-slate-300 overflow-hidden border border-slate-300 bg-slate-50 shadow-lg sm:rounded-xl">
|
||||
{visibility.error ? (
|
||||
<ListItem
|
||||
key="error"
|
||||
@@ -267,7 +257,7 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'group mx-auto w-full p-2 transition-colors duration-1000 sm:rounded-xl',
|
||||
'relative mx-auto w-full p-2 transition-colors duration-1000 sm:rounded-xl',
|
||||
'focus-within:border-slate-400 sm:max-w-md',
|
||||
'border-slate-300 bg-slate-50 p-2 shadow-lg',
|
||||
].join(' ')}
|
||||
@@ -290,7 +280,6 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
||||
STYLES.INPUT_CLASSNAME,
|
||||
'rounded-lg outline-none',
|
||||
'bg-slate-50 placeholder:italic',
|
||||
anyShown && 'rounded-b-none',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
@@ -306,7 +295,11 @@ export default function UserSearchBar({ isServerRendered }: PropTypes) {
|
||||
ref={inputRef}
|
||||
/>
|
||||
</label>
|
||||
<UserSearchBarItems />
|
||||
{anyShown && (
|
||||
<div className="absolute left-0 right-0 top-full z-50 mt-1">
|
||||
<UserSearchBarItems />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
interface SerializedTrie<T> {
|
||||
// terminal node?
|
||||
t: 1 | 0;
|
||||
// value of the node
|
||||
v: T;
|
||||
// optional children
|
||||
c?: { [s: string]: SerializedTrie<T> };
|
||||
}
|
||||
|
||||
export class TrieNode<T> {
|
||||
public terminal: boolean;
|
||||
public value: T;
|
||||
public children: Map<string, TrieNode<T>>;
|
||||
public serialized: SerializedTrie<T>;
|
||||
|
||||
constructor(ser: SerializedTrie<T>) {
|
||||
this.terminal = ser.t == 1;
|
||||
this.value = ser.v;
|
||||
this.children = new Map();
|
||||
this.serialized = ser;
|
||||
|
||||
if (ser.c != null) {
|
||||
for (const [key, value] of Object.entries(ser.c)) {
|
||||
this.children.set(key, new TrieNode(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class Trie<T> {
|
||||
public root: TrieNode<T>;
|
||||
constructor(ser: SerializedTrie<T>) {
|
||||
this.root = new TrieNode(ser);
|
||||
}
|
||||
|
||||
public nodeForPrefix(key: string): {
|
||||
chain: string[];
|
||||
node: TrieNode<T> | null;
|
||||
} {
|
||||
let chain = [];
|
||||
let node = this.root;
|
||||
let remaining = key;
|
||||
while (node && remaining.length > 0) {
|
||||
let exactChild = null;
|
||||
console.log('remaining: ', remaining);
|
||||
|
||||
for (const [childKey, child] of node.children.entries()) {
|
||||
if (remaining.startsWith(childKey)) {
|
||||
console.log('exact match for: ', childKey);
|
||||
exactChild = child;
|
||||
chain.push(childKey);
|
||||
remaining = remaining.slice(childKey.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// if an exact match was found, continue iterating
|
||||
if (exactChild) {
|
||||
node = exactChild;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log('looking for partial match for ', remaining);
|
||||
for (const [childKey, child] of node.children.entries()) {
|
||||
const startsWith = childKey.startsWith(remaining);
|
||||
console.log(
|
||||
'test ',
|
||||
childKey,
|
||||
' against ',
|
||||
remaining,
|
||||
': ',
|
||||
startsWith,
|
||||
' ',
|
||||
child.serialized,
|
||||
);
|
||||
if (startsWith) {
|
||||
console.log('partial match for: ', remaining, ': ', child.serialized);
|
||||
chain.push(childKey);
|
||||
return { chain, node: child };
|
||||
}
|
||||
}
|
||||
|
||||
console.log('did not find partial, bailing!');
|
||||
return { chain, node: null };
|
||||
}
|
||||
|
||||
// // return remaining.length === 0 && node && node.terminal ? node : null;
|
||||
console.log('returning child ', node, ' for remaining ', remaining);
|
||||
return { chain, node };
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
function buildUsersTrie(users) {
|
||||
const rootNode = new trie();
|
||||
users.forEach(([id, name]) => {
|
||||
rootNode.insert(name.toLowerCase(), [id, name]);
|
||||
});
|
||||
return JSON.stringify(rootNode.serialize());
|
||||
}
|
||||
class trie_node {
|
||||
constructor() {
|
||||
this.terminal = false;
|
||||
this.children = new Map();
|
||||
}
|
||||
serialize() {
|
||||
const { terminal, value, children } = this;
|
||||
let mapped = {};
|
||||
let numChildren = 0;
|
||||
Object.keys(Object.fromEntries(children)).forEach((childKey) => {
|
||||
numChildren += 1;
|
||||
mapped[childKey] = children.get(childKey).serialize();
|
||||
});
|
||||
return {
|
||||
t: this.terminal ? 1 : 0,
|
||||
v: value,
|
||||
c: numChildren > 0 ? mapped : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
class trie {
|
||||
constructor() {
|
||||
this.root = new trie_node();
|
||||
this.elements = 0;
|
||||
}
|
||||
serialize() {
|
||||
return this.root.serialize();
|
||||
}
|
||||
get length() {
|
||||
return this.elements;
|
||||
}
|
||||
get(key) {
|
||||
const node = this.getNode(key);
|
||||
if (node) {
|
||||
return node.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
contains(key) {
|
||||
const node = this.getNode(key);
|
||||
return !!node;
|
||||
}
|
||||
insert(key, value) {
|
||||
let node = this.root;
|
||||
let remaining = key;
|
||||
while (remaining.length > 0) {
|
||||
let child = null;
|
||||
for (const childKey of node.children.keys()) {
|
||||
const prefix = this.commonPrefix(remaining, childKey);
|
||||
if (!prefix.length) {
|
||||
continue;
|
||||
}
|
||||
if (prefix.length === childKey.length) {
|
||||
// enter child node
|
||||
child = node.children.get(childKey);
|
||||
remaining = remaining.slice(childKey.length);
|
||||
break;
|
||||
}
|
||||
else {
|
||||
// split the child
|
||||
child = new trie_node();
|
||||
child.children.set(childKey.slice(prefix.length), node.children.get(childKey));
|
||||
node.children.delete(childKey);
|
||||
node.children.set(prefix, child);
|
||||
remaining = remaining.slice(prefix.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!child && remaining.length) {
|
||||
child = new trie_node();
|
||||
node.children.set(remaining, child);
|
||||
remaining = "";
|
||||
}
|
||||
node = child;
|
||||
}
|
||||
if (!node.terminal) {
|
||||
node.terminal = true;
|
||||
this.elements += 1;
|
||||
}
|
||||
node.value = value;
|
||||
}
|
||||
remove(key) {
|
||||
const node = this.getNode(key);
|
||||
if (node) {
|
||||
node.terminal = false;
|
||||
this.elements -= 1;
|
||||
}
|
||||
}
|
||||
map(prefix, func) {
|
||||
const mapped = [];
|
||||
const node = this.getNode(prefix);
|
||||
const stack = [];
|
||||
if (node) {
|
||||
stack.push([prefix, node]);
|
||||
}
|
||||
while (stack.length) {
|
||||
const [key, node] = stack.pop();
|
||||
if (node.terminal) {
|
||||
mapped.push(func(key, node.value));
|
||||
}
|
||||
for (const c of node.children.keys()) {
|
||||
stack.push([key + c, node.children.get(c)]);
|
||||
}
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
getNode(key) {
|
||||
let node = this.root;
|
||||
let remaining = key;
|
||||
while (node && remaining.length > 0) {
|
||||
let child = null;
|
||||
for (let i = 1; i <= remaining.length; i += 1) {
|
||||
child = node.children.get(remaining.slice(0, i));
|
||||
if (child) {
|
||||
remaining = remaining.slice(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
node = child;
|
||||
}
|
||||
return remaining.length === 0 && node && node.terminal ? node : null;
|
||||
}
|
||||
commonPrefix(a, b) {
|
||||
const shortest = Math.min(a.length, b.length);
|
||||
let i = 0;
|
||||
for (; i < shortest; i += 1) {
|
||||
if (a[i] !== b[i]) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return a.slice(0, i);
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
type UserRow = [number, string];
|
||||
|
||||
function buildUsersTrie(users: UserRow[]): string {
|
||||
const rootNode = new trie<[number, string]>();
|
||||
users.forEach(([id, name]) => {
|
||||
rootNode.insert(name.toLowerCase(), [id, name]);
|
||||
});
|
||||
return JSON.stringify(rootNode.serialize());
|
||||
}
|
||||
|
||||
class trie_node<T> {
|
||||
public terminal: boolean;
|
||||
public value: T;
|
||||
public children: Map<string, trie_node<T>>;
|
||||
|
||||
constructor() {
|
||||
this.terminal = false;
|
||||
this.children = new Map();
|
||||
}
|
||||
|
||||
public serialize(): Object {
|
||||
const { terminal, value, children } = this;
|
||||
let mapped = {};
|
||||
let numChildren = 0;
|
||||
Object.keys(Object.fromEntries(children)).forEach((childKey) => {
|
||||
numChildren += 1;
|
||||
mapped[childKey] = children.get(childKey).serialize();
|
||||
});
|
||||
return {
|
||||
t: this.terminal ? 1 : 0,
|
||||
v: value,
|
||||
c: numChildren > 0 ? mapped : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class trie<T> {
|
||||
public root: trie_node<T>;
|
||||
public elements: number;
|
||||
|
||||
constructor() {
|
||||
this.root = new trie_node<T>();
|
||||
this.elements = 0;
|
||||
}
|
||||
|
||||
public serialize(): Object {
|
||||
return this.root.serialize();
|
||||
}
|
||||
|
||||
public get length(): number {
|
||||
return this.elements;
|
||||
}
|
||||
|
||||
public get(key: string): T | null {
|
||||
const node = this.getNode(key);
|
||||
if (node) {
|
||||
return node.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public contains(key: string): boolean {
|
||||
const node = this.getNode(key);
|
||||
return !!node;
|
||||
}
|
||||
|
||||
public insert(key: string, value: T): void {
|
||||
let node = this.root;
|
||||
let remaining = key;
|
||||
while (remaining.length > 0) {
|
||||
let child: trie_node<T> = null;
|
||||
for (const childKey of node.children.keys()) {
|
||||
const prefix = this.commonPrefix(remaining, childKey);
|
||||
if (!prefix.length) {
|
||||
continue;
|
||||
}
|
||||
if (prefix.length === childKey.length) {
|
||||
// enter child node
|
||||
child = node.children.get(childKey);
|
||||
remaining = remaining.slice(childKey.length);
|
||||
break;
|
||||
} else {
|
||||
// split the child
|
||||
child = new trie_node<T>();
|
||||
child.children.set(
|
||||
childKey.slice(prefix.length),
|
||||
node.children.get(childKey)
|
||||
);
|
||||
node.children.delete(childKey);
|
||||
node.children.set(prefix, child);
|
||||
remaining = remaining.slice(prefix.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!child && remaining.length) {
|
||||
child = new trie_node<T>();
|
||||
node.children.set(remaining, child);
|
||||
remaining = "";
|
||||
}
|
||||
node = child;
|
||||
}
|
||||
if (!node.terminal) {
|
||||
node.terminal = true;
|
||||
this.elements += 1;
|
||||
}
|
||||
node.value = value;
|
||||
}
|
||||
|
||||
public remove(key: string): void {
|
||||
const node = this.getNode(key);
|
||||
if (node) {
|
||||
node.terminal = false;
|
||||
this.elements -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
public map<U>(prefix: string, func: (key: string, value: T) => U): U[] {
|
||||
const mapped = [];
|
||||
const node = this.getNode(prefix);
|
||||
const stack: [string, trie_node<T>][] = [];
|
||||
if (node) {
|
||||
stack.push([prefix, node]);
|
||||
}
|
||||
while (stack.length) {
|
||||
const [key, node] = stack.pop();
|
||||
if (node.terminal) {
|
||||
mapped.push(func(key, node.value));
|
||||
}
|
||||
for (const c of node.children.keys()) {
|
||||
stack.push([key + c, node.children.get(c)]);
|
||||
}
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
private getNode(key: string): trie_node<T> | null {
|
||||
let node = this.root;
|
||||
let remaining = key;
|
||||
while (node && remaining.length > 0) {
|
||||
let child = null;
|
||||
for (let i = 1; i <= remaining.length; i += 1) {
|
||||
child = node.children.get(remaining.slice(0, i));
|
||||
if (child) {
|
||||
remaining = remaining.slice(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
node = child;
|
||||
}
|
||||
return remaining.length === 0 && node && node.terminal ? node : null;
|
||||
}
|
||||
|
||||
private commonPrefix(a: string, b: string): string {
|
||||
const shortest = Math.min(a.length, b.length);
|
||||
let i = 0;
|
||||
for (; i < shortest; i += 1) {
|
||||
if (a[i] !== b[i]) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return a.slice(0, i);
|
||||
}
|
||||
}
|
||||
@@ -117,11 +117,25 @@ class Domain::Fa::Job::ScanPostJob < Domain::Fa::Job::Base
|
||||
uri = Addressable::URI.parse(submission.full_res_img)
|
||||
uri.scheme = "https" if uri.scheme.blank?
|
||||
|
||||
if (file = post.file) && (file.url_str != uri.to_s)
|
||||
file = post.files.build(url_str: uri.to_s)
|
||||
else
|
||||
file = post.file || post.build_file(url_str: uri.to_s)
|
||||
# resolve the existing file and check if the URL has changed.
|
||||
# sometimes, the domain will change from `d.facdn.net` to
|
||||
# `d.furaffinity.net`, and we want to ignore the change in that case
|
||||
file = post.file
|
||||
if file && (old_url_str = file.url_str) && (old_url_str != uri.to_s)
|
||||
if self.class.uri_same_with_normalized_facdn_host?(old_url_str, uri.to_s)
|
||||
logger.info(
|
||||
format_tags(
|
||||
make_tag("old_url_str", old_url_str),
|
||||
make_tag("new_url_str", uri.to_s),
|
||||
"file url has changed, but is the same domain",
|
||||
),
|
||||
)
|
||||
else
|
||||
file = post.files.build(url_str: uri.to_s)
|
||||
end
|
||||
end
|
||||
file ||= post.build_file(url_str: uri.to_s)
|
||||
|
||||
if file.url_str_changed?
|
||||
file.enqueue_job_after_save(
|
||||
Domain::Fa::Job::ScanFileJob,
|
||||
@@ -142,4 +156,22 @@ class Domain::Fa::Job::ScanPostJob < Domain::Fa::Job::Base
|
||||
post.posted_at = submission.posted_date&.in_time_zone("UTC")
|
||||
post.scanned_at = Time.now
|
||||
end
|
||||
|
||||
FA_CDN_HOSTS = %w[d.facdn.net d.furaffinity.net].freeze
|
||||
sig { params(url_str: String, new_url_str: String).returns(T::Boolean) }
|
||||
def self.uri_same_with_normalized_facdn_host?(url_str, new_url_str)
|
||||
uri = Addressable::URI.parse(url_str)
|
||||
new_uri = Addressable::URI.parse(new_url_str)
|
||||
uri.scheme = nil
|
||||
new_uri.scheme = nil
|
||||
|
||||
if [uri, new_uri].all? { |uri| FA_CDN_HOSTS.include?(uri.host) }
|
||||
# both URIs have an facdn host, so compare them but ignore the host
|
||||
uri.host = nil
|
||||
new_uri.host = nil
|
||||
uri == new_uri
|
||||
else
|
||||
url_str == new_url_str
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# typed: strict
|
||||
class Domain::Post < ReduxApplicationRecord
|
||||
extend T::Helpers
|
||||
include HasAuxTable
|
||||
include HasCompositeToParam
|
||||
include HasViewPrefix
|
||||
include AttrJsonRecordAliases
|
||||
|
||||
@@ -1,35 +1,6 @@
|
||||
# typed: strict
|
||||
class Domain::Post::E621Post < Domain::Post
|
||||
attr_json :state, :string
|
||||
attr_json :e621_id, :integer
|
||||
# When was the post's /posts/<post_id>/favorites pages scanned?
|
||||
# Used to identify users with a significant number of favorites, setting
|
||||
# their `num_other_favs_cached` attribute
|
||||
attr_json :scanned_post_favs_at, ActiveModelUtcTimeValue.new
|
||||
attr_json :rating, :string
|
||||
attr_json :tags_array, ActiveModel::Type::Value.new
|
||||
attr_json :flags_array, :string, array: true
|
||||
attr_json :pools_array, :string, array: true
|
||||
attr_json :sources_array, :string, array: true
|
||||
attr_json :artists_array, :string, array: true
|
||||
|
||||
attr_json :e621_updated_at, ActiveModelUtcTimeValue.new
|
||||
attr_json :parent_post_e621_id, :integer
|
||||
attr_json :last_index_page_id, :integer
|
||||
attr_json :caused_by_entry_id, :integer
|
||||
attr_json :scan_log_entry_id, :integer
|
||||
attr_json :index_page_ids, :integer, array: true
|
||||
attr_json :description, :string
|
||||
attr_json :score, :integer
|
||||
attr_json :score_up, :integer
|
||||
attr_json :score_down, :integer
|
||||
attr_json :num_favorites, :integer
|
||||
attr_json :num_comments, :integer
|
||||
attr_json :change_seq, :integer
|
||||
attr_json :md5, :string
|
||||
attr_json :prev_md5s, :string, array: true
|
||||
attr_json :scan_error, :string
|
||||
attr_json :uploader_user_id, :integer
|
||||
aux_table :e621
|
||||
|
||||
has_single_file!
|
||||
has_faving_users! Domain::User::E621User
|
||||
|
||||
@@ -20,20 +20,6 @@
|
||||
<div class="w-full max-w-2xl mx-auto mt-4 text-center sm:mt-6">
|
||||
<% index_type_header_partial = "domain/posts/index_type_headers/#{@posts_index_view_config.index_type_header}" %>
|
||||
<%= render partial: index_type_header_partial, locals: { user: @user, params: params, posts: @posts } %>
|
||||
<div class="mt-4 flex justify-center gap-4">
|
||||
<% if params[:view] == "table" %>
|
||||
<%= link_to domain_posts_path(view: "gallery"), class: "text-blue-600 hover:text-blue-800" do %>
|
||||
<i class="fas fa-th-large"></i> Gallery View
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= link_to domain_posts_path(view: "table"), class: "text-blue-600 hover:text-blue-800" do %>
|
||||
<i class="fas fa-table"></i> Table View
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= link_to visual_search_domain_posts_path, class: "text-blue-600 hover:text-blue-800" do %>
|
||||
<i class="fas fa-search"></i> Visual Search
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% if @posts_index_view_config.show_domain_filters %>
|
||||
<%= render partial: "domain_filter_controls" %>
|
||||
|
||||
@@ -1,32 +1,57 @@
|
||||
<% content_for :head do %>
|
||||
<%# <%= javascript_pack_tag "application-bundle" %>
|
||||
<% end %>
|
||||
<div class="mt-2 sm:m-2 sm:p-4">
|
||||
<%= react_component("UserSearchBar", props: {}, prerender: true, strict_mode: true) %>
|
||||
<div
|
||||
class="mx-auto mt-2 w-full border-y border-slate-300 bg-slate-50 p-2 shadow-lg sm:max-w-md sm:rounded-xl sm:border"
|
||||
>
|
||||
<div class="space-y-4 p-1">
|
||||
<div class="border-b border-slate-200 pb-3">
|
||||
<h2 class="font-medium text-slate-700">Questions? Comments? Suggestions?</h2>
|
||||
<div class="mt-2 text-sm text-slate-600">
|
||||
Contact @DeltaNoises on:
|
||||
<div class="mt-1 flex items-center gap-3">
|
||||
<%= link_to "https://t.me/DeltaNoises", target: "_blank", class: "blue-link inline-flex items-center gap-1" do %>
|
||||
<i class="fab fa-telegram"></i> Telegram
|
||||
<% end %>
|
||||
<%= link_to "https://bsky.app/profile/delta.refurrer.com", target: "_blank", class: "blue-link inline-flex items-center gap-1" do %>
|
||||
<i class="fas fa-cloud"></i> BlueSky
|
||||
<% end %>
|
||||
<!-- Primary Action: User Search -->
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<%= react_component("UserSearchBar", props: {}, prerender: true, strict_mode: true) %>
|
||||
</div>
|
||||
<!-- Navigation Section -->
|
||||
<div class="mx-auto mt-8 max-w-2xl">
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<%= link_to domain_posts_path, class: "group flex items-center justify-center gap-3 rounded-lg bg-slate-50 border border-slate-200 p-4 hover:bg-blue-50 hover:border-blue-200 transition-all" do %>
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 group-hover:bg-blue-100">
|
||||
<i class="fas fa-images text-blue-600 group-hover:text-blue-700"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-slate-700 group-hover:text-slate-800">View All Posts</span>
|
||||
<i class="fas fa-arrow-right ml-auto text-xs text-slate-400 group-hover:text-blue-500"></i>
|
||||
<% end %>
|
||||
<%= link_to visual_search_domain_posts_path, class: "group flex items-center justify-center gap-3 rounded-lg bg-slate-50 border border-slate-200 p-4 hover:bg-emerald-50 hover:border-emerald-200 transition-all" do %>
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-50 group-hover:bg-emerald-100">
|
||||
<i class="fas fa-search text-emerald-600 group-hover:text-emerald-700"></i>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-slate-700 group-hover:text-slate-800">Search by Image</span>
|
||||
<i class="fas fa-upload ml-auto text-xs text-slate-400 group-hover:text-emerald-500"></i>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">New</span>
|
||||
<div class="mt-1">
|
||||
Try the <%= link_to "FurAffinity User Recommender",
|
||||
furecs_user_script_path,
|
||||
class: "blue-link" %> user script to discover similar artists and users!
|
||||
</div>
|
||||
</div>
|
||||
<!-- Feature announcement -->
|
||||
<div class="mx-auto mt-6 max-w-2xl rounded-lg border border-blue-100 bg-blue-50/50 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800">New</span>
|
||||
<div class="flex-1 text-sm text-slate-700">
|
||||
Try the <%= link_to "FurAffinity User Recommender",
|
||||
furecs_user_script_path,
|
||||
class: "text-blue-700 underline decoration-blue-300 underline-offset-2 hover:decoration-blue-500 font-medium" %> user script to discover similar artists and users!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Contact Section -->
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div class="border-t border-slate-200 pt-8">
|
||||
<div class="text-center">
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-slate-600">Have questions, suggestions, or found a bug?</p>
|
||||
<p class="text-xs text-slate-500 mt-1">Get in touch with @DeltaNoises</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:justify-center sm:gap-6">
|
||||
<%= link_to "https://t.me/DeltaNoises", target: "_blank", class: "group inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 bg-slate-50 border border-slate-200 rounded-lg hover:bg-slate-100 hover:text-slate-700 transition-all" do %>
|
||||
<i class="fab fa-telegram text-blue-500 group-hover:text-blue-600"></i>
|
||||
<span>Message on Telegram</span>
|
||||
<% end %>
|
||||
<%= link_to "https://bsky.app/profile/delta.refurrer.com", target: "_blank", class: "group inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 bg-slate-50 border border-slate-200 rounded-lg hover:bg-slate-100 hover:text-slate-700 transition-all" do %>
|
||||
<i class="fas fa-cloud text-sky-500 group-hover:text-sky-600"></i>
|
||||
<span>Follow on BlueSky</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
58
db/migrate/20250723193659_migrate_e621_posts_to_aux.rb
Normal file
58
db/migrate/20250723193659_migrate_e621_posts_to_aux.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
# typed: strict
|
||||
# frozen_string_literal: true
|
||||
#
|
||||
class MigrateE621PostsToAux < ActiveRecord::Migration[7.2]
|
||||
sig { void }
|
||||
def change
|
||||
create_aux_table :domain_posts, :e621 do |t|
|
||||
t =
|
||||
T.cast(t, ActiveRecord::ConnectionAdapters::PostgreSQL::TableDefinition)
|
||||
|
||||
t.string :state, null: false
|
||||
t.integer :e621_id, null: false
|
||||
t.timestamp :scanned_post_favs_at
|
||||
|
||||
t.string :rating
|
||||
t.jsonb :tags_array
|
||||
t.jsonb :flags_array
|
||||
t.jsonb :pools_array
|
||||
t.jsonb :sources_array
|
||||
t.jsonb :artists_array
|
||||
|
||||
t.timestamp :e621_updated_at
|
||||
t.integer :parent_post_e621_id
|
||||
t.references :last_index_page,
|
||||
index: false,
|
||||
foreign_key: {
|
||||
to_table: :http_log_entries,
|
||||
}
|
||||
t.references :caused_by_entry,
|
||||
index: false,
|
||||
foreign_key: {
|
||||
to_table: :http_log_entries,
|
||||
}
|
||||
t.references :scan_log_entry,
|
||||
index: false,
|
||||
foreign_key: {
|
||||
to_table: :http_log_entries,
|
||||
}
|
||||
t.jsonb :index_page_ids
|
||||
t.string :description
|
||||
t.integer :score
|
||||
t.integer :score_up
|
||||
t.integer :score_down
|
||||
t.integer :num_favorites
|
||||
t.integer :num_comments
|
||||
t.integer :change_seq
|
||||
t.string :md5
|
||||
t.jsonb :prev_md5s
|
||||
t.string :scan_error
|
||||
t.references :uploader_user,
|
||||
index: false,
|
||||
foreign_key: {
|
||||
to_table: :domain_users_e621_aux,
|
||||
primary_key: :base_table_id,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
73
db/migrate/20250723194407_migrate_e621_post_data_to_aux.rb
Normal file
73
db/migrate/20250723194407_migrate_e621_post_data_to_aux.rb
Normal file
@@ -0,0 +1,73 @@
|
||||
# typed: strict
|
||||
# frozen_string_literal: true
|
||||
|
||||
class MigrateE621PostDataToAux < ActiveRecord::Migration[7.2]
|
||||
sig { void }
|
||||
def up
|
||||
execute <<~SQL
|
||||
INSERT INTO domain_posts_e621_aux (
|
||||
base_table_id,
|
||||
state,
|
||||
e621_id,
|
||||
scanned_post_favs_at,
|
||||
rating,
|
||||
tags_array,
|
||||
flags_array,
|
||||
pools_array,
|
||||
sources_array,
|
||||
artists_array,
|
||||
e621_updated_at,
|
||||
parent_post_e621_id,
|
||||
last_index_page_id,
|
||||
caused_by_entry_id,
|
||||
scan_log_entry_id,
|
||||
index_page_ids,
|
||||
description,
|
||||
score,
|
||||
score_up,
|
||||
score_down,
|
||||
num_favorites,
|
||||
num_comments,
|
||||
change_seq,
|
||||
md5,
|
||||
prev_md5s,
|
||||
scan_error,
|
||||
uploader_user_id
|
||||
)
|
||||
SELECT
|
||||
id as base_table_id,
|
||||
(json_attributes->>'state')::text as state,
|
||||
(json_attributes->>'e621_id')::integer as e621_id,
|
||||
(json_attributes->>'scanned_post_favs_at')::timestamp as scanned_post_favs_at,
|
||||
(json_attributes->>'rating')::text as rating,
|
||||
(json_attributes->>'tags_array')::jsonb as tags_array,
|
||||
(json_attributes->>'flags_array')::jsonb as flags_array,
|
||||
(json_attributes->>'pools_array')::jsonb as pools_array,
|
||||
(json_attributes->>'sources_array')::jsonb as sources_array,
|
||||
(json_attributes->>'artists_array')::jsonb as artists_array,
|
||||
(json_attributes->>'e621_updated_at')::timestamp as e621_updated_at,
|
||||
(json_attributes->>'parent_post_e621_id')::integer as parent_post_e621_id,
|
||||
(json_attributes->>'last_index_page_id')::integer as last_index_page_id,
|
||||
(json_attributes->>'caused_by_entry_id')::integer as caused_by_entry_id,
|
||||
(json_attributes->>'scan_log_entry_id')::integer as scan_log_entry_id,
|
||||
(json_attributes->>'index_page_ids')::jsonb as index_page_ids,
|
||||
(json_attributes->>'description')::text as description,
|
||||
(json_attributes->>'score')::integer as score,
|
||||
(json_attributes->>'score_up')::integer as score_up,
|
||||
(json_attributes->>'score_down')::integer as score_down,
|
||||
(json_attributes->>'num_favorites')::integer as num_favorites,
|
||||
(json_attributes->>'num_comments')::integer as num_comments,
|
||||
(json_attributes->>'change_seq')::integer as change_seq,
|
||||
(json_attributes->>'md5')::text as md5,
|
||||
(json_attributes->>'prev_md5s')::jsonb as prev_md5s,
|
||||
(json_attributes->>'scan_error')::text as scan_error,
|
||||
(json_attributes->>'uploader_user_id')::integer as uploader_user_id
|
||||
FROM domain_posts
|
||||
WHERE type = 'Domain::Post::E621Post'
|
||||
SQL
|
||||
end
|
||||
|
||||
sig { void }
|
||||
def down
|
||||
end
|
||||
end
|
||||
118
db/structure.sql
118
db/structure.sql
@@ -1336,6 +1336,60 @@ CREATE TABLE public.domain_posts (
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_posts_e621_aux; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.domain_posts_e621_aux (
|
||||
base_table_id bigint NOT NULL,
|
||||
state character varying NOT NULL,
|
||||
e621_id integer NOT NULL,
|
||||
scanned_post_favs_at timestamp without time zone,
|
||||
rating character varying,
|
||||
tags_array jsonb,
|
||||
flags_array jsonb,
|
||||
pools_array jsonb,
|
||||
sources_array jsonb,
|
||||
artists_array jsonb,
|
||||
e621_updated_at timestamp without time zone,
|
||||
parent_post_e621_id integer,
|
||||
last_index_page_id bigint,
|
||||
caused_by_entry_id bigint,
|
||||
scan_log_entry_id bigint,
|
||||
index_page_ids jsonb,
|
||||
description character varying,
|
||||
score integer,
|
||||
score_up integer,
|
||||
score_down integer,
|
||||
num_favorites integer,
|
||||
num_comments integer,
|
||||
change_seq integer,
|
||||
md5 character varying,
|
||||
prev_md5s jsonb,
|
||||
scan_error character varying,
|
||||
uploader_user_id bigint
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_posts_e621_aux_base_table_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.domain_posts_e621_aux_base_table_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_posts_e621_aux_base_table_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.domain_posts_e621_aux_base_table_id_seq OWNED BY public.domain_posts_e621_aux.base_table_id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_posts_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
@@ -2759,6 +2813,13 @@ ALTER TABLE ONLY public.domain_post_groups ALTER COLUMN id SET DEFAULT nextval('
|
||||
ALTER TABLE ONLY public.domain_posts ALTER COLUMN id SET DEFAULT nextval('public.domain_posts_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_posts_e621_aux base_table_id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.domain_posts_e621_aux ALTER COLUMN base_table_id SET DEFAULT nextval('public.domain_posts_e621_aux_base_table_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_twitter_tweets id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
@@ -2948,6 +3009,14 @@ ALTER TABLE ONLY public.domain_post_groups
|
||||
ADD CONSTRAINT domain_post_groups_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_posts_e621_aux domain_posts_e621_aux_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.domain_posts_e621_aux
|
||||
ADD CONSTRAINT domain_posts_e621_aux_pkey PRIMARY KEY (base_table_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_posts domain_posts_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
@@ -3905,6 +3974,13 @@ CREATE INDEX index_domain_post_group_joins_on_type ON public.domain_post_group_j
|
||||
CREATE INDEX index_domain_post_groups_on_type ON public.domain_post_groups USING btree (type);
|
||||
|
||||
|
||||
--
|
||||
-- Name: index_domain_posts_e621_aux_on_base_table_id; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX index_domain_posts_e621_aux_on_base_table_id ON public.domain_posts_e621_aux USING btree (base_table_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: index_domain_posts_on_posted_at; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
@@ -5022,6 +5098,22 @@ ALTER TABLE ONLY public.domain_twitter_medias
|
||||
ADD CONSTRAINT fk_rails_5fffa41fa6 FOREIGN KEY (file_id) REFERENCES public.http_log_entries(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_posts_e621_aux fk_rails_73ac068c64; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.domain_posts_e621_aux
|
||||
ADD CONSTRAINT fk_rails_73ac068c64 FOREIGN KEY (uploader_user_id) REFERENCES public.domain_users_e621_aux(base_table_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_posts_e621_aux fk_rails_7deb1f0178; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.domain_posts_e621_aux
|
||||
ADD CONSTRAINT fk_rails_7deb1f0178 FOREIGN KEY (scan_log_entry_id) REFERENCES public.http_log_entries(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_users_fa_aux fk_rails_7e51f8bfbc; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
@@ -5062,6 +5154,22 @@ ALTER TABLE ONLY public.domain_user_post_creations
|
||||
ADD CONSTRAINT fk_rails_9f4b85bc57 FOREIGN KEY (user_id) REFERENCES public.domain_users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_posts_e621_aux fk_rails_a90522803d; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.domain_posts_e621_aux
|
||||
ADD CONSTRAINT fk_rails_a90522803d FOREIGN KEY (last_index_page_id) REFERENCES public.http_log_entries(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_posts_e621_aux fk_rails_ae368c64c2; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.domain_posts_e621_aux
|
||||
ADD CONSTRAINT fk_rails_ae368c64c2 FOREIGN KEY (base_table_id) REFERENCES public.domain_posts(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_user_follows fk_rails_b45e6e3979; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
@@ -5142,6 +5250,14 @@ ALTER TABLE ONLY public.domain_post_files
|
||||
ADD CONSTRAINT fk_rails_d059c07f77 FOREIGN KEY (log_entry_id) REFERENCES public.http_log_entries(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_posts_e621_aux fk_rails_d691739802; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.domain_posts_e621_aux
|
||||
ADD CONSTRAINT fk_rails_d691739802 FOREIGN KEY (caused_by_entry_id) REFERENCES public.http_log_entries(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_post_group_joins fk_rails_eddd0a9390; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
@@ -5181,6 +5297,8 @@ ALTER TABLE ONLY public.domain_twitter_tweets
|
||||
SET search_path TO "$user", public;
|
||||
|
||||
INSERT INTO "schema_migrations" (version) VALUES
|
||||
('20250723194407'),
|
||||
('20250723193659'),
|
||||
('20250722235434'),
|
||||
('20250722153048'),
|
||||
('20250722152949'),
|
||||
|
||||
Submodule gems/has_aux_table updated: fb7912e353...4249329fa3
@@ -219,4 +219,81 @@ describe Domain::Fa::Job::ScanPostJob do
|
||||
expect(post.state).to eq("removed")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#uri_same_with_normalized_facdn_host?" do
|
||||
let(:client_mock_config) { [] }
|
||||
|
||||
shared_examples "has result" do |result|
|
||||
it "is #{result.to_s}, both have schema" do
|
||||
url1 = "https://#{host1}#{path1}"
|
||||
url2 = "https://#{host2}#{path2}"
|
||||
expect(
|
||||
described_class.uri_same_with_normalized_facdn_host?(url1, url2),
|
||||
).to eq(result)
|
||||
end
|
||||
|
||||
it "is #{result.to_s}, both missing schema" do
|
||||
url1 = "//#{host1}#{path1}"
|
||||
url2 = "//#{host2}#{path2}"
|
||||
expect(
|
||||
described_class.uri_same_with_normalized_facdn_host?(url1, url2),
|
||||
).to eq(result)
|
||||
end
|
||||
|
||||
it "is #{result.to_s}, one has schema" do
|
||||
url1 = "https://#{host1}#{path1}"
|
||||
url2 = "//#{host2}#{path2}"
|
||||
expect(
|
||||
described_class.uri_same_with_normalized_facdn_host?(url1, url2),
|
||||
).to eq(result)
|
||||
end
|
||||
end
|
||||
|
||||
shared_context "host: different cdn hosts" do
|
||||
let(:host1) { "d.facdn.net" }
|
||||
let(:host2) { "d.furaffinity.net" }
|
||||
end
|
||||
|
||||
shared_context "host: both hosts are d.facdn.net" do
|
||||
let(:host1) { "d.facdn.net" }
|
||||
let(:host2) { "d.facdn.net" }
|
||||
end
|
||||
|
||||
shared_context "host: both hosts are d.furaffinity.net" do
|
||||
let(:host1) { "d.furaffinity.net" }
|
||||
let(:host2) { "d.furaffinity.net" }
|
||||
end
|
||||
|
||||
shared_context "host: one domain is not a cdn" do
|
||||
let(:host1) { "d.facdn.net" }
|
||||
let(:host2) { "example.com" }
|
||||
end
|
||||
|
||||
shared_context "paths: are the same" do
|
||||
let(:path1) { "/art/user/1234567890/image.jpg" }
|
||||
let(:path2) { "/art/user/1234567890/image.jpg" }
|
||||
end
|
||||
|
||||
shared_context "paths: are different" do
|
||||
let(:path1) { "/art/user/1234567890/image.jpg" }
|
||||
let(:path2) { "/art/user/1234567890/some_other_image.jpg" }
|
||||
end
|
||||
|
||||
[
|
||||
["host: different cdn hosts", "paths: are the same", true],
|
||||
["host: both hosts are d.facdn.net", "paths: are the same", true],
|
||||
["host: both hosts are d.furaffinity.net", "paths: are the same", true],
|
||||
["host: one domain is not a cdn", "paths: are the same", false],
|
||||
["host: different cdn hosts", "paths: are different", false],
|
||||
["host: both hosts are d.facdn.net", "paths: are different", false],
|
||||
["host: both hosts are d.furaffinity.net", "paths: are different", false],
|
||||
["host: one domain is not a cdn", "paths: are different", false],
|
||||
].each do |host_context, path_context, result|
|
||||
context "#{host_context} and #{path_context}" do
|
||||
include_context host_context
|
||||
include_context path_context
|
||||
include_examples "has result", result
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user