diff --git a/back/lib/apiswagger.js b/back/lib/apiswagger.js index d67409c..5d8d860 100644 --- a/back/lib/apiswagger.js +++ b/back/lib/apiswagger.js @@ -1,6 +1,5 @@ module.exports = (app, client) => { const constant = require('../constant'), - zlib = require('zlib'), asyncHandler = require('express-async-handler'), { param, validationResult } = require('express-validator') /** @@ -513,33 +512,4 @@ module.exports = (app, client) => { res.status(404).end() } })) - /** - * @swagger - * /api/download_index: - * get: - * summary: Retrieve all content of ElasticSearch index. - * description: Retrieve all content of ElasticSearch index. - * responses: - * 200: - * description: A file compressed with gzip. - * content: - * application/gzip: - */ - app.get('/api/download_index', asyncHandler(async (req, res) => { - try { - res.setHeader('Content-Type', 'application/gzip') - res.setHeader('Content-disposition', 'attachment; filename=fediblock-index.json.gz') - const result = await client.search({ - index: constant.index, - size: 9999, - query: { - match_all: {} - } - }, { asStream: true, meta: false }) - result.pipe(zlib.createGzip()).pipe(res) - } catch (e) { - console.error(e) - res.status(404).end() - } - })) } diff --git a/front/package.json b/front/package.json index ea4fbcc..0b7b220 100644 --- a/front/package.json +++ b/front/package.json @@ -6,6 +6,7 @@ "cra-template": "1.2.0", "dayjs": "^1.11.13", "html2canvas": "^1.4.1", + "react-icons": "^5.3.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-scripts": "5.0.1", diff --git a/front/src/App.css b/front/src/App.css index 6ce1625..71ec954 100644 --- a/front/src/App.css +++ b/front/src/App.css @@ -1,405 +1,611 @@ +@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap'); + :root[data-theme="dark"] { - --background-color: #212529; - --color: #fefefe; - --instance-list: #333333; - --footer: #cccccc; + --bg: #05060a; + --bg-soft: #0f1118; + --panel: rgba(255, 255, 255, 0.04); + --panel-strong: rgba(255, 255, 255, 0.08); + --text: #f7f7fb; + --muted: #a7adc7; + --accent: #7ef3e4; + --accent-strong: #5ad1ff; + --border: rgba(255, 255, 255, 0.1); } :root[data-theme="light"] { - --background-color: #f5f5f5; - --color: #333333; - --instance-list: #f0f0f0; - --footer: #666666; + --bg: #f6f7fb; + --bg-soft: #ffffff; + --panel: rgba(15, 17, 24, 0.04); + --panel-strong: rgba(15, 17, 24, 0.08); + --text: #0f1118; + --muted: #5a6078; + --accent: #0066ff; + --accent-strong: #111dff; + --border: rgba(15, 17, 24, 0.1); +} + +*, *::before, *::after { + box-sizing: border-box; } body { - background-color: var(--background-color); - text-align: center; - font-family: Verdana, Geneva, Tahoma, sans-serif; + margin: 0; + font-family: 'Space Grotesk', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; scrollbar-width: thin; } -h1, -h3, -h4, -.scan { - margin: 0 auto; - color: var(--color); - text-align: center; -} - -h3 { - padding: 10px 0; - border-bottom: 1px solid var(--footer); - margin-bottom: 1rem; -} - -a, -a:hover { - color: var(--color); +a { + color: inherit; text-decoration: none; } -input[type="text"] { - padding: 10px; +.muted { + color: var(--muted); +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.2em; + font-size: 0.75rem; + color: var(--accent); +} + +.subtitle { + margin: 0.25rem 0 0; + color: var(--muted); +} + +.app-shell { + padding: 2rem clamp(1rem, 4vw, 4rem) 3rem; + background: radial-gradient(circle at top, rgba(126, 243, 228, 0.15), transparent 45%), var(--bg); + min-height: 100vh; + display: flex; + flex-direction: column; + gap: 2rem; +} + +.hero-grid { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.content-layout { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); + gap: 2rem; + align-items: start; +} + +@media (max-width: 900px) { + .content-layout { + grid-template-columns: 1fr; + } +} + +.title-card { + display: flex; + justify-content: space-between; + gap: 1.5rem; + padding: 1.75rem; + border-radius: 1.5rem; + background: linear-gradient(135deg, rgba(126, 243, 228, 0.15), rgba(90, 209, 255, 0.05)); + border: 1px solid var(--border); + flex-wrap: wrap; +} + +.title-stack { + flex: 1; +} + +.title-card h1 { + margin: 0.2rem 0 0.35rem; + font-size: clamp(2rem, 5vw, 3.4rem); +} + +.ghost-button, +.secondary-button, +.footer-actions button, +.footer-actions a, +.ticker-chip, +.suggestions button, +.instance-card__header button, +.instance-card__import button, +.modal-actions button, +.search-panel__header .ghost-button { + border: 1px solid transparent; + background: transparent; + color: var(--text); + padding: 0.65rem 1rem; + border-radius: 999px; + display: inline-flex; + align-items: center; + gap: 0.45rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease, border 0.2s ease, transform 0.2s ease; +} + +.ghost-button { + border-color: var(--border); + background: var(--panel); +} + +.ghost-button:hover, +.secondary-button:hover, +.footer-actions button:hover, +.footer-actions a:hover, +.ticker-chip:hover, +.suggestions button:hover, +.instance-card__import button:hover, +.modal-actions button:hover { + border-color: var(--accent); + background: var(--panel-strong); + transform: translateY(-1px); +} + +.ticker-card, +.stats-card, +.search-panel, +.scan-card, +.instance-card, +.instance-card__import { + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: 1.25rem; + padding: 1.5rem; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12); +} + +.ticker-card__header { + display: flex; + gap: 0.75rem; + align-items: center; + margin-bottom: 1rem; + font-weight: 600; +} + +.ticker-card__title small { + display: block; + font-size: 0.8rem; + color: var(--muted); +} + +.ticker-card__body { + overflow: hidden; +} + +.ticker-marquee { + display: inline-flex; + gap: 0.75rem; + animation: ticker 45s linear infinite; + min-width: 100%; +} + +.ticker-card:hover .ticker-marquee { + animation-play-state: paused; +} + +@keyframes ticker { + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } +} + +.ticker-chip { + font-size: 0.9rem; + border-color: var(--border); + background: var(--panel); + white-space: nowrap; +} + +.ticker-chip__time { + font-size: 0.75rem; + color: var(--muted); +} + +.stats-card { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1.5rem; + align-items: start; +} + +.stats-card__main { + display: flex; + gap: 1rem; + align-items: center; +} + +.stats-card__icon { + width: 3.5rem; + height: 3.5rem; + border-radius: 1rem; + display: grid; + place-items: center; + background: var(--panel); + color: var(--accent); +} + +.stats-card__main h2 { + margin: 0.2rem 0; + font-size: 2.2rem; +} + +.stats-card__links { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.stats-card__links a { + color: var(--accent); + font-weight: 600; + display: inline-flex; + gap: 0.4rem; + align-items: center; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; + margin: 0; +} + +.stats-grid div { + padding: 0.75rem 1rem; + background: var(--panel); + border-radius: 0.85rem; +} + +.stats-grid dt { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + margin: 0 0 0.25rem; +} + +.stats-grid dd { + margin: 0; + font-weight: 600; + font-size: 1.1rem; +} + +.search-panel { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.search-panel__header { + display: flex; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.input-stack { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.input-field { + flex: 1; + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.65rem 1rem; + border-radius: 999px; + background: var(--panel); + border: 1px solid var(--border); +} + +.input-field input { + flex: 1; border: none; - border-bottom: 1px solid var(--footer); - background-color: var(--instance-list); - color: var(--color); + background: transparent; + color: var(--text); + font-size: 1rem; + outline: none; } -.placeholder { - font-size: small; - margin: 0 auto; - color: var(--color); +.secondary-button { + border-color: var(--accent); + color: var(--accent); } -.title, .placeholder a, footer a, .download-csv { - cursor: pointer; +.suggestions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; } -hr { - margin: 1rem auto; - border-bottom: 1px solid var(--footer); - width: 50%; +.suggestions button { + border-color: var(--border); + background: var(--panel); } -.tooltip { - visibility: hidden; - font-size: small; - text-align: left; - width: 2.2in; - background-color: var(--background-color); - color: var(--color); - border-radius: 5px; - padding: 1rem 1rem 0 0; - position: absolute; - z-index: 1; - opacity: 0; - transition: 0.5s; - border: 1px solid var(--footer); -} - -.count:hover .tooltip { - visibility: visible; - opacity: 1; -} - -.reverse { - margin: 0.5rem; - padding: 0.5rem; - border: 1px solid var(--footer); - background-color: var(--color); - color: var(--background-color); -} - -.reverse:hover { - color: var(--color); - background-color: var(--background-color); - cursor: pointer; +.list-shell { + max-height: 65vh; + overflow: hidden; + border-radius: 1rem; + border: 1px solid var(--border); } .instancelist { - list-style-type: none; - margin: 20px 0; + list-style: none; + margin: 0; padding: 0; - color: var(--color); - max-height: 58vh; - overflow: auto; + max-height: inherit; + overflow-y: auto; scrollbar-width: thin; } -@media (min-width: 992px) { - .instancelist li { - width: 30%; - } - - .instancelist li:hover { - width: 35%; - } - - .instance, - .placeholder, - h4 { - width: 30%; - } - - .modal-content { - width: 28%; - } +.instance-card, +.instance-card__import { + border-radius: 0; + border: none; + box-shadow: none; + border-bottom: 1px solid var(--border); } -@media (min-width: 600px) and (max-width: 992px) { - .instancelist li { - width: 60%; - } - - .instancelist li:hover { - width: 65%; - } - - .instance, - .placeholder, - h4 { - width: 60%; - } - - .modal-content { - width: 58%; - } +.instance-card:last-child { + border-bottom: none; } -@media (max-width: 600px) { - .instancelist li { - width: 90%; - } - - .instancelist li:hover { - width: 95%; - } - - .instance, - .placeholder, - h4 { - width: 90%; - } - - .api { - display: none; - } - - .modal-content { - width: 88%; - } +.instance-card__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; } -.instancelist li { - color: var(--color); - padding: 10px; - border-bottom: 1px solid var(--footer); - margin: 0 auto; - max-height: 1.5em; - overflow: hidden; - min-height: 1.5em; - line-height: 1.5; +.instance-card__header button { + padding: 0; + border: none; + background: none; + font-size: 1.05rem; } -.instancelist li:hover { - background-color: var(--instance-list); - cursor: pointer; - max-height: fit-content; +.instance-card__badge { + font-size: 0.8rem; + color: var(--accent); } -.instancelist li img { - width: 70%; - margin: 0 auto; +.instance-card__body { + font-size: 0.85rem; + color: var(--muted); + display: grid; + gap: 0.25rem; } -@keyframes opacity { - from { - opacity: 0; - } - - to { - opacity: 1; - } +.instance-card__body img { + width: 100%; + border-radius: 0.75rem; } -footer { +.instance-card__import { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.instance-card__order { + font-weight: 700; + margin-right: 1rem; + color: var(--accent); +} + +.scan-card { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.scan-card header { + display: flex; + gap: 1rem; + align-items: center; +} + +.scan-card p { + margin: 0; + font-size: 1rem; +} + +.app-footer { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.5rem; + border-radius: 1.25rem; + border: 1px solid var(--border); + background: var(--bg-soft); +} + +.footer-metrics { + display: flex; + flex-direction: column; + gap: 0.5rem; + font-size: 0.95rem; +} + +.footer-metrics svg { + color: var(--accent); + margin-right: 0.5rem; +} + +.footer-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.footer-actions a { + text-decoration: none; +} + +.footer-credit { + margin-left: auto; + font-size: 0.85rem; + color: var(--muted); +} + +.footer-actions a.footer-credit { + border: none; + padding: 0; + background: transparent; + color: var(--muted); +} + +.footer-actions a.footer-credit:hover { + color: var(--text); + border: none; + background: transparent; + transform: none; +} + +.loader-content { + z-index: 100; + background: rgba(5, 6, 10, 0.75); + width: 100%; + height: 100%; + display: none; + position: fixed; + inset: 0; + backdrop-filter: blur(6px); +} + +.loader { + background: var(--bg-soft); + padding: 2rem; + border-radius: 1.5rem; + border: 1px solid var(--border); + box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); text-align: center; - font-size: 12px; - padding: 10px; - color: var(--footer); } -.count { - text-decoration: underline dotted var(--color); - cursor: help; - position: relative; - display: inline-block; - text-underline-offset: 2px; -} - -.blocklist { - width: 90%; - text-align: left; - list-style: none; - padding: 1rem; -} - -.blocklist li { - color: var(--color); - white-space: nowrap; - word-wrap: break-word; - overflow: hidden; -} - -.blocklist li:hover { - white-space: inherit; - cursor: pointer; - color: var(--color); -} - -.blocklist li a, -.blocklist li a:hover, -.blocklist li a:visited { - color: var(--color); - text-decoration: underline; -} - -.blockinstance a, -.blockinstance a:hover { - color: var(--color); - text-decoration: underline; - cursor: help; -} - -.blockcount { - font-weight: bolder; +.loader p { + margin-top: 1rem; + color: var(--muted); } .modal { display: none; position: fixed; - z-index: 1; - left: 0; - top: 0; - width: 100%; - height: 100%; - overflow: auto; - scrollbar-width: thin; - background-color: rgba(0, 0, 0, 0.4); + inset: 0; + background: rgba(5, 6, 10, 0.8); + backdrop-filter: blur(8px); + z-index: 200; + padding: 1rem; } .modal-content { - position: relative; - background-color: var(--background-color); - margin: 10px auto; - padding: 0; - border: 1px solid var(--footer); - -webkit-animation-name: animatetop; - -webkit-animation-duration: 0.4s; - animation-name: animatetop; + max-width: 640px; + margin: auto; + background: var(--bg-soft); + border-radius: 1.5rem; + border: 1px solid var(--border); + overflow: hidden; animation-duration: 0.4s; + animation-name: animatetop; } .modal-header { - padding: 6px; - background-color: var(--color); - color: var(--background-color); + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 1.5rem; + border-bottom: 1px solid var(--border); +} + +.modal-actions { + display: flex; + gap: 0.5rem; } .modal-body { - color: var(--color); - background-color: var(--background-color); - padding: 6px; + padding: 1.5rem; +} + +.blocklist { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; + max-height: 60vh; + overflow-y: auto; + scrollbar-width: thin; +} + +.blocklist li { + line-height: 1.4; +} + +.blocklist a { + color: var(--accent); + cursor: pointer; +} + +.link-button { + border: none; + background: transparent; + color: var(--accent); + text-decoration: underline; + cursor: pointer; + padding: 0; + font: inherit; +} + +.link-button:hover { + color: var(--accent-strong); +} + +.blockcount { + font-size: 1.4rem; + color: var(--accent); } .modal-footer { - padding: 6px; - background-color: var(--color); - color: var(--background-color); -} - -.closemodal, -.download, -.capture { - color: var(--footer); - float: right; - font-size: 3rem; - font-weight: bolder; - margin: 0px 15px; -} - -.closemodal:hover, -.closemodal:focus, -.download:hover, -.download:focus, -.capture:hover, -.capture:focus { - color: var(--color); - text-decoration: none; - cursor: pointer; + border-top: 1px solid var(--border); + padding: 1rem 1.5rem; } @keyframes animatetop { from { - top: -300px; opacity: 0; + transform: translateY(60px); } - to { - top: 0; opacity: 1; + transform: translateY(0); } } @keyframes animatebottom { from { - top: 0; opacity: 1; + transform: translateY(0); } - to { - top: -300px; - opacity: 0 + opacity: 0; + transform: translateY(60px); } -} - -h4 { - white-space: nowrap; - overflow: hidden; - position: relative; - border-left: 1px solid var(--background-color); - border-right: 1px solid var(--background-color); - transition: 0.2s; -} - -h4 p { - margin: 0.3rem 0; -} - -h4:hover { - width: 90%; -} - -.bounce { - display: inline-block; - animation: marquee 90s linear infinite; -} - -.bounce:hover { - animation-play-state: paused; -} - -@keyframes marquee { - 0% { - transform: translateX(100vw); - } - - 100% { - transform: translateX(-100%); - } -} - -.loader-content { - z-index: 0; - background-color: rgba(0, 0, 0, 0.4); - width: 100%; - height: 100%; - display: none; - position: fixed; - top: 0; - left: 0; - overflow: auto; - scrollbar-width: thin; -} - -.loader { - background-color: var(--color); - width: fit-content; - margin: 50vh auto; - z-index: 1; - position: relative; - color: var(--background-color); } \ No newline at end of file diff --git a/front/src/App.js b/front/src/App.js index e2258ef..d937eb2 100644 --- a/front/src/App.js +++ b/front/src/App.js @@ -12,16 +12,19 @@ function App() { const [searchTerm, setSearchTerm] = useState(''), [matrix, setMatrix] = useState('off') return ( - <> - - <Bar setSearch={d => setSearchTerm(d)} /> - <Count /> - <Form searchTerm={searchTerm} matrix={matrix} /> - <Scan /> - <hr /> + <div className="app-shell"> + <header className="hero-grid"> + <Title /> + <Bar setSearch={d => setSearchTerm(d)} /> + <Count /> + </header> + <main className="content-layout"> + <Form searchTerm={searchTerm} matrix={matrix} /> + <Scan /> + </main> <Footer setCurrentMatrix={m => setMatrix(m)} /> <Loader /> - </> + </div> ); } diff --git a/front/src/component/Bar.js b/front/src/component/Bar.js index dab139c..9b968a2 100644 --- a/front/src/component/Bar.js +++ b/front/src/component/Bar.js @@ -1,9 +1,11 @@ import { useEffect, useState, useCallback } from 'react'; -import dayjs from '../../node_modules/dayjs/'; -import relativeTime from '../../node_modules/dayjs/plugin/relativeTime'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { FiActivity } from 'react-icons/fi'; + dayjs.extend(relativeTime) -const Bar = (prop) => { +const Bar = ({ setSearch }) => { const [bounce, setBounce] = useState([]), fillBounce = useCallback(async () => { try { @@ -15,14 +17,47 @@ const Bar = (prop) => { } catch (e) { console.error(e) } - }) + }, []) + useEffect(() => { fillBounce() - }, []) + }, [fillBounce]) + return ( - <h4> - <p className="bounce">{bounce && bounce.length > 0 ? bounce.map(b => <a onClick={() => prop.setSearch(b.content.replace(/.*#/g, '').replace(/\<\/a\>\<\/p\>/, ''))}>{b.content.replace(/<[^>]*>/g, '').replace(new RegExp(new URL(window.location.href).host), '')} - {dayjs().to(b.published)}</a>).reduce((prev, curr) => [prev, ' | ', curr]) : []}</p> - </h4> + <section className="ticker-card" aria-live="polite"> + <header className="ticker-card__header"> + <FiActivity aria-hidden="true" /> + <div className="ticker-card__title"> + <span>Recent activity</span> + <small>Select any entry to reuse as a search</small> + </div> + </header> + <div className="ticker-card__body"> + {bounce?.length ? ( + <div className="ticker-marquee"> + {bounce.map((b, index) => { + const sanitized = b.content.replace(/.*#/g, '').replace(/<\/a><\/p>/, ''), + plainText = b.content + .replace(/<[^>]*>/g, '') + .replace(new RegExp(new URL(window.location.href).host), '') + return ( + <button + type="button" + className="ticker-chip" + key={`${b.id ?? index}-${b.published}`} + onClick={() => setSearch(sanitized)} + > + <span className="ticker-chip__content">{plainText}</span> + <span className="ticker-chip__time">{dayjs().to(b.published)}</span> + </button> + ) + })} + </div> + ) : ( + <span className="muted">No recent activity</span> + )} + </div> + </section> ) } diff --git a/front/src/component/Count.js b/front/src/component/Count.js index facdcff..dc60908 100644 --- a/front/src/component/Count.js +++ b/front/src/component/Count.js @@ -1,8 +1,9 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useMemo } from 'react'; +import { FiTrendingUp, FiExternalLink } from 'react-icons/fi'; const Count = () => { - const [count, setCount] = useState('0'), - [statsres, setStatsres] = useState(''), + const [count, setCount] = useState(0), + [statsres, setStatsres] = useState(null), fillStats = useCallback(async () => { try { const [res, stats] = await Promise.all([(await fetch('/api/count')).json(), (await fetch('/api/stats')).json()]) @@ -11,30 +12,82 @@ const Count = () => { } catch (e) { console.error(e) } - }) + }, []) + useEffect(() => { - (async () => { - await fillStats() - })() - }, []) + fillStats() + }, [fillStats]) + + const statLines = useMemo(() => { + if (!statsres) { + return [] + } + const safeDiv = (numerator, denominator) => { + if (!denominator) return 0 + return numerator / denominator + } + return [ + { label: 'Statuses AVG', value: Math.round(statsres.status_avg ?? 0) }, + { label: 'Statuses MAX', value: statsres.status_max }, + { label: 'Domain AVG', value: Math.round(statsres.domain_avg ?? 0) }, + { label: 'Domain MAX', value: statsres.domain_max }, + { label: 'Users AVG', value: Math.round(statsres.user_avg ?? 0) }, + { label: 'Users MAX', value: statsres.user_max }, + { label: 'Stats Instances', value: statsres.stats_filtered }, + { label: 'Total Instances', value: statsres.instance_count }, + { + label: 'Users per Instance', + value: safeDiv(Math.round(statsres.user_avg ?? 0), statsres.instance_count ?? 1).toFixed(2) + }, + { + label: 'Statuses per Domain', + value: safeDiv(Math.round(statsres.status_avg ?? 0), Math.round(statsres.domain_avg ?? 1)).toFixed(2) + }, + { + label: 'Statuses per User', + value: safeDiv(Math.round(statsres.status_avg ?? 0), Math.round(statsres.user_avg ?? 1)).toFixed(2) + } + ] + }, [statsres]) + + const formatNumber = value => { + if (Number.isNaN(Number(value))) { + return value ?? '--' + } + return new Intl.NumberFormat().format(value ?? 0) + } + return ( - <h3>Search in <a href="/api/stats" target="_blank"> - <span className="count">{count}<div className="tooltip"> - <u><strong><center>STATS</center></strong></u> - <ul><li>Statuses AVG: {Math.round(statsres.status_avg)}</li> - <li>Statuses MAX: {statsres.status_max}</li> - <li>Domain AVG: {Math.round(statsres.domain_avg)}</li> - <li>Domain MAX: {statsres.domain_max}</li> - <li>Users AVG: {Math.round(statsres.user_avg)}</li> - <li>Users MAX: {statsres.user_max}</li> - <li>Stats Instances: {statsres.stats_filtered}</li> - <li>Total Instances: {statsres.instance_count}</li> - <li>Users by Instance: {(Math.round(statsres.user_avg) / statsres.instance_count).toFixed(2)}</li> - <li>Statuses by Domain: {(Math.round(statsres.status_avg) / Math.round(statsres.domain_avg)).toFixed(2)}</li> - <li>Statuses by User: {(Math.round(statsres.status_avg) / Math.round(statsres.user_avg)).toFixed(2)}</li> - </ul></div></span> - </a> public instances<span className="api"> - <a href="/api-docs" target="_blank">API docs</a></span> - </h3> + <section className="stats-card"> + <div className="stats-card__main"> + <div className="stats-card__icon"> + <FiTrendingUp aria-hidden="true" /> + </div> + <div> + <p className="muted">Searching across</p> + <h2>{formatNumber(count)}</h2> + <p className="muted">public instances</p> + </div> + </div> + <div className="stats-card__links"> + <a href="/api/stats" target="_blank" rel="noreferrer"> + View stats + <FiExternalLink aria-hidden="true" /> + </a> + <a href="/api-docs" target="_blank" rel="noreferrer"> + API docs + <FiExternalLink aria-hidden="true" /> + </a> + </div> + <dl className="stats-grid"> + {statLines.map(item => ( + <div key={item.label}> + <dt>{item.label}</dt> + <dd>{formatNumber(item.value)}</dd> + </div> + ))} + </dl> + </section> ) } diff --git a/front/src/component/Footer.js b/front/src/component/Footer.js index 45b7e86..293d03f 100644 --- a/front/src/component/Footer.js +++ b/front/src/component/Footer.js @@ -1,24 +1,23 @@ import { useEffect, useState, useCallback } from 'react'; +import { FiSun, FiMoon, FiDownload, FiGrid, FiZap } from 'react-icons/fi'; import Messenger from '../random-text'; -const Footer = (prop) => { +const Footer = ({ setCurrentMatrix }) => { const [theme, setTheme] = useState('dark'), [matrix, setMatrix] = useState('off'), [served, setServed] = useState({ served: 0, lastscan: 0, server: 0, instances: 0, peers: 0, created: 0, updated: 0 }), - loading = () => { - document.querySelector('.loader-content').style.display = 'initial' - setTimeout(() => { - document.querySelector('.loader-content').style.display = 'none' - }, 60 * 1000) - }, - toggleTheme = useCallback(() => { - let tm = document.documentElement.dataset.theme - if (!theme || tm === 'light') { - setTheme('dark') - } else { - setTheme('light') + toggleLoader = useCallback(() => { + const loader = document.querySelector('.loader-content') + if (loader) { + loader.style.display = 'initial' + setTimeout(() => { + loader.style.display = 'none' + }, 60 * 1000) } - }), + }, []), + toggleTheme = useCallback(() => { + setTheme(prev => (prev === 'light' ? 'dark' : 'light')) + }, []), refreshServed = useCallback(async () => { try { const response = await fetch('/api/served') @@ -28,38 +27,67 @@ const Footer = (prop) => { } catch (e) { console.error(e) } - }), + }, []), toggleMatrix = useCallback(() => { - if (matrix === 'off') { - setMatrix('on') - prop.setCurrentMatrix('on') - } else { - setMatrix('off') - prop.setCurrentMatrix('off') - } - }) + setMatrix(prev => { + const next = prev === 'off' ? 'on' : 'off' + setCurrentMatrix(next) + return next + }) + }, [setCurrentMatrix]) + useEffect(() => { - setTheme(theme) document.documentElement.dataset.theme = theme + }, [theme]) + + useEffect(() => { refreshServed() + }, [refreshServed]) + + useEffect(() => { if (matrix === 'on') { - var walker = document.createTreeWalker(document.getElementById('root'), NodeFilter.SHOW_TEXT) - while (walker.nextNode()) { - if (walker.currentNode.textContent.length > 1) { - new Messenger(walker.currentNode) + const root = document.getElementById('root') + if (root) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT) + while (walker.nextNode()) { + if (walker.currentNode.textContent.length > 1) { + new Messenger(walker.currentNode) + } } } } - }, [theme]) + }, [matrix]) + return ( - <footer>Served <span className="served">{served.served}</span> times - Last scan <span className="lastscan">{served.lastscan}</span> peers of <span - className="server">{served.server}</span><br /> - Total scanned <span id="instances">{served.instances}</span> instances with <span className="peers">{served.peers}</span> peers - <span - className="created">{served.created}</span> created - <span className="updated">{served.updated}</span> updated<br /> - matrix <a className="matrix" onClick={() => toggleMatrix()}>{matrix}</a> - download json <a className="download_index" href="/api/download_index" - onClick={() => loading()} download="fediblock-index.json.gz" target="_blank">index</a> - - by <a href="https://about.manalejandro.com" target="_blank">ale</a> <s>©</s>2025  - <a className="darklight" onClick={() => toggleTheme()}>{!theme || theme === 'dark' ? '☼' : '☽'}</a> + <footer className="app-footer"> + <div className="footer-metrics"> + <div> + <FiGrid aria-hidden="true" /> + <p> + <strong>{served.served}</strong> requests served - Last scan {served.lastscan} + </p> + </div> + <p> + Total {served.instances} instances - {served.peers} peers - {served.created} created - {served.updated} updated - server {served.server} + </p> + </div> + <div className="footer-actions"> + <button type="button" onClick={toggleMatrix}> + <FiZap aria-hidden="true" /> + Matrix {matrix === 'on' ? 'ON' : 'OFF'} + </button> + <a className="download_index" href="/api/download_index" onClick={toggleLoader} download="fediblock-index.json.gz" target="_blank" rel="noreferrer"> + <FiDownload aria-hidden="true" /> + Download index + </a> + <button type="button" onClick={toggleTheme}> + {theme === 'dark' ? <FiSun aria-hidden="true" /> : <FiMoon aria-hidden="true" />} + {theme === 'dark' ? 'Light mode' : 'Dark mode'} + </button> + <a href="https://about.manalejandro.com" target="_blank" rel="noreferrer" className="footer-credit"> + by ale <s>©</s>2025 + </a> + </div> </footer> ) } diff --git a/front/src/component/Form.js b/front/src/component/Form.js index 5ff83ff..8658bff 100644 --- a/front/src/component/Form.js +++ b/front/src/component/Form.js @@ -1,166 +1,248 @@ import { useEffect, useState, useCallback, useRef } from 'react'; +import { FiSearch, FiRotateCcw, FiRefreshCcw, FiChevronRight } from 'react-icons/fi'; import Modal from './Modal'; import Messenger from '../random-text'; -const Form = (prop) => { - let csv +const sanitizeInstanceInput = value => { + if (!value) return '' + let sanitized = value.replace(/[^a-z0-9.\-*:]/gi, '') + if (sanitized.startsWith('.')) { + sanitized = sanitized.substring(1) + } + return sanitized +} + +const Form = ({ searchTerm: externalSearch, matrix }) => { const [searchTerm, setSearchTerm] = useState(''), [list, setList] = useState([]), [typeList, setTypeList] = useState('ranking'), [domain, setDomain] = useState({ domain: '' }), [reverse, setReverse] = useState(false), refList = useRef(null), - refDownload = useRef(null), - loading = () => { - document.querySelector('.loader-content').style.display = 'initial' - }, - download = () => { - if (csv.split('\n').length > 2) { - refDownload.current.href = window.URL.createObjectURL(new Blob([csv], { type: 'text/csv' })) - refDownload.current.download = 'fediblock-top100.csv' + csvRef = useRef(''), + toggleLoader = useCallback((show = true) => { + const loader = document.querySelector('.loader-content') + if (loader) { + loader.style.display = show ? 'initial' : 'none' } - }, + }, []), + download = useCallback(() => { + if (csvRef.current.split('\n').length > 2) { + const blob = new Blob([csvRef.current], { type: 'text/csv' }), + url = window.URL.createObjectURL(blob), + anchor = document.createElement('a') + anchor.href = url + anchor.download = 'fediblock-top100.csv' + anchor.click() + window.URL.revokeObjectURL(url) + } + }, []), listinstance = useCallback(async content => { - loading() - if (content && content.length > 0) { - try { - const result = await fetch('/api/list/' + content), - res = await result.json() - if (res && Array.isArray(res.instances) && Array.isArray(res.suggests)) { - setTypeList('list') - setList(res) - document.querySelector('.loader-content').style.display = 'none' - } else { - document.querySelector('.loader-content').style.display = 'none' - window.alert('Error: No response') - } - } catch (e) { - document.querySelector('.loader-content').style.display = 'none' - window.alert('Error: ' + e.message) - } - } else { - document.querySelector('.loader-content').style.display = 'none' + if (!content || !content.length) { + toggleLoader(false) + return } - }), + toggleLoader(true) + try { + const result = await fetch('/api/list/' + content), + res = await result.json() + if (res && Array.isArray(res.instances) && Array.isArray(res.suggests)) { + setTypeList('list') + setList(res) + } else { + window.alert('Error: No response') + } + } catch (e) { + window.alert('Error: ' + e.message) + } finally { + toggleLoader(false) + } + }, [toggleLoader]), ranking = useCallback(async () => { - loading() + toggleLoader(true) try { const result = await fetch('/api/ranking'), res = await result.json() if (Array.isArray(res) && res.length > 0) { setTypeList('ranking') setList(res) - document.querySelector('.loader-content').style.display = 'none' } else { - document.querySelector('.loader-content').style.display = 'none' window.alert('Error: No response') } } catch (e) { - document.querySelector('.loader-content').style.display = 'none' window.alert('Error: ' + e.message) + } finally { + toggleLoader(false) } - }), - filterKeys = useCallback(event => { - if (event.key && !event.ctrlKey && !event.altKey) { - if ((event.key.length === 1 && /[a-z0-9.\-*:]/i.test(event.key)) || (event.key === 'Backspace' && event.target.value !== '')) { - if (event.key === '.' && event.target.value === '') { - return - } else if (event.key === 'Backspace' && event.target.value !== '') { - setSearchTerm(searchTerm.substring(0, searchTerm.length - 1)) - } else { - setSearchTerm(searchTerm + event.key) - } - } + }, [toggleLoader]), + handleReverseSearch = useCallback(() => { + if (searchTerm && searchTerm.length > 0) { + setReverse(true) + setDomain({ domain: searchTerm }) + toggleLoader(true) } - }) + }, [searchTerm, toggleLoader]) + + const handleInputChange = useCallback(event => { + setSearchTerm(sanitizeInstanceInput(event.target.value)) + }, []) + useEffect(() => { if (searchTerm && searchTerm.length > 0) { listinstance(searchTerm) window.location.hash = searchTerm } else { - (async () => { - await ranking() - })() + ranking() } - if (prop.matrix === 'on') { - var walker = document.createTreeWalker(refList.current, NodeFilter.SHOW_TEXT) + }, [searchTerm, listinstance, ranking]) + + useEffect(() => { + if (matrix === 'on' && refList.current) { + const walker = document.createTreeWalker(refList.current, NodeFilter.SHOW_TEXT) while (walker.nextNode()) { if (walker.currentNode.textContent.length > 1) { new Messenger(walker.currentNode) } } } - }, [searchTerm, prop.matrix]) + }, [list, matrix]) + useEffect(() => { - if (prop.searchTerm && prop.searchTerm.length > 0) { - setSearchTerm(prop.searchTerm) + if (externalSearch && externalSearch.length > 0) { + setSearchTerm(sanitizeInstanceInput(externalSearch)) } - }, [prop.searchTerm]) + }, [externalSearch]) + useEffect(() => { if (window.location.hash && window.location.hash !== '#') { - setSearchTerm(window.location.hash.substring(1)) + setSearchTerm(sanitizeInstanceInput(window.location.hash.substring(1))) } }, []) + + const suggestions = typeList === 'list' && list.suggests && list.suggests.length > 0 ? list.suggests : [] + const host = typeof window !== 'undefined' ? new URL(window.location.href).host : 'fediblock' + + if (typeList === 'ranking') { + csvRef.current = '#domain,#severity,#reject_media,#reject_reports,#public_comment,#obfuscate\n' + } + return ( - <> - <section> - <input - autoFocus - type="text" - autoComplete="off" - className="instance" - placeholder="Type the name of the instance" - onKeyUp={(e) => filterKeys(e)} - value={searchTerm} /> - <button className="reverse" title="Reverse search..." onClick={() => { - if (searchTerm && searchTerm.length > 0) { - setReverse(true) - setDomain({ domain: searchTerm }) - loading() - } - }}>Reverse</button> - <div className="placeholder">{typeList === 'list' ? list.suggests && list.suggests.length > 0 ? list.suggests.map(suggest => (<a onClick={() => setSearchTerm(suggest)}>{suggest}</a>)).reduce((prev, curr) => [prev, ', ', curr]) : [] : []}</div> - <ul className="instancelist" ref={refList}>{typeList === 'list' ? list.instances.map(r => - (<li><a onClick={() => { - if (r.blocks) { - setReverse(false) - setDomain({ domain: r.domain }) - loading() - } else { - var a = document.createElement('a') - a.href = '/api/detail_api/' + r.domain - a.title = 'API info for ' + r.domain - a.target = '_blank' - a.dispatchEvent(new MouseEvent('click')) - } - }}>{r.domain}</a> - <span dangerouslySetInnerHTML={{ __html: r.blocks ? ` - ` + r.blocks + ` blocks ` : ` ` }}></span> - <span dangerouslySetInnerHTML={{ - __html: r.nodeinfo ? `<a href="/api/detail_nodeinfo/${r.domain}" title="Nodeinfo for ${r.domain}" target="_blank">ⓘ</a>` + `<br />` : `<br />` - }}></span> - <span dangerouslySetInnerHTML={{ - __html: r.api?.title ? `<br /><br />` + r.api.title + ` - ` + r.api.uri + `<br />` : `<br /><br />` - }}></span> - <span dangerouslySetInnerHTML={{ __html: r.last ? `Last update: ` + (new Date(r.last)).toLocaleString() + `<br />` : `` }}></span> - <span dangerouslySetInnerHTML={{ __html: r.api?.email ? `Email: ` + r.api.email + `<br />` : `` }}></span> - <span dangerouslySetInnerHTML={{ __html: `Registration: ` + (r.api?.registrations ? `open` : `closed`) + ` - Version: ` + r.api?.version + `<br />` }}></span> - <span dangerouslySetInnerHTML={{ __html: r.api?.stats ? `Users: ` + r.api.stats.user_count + ` - Statuses: ` + r.api.stats.status_count + ` - Domains: ` + r.api.stats.domain_count + `<br />` : `` }}></span> - <span dangerouslySetInnerHTML={{ __html: r.api?.description ? `Description: ` + r.api.description + `<br />` : `` }}></span> - <span dangerouslySetInnerHTML={{ __html: r.api?.thumbnail ? `<img domain="${r.domain}" loading="lazy" src="${r.api.thumbnail}" />` + `<br />` : `` }}></span> - </li> - )) : typeList === 'ranking' ? list.map((r, i) => { - if (i === 0) { - csv = '#domain,#severity,#reject_media,#reject_reports,#public_comment,#obfuscate\n' - return (<><li><strong>Top 100</strong><br /><small className="download-top"><a ref={refDownload} onClick={() => download()}>(Import CSV)</a></small></li><li>{i + 1} - {r.domain} - {r.count} blocks</li></>) - } else { - csv += !r.domain.match(/\*/) ? r.domain + ',suspend,False,False,"suspended by top 100 of ' + new URL(window.location.href).host + '",False\n' : '' - return (<li>{i + 1} - {r.domain} - {r.count} blocks</li>) - } - }) : ''} + <section className="search-panel"> + <header className="search-panel__header"> + <div> + <span className="eyebrow">Explore instances</span> + <h2>Review blocklists in seconds</h2> + <p className="muted">Search a specific domain or browse the most reported top 100.</p> + </div> + <button type="button" className="ghost-button" onClick={ranking}> + <FiRefreshCcw aria-hidden="true" /> + Refresh ranking + </button> + </header> + <div className="input-stack"> + <label className="input-field"> + <FiSearch aria-hidden="true" /> + <input + autoFocus + type="text" + autoComplete="off" + className="instance" + placeholder="Type an instance domain" + value={searchTerm} + onChange={handleInputChange} + /> + </label> + <button className="secondary-button" title="Reverse lookup" type="button" onClick={handleReverseSearch}> + <FiRotateCcw aria-hidden="true" /> + Reverse lookup + </button> + </div> + {suggestions.length > 0 && ( + <div className="suggestions"> + {suggestions.map(suggest => ( + <button type="button" key={suggest} onClick={() => setSearchTerm(sanitizeInstanceInput(suggest))}> + {suggest} + <FiChevronRight aria-hidden="true" /> + </button> + ))} + </div> + )} + <div className="list-shell" ref={refList}> + <ul className="instancelist"> + {typeList === 'list' && Array.isArray(list.instances) + ? list.instances.map(r => ( + <li className="instance-card" key={r.domain}> + <div className="instance-card__header"> + <button + type="button" + onClick={() => { + if (r.blocks) { + setReverse(false) + setDomain({ domain: r.domain }) + toggleLoader(true) + } else { + window.open('/api/detail_api/' + r.domain, '_blank', 'noopener,noreferrer') + } + }} + > + {r.domain} + </button> + <span + className="instance-card__badge" + dangerouslySetInnerHTML={{ __html: r.blocks ? `${r.blocks} blocks` : ' ' }} + ></span> + </div> + <div className="instance-card__body"> + <span dangerouslySetInnerHTML={{ + __html: r.nodeinfo ? `<a href="/api/detail_nodeinfo/${r.domain}" title="Nodeinfo for ${r.domain}" target="_blank">ⓘ</a>` : '' + }}></span> + <span dangerouslySetInnerHTML={{ + __html: r.api?.title ? `${r.api.title} - ${r.api.uri}<br />` : '' + }}></span> + <span dangerouslySetInnerHTML={{ __html: r.last ? `Last updated: ${(new Date(r.last)).toLocaleString()}<br />` : '' }}></span> + <span dangerouslySetInnerHTML={{ __html: r.api?.email ? `Email: ${r.api.email}<br />` : '' }}></span> + <span dangerouslySetInnerHTML={{ __html: `Registration: ${(r.api?.registrations ? 'open' : 'closed')} - Version: ${r.api?.version ?? 'N/A'}<br />` }}></span> + <span dangerouslySetInnerHTML={{ __html: r.api?.stats ? `Users: ${r.api.stats.user_count} - Statuses: ${r.api.stats.status_count} - Domains: ${r.api.stats.domain_count}<br />` : '' }}></span> + <span dangerouslySetInnerHTML={{ __html: r.api?.description ? `Description: ${r.api.description}<br />` : '' }}></span> + <span dangerouslySetInnerHTML={{ __html: r.api?.thumbnail ? `<img domain="${r.domain}" loading="lazy" src="${r.api.thumbnail}" alt="${r.domain} thumbnail" />` : '' }}></span> + </div> + </li> + )) + : null} + {typeList === 'ranking' && Array.isArray(list) + ? ( + <> + <li className="instance-card instance-card__import"> + <div> + <strong>Top 100</strong> + <p className="muted">Ready-to-import CSV for Mastodon.</p> + </div> + <button type="button" className="ghost-button" onClick={download}> + Download CSV + </button> + </li> + {list.map((r, i) => { + if (!r) return null + if (!r.domain.match(/\*/) && i > 0) { + csvRef.current += `${r.domain},suspend,False,False,"suspended by top 100 of ${host}",False\n` + } + return ( + <li className="instance-card" key={`${r.domain}-${i}`}> + <span className="instance-card__order">{i + 1}</span> + <div> + <strong>{r.domain}</strong> + <p className="muted">{r.count} reported blocks</p> + </div> + </li> + ) + })} + </> + ) + : null} </ul> - </section > - <Modal domain={domain} reverse={reverse} setSearch={d => setSearchTerm(d)} matrix={prop.matrix} /> - </> + </div> + <Modal domain={domain} reverse={reverse} setSearch={d => setSearchTerm(sanitizeInstanceInput(d))} matrix={matrix} /> + </section> ) } diff --git a/front/src/component/Loader.js b/front/src/component/Loader.js index bba45dc..08d2e1b 100644 --- a/front/src/component/Loader.js +++ b/front/src/component/Loader.js @@ -1,16 +1,18 @@ import { useEffect, useState } from 'react'; import '../loaders.css'; +const loaderClasses = ['loader-pong', 'loader-pacman', 'loader-abyss', 'loader-jump', 'loader-loading', 'loader-avenger', 'loader-mario'] + const Loader = () => { - const loaders = ['loader-pong', 'loader-pacman', 'loader-abyss', 'loader-jump', 'loader-loading', 'loader-avenger', 'loader-mario'], - [load, setLoad] = useState('load ' + loaders[Math.floor(Math.random() * loaders.length)]) + const [load, setLoad] = useState(() => 'load ' + loaderClasses[Math.floor(Math.random() * loaderClasses.length)]) useEffect(() => { - setLoad('load ' + loaders[Math.floor(Math.random() * loaders.length)]) + setLoad('load ' + loaderClasses[Math.floor(Math.random() * loaderClasses.length)]) }, []) return ( - <div className="loader-content"> + <div className="loader-content" role="alert" aria-live="assertive"> <div className="loader"> <div className={load}></div> + <p>Preparing live data...</p> </div> </div> ) diff --git a/front/src/component/Modal.js b/front/src/component/Modal.js index 4d7b833..4e4f525 100644 --- a/front/src/component/Modal.js +++ b/front/src/component/Modal.js @@ -1,9 +1,9 @@ import { useEffect, useCallback, useState, useRef } from 'react'; -import html2canvas from '../../node_modules/html2canvas/dist/html2canvas.js'; +import html2canvas from 'html2canvas'; +import { FiDownload, FiCamera, FiX } from 'react-icons/fi'; import Messenger from '../random-text.js'; const Modal = (prop) => { - let csv const [blocktitle, setBlocktitle] = useState(''), [blockcount, setBlockcount] = useState(''), [blockinstance, setBlockinstance] = useState(''), @@ -11,33 +11,54 @@ const Modal = (prop) => { [blocklist, setBlocklist] = useState([]), hrefCanvas = useRef(null), refList = useRef(null), + csvRef = useRef(''), + currentDomain = prop.domain?.domain ?? 'instance', loading = () => { - document.querySelector('.loader-content').style.display = 'initial' + const loader = document.querySelector('.loader-content') + if (loader) { + loader.style.display = 'initial' + } + }, + stopLoading = () => { + const loader = document.querySelector('.loader-content') + if (loader) { + loader.style.display = 'none' + } }, closeModal = event => { if (event.target.classList.contains('modal') || event.target.classList.contains('closemodal')) { - document.querySelector('.modal-content').style.animationName = 'animatebottom' + const modalContent = document.querySelector('.modal-content') + if (modalContent) { + modalContent.style.animationName = 'animatebottom' + } } }, animationEnd = event => { if (event.animationName === 'animatebottom') { - document.querySelector('.modal').style.display = 'none' + const modal = document.querySelector('.modal') + if (modal) { + modal.style.display = 'none' + } } }, capture = useCallback(async () => { const canvas = await html2canvas(hrefCanvas.current, { useCORS: true }), - capture = document.querySelector('.capture') - capture.download = 'fediblock-' + Date.now() + '.png' - capture.href = canvas.toDataURL('image/png', 1.0) - capture.dispatchEvent(new MouseEvent('click')) - }), - download = () => { - const download = document.querySelector('.download') - if (csv.split('\n').length > 2) { - download.href = window.URL.createObjectURL(new Blob([csv], { type: 'text/csv' })) - download.download = 'fediblock-' + prop.domain.domain + '.csv' + captureLink = document.createElement('a') + captureLink.download = 'fediblock-' + Date.now() + '.png' + captureLink.href = canvas.toDataURL('image/png', 1.0) + captureLink.click() + }, []), + download = useCallback(() => { + if (csvRef.current.split('\n').length > 2) { + const blob = new Blob([csvRef.current], { type: 'text/csv' }), + url = window.URL.createObjectURL(blob), + anchor = document.createElement('a') + anchor.href = url + anchor.download = 'fediblock-' + currentDomain + '.csv' + anchor.click() + window.URL.revokeObjectURL(url) } - }, + }, [currentDomain]), reverse = useCallback(async content => { if (content && content.length > 0) { loading() @@ -49,11 +70,16 @@ const Modal = (prop) => { setBlockinstance('ing ' + content) setBlocktook('took ' + res.took + 'ms') setBlocklist(res.instances) - document.querySelector('.loader-content').style.display = 'none' - document.querySelector('.modal-content').style.animationName = 'animatetop' - document.querySelector('.modal').style.display = 'block' + const modalContent = document.querySelector('.modal-content') + const modal = document.querySelector('.modal') + if (modalContent) { + modalContent.style.animationName = 'animatetop' + } + if (modal) { + modal.style.display = 'block' + } if (prop.matrix === 'on') { - var walker = document.createTreeWalker(refList.current, NodeFilter.SHOW_TEXT) + const walker = document.createTreeWalker(refList.current, NodeFilter.SHOW_TEXT) while (walker.nextNode()) { if (walker.currentNode.textContent.length > 1) { new Messenger(walker.currentNode) @@ -61,13 +87,14 @@ const Modal = (prop) => { } } } + stopLoading() } - }), + }, [prop.matrix]), listblock = useCallback(async (domain) => { const result = await fetch('/api/detail/' + domain), res = await result.json() if (res.blocks && Array.isArray(res.blocks)) { - csv = '#domain,#severity,#reject_media,#reject_reports,#public_comment,#obfuscate\n' + csvRef.current = '#domain,#severity,#reject_media,#reject_reports,#public_comment,#obfuscate\n' setBlocktitle('Blocked List') setBlockcount(res.blocks.length) setBlocktook('took ' + res.took + 'ms') @@ -76,7 +103,7 @@ const Modal = (prop) => { + `Last update: ` + (new Date(res.last)).toLocaleString()) setBlocklist(res.blocks) if (prop.matrix === 'on') { - var walker = document.createTreeWalker(refList.current, NodeFilter.SHOW_TEXT) + const walker = document.createTreeWalker(refList.current, NodeFilter.SHOW_TEXT) while (walker.nextNode()) { if (walker.currentNode.textContent.length > 1) { new Messenger(walker.currentNode) @@ -84,16 +111,23 @@ const Modal = (prop) => { } } } else { - var a = document.createElement('a') - a.href = '/api/detail_api/' + domain - a.title = 'API info for ' + domain - a.target = '_blank' - a.dispatchEvent(new MouseEvent('click')) + const link = document.createElement('a') + link.href = '/api/detail_api/' + domain + link.title = 'API info for ' + domain + link.target = '_blank' + link.dispatchEvent(new MouseEvent('click')) } - document.querySelector('.loader-content').style.display = 'none' - document.querySelector('.modal-content').style.animationName = 'animatetop' - document.querySelector('.modal').style.display = 'block' - }) + stopLoading() + const modalContent = document.querySelector('.modal-content') + const modal = document.querySelector('.modal') + if (modalContent) { + modalContent.style.animationName = 'animatetop' + } + if (modal) { + modal.style.display = 'block' + } + }, [prop.matrix]) + useEffect(() => { if (prop.domain && prop.domain.domain.length > 0) { if (prop.reverse) { @@ -102,38 +136,63 @@ const Modal = (prop) => { listblock(prop.domain.domain) } } - }, [prop.domain, prop.reverse]) + }, [prop.domain, prop.reverse, listblock, reverse]) + return ( - <div className="modal" onClick={(e) => closeModal(e)}> - <div className="modal-content" onAnimationEnd={(e) => animationEnd(e)} ref={refList}> + <div className="modal" onClick={closeModal}> + <div className="modal-content" onAnimationEnd={animationEnd} ref={refList}> <div className="modal-header"> - <span className="closemodal" title="Close" onClick={(e) => closeModal(e)}>×</span> - <a className="capture" title="Take snapshot" onClick={capture} type="image/png" target="_blank">📷</a> - {!prop.reverse ? <a className="download" title="Download fediblock CSV mastodon file" onClick={download} type="text/csv" target="_blank">⇩</a> : ''} - <h2>{blocktitle}</h2> + <div> + <p className="eyebrow">{blocktitle}</p> + <h2> + <span className="blockcount">{blockcount}</span> public instances block<span className="blockinstance" dangerouslySetInnerHTML={{ __html: blockinstance }}></span> + </h2> + <small className="blocktook">{blocktook}</small> + </div> + <div className="modal-actions"> + <button className="capture" title="Capture snapshot" onClick={capture} type="button"> + <FiCamera aria-hidden="true" /> + </button> + {!prop.reverse && ( + <button className="download" title="Download CSV" onClick={download} type="button"> + <FiDownload aria-hidden="true" /> + </button> + )} + <button className="closemodal" title="Close" onClick={closeModal} type="button"> + <FiX aria-hidden="true" /> + </button> + </div> </div> <div className="modal-body" ref={hrefCanvas}> - <p> - <span className="blockcount">{blockcount}</span> public instances are block<span className="blockinstance" dangerouslySetInnerHTML={{ __html: blockinstance }}></span> - <br /><small className="blocktook">{blocktook}</small> - </p> - <ul className="blocklist">{prop.reverse ? blocklist.map((instance, index) => { - return (<li>{index + 1}. <a onClick={() => { - prop.setSearch(instance.instance) - document.querySelector('.modal-content').style.animationName = 'animatebottom' - }}>{instance.instance}</a>{instance.comment ? ' - ' + instance.comment : ''}</li>) - }) : blocklist.map((r, i) => { + <ul className="blocklist">{prop.reverse ? blocklist.map((instance, index) => ( + <li key={`reverse-${instance.instance}-${index}`}> + {index + 1}. <button type="button" className="link-button" onClick={() => { + prop.setSearch(instance.instance) + const modalContent = document.querySelector('.modal-content') + if (modalContent) { + modalContent.style.animationName = 'animatebottom' + } + }}>{instance.instance}</button>{instance.comment ? ' - ' + instance.comment : ''} + </li> + )) : blocklist.map((r, i) => { if (r?.domain) { - csv += !r.domain.match(/\*/) ? r.domain + ',' + (r.severity ? r.severity : '') + ',False,False,' + (r.comment ? '"' + r.comment + '"' : '') + ',False\n' : '' - return (<li>{i + 1}. <a onClick={() => { - prop.setSearch(r.domain) - document.querySelector('.modal-content').style.animationName = 'animatebottom' - }}>{r.domain}</a>{r.severity ? ' - ' + r.severity : ''}{r.comment ? ' - ' + r.comment : ''}</li>) + csvRef.current += !r.domain.match(/\*/) ? r.domain + ',' + (r.severity ? r.severity : '') + ',False,False,' + (r.comment ? '"' + r.comment + '"' : '') + ',False\n' : '' + return ( + <li key={`block-${r.domain}-${i}`}> + {i + 1}. <button type="button" className="link-button" onClick={() => { + prop.setSearch(r.domain) + const modalContent = document.querySelector('.modal-content') + if (modalContent) { + modalContent.style.animationName = 'animatebottom' + } + }}>{r.domain}</button>{r.severity ? ' - ' + r.severity : ''}{r.comment ? ' - ' + r.comment : ''} + </li> + ) } + return null })}</ul> </div> - <div className="modal-footer"> - </div> + <div className="modal-footer"></div> </div> </div> ) diff --git a/front/src/component/Scan.js b/front/src/component/Scan.js index acc3ba2..d449b2e 100644 --- a/front/src/component/Scan.js +++ b/front/src/component/Scan.js @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { FiCpu } from 'react-icons/fi'; const Scan = () => { const [scan, setScan] = useState('Scanning...'), @@ -9,7 +10,7 @@ const Scan = () => { source.onmessage = event => { if (event.data) { if (event.data.length > 0) { - setScan('Async Scanning' + event.data) + setScan('Async Scanning ' + event.data) } else { setScan('Async Scanning...') } @@ -19,12 +20,22 @@ const Scan = () => { console.error(error) } load.current = true + return () => { + source.close() + } } - }) + }, []) return ( - <div> - <span className="scan">{scan}</span> - </div> + <section className="scan-card" aria-live="polite"> + <header> + <FiCpu aria-hidden="true" /> + <div> + <p className="eyebrow">Live scan</p> + <strong>Federated monitor</strong> + </div> + </header> + <p>{scan}</p> + </section> ) } diff --git a/front/src/component/Title.js b/front/src/component/Title.js index 17488f0..0a699c0 100644 --- a/front/src/component/Title.js +++ b/front/src/component/Title.js @@ -1,12 +1,24 @@ import { useCallback } from 'react'; +import { FiRefreshCcw } from 'react-icons/fi'; const Title = () => { const click = useCallback(() => { window.location.hash = '' window.location.reload(false) - }) + }, []) + return ( - <h1><a onClick={() => click()} className="title">Fediblock Instance Φ</a></h1> + <div className="title-card"> + <div className="title-stack"> + <span className="eyebrow">Fediverse safety toolkit</span> + <h1>Fediblock Instance Φ</h1> + <p className="subtitle">Monitor instance health, blocks, and metrics in a clean, real-time dashboard.</p> + </div> + <button className="ghost-button" type="button" onClick={click}> + <FiRefreshCcw aria-hidden="true" /> + Reset view + </button> + </div> ) }