FEAT Infinite scroll works. Sort-of.
I've implemented a Cards/Card interface, but it's heavy and direct, with absolutely not a care in the world about how much memory your browser can give me. It only moves forward in time, and accumulates as it goes. The React is brutal and heavy, but for a prototype, eh... it's a prototype. I'm a firm believer in prototypes. Criticism: "Cards" is doing too much, and not enough. It would make more sense to have a moving window, showing no more than what it would take to show the user what is expected at that moment, with the ability to load high and low, keeping only what the user is _looking at_ in the DOM tree. Proposal: Cards needs a CardCollection proxy object, which the Cards view object (personal convention: TSX files are named for what they show, semantically; TS files may be suffixed with "Model" or "Collection", a'la Backbone, to seperate them from the views, if needed; "Model" objects are very rarely needed) taps it as the user moves up and down; the IntersectionObserver tool would be pretty good for this; and if the proxy actually _cached_ the fields it had already seen, it wouldn't be slow at all. The proxy object could also support the search feature by keeping two collections: one of the "whole" set, and one of the "last search" set; the proxy could swap between them as needed. Enhancement: Well, it could use a "zoom" feature with a nice overlay. It might even be lovely to have a second page with all the things I haven't shown, like attributes, subtype, rarity (not, not the pony), or keywords. Enhancement: It is possible to search based on set, attributes, and so forth, so a sidebar with those as filters would also be kinda cool.
This commit is contained in:
parent
58200aa79b
commit
f71d439a22
|
@ -0,0 +1,29 @@
|
|||
|
||||
.PHONY: all
|
||||
all: help
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@M=$$(perl -ne 'm/((\w|-)*):.*##/ && print length($$1)."\n"' Makefile | \
|
||||
sort -nr | head -1) && \
|
||||
perl -ne "m/^((\w|-)*):.*##\s*(.*)/ && print(sprintf(\"%s: %s\t%s\n\", \$$1, \" \"x($$M-length(\$$1)), \$$3))" Makefile
|
||||
|
||||
./node_modules/.yarn-integrity: package.json
|
||||
yarn install
|
||||
|
||||
./build/asset-manifest.json: ./node_modules/.yarn-integrity src/App.tsx src/cards/Cards.tsx src/cards/Card.tsx
|
||||
yarn build
|
||||
|
||||
.PHONY: install
|
||||
install: ./node_modules/.yarn-integrity ## Install any yarn packages necessary
|
||||
|
||||
.PHONY: build
|
||||
build: ./build/asset-manifest.json ## Build the package if it is out of date
|
||||
|
||||
.PHONY: serve
|
||||
serve: build/asset-manifest.json ## Do everything necessary to get up and running
|
||||
yarn run http-server build/
|
||||
|
||||
.PHONY: dev
|
||||
dev: ./node_modules/.yarn-integrity ## Run the server in "dev" mode, with hot reload
|
||||
yarn start
|
24
README.md
24
README.md
|
@ -9,16 +9,16 @@ set out in the Code Challenge are:
|
|||
|
||||
### Requirements Checklist
|
||||
|
||||
- Show results in a card grid format with the image prominently displayed.
|
||||
- Each card displays: Image, Name, Text, Set Name, and Type. Additional fields are optional.
|
||||
- Display a loading indicator when communicating with the API.
|
||||
- Use a responsive design that accommod_es, at minimum, desktop and mobile.
|
||||
- Initially, fetch and display the first 20 results returned by the API.
|
||||
- As the user scrolls down the page, load and append additional cards using "infinite scroll."
|
||||
- Retrieve additional pages of results as-needed but do not load more than 20 cards with
|
||||
- ✓ Show results in a card grid format with the image prominently displayed.
|
||||
- ✓ Each card displays: Image, Name, Text, Set Name, and Type. Additional fields are optional.
|
||||
- ✓ Display a loading indicator when communicating with the API.
|
||||
- ✓ Use a responsive design that accommod_es, at minimum, desktop and mobile.
|
||||
- ✓ Initially, fetch and display the first 20 results returned by the API.
|
||||
- ✓ As the user scrolls down the page, load and append additional cards using "infinite scroll."
|
||||
- ✓ Retrieve additional pages of results as-needed but do not load more than 20 cards with
|
||||
each request.
|
||||
- Allow the user to search for cards by Name.
|
||||
- Use modern open-source web technologies to implement your solution (React, Backbone,
|
||||
- ✓ Use modern open-source web technologies to implement your solution (React, Backbone,
|
||||
Angular, Vue, Underscore, etc.).
|
||||
- Provide instructions for prerequisites, installation, and application setup and build in a
|
||||
README file.
|
||||
|
@ -45,14 +45,6 @@ by an independent developer; it is not affiliated with Highspot, or with
|
|||
the intellectual property owners of Elder Scrolls Legends. Please help
|
||||
us use it responsibly.
|
||||
|
||||
### UNSTATED Requirement
|
||||
|
||||
- Does the app work well on both mobile and desktop platforms?
|
||||
|
||||
Although this requirement is not stated in the requirements checklist,
|
||||
it is earlier stated that "A successful submission will function on
|
||||
modern desktop and mobile browsers in a visually appealing way."
|
||||
|
||||
## Initial impressions
|
||||
|
||||
This is a single-page application. The 'search' is more of a filter,
|
||||
|
|
|
@ -14,13 +14,13 @@
|
|||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-scripts": "3.4.3",
|
||||
"sass": "^1.26.10"
|
||||
"sass": "^1.26.10",
|
||||
"typescript": "^3.9.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
"test": "react-scripts test"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
|
@ -38,6 +38,6 @@
|
|||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^4.0.2"
|
||||
"http-server": "^0.12.3"
|
||||
}
|
||||
}
|
||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,93 @@
|
|||
Copyright (c) 2012, Brian J. Bonislawsky DBA Astigmatic (AOETI) (astigma@astigmatic.com), with Reserved Font Names "Marcellus"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
89
src/App.scss
89
src/App.scss
|
@ -1,14 +1,95 @@
|
|||
@import-normalize;
|
||||
@import "styles/_normalize";
|
||||
@import "styles/_fonts";
|
||||
@import "styles/_typography";
|
||||
@import "styles/_loading";
|
||||
|
||||
body {
|
||||
background: repeat url("images/elder_scrolling_bg.png") #0c0c0c;
|
||||
color: #f8f8f8;
|
||||
font-family: "Marcellus";
|
||||
}
|
||||
|
||||
body * {
|
||||
font-family: "Marcellus";
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
margin: 0 auto 0 auto;
|
||||
max-width: 1200px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-height: 64px;
|
||||
|
||||
.left, right {
|
||||
height: 100%;
|
||||
top: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.left {
|
||||
margin-left: 15px;
|
||||
|
||||
h1 {
|
||||
text-shadow: 0 0 2px rgba(252, 252, 252, 0.8);
|
||||
font-weight: 400;
|
||||
font-size: 4.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cardbox {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
font-size: 1.4rem;
|
||||
justify-content: space-between;
|
||||
align-content: space-between;
|
||||
|
||||
.card {
|
||||
width: 192px;
|
||||
border: 1px solid #707c80;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem;
|
||||
margin: 0.5rem;
|
||||
|
||||
img {
|
||||
height: 290px;
|
||||
width: 175px;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lds-spinner-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
#page-bottom {
|
||||
min-height: 4rem;
|
||||
}
|
||||
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
|
|
18
src/App.tsx
18
src/App.tsx
|
@ -5,14 +5,16 @@ import { Cards } from "./cards/Cards";
|
|||
function App() {
|
||||
return (
|
||||
<div className="elder-scrolling">
|
||||
<header>
|
||||
<div className="left">
|
||||
<h1>The Elder Scrolls</h1>
|
||||
</div>
|
||||
<div className="right">
|
||||
<h2>Search goes here</h2>
|
||||
</div>
|
||||
</header>
|
||||
<div className="container">
|
||||
<header>
|
||||
<div className="left">
|
||||
<h1>The Elder Scrolls</h1>
|
||||
</div>
|
||||
<div className="right">
|
||||
<h2>Search goes here</h2>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
<section>
|
||||
<Cards />
|
||||
</section>
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
import * as React from "react";
|
||||
import { CardProps } from "./types";
|
||||
|
||||
export const Card = ({ card }: { card: CardProps }) => {
|
||||
return (
|
||||
<div className="card" key="{idx}">
|
||||
<img src={card.imageUrl} alt={card.name} />
|
||||
<div className="content">
|
||||
<h4>{card.name}</h4>
|
||||
<p className="card-text">{card.text}</p>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Set Name:</strong>
|
||||
</td>
|
||||
<td>{card.set.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Type:</strong>
|
||||
</td>
|
||||
<td>{card.type}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,3 +1,69 @@
|
|||
import * as React from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Loading } from "./Loading";
|
||||
import { Card } from "./Card";
|
||||
import { useInfiniteScroll } from "./infiniteScroll";
|
||||
import {
|
||||
CardProps,
|
||||
CardRequestProps,
|
||||
CardState,
|
||||
CardStateHandler
|
||||
} from "./types";
|
||||
|
||||
export const Cards = () => <div>Cards go here</div>;
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const firstUrl = `https://api.elderscrollslegends.io/v1/cards?pageSize=${PAGE_SIZE}&page=1`;
|
||||
|
||||
const emptyCards: CardState = {
|
||||
cards: [],
|
||||
next: firstUrl
|
||||
};
|
||||
|
||||
export const Cards = () => {
|
||||
const [cards, setCards]: CardStateHandler = useState(emptyCards);
|
||||
const [cardPage, setCardPage]: [string | null, Function] = useState(null);
|
||||
const [loading, setLoading]: [boolean, Function] = useState(false);
|
||||
|
||||
const getNextPage = () => {
|
||||
setCardPage(cards.next);
|
||||
setLoading(true);
|
||||
};
|
||||
|
||||
let pageBottomRef = useRef(null);
|
||||
useInfiniteScroll(pageBottomRef, getNextPage);
|
||||
|
||||
const fetchMoreCards = async () => {
|
||||
if (cards.next === null || cardPage === null) {
|
||||
return cards;
|
||||
}
|
||||
|
||||
const request = new Request(cardPage!);
|
||||
const response = await fetch(request);
|
||||
const data: CardRequestProps = await response.json();
|
||||
setCards({
|
||||
cards: cards.cards.concat(data.cards),
|
||||
next: data._links && data._links.next ? data._links.next : null
|
||||
});
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (cards.next && cardPage === null) {
|
||||
getNextPage();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchMoreCards();
|
||||
}, [cardPage]);
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="cardbox">
|
||||
{cards.cards.map((card: CardProps, count: number) => (
|
||||
<Card card={card} key={count} />
|
||||
))}
|
||||
</div>
|
||||
{loading && <Loading />}
|
||||
<div id="page-bottom" ref={pageBottomRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
import * as React from "react";
|
||||
|
||||
export const Loading = () => (
|
||||
<div className="lds-spinner-container">
|
||||
<div className="lds-spinner">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
|
@ -0,0 +1,33 @@
|
|||
import * as React from "react";
|
||||
import { useEffect, useCallback } from "react";
|
||||
|
||||
// From the Smashing Magazine article,
|
||||
// https://www.smashingmagazine.com/2020/03/infinite-scroll-lazy-image-loading-react/
|
||||
|
||||
// As this is new to me, I'm going to try to explain it;
|
||||
// IntersectionObserver takes a callback to be called when it's
|
||||
// triggred. It's trigged when the node it's been asked to observe
|
||||
// changes its relationship with another object or, without a
|
||||
// specified object, with the viewport. When the `intersectionRatio`
|
||||
// exceed zero, it is _intersecting_ the viewport, i.e. it is now
|
||||
// visible.
|
||||
|
||||
// useCallback is a memoizer; it only updates the contents of the
|
||||
// function its wrapped if the included function changes, which
|
||||
// it shouldn't.
|
||||
|
||||
export const useInfiniteScroll = (scrollRef: React.RefObject<HTMLElement>, stateUpdate: Function) => {
|
||||
const scrollObserver = (node: HTMLElement) => {
|
||||
new IntersectionObserver(entries => {
|
||||
if (entries.some(en => en.intersectionRatio > 0)) {
|
||||
stateUpdate();
|
||||
}
|
||||
}).observe(node);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollObserver(scrollRef.current);
|
||||
}
|
||||
}, [scrollObserver, scrollRef]);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
interface SetProps {
|
||||
id: string;
|
||||
name: string;
|
||||
_self: string;
|
||||
}
|
||||
|
||||
export interface CardProps {
|
||||
name: string;
|
||||
rarity: string;
|
||||
type: string;
|
||||
cost: number;
|
||||
set: SetProps;
|
||||
collectible: boolean;
|
||||
soulSummon: number;
|
||||
soulTrap: number;
|
||||
text: string;
|
||||
attributes: string[];
|
||||
keywords: string[];
|
||||
unique: boolean;
|
||||
imageUrl: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface LinkProps {
|
||||
next?: string;
|
||||
prev?: string;
|
||||
}
|
||||
|
||||
export interface CardRequestProps {
|
||||
cards: CardProps[];
|
||||
_links: LinkProps;
|
||||
_pageSize: number;
|
||||
_totalCount: number;
|
||||
}
|
||||
|
||||
|
||||
export interface CardState {
|
||||
cards: CardProps[];
|
||||
next: string | null;
|
||||
}
|
||||
|
||||
export type CardStateHandler = [CardState, Function];
|
||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
@ -1,45 +0,0 @@
|
|||
$white: #ffffff;
|
||||
$cream: #f8f8f8;
|
||||
|
||||
$raven: #373d3f; // For body text
|
||||
$jet: #131516; // For header text
|
||||
$stone: #707c80; // For meta text
|
||||
$pearl: #dadedf; // Meta elsewhere
|
||||
|
||||
$menufade: rgba(255, 255, 255, 0.2);
|
||||
$whiteblur: rgba(252, 252, 252, 0.8);
|
||||
$shadow: rgba(1, 1, 1, 0.15);
|
||||
$shadowjet: rgba(19, 21, 20, 0.15);
|
||||
$darkshadow: rgba(0, 0, 0, 0.1);
|
||||
$clearjet: rgba(19, 21, 20, 0.1);
|
||||
$lightjet: rgba(19, 21, 20, 0.6);
|
||||
|
||||
$bigorange: #ef6c00;
|
||||
|
||||
$color-background: #fcfcfc;
|
||||
$color-text: #2C3531;
|
||||
|
||||
|
||||
$color-primary-0: #EF6C00; // Main Primary color */
|
||||
$color-primary-1: #FF9E4E;
|
||||
$color-primary-2: #FF8A29;
|
||||
$color-primary-3: #C15800;
|
||||
$color-primary-4: #9D4700;
|
||||
|
||||
$color-secondary-1-0: #EF9F00; // Main Secondary color (1) */
|
||||
$color-secondary-1-1: #FFC44E;
|
||||
$color-secondary-1-2: #FFB829;
|
||||
$color-secondary-1-3: #C18100;
|
||||
$color-secondary-1-4: #9D6800;
|
||||
|
||||
$color-secondary-2-0: #113CA0; // Main Secondary color (2) */
|
||||
$color-secondary-2-1: #4669BA;
|
||||
$color-secondary-2-2: #2B53AF;
|
||||
$color-secondary-2-3: #0D3082;
|
||||
$color-secondary-2-4: #092669;
|
||||
|
||||
$color-complement-0: #008F8F; // Main Complement color */
|
||||
$color-complement-1: #34ACAC;
|
||||
$color-complement-2: #199E9E;
|
||||
$color-complement-3: #007474;
|
||||
$color-complement-4: #005E5E;
|
|
@ -1,59 +1,11 @@
|
|||
$unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
|
||||
$josefinfonts:
|
||||
100 Thin normal,
|
||||
100 ThinItalic italic,
|
||||
200 ExtraLight normal,
|
||||
200 ExtraLight italic,
|
||||
300 Light normal,
|
||||
300 LightItalic italic,
|
||||
400 Regular normal,
|
||||
400 RegularItalic italic,
|
||||
500 Medium normal,
|
||||
500 MediumItalic italic,
|
||||
600 SemiBold normal,
|
||||
600 SemiBoldItalic italic,
|
||||
700 Bold normal,
|
||||
700 Bold italic;
|
||||
|
||||
@each $weight, $filename, $style in $josefinfonts {
|
||||
@font-face {
|
||||
font-family: 'JosefinSans';
|
||||
font-style: $style;
|
||||
font-weight: $weight;
|
||||
src: url("./fonts/JosefinSans-#{$filename}.woff2") format("woff"),
|
||||
url("./fonts/JosefinSans-#{$filename}.ttf") format("truetype");
|
||||
@font-face {
|
||||
font-family: 'Marcellus';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url("/fonts/Marcellus-Regular.woff2") format("woff"),
|
||||
url("/fonts/Marcellus-Regular.ttf") format("truetype");
|
||||
unicode-range: $unicode-range;
|
||||
}
|
||||
}
|
||||
|
||||
$cardofonts:
|
||||
400 Regular normal,
|
||||
400 Italic italic,
|
||||
700 Bold normal;
|
||||
|
||||
@each $weight, $filename, $style in $cardofonts {
|
||||
@font-face {
|
||||
font-family: 'Cardo';
|
||||
font-style: $style;
|
||||
font-weight: $weight;
|
||||
src: url("./fonts/Cardo-#{$filename}.woff2") format("woff"),
|
||||
url("./fonts/Cardo-#{$filename}.ttf") format("truetype");
|
||||
unicode-range: $unicode-range;
|
||||
}
|
||||
}
|
||||
|
||||
$firafonts:
|
||||
400 Regular normal,;
|
||||
|
||||
@each $weight, $filename, $style in $firafonts {
|
||||
@font-face {
|
||||
font-family: 'FiraCode';
|
||||
font-style: $style;
|
||||
font-weight: $weight;
|
||||
src: url("./fonts/FiraCode-#{$filename}.woff2") format("woff"),
|
||||
url("./fonts/Fira-#{$filename}.ttf") format("truetype");
|
||||
unicode-range: $unicode-range;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
/* https://loading.io/css/ */
|
||||
|
||||
.lds-spinner {
|
||||
color: #373d3f;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
.lds-spinner div {
|
||||
transform-origin: 40px 40px;
|
||||
animation: lds-spinner 1.2s linear infinite;
|
||||
}
|
||||
.lds-spinner div:after {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 37px;
|
||||
width: 6px;
|
||||
height: 18px;
|
||||
border-radius: 20%;
|
||||
background: #fff;
|
||||
}
|
||||
.lds-spinner div:nth-child(1) {
|
||||
transform: rotate(0deg);
|
||||
animation-delay: -1.1s;
|
||||
}
|
||||
.lds-spinner div:nth-child(2) {
|
||||
transform: rotate(30deg);
|
||||
animation-delay: -1s;
|
||||
}
|
||||
.lds-spinner div:nth-child(3) {
|
||||
transform: rotate(60deg);
|
||||
animation-delay: -0.9s;
|
||||
}
|
||||
.lds-spinner div:nth-child(4) {
|
||||
transform: rotate(90deg);
|
||||
animation-delay: -0.8s;
|
||||
}
|
||||
.lds-spinner div:nth-child(5) {
|
||||
transform: rotate(120deg);
|
||||
animation-delay: -0.7s;
|
||||
}
|
||||
.lds-spinner div:nth-child(6) {
|
||||
transform: rotate(150deg);
|
||||
animation-delay: -0.6s;
|
||||
}
|
||||
.lds-spinner div:nth-child(7) {
|
||||
transform: rotate(180deg);
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
.lds-spinner div:nth-child(8) {
|
||||
transform: rotate(210deg);
|
||||
animation-delay: -0.4s;
|
||||
}
|
||||
.lds-spinner div:nth-child(9) {
|
||||
transform: rotate(240deg);
|
||||
animation-delay: -0.3s;
|
||||
}
|
||||
.lds-spinner div:nth-child(10) {
|
||||
transform: rotate(270deg);
|
||||
animation-delay: -0.2s;
|
||||
}
|
||||
.lds-spinner div:nth-child(11) {
|
||||
transform: rotate(300deg);
|
||||
animation-delay: -0.1s;
|
||||
}
|
||||
.lds-spinner div:nth-child(12) {
|
||||
transform: rotate(330deg);
|
||||
animation-delay: 0s;
|
||||
}
|
||||
@keyframes lds-spinner {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,428 @@
|
|||
/* normalize.css v4.0.0 | MIT License | github.com/necolas/normalize.css */
|
||||
html {
|
||||
font-family: sans-serif;
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
article,
|
||||
aside,
|
||||
details,
|
||||
figcaption,
|
||||
figure,
|
||||
footer,
|
||||
header,
|
||||
main,
|
||||
menu,
|
||||
nav,
|
||||
section,
|
||||
summary {
|
||||
display: block;
|
||||
}
|
||||
|
||||
audio,
|
||||
canvas,
|
||||
progress,
|
||||
video {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
audio:not([controls]) {
|
||||
display: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
template,
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
a:active,
|
||||
a:hover {
|
||||
outline-width: 0;
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
border-bottom: none;
|
||||
text-decoration: underline;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
dfn {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
margin: 0.67em 0;
|
||||
}
|
||||
|
||||
mark {
|
||||
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
img {
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
svg:not(:root) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
code,
|
||||
kbd,
|
||||
pre,
|
||||
samp {
|
||||
font-family: monospace, monospace;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 1em 40px;
|
||||
}
|
||||
|
||||
hr {
|
||||
-webkit-box-sizing: content-box;
|
||||
box-sizing: content-box;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
optgroup {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
button,
|
||||
[type="button"],
|
||||
[type="reset"],
|
||||
[type="submit"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[disabled] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button,
|
||||
html [type="button"],
|
||||
[type="reset"],
|
||||
[type="submit"] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
button::-moz-focus-inner,
|
||||
input::-moz-focus-inner {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
button:-moz-focusring,
|
||||
input:-moz-focusring {
|
||||
outline: 1px dotted ButtonText;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 1px solid #c0c0c0;
|
||||
margin: 0 2px;
|
||||
padding: 0.35em 0.625em 0.75em;
|
||||
}
|
||||
|
||||
legend {
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
color: inherit;
|
||||
display: table;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
textarea {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
[type="checkbox"],
|
||||
[type="radio"] {
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[type="number"]::-webkit-inner-spin-button,
|
||||
[type="number"]::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[type="search"] {
|
||||
-webkit-appearance: textfield;
|
||||
}
|
||||
|
||||
[type="search"]::-webkit-search-cancel-button,
|
||||
[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
-webkit-box-sizing: inherit;
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
@-ms-viewport {
|
||||
width: device-width;
|
||||
}
|
||||
|
||||
|
||||
|
||||
[tabindex="-1"]:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 0;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
abbr[title],
|
||||
abbr[data-original-title] {
|
||||
cursor: help;
|
||||
border-bottom: 1px dotted #818a91;
|
||||
}
|
||||
|
||||
address {
|
||||
margin-bottom: 1rem;
|
||||
font-style: normal;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
dl {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ul ul,
|
||||
ol ul,
|
||||
ul ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-bottom: .5rem;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:focus, a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
a:not([href]):not([tabindex]) {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:not([href]):not([tabindex]):focus, a:not([href]):not([tabindex]):hover {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:not([href]):not([tabindex]):focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
img {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
[role="button"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a,
|
||||
area,
|
||||
button,
|
||||
[role="button"],
|
||||
input,
|
||||
label,
|
||||
select,
|
||||
summary,
|
||||
textarea {
|
||||
-ms-touch-action: manipulation;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
color: #818a91;
|
||||
text-align: left;
|
||||
caption-side: bottom;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
button:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
textarea {
|
||||
margin: 0;
|
||||
line-height: inherit;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
input[type="radio"]:disabled,
|
||||
input[type="checkbox"]:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input[type="date"],
|
||||
input[type="time"],
|
||||
input[type="datetime-local"],
|
||||
input[type="month"] {
|
||||
-webkit-appearance: listbox;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin-bottom: .5rem;
|
||||
font-size: 1.5rem;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
input[type="search"] {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
output {
|
||||
display: inline-block;
|
||||
}
|
|
@ -1,18 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"module": "esnext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
|
|
Loading…
Reference in New Issue