Skip to content

Commit 13fef40

Browse files
committed
Integrate content browser directly into the website
1 parent ae4ddbe commit 13fef40

File tree

4 files changed

+414
-19
lines changed

4 files changed

+414
-19
lines changed

_includes/head.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,7 @@
2727
{%- if page.use_iframe -%}
2828
<link rel="stylesheet" href="{{ "/assets/iframe.css" | relative_url }}" >
2929
{%- endif -%}
30+
{%- if page.content_explorer -%}
31+
<link rel="stylesheet" href="{{ "/assets/content-explorer.css" | relative_url }}" >
32+
{%- endif -%}
3033
</head>

assets/content-explorer.css

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
:root {
2+
--primary: hsl(87, 38%, 13%);
3+
--secondary: hsl(105, 6%, 45%);
4+
--tertiary: hsl(79, 15%, 95%);
5+
--light: hsl(92, 100%, 99%);
6+
--accent: hsl(99, 74%, 92%);
7+
--accent-alt: hsl(101, 100%, 97%);
8+
--accent-ui: hsl(106, 28%, 48%);
9+
--semantic-green: hsl(88, 76%, 83%);
10+
--semantic-red: hsl(0, 76%, 83%);
11+
}
12+
13+
button,
14+
.btn {
15+
display: block;
16+
text-wrap: nowrap;
17+
border: 1px solid var(--secondary);
18+
border-radius: 0.5em;
19+
cursor: pointer;
20+
padding: 0.4em 1em;
21+
font-size: inherit;
22+
position: relative;
23+
color: var(--primary);
24+
background-color: var(--tertiary);
25+
text-decoration: none;
26+
}
27+
28+
button:hover,
29+
.btn:hover {
30+
opacity: 0.7;
31+
}
32+
33+
button:active,
34+
.btn:active {
35+
opacity: 0.9;
36+
}
37+
38+
button[data-secondary] {
39+
color: var(--accent-ui);
40+
background-color: var(--accent);
41+
border-color: var(--accent-ui);
42+
font-weight: 700;
43+
}
44+
45+
button[data-primary],
46+
.btn[data-primary] {
47+
color: var(--light);
48+
background-color: var(--accent-ui);
49+
border-color: var(--accent-ui);
50+
outline: 1px solid var(--accent);
51+
outline-offset: -0.15em;
52+
padding: 0.4em 0.8em;
53+
font-weight: 700;
54+
}
55+
56+
button:hover {
57+
opacity: 0.7;
58+
}
59+
60+
.button-group {
61+
display: flex;
62+
}
63+
64+
.button-group > button {
65+
border-radius: 0;
66+
}
67+
68+
.button-group > :first-child {
69+
border-top-left-radius: 0.5em;
70+
border-bottom-left-radius: 0.5em;
71+
}
72+
73+
.button-group > :last-child {
74+
border-top-right-radius: 0.5em;
75+
border-bottom-right-radius: 0.5em;
76+
}
77+
78+
.button-group > :not(:last-child) {
79+
border-right: 0;
80+
}
81+
82+
.selection {
83+
width: 100%;
84+
height: 7em;
85+
border-radius: 1em;
86+
overflow: hidden;
87+
background-color: var(--tertiary);
88+
position: relative;
89+
cursor: pointer;
90+
z-index: 1;
91+
}
92+
93+
.selected.selection {
94+
outline: 3px solid white;
95+
outline-offset: -0.4em;
96+
box-shadow: 0.2em 0.2em 0.5em hsla(0, 0%, 0%, 0.3);
97+
}
98+
99+
.selection::before {
100+
content: '';
101+
top: 0;
102+
left: 0;
103+
width: 100%;
104+
height: 100%;
105+
position: absolute;
106+
background-image: var(--image);
107+
background-size: cover;
108+
background-position: center;
109+
filter: grayscale(100%);
110+
z-index: -1;
111+
}
112+
113+
.selection-inside {
114+
position: absolute;
115+
bottom: 0.2em;
116+
left: 0.4em;
117+
font-weight: bold;
118+
font-size: 1.7em;
119+
color: white;
120+
}
121+
122+
.gradient {
123+
position: relative;
124+
width: 100%;
125+
height: 100%;
126+
background: linear-gradient(transparent 0 20%, var(--secondary));
127+
}
128+
129+
.selected .gradient {
130+
background: linear-gradient(transparent 0 20%, var(--accent-ui));
131+
}
132+
133+
input {
134+
border: 1px solid var(--secondary);
135+
border-radius: 0.5em;
136+
font-family: inherit;
137+
padding: 0.4em 1em;
138+
font-size: inherit;
139+
position: relative;
140+
color: var(--primary);
141+
background-color: var(--tertiary);
142+
}
143+
144+
input::placeholder {
145+
font-style: italic;
146+
}
147+
148+
.content-explorer-container {
149+
display: flex;
150+
flex-direction: column;
151+
gap: 2em;
152+
max-width: 700px;
153+
margin: 2em auto 0.5em auto;
154+
accent-color: var(--accent-ui);
155+
line-height: 1.4;
156+
}
157+
158+
.selection-container {
159+
display: flex;
160+
gap: 1.5em;
161+
}
162+
163+
.filter-container {
164+
display: flex;
165+
gap: 0.5em;
166+
}
167+
168+
.filter-container > input {
169+
width: 100%;
170+
}
171+
172+
#list-container {
173+
display: flex;
174+
flex-direction: column;
175+
gap: 1em;
176+
}
177+
178+
.item {
179+
max-width: 100%;
180+
background-color: var(--tertiary);
181+
border: 1px solid var(--secondary);
182+
border-radius: 0.5em;
183+
padding: 1em;
184+
text-align: justify;
185+
overflow: auto;
186+
}
187+
188+
.item img {
189+
width: 12em;
190+
object-fit: contain;
191+
border-radius: 0.25em;
192+
float: left;
193+
margin-right: 1em;
194+
outline: 1px solid hsla(0, 0%, 100%, 0.5);
195+
outline-offset: -0.2em;
196+
}
197+
198+
.item p {
199+
margin: 1em 0 0 0;
200+
}
201+
202+
.item h3 {
203+
margin: 0 0;
204+
color: var(--primary);
205+
}
206+
207+
.item .author {
208+
margin: 0;
209+
color: var(--secondary);
210+
}
211+
212+
.item .item-header {
213+
display: flex;
214+
justify-content: space-between;
215+
gap: 1em;
216+
text-align: start;
217+
}
218+
219+
.item .item-header .author-url {
220+
color: inherit;
221+
text-underline-offset: 0.2em;
222+
}
223+
224+
.size-badge {
225+
border-radius: 0.3em;
226+
font-family: monospace;
227+
padding: 0.1em 0.2em;
228+
background-color: var(--tertiary);
229+
font-size: 0.9em;
230+
}
231+
232+
.item .size-badge {
233+
background-color: var(--light);
234+
border: 1px solid var(--secondary);
235+
}

assets/content-explorer.js

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// TODO: Better ordering algorithm
2+
// TODO: Train cars + train sets
3+
4+
const listContainer = document.getElementById('list-container');
5+
const COMPENSATION_TEXTS = { free: 'Download (free)', donation: 'Download (donation-ware)', commercial: 'Buy (commercial)' };
6+
7+
const _routes = await fetch('https://raw.githubusercontent.com/openrails/content/main/routes.json');
8+
const routes = (await _routes.json()).map((item, index) => ({ index, ...item }));
9+
10+
let selectedCard = 'routes';
11+
let filters = ['free', 'donation', 'commercial'];
12+
13+
const addOrRemove = (arr, item) => (arr.includes(item) ? arr.filter((i) => i !== item) : [...arr, item]);
14+
15+
function fuzzySearch(input, data, keys) {
16+
// Convert input to lowercase for case-insensitive search
17+
const searchQueries = input.toLowerCase().split(/\s+/);
18+
19+
// Filter the data array based on the fuzzy search
20+
const result = data.filter((item) => {
21+
// Check if all search queries are present in any of the keys
22+
return searchQueries.every((searchQuery) => {
23+
// Check each key in the keys array
24+
return keys.some((key) => {
25+
// Get the nested value if key is nested
26+
const value = key.split('.').reduce((acc, currentKey) => {
27+
return acc ? acc[currentKey] : undefined;
28+
}, item);
29+
30+
// If value is a string, check if it includes the search query
31+
if (typeof value === 'string') {
32+
return value.toLowerCase().includes(searchQuery);
33+
}
34+
35+
return false;
36+
});
37+
});
38+
});
39+
40+
return result;
41+
}
42+
43+
// Modified function from https://stackoverflow.com/a/18650828
44+
function formatBytes(bytes, decimals = 2) {
45+
if (!+bytes) return '0 Bytes';
46+
47+
const k = 1000;
48+
const dm = decimals < 0 ? 0 : decimals;
49+
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
50+
51+
const i = Math.floor(Math.log(bytes) / Math.log(k));
52+
53+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
54+
}
55+
56+
const selectionCards = document.getElementsByClassName('selection');
57+
for (const card of selectionCards) {
58+
card.addEventListener('click', (e) => selectCard(e));
59+
}
60+
61+
const filterButtons = document.getElementsByClassName('filter-btn');
62+
for (const btn of filterButtons) {
63+
btn.addEventListener('click', (e) => toggleFilter(e));
64+
}
65+
66+
const searchInput = document.getElementById('search');
67+
searchInput.addEventListener('input', generateList);
68+
69+
function selectCard(e) {
70+
for (const card of selectionCards) {
71+
card.classList.remove('selected');
72+
}
73+
74+
e.currentTarget.classList.add('selected');
75+
selectedCard = e.currentTarget.dataset.card;
76+
}
77+
78+
function toggleFilter(e) {
79+
const filterType = e.target.dataset.type;
80+
if (filters.length === 1 && filters.includes(filterType)) return;
81+
82+
e.target.toggleAttribute('data-secondary');
83+
filters = addOrRemove(filters, filterType);
84+
85+
generateList();
86+
}
87+
88+
function updateContainer(elementsArray) {
89+
const existingChildren = Array.from(listContainer.children);
90+
91+
// Remove elements that are not in the new array
92+
existingChildren.forEach((child) => {
93+
const index = Number(child.dataset.index);
94+
if (!elementsArray.some((e) => Number(e.dataset.index) === index)) {
95+
listContainer.removeChild(child);
96+
}
97+
});
98+
99+
// Insert new elements at their correct position
100+
elementsArray.forEach((element) => {
101+
const currentChildren = Array.from(listContainer.children);
102+
const index = Number(element.dataset.index);
103+
104+
if (currentChildren.some((e) => Number(e.dataset.index) === index)) return;
105+
106+
const nextElement = currentChildren.find((e) => Number(e.dataset.index) > index);
107+
listContainer.insertBefore(element, nextElement);
108+
});
109+
}
110+
111+
function generateList() {
112+
const newItems = [];
113+
114+
const filteredRoutes = fuzzySearch(searchInput.value, routes, ['name', 'description', 'author.name']);
115+
116+
for (const route of filteredRoutes.filter((r) => filters.includes(r.compensation))) {
117+
const element = document.createElement('div');
118+
119+
const authorComponent = route.author.url ? `<a class="author-url" href="${route.author.url}" target="_blank">${route.author.name}</a>` : route.author.name;
120+
element.innerHTML = `
121+
<img src="${route.image}" />
122+
<div class="item-header">
123+
<div>
124+
<h3>${route.name}</h3>
125+
<p class="author">created by ${authorComponent}</p>
126+
</div>
127+
<div><a class="btn" href="${route.url}" data-primary>${COMPENSATION_TEXTS[route.compensation]}</a></div>
128+
</div>
129+
<p>
130+
${route.description}
131+
</p>
132+
${route.downloadSize || route.installSize ? '<div style="margin-top: 0.5em;"></div>' : ''}
133+
${route.downloadSize ? `<span class="size-badge">Download size: ${formatBytes(route.downloadSize)}</span>` : ''}
134+
${route.installSize ? `<span class="size-badge">Install size: ${formatBytes(route.installSize)}</span>` : ''}
135+
`;
136+
137+
element.classList.add('item');
138+
element.dataset.index = route.index;
139+
newItems.push(element);
140+
}
141+
142+
updateContainer(newItems);
143+
}
144+
145+
generateList();

0 commit comments

Comments
 (0)