`/universes/${id}/skins/${s.id}`}
14 | collectionName={name}
15 | collectionIcon={}
16 | collectionPage="/universes/[id]"
17 | {...{ skin, prev, next }}
18 | />
19 | );
20 | }
21 |
22 | export async function getStaticProps(ctx) {
23 | const { universeId, skinId } = ctx.params;
24 |
25 | const {
26 | universes,
27 | champions,
28 | skinlines: allSkinlines,
29 | skins: allSkins,
30 | } = store.patch;
31 | const universe = universes.find((u) => u.id.toString() === universeId);
32 | if (!universe)
33 | return {
34 | notFound: true,
35 | };
36 |
37 | const skins = allSkinlines
38 | .filter((l) => universe.skinSets.includes(l.id))
39 | .map((l) => allSkinlines.find((line) => line.id === l.id))
40 | .sort((a, b) => (a.name > b.name ? 1 : -1))
41 | .map((l) => skinlineSkins(l.id, allSkins, champions))
42 | .flat();
43 |
44 | const currentIdx = skins.findIndex((s) => s.id.toString() === skinId);
45 | if (currentIdx === -1)
46 | return {
47 | notFound: true,
48 | };
49 |
50 | const { skin, prev, next } = await prepareCollection(skins, currentIdx);
51 |
52 | return {
53 | props: {
54 | name: universe.name,
55 | id: universe.id,
56 | skin,
57 | prev,
58 | next,
59 | patch: store.patch.fullVersionString,
60 | },
61 | };
62 | }
63 |
64 | export async function getStaticPaths() {
65 | let paths = [];
66 | if (process.env.NODE_ENV === "production") {
67 | const { universes, skins } = store.patch;
68 | paths = Object.values(skins)
69 | .map((skin) =>
70 | (skin.skinLines ?? [])
71 | .map((skinline) => {
72 | const u = universes.find((u) => u.skinSets.includes(skinline.id));
73 | if (!u) return null;
74 | return {
75 | params: {
76 | universeId: u.id.toString(),
77 | skinId: skin.id.toString(),
78 | },
79 | };
80 | })
81 | .filter((a) => a)
82 | )
83 | .flat();
84 | }
85 |
86 | return {
87 | paths,
88 | fallback: "blocking",
89 | };
90 | }
91 |
--------------------------------------------------------------------------------
/styles/static.module.scss:
--------------------------------------------------------------------------------
1 | .main {
2 | width: 1000px;
3 | left: 0;
4 | right: 0;
5 | margin: auto;
6 | box-sizing: border-box;
7 | color: rgb(165, 178, 196);
8 |
9 | a[href] {
10 | text-decoration: underline;
11 | }
12 |
13 | li {
14 | line-height: 1.2;
15 | margin-bottom: 0.4em;
16 | }
17 |
18 | h1 {
19 | font-size: 5em;
20 | letter-spacing: -0.05em;
21 | color: white;
22 | margin-bottom: 0.5em;
23 | margin-top: 1em;
24 | line-height: 1.1;
25 | }
26 |
27 | h2 {
28 | font-size: 2em;
29 | margin: 0;
30 | font-weight: 500;
31 |
32 | a[href] {
33 | text-decoration: none;
34 | position: relative;
35 |
36 | &::after {
37 | content: "🔗";
38 | opacity: 0.3;
39 | padding-left: 0.2em;
40 | transition: opacity 200ms;
41 | }
42 |
43 | &:hover::after {
44 | opacity: 0.7;
45 | }
46 | }
47 | }
48 |
49 | h6 {
50 | font-weight: 400;
51 | letter-spacing: -0.03em;
52 | font-size: 1.2em;
53 | opacity: 1;
54 | color: rgb(96, 112, 134);
55 | margin: 0;
56 |
57 | a[href] {
58 | text-decoration: none;
59 | }
60 | }
61 |
62 | code {
63 | background: rgba(121, 163, 207, 0.1);
64 | white-space: nowrap;
65 | padding: 0 3px;
66 | font-family: inherit;
67 | border-radius: 2px;
68 | }
69 |
70 | table {
71 | border-collapse: collapse;
72 | width: 100%;
73 |
74 | td,
75 | th {
76 | border: 1px solid rgb(31, 39, 48);
77 | padding: 6px 8px;
78 | text-align: left;
79 | }
80 |
81 | th {
82 | background: rgb(18, 23, 29);
83 | }
84 | }
85 |
86 | header {
87 | display: flex;
88 | justify-content: space-between;
89 | align-items: center;
90 | margin: 1em 0;
91 | }
92 |
93 | hr {
94 | border: 0;
95 | border-top: 1px dashed rgb(165, 178, 196);
96 | opacity: 0.3;
97 | }
98 |
99 | @media screen and (max-width: 1200px) {
100 | h1 {
101 | margin-top: 0.2em;
102 | }
103 | }
104 |
105 | @media screen and (max-width: 1048px) {
106 | width: 100%;
107 | padding: 0 24px;
108 | }
109 |
110 | @media screen and (max-width: 850px) {
111 | font-size: 15px;
112 | padding: 16px;
113 |
114 | h1 {
115 | margin: 0em 0 0.2em;
116 | font-size: 4em;
117 | }
118 |
119 | header {
120 | flex-direction: column;
121 | align-items: flex-start;
122 | gap: 0.4em;
123 | }
124 |
125 | h6 {
126 | margin-top: -0.2em;
127 | }
128 | }
129 |
130 | @media screen and (max-width: 650px) {
131 | line-height: 1.4;
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/pages/_document.js:
--------------------------------------------------------------------------------
1 | import BaseDocument, { Head, Html, Main, NextScript } from "next/document";
2 |
3 | class Document extends BaseDocument {
4 | render() {
5 | return (
6 |
7 |
8 |
9 |
10 |
15 |
20 |
25 |
30 |
35 |
39 |
40 |
41 |
46 | {process.env.NEXT_PUBLIC_VERCEL_ENV === "production" && (
47 | <>
48 |
52 |
65 | {/* */}
70 | >
71 | )}
72 |
73 |
74 |
75 |
76 |
77 |
78 | );
79 | }
80 | }
81 |
82 | export default Document;
83 |
--------------------------------------------------------------------------------
/components/footer/index.js:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 | import styles from "./styles.module.scss";
3 | import { useProps } from "../../data/contexts";
4 | import getConfig from "next/config";
5 |
6 | const { publicRuntimeConfig } = getConfig();
7 |
8 | export function Footer({ flat }) {
9 | const { patch } = useProps();
10 | return (
11 |
89 | );
90 | }
91 |
92 | export function FooterContainer({ children }) {
93 | return {children}
;
94 | }
95 |
--------------------------------------------------------------------------------
/pages/universes/index.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import Head from "next/head";
3 | import { useProps } from "../../data/contexts";
4 | import styles from "../../styles/index.module.scss";
5 | import Link from "next/link";
6 | import { store } from "../../data/store";
7 | import { Nav } from "../../components/nav";
8 | import { Layout } from "../../components";
9 | import {
10 | makeDescription,
11 | makeTitle,
12 | useArrowNavigation,
13 | } from "../../data/helpers";
14 | import { prepareAdditions } from "../../components/new-additions/helpers";
15 |
16 | function UniversesList() {
17 | const { universes, skinlines } = useProps();
18 | return (
19 |
20 | {universes.map((u) => {
21 | const skinSets = u.skinSets
22 | .map((id) => ({
23 | id,
24 | name: skinlines.find((s) => s.id === id).name,
25 | }))
26 | .sort((a, b) => (a.name > b.name ? 1 : -1));
27 | return (
28 |
29 |
34 |
{u.name}
35 |
36 | {(skinSets.length > 1 || skinSets[0].name !== u.name) && (
37 |
38 | {skinSets.map(({ name, id }) => (
39 | -
40 |
45 | {name}
46 |
47 |
48 | ))}
49 |
50 | )}
51 |
52 | );
53 | })}
54 |
55 | );
56 | }
57 |
58 | export default function Index() {
59 | useEffect(() => {
60 | localStorage.lastIndex = "/universes";
61 | }, []);
62 |
63 | const handlers = useArrowNavigation("/", "/skinlines");
64 |
65 | const { universes } = useProps();
66 |
67 | return (
68 | <>
69 |
70 | {makeTitle("Universes")}
71 | {makeDescription(
72 | `Browse through League of Legends skins from the comfort of your browser. Take a look at these ${universes.length} universes!`
73 | )}
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | >
82 | );
83 | }
84 |
85 | Index.getLayout = (page) => {page};
86 |
87 | export async function getStaticProps() {
88 | const { universes, skinlines } = store.patch;
89 |
90 | return {
91 | props: {
92 | universes: universes.filter((u) => u.skinSets.length),
93 | skinlines,
94 | patch: store.patch.fullVersionString,
95 | added: await prepareAdditions(),
96 | },
97 | };
98 | }
99 |
--------------------------------------------------------------------------------
/styles/collection.module.scss:
--------------------------------------------------------------------------------
1 | .background {
2 | position: fixed;
3 | top: 0;
4 | left: 0;
5 | z-index: -2;
6 | width: 100vw;
7 | height: 100vh;
8 | background-size: cover;
9 | filter: blur(4px);
10 | opacity: 0.2;
11 |
12 | &::after {
13 | content: "";
14 | position: fixed;
15 | top: 0;
16 | left: 0;
17 | width: 100vw;
18 | height: 100vh;
19 | background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), transparent);
20 | }
21 | }
22 |
23 | .container {
24 | position: relative;
25 | z-index: 1;
26 | }
27 |
28 | .title {
29 | letter-spacing: -0.04em;
30 | font-size: 5em;
31 | padding-left: 32px;
32 |
33 | text-align: left;
34 | margin: 32px 0 24px;
35 | line-height: 1;
36 | color: white;
37 | }
38 |
39 | .parents,
40 | .subtitle {
41 | margin: 32px 0 -24px;
42 | padding-left: 32px;
43 | display: flex;
44 | align-items: center;
45 | gap: 0.4em;
46 | opacity: 0.5;
47 | font-weight: 300;
48 | color: rgb(165, 178, 196);
49 | }
50 |
51 | .parents {
52 | line-height: 1.1;
53 | a {
54 | display: flex;
55 | align-items: center;
56 | gap: 0.3em;
57 | }
58 | svg {
59 | height: 1.2em;
60 | }
61 | padding-bottom: 18px;
62 | margin: 0;
63 | gap: 0.3em;
64 | align-items: center;
65 | }
66 |
67 | .description {
68 | padding: 0 32px;
69 | color: rgb(165, 178, 196);
70 | filter: drop-shadow(0 0 4px black);
71 | }
72 |
73 | .groupTitle {
74 | text-align: left;
75 | text-transform: uppercase;
76 | letter-spacing: 1px;
77 | font-size: 1.5em;
78 | line-height: 1.2;
79 | margin: 20px 0 -8px;
80 | padding-top: 20px;
81 | padding-left: 32px;
82 | border-top: 1px solid rgba(255, 255, 255, 0.1);
83 | a {
84 | align-items: center;
85 | gap: 0.4em;
86 | display: flex;
87 |
88 | svg {
89 | opacity: 0.3;
90 | }
91 | }
92 | }
93 |
94 | .controls {
95 | display: flex;
96 | margin-top: 0.2em;
97 | justify-content: flex-start;
98 | padding-left: 32px;
99 |
100 | margin-bottom: 0.4em;
101 | label {
102 | display: block;
103 |
104 | span {
105 | color: rgb(165, 178, 196);
106 | display: block;
107 | }
108 |
109 | select {
110 | font-size: 0.9em;
111 | }
112 | }
113 | }
114 | @media screen and (max-width: 1000px) {
115 | .title,
116 | .groupTitle,
117 | .parents,
118 | .subtitle {
119 | padding-left: 24px;
120 | }
121 | .description {
122 | padding: 0 24px;
123 | }
124 | }
125 |
126 | @media screen and (max-width: 850px) {
127 | .title,
128 | .groupTitle,
129 | .subtitle,
130 | .parents,
131 | .controls {
132 | padding-left: 16px;
133 | }
134 | .description {
135 | padding: 0 16px;
136 | line-height: 1.4;
137 | }
138 |
139 | .title {
140 | margin: 16px 0 8px;
141 | font-size: 4em;
142 | }
143 |
144 | .groupTitle {
145 | margin: 12px 0 -4px;
146 | }
147 | }
148 |
149 | @media screen and (max-width: 650px) {
150 | .subtitle {
151 | margin-bottom: -8px;
152 | font-size: 1.2em;
153 | margin-top: 16px;
154 | svg {
155 | height: 1.1em;
156 | }
157 | }
158 | .description {
159 | font-size: 0.9em;
160 | }
161 | .title {
162 | margin: 16px 0 8px;
163 | font-size: 2.5em;
164 | }
165 |
166 | .groupTitle {
167 | font-size: 1.3em;
168 | }
169 | }
170 |
--------------------------------------------------------------------------------
/pages/index.js:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import Link from "next/link";
3 | import { useEffect, useMemo } from "react";
4 | import { Layout } from "../components";
5 | import Image from "../components/image";
6 | import { Nav } from "../components/nav";
7 | import { prepareAdditions } from "../components/new-additions/helpers";
8 | import { useProps } from "../data/contexts";
9 | import {
10 | asset,
11 | classes,
12 | makeDescription,
13 | makeTitle,
14 | useArrowNavigation,
15 | useLocalStorageState,
16 | } from "../data/helpers";
17 | import { store } from "../data/store";
18 | import styles from "../styles/index.module.scss";
19 |
20 | function ChampionsList({ role }) {
21 | const { champions } = useProps();
22 | const filteredChamps = useMemo(() => {
23 | if (!role) return champions;
24 |
25 | return champions.filter((c) => c.roles.includes(role));
26 | }, [champions, role]);
27 |
28 | return (
29 |
51 | );
52 | }
53 |
54 | export default function Index() {
55 | const [champRole, setChampRole] = useLocalStorageState(
56 | "champs_index__champRole",
57 | ""
58 | );
59 |
60 | useEffect(() => {
61 | localStorage.lastIndex = "/";
62 | }, []);
63 |
64 | const handlers = useArrowNavigation("/skinlines", "/universes");
65 |
66 | const { champions } = useProps();
67 |
68 | return (
69 | <>
70 |
71 | {makeTitle()}
72 | {makeDescription(
73 | `Browse through League of Legends skins from the comfort of your browser. Take a look at these ${champions.length} champions!`
74 | )}
75 |
76 |
77 |
100 | >
101 | );
102 | }
103 |
104 | Index.getLayout = (page) => {page};
105 |
106 | export async function getStaticProps() {
107 | return {
108 | props: {
109 | champions: store.patch.champions,
110 | patch: store.patch.fullVersionString,
111 | added: await prepareAdditions(),
112 | },
113 | };
114 | }
115 |
--------------------------------------------------------------------------------
/components/skin-viewer/popup.js:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | ChevronDown,
4 | ChevronUp,
5 | ExternalLink,
6 | Folder,
7 | Globe,
8 | Palette,
9 | User,
10 | Video,
11 | } from "lucide-react";
12 | import Link from "next/link";
13 | import { useEffect, useState } from "react";
14 | import { asset } from "../../data/helpers";
15 | import Image from "../image";
16 | import styles from "./styles.module.scss";
17 |
18 | export function Popup({ skin }) {
19 | const [showChromas, setShowChromas] = useState(false);
20 | useEffect(() => {
21 | setShowChromas(false);
22 | }, [skin]);
23 | const meta = skin.$skinExplorer;
24 | return (
25 |
114 | );
115 | }
116 |
--------------------------------------------------------------------------------
/pages/_app.js:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import { useRouter } from "next/router";
3 | import { useEffect, useRef } from "react";
4 | import {
5 | useDynamicStickyHeight,
6 | useVenatusTracking,
7 | } from "../components/venatus";
8 | import { PropsProvider } from "../data/contexts";
9 | import "../styles/globals.scss";
10 |
11 | /**
12 | *
13 | * @param {string} url
14 | * @param {import("react").ScriptHTMLAttributes} props
15 | * @returns
16 | */
17 | function loadScript(url, props = {}) {
18 | const script = document.createElement("script");
19 | script.src = url;
20 | script.async = true;
21 | // script.onload = () => resolve();
22 | // script.onerror = (e) => console.error(e);
23 | Object.entries(props).forEach(([key, value]) => {
24 | script.setAttribute(key, value);
25 | });
26 | document.head.appendChild(script);
27 | }
28 |
29 | const NO_AD_PATHS = ["/privacy-policy"];
30 | function useDynamicAdScript() {
31 | const lastPathClass = useRef();
32 |
33 | const router = useRouter();
34 | useEffect(() => {
35 | function loadDynamicAdScript(url) {
36 | let pathClass = "venatus";
37 | if (NO_AD_PATHS.some((path) => url.startsWith(path))) pathClass = "none";
38 |
39 | if (lastPathClass.current && lastPathClass.current !== pathClass) {
40 | location.href = url;
41 | return;
42 | }
43 | lastPathClass.current = pathClass;
44 |
45 | if (pathClass === "venatus")
46 | loadScript(
47 | "https://hb.vntsm.com/v4/live/vms/sites/skinexplorer.lol/index.js"
48 | );
49 | }
50 | loadDynamicAdScript(window.location.pathname);
51 | router.events.on("routeChangeStart", loadDynamicAdScript);
52 | return () => {
53 | router.events.off("routeChangeStart", loadDynamicAdScript);
54 | };
55 | }, [router]);
56 | }
57 |
58 | function useVenatusRouteInterceptor() {
59 | const router = useRouter();
60 | useEffect(() => {
61 | // Listen for route change start events
62 | const handleRouteChangeStart = (url) => {
63 | if (!router.pathname.startsWith("/venatus-test")) return;
64 | if (url.startsWith("/venatus-test")) return;
65 | // Prevent default navigation
66 | router.events.emit("routeChangeError");
67 | // Redirect elsewhere
68 | router.push("/venatus-test" + url);
69 | // Throw error to stop navigation (will be caught by Next.js)
70 | throw new Error(`Route changed to ${url} was blocked`);
71 | };
72 |
73 | router.events.on("routeChangeStart", handleRouteChangeStart);
74 |
75 | return () => {
76 | router.events.off("routeChangeStart", handleRouteChangeStart);
77 | };
78 | }, [router]);
79 | }
80 |
81 | export default function App({ Component, pageProps }) {
82 | useVenatusRouteInterceptor();
83 | useDynamicAdScript();
84 | useVenatusTracking();
85 | useDynamicStickyHeight();
86 |
87 | const getLayout = Component.getLayout || ((page) => page);
88 |
89 | useEffect(() => {
90 | if (typeof window === "undefined") return;
91 | function resize() {
92 | const vh = window.innerHeight * 0.01;
93 | document.documentElement.style.setProperty("--vh", `${vh}px`);
94 | }
95 | resize();
96 | window.addEventListener("resize", resize);
97 | return () => window.removeEventListener("resize", resize);
98 | }, []);
99 |
100 | return (
101 | <>
102 |
103 |
107 |
108 |
109 | {getLayout()}
110 |
111 | >
112 | );
113 | }
114 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 |
4 |
5 | ## [v1.1.1](https://github.com/preyneyv/lol-skin-explorer/tree/v1.1.1)
6 |
7 | ###### January 12th, 2022
8 |
9 |
10 |
11 | - Fixed bug that prevented site from updating due to a universe (Steel Valkyries) with no skinlines in the game files.
12 |
13 | ---
14 |
15 |
16 |
17 | ## [v1.1.0](https://github.com/preyneyv/lol-skin-explorer/tree/v1.1.0)
18 |
19 | ###### January 12th, 2022
20 |
21 |
22 |
23 | - Skins now have a SkinSpotlights jumplink. Implementation is a bit rudimentary,
24 | but it serves the purpose (for now).
25 | - OpenSearch support! Skin Explorer now registers as a search engine in the
26 | browser so you can search without even loading the website.
27 | - All data is now cached at build time instead of coming from the Redis cache at
28 | runtime, including the resources needed for dynamic routes like the Omnisearch
29 | API.
30 |
31 | ---
32 |
33 |
34 |
35 | ## [v1.0.6](https://github.com/preyneyv/lol-skin-explorer/tree/v1.0.6)
36 |
37 | ###### January 5th, 2022
38 |
39 |
40 |
41 | - Fixed placeholder images not loading sometimes.
42 | - Fixed spacing on info popup in skin viewer on small screens.
43 |
44 | ---
45 |
46 |
47 |
48 | ## [v1.0.5](https://github.com/preyneyv/lol-skin-explorer/tree/v1.0.5)
49 |
50 | ###### January 5th, 2022
51 |
52 |
53 |
54 | - Modified `robots.txt` to disallow internal pages.
55 |
56 | ---
57 |
58 |
59 |
60 | ## [v1.0.4](https://github.com/preyneyv/lol-skin-explorer/tree/v1.0.4)
61 |
62 | ###### January 5th, 2022
63 |
64 |
65 |
66 | - Added a "new additions" section on all indexes that shows all the skins in
67 | the PBE that aren't on the live patch. Maybe there's a better way to
68 | determine these, but I'll revisit it later.
69 | - Added a placeholder image for non-existent images (as happens often with
70 | newly released skins).
71 | - Minor styling changes.
72 |
73 | ---
74 |
75 |
76 |
77 | ## [v1.0.3](https://github.com/preyneyv/lol-skin-explorer/tree/v1.0.3)
78 |
79 | ###### January 4th, 2022
80 |
81 |
82 |
83 | - Turns out skinlines can be empty. Weird. Fixed fatal bugs on universe and
84 | skinline pages.
85 | - Added a download button to the skin viewer. (Shortcut: `D`)
86 | - Fixed Wukong Teemo.GG hyperlink.
87 |
88 | ---
89 |
90 |
91 |
92 | ## [v1.0.2](https://github.com/preyneyv/lol-skin-explorer/tree/v1.0.2)
93 |
94 | ###### December 31th, 2021
95 |
96 |
97 |
98 | - Added H1 tags to skin viewer to help with SEO indexing.
99 |
100 | ---
101 |
102 |
103 |
104 | ## [v1.0.1](https://github.com/preyneyv/lol-skin-explorer/tree/v1.0.1)
105 |
106 | ###### December 27th, 2021
107 |
108 |
109 |
110 | - Bugfix for scaling on viewer popup.
111 | - Added backlink to universe from skinline.
112 | - Visual bugfix for omnisearch on mobile.
113 | - Added swipe to navigate on indexes.
114 |
115 | ---
116 |
117 |
118 |
119 | ## [v1.0.0](https://github.com/preyneyv/lol-skin-explorer/tree/v1.0.0)
120 |
121 | ###### December 27th, 2021
122 |
123 |
124 |
125 | The first formal release of Skin Explorer! This is pretty much a complete
126 | rewrite of the alpha version that was [initially posted to r/leagueoflegends](https://www.reddit.com/r/leagueoflegends/comments/r7c0ir/i_made_skin_explorer_an_online_skin_splash_art/), with a ton of new features.
127 |
128 | - Pre-rework splash art, all the way back to patch 7.1!
129 | - Skin universes to group related skinlines.
130 | - You can also see skin chromas now.
131 | - Touch- and mobile-friendly.
132 | - Supports rich embeds in apps like Discord, Twitter, etc.
133 | - Supports Add to Home Screen (iOS Safari, Android Chrome) and Install (Desktop Chrome).
134 | - More aggressive caching of resources to keep app snappy.
135 | - Statically rendered and SEO optimized.
136 | - Many QoL enhancements throughout.
137 |
--------------------------------------------------------------------------------
/pages/champions/[champId]/index.js:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import Image from "next/image";
3 | import { useMemo } from "react";
4 | import { Fallback } from "../../../components/fallback";
5 | import { Footer, FooterContainer } from "../../../components/footer";
6 | import { Header } from "../../../components/header";
7 | import { SkinGrid } from "../../../components/skin-grid";
8 | import { FooterAds, SidebarAdLayout } from "../../../components/venatus";
9 | import { useProps } from "../../../data/contexts";
10 | import {
11 | asset,
12 | championSkins,
13 | makeDescription,
14 | makeImage,
15 | makeTitle,
16 | useLocalStorageState,
17 | useSortedSkins,
18 | } from "../../../data/helpers";
19 | import { store } from "../../../data/store";
20 | import styles from "../../../styles/collection.module.scss";
21 |
22 | function _Page() {
23 | const { champion, skins } = useProps();
24 | const [sortBy, setSortBy] = useLocalStorageState(
25 | "champion__sortBy",
26 | "release"
27 | );
28 | const base = useMemo(() => skins.find((s) => s.isBase), [skins]);
29 |
30 | const linkTo = (skin) => `/champions/${champion.key}/skins/${skin.id}`;
31 |
32 | const sortedSkins = useSortedSkins(sortBy === "rarity", skins);
33 |
34 | return (
35 | <>
36 |
37 | {makeTitle(champion.name)}
38 | {makeDescription(
39 | `Browse through the ${skins.length} skin${
40 | skins.length == 1 ? "" : "s"
41 | } that ${champion.name} has!`
42 | )}
43 | {makeImage(asset(base.uncenteredSplashPath), champion.name)}
44 |
45 |
46 |
47 |
48 |
49 |
56 |
57 |
58 |
59 |
60 | {champion.name}
61 |
62 |
72 |
73 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | >
86 | );
87 | }
88 |
89 | export default function Page() {
90 | return (
91 |
92 | <_Page />
93 |
94 | );
95 | }
96 |
97 | export async function getStaticProps(ctx) {
98 | const { champId } = ctx.params;
99 |
100 | const { champions, skins: allSkins } = store.patch;
101 |
102 | const champion = champions.find((c) => c.key === champId);
103 | if (!champion) {
104 | return {
105 | notFound: true,
106 | };
107 | }
108 |
109 | const skins = championSkins(champion.id, allSkins);
110 |
111 | return {
112 | props: {
113 | champion,
114 | skins,
115 | patch: store.patch.fullVersionString,
116 | },
117 | };
118 | }
119 |
120 | export async function getStaticPaths() {
121 | let paths = [];
122 | if (process.env.NODE_ENV === "production") {
123 | const { champions } = store.patch;
124 | paths = champions.map((c) => ({ params: { champId: c.key.toString() } }));
125 | }
126 |
127 | return {
128 | paths,
129 | fallback: "blocking",
130 | };
131 | }
132 |
--------------------------------------------------------------------------------
/components/header/index.js:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import styles from "./styles.module.scss";
3 | import Image from "next/image";
4 | import classNames from "classnames";
5 | import logo from "../../assets/logo.png";
6 | import { Omnisearch } from "../omnisearch";
7 | import { useEscapeTo } from "../../data/helpers";
8 | import { ArrowLeft, ExternalLink, Menu, Search, X } from "lucide-react";
9 | import { useLayoutEffect, useEffect, useRef, useState } from "react";
10 |
11 | export const Header = ({ flat, backTo }) => {
12 | const back =
13 | typeof window !== "undefined" ? localStorage.lastIndex ?? backTo : backTo;
14 | useEscapeTo(back);
15 |
16 | const [menuOpen, setMenuOpen] = useState(false);
17 | const [showSearch, setShowSearch] = useState(false);
18 | const omnisearch = useRef();
19 | (typeof window === "undefined" ? useEffect : useLayoutEffect)(() => {
20 | if (showSearch) omnisearch.current?.focus();
21 | }, [showSearch]);
22 |
23 | return (
24 | <>
25 |
122 |
123 | >
124 | );
125 | };
126 |
--------------------------------------------------------------------------------
/components/omnisearch/index.js:
--------------------------------------------------------------------------------
1 | import React, { useImperativeHandle } from "react";
2 | import Image from "../image";
3 | import { useRouter } from "next/router";
4 | import { useRef, useState, useEffect } from "react";
5 | import { asset } from "../../data/helpers";
6 | import classNames from "classnames";
7 | import styles from "./styles.module.scss";
8 | import axios from "axios";
9 |
10 | export const Omnisearch = React.forwardRef(({}, ref) => {
11 | const inp = useRef();
12 | const router = useRouter();
13 | useImperativeHandle(
14 | ref,
15 | () => ({
16 | focus: () => inp.current?.focus(),
17 | }),
18 | []
19 | );
20 |
21 | const [query, setQuery] = useState("");
22 | const [selected, setSelected] = useState(0);
23 | const [showResults, setShowResults] = useState(false);
24 | const [matches, setMatches] = useState([]);
25 |
26 | useEffect(() => {
27 | axios
28 | .get("/api/omnisearch", { params: { query } })
29 | .then((res) => setMatches(res.data));
30 | }, [query]);
31 |
32 | useEffect(() => setSelected(0), [query]);
33 |
34 | function onSelect(entity) {
35 | const { type } = entity;
36 | let route;
37 | if (type === "champion") {
38 | route = `/champions/${entity.key}`;
39 | }
40 | if (type === "skinline") {
41 | route = `/skinlines/${entity.id}`;
42 | }
43 | if (type === "universe") {
44 | route = `/universes/${entity.id}`;
45 | }
46 | if (type === "skin") {
47 | route = `/champions/${entity.key}/skins/${entity.id}`;
48 | }
49 |
50 | if (route) {
51 | router.push(route, route);
52 | }
53 | }
54 |
55 | function selectActive() {
56 | onSelect(matches[selected]);
57 | setQuery("");
58 | }
59 |
60 | useEffect(() => {
61 | function onKeyDown() {
62 | if (document.activeElement === document.body) {
63 | inp.current?.focus();
64 | }
65 | }
66 | document.addEventListener("keypress", onKeyDown);
67 | return () => document.removeEventListener("keypress", onKeyDown);
68 | });
69 | return (
70 |
71 |
setQuery(e.target.value)}
77 | onFocus={() => setShowResults(true)}
78 | onBlur={() => setShowResults(false)}
79 | onKeyDown={(e) => {
80 | if (e.key === "ArrowDown") {
81 | setSelected((selected + 1) % matches.length);
82 | e.preventDefault();
83 | }
84 | if (e.key === "ArrowUp") {
85 | setSelected((selected === 0 ? matches.length : selected) - 1);
86 | e.preventDefault();
87 | }
88 | if (e.key === "Enter" && matches.length) {
89 | selectActive();
90 | e.preventDefault();
91 | e.target.blur();
92 | }
93 | }}
94 | />
95 | {showResults && matches.length !== 0 && (
96 |
97 | {matches.map((match, i) => (
98 | - setSelected(i)}
100 | onMouseDown={selectActive}
101 | className={classNames({
102 | [styles.selected]: selected === i,
103 | })}
104 | key={i}
105 | >
106 | {match.image && (
107 |
108 |
115 |
116 | )}
117 |
118 |
{match.name}
119 | {match.type === "champion" ? (
120 |
Champion
121 | ) : match.type === "skinline" ? (
122 |
Skinline
123 | ) : match.type === "universe" ? (
124 |
Universe
125 | ) : (
126 |
Skin
127 | )}
128 |
129 |
130 | ))}
131 |
132 | )}
133 |
134 | );
135 | });
136 | Omnisearch.displayName = "Omnisearch";
137 |
--------------------------------------------------------------------------------
/components/header/styles.module.scss:
--------------------------------------------------------------------------------
1 | .header {
2 | position: fixed;
3 | top: 0;
4 | width: 100%;
5 | height: 56px;
6 | z-index: 999;
7 | display: grid;
8 | grid-template-columns: auto 1fr auto;
9 | border-bottom: 1px solid rgb(31, 39, 48);
10 | background: rgb(12, 15, 19);
11 | align-items: center;
12 | // padding: 0 10px;
13 | gap: 10px;
14 |
15 | transition: background-color 300ms, border-color 300ms;
16 |
17 | .logo {
18 | display: flex;
19 | gap: 8px;
20 | align-items: center;
21 | padding-left: 10px;
22 | // height: 36px;
23 | svg {
24 | color: rgb(72, 89, 111);
25 | transition: color 200ms;
26 | }
27 | &:hover svg {
28 | color: white;
29 | }
30 | }
31 |
32 | &.flat {
33 | background: rgba(19, 25, 32, 0.9);
34 | // border-color: transparent;
35 | }
36 |
37 | .omnisearchIcon,
38 | .menuIcon {
39 | height: 56px;
40 | width: 56px;
41 | display: flex;
42 | align-items: center;
43 | justify-content: center;
44 | user-select: none;
45 |
46 | > svg {
47 | color: rgb(72, 89, 111);
48 | transition: color 200ms;
49 | }
50 | }
51 |
52 | .omnisearchIcon {
53 | display: none;
54 | }
55 |
56 | .menuIcon {
57 | position: relative;
58 | border-left: 1px solid rgb(31, 39, 48);
59 | transition: background 200ms, border-color 200ms;
60 |
61 | ul {
62 | list-style: none;
63 | padding: 0;
64 | z-index: 1000;
65 | cursor: default;
66 | margin: 0;
67 | background: rgb(22, 33, 48);
68 | position: absolute;
69 | bottom: 0;
70 | // padding: 4px;
71 | right: 0;
72 | transform: translateY(100%);
73 | filter: drop-shadow(0 3px 7px rgba(0, 0, 0, 0.4));
74 | pointer-events: none;
75 | opacity: 0;
76 | transition: opacity 200ms;
77 |
78 | li a {
79 | display: flex;
80 | align-items: center;
81 | cursor: pointer;
82 | gap: 4px;
83 | padding: 8px 12px;
84 | min-width: 200px;
85 | color: rgb(165, 178, 196);
86 | transition: color 200ms, background 200ms;
87 |
88 | &:hover {
89 | background: rgba(121, 163, 207, 0.1);
90 | color: white;
91 | }
92 |
93 | svg {
94 | height: 18px;
95 | color: rgba(121, 163, 207, 0.4);
96 | transform: translateY(-2px);
97 | }
98 | }
99 |
100 | .divider {
101 | border-bottom: 1px solid rgba(121, 163, 207, 0.2);
102 | }
103 | }
104 |
105 | // &:hover > ul {
106 | // opacity: 1;
107 | // pointer-events: all;
108 | // }
109 | }
110 | }
111 |
112 | @supports (backdrop-filter: blur(1px)) {
113 | .header.flat {
114 | // border-color: transparent;
115 | background: rgba(8, 13, 19, 0.5);
116 | backdrop-filter: blur(24px);
117 | }
118 | }
119 |
120 | .headerSpacer {
121 | height: 56px;
122 | }
123 |
124 | @media screen and (max-width: 440px) {
125 | .header {
126 | grid-template-columns: 1fr auto auto;
127 | gap: 0;
128 | .omnisearch {
129 | display: none;
130 | padding-left: 8px;
131 | }
132 |
133 | .omnisearchIcon {
134 | display: flex;
135 | }
136 |
137 | .menuIcon ul {
138 | width: 100vw;
139 | height: calc(100vh - 56px);
140 | background: rgb(12, 15, 19) !important;
141 | }
142 |
143 | &.search {
144 | .logo {
145 | display: none;
146 | }
147 | .omnisearch {
148 | display: block;
149 | }
150 | }
151 | }
152 | }
153 |
154 | @media (pointer: coarse) {
155 | .menuIcon {
156 | cursor: pointer;
157 |
158 | &.open {
159 | background: rgb(22, 33, 48);
160 | border-color: transparent;
161 | }
162 |
163 | &.open > svg {
164 | color: white;
165 | }
166 |
167 | &.open > ul {
168 | opacity: 1;
169 | pointer-events: all;
170 | }
171 | }
172 | }
173 |
174 | @media (pointer: fine) {
175 | .menuIcon {
176 | cursor: pointer;
177 | * {
178 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
179 | }
180 |
181 | &:hover {
182 | background: rgb(22, 33, 48);
183 | border-color: transparent;
184 | }
185 |
186 | &:hover > svg {
187 | color: white;
188 | }
189 |
190 | &:hover > ul {
191 | opacity: 1;
192 | pointer-events: all;
193 | }
194 | }
195 | }
196 |
--------------------------------------------------------------------------------
/components/venatus/index.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 | import styles from "./styles.module.scss";
3 |
4 | function vm() {
5 | self.__VM = self.__VM || [];
6 | return self.__VM;
7 | }
8 |
9 | /**
10 | * @param {{ placementName: string; alias?: string }} props
11 | * @returns
12 | */
13 | export const VenatusAd = ({ placementName, alias }) => {
14 | const elRef = useRef(null);
15 |
16 | useEffect(() => {
17 | let placement;
18 | console.log("[PROSPER] add", placementName);
19 |
20 | vm().push(function (admanager, scope) {
21 | if (placementName === "vertical_sticky") {
22 | scope.Config.verticalSticky().display();
23 | } else if (
24 | placementName === "horizontal_sticky" ||
25 | placementName === "mobile_horizontal_sticky" ||
26 | placementName === "video_slider"
27 | ) {
28 | placement = scope.Config.get(placementName).displayBody();
29 | } else {
30 | placement = scope.Config.get(placementName).display(elRef.current);
31 | }
32 | });
33 |
34 | return () => {
35 | vm().push(function (admanager, scope) {
36 | console.log("[PROSPER] removed", placementName);
37 | if (placementName === "vertical_sticky") {
38 | scope.Config.verticalSticky().destroy();
39 | } else {
40 | placement.remove();
41 | }
42 | });
43 | };
44 | }, [placementName]);
45 |
46 | return ;
47 | };
48 |
49 | export function useVenatusTracking() {
50 | useEffect(() => {
51 | vm().push(function (admanager, scope) {
52 | scope.Instances.pageManager.on(
53 | "navigated",
54 | () => {
55 | scope.Instances.pageManager.newPageSession(false);
56 | },
57 | false
58 | );
59 | });
60 | }, []);
61 | }
62 |
63 | export function useDynamicStickyHeight() {
64 | useEffect(() => {
65 | const INTERVAL = 1000;
66 | function setStickyHeight() {
67 | const ads = document.querySelectorAll(
68 | "body > span > span > span > iframe"
69 | );
70 | let hasMobile = false,
71 | hasDesktop = false;
72 | ads.forEach((ad) => {
73 | if (ad.clientHeight === 50) {
74 | hasMobile = true;
75 | } else {
76 | hasDesktop = true;
77 | }
78 | });
79 |
80 | document.body.style.setProperty(
81 | "--asp-mobile-h",
82 | hasMobile ? "50px" : "0px"
83 | );
84 | document.body.style.setProperty(
85 | "--asp-desktop-h",
86 | hasDesktop ? "90px" : "0px"
87 | );
88 | }
89 | setStickyHeight();
90 | function lazyStickyHeight() {
91 | const redo = () => (timeout = setTimeout(lazyStickyHeight, INTERVAL));
92 | if (window.requestIdleCallback) {
93 | window.requestIdleCallback(
94 | () => {
95 | setStickyHeight();
96 | redo();
97 | },
98 | { timeout: 1000 }
99 | );
100 | } else {
101 | setStickyHeight();
102 | redo();
103 | }
104 | }
105 | let timeout = setTimeout(lazyStickyHeight, INTERVAL);
106 | const obs = new MutationObserver(() => setStickyHeight());
107 | obs.observe(document.body, {
108 | childList: true,
109 | subtree: true,
110 | });
111 | return () => {
112 | clearInterval(timeout);
113 | obs.disconnect();
114 | };
115 | }, []);
116 | }
117 |
118 | export function SidebarAdLayout({ children }) {
119 | return (
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 |
139 | );
140 | }
141 |
142 | export function FooterAds() {
143 | return (
144 |
145 |
146 |
147 |
148 | );
149 | }
150 |
--------------------------------------------------------------------------------
/styles/index.module.scss:
--------------------------------------------------------------------------------
1 | .container {
2 | display: grid;
3 |
4 | nav {
5 | display: flex;
6 | gap: 10px;
7 | align-items: center;
8 | border-bottom: 1px solid rgb(31, 39, 48);
9 | user-select: none;
10 | overflow-x: auto;
11 | overflow-y: hidden;
12 |
13 | > div {
14 | display: flex;
15 | align-items: center;
16 | padding: 8px 10px;
17 | position: relative;
18 |
19 | &:not(:first-child)::before {
20 | content: "";
21 | top: -2px;
22 | height: calc(100% + 4px);
23 | left: -10px;
24 | position: absolute;
25 | border-left: 1px solid rgb(31, 39, 48);
26 | }
27 | }
28 |
29 | .tabs > a {
30 | padding: 4px 8px 4px 6px;
31 | cursor: pointer;
32 | color: rgb(72, 89, 111);
33 | transition: background-color 200ms, color 200ms;
34 | display: flex;
35 | align-items: center;
36 | gap: 2px;
37 |
38 | svg {
39 | height: 18px;
40 | }
41 |
42 | &:hover {
43 | color: white;
44 | }
45 |
46 | &.active {
47 | cursor: default;
48 | color: white;
49 | background-color: rgba(121, 163, 207, 0.2);
50 | }
51 | }
52 |
53 | .filters label {
54 | display: flex;
55 | align-items: center;
56 | gap: 8px;
57 | font-size: 0.9em;
58 |
59 | span {
60 | color: rgb(165, 178, 196);
61 | }
62 | }
63 | }
64 | }
65 |
66 | .champions {
67 | display: grid;
68 | padding: 10px;
69 | line-height: 1.2;
70 | grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
71 | gap: 14px;
72 | justify-content: space-between;
73 |
74 | > a {
75 | display: grid;
76 | position: relative;
77 | grid-template-columns: 40px auto;
78 | align-items: center;
79 | z-index: 1;
80 | gap: 12px;
81 |
82 | &::before {
83 | content: "";
84 | background: rgba(121, 163, 207, 0.1);
85 | position: absolute;
86 | top: -6px;
87 | left: -6px;
88 | width: calc(100% + 12px);
89 | height: calc(100% + 12px);
90 | opacity: 0;
91 | z-index: -1;
92 | transition: opacity 200ms;
93 | }
94 |
95 | &:hover::before {
96 | opacity: 1;
97 | }
98 |
99 | div {
100 | color: rgb(165, 178, 196);
101 | transition: color 200ms;
102 | font-weight: 300;
103 | text-overflow: ellipsis;
104 | overflow: hidden;
105 | }
106 |
107 | &:hover div {
108 | color: white;
109 | // opacity: 1;
110 | }
111 |
112 | img {
113 | background: rgba(121, 163, 207, 0.1);
114 | }
115 | }
116 | }
117 |
118 | .universes,
119 | .skinlines {
120 | columns: 6;
121 | padding: 10px;
122 | font-weight: 300;
123 | line-height: 1.3;
124 |
125 | > div {
126 | display: block;
127 | break-inside: avoid;
128 |
129 | a {
130 | color: rgb(165, 178, 196);
131 | // opacity: 0.7;
132 | display: block;
133 | padding: 6px 8px;
134 | transition: background-color 200ms, color 200ms, opacity 200ms;
135 |
136 | &:hover {
137 | background: rgb(23, 29, 36);
138 | color: white;
139 | opacity: 1;
140 | }
141 | }
142 |
143 | > ul {
144 | padding: 0;
145 | margin: 0;
146 | z-index: 12;
147 | width: 100%;
148 | list-style: none;
149 |
150 | a {
151 | margin-left: 32px;
152 | padding: 4px 8px;
153 | color: rgba(121, 163, 207, 0.6);
154 | }
155 | }
156 | }
157 | }
158 |
159 | @media screen and (max-width: 1250px) {
160 | .universes,
161 | .skinlines {
162 | columns: 5;
163 | }
164 | }
165 |
166 | @media screen and (max-width: 1100px) {
167 | .champions {
168 | grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
169 | gap: 10px;
170 | }
171 |
172 | .universes,
173 | .skinlines {
174 | columns: 4;
175 |
176 | > div > ul a {
177 | margin-left: 16px;
178 | }
179 | }
180 | }
181 |
182 | @media screen and (max-width: 800px) {
183 | .universes,
184 | .skinlines {
185 | columns: 3;
186 | }
187 | }
188 |
189 | @media screen and (max-width: 600px) {
190 | .container nav {
191 | flex-direction: column;
192 | align-items: stretch;
193 | gap: 0;
194 |
195 | > div:not(:first-child) {
196 | // border-left: none;
197 | border-top: 1px solid rgb(31, 39, 48);
198 | }
199 | ::before {
200 | display: none;
201 | }
202 | }
203 | }
204 |
205 | @media screen and (max-width: 500px) {
206 | .universes,
207 | .skinlines {
208 | columns: 2;
209 | }
210 | }
211 |
212 | @media screen and (max-width: 400px) {
213 | .universes,
214 | .skinlines {
215 | columns: 1;
216 | }
217 | }
218 |
--------------------------------------------------------------------------------
/pages/skinlines/[skinlineId]/index.js:
--------------------------------------------------------------------------------
1 | import { Folder, Globe } from "lucide-react";
2 | import Head from "next/head";
3 | import Image from "next/image";
4 | import Link from "next/link";
5 | import { Fallback } from "../../../components/fallback";
6 | import { Footer, FooterContainer } from "../../../components/footer";
7 | import { Header } from "../../../components/header";
8 | import { SkinGrid } from "../../../components/skin-grid";
9 | import { FooterAds, SidebarAdLayout } from "../../../components/venatus";
10 | import { useProps } from "../../../data/contexts";
11 | import {
12 | asset,
13 | makeDescription,
14 | makeImage,
15 | makeTitle,
16 | skinlineSkins,
17 | useLocalStorageState,
18 | useSortedSkins,
19 | } from "../../../data/helpers";
20 | import { store } from "../../../data/store";
21 | import styles from "../../../styles/collection.module.scss";
22 |
23 | function _Page() {
24 | const { skinline, universes, skins } = useProps();
25 | const [sortBy, setSortBy] = useLocalStorageState(
26 | "skinline__sortBy",
27 | "champion"
28 | );
29 |
30 | const linkTo = (skin) => `/skinlines/${skinline.id}/skins/${skin.id}`;
31 |
32 | const sortedSkins = useSortedSkins(sortBy === "rarity", skins);
33 | const splash = skins.length > 0 && asset(skins[0].uncenteredSplashPath);
34 |
35 | return (
36 | <>
37 |
38 | {makeTitle(skinline.name)}
39 | {makeDescription(
40 | `Browse through all ${skins.length} skin${
41 | skins.length == 1 ? "" : "s"
42 | } in the League of Legends ${skinline.name} skinline!`
43 | )}
44 | {splash && makeImage(splash, skinline.name)}
45 |
46 |
47 |
48 |
49 | {splash && (
50 |
51 |
58 |
59 | )}
60 |
61 |
62 |
63 |
64 |
65 | Skinline
66 |
67 | {skinline.name}
68 | {!!universes.length && (
69 |
83 | )}
84 |
85 |
95 |
96 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 | >
109 | );
110 | }
111 |
112 | export default function Page() {
113 | return (
114 |
115 | <_Page />
116 |
117 | );
118 | }
119 |
120 | export async function getStaticProps(ctx) {
121 | const { skinlineId } = ctx.params;
122 |
123 | const {
124 | champions,
125 | skinlines,
126 | universes: allUniverses,
127 | skins: allSkins,
128 | } = store.patch;
129 |
130 | const skinline = skinlines.find((l) => l.id.toString() == skinlineId);
131 | if (!skinline) {
132 | return {
133 | notFound: true,
134 | };
135 | }
136 |
137 | const skins = skinlineSkins(skinline.id, allSkins, champions);
138 | const universes = allUniverses.filter((u) =>
139 | u.skinSets.includes(skinline.id)
140 | );
141 |
142 | return {
143 | props: {
144 | skinline,
145 | skins,
146 | universes,
147 | patch: store.patch.fullVersionString,
148 | },
149 | };
150 | }
151 |
152 | export async function getStaticPaths() {
153 | let paths = [];
154 | if (process.env.NODE_ENV === "production") {
155 | const { skinlines } = store.patch;
156 | paths = skinlines.map((l) => ({ params: { skinlineId: l.id.toString() } }));
157 | }
158 |
159 | return {
160 | paths,
161 | fallback: true,
162 | };
163 | }
164 |
--------------------------------------------------------------------------------
/pages/universes/[universeId]/index.js:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import Image from "next/image";
3 | import { Fallback } from "../../../components/fallback";
4 | import { Header } from "../../../components/header";
5 | import { useProps } from "../../../data/contexts";
6 | import {
7 | asset,
8 | makeDescription,
9 | makeImage,
10 | makeTitle,
11 | skinlineSkins,
12 | useLocalStorageState,
13 | useSortedSkins,
14 | } from "../../../data/helpers";
15 | import { store } from "../../../data/store";
16 |
17 | import { Folder, Globe } from "lucide-react";
18 | import Link from "next/link";
19 | import { Footer, FooterContainer } from "../../../components/footer";
20 | import { SkinGrid } from "../../../components/skin-grid";
21 | import { FooterAds, SidebarAdLayout } from "../../../components/venatus";
22 | import styles from "../../../styles/collection.module.scss";
23 |
24 | function Skinline({ sortByRarity, skinline, linkTo }) {
25 | const sortedSkins = useSortedSkins(sortByRarity, skinline.skins);
26 |
27 | return (
28 | <>
29 |
37 |
42 | >
43 | );
44 | }
45 |
46 | function _Page() {
47 | const { skinlines, universe } = useProps();
48 | const [sortBy, setSortBy] = useLocalStorageState(
49 | "universe__sortBy",
50 | "champion"
51 | );
52 |
53 | const linkTo = (skin) => `/universes/${universe.id}/skins/${skin.id}`;
54 | const splash =
55 | skinlines.length > 0 &&
56 | skinlines[0].skins.length > 0 &&
57 | asset(skinlines[0].skins[0].uncenteredSplashPath);
58 | return (
59 | <>
60 |
61 | {makeTitle(universe.name)}
62 | {makeDescription(
63 | universe.description ||
64 | `Browse through all the skins in the League of Legends ${universe.name} universe!`
65 | )}
66 | {splash && makeImage(splash, universe.name)}
67 |
68 |
69 |
70 |
71 | {splash && (
72 |
73 |
80 |
81 | )}
82 |
83 |
84 |
85 |
86 |
87 | Universe
88 |
89 | {universe.name}
90 | {universe.description && (
91 | {universe.description}
92 | )}
93 |
94 |
104 |
105 |
106 | {skinlines.map((l) => (
107 |
113 | ))}
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 | >
122 | );
123 | }
124 |
125 | export default function Page() {
126 | return (
127 |
128 | <_Page />
129 |
130 | );
131 | }
132 |
133 | export async function getStaticProps(ctx) {
134 | const { universeId } = ctx.params;
135 |
136 | const {
137 | universes,
138 | champions,
139 | skinlines: allSkinlines,
140 | skins: allSkins,
141 | } = store.patch;
142 |
143 | const universe = universes.find((u) => u.id.toString() === universeId);
144 | if (!universe)
145 | return {
146 | notFound: true,
147 | };
148 |
149 | const skinlines = allSkinlines
150 | .filter((l) => universe.skinSets.includes(l.id))
151 | .map((l) => ({ ...l, skins: skinlineSkins(l.id, allSkins, champions) }))
152 | .sort((a, b) => (a.name > b.name ? 1 : -1));
153 |
154 | return {
155 | props: {
156 | universe,
157 | skinlines,
158 | patch: store.patch.fullVersionString,
159 | },
160 | };
161 | }
162 |
163 | export async function getStaticPaths() {
164 | let paths = [];
165 | if (process.env.NODE_ENV === "production") {
166 | const { universes } = store.patch;
167 | paths = universes.map((u) => ({ params: { universeId: u.id.toString() } }));
168 | }
169 |
170 | return {
171 | paths,
172 | fallback: "blocking",
173 | };
174 | }
175 |
--------------------------------------------------------------------------------
/pwa.cache.js:
--------------------------------------------------------------------------------
1 | // Copied from https://github.com/shadowwalker/next-pwa/blob/master/cache.js
2 | "use strict";
3 |
4 | // Workbox RuntimeCaching config: https://developers.google.com/web/tools/workbox/reference-docs/latest/module-workbox-build#.RuntimeCachingEntry
5 | module.exports = [
6 | {
7 | urlPattern: /^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,
8 | handler: "CacheFirst",
9 | options: {
10 | cacheName: "google-fonts-webfonts",
11 | expiration: {
12 | maxEntries: 4,
13 | maxAgeSeconds: 365 * 24 * 60 * 60, // 365 days
14 | },
15 | },
16 | },
17 | {
18 | urlPattern: /^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,
19 | handler: "StaleWhileRevalidate",
20 | options: {
21 | cacheName: "google-fonts-stylesheets",
22 | expiration: {
23 | maxEntries: 4,
24 | maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
25 | },
26 | },
27 | },
28 | {
29 | urlPattern: /\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,
30 | handler: "StaleWhileRevalidate",
31 | options: {
32 | cacheName: "static-font-assets",
33 | expiration: {
34 | maxEntries: 4,
35 | maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
36 | },
37 | },
38 | },
39 | {
40 | urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
41 | handler: "StaleWhileRevalidate",
42 | options: {
43 | cacheName: "static-image-assets",
44 | expiration: {
45 | maxEntries: 64,
46 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
47 | },
48 | },
49 | },
50 | {
51 | urlPattern: /\/_next\/image\?url=.+$/i,
52 | handler: "StaleWhileRevalidate",
53 | options: {
54 | cacheName: "next-image",
55 | expiration: {
56 | maxEntries: 64,
57 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
58 | },
59 | },
60 | },
61 | {
62 | urlPattern: /\.(?:mp3|wav|ogg)$/i,
63 | handler: "CacheFirst",
64 | options: {
65 | rangeRequests: true,
66 | cacheName: "static-audio-assets",
67 | expiration: {
68 | maxEntries: 32,
69 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
70 | },
71 | },
72 | },
73 | {
74 | urlPattern: /\.(?:mp4)$/i,
75 | handler: "CacheFirst",
76 | options: {
77 | rangeRequests: true,
78 | cacheName: "static-video-assets",
79 | expiration: {
80 | maxEntries: 32,
81 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
82 | },
83 | },
84 | },
85 | {
86 | urlPattern: /\.(?:js)$/i,
87 | handler: "StaleWhileRevalidate",
88 | options: {
89 | cacheName: "static-js-assets",
90 | expiration: {
91 | maxEntries: 32,
92 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
93 | },
94 | },
95 | },
96 | {
97 | urlPattern: /\.(?:css|less)$/i,
98 | handler: "StaleWhileRevalidate",
99 | options: {
100 | cacheName: "static-style-assets",
101 | expiration: {
102 | maxEntries: 32,
103 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
104 | },
105 | },
106 | },
107 | {
108 | urlPattern: /\/_next\/data\/.+\/.+\.json$/i,
109 | handler: "StaleWhileRevalidate",
110 | options: {
111 | cacheName: "next-data",
112 | expiration: {
113 | maxEntries: 32,
114 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
115 | },
116 | },
117 | },
118 | {
119 | urlPattern: /\.(?:json|xml|csv)$/i,
120 | handler: "NetworkFirst",
121 | options: {
122 | cacheName: "static-data-assets",
123 | expiration: {
124 | maxEntries: 32,
125 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
126 | },
127 | },
128 | },
129 | {
130 | urlPattern: ({ url }) => {
131 | const isSameOrigin = self.origin === url.origin;
132 | if (!isSameOrigin) return false;
133 | const pathname = url.pathname;
134 | // Exclude /api/auth/callback/* to fix OAuth workflow in Safari without impact other environment
135 | // Above route is default for next-auth, you may need to change it if your OAuth workflow has a different callback route
136 | // Issue: https://github.com/shadowwalker/next-pwa/issues/131#issuecomment-821894809
137 | if (pathname.startsWith("/api/auth/")) return false;
138 | if (pathname.startsWith("/api/")) return true;
139 | return false;
140 | },
141 | handler: "NetworkFirst",
142 | method: "GET",
143 | options: {
144 | cacheName: "apis",
145 | expiration: {
146 | maxEntries: 16,
147 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
148 | },
149 | networkTimeoutSeconds: 10, // fall back to cache if api does not response within 10 seconds
150 | },
151 | },
152 | {
153 | urlPattern: ({ url }) => {
154 | const isSameOrigin = self.origin === url.origin;
155 | if (!isSameOrigin) return false;
156 | const pathname = url.pathname;
157 | if (pathname.startsWith("/api/")) return false;
158 | return true;
159 | },
160 | handler: "NetworkFirst",
161 | options: {
162 | cacheName: "others",
163 | expiration: {
164 | maxEntries: 32,
165 | maxAgeSeconds: 24 * 60 * 60, // 24 hours
166 | },
167 | networkTimeoutSeconds: 10,
168 | },
169 | },
170 | {
171 | urlPattern: ({ url }) => {
172 | const isSameOrigin = self.origin === url.origin;
173 | return !isSameOrigin;
174 | },
175 | handler: "NetworkFirst",
176 | options: {
177 | cacheName: "cross-origin",
178 | expiration: {
179 | maxEntries: 64,
180 | maxAgeSeconds: 60 * 60, // 1 hour
181 | },
182 | networkTimeoutSeconds: 10,
183 | },
184 | },
185 | ];
186 |
--------------------------------------------------------------------------------
/data/helpers.js:
--------------------------------------------------------------------------------
1 | import { CDRAGON, ROOT } from "./constants";
2 | import { useProps } from "./contexts";
3 | import { useState, useEffect } from "react";
4 | import { useRouter } from "next/router";
5 | import { useSwipeable } from "react-swipeable";
6 |
7 | function isTextBox(element) {
8 | if (!element) return false;
9 | var tagName = element.tagName.toLowerCase();
10 | if (tagName === "textarea") return true;
11 | if (tagName !== "input") return false;
12 | var type = element.getAttribute("type").toLowerCase(),
13 | // if any of these input types is not supported by a browser, it will behave as input type text.
14 | inputTypes = [
15 | "text",
16 | "password",
17 | "number",
18 | "email",
19 | "tel",
20 | "url",
21 | "search",
22 | "date",
23 | "datetime",
24 | "datetime-local",
25 | "time",
26 | "month",
27 | "week",
28 | ];
29 | return inputTypes.indexOf(type) >= 0;
30 | }
31 |
32 | export function dataRoot(patch = "pbe") {
33 | return `${CDRAGON}/${patch}/plugins/rcp-be-lol-game-data/global/default`;
34 | }
35 |
36 | export function asset(path, patch = "pbe") {
37 | return path.replace("/lol-game-data/assets", dataRoot(patch)).toLowerCase();
38 | }
39 |
40 | export function splitId(id) {
41 | return [Math.floor(id / 1000), id % 1000];
42 | }
43 |
44 | export function championSkins(id, skins) {
45 | return Object.values(skins).filter((skin) => splitId(skin.id)[0] === id);
46 | }
47 |
48 | export function useChampionSkins(id) {
49 | const { skins } = useProps();
50 | return championSkins(id, skins);
51 | }
52 |
53 | export function skinlineSkins(id, skins, champions) {
54 | return Object.values(skins)
55 | .filter((skin) => skin.skinLines?.some((line) => line.id === id))
56 | .sort((a, b) => {
57 | const aId = splitId(a.id)[0];
58 | const bId = splitId(b.id)[0];
59 | const aIndex = champions.findIndex((c) => c.id === aId);
60 | const bIndex = champions.findIndex((c) => c.id === bId);
61 | return aIndex - bIndex;
62 | });
63 | }
64 |
65 | export function useSkinlineSkins(id) {
66 | const { skins, champions } = useProps();
67 | return skinlineSkins(id, skins, champions);
68 | }
69 |
70 | export const rarities = {
71 | kUltimate: ["ultimate.png", "Ultimate"],
72 | kMythic: ["mythic.png", "Mythic"],
73 | kLegendary: ["legendary.png", "Legendary"],
74 | kEpic: ["epic.png", "Epic"],
75 | };
76 |
77 | export const classes = {
78 | assassin: "Assassin",
79 | fighter: "Fighter",
80 | mage: "Mage",
81 | marksman: "Marksman",
82 | support: "Support",
83 | tank: "Tank",
84 | };
85 |
86 | export function rarity(skin) {
87 | if (!rarities[skin.rarity]) return null;
88 | const [imgName, name] = rarities[skin.rarity];
89 | const imgUrl = `${dataRoot()}/v1/rarity-gem-icons/${imgName}`;
90 | return [imgUrl, name];
91 | }
92 |
93 | export function modelviewerUrl(skin, champion) {
94 | return `https://www.modelviewer.lol/en-US/model-viewer?id=${skin.id}`;
95 | // const skinId = splitId(skin.id)[1];
96 | // return `https://teemo.gg/model-viewer?game=league-of-legends&type=champions&object=${champion.alias.toLowerCase()}&skinid=${champion.alias.toLowerCase()}-${skinId}`;
97 | }
98 |
99 | export function useLocalStorageState(name, initialValue) {
100 | const [value, _setValue] = useState(initialValue);
101 | useEffect(() => {
102 | localStorage[name] && _setValue(JSON.parse(localStorage[name]));
103 | }, [name]);
104 |
105 | const setValue = (v) => {
106 | _setValue(v);
107 | localStorage[name] = JSON.stringify(v);
108 | };
109 | return [value, setValue];
110 | }
111 |
112 | export function useSortedSkins(sortByRarity, skins) {
113 | if (sortByRarity) {
114 | const keys = Object.keys(rarities).reverse();
115 | return skins
116 | .slice()
117 | .sort((a, b) => keys.indexOf(b.rarity) - keys.indexOf(a.rarity));
118 | }
119 |
120 | return skins;
121 | }
122 |
123 | export function useEscapeTo(url) {
124 | const router = useRouter();
125 | useEffect(() => {
126 | function onKeyDown(e) {
127 | if (isTextBox(document.activeElement)) return; // Ignore events when an input is active.
128 | if (e.code === "Escape") {
129 | router.push(url, url);
130 | e.preventDefault();
131 | }
132 | }
133 |
134 | document.addEventListener("keydown", onKeyDown);
135 | return () => document.removeEventListener("keydown", onKeyDown);
136 | }, [router, url]);
137 | }
138 |
139 | export function useArrowNavigation(left, right) {
140 | const handlers = useSwipeable({
141 | delta: 50,
142 | onSwipedLeft(e) {
143 | e.event.preventDefault();
144 | router.push(right, right);
145 | },
146 | onSwipedRight(e) {
147 | e.event.preventDefault();
148 | router.push(left, left);
149 | },
150 | });
151 | const router = useRouter();
152 | useEffect(() => {
153 | function onKeyDown(e) {
154 | if (isTextBox(document.activeElement)) return; // Ignore events when an input is active.
155 | if (e.key === "ArrowLeft") {
156 | router.push(left, left);
157 | e.preventDefault();
158 | } else if (e.key === "ArrowRight") {
159 | router.push(right, right);
160 | e.preventDefault();
161 | }
162 | }
163 |
164 | document.addEventListener("keydown", onKeyDown);
165 | return () => document.removeEventListener("keydown", onKeyDown);
166 | }, [router, left, right]);
167 | return handlers;
168 | }
169 |
170 | export function makeTitle(...pages) {
171 | let t = [...pages, "Skin Explorer"].join(" · ");
172 | if (pages.length === 0) {
173 | t = "Skin Explorer · League of Legends";
174 | }
175 |
176 | return (
177 | <>
178 | {t};
179 |
180 |
181 | >
182 | );
183 | }
184 |
185 | export function makeDescription(desc) {
186 | return (
187 | <>
188 |
189 |
190 | >
191 | );
192 | }
193 |
194 | export function makeCanonical(url) {
195 | const u = ROOT + url;
196 | return (
197 | <>
198 |
199 |
200 | >
201 | );
202 | }
203 |
204 | export function makeImage(url, alt = null) {
205 | return (
206 | <>
207 |
208 |
209 | {alt && }
210 | >
211 | );
212 | }
213 |
--------------------------------------------------------------------------------
/public/ads.txt:
--------------------------------------------------------------------------------
1 | google.com, pub-3297862613403903, DIRECT, f08c47fec0942fa0
2 |
3 |
4 | ownerdomain=skinexplorer.lol
5 | managerdomain=venatus.com
6 | #V 17.06.2025 VH
7 |
8 | venatus.com, 681c6fb13d7e65595e4ee679, DIRECT
9 |
10 | #----------------------------------------------------------------------------#
11 | # . #
12 | # .o8 #
13 | # oooo ooo .ooooo. ooo. .oo. .oooo. .o888oo oooo oooo .oooo.o #
14 | # `88. .8' d88' `88b `888P"Y88b `P )88b 888 `888 `888 d88( "8 #
15 | # `88..8' 888ooo888 888 888 .oP"888 888 888 888 `"Y88b. #
16 | # `888' 888 . 888 888 d8( 888 888 . 888 888 o. )88b #
17 | # `8' `Y8bod8P' o888o o888o `Y888""8o "888" `V88V"V8P' 8""888P' #
18 | # #
19 | # The leading advertising solution for gaming and entertainment #
20 | # #
21 | # To become a publisher or advertise please contact info@venatus.com #
22 | # #
23 | #----------------------------------------------------------------------------#
24 |
25 | adagio.io, 1090, DIRECT
26 | rubiconproject.com, 19116, RESELLER, 0bfd66d529a55807
27 | pubmatic.com, 159110, RESELLER, 5d62403b186f2ace
28 | lijit.com, 367236, RESELLER, fafdf38b16bf6b2b
29 | improvedigital.com, 1790, RESELLER
30 | triplelift.com, 13482, RESELLER, 6c33edb13117fd86
31 | rubiconproject.com, 12186, RESELLER, 0bfd66d529a55807
32 | video.unrulymedia.com, 5672421953199218469, RESELLER
33 | amxrtb.com, 105199358, DIRECT
34 | amxrtb.com, 105199778, DIRECT
35 | sharethrough.com, a6a34444, RESELLER, d53b998a7bd4ecd2
36 | appnexus.com, 12290, RESELLER
37 | pubmatic.com, 158355, RESELLER, 5d62403b186f2ace
38 | rubiconproject.com, 23844, RESELLER, 0bfd66d529a55807
39 | openx.com, 559680764, RESELLER, 6a698e2ec38604c6
40 | adform.com, 2767, RESELLER
41 | adyoulike.com, c1314a52de718f3c214c00173d2994f9, DIRECT
42 | pubmatic.com, 160925, RESELLER, 5d62403b186f2ace
43 | aps.amazon.com,70247b00-ff8f-4016-b3ab-8344daf96e09,DIRECT
44 | aniview.com, 5f2063121d82c82557194737, RESELLER, 78b21b97965ec3f8
45 | aniview.com, 643f8e74688b10f72307cc24, DIRECT, 78b21b97965ec3f8
46 | google.com, pub-6346866704322274, RESELLER, f08c47fec0942fa0
47 | pubmatic.com, 160993, RESELLER, 5d62403b186f2ace
48 | rubiconproject.com, 13918, RESELLER, 0bfd66d529a55807
49 | google.com, pub-5717092533913515, RESELLER, f08c47fec0942fa0
50 | gannett.com, 22652678936, RESELLER
51 | richaudience.com, 1ru8dKmJJV, RESELLER
52 | sharethrough.com, zLsEa05k, RESELLER, d53b998a7bd4ecd2
53 | aps.amazon.com, 1ad7261b-91ea-4b6f-b9e9-b83522205b75, RESELLER
54 | pubmatic.com, 161335, RESELLER, 5d62403b186f2ace
55 | openx.com, 556532676, RESELLER, 6a698e2ec38604c6
56 | blockthrough.com, 5643766199222272, DIRECT
57 | criteo.com, B-062405, DIRECT, 9fac4a4a87c2a44f
58 | themediagrid.com, CVQXOH, DIRECT, 35d5010d7789b49d
59 | freewheel.tv, 211121, DIRECT
60 | freewheel.tv, 211129-524565, DIRECT
61 | freewheel.tv, 211129-169843, DIRECT
62 | google.com, pub-5781531207509232, DIRECT, f08c47fec0942fa0
63 | google.com, pub-5781531207509232, RESELLER, f08c47fec0942fa0
64 | google.com, pub-2553634189837243, RESELLER, f08c47fec0942fa0
65 | gumgum.com, 13385, RESELLER, ffdef49475d318a9
66 | gumgum.com, 14302, RESELLER, ffdef49475d318a9
67 | rubiconproject.com, 23434, RESELLER, 0bfd66d529a55807
68 | pubmatic.com, 157897, RESELLER, 5d62403b186f2ace
69 | indexexchange.com, 183921, DIRECT, 50b1c356f2c5c8fc
70 | indexexchange.com, 193067, DIRECT, 50b1c356f2c5c8fc
71 | indexexchange.com, 194127, DIRECT, 50b1c356f2c5c8fc
72 | indexexchange.com, 205972, RESELLER, 50b1c356f2c5c8fc
73 | infolinks.com, 3187418, direct
74 | pubmatic.com, 156872, reseller, 5d62403b186f2ace
75 | xandr.com, 3251, reseller
76 | indexexchange.com, 191306, reseller
77 | lijit.com, 268479, reseller, fafdf38b16bf6b2b
78 | media.net, 8CUY6IX4H, reseller
79 | openx.com, 543174347, reseller, 6a698e2ec38604c6
80 | video.unrulymedia.com, 2221906906, reseller
81 | improvedigital.com, 2016, reseller
82 | inmobi.com, cdfd74ce1cdd4a67b1c4794c072324ff, DIRECT, 83e75a7ae333ca9d
83 | conversantmedia.com,40881,RESELLER,03113cd04947736d
84 | insticator.com,843c9a44-60ea-4342-8ad4-68f894283b3e,DIRECT,b3511ffcafb23a32
85 | sharethrough.com,Q9IzHdvp,DIRECT,d53b998a7bd4ecd2
86 | rubiconproject.com,17062,RESELLER,0bfd66d529a55807
87 | risecodes.com,6124caed9c7adb0001c028d8,DIRECT
88 | pubmatic.com,95054,DIRECT,5d62403b186f2ace
89 | openx.com,558230700,RESELLER,6a698e2ec38604c6
90 | video.unrulymedia.com,136898039,RESELLER
91 | lijit.com,257618,RESELLER,fafdf38b16bf6b2b
92 | minutemedia.com,01garg96c88b,RESELLER
93 | appnexus.com,3695,RESELLER,f5ab79cb980f11d1
94 | kargo.com, 8688, DIRECT
95 | kueez.com,e5b6208bc94ed2d5788e1e4c1cf5452e, DIRECT
96 | rubiconproject.com, 16920, RESELLER, 0bfd66d529a55807
97 | openx.com, 557564833, RESELLER, 6a698e2ec38604c6
98 | lijit.com, 407406, RESELLER, fafdf38b16bf6b2b #SOVRN
99 | appnexus.com, 8826,RESELLER, f5ab79cb980f11d1
100 | Media.net,8CU4JTRF9, RESELLER
101 | rubiconproject.com, 13762, RESELLER, 0bfd66d529a55807
102 | media.net, 8CU8ARTF8, DIRECT
103 | Media.net, 8CU198XI2, DIRECT
104 | themediagrid.com, LTW57M, DIRECT, 35d5010d7789b49d
105 | ogury.com, 086233d2-e8a8-44fc-907b-f0752e1c85de, DIRECT
106 | appnexus.com, 11470, RESELLER
107 | openx.com, 542378302, RESELLER, 6a698e2ec38604c6
108 | openx.com, 540134228, RESELLER, 6a698e2ec38604c6
109 | openx.com, 537144009, RESELLER, 6a698e2ec38604c6
110 | openx.com, 560557013, RESELLER, 6a698e2ec38604c6
111 | optidigital.com,p230,DIRECT
112 | pubmatic.com,158939,RESELLER,5d62403b186f2ace
113 | rubiconproject.com,20336,RESELLER,0bfd66d529a55807
114 | smartadserver.com,3379,RESELLER,060d053dcf45cbf3
115 | triplelift.com,8183,RESELLER,6c33edb13117fd86
116 | the-ozone-project.com, ozoneven0005, DIRECT
117 | openx.com, 540731760, RESELLER, 6a698e2ec38604c6
118 | pubmatic.com, 160557, RESELLER, 5d62403b186f2ace
119 | themediagrid.com, WF71T3, DIRECT, 35d5010d7789b49d
120 | Yahoo.com, 60170, DIRECT, e1a5b5b6e3255540
121 | pubmatic.com, 159234, RESELLER, 5d62403b186f2ace
122 | pubmatic.com, 160552, RESELLER, 5d62403b186f2ace
123 | pubmatic.com, 159401, RESELLER, 5d62403b186f2ace
124 | pubmatic.com, 165533, RESELLER, 5d62403b186f2ace
125 | richaudience.com, 1XvIoD5o0S, DIRECT
126 | pubmatic.com, 81564, DIRECT, 5d62403b186f2ace
127 | pubmatic.com, 156538, DIRECT, 5d62403b186f2ace
128 | appnexus.com, 8233, DIRECT
129 | rubiconproject.com, 13510, DIRECT
130 | risecodes.com, 5fa94677b2db6a00015b22a9, DIRECT
131 | pubmatic.com, 160295, RESELLER, 5d62403b186f2ace
132 | xandr.com, 14082, RESELLER
133 | rubiconproject.com, 23876, RESELLER, 0bfd66d529a55807
134 | sharethrough.com, 5926d422, RESELLER, d53b998a7bd4ecd2
135 | yieldmo.com, 2754490424016969782, RESELLER
136 | media.net, 8CUQ6928Q, RESELLER
137 | onetag.com, 69f48c2160c8113, RESELLER
138 | amxrtb.com, 105199691, RESELLER
139 | openx.com, 537140488, RESELLER, 6a698e2ec38604c6
140 | video.unrulymedia.com, 335119963, RESELLER
141 | seedtag.com, 68011633bc1ec10007f62708, DIRECT
142 | xandr.com, 4009, DIRECT, f5ab79cb980f11d1
143 | rubiconproject.com, 17280, DIRECT, 0bfd66d529a55807
144 | smartadserver.com, 3050, DIRECT
145 | lijit.com, 397546, DIRECT, fafdf38b16bf6b2b
146 | sharethrough.com, 31c129df, DIRECT, d53b998a7bd4ecd2
147 | sharethrough.com, awx1H4AI, RESELLER, d53b998a7bd4ecd2
148 | smaato.com, 1100055690, DIRECT, 07bcf65f187117b4
149 | smaato.com, 1100049216, DIRECT, 07bcf65f187117b5
150 | rubiconproject.com, 24600, RESELLER, 0bfd66d529a55807
151 | pubmatic.com, 156177, RESELLER, 5d62403b186f2ace
152 | smartadserver.com, 3490, DIRECT
153 | smartadserver.com, 4016, DIRECT
154 | smartadserver.com, 4074, DIRECT
155 | sovrn.com, 237754, DIRECT, fafdf38b16bf6b2b
156 | lijit.com, 237754, DIRECT, fafdf38b16bf6b2b
157 | lijit.com, 506352, DIRECT, fafdf38b16bf6b2b
158 | teads.tv, 23348, DIRECT, 15a9c44f6d26cbe1
159 | venatus.com, 1, DIRECT
160 | triplelift.com, 6059, RESELLER, 6c33edb13117fd86
161 | video.unrulymedia.com, 985572675, DIRECT
162 | video.unrulymedia.com, 985572675, RESELLER
163 | sharethrough.com, 6qlnf8SY, RESELLER, d53b998a7bd4ecd2
164 | adwmg.com, 101261, DIRECT, c9688a22012618e7
165 | google.com, pub-8622186303703569, DIRECT, f08c47fec0942fa0
166 | freewheel.tv, 1604590, DIRECT
167 | freewheel.tv, 1604595, DIRECT
168 | pubmatic.com, 156512, DIRECT
169 | indexexchange.com, 183753, DIRECT
170 | wunderkind.co, 6438, DIRECT
171 | wunderkind.co, 6449, DIRECT
172 | criteo.com, B-068503, DIRECT
173 | appnexus.com, 806, DIRECT, f5ab79cb980f11d1
174 | appnexus.com,1908,RESELLER,f5ab79cb980f11d1
175 | adinplay.com, FTB, DIRECT
176 |
--------------------------------------------------------------------------------
/components/skin-viewer/styles.module.scss:
--------------------------------------------------------------------------------
1 | @keyframes fadeOut {
2 | from {
3 | opacity: 1;
4 | }
5 | to {
6 | opacity: 0;
7 | }
8 | }
9 | @keyframes fadeOut50 {
10 | from {
11 | opacity: 0.5;
12 | }
13 | to {
14 | opacity: 0;
15 | }
16 | }
17 | @keyframes fadeIn {
18 | from {
19 | opacity: 0;
20 | }
21 | to {
22 | opacity: 1;
23 | }
24 | }
25 | @keyframes fadeIn50 {
26 | from {
27 | opacity: 0;
28 | }
29 | to {
30 | opacity: 0.5;
31 | }
32 | }
33 |
34 | .viewer {
35 | touch-action: "pan-y";
36 | cursor: none;
37 | overflow: clip;
38 | position: absolute;
39 | top: 0;
40 | left: 0;
41 | width: 100%;
42 | height: 100%;
43 |
44 | container-type: size;
45 |
46 | &.show {
47 | cursor: unset;
48 | }
49 |
50 | &.fill {
51 | cursor: grab;
52 |
53 | &:active {
54 | cursor: grabbing;
55 | }
56 | }
57 | }
58 |
59 | .hitbox {
60 | position: absolute;
61 | top: 0;
62 | left: 0;
63 | width: 100cqw;
64 | height: 100cqh;
65 | z-index: 1;
66 | }
67 |
68 | .viewer header {
69 | display: flex;
70 | // align-items: center;
71 | position: absolute;
72 | z-index: 999;
73 | user-select: none;
74 | gap: 12px;
75 |
76 | .backTo {
77 | filter: drop-shadow(0 0 5px black);
78 |
79 | display: flex;
80 | align-items: center;
81 | padding: 8px 4px;
82 | gap: 4px;
83 | white-space: nowrap;
84 | overflow: hidden;
85 | > svg {
86 | opacity: 0.5;
87 | transition: opacity 200ms;
88 | width: 24px;
89 | flex: 0 0 24px;
90 | }
91 | div {
92 | display: flex;
93 | gap: 4px;
94 | align-items: center;
95 | svg {
96 | height: 18px;
97 | }
98 | }
99 |
100 | &:hover {
101 | > svg {
102 | opacity: 1;
103 | }
104 | }
105 | }
106 |
107 | .controls {
108 | display: flex;
109 | align-items: center;
110 | gap: 4px;
111 |
112 | > * {
113 | display: flex;
114 | align-items: center;
115 | padding: 8px;
116 | opacity: 1;
117 | transition: opacity 200ms;
118 | filter: drop-shadow(0 0 5px black);
119 | cursor: pointer;
120 |
121 | &.dropdown {
122 | padding: 0 0 0 8px;
123 | }
124 |
125 | select {
126 | font-size: 0.9em;
127 | }
128 |
129 | @media (pointer: fine) {
130 | &:not(.dropdown) {
131 | opacity: 0.5;
132 | }
133 | &:hover {
134 | opacity: 1;
135 | }
136 | }
137 | }
138 | }
139 | }
140 |
141 | .letterBox {
142 | pointer-events: none;
143 |
144 | position: absolute;
145 | top: 0;
146 | left: 0;
147 | width: 100%;
148 | height: 100%;
149 | filter: blur(10px);
150 | z-index: 1;
151 | user-select: none;
152 | opacity: 0;
153 |
154 | video {
155 | position: absolute;
156 | width: 100%;
157 | height: 100%;
158 | }
159 | }
160 |
161 | .prev,
162 | .next {
163 | position: absolute;
164 | bottom: 0;
165 | padding: 8px 4px;
166 | z-index: 999;
167 | display: flex;
168 | align-items: flex-end;
169 | gap: 4px;
170 | filter: drop-shadow(0 0 5px black);
171 | opacity: 0.5;
172 | transition: opacity 200ms;
173 |
174 | div {
175 | max-width: calc(50cqw - 40px);
176 | overflow: hidden;
177 | line-height: 1.2;
178 | }
179 |
180 | svg {
181 | height: 18px;
182 | opacity: 0.5;
183 | }
184 |
185 | &::before {
186 | content: "";
187 | position: absolute;
188 | bottom: 0;
189 | height: calc(100% + 64px);
190 | width: calc(100% + 64px);
191 | pointer-events: none;
192 | z-index: -1;
193 | }
194 |
195 | @media (pointer: fine) {
196 | &:hover {
197 | opacity: 1;
198 | }
199 | }
200 |
201 | @media (pointer: coarse) {
202 | opacity: 1;
203 | }
204 | }
205 |
206 | .prev {
207 | left: 0;
208 | &::before {
209 | left: 0;
210 | }
211 | }
212 |
213 | .next {
214 | right: 0;
215 | &::before {
216 | right: 0;
217 | }
218 | text-align: right;
219 | }
220 |
221 | .overlay {
222 | position: absolute;
223 | height: 100%;
224 | width: 100%;
225 | z-index: 999;
226 | opacity: 0.3;
227 | transition: opacity 300ms ease;
228 | pointer-events: none;
229 | > * {
230 | pointer-events: auto;
231 | }
232 | }
233 |
234 | .viewer.show .overlay {
235 | opacity: 1;
236 | }
237 |
238 | .viewer main {
239 | pointer-events: none;
240 | position: absolute;
241 | width: 100%;
242 | height: 100%;
243 | z-index: 2;
244 | opacity: 0;
245 | top: 0;
246 | left: 0;
247 |
248 | user-select: none;
249 |
250 | video {
251 | position: absolute;
252 | width: 100%;
253 | height: 100%;
254 | }
255 | }
256 |
257 | .viewer.smoothX {
258 | main {
259 | transition: transform 300ms ease;
260 | }
261 | }
262 |
263 | .viewer.loaded {
264 | main {
265 | animation: fadeIn 300ms ease forwards;
266 | }
267 | .letterBox {
268 | animation: fadeIn50 300ms ease forwards;
269 | }
270 | }
271 |
272 | .viewer.exiting {
273 | main {
274 | transition: transform 300ms ease;
275 | animation: fadeOut 300ms ease forwards;
276 | }
277 |
278 | .letterBox {
279 | animation: fadeOut50 300ms ease forwards;
280 | }
281 | }
282 |
283 | .infoBox {
284 | position: relative;
285 | z-index: 999;
286 | top: 0;
287 | right: 0;
288 | filter: drop-shadow(0 0 5px black);
289 | height: 100%;
290 | width: 100%;
291 | pointer-events: none;
292 |
293 | .name {
294 | position: absolute;
295 | z-index: 999;
296 | white-space: nowrap;
297 | top: 0;
298 | right: 0;
299 | box-sizing: border-box;
300 | transition: opacity 300ms ease;
301 | opacity: 0.5;
302 | user-select: none;
303 | // display: flex;
304 | cursor: default;
305 | // align-items: center;
306 | transition: background 300ms ease, opacity 300ms ease;
307 | pointer-events: auto;
308 |
309 | h1 {
310 | all: inherit;
311 | }
312 |
313 | > div {
314 | display: flex;
315 | gap: 8px;
316 | padding: 8px;
317 | align-items: center;
318 |
319 | > span {
320 | display: flex;
321 | align-items: center;
322 | gap: 8px;
323 |
324 | img {
325 | width: 20px;
326 | }
327 | }
328 | }
329 | }
330 |
331 | .popup {
332 | width: 400px;
333 | max-width: calc(100cqw - 24px);
334 | max-height: calc(1cqh * 100 - 41px);
335 | overflow: auto;
336 | cursor: initial !important;
337 | position: absolute;
338 | top: 41px;
339 | pointer-events: none;
340 | right: 0;
341 | background: rgb(22, 33, 48);
342 | opacity: 0;
343 | transition: opacity 300ms ease, transform 300ms ease;
344 | padding: 8px;
345 | font-size: 1em;
346 | transform: translateY(6px);
347 | line-height: 1.5;
348 | color: rgb(165, 178, 196);
349 | font-weight: 300;
350 | }
351 |
352 | @supports (backdrop-filter: blur(1px)) {
353 | .popup {
354 | background: rgba(8, 13, 19, 0.8);
355 | backdrop-filter: blur(24px);
356 | }
357 | }
358 |
359 | @media (pointer: fine) {
360 | &:hover {
361 | .name {
362 | opacity: 1 !important;
363 | background: rgb(22, 33, 48);
364 | }
365 | @supports (backdrop-filter: blur(1px)) {
366 | .name {
367 | border-color: transparent;
368 | background: rgba(8, 13, 19, 0.8);
369 | // backdrop-filter: blur(24px);
370 | }
371 | }
372 | .popup {
373 | pointer-events: auto;
374 | opacity: 1;
375 | transform: none;
376 | transition-delay: none !important;
377 | }
378 | }
379 | }
380 |
381 | @media (pointer: coarse) {
382 | &.show {
383 | .name {
384 | opacity: 1 !important;
385 | background: rgb(22, 33, 48);
386 | }
387 | @supports (backdrop-filter: blur(1px)) {
388 | .name {
389 | border-color: transparent;
390 | background: rgba(8, 13, 19, 0.8);
391 | // backdrop-filter: blur(24px);
392 | }
393 | }
394 | .popup {
395 | pointer-events: all;
396 | opacity: 1;
397 | transform: none;
398 | transition-delay: 0;
399 | }
400 | }
401 | }
402 | }
403 |
404 | .viewer.show .infoBox .name {
405 | opacity: 0.7;
406 | }
407 |
408 | @media screen and (max-width: 1100px) {
409 | .popup {
410 | font-size: 0.9em !important;
411 | }
412 | }
413 |
414 | @media screen and (max-width: 600px) {
415 | .viewer header {
416 | width: 100%;
417 | justify-content: space-between;
418 | }
419 | .prev,
420 | .next {
421 | display: none;
422 | }
423 |
424 | .infoBox {
425 | top: unset;
426 | bottom: 0;
427 | .name {
428 | bottom: 0;
429 | top: unset;
430 | width: 100%;
431 | > div {
432 | justify-content: space-between;
433 | padding: 11.5px 8px;
434 | }
435 |
436 | height: unset;
437 | // padding-bottom: calc(env(safe-area-inset-bottom, 0) - 11.5px);
438 | }
439 | .popup {
440 | top: unset;
441 | --bottom: calc(36.5px + max(env(safe-area-inset-bottom, 0), 11.5px));
442 | --bottom: calc(36.5px + 11.5px);
443 | bottom: var(--bottom);
444 | // bottom: calc(36.5px + env(safe-area-inset-bottom, 0));
445 | max-width: unset;
446 | width: 100%;
447 | line-height: 1.45;
448 | max-height: calc(1cqh * 100 - var(--bottom));
449 | border-bottom: 1px solid rgba(121, 163, 207, 0.2);
450 | }
451 | }
452 | }
453 |
454 | .popup {
455 | nav {
456 | display: flex;
457 | gap: 4px 12px;
458 | flex-wrap: wrap;
459 | align-items: center;
460 | margin-bottom: 8px;
461 |
462 | > div {
463 | white-space: nowrap;
464 | display: flex;
465 | align-items: center;
466 | gap: 2px;
467 |
468 | svg {
469 | height: 18px;
470 | opacity: 0.5;
471 | }
472 |
473 | a {
474 | opacity: 0.7;
475 | transition: 200ms opacity;
476 | &:hover {
477 | opacity: 1;
478 | }
479 | }
480 |
481 | a:not(:last-child)::after {
482 | content: ", ";
483 | }
484 | }
485 | }
486 |
487 | p {
488 | margin: 0 0 8px 0;
489 | }
490 |
491 | h3 {
492 | margin: 8px -8px -8px;
493 | display: flex;
494 | text-transform: uppercase;
495 | align-items: center;
496 | justify-content: space-between;
497 | cursor: pointer;
498 | padding: 0.8em 10px;
499 |
500 | font-size: 0.9em;
501 | letter-spacing: 0.1em;
502 | border-top: 1px solid rgba(121, 163, 207, 0.2);
503 |
504 | > span {
505 | display: flex;
506 | align-items: center;
507 | gap: 0.4em;
508 | opacity: 0.7;
509 | transition: opacity 200ms;
510 |
511 | > svg {
512 | height: 1.4em;
513 | }
514 | }
515 |
516 | > svg {
517 | opacity: 0.5;
518 | transition: opacity 200ms;
519 | }
520 |
521 | &:hover {
522 | > span,
523 | > svg {
524 | opacity: 1;
525 | }
526 | }
527 | }
528 | }
529 |
530 | .chromas {
531 | display: grid;
532 | grid-template-columns: repeat(auto-fill, 180px);
533 | justify-content: space-evenly;
534 | padding-bottom: 0.5em;
535 |
536 | > div {
537 | width: 180px;
538 | height: 180px;
539 | position: relative;
540 |
541 | > div {
542 | width: 16px;
543 | height: 16px;
544 | border-radius: 100%;
545 | border: 1px solid white;
546 | }
547 | }
548 | }
549 |
550 | /** Ad Container */
551 |
552 | .adContainer {
553 | display: grid;
554 | grid-template-columns: 300px 1fr;
555 | position: fixed;
556 | top: 0;
557 | left: 0;
558 | width: 100%;
559 | height: 100%;
560 | padding-bottom: env(safe-area-inset-bottom, 0);
561 | }
562 |
563 | .mainContent {
564 | display: grid;
565 | grid-auto-flow: column;
566 | // grid-template-rows: 1fr;
567 | grid-template-rows: auto 1fr auto;
568 | }
569 |
570 | .viewerContainer {
571 | position: relative;
572 | height: 100%;
573 | }
574 |
575 | .adBanner {
576 | display: flex;
577 | justify-content: center;
578 | align-items: center;
579 | }
580 |
581 | .sidebar {
582 | border-right: 1px solid rgba(121, 163, 207, 0.2);
583 | padding-bottom: var(--asp-h);
584 | display: flex;
585 | flex-direction: column;
586 | justify-content: space-between;
587 | align-items: center;
588 | }
589 |
590 | @media screen and (max-width: 1200px) {
591 | .sidebar {
592 | display: none;
593 | }
594 | .adContainer {
595 | grid-template-columns: 1fr;
596 | }
597 | }
598 |
599 | .adBanner {
600 | border-bottom: 1px solid rgba(121, 163, 207, 0.2);
601 | }
602 |
--------------------------------------------------------------------------------
/public/offline.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Offline · Skin Explorer
8 |
64 |
65 |
66 |
67 |
68 |
73 |
74 |
You're Offline!
75 |
76 | Sadly, Skin Explorer doesn't work without an active internet connection.
77 | Please reconnect to the internet, then refresh this page.
78 |
79 |
80 |
81 |
112 |
113 |
114 |
--------------------------------------------------------------------------------
/components/skin-viewer/index.js:
--------------------------------------------------------------------------------
1 | import classNames from "classnames";
2 | import {
3 | ArrowLeft,
4 | ArrowRight,
5 | Download,
6 | Info,
7 | Maximize2,
8 | Minimize2,
9 | User,
10 | Users,
11 | } from "lucide-react";
12 | import Head from "next/head";
13 | import Link from "next/link";
14 | import { useRouter } from "next/router";
15 | import {
16 | Fragment,
17 | useCallback,
18 | useEffect,
19 | useMemo,
20 | useRef,
21 | useState,
22 | } from "react";
23 | import { useSwipeable } from "react-swipeable";
24 | import {
25 | asset,
26 | makeCanonical,
27 | makeDescription,
28 | makeImage,
29 | makeTitle,
30 | rarity,
31 | useEscapeTo,
32 | useLocalStorageState,
33 | } from "../../data/helpers";
34 | import Image from "../image";
35 | import { Loading } from "../loading";
36 | import { FooterAds, VenatusAd } from "../venatus";
37 | import { Popup } from "./popup";
38 | import styles from "./styles.module.scss";
39 |
40 | const _supportsPrefetch = () => {
41 | if (typeof window === "undefined") return false;
42 | const fakeLink = document.createElement("link");
43 | try {
44 | if (fakeLink.relList?.supports) {
45 | return fakeLink.relList.supports("prefetch");
46 | }
47 | } catch (err) {
48 | return false;
49 | }
50 | };
51 |
52 | const pseudoPrefetch = (skin, patch) => {
53 | new window.Image().src = asset(skin.splashPath, patch || "pbe");
54 | new window.Image().src = asset(skin.uncenteredSplashPath, patch || "pbe");
55 | };
56 |
57 | const prefetchLinks = (skin, patch) => {
58 | return skin.splashVideoPath ? (
59 | <>
60 |
65 |
70 | >
71 | ) : (
72 | <>
73 |
74 |
79 | >
80 | );
81 | };
82 |
83 | const canPlayWebM = () => {
84 | return (
85 | typeof window !== "undefined" &&
86 | document
87 | .createElement("video")
88 | .canPlayType('video/webm; codecs="vp8, vorbis"') === "probably"
89 | );
90 | };
91 |
92 | let draggingOrigin;
93 |
94 | const clamp = (v) => Math.min(1, Math.max(0, v));
95 |
96 | function _SkinViewer({
97 | backTo,
98 | linkTo,
99 | collectionName,
100 | collectionIcon,
101 | prev,
102 | next,
103 | skin,
104 | }) {
105 | const meta = skin.$skinExplorer;
106 | const supportsVideo = useMemo(() => canPlayWebM(), []);
107 | const supportsPrefetch = useMemo(() => _supportsPrefetch(), []);
108 |
109 | const router = useRouter();
110 | useEscapeTo(backTo);
111 | const [centered, setCentered] = useLocalStorageState(
112 | "viewer__centered",
113 | false
114 | );
115 | const [fill, setFill] = useLocalStorageState("viewer__fill", false);
116 | const [deltaX, setDeltaX] = useState(0);
117 | const [smoothX, setSmoothX] = useState(false);
118 | const [exiting, setExiting] = useState(false);
119 | const [loaded, setLoaded] = useState(true);
120 | const [showUI, setShowUI] = useState(true);
121 | const [showInfoBox, setShowInfoBox] = useState(false);
122 | const [position, setPosition] = useState({ top: 0.5, left: 0.5 });
123 | const [velocity, setVelocity] = useState({ top: 0, left: 0 });
124 | const [patch, setPatch] = useState("");
125 | const showUIRef = useRef();
126 | const dimensions = useRef({ width: 1, height: 1 });
127 |
128 | useEffect(() => {
129 | setDeltaX(0);
130 | setSmoothX(false);
131 | setExiting(false);
132 | setLoaded(false);
133 | setPosition({ top: 0.5, left: 0.5 });
134 | setPatch("");
135 | setVelocity({ top: 0, left: 0 });
136 |
137 | if (!supportsPrefetch) {
138 | pseudoPrefetch(skin);
139 | meta.changes && meta.changes.map((patch) => pseudoPrefetch(skin, patch));
140 | prev && pseudoPrefetch(prev);
141 | next && pseudoPrefetch(next);
142 | }
143 | }, [skin, supportsPrefetch, prev, next, meta]);
144 |
145 | useEffect(() => {
146 | if (Math.abs(velocity.top) < 0.000001 && Math.abs(velocity.left) < 0.000001)
147 | return;
148 |
149 | const i = requestAnimationFrame(() => {
150 | setPosition({
151 | top: clamp(position.top - velocity.top * 18),
152 | left: clamp(position.left - velocity.left * 18),
153 | });
154 | setVelocity({
155 | top: velocity.top * 0.95,
156 | left: velocity.left * 0.95,
157 | });
158 | });
159 | return () => cancelAnimationFrame(i);
160 | }, [position.top, position.left, velocity]);
161 |
162 | useEffect(() => {
163 | if (showUI) {
164 | clearTimeout(showUIRef.current);
165 | setTimeout(() => setShowUI(false), 3000);
166 | }
167 | }, [showUI, setShowUI]);
168 |
169 | const vidPath = supportsVideo
170 | ? centered
171 | ? skin.splashVideoPath
172 | : skin.collectionSplashVideoPath
173 | : false;
174 | const imgPath = centered ? skin.splashPath : skin.uncenteredSplashPath;
175 | const objectFit = fill ? "cover" : "contain";
176 | const objectPosition = fill
177 | ? `${position.left * 100}% ${position.top * 100}% `
178 | : "center center";
179 | const r = rarity(skin);
180 |
181 | const goPrevious = useCallback(
182 | (swipe) => {
183 | if (!prev || exiting) return;
184 | setExiting(true);
185 |
186 | if (swipe) {
187 | setDeltaX(swipe ? "100vw" : "80px");
188 | router.prefetch(router.pathname, linkTo(prev));
189 | setTimeout(() => router.replace(router.pathname, linkTo(prev)), 300);
190 | } else {
191 | router.replace(router.pathname, linkTo(prev));
192 | }
193 | },
194 | [router, linkTo, prev, setExiting, setDeltaX, exiting]
195 | );
196 |
197 | const goNext = useCallback(
198 | (swipe) => {
199 | if (!next || exiting) return;
200 | setExiting(true);
201 |
202 | if (swipe) {
203 | setDeltaX(swipe ? "-100vw" : "-80px");
204 | router.prefetch(router.pathname, linkTo(next));
205 | setTimeout(() => router.replace(router.pathname, linkTo(next)), 300);
206 | } else {
207 | router.replace(router.pathname, linkTo(next));
208 | }
209 | },
210 | [router, linkTo, next, setExiting, setDeltaX, exiting]
211 | );
212 |
213 | const toggleFill = useCallback(() => setFill(!fill), [fill, setFill]);
214 |
215 | const toggleCentered = useCallback(
216 | () => setCentered(!centered),
217 | [centered, setCentered]
218 | );
219 |
220 | /**
221 | * Download the current image. We have to do it this way because Chrome
222 | * decided that a[href][download] shouldn't work for CORS stuff.
223 | */
224 | const downloadActive = useCallback(async () => {
225 | const image = await fetch(asset(imgPath, patch || "pbe"));
226 | const imageBlog = await image.blob();
227 | const imageURL = URL.createObjectURL(imageBlog);
228 |
229 | const link = document.createElement("a");
230 | link.href = imageURL;
231 | link.download = `${skin.name}${
232 | patch ? " - Patch " + patch.replaceAll(".", "_") : ""
233 | }`;
234 | document.body.appendChild(link);
235 | link.click();
236 | document.body.removeChild(link);
237 | URL.revokeObjectURL(imageURL);
238 | }, [imgPath, patch, skin]);
239 |
240 | useEffect(() => {
241 | function onKeyDown(e) {
242 | if (e.key === "ArrowLeft") goPrevious(false);
243 | if (e.key === "ArrowRight") goNext(false);
244 | if (meta.changes && e.key === "ArrowUp")
245 | setPatch(meta.changes[meta.changes.indexOf(patch) - 1] || "");
246 | if (meta.changes && e.key === "ArrowDown")
247 | setPatch(meta.changes[meta.changes.indexOf(patch) + 1] || patch);
248 | if (e.code === "KeyZ") toggleFill();
249 | if (e.code === "KeyC") toggleCentered();
250 | if (e.code === "KeyD") downloadActive();
251 | }
252 | document.addEventListener("keydown", onKeyDown);
253 | return () => document.removeEventListener("keydown", onKeyDown);
254 | }, [
255 | goNext,
256 | goPrevious,
257 | toggleFill,
258 | toggleCentered,
259 | downloadActive,
260 | patch,
261 | meta.changes,
262 | ]);
263 |
264 | useEffect(() => {
265 | function onClick() {
266 | setShowInfoBox(false);
267 | }
268 |
269 | document.addEventListener("click", onClick);
270 | return () => document.removeEventListener("click", onClick);
271 | });
272 |
273 | const doPan = (x, y, isDelta = false) => {
274 | const delta = isDelta
275 | ? [x, y]
276 | : [x - draggingOrigin[0], y - draggingOrigin[1]];
277 | const { width, height } = dimensions.current;
278 | !isDelta && (draggingOrigin = [x, y]);
279 | setPosition({
280 | left: clamp(
281 | position.left -
282 | delta[0] / ((width / height) * window.innerHeight - window.innerWidth)
283 | ),
284 | top: clamp(
285 | position.top -
286 | delta[1] / ((height / width) * window.innerWidth - window.innerHeight)
287 | ),
288 | });
289 | };
290 |
291 | const handlers = useSwipeable({
292 | onSwipeStart(e) {
293 | e.event.preventDefault();
294 | if (fill) {
295 | draggingOrigin = [e.deltaX, e.deltaY];
296 | }
297 | },
298 | onSwiping(e) {
299 | e.event.preventDefault();
300 | if (fill) {
301 | doPan(e.deltaX, e.deltaY);
302 | } else {
303 | if (!prev) return;
304 | if (e.dir === "Left" || e.dir === "Right") {
305 | setDeltaX(`${e.deltaX}px`);
306 | setSmoothX(false);
307 | }
308 | }
309 | },
310 | onSwiped(e) {
311 | e.event.preventDefault();
312 | if (fill) {
313 | const { width, height } = dimensions.current;
314 | let left = e.vxvy[0] / (width - window.innerWidth),
315 | top = e.vxvy[1] / (height - window.innerHeight);
316 | if (Math.abs(e.vxvy[0]) < 0.8) left = 0;
317 | if (Math.abs(e.vxvy[1]) < 0.8) top = 0;
318 | setVelocity({
319 | left,
320 | top,
321 | });
322 | draggingOrigin = null;
323 | } else {
324 | setDeltaX(`0px`);
325 | setSmoothX(true);
326 | }
327 | },
328 | onSwipedLeft(e) {
329 | e.event.preventDefault();
330 | !fill && e.velocity > 0.6 && goNext(true);
331 | },
332 | onSwipedRight(e) {
333 | e.event.preventDefault();
334 | !fill && e.velocity > 0.6 && goPrevious(true);
335 | },
336 | onSwipedUp(e) {
337 | e.event.preventDefault();
338 | const { width, height } = dimensions.current;
339 |
340 | if (
341 | (!fill || (height / width) * window.innerWidth <= window.innerHeight) &&
342 | meta.changes
343 | )
344 | setPatch(
345 | meta.changes[
346 | (meta.changes.indexOf(patch) + 1) % (meta.changes.length + 1)
347 | ] || ""
348 | );
349 | },
350 | preventDefaultTouchmoveEvent: true,
351 | delta: { left: 3, right: 3, up: 50 },
352 | });
353 |
354 | return (
355 | <>
356 |
357 | {makeTitle(skin.name)}
358 | {makeDescription(
359 | skin.description || `Look at the splash art for ${skin.name}!`
360 | )}
361 | {makeImage(asset(skin.uncenteredSplashPath), skin.name)}
362 | {makeCanonical(`/champions/${meta.champion.key}/skins/${skin.id}`)}
363 | {prefetchLinks(skin)}
364 | {meta.changes &&
365 | meta.changes.map((patch) => (
366 | {prefetchLinks(skin, patch)}
367 | ))}
368 | {prev && prefetchLinks(prev)}
369 | {next && prefetchLinks(next)}
370 |
377 |
378 |
387 |
391 | setVelocity({
392 | top: 0,
393 | left: 0,
394 | })
395 | }
396 | onDoubleClick={toggleFill}
397 | onMouseDown={(e) => {
398 | if (fill) {
399 | draggingOrigin = [e.screenX, e.screenY];
400 | }
401 | }}
402 | onMouseMove={(e) => {
403 | if (fill && draggingOrigin) {
404 | doPan(e.screenX, e.screenY);
405 | }
406 | setShowUI(true);
407 | }}
408 | onMouseUp={(e) => {
409 | draggingOrigin = null;
410 | }}
411 | onWheel={(e) => {
412 | if (fill) {
413 | doPan(-e.deltaX, -e.deltaY, true);
414 | }
415 | }}
416 | />
417 |
474 |
e.stopPropagation()}
477 | >
478 |
setShowInfoBox(!showInfoBox)}
481 | >
482 |
483 |
484 | {r && (
485 |
495 | )}
496 | {skin.name}
497 |
498 |
499 |
500 |
501 |
502 |
503 |
504 | {vidPath ? (
505 |
515 | ) : (
516 |
524 | )}
525 |
526 |
527 |
531 | {vidPath ? (
532 |
549 | ) : (
550 | {
559 | dimensions.current = {
560 | width: naturalWidth,
561 | height: naturalHeight,
562 | };
563 | setLoaded(true);
564 | }}
565 | />
566 | )}
567 |
568 |
569 | >
570 | );
571 | }
572 |
573 | export function SkinViewer(props) {
574 | const router = useRouter();
575 | if (router.isFallback) {
576 | return (
577 | <>
578 |
586 |
587 |
588 | >
589 | );
590 | }
591 |
592 | return (
593 |
594 |
595 |
596 |
597 |
598 |
599 |
600 |
601 |
602 |
603 |
604 |
605 |
606 |
607 |
608 | <_SkinViewer {...props} />
609 |
610 | {/*
hey
*/}
611 |
612 |
613 |
614 | );
615 | }
616 |
--------------------------------------------------------------------------------