Skip to content

Instantly share code, notes, and snippets.

@justaguywhocodes
Created May 5, 2022 06:04
Show Gist options
  • Save justaguywhocodes/3f17acb61f6fcafe427551f1516cd75d to your computer and use it in GitHub Desktop.
Save justaguywhocodes/3f17acb61f6fcafe427551f1516cd75d to your computer and use it in GitHub Desktop.
React | Crypto UI 2.0
<div id="root"></div>
enum CoinGeckoApi {
AllCoins = "coins/markets?vs_currency=usd&page=1&per_page=30&sparkline=false",
Base = "https://api.coingecko.com/api/v3"
}
enum RequestStatus {
Error = "Error",
Idle = "Idle",
Loading = "Loading",
Success = "Success"
}
enum Color {
Green = "76, 175, 80",
Red = "198, 40, 40"
}
interface ICrypto {
change: number;
id: string;
image: string;
marketCap: number;
name: string;
price: number;
rank: number;
supply: number;
symbol: string;
volume: number;
}
interface IChartPoint {
price: number;
timestamp: number;
}
/* ---------- Crypto Utility ---------- */
interface ICryptoUtility {
formatPercent: (value: number) => string;
formatUSD: (value: number) => string;
getByID: (id: string, cryptos: ICrypto[]) => ICrypto | null;
map: (data: any) => ICrypto;
mapAll: (data: any[]) => ICrypto[];
}
const CryptoUtility: ICryptoUtility = {
formatPercent: (value: number): string => {
return (value / 100).toLocaleString("en-US", { style: "percent", minimumFractionDigits: 2 });
},
formatUSD: (value: number): string => {
return value.toLocaleString("en-US", { style: "currency", currency: "USD" });
},
getByID: (id: string, cryptos: ICrypto[]): ICrypto | null => {
const match: ICrypto = cryptos.find((crypto: ICrypto) => crypto.id === id);
return match || null;
},
map: (data: any): ICrypto => {
return {
change: data.price_change_percentage_24h,
id: data.id,
image: data.image,
marketCap: CryptoUtility.formatUSD(data.market_cap),
name: data.name,
price: CryptoUtility.formatUSD(data.current_price),
rank: data.market_cap_rank,
supply: data.circulating_supply.toLocaleString(),
symbol: data.symbol,
volume: CryptoUtility.formatUSD(data.total_volume)
}
},
mapAll: (data: any[]): ICrypto[] => {
return data.map((item: any) => CryptoUtility.map(item));
}
}
/* ---------- Chart Utility ---------- */
interface IChartUtility {
draw: (id: string, points: IChartPoint[], change: number) => Chart;
getDatasetOptions: (change: number) => Chart.ChartDataSets;
getOptions: (points: IChartPoint[]) => Chart.ChartOptions;
getUrl: (id: string) => string;
mapPoints: (data: any) => IChartPoint[];
update: (chart: Chart, points: IChartPoint[], change: number) => void;
}
const ChartUtility: IChartUtility = {
draw: (id: string, points: IChartPoint[], change: number): Chart => {
const canvas: HTMLCanvasElement | null = document.getElementById(id) as HTMLCanvasElement | null;
if(canvas !== null) {
const context: CanvasRenderingContext2D | null = canvas.getContext("2d");
const {
clientHeight: height,
clientWidth: width
} = context.canvas;
context.stroke();
return new Chart(context, {
type: "line",
data: {
datasets: [{
data: points.map((point: IChartPoint) => point.price),
...ChartUtility.getDatasetOptions(change)
}],
labels: points.map((point: IChartPoint) => point.timestamp)
},
options: ChartUtility.getOptions(points)
})
}
},
getDatasetOptions: (change: number): Chart.ChartDataSets => {
const color: Color = change >= 0 ? Color.Green : Color.Red;
return {
backgroundColor: "rgba(" + color + ", 0.1)",
borderColor: "rgba(" + color + ", 0.5)",
fill: true,
tension: 0.2,
pointRadius: 0
}
},
getOptions: (points: IChartPoint[]): Chart.ChartOptions => {
const min: number = Math.min.apply(Math, points.map((point: IChartPoint) => point.price)),
max: number = Math.max.apply(Math, points.map((point: IChartPoint) => point.price));
return {
maintainAspectRatio: false,
responsive: true,
scales: {
x: {
display: false,
gridLines: {
display: false
}
},
y: {
display: false,
gridLines: {
display: false
},
suggestedMin: min * 0.98,
suggestedMax: max * 1.02
}
},
plugins: {
legend: {
display: false
},
title: {
display: false
}
}
}
},
getUrl: (id: string): string => {
return `${CoinGeckoApi.Base}/coins/${id}/market_chart?vs_currency=usd&days=1`;
},
mapPoints: (data: any): IChartPoint[] => {
return data.prices.map((price: number[]) => ({
price: price[1],
timestamp: price[0]
}))
},
update: (chart: Chart, points: IChartPoint[], change: number): void => {
chart.options = ChartUtility.getOptions(points);
const options: Chart.ChartDataSets = ChartUtility.getDatasetOptions(change);
chart.data.datasets[0].data = points.map((point: IChartPoint) => point.price);
chart.data.datasets[0].backgroundColor = options.backgroundColor;
chart.data.datasets[0].borderColor = options.borderColor;
chart.data.datasets[0].pointRadius = options.pointRadius;
chart.data.labels = points.map((point: IChartPoint) => point.timestamp);
chart.update();
}
}
/* ---------- Loading Component ---------- */
const LoadingSpinner: React.FC = () => {
return (
<div className="loading-spinner-wrapper">
<div className="loading-spinner">
<i className="fa-regular fa-spinner-third" />
</div>
</div>
);
}
/* ---------- Crypto List Component ---------- */
const CryptoListToggle: React.FC = () => {
const { state, toggleList } = React.useContext(AppContext);
if(state.status === RequestStatus.Success && state.cryptos.length > 0) {
const classes: string = classNames(
"fa-regular", {
"fa-bars": !state.listToggled,
"fa-xmark": state.listToggled
});
return (
<button
id="crypto-list-toggle-button"
onClick={() => toggleList(!state.listToggled)}
>
<i className={classes} />
</button>
)
}
return null;
}
interface CryptoListItemProps {
crypto: ICrypto;
}
const CryptoListItem: React.FC<CryptoListItemProps> = (props: CryptoListItemProps) => {
const { state, selectCrypto } = React.useContext(AppContext);
const { crypto } = props;
const getClasses = (): string => {
const selected: boolean = state.selectedCrypto && state.selectedCrypto.id === crypto.id;
return classNames(
"crypto-list-item", {
selected
});
}
return (
<button type="button" className={getClasses()} onClick={() => selectCrypto(crypto.id)}>
<div className="crypto-list-item-background">
<h1 className="crypto-list-item-symbol">{crypto.symbol}</h1>
<img className="crypto-list-item-background-image" src={crypto.image} />
</div>
<div className="crypto-list-item-content">
<h1 className="crypto-list-item-rank">{crypto.rank}</h1>
<img className="crypto-list-item-image" src={crypto.image} />
<div className="crypto-list-item-details">
<h1 className="crypto-list-item-name">{crypto.name}</h1>
<h1 className="crypto-list-item-price">{crypto.price}</h1>
</div>
</div>
</button>
);
}
const CryptoList: React.FC = () => {
const { state } = React.useContext(AppContext);
if(state.status === RequestStatus.Success && state.cryptos.length > 0) {
const getItems = (): JSX.Element[] => {
return state.cryptos.map((crypto: ICrypto) => (
<CryptoListItem key={crypto.id} crypto={crypto} />
));
}
return (
<div id="crypto-list">
{getItems()}
</div>
);
}
return null;
}
/* ---------- Crypto Price Chart Component ---------- */
interface ICryptoPriceChartState {
chart: Chart;
points: IChartPoint[];
status: RequestStatus;
}
const CryptoPriceChart: React.FC = () => {
const { selectedCrypto: crypto } = React.useContext(AppContext).state;
const id: string = "crypto-price-chart";
const [state, setStateTo] = React.useState<ICryptoPriceChartState>({
chart: null,
points: [],
status: RequestStatus.Loading
});
const setStatusTo = (status: RequestStatus): void => {
setStateTo({ ...state, status });
}
const setChartTo = (chart: Chart): void => {
setStateTo({ ...state, chart });
}
React.useEffect(() => {
const fetch = async (): Promise<void> => {
try {
setStatusTo(RequestStatus.Loading);
const res: any = await axios.get(ChartUtility.getUrl(crypto.id));
setStateTo({
...state,
points: ChartUtility.mapPoints(res.data),
status: RequestStatus.Success
});
} catch (err) {
console.error(err);
setStatusTo(RequestStatus.Error);
}
}
fetch();
}, [crypto]);
React.useEffect(() => {
if(state.chart === null && state.status === RequestStatus.Success) {
setChartTo(ChartUtility.draw(id, state.points, crypto.change));
}
}, [state.status]);
React.useEffect(() => {
if(state.chart !== null) {
const update = (): void => ChartUtility.update(state.chart, state.points, crypto.change);
update();
}
}, [state.chart, state.points]);
const getLoadingSpinner = (): JSX.Element => {
if(state.status === RequestStatus.Loading) {
return (
<div id="crypto-price-chart-loading-spinner">
<LoadingSpinner />
</div>
);
}
}
return (
<div id="crypto-price-chart-wrapper">
<canvas id={id} />
{getLoadingSpinner()}
</div>
);
}
/* ---------- Crypto Details Component ---------- */
interface CryptoFieldProps {
className?: string;
label: string;
value: string | number;
}
const CryptoField: React.FC<CryptoFieldProps> = (props: CryptoFieldProps) => {
return (
<div className={classNames("crypto-field", props.className)}>
<h1 className="crypto-field-value">{props.value}</h1>
<h1 className="crypto-field-label">{props.label}</h1>
</div>
);
}
interface ICryptoDetailsState {
crypto: ICrypto;
transitioning: boolean;
}
const CryptoDetails: React.FC = () => {
const { selectedCrypto } = React.useContext(AppContext).state;
const [state, setStateTo] = React.useState<ICryptoDetailsState>({
crypto: null,
transitioning: true
});
const setTransitioningTo = (transitioning: boolean): void => {
setStateTo({ ...state, transitioning });
}
const { crypto } = state;
React.useEffect(() => {
if(selectedCrypto !== null) {
setTransitioningTo(true);
const timeout: NodeJS.Timeout = setTimeout(() => {
setStateTo({ crypto: selectedCrypto, transitioning: false });
}, 500);
return () => {
clearTimeout(timeout);
}
}
}, [selectedCrypto]);
if(crypto !== null) {
const sign: string = crypto.change >= 0 ? "positive" : "negative";
return (
<div id="crypto-details" className={classNames(sign, { transitioning: state.transitioning })}>
<div id="crypto-details-content">
<div id="crypto-fields">
<CryptoField label="Rank" value={crypto.rank} />
<CryptoField label="Name" value={crypto.name} />
<CryptoField label="Price" value={crypto.price} />
<CryptoField label="Market Cap" value={crypto.marketCap} />
<CryptoField label="24H Volume" value={crypto.volume} />
<CryptoField label="Circulating Supply" value={crypto.supply} />
<CryptoField
className={sign}
label="24H Change"
value={CryptoUtility.formatPercent(crypto.change)}
/>
</div>
<CryptoPriceChart />
<h1 id="crypto-details-symbol">{crypto.symbol}</h1>
</div>
</div>
);
}
return null;
}
/* ---------- App Component ---------- */
interface IAppState {
cryptos: ICrypto[];
listToggled: boolean;
selectedCrypto: ICrypto;
status: RequestStatus;
}
interface IAppContext {
state: IAppState;
selectCrypto: (id: string) => void;
setStateTo: (state: IAppState) => void;
toggleList: (listToggled: boolean) => void;
}
const AppContext = React.createContext<IAppContext>(null);
const App: React.FC = () => {
const [state, setStateTo] = React.useState<IAppState>({
cryptos: [],
listToggled: true,
selectedCrypto: null,
status: RequestStatus.Loading
});
const setStatusTo = (status: RequestStatus): void => {
setStateTo({ ...state, status });
}
const selectCrypto = (id: string): void => {
setStateTo({
...state,
listToggled: window.innerWidth > 800,
selectedCrypto: CryptoUtility.getByID(id, state.cryptos)
});
}
const toggleList = (listToggled: boolean): void => {
setStateTo({ ...state, listToggled });
}
React.useEffect(() => {
const fetch = async (): Promise<void> => {
try {
setStatusTo(RequestStatus.Loading);
const res: any = await axios.get(`${CoinGeckoApi.Base}/${CoinGeckoApi.AllCoins}`);
setStateTo({
...state,
cryptos: CryptoUtility.mapAll(res.data),
status: RequestStatus.Success
});
} catch (err) {
console.error(err);
setStatusTo(RequestStatus.Error);
}
}
fetch();
}, []);
React.useEffect(() => {
if(state.status === RequestStatus.Success && state.cryptos.length > 0) {
selectCrypto(state.cryptos[0].id);
}
}, [state.status]);
const getLoadingSpinner = (): JSX.Element => {
if(state.status === RequestStatus.Loading) {
return (
<LoadingSpinner />
);
}
}
return(
<AppContext.Provider value={{ state, selectCrypto, setStateTo, toggleList }}>
<div id="app" className={classNames({ "list-toggled": state.listToggled })}>
<CryptoList />
<CryptoDetails />
<CryptoListToggle />
{getLoadingSpinner()}
</div>
</AppContext.Provider>
)
}
ReactDOM.render(<App/>, document.getElementById("root"));
<script src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/browse/@types/[email protected]/index.d.ts"></script>
<script src="https://unpkg.com/browse/@types/[email protected]/index.d.ts"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.24.0/axios.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/classnames/2.3.1/index.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.6.2/chart.min.js"></script>
<script src="https://kit.fontawesome.com/86933bf68b.js"></script>
@function gray($color){
@return rgb($color, $color, $color);
}
$shadow1: rgba(0, 0, 0, 0.12) 0px 1px 6px, rgba(0, 0, 0, 0.12) 0px 1px 4px;
$blue: rgb(30, 136, 229);
$green: rgb(76, 175, 80);
$red: rgb(198, 40, 40);
@keyframes fadeInFromLeft {
from {
opacity: 0;
transform: translateX(-20px);
}
50% {
opacity: 0;
}
to {
opacity: 1;
transform: translateX(0px);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
50% {
transform: rotate(720deg);
}
to {
transform: rotate(1440deg);
}
}
body{
margin: 0px;
overflow: hidden;
padding: 0px;
input, h1, a, button {
color: gray(90);
font-family: 'Rubik', sans-serif;
font-weight: 400;
margin: 0px;
padding: 0px;
}
}
#app {
background-color: gray(30);
height: 100vh;
overflow: hidden;
&.list-toggled {
#crypto-list {
transform: translateX(0px);
}
#crypto-details {
#crypto-details-content {
margin-left: 480px;
width: calc(100% - 480px);
}
}
}
.loading-spinner-wrapper {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
left: 0px;
position: absolute;
top: 0px;
width: 100%;
.loading-spinner {
animation: spin 2s ease-in-out infinite;
pointer-events: none;
i {
color: white;
font-size: 3em;
}
}
}
#crypto-list {
animation: fadeInFromLeft 0.25s ease-in;
display: flex;
flex-direction: column;
gap: 10px;
height: calc(100% - 20px);
overflow: auto;
padding: 10px;
padding-left: 0px;
position: relative;
transform: translateX(-520px);
transition: all 0.25s;
width: 500px;
z-index: 2;
&::-webkit-scrollbar {
width: 0px;
}
.crypto-list-item {
background-color: gray(20);
border: none;
border-bottom-right-radius: 1000px;
border-top-right-radius: 1000px;
cursor: pointer;
display: flex;
outline: none;
padding: 10px;
padding-left: 0px;
position: relative;
transition: all 0.25s;
&:hover,
&:focus,
&.selected {
.crypto-list-item-content {
width: calc(100% - 120px);
}
}
&.selected {
.crypto-list-item-content {
background-color: rgba(white, 0.15);
}
}
.crypto-list-item-background {
align-items: center;
border-radius: 10px;
border-bottom-right-radius: 1000px;
border-top-right-radius: 1000px;
display: flex;
height: 100%;
left: 0px;
overflow: hidden;
position: absolute;
top: 0px;
width: 100%;
z-index: 1;
.crypto-list-item-symbol {
color: white;
font-size: 8em;
font-weight: 500;
max-width: calc(100% - 160px);
opacity: 0.1;
overflow: hidden;
position: relative;
text-transform: uppercase;
white-space: nowrap;
}
.crypto-list-item-background-image {
background-color: gray(20);
border-radius: 1000px;
height: 120px;
opacity: 0.25;
position: absolute;
right: 20px;
width: 120px;
z-index: 2;
}
}
.crypto-list-item-content {
align-items: center;
backdrop-filter: blur(5px);
background-color: rgba(white, 0.05);
border-bottom-right-radius: 1000px;
border-top-right-radius: 1000px;
box-shadow: $shadow1;
display: flex;
gap: 20px;
padding: 40px;
position: relative;
transition: all 0.25s;
white-space: nowrap;
width: calc(100% - 160px);
z-index: 2;
h1 {
color: white;
font-size: 1em;
}
.crypto-list-item-image {
border-radius: 1000px;
height: 60px;
width: 60px;
}
.crypto-list-item-rank {
font-size: 1.25em;
font-weight: 500;
text-align: right;
width: 40px;
}
.crypto-list-item-details {
display: flex;
flex-direction: column;
gap: 2px;
text-align: left;
width: 240px;
.crypto-list-item-name {
font-size: 1.75em;
}
}
}
}
}
#crypto-details {
animation: fadeInFromLeft 0.25s ease-in;
height: 100vh;
left: 0px;
position: fixed;
top: 0px;
width: 100vw;
z-index: 1;
&.transitioning {
#crypto-details-content {
#crypto-fields {
opacity: 0;
transform: translateX(-20px);
}
#crypto-details-background-image {
opacity: 0;
transform: translateY(-20px);
}
#crypto-details-symbol {
opacity: 0;
transform: translateY(20px);
}
}
}
#crypto-details-content {
border-left: 1px solid rgba(white, 0.05);
height: 100%;
margin-left: 0px;
min-width: 300px;
position: relative;
transition: all 0.25s;
width: calc(100% - 1px);
#crypto-fields {
display: inline-flex;
flex-direction: column;
gap: 10px;
padding: 10px;
padding-left: 40px;
padding-top: 20px;
position: relative;
transition: all 0.25s;
z-index: 3;
.crypto-field {
white-space: nowrap;
&.positive {
.crypto-field-value {
color: $green;
}
}
&.negative {
.crypto-field-value {
color: $red;
}
}
.crypto-field-value {
color: white;
font-size: 1.5em;
}
.crypto-field-label {
color: rgba(white, 0.5);
font-size: 0.8em;
font-weight: 500;
text-transform: uppercase;
}
}
}
#crypto-price-chart-wrapper {
height: 100%;
left: 0px;
position: absolute;
top: 0px;
width: 100%;
z-index: 2;
#crypto-price-chart-loading-spinner {
bottom: 0px;
height: 100px;
left: 0px;
position: absolute;
width: 100px;
z-index: 2;
.loading-spinner {
i {
font-size: 2em;
}
}
}
#crypto-price-chart {
height: 100%;
position: relative;
width: 100%;
}
}
#crypto-details-symbol {
bottom: 0px;
color: white;
filter: blur(5px);
font-size: 20em;
font-weight: 500;
left: 0px;
margin: 20px;
opacity: 0.15;
position: absolute;
text-transform: uppercase;
transition: all 0.25s;
z-index: 1;
}
}
}
#crypto-list-toggle-button {
background-color: gray(30);
border: none;
border-radius: 100px;
box-shadow: $shadow1;
bottom: 0px;
cursor: pointer;
margin: 20px;
padding: 20px;
position: absolute;
right: 0px;
z-index: 3;
&:hover,
&:focus {
i {
color: $blue;
}
}
i {
color: white;
font-size: 2em;
height: 30px;
line-height: 30px;
text-align: center;
width: 30px;
}
}
#youtube-link {
align-items: center;
border-radius: 6px;
display: flex;
gap: 10px;
margin: 10px;
padding: 10px;
position: absolute;
right: 0px;
text-decoration: none;
top: 0px;
z-index: 1;
&:hover,
&:focus {
backdrop-filter: blur(5px);
background-color: rgba(white, 0.1);
}
i {
color: $red;
font-size: 1.5em;
}
h1 {
color: white;
font-size: 1.25em;
}
}
}
@media (max-width: 800px) {
#app {
&.list-toggled {
#crypto-list {
width: calc(100% - 10px);
}
#crypto-details {
#crypto-details-content {
margin-left: 100%;
width: calc(100% - 1px);
}
}
}
#crypto-details {
#crypto-details-content {
#crypto-fields {
padding-left: 20px;
}
#crypto-details-symbol {
font-size: 10em;
}
}
}
}
}
@media (max-width: 500px) {
#app {
#crypto-list {
.crypto-list-item {
&:hover,
&:focus,
&.selected {
.crypto-list-item-content {
width: calc(100% - 80px);
}
}
.crypto-list-item-background {
.crypto-list-item-symbol {
font-size: 6em;
}
.crypto-list-item-background-image {
height: 80px;
width: 80px;
}
}
.crypto-list-item-content {
padding: 20px;
width: calc(100% - 120px);
.crypto-list-item-details {
.crypto-list-item-name {
font-size: 1.25em;
}
}
}
}
}
#youtube-link {
i {
font-size: 1.25em;
}
h1 {
font-size: 1em;
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment