unmagic-icon 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -1
- data/README.md +6 -0
- data/lib/unmagic/icon/library/source/bootstrap_icons.rb +17 -0
- data/lib/unmagic/icon/library/source/coloured_icons.rb +20 -0
- data/lib/unmagic/icon/library/source/iconoir.rb +23 -0
- data/lib/unmagic/icon/library/source/material_design_icons.rb +19 -0
- data/lib/unmagic/icon/library/source/octicons.rb +18 -0
- data/lib/unmagic/icon/library/source/phosphor.rb +21 -0
- data/lib/unmagic/icon/library/source.rb +6 -0
- data/lib/unmagic/icon/version.rb +1 -1
- data/lib/unmagic/icon/web/views/icons.html.erb +6 -0
- data/lib/unmagic/icon/web/views/layout.html.erb +137 -52
- data/lib/unmagic/icon/web.rb +88 -27
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 17b7d09638783ea1144e26806aa2e7915d04e08645ed39c2df6a2f3026a69bdf
|
|
4
|
+
data.tar.gz: 39b8e9e8fc1a88c494c1336224b88632c0f5153e002fa2a2663b1f476c42f27b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 58e783d9c8829e92c70149d48f95a012a092123fa85ec2e92f68eb30203d4028722f7db8aad66c487880749b5e9647062c11cc4118c06faf71b5784fd617a9ea
|
|
7
|
+
data.tar.gz: d5ecd53c6ee72f8aaa9050221d4a619fc58a778f9998896a50530a1cee737564f3667e5bb4435040c69c645861dcb8a0ca829bcb3b923db025d51b32800a1882
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-06-22
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- New downloadable icon libraries: `coloured-icons` (full-colour brand/tech
|
|
14
|
+
logos), `bootstrap-icons`, `octicons`, `iconoir`, `material-design-icons`
|
|
15
|
+
(Pictogrammers @mdi), and `phosphor` (six weights flattened into one set)
|
|
16
|
+
- Server-side search in the `Unmagic::Icon::Web` Rack app
|
|
17
|
+
|
|
10
18
|
## [0.1.0] - 2026-06-21
|
|
11
19
|
|
|
12
20
|
### Added
|
|
@@ -33,5 +41,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
33
41
|
hooks into `assets:precompile`) and `unmagic:icons:download[library]`
|
|
34
42
|
- `Unmagic::Icon::Web`, a Rack app for browsing the configured icon libraries
|
|
35
43
|
|
|
36
|
-
[Unreleased]: https://github.com/unreasonable-magic/unmagic-icon/compare/v0.
|
|
44
|
+
[Unreleased]: https://github.com/unreasonable-magic/unmagic-icon/compare/v0.2.0...HEAD
|
|
45
|
+
[0.2.0]: https://github.com/unreasonable-magic/unmagic-icon/compare/v0.1.0...v0.2.0
|
|
37
46
|
[0.1.0]: https://github.com/unreasonable-magic/unmagic-icon/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -127,6 +127,12 @@ Available libraries:
|
|
|
127
127
|
| `simple-icons` | Simple Icons | SVG icons for popular brands |
|
|
128
128
|
| `material-file-icons` | Material File Icons | Material Design file icons with filename/extension aliases |
|
|
129
129
|
| `silk` | Silk Icons Scalable | The classic silk icon set recreated as SVG |
|
|
130
|
+
| `coloured-icons` | Coloured Icons | Full-colour brand and technology logos |
|
|
131
|
+
| `bootstrap-icons` | Bootstrap Icons | Official open source SVG icon library for Bootstrap |
|
|
132
|
+
| `octicons` | Octicons | Icons and icon font from GitHub |
|
|
133
|
+
| `iconoir` | Iconoir | Free open source icons designed on a 24x24 grid |
|
|
134
|
+
| `material-design-icons` | Material Design Icons | 7400+ Material Design icons (Pictogrammers @mdi) |
|
|
135
|
+
| `phosphor` | Phosphor Icons | Flexible icon family with six weights (thin to fill, plus duotone) |
|
|
130
136
|
|
|
131
137
|
### Browsing icons
|
|
132
138
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Unmagic
|
|
2
|
+
class Icon
|
|
3
|
+
class Library
|
|
4
|
+
class Source
|
|
5
|
+
class BootstrapIcons < Source
|
|
6
|
+
key :"bootstrap-icons"
|
|
7
|
+
title "Bootstrap Icons"
|
|
8
|
+
description "Official open source SVG icon library for Bootstrap"
|
|
9
|
+
url "https://github.com/twbs/icons/archive/refs/tags/v1.13.1.zip"
|
|
10
|
+
archive :zip
|
|
11
|
+
dir "bootstrap-icons"
|
|
12
|
+
extract "icons-1.13.1/icons/*.svg"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Unmagic
|
|
2
|
+
class Icon
|
|
3
|
+
class Library
|
|
4
|
+
class Source
|
|
5
|
+
# Coloured Icons: full-colour brand/logo svgs organised by category in
|
|
6
|
+
# the repo (public/logos/<category>/<name>/<name>.svg). No release asset,
|
|
7
|
+
# but GitHub serves a tag archive; we flatten every svg into one library.
|
|
8
|
+
class ColouredIcons < Source
|
|
9
|
+
key :"coloured-icons"
|
|
10
|
+
title "Coloured Icons"
|
|
11
|
+
description "Full-colour brand and technology logos"
|
|
12
|
+
url "https://github.com/dheereshag/coloured-icons/archive/refs/tags/1.9.7.zip"
|
|
13
|
+
archive :zip
|
|
14
|
+
dir "coloured-icons"
|
|
15
|
+
extract "coloured-icons-1.9.7/public/logos/**/*.svg"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Unmagic
|
|
2
|
+
class Icon
|
|
3
|
+
class Library
|
|
4
|
+
class Source
|
|
5
|
+
# Ships regular and solid styles under icons/<style>; keep them as
|
|
6
|
+
# separate sub-libraries (iconoir/regular, iconoir/solid) so the shared
|
|
7
|
+
# icon names don't collide.
|
|
8
|
+
class Iconoir < Source
|
|
9
|
+
key :iconoir
|
|
10
|
+
title "Iconoir"
|
|
11
|
+
description "Free open source icons designed on a 24x24 grid"
|
|
12
|
+
url "https://github.com/iconoir-icons/iconoir/archive/refs/tags/v7.11.1.zip"
|
|
13
|
+
archive :zip
|
|
14
|
+
dir "iconoir"
|
|
15
|
+
extract_into(
|
|
16
|
+
"iconoir-7.11.1/icons/regular" => "regular",
|
|
17
|
+
"iconoir-7.11.1/icons/solid" => "solid"
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Unmagic
|
|
2
|
+
class Icon
|
|
3
|
+
class Library
|
|
4
|
+
class Source
|
|
5
|
+
# Pictogrammers' Material Design Icons (@mdi/svg) — the general-purpose
|
|
6
|
+
# icon set, distinct from the file-type icons in MaterialFileIcons.
|
|
7
|
+
class MaterialDesignIcons < Source
|
|
8
|
+
key :"material-design-icons"
|
|
9
|
+
title "Material Design Icons"
|
|
10
|
+
description "7400+ Material Design icons (Pictogrammers @mdi)"
|
|
11
|
+
url "https://github.com/Templarian/MaterialDesign-SVG/archive/refs/tags/v7.4.47.zip"
|
|
12
|
+
archive :zip
|
|
13
|
+
dir "material-design-icons"
|
|
14
|
+
extract "MaterialDesign-SVG-7.4.47/svg/*.svg"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Unmagic
|
|
2
|
+
class Icon
|
|
3
|
+
class Library
|
|
4
|
+
class Source
|
|
5
|
+
# GitHub's icon set. Names carry their size (e.g. alert-16, alert-24).
|
|
6
|
+
class Octicons < Source
|
|
7
|
+
key :octicons
|
|
8
|
+
title "Octicons"
|
|
9
|
+
description "Icons and icon font from GitHub"
|
|
10
|
+
url "https://github.com/primer/octicons/archive/refs/tags/v19.28.1.zip"
|
|
11
|
+
archive :zip
|
|
12
|
+
dir "octicons"
|
|
13
|
+
extract "octicons-19.28.1/icons/*.svg"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Unmagic
|
|
2
|
+
class Icon
|
|
3
|
+
class Library
|
|
4
|
+
class Source
|
|
5
|
+
# Six weights live under assets/<weight>; every non-regular filename
|
|
6
|
+
# already carries its weight suffix (ghost-bold, ghost-fill, …), so we
|
|
7
|
+
# flatten all weights into one library without name collisions. Regular
|
|
8
|
+
# is the bare name (ghost), the rest are ghost-<weight>.
|
|
9
|
+
class Phosphor < Source
|
|
10
|
+
key :phosphor
|
|
11
|
+
title "Phosphor Icons"
|
|
12
|
+
description "Flexible icon family with six weights (thin to fill, plus duotone)"
|
|
13
|
+
url "https://github.com/phosphor-icons/core/archive/refs/tags/v2.0.8.zip"
|
|
14
|
+
archive :zip
|
|
15
|
+
dir "phosphor"
|
|
16
|
+
extract "core-2.0.8/assets/*/*.svg"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -240,3 +240,9 @@ require_relative "source/lucide"
|
|
|
240
240
|
require_relative "source/simple_icons"
|
|
241
241
|
require_relative "source/material_file_icons"
|
|
242
242
|
require_relative "source/silk"
|
|
243
|
+
require_relative "source/coloured_icons"
|
|
244
|
+
require_relative "source/bootstrap_icons"
|
|
245
|
+
require_relative "source/octicons"
|
|
246
|
+
require_relative "source/iconoir"
|
|
247
|
+
require_relative "source/material_design_icons"
|
|
248
|
+
require_relative "source/phosphor"
|
data/lib/unmagic/icon/version.rb
CHANGED
|
@@ -181,13 +181,38 @@
|
|
|
181
181
|
padding: 40px;
|
|
182
182
|
font-size: 14px;
|
|
183
183
|
}
|
|
184
|
-
|
|
184
|
+
|
|
185
|
+
.load-more-wrap {
|
|
186
|
+
display: flex;
|
|
187
|
+
justify-content: center;
|
|
188
|
+
margin: 30px 0 10px;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.load-more {
|
|
192
|
+
padding: 10px 24px;
|
|
193
|
+
background: var(--tab-bg);
|
|
194
|
+
border: 1px solid var(--tab-border);
|
|
195
|
+
border-radius: 4px;
|
|
196
|
+
cursor: pointer;
|
|
197
|
+
font-size: 14px;
|
|
198
|
+
color: var(--fg);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.load-more:hover {
|
|
202
|
+
background: var(--tab-hover);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.load-more:disabled {
|
|
206
|
+
cursor: default;
|
|
207
|
+
opacity: 0.6;
|
|
208
|
+
}
|
|
209
|
+
|
|
185
210
|
[hidden] {
|
|
186
211
|
display: none !important;
|
|
187
212
|
}
|
|
188
213
|
</style>
|
|
189
|
-
<link rel="icon" href="<%=
|
|
190
|
-
<link rel="apple-touch-icon" href="<%=
|
|
214
|
+
<link rel="icon" href="<%= asset_url "/public/favicon.png" %>" type="image/png">
|
|
215
|
+
<link rel="apple-touch-icon" href="<%= asset_url "/public/favicon.png" %>">
|
|
191
216
|
</head>
|
|
192
217
|
<body>
|
|
193
218
|
<div class="header">
|
|
@@ -204,8 +229,12 @@
|
|
|
204
229
|
</div>
|
|
205
230
|
|
|
206
231
|
<div class="tabs">
|
|
232
|
+
<a href="<%= library_url(nil) %>"
|
|
233
|
+
class="tab <%= @selected_library.nil? ? 'active' : '' %>">
|
|
234
|
+
All (<%= @libraries.sum { |library| library.icons.length } %>)
|
|
235
|
+
</a>
|
|
207
236
|
<% @libraries.each do |library| %>
|
|
208
|
-
<a href="<%=
|
|
237
|
+
<a href="<%= library_url(library.name) %>"
|
|
209
238
|
class="tab <%= library == @selected_library ? 'active' : '' %>">
|
|
210
239
|
<%= library.name %> (<%= library.icons.length %>)
|
|
211
240
|
</a>
|
|
@@ -214,57 +243,100 @@
|
|
|
214
243
|
</div>
|
|
215
244
|
|
|
216
245
|
<div class="stats" id="stats"></div>
|
|
217
|
-
|
|
218
|
-
<div class="icon-grid" id="iconGrid"
|
|
219
|
-
|
|
246
|
+
|
|
247
|
+
<div class="icon-grid" id="iconGrid">
|
|
248
|
+
<% if @page_icons.empty? %>
|
|
249
|
+
<div class="no-results">No icons found</div>
|
|
250
|
+
<% else %>
|
|
251
|
+
<%= render_icons(@page_icons) %>
|
|
252
|
+
<% end %>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<div class="load-more-wrap" id="loadMoreWrap" hidden>
|
|
256
|
+
<button class="load-more" id="loadMore">Load more</button>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
220
259
|
<div class="copy-toast" id="copyToast">Copied to clipboard!</div>
|
|
221
260
|
|
|
222
261
|
<script>
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
262
|
+
// The current selection and paging state. Icons themselves are rendered by
|
|
263
|
+
// the server (the `icons` partial); the browser only fetches more of them.
|
|
264
|
+
const libraryParam = <%= @selected_param.to_json %>;
|
|
265
|
+
const libraryLabel = <%= (@selected_library&.name || "All").to_json %>;
|
|
266
|
+
const pageSize = <%= Unmagic::Icon::Web::PAGE_SIZE %>;
|
|
267
|
+
|
|
268
|
+
let searchQuery = <%= @search_query.to_json %>;
|
|
269
|
+
let total = <%= @total %>;
|
|
270
|
+
let loadedOffset = <%= @offset %>;
|
|
271
|
+
|
|
230
272
|
// Elements
|
|
231
273
|
const searchInput = document.getElementById('searchInput');
|
|
232
274
|
const iconGrid = document.getElementById('iconGrid');
|
|
233
275
|
const stats = document.getElementById('stats');
|
|
234
276
|
const copyToast = document.getElementById('copyToast');
|
|
235
277
|
const themeSwitcher = document.getElementById('themeSwitcher');
|
|
236
|
-
|
|
278
|
+
const loadMoreWrap = document.getElementById('loadMoreWrap');
|
|
279
|
+
const loadMoreBtn = document.getElementById('loadMore');
|
|
280
|
+
|
|
237
281
|
// Functions
|
|
238
|
-
function
|
|
239
|
-
|
|
240
|
-
const filtered = query
|
|
241
|
-
? iconsData.filter(icon => icon.name.toLowerCase().includes(query))
|
|
242
|
-
: iconsData;
|
|
243
|
-
|
|
244
|
-
renderIcons(filtered);
|
|
245
|
-
updateStats(filtered.length);
|
|
282
|
+
function renderedCount() {
|
|
283
|
+
return iconGrid.querySelectorAll('.icon-item').length;
|
|
246
284
|
}
|
|
247
|
-
|
|
248
|
-
function
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
285
|
+
|
|
286
|
+
function updateStats() {
|
|
287
|
+
stats.textContent = `Showing ${renderedCount()} of ${total} icons from ${libraryLabel}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function updateLoadMore() {
|
|
291
|
+
loadMoreWrap.hidden = renderedCount() >= total;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Build the URL for a page of rendered icon markup from the server.
|
|
295
|
+
function pageUrl(offset) {
|
|
296
|
+
const params = new URLSearchParams();
|
|
297
|
+
params.set('library', libraryParam);
|
|
298
|
+
if (searchQuery) params.set('search', searchQuery);
|
|
299
|
+
params.set('offset', offset);
|
|
300
|
+
params.set('format', 'fragment');
|
|
301
|
+
return window.location.pathname + '?' + params.toString();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let loading = false;
|
|
305
|
+
async function loadMore() {
|
|
306
|
+
if (loading) return;
|
|
307
|
+
loading = true;
|
|
308
|
+
loadMoreBtn.disabled = true;
|
|
309
|
+
loadMoreBtn.textContent = 'Loading…';
|
|
310
|
+
try {
|
|
311
|
+
const res = await fetch(pageUrl(loadedOffset + pageSize));
|
|
312
|
+
total = parseInt(res.headers.get('X-Total-Count'), 10);
|
|
313
|
+
loadedOffset = parseInt(res.headers.get('X-Offset'), 10);
|
|
314
|
+
iconGrid.insertAdjacentHTML('beforeend', await res.text());
|
|
315
|
+
} finally {
|
|
316
|
+
loading = false;
|
|
317
|
+
loadMoreBtn.disabled = false;
|
|
318
|
+
loadMoreBtn.textContent = 'Load more';
|
|
319
|
+
updateStats();
|
|
320
|
+
updateLoadMore();
|
|
252
321
|
}
|
|
253
|
-
|
|
254
|
-
iconGrid.innerHTML = icons.map(icon => {
|
|
255
|
-
return `
|
|
256
|
-
<div class="icon-item" data-icon="${icon.name}">
|
|
257
|
-
${icon.svg}
|
|
258
|
-
<div class="icon-name">${icon.name}</div>
|
|
259
|
-
</div>
|
|
260
|
-
`;
|
|
261
|
-
}).join('');
|
|
262
322
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
323
|
+
|
|
324
|
+
// Live search: re-request the first page from the server. A sequence guard
|
|
325
|
+
// drops responses that arrive out of order while the user keeps typing.
|
|
326
|
+
let searchSeq = 0;
|
|
327
|
+
async function runSearch() {
|
|
328
|
+
const seq = ++searchSeq;
|
|
329
|
+
const res = await fetch(pageUrl(0));
|
|
330
|
+
const html = await res.text();
|
|
331
|
+
if (seq !== searchSeq) return;
|
|
332
|
+
|
|
333
|
+
total = parseInt(res.headers.get('X-Total-Count'), 10);
|
|
334
|
+
loadedOffset = 0;
|
|
335
|
+
iconGrid.innerHTML = html.trim() ? html : '<div class="no-results">No icons found</div>';
|
|
336
|
+
updateStats();
|
|
337
|
+
updateLoadMore();
|
|
266
338
|
}
|
|
267
|
-
|
|
339
|
+
|
|
268
340
|
function copyIcon(iconName) {
|
|
269
341
|
const text = `${iconName}`;
|
|
270
342
|
navigator.clipboard.writeText(text).then(() => {
|
|
@@ -275,18 +347,26 @@
|
|
|
275
347
|
}
|
|
276
348
|
|
|
277
349
|
function updateURL() {
|
|
278
|
-
|
|
350
|
+
// Preserve the current `library` selection, only swap `search`.
|
|
351
|
+
const params = new URLSearchParams(window.location.search);
|
|
279
352
|
if (searchQuery) {
|
|
280
|
-
params.set('
|
|
353
|
+
params.set('search', searchQuery);
|
|
354
|
+
} else {
|
|
355
|
+
params.delete('search');
|
|
281
356
|
}
|
|
282
357
|
const queryString = params.toString();
|
|
283
358
|
const newURL = window.location.pathname + (queryString ? '?' + queryString : '');
|
|
284
359
|
window.history.replaceState({}, '', newURL);
|
|
285
|
-
|
|
286
|
-
//
|
|
360
|
+
|
|
361
|
+
// Keep the search query in sync across every tab link.
|
|
287
362
|
document.querySelectorAll('.tab').forEach(tab => {
|
|
288
|
-
const
|
|
289
|
-
|
|
363
|
+
const url = new URL(tab.href, window.location.origin);
|
|
364
|
+
if (searchQuery) {
|
|
365
|
+
url.searchParams.set('search', searchQuery);
|
|
366
|
+
} else {
|
|
367
|
+
url.searchParams.delete('search');
|
|
368
|
+
}
|
|
369
|
+
tab.setAttribute('href', url.pathname + url.search);
|
|
290
370
|
});
|
|
291
371
|
}
|
|
292
372
|
|
|
@@ -306,24 +386,29 @@
|
|
|
306
386
|
}
|
|
307
387
|
|
|
308
388
|
// Event listeners
|
|
389
|
+
let searchTimer = null;
|
|
309
390
|
searchInput.addEventListener('input', (e) => {
|
|
310
391
|
searchQuery = e.target.value;
|
|
311
|
-
filterIcons();
|
|
312
392
|
updateURL();
|
|
393
|
+
clearTimeout(searchTimer);
|
|
394
|
+
searchTimer = setTimeout(runSearch, 200);
|
|
313
395
|
});
|
|
314
|
-
|
|
396
|
+
|
|
397
|
+
loadMoreBtn.addEventListener('click', loadMore);
|
|
398
|
+
|
|
315
399
|
iconGrid.addEventListener('click', (e) => {
|
|
316
400
|
const iconItem = e.target.closest('.icon-item');
|
|
317
401
|
if (iconItem) {
|
|
318
402
|
copyIcon(iconItem.dataset.icon);
|
|
319
403
|
}
|
|
320
404
|
});
|
|
321
|
-
|
|
405
|
+
|
|
322
406
|
themeSwitcher.addEventListener('click', toggleTheme);
|
|
323
|
-
|
|
407
|
+
|
|
324
408
|
// Initialize
|
|
325
409
|
loadTheme();
|
|
326
|
-
|
|
410
|
+
updateStats();
|
|
411
|
+
updateLoadMore();
|
|
327
412
|
</script>
|
|
328
413
|
</body>
|
|
329
414
|
</html>
|
data/lib/unmagic/icon/web.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "rack/utils"
|
|
|
5
5
|
|
|
6
6
|
require "erb"
|
|
7
7
|
require "cgi"
|
|
8
|
+
require "json"
|
|
8
9
|
|
|
9
10
|
module Unmagic
|
|
10
11
|
class Icon
|
|
@@ -21,49 +22,109 @@ module Unmagic
|
|
|
21
22
|
end.to_app
|
|
22
23
|
end
|
|
23
24
|
|
|
25
|
+
# The library param value that means "show icons from every library".
|
|
26
|
+
ALL_LIBRARIES = "all"
|
|
27
|
+
|
|
28
|
+
# How many icons to send per page (initial render and each "load more").
|
|
29
|
+
PAGE_SIZE = 100
|
|
30
|
+
|
|
24
31
|
def call(env)
|
|
25
32
|
@request = Rack::Request.new(env)
|
|
26
33
|
@libraries = Unmagic::Icon::Library::Registry.all
|
|
27
34
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if first_library.nil?
|
|
33
|
-
[ 404, { "content-type" => "text/plain" }, [ "No libraries found" ] ]
|
|
34
|
-
else
|
|
35
|
-
[ 302, { "location" => url(first_library, @request.params) }, [] ]
|
|
36
|
-
end
|
|
37
|
-
else
|
|
38
|
-
library_name = @request.path_info.delete_prefix("/")
|
|
35
|
+
@search_query = @request.params["search"].to_s
|
|
36
|
+
@offset = @request.params["offset"].to_i
|
|
37
|
+
@offset = 0 if @offset.negative?
|
|
38
|
+
library_param = @request.params["library"].to_s
|
|
39
39
|
|
|
40
|
+
# No `library` param (a fresh page load) defaults to "All".
|
|
41
|
+
if library_param.empty? || library_param == ALL_LIBRARIES
|
|
42
|
+
@selected_library = nil
|
|
43
|
+
@selected_param = ALL_LIBRARIES
|
|
44
|
+
candidates = @libraries.flat_map(&:icons)
|
|
45
|
+
else
|
|
40
46
|
begin
|
|
41
|
-
@selected_library = Unmagic::Icon::Library::Registry.find(
|
|
42
|
-
rescue Unmagic::Icon::
|
|
43
|
-
return [ 404, { "content-type" => "text/plain" }, [ "Library not found: #{
|
|
47
|
+
@selected_library = Unmagic::Icon::Library::Registry.find(library_param)
|
|
48
|
+
rescue Unmagic::Icon::LibraryNotFoundError
|
|
49
|
+
return [ 404, { "content-type" => "text/plain" }, [ "Library not found: #{library_param}" ] ]
|
|
44
50
|
end
|
|
51
|
+
@selected_param = @selected_library.name
|
|
52
|
+
candidates = @selected_library.icons
|
|
53
|
+
end
|
|
45
54
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
# Search and paginate on the server; only the page's icons get their svg
|
|
56
|
+
# read+serialized, so we never inline the whole (possibly huge) set.
|
|
57
|
+
filtered = filter_icons(candidates, @search_query)
|
|
58
|
+
@total = filtered.length
|
|
59
|
+
@page_icons = filtered[@offset, PAGE_SIZE] || []
|
|
49
60
|
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
return fragment_response if fragment_request?
|
|
62
|
+
|
|
63
|
+
template_path = File.expand_path("web/views/layout.html.erb", __dir__)
|
|
64
|
+
template = ERB.new(File.read(template_path))
|
|
65
|
+
html = template.result(binding)
|
|
66
|
+
|
|
67
|
+
[ 200, { "content-type" => "text/html; charset=utf-8" }, [ html ] ]
|
|
52
68
|
end
|
|
53
69
|
|
|
54
70
|
def escape(text)
|
|
55
71
|
CGI.escapeHTML(text.to_s)
|
|
56
72
|
end
|
|
57
73
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
74
|
+
# Case-insensitive substring match on the icon name.
|
|
75
|
+
def filter_icons(icons, query)
|
|
76
|
+
return icons if query.empty?
|
|
61
77
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
78
|
+
needle = query.downcase
|
|
79
|
+
icons.select { |icon| icon.name.downcase.include?(needle) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def fragment_request?
|
|
83
|
+
@request.params["format"] == "fragment"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The page of icons as a rendered HTML fragment, which the browser appends
|
|
87
|
+
# ("load more") or swaps in (live search). Counts ride along as headers so
|
|
88
|
+
# the body stays pure markup. Icons are rendered in exactly one place —
|
|
89
|
+
# the `icons` partial, shared with the full page.
|
|
90
|
+
def fragment_response
|
|
91
|
+
headers = {
|
|
92
|
+
"content-type" => "text/html; charset=utf-8",
|
|
93
|
+
"x-total-count" => @total.to_s,
|
|
94
|
+
"x-offset" => @offset.to_s,
|
|
95
|
+
"x-page-size" => PAGE_SIZE.to_s
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
[ 200, headers, [ render_icons(@page_icons) ] ]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Render the shared icons partial for a list of icons. `-%>` trim keeps the
|
|
102
|
+
# output empty (not whitespace) when there are no icons, so the browser can
|
|
103
|
+
# detect "no results".
|
|
104
|
+
def render_icons(icons)
|
|
105
|
+
template_path = File.expand_path("web/views/icons.html.erb", __dir__)
|
|
106
|
+
ERB.new(File.read(template_path), trim_mode: "-").result(binding)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Build a URL to the gallery selecting the given library, carrying the
|
|
110
|
+
# current search query along. `library_name` of nil selects "All".
|
|
111
|
+
def library_url(library_name)
|
|
112
|
+
params = { "library" => library_name || ALL_LIBRARIES }
|
|
113
|
+
params["search"] = @search_query unless @search_query.empty?
|
|
114
|
+
url(params)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Build a static asset URL, honouring any SCRIPT_NAME mount prefix.
|
|
118
|
+
def asset_url(path)
|
|
119
|
+
[ @request.env["SCRIPT_NAME"], path ].join.gsub(%r{/+}, "/")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def url(params)
|
|
125
|
+
base = @request.env["SCRIPT_NAME"].to_s
|
|
126
|
+
base = "/" if base.empty?
|
|
127
|
+
"#{base}?#{Rack::Utils.build_query(params)}"
|
|
67
128
|
end
|
|
68
129
|
end
|
|
69
130
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: unmagic-icon
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Keith Pitt
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -101,17 +101,24 @@ files:
|
|
|
101
101
|
- lib/unmagic/icon/library.rb
|
|
102
102
|
- lib/unmagic/icon/library/registry.rb
|
|
103
103
|
- lib/unmagic/icon/library/source.rb
|
|
104
|
+
- lib/unmagic/icon/library/source/bootstrap_icons.rb
|
|
105
|
+
- lib/unmagic/icon/library/source/coloured_icons.rb
|
|
104
106
|
- lib/unmagic/icon/library/source/devicons.rb
|
|
105
107
|
- lib/unmagic/icon/library/source/feather.rb
|
|
106
108
|
- lib/unmagic/icon/library/source/heroicons.rb
|
|
109
|
+
- lib/unmagic/icon/library/source/iconoir.rb
|
|
107
110
|
- lib/unmagic/icon/library/source/lucide.rb
|
|
111
|
+
- lib/unmagic/icon/library/source/material_design_icons.rb
|
|
108
112
|
- lib/unmagic/icon/library/source/material_file_icons.rb
|
|
113
|
+
- lib/unmagic/icon/library/source/octicons.rb
|
|
114
|
+
- lib/unmagic/icon/library/source/phosphor.rb
|
|
109
115
|
- lib/unmagic/icon/library/source/silk.rb
|
|
110
116
|
- lib/unmagic/icon/library/source/simple_icons.rb
|
|
111
117
|
- lib/unmagic/icon/library/source/tabler.rb
|
|
112
118
|
- lib/unmagic/icon/version.rb
|
|
113
119
|
- lib/unmagic/icon/web.rb
|
|
114
120
|
- lib/unmagic/icon/web/public/favicon.png
|
|
121
|
+
- lib/unmagic/icon/web/views/icons.html.erb
|
|
115
122
|
- lib/unmagic/icon/web/views/layout.html.erb
|
|
116
123
|
- lib/unmagic_icon.rb
|
|
117
124
|
homepage: https://github.com/unreasonable-magic/unmagic-icon
|