From 05d8af18056853ed533a5a44259b909e738dd042 Mon Sep 17 00:00:00 2001 From: Shawn Tobin Date: Thu, 20 Jul 2023 14:23:39 -0230 Subject: [PATCH 1/3] Star Analyzer feature - takes a star's patp as input and displays all 65k of its planets, along with assigning 'tags' to noteworthy planets. --- declarations.d.ts | 1 + package.json | 2 + src/App.tsx | 4 +- src/components/Analyzer/FilterBar.tsx | 107 +++ src/components/Analyzer/PlanetTable.tsx | 124 +++ src/components/Analyzer/SearchBarFilter.tsx | 59 ++ src/components/Analyzer/StarAnalyzer.scss | 231 ++++++ src/components/Analyzer/StarAnalyzer.tsx | 337 ++++++++ src/components/Analyzer/StarTextInput.tsx | 57 ++ src/components/Footer/Footer.tsx | 5 + src/components/Header/HomeHeader.tsx | 11 +- src/components/Home.tsx | 5 +- src/components/MetricsBar.tsx | 102 +-- src/components/SectionBar.tsx | 2 +- src/utils/analyzer-utils.ts | 857 ++++++++++++++++++++ tsconfig.json | 10 +- 16 files changed, 1843 insertions(+), 71 deletions(-) create mode 100644 declarations.d.ts create mode 100644 src/components/Analyzer/FilterBar.tsx create mode 100644 src/components/Analyzer/PlanetTable.tsx create mode 100644 src/components/Analyzer/SearchBarFilter.tsx create mode 100644 src/components/Analyzer/StarAnalyzer.scss create mode 100644 src/components/Analyzer/StarAnalyzer.tsx create mode 100644 src/components/Analyzer/StarTextInput.tsx create mode 100644 src/utils/analyzer-utils.ts diff --git a/declarations.d.ts b/declarations.d.ts new file mode 100644 index 0000000..923c5d5 --- /dev/null +++ b/declarations.d.ts @@ -0,0 +1 @@ +declare module "urbit-ob"; diff --git a/package.json b/package.json index f5b88d0..50180ad 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "prompt": "^1.0.0", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-hook-form": "^7.45.1", + "react-paginate": "^8.2.0", "react-router-dom": "^5.0.0", "react-scripts": "4.0.3", "replace-in-file": "^4.1.1", diff --git a/src/App.tsx b/src/App.tsx index 1c8fd79..fe8ced8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,7 +25,7 @@ import { getPreferredWallet } from './utils/local-storage'; import { minGas } from './utils/gas-prices'; import { getEthBalance } from './utils/eth'; import Container from './components/Container'; - +import StarAnalyzer from "./components/Analyzer/StarAnalyzer"; // import { toPairsIn } from 'lodash'; // import { ToggleSwitch } from '@tlon/indigo-react'; @@ -215,6 +215,8 @@ const App = () => { + + diff --git a/src/components/Analyzer/FilterBar.tsx b/src/components/Analyzer/FilterBar.tsx new file mode 100644 index 0000000..6657a3d --- /dev/null +++ b/src/components/Analyzer/FilterBar.tsx @@ -0,0 +1,107 @@ +import { Icon, Box, Button } from "@tlon/indigo-react"; +import { filters } from "../../utils/analyzer-utils" + +interface FilterBarProps { + selectedFilter: string; + star: string; + applyFilter: (filter: string) => void; + reset: () => void; +} + +const FilterBar = (props: FilterBarProps) => { + const { star, selectedFilter } = props; + + const renderFilters = () => { + return filters.map((filter) => { + return ( + + + + ); + }); + }; + + const renderContent = () => { + return ( + + + {star && renderBackButton()} + + {/*
Filter Tags:
*/} +
+ {renderFilters()} +
+
+ ) + } + + + const renderBackButton = () => { + return ( + + + ) + } + + + return ( + <> + {renderContent()} + + ); +}; + +export default FilterBar; diff --git a/src/components/Analyzer/PlanetTable.tsx b/src/components/Analyzer/PlanetTable.tsx new file mode 100644 index 0000000..ae98d38 --- /dev/null +++ b/src/components/Analyzer/PlanetTable.tsx @@ -0,0 +1,124 @@ +import { Icon } from "@tlon/indigo-react"; +import "./StarAnalyzer.scss"; +import { sigil, reactRenderer } from "@tlon/sigil-js"; +import ReactPaginate from "react-paginate"; +import * as utils from "../../utils/analyzer-utils"; + + +interface Planet { + point: number; + patp: string; + tags?: string[]; +} + +interface PlanetTableProps { + items: Planet[]; + currentPage: number; + itemOffset: number; + setItemOffset: (offset: number) => void; + setCurrentPage: (page: number) => void; +} + +const PlanetTable: React.FC = (props) => { + const { items, currentPage, itemOffset } = props; + + const Items: React.FC<{ currentItems: Planet[] }> = ({ currentItems }) => { + return ( + <> + {currentItems.map((planet, index) => ( + + + {sigil({ + patp: planet.patp, + renderer: reactRenderer, + size: 30, + colors: ["black", "#FFFFFF"], + })} + + {planet.patp} + {planet.point} + {renderTags(planet.tags || [])} + + ))} + + ); + }; + + const itemsPerPage = 100; + + const endOffset = itemOffset + itemsPerPage; + const currentItems = items.slice(itemOffset, endOffset); + const pageCount = Math.ceil(items.length / itemsPerPage); + + const handlePageClick = (event: { selected: number }) => { + const newOffset = (event.selected * itemsPerPage) % items.length; + + props.setItemOffset(newOffset); + props.setCurrentPage(event.selected); + }; + + const renderTags = (tags: string[]) => { + return tags.map((tag, index) => { + return ( + + {tag} + + ); + }); + }; + + return ( + <> + + + + + + + + + + + + +
SigilName + ID + Tags
+ +
+ } + onPageChange={handlePageClick} + pageRangeDisplayed={3} + marginPagesDisplayed={0} + pageCount={pageCount} + previousLabel={} + pageClassName="page-item" + pageLinkClassName="page-link" + previousClassName="page-item" + previousLinkClassName="page-link" + nextClassName="page-item" + nextLinkClassName="page-link" + breakLabel="..." + breakClassName="page-item" + breakLinkClassName="page-link" + containerClassName="pagination" + activeClassName="active" + renderOnZeroPageCount={null} + /> +
+ + ); +}; + +export default PlanetTable; diff --git a/src/components/Analyzer/SearchBarFilter.tsx b/src/components/Analyzer/SearchBarFilter.tsx new file mode 100644 index 0000000..f919683 --- /dev/null +++ b/src/components/Analyzer/SearchBarFilter.tsx @@ -0,0 +1,59 @@ +import { useForm } from "react-hook-form"; + +type FormValues = { + searchFilter: string; +}; + +type SearchBarFilterProps = { + placeholder: string; + handleSubmit: (filterValue: string) => void; +}; + +const SearchBarFilter = (props: SearchBarFilterProps) => { + const { placeholder, handleSubmit } = props; + const { + register, + reset, + handleSubmit: formSubmit + } = useForm(); + + const onSubmit = (data: FormValues) => { + handleSubmit(data.searchFilter); + reset({ + searchFilter: "", + }); + }; + + return ( + <> +
+
+ +
+
+ + ); +}; + +export default SearchBarFilter; diff --git a/src/components/Analyzer/StarAnalyzer.scss b/src/components/Analyzer/StarAnalyzer.scss new file mode 100644 index 0000000..18a115a --- /dev/null +++ b/src/components/Analyzer/StarAnalyzer.scss @@ -0,0 +1,231 @@ +@import "../../styles/variables.scss"; + +.button-csv { + background-color: green; + color: pink; +} + +.patp-input { + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: flex-start; + padding: 15px 15px 15px 20px; + border: solid 1px #656363; + font-size: 14px; + background: white; + border-radius: 20px; + color: black; + width: 100%; + font-family: "Source Code Pro"; + margin: 0px 0px 20px 0px; + max-width: 400px; + font-size: 16px; + box-shadow: 0px 0px 10px rgba(196, 196, 196, 0.4); +} + +.page-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + margin-top: 20px; + width: 100%; + font-family: Arial, sans-serif; +} + +.sigil { + font-size: 50px; + max-width: 200px; +} + +.shimmer { + -webkit-mask: linear-gradient(-60deg, #000 30%, #0005, #000 70%) right/300% + 100%; + background-repeat: no-repeat; + animation: shimmer 4s infinite; + border: solid 1px #646464; + display: flex; + flex-direction: row; + align-items: center; + padding-right: 30px; + overflow: hidden; + border-radius: 10px; +} + +@keyframes shimmer { + 100% { + -webkit-mask-position: left; + } +} + +.table-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + width: 100%; + border-color: black; + margin-bottom: 10px; +} + +.pagination { + display: flex; + justify-content: center; + list-style: none; + padding: 0; +} + +.page-item { + margin: 0 5px; +} + +.page-link { + color: #007bff; + text-decoration: none; + padding: 5px 10px; + border-radius: 5px; + border: 1px solid transparent; + transition: all 0.3s ease; +} + +.page-link:hover { + color: #0056b3; + border: 1px solid #007bff; + background-color: #e9ecef; + text-decoration: none; +} + +.active .page-link { + color: #007bff; + background-color: #007bff; + border-color: #007bff; +} + +.page-item .page-link { + background-color: #fff; +} + +.breakClassName .page-link { + color: #6c757d; + pointer-events: none; +} + +.remove-mobile { + display: flex; + align-items: center; +} + +@media (max-width: 550px) { + .remove-mobile { + display: none; + } +} + +.patp-text { + margin-left: 20px; + font-family: "Source Code Pro"; + font-weight: 600; + font-size: 18; +} + +.star-input-container { + width: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding-top: 30px; +} + +.analyze-button { + color: black; + padding: 22px; + width: 130px; + height: 40px; + font-weight: 600; + border-width: 1; + box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.2); + background-color: #f6b451; +} + +.spinner-container { + width: 100%; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +.loading-text { + font-size: 20px; + font-weight: 700; + font-family: "Source Code Pro"; + padding-left: 20px; +} + +.result-text { + margin: 20px; + width: 100%; + color: gray; +} + +.filter-container { + width: 100%; + display: flex; + align-items: center; + flex-wrap: wrap; + margin: 15px; +} + +.small-text { + margin-top: 75px; + font-size: 14px; + color: gray; + padding: 15px; + border-radius: 5px; +} + +.search-bar { + height: 50px; + box-sizing: "border-box"; + width: 100px; + min-width: 250px; + padding-left: 25px; + border-radius: 30px; + border: solid; + border-width: 1px; + border-color: gray; + font-family: "Source Code Pro"; +} + +.tag-badge { + display: "inline-block"; + padding-top: 5px; + padding-bottom: 5px; + padding-left: 7px; + padding-right: 7px; + margin-right: 5px; + font-size: 14px; + font-weight: 600; + border-radius: 3px; + color: white; + font-family: "Inter"; +} + +.pagination-container { + display: "flex"; + flex: 1; + margin-top: 50px; + margin-bottom: 50px; + justify-content: "center"; + align-items: "center"; + margin-right: 20ox; + margin-left: 20px; +} + +.planet-table-container { + width: 100%; + text-align: left; + font-family: "Source Code Pro"; +} diff --git a/src/components/Analyzer/StarAnalyzer.tsx b/src/components/Analyzer/StarAnalyzer.tsx new file mode 100644 index 0000000..595864c --- /dev/null +++ b/src/components/Analyzer/StarAnalyzer.tsx @@ -0,0 +1,337 @@ +import React, { useEffect, useState } from "react"; +import HomeHeader from "../Header/HomeHeader"; +import { Icon, Button, LoadingSpinner } from "@tlon/indigo-react"; +import "./StarAnalyzer.scss"; +import * as utils from "../../utils/analyzer-utils"; +import ob from "urbit-ob"; +import { sigil, reactRenderer } from "@tlon/sigil-js"; +import PlanetTable from "./PlanetTable"; +import FilterBar from "./FilterBar"; +import SearchBarFilter from "./SearchBarFilter"; +import { formatNumber } from "../../utils/text"; +import { pluralize } from "../../utils/text"; +import StarTextInput from "./StarTextInput"; +import Footer from "../Footer/Footer"; +import { useParams, useHistory, useLocation, Redirect } from "react-router-dom"; + +interface Planet { + point: number; + patp: string; + tags?: string[]; +} + +interface RouteParams { + star: string; +} + +const StarAnalyzer = () => { + const { star: starUrl } = useParams(); + const [patp, setPatp] = useState(""); + const [loading, setLoading] = useState(false); + const [starSigil, setStarSigil] = useState(); + const [showTextInput, setShowTextInput] = useState( + starUrl ? false : true + ); + const [tableData, setTableData] = useState([]); + const [selectedFilter, setSelectedFilter] = useState(""); + const [textFilter, setTextFilter] = useState(""); + const [inputError, setInputError] = useState(false); + const [itemOffset, setItemOffset] = useState(0); + const [currentPage, setCurrentPage] = useState(0); + const [shouldRedirect, setShouldRedirect] = useState(false); + const location = useLocation(); + const history = useHistory(); + + const params = new URLSearchParams(location.search); + + const LoadingSpinnerAsAny = LoadingSpinner as any; + + useEffect(() => { + // for when page is naviagted to via link + + if (starUrl && ob.isValidPatp(starUrl) && starUrl.length === 7) { + setLoading(true); + + const tag = params.get("tag"); + const filter = params.get("filter"); + + // Timeout used to allow the loading spinner to first render, + // otherwise it doesn't load due to the 65k planet array being generated + + setTimeout(() => { + setShowTextInput(false); + setPatp(starUrl); + startAnalyzing(starUrl); + setSelectedFilter(tag || ""); + setTextFilter(filter || ""); + }, 1500); + } else { + setShowTextInput(true); + + // redirects to input screen if invalid patp + setShouldRedirect(true); + } + }, []); + + useEffect(() => { + // to generate sigil image on input screen + + if (patp && ob.isValidPatp(patp) && patp.length === 7) { + const _sigil = sigil({ + patp: patp, + renderer: reactRenderer, + size: 130, + colors: ["black", "#FFFFFF"], + }); + + setStarSigil(_sigil); + } else { + setStarSigil(undefined); + } + }, [patp]); + + const handleApplyFilters = () => { + history.push({ + pathname: location.pathname, + search: params.toString(), + }); + }; + + const handleFilterClick = (filter: string) => { + params.set("tag", filter); + + // reset pagination for new filter + + setItemOffset(0); + setCurrentPage(0); + if (selectedFilter === filter) { + setSelectedFilter(""); + params.delete("tag"); + } else { + setSelectedFilter(filter); + } + handleApplyFilters(); + }; + + const startAnalyzing = (_star: string) => { + const planets = utils.getPlanets(_star); + + setTableData(planets); + setLoading(false); + setShowTextInput(false); + + // update url with the star + + if (!starUrl) { + history.push(`/star-analyzer/${_star}`); + } + }; + + const checkInput = (_star: string) => { + // reset filters and pagination + + setSelectedFilter(""); + setTextFilter(""); + setItemOffset(0); + setCurrentPage(0); + + const _patp = utils.formatPatp(_star); + + if (!ob.isValidPatp(_patp) || _patp?.length !== 7) { + setInputError(true); + setLoading(false); + return; + } else { + setLoading(true); + + // Timeout used to allow the loading spinner to first render + + setTimeout(() => { + startAnalyzing(_star); + }, 1500); + } + }; + + const renderSigil = () => { + return ( + starSigil && ( +
+
{starSigil}
+
+ {patp} +
+
+ {ob.isValidPatp(patp) && ob.patp2dec(patp)} +
+
+
+ ) + ); + }; + + const handleTextChange = (val: string) => { + inputError && setInputError(false); + const formattedPatp = utils.formatPatp(val); + setPatp(formattedPatp); + }; + + const renderTextInput = () => { + return ( +
+
{renderSigil()}
+ + checkInput(patp)} + /> + +
+ Star Analyzer is a tool for exploring the spawnable planets of an + Urbit star. +
+ Planets are tagged based on arbitrary properties which may be of + interest. +
+
+ ); + }; + + const applyTextFilter = (val: string) => { + setTextFilter(val); + + // update url + params.set("filter", val); + handleApplyFilters(); + }; + + const clearTextFilter = () => { + setTextFilter(""); + + // update url + params.delete("filter"); + handleApplyFilters(); + }; + + const renderResults = () => { + const filteredData = tableData.filter( + (item) => + (item.tags?.includes(selectedFilter) || !selectedFilter) && + (item.patp.toLowerCase().includes(textFilter.toLowerCase()) || + !textFilter) + ); + + return ( + <> +
+ + {textFilter && ( + + )} +
+ +
+ {`${formatNumber(filteredData?.length)} ${pluralize( + "result", + filteredData?.length, + true + )}`} +
+ + {filteredData.length > 0 && ( + setCurrentPage(page)} + setItemOffset={(offset: number) => setItemOffset(offset)} + /> + )} + + ); + }; + + const resetInput = () => { + setShowTextInput(true); + history.push("/star-analyzer"); + }; + + const renderLoading = () => { + return ( +
+ +
Loading planets...
+
+ ); + }; + + const renderContent = () => { + if (showTextInput) { + return
{renderTextInput()}
; + } else { + return
{renderResults()}
; + } + }; + + return ( + <> + {shouldRedirect && } + + {!showTextInput && !loading && ( + + )} + + {loading && renderLoading()} +
+ {showTextInput && !loading && } + + {!loading && renderContent()} +
+ {!showTextInput &&