unmagic-icon 0.1.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 +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE +21 -0
- data/README.md +156 -0
- data/lib/tasks/unmagic/icon/download.rake +26 -0
- data/lib/tasks/unmagic/icon/install.rake +25 -0
- data/lib/unmagic/icon/action_view_helpers.rb +14 -0
- data/lib/unmagic/icon/configuration.rb +34 -0
- data/lib/unmagic/icon/engine.rb +44 -0
- data/lib/unmagic/icon/library/registry.rb +81 -0
- data/lib/unmagic/icon/library/source/devicons.rb +18 -0
- data/lib/unmagic/icon/library/source/feather.rb +18 -0
- data/lib/unmagic/icon/library/source/heroicons.rb +23 -0
- data/lib/unmagic/icon/library/source/lucide.rb +18 -0
- data/lib/unmagic/icon/library/source/material_file_icons.rb +64 -0
- data/lib/unmagic/icon/library/source/silk.rb +44 -0
- data/lib/unmagic/icon/library/source/simple_icons.rb +18 -0
- data/lib/unmagic/icon/library/source/tabler.rb +18 -0
- data/lib/unmagic/icon/library/source.rb +242 -0
- data/lib/unmagic/icon/library.rb +114 -0
- data/lib/unmagic/icon/version.rb +7 -0
- data/lib/unmagic/icon/web/public/favicon.png +0 -0
- data/lib/unmagic/icon/web/views/layout.html.erb +329 -0
- data/lib/unmagic/icon/web.rb +70 -0
- data/lib/unmagic/icon.rb +134 -0
- data/lib/unmagic_icon.rb +3 -0
- metadata +143 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Icon
|
|
5
|
+
class Library
|
|
6
|
+
class Source
|
|
7
|
+
class Tabler < Source
|
|
8
|
+
key :tabler
|
|
9
|
+
title "Tabler Icons"
|
|
10
|
+
description "Over 5400 free SVG icons"
|
|
11
|
+
url "https://github.com/tabler/tabler-icons/releases/download/v3.24.0/tabler-icons-3.24.0.zip"
|
|
12
|
+
archive :zip
|
|
13
|
+
extract "svg/*.svg"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
require "tmpdir"
|
|
9
|
+
require "open3"
|
|
10
|
+
|
|
11
|
+
# Terminal progress is a nicety, not a requirement. The downloader runs fine
|
|
12
|
+
# (with plain output) when these sibling gems aren't installed.
|
|
13
|
+
begin
|
|
14
|
+
require "unmagic/terminal/progress_bar"
|
|
15
|
+
require "unmagic/support/monitored_enumerator"
|
|
16
|
+
rescue LoadError
|
|
17
|
+
# no-op — falls back to plain iteration in #each_with_progress
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module Unmagic
|
|
21
|
+
class Icon
|
|
22
|
+
class Library
|
|
23
|
+
# A downloadable source for an icon library: its metadata and how to fetch
|
|
24
|
+
# it. Each library is a subclass that declares metadata with the class-level
|
|
25
|
+
# DSL; the base carries the shared download/extract/manifest plumbing. A
|
|
26
|
+
# subclass overrides #write_manifest to generate a mapping, or #acquire to
|
|
27
|
+
# use a different transport.
|
|
28
|
+
class Source
|
|
29
|
+
class Error < StandardError; end
|
|
30
|
+
class DownloadError < Error; end
|
|
31
|
+
class ExtractionError < Error; end
|
|
32
|
+
|
|
33
|
+
REGISTRY = {}
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
def all
|
|
37
|
+
REGISTRY.values
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def find(name)
|
|
41
|
+
REGISTRY[name.to_sym] or raise ArgumentError, "Unknown library: #{name}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def exists?(name)
|
|
45
|
+
REGISTRY.key?(name.to_sym)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# ---- metadata DSL (each setter doubles as a reader) ----
|
|
49
|
+
|
|
50
|
+
def key(value = nil)
|
|
51
|
+
return @key unless value
|
|
52
|
+
|
|
53
|
+
@key = value.to_sym
|
|
54
|
+
REGISTRY[@key] = self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def title(value = nil)
|
|
58
|
+
value ? @title = value : @title
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def description(value = nil)
|
|
62
|
+
value ? @description = value : @description
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def url(value = nil)
|
|
66
|
+
value ? @url = value : @url
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def archive(value = nil)
|
|
70
|
+
value ? @archive = value : @archive
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# On-disk directory name; defaults to the key.
|
|
74
|
+
def dir(value = nil)
|
|
75
|
+
value ? @dir = value.to_s : (@dir || key.to_s)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Glob patterns (relative to the extracted archive) of svgs to copy.
|
|
79
|
+
def extract(*patterns)
|
|
80
|
+
patterns.empty? ? (@extract || []) : @extract = patterns
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Map source directories to target subdirectories (e.g. heroicons sizes).
|
|
84
|
+
def extract_into(mapping = nil)
|
|
85
|
+
mapping ? @extract_into = mapping : @extract_into
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def download(target_dir: default_target_dir, force: false)
|
|
90
|
+
target_dir = Pathname(target_dir)
|
|
91
|
+
|
|
92
|
+
if target_dir.exist? && !force
|
|
93
|
+
puts "→ Skipping #{self.class.title} (already exists at #{target_dir}, use force: true to re-download)"
|
|
94
|
+
return
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
puts "→ Downloading #{self.class.title}..."
|
|
98
|
+
puts " #{self.class.description}"
|
|
99
|
+
|
|
100
|
+
acquire(target_dir)
|
|
101
|
+
|
|
102
|
+
puts " ✓ Downloaded #{count_svgs(target_dir)} icons to #{target_dir}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def default_target_dir
|
|
108
|
+
base = Unmagic::Icon.configuration.download_path
|
|
109
|
+
raise ArgumentError, "Set Unmagic::Icon.configuration.download_path or pass target_dir:" unless base
|
|
110
|
+
|
|
111
|
+
Pathname(base).join(self.class.dir)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Default acquisition: download an archive, extract, copy matched svgs,
|
|
115
|
+
# then let the subclass write a manifest. Override for other transports.
|
|
116
|
+
def acquire(target_dir)
|
|
117
|
+
Dir.mktmpdir do |tmpdir|
|
|
118
|
+
archive_path = File.join(tmpdir, "#{self.class.key}#{File.extname(self.class.url)}")
|
|
119
|
+
download_file(self.class.url, archive_path)
|
|
120
|
+
extract_archive(archive_path, tmpdir, self.class.archive)
|
|
121
|
+
|
|
122
|
+
FileUtils.mkdir_p(target_dir)
|
|
123
|
+
copy_assets(tmpdir, target_dir)
|
|
124
|
+
write_manifest(tmpdir, target_dir)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Libraries that ship a file→icon mapping override this to write a
|
|
129
|
+
# manifest.json into target_dir. Default: nothing.
|
|
130
|
+
def write_manifest(_tmpdir, _target_dir)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def copy_assets(tmpdir, target_dir)
|
|
134
|
+
if self.class.extract_into
|
|
135
|
+
copy_into_subdirs(tmpdir, target_dir)
|
|
136
|
+
else
|
|
137
|
+
copy_flat(tmpdir, target_dir)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def copy_flat(tmpdir, target_dir)
|
|
142
|
+
files = self.class.extract.flat_map do |pattern|
|
|
143
|
+
Dir.glob(File.join(tmpdir, pattern)).select { |file| File.file?(file) }
|
|
144
|
+
end
|
|
145
|
+
return if files.empty?
|
|
146
|
+
|
|
147
|
+
puts " Extracting #{files.size} icons..."
|
|
148
|
+
each_with_progress(files) { |file| FileUtils.cp(file, target_dir) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def copy_into_subdirs(tmpdir, target_dir)
|
|
152
|
+
files = self.class.extract_into.flat_map do |source_dir, subdir|
|
|
153
|
+
Dir.glob(File.join(tmpdir, source_dir, "*.svg")).map { |file| [ file, subdir ] }
|
|
154
|
+
end
|
|
155
|
+
return if files.empty?
|
|
156
|
+
|
|
157
|
+
puts " Extracting #{files.size} icons..."
|
|
158
|
+
each_with_progress(files) do |(file, subdir)|
|
|
159
|
+
next unless File.file?(file)
|
|
160
|
+
|
|
161
|
+
destination = Pathname(target_dir).join(subdir)
|
|
162
|
+
FileUtils.mkdir_p(destination)
|
|
163
|
+
FileUtils.cp(file, destination)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def each_with_progress(items, show_time: false)
|
|
168
|
+
if defined?(Unmagic::Support::MonitoredEnumerator) && defined?(Unmagic::Terminal::ProgressBar)
|
|
169
|
+
bar = Unmagic::Terminal::ProgressBar.new(total: items.size, width: 40, show_time: show_time)
|
|
170
|
+
monitor = proc do |event|
|
|
171
|
+
case event.event
|
|
172
|
+
when :progress
|
|
173
|
+
bar.update(event.progress.current + 1)
|
|
174
|
+
print "\r #{bar.render}"
|
|
175
|
+
when :finish
|
|
176
|
+
puts
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
Unmagic::Support::MonitoredEnumerator.new(items, monitor).each { |item| yield item }
|
|
181
|
+
else
|
|
182
|
+
items.each { |item| yield item }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def download_file(url, destination)
|
|
187
|
+
response = fetch_with_redirect(URI(url))
|
|
188
|
+
raise DownloadError, "Failed to download #{url}: HTTP #{response.code}" if response.code != "200"
|
|
189
|
+
|
|
190
|
+
File.binwrite(destination, response.body)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def fetch_with_redirect(uri, headers = {}, limit = 10)
|
|
194
|
+
raise DownloadError, "Too many redirects" if limit.zero?
|
|
195
|
+
|
|
196
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
197
|
+
http.use_ssl = (uri.scheme == "https")
|
|
198
|
+
|
|
199
|
+
request = Net::HTTP::Get.new(uri)
|
|
200
|
+
headers.each { |name, value| request[name] = value }
|
|
201
|
+
|
|
202
|
+
response = http.request(request)
|
|
203
|
+
|
|
204
|
+
case response
|
|
205
|
+
when Net::HTTPSuccess
|
|
206
|
+
response
|
|
207
|
+
when Net::HTTPRedirection
|
|
208
|
+
fetch_with_redirect(URI(response["location"]), headers, limit - 1)
|
|
209
|
+
else
|
|
210
|
+
response
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def extract_archive(archive_path, destination, type)
|
|
215
|
+
case type
|
|
216
|
+
when :zip
|
|
217
|
+
_, stderr, status = Open3.capture3("unzip", "-q", "-o", archive_path, "-d", destination)
|
|
218
|
+
raise ExtractionError, "Failed to extract zip: #{stderr}" unless status.success?
|
|
219
|
+
when :tgz, :tar
|
|
220
|
+
_, stderr, status = Open3.capture3("tar", "-xzf", archive_path, "-C", destination)
|
|
221
|
+
raise ExtractionError, "Failed to extract tar: #{stderr}" unless status.success?
|
|
222
|
+
else
|
|
223
|
+
raise ExtractionError, "Unknown archive type: #{type}"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def count_svgs(directory)
|
|
228
|
+
Dir.glob(File.join(directory, "**", "*.svg")).size
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
require_relative "source/heroicons"
|
|
236
|
+
require_relative "source/devicons"
|
|
237
|
+
require_relative "source/feather"
|
|
238
|
+
require_relative "source/tabler"
|
|
239
|
+
require_relative "source/lucide"
|
|
240
|
+
require_relative "source/simple_icons"
|
|
241
|
+
require_relative "source/material_file_icons"
|
|
242
|
+
require_relative "source/silk"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Unmagic
|
|
6
|
+
class Icon
|
|
7
|
+
class Library
|
|
8
|
+
# The optional per-set manifest. Asset-kind-agnostic: it carries metadata,
|
|
9
|
+
# a `default`, a global `aliases` map, and optional per-entry declarations
|
|
10
|
+
# (`entries`, each with its own `aliases`). Icons auto-discover from disk,
|
|
11
|
+
# so for an icon library only `default` + `aliases` are typically present.
|
|
12
|
+
MANIFEST_FILENAME = "manifest.json"
|
|
13
|
+
|
|
14
|
+
# The lone asset-kind-specific bit: which files in the directory are assets.
|
|
15
|
+
# SVG today; a future raster pack would scan png/gif here.
|
|
16
|
+
ASSET_GLOB = "*.svg"
|
|
17
|
+
|
|
18
|
+
attr_reader :name, :path
|
|
19
|
+
|
|
20
|
+
def initialize(name:, path:)
|
|
21
|
+
@name = name
|
|
22
|
+
@path = path
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Resolve a query to an icon. Order: exact icon file, then the alias index
|
|
26
|
+
# (exact alias, then longest matching glob/pattern), then the manifest's
|
|
27
|
+
# default. Raises when nothing matches and no default is declared.
|
|
28
|
+
def find(query)
|
|
29
|
+
resolve(query.to_s) or
|
|
30
|
+
raise Unmagic::Icon::IconNotFoundError.new("Can't find #{query} in #{path}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def icons
|
|
34
|
+
icons_by_key.values
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def aliases
|
|
38
|
+
alias_index
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def resolve(query)
|
|
44
|
+
icons_by_key[query] ||
|
|
45
|
+
aliased(query) ||
|
|
46
|
+
default_icon
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def aliased(query)
|
|
50
|
+
target = lookup_alias(query.downcase)
|
|
51
|
+
icons_by_key[target] if target
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Exact aliases win over patterns; patterns are tried longest-first so the
|
|
55
|
+
# most specific match wins (e.g. "*.test.tsx" beats "*.tsx").
|
|
56
|
+
def lookup_alias(key)
|
|
57
|
+
exact, patterns = alias_index
|
|
58
|
+
exact[key] ||
|
|
59
|
+
patterns.find { |pattern, _| File.fnmatch?(pattern, key, File::FNM_EXTGLOB) }&.last
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def default_icon
|
|
63
|
+
target = manifest["default"]
|
|
64
|
+
icons_by_key[target] if target
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def icons_by_key
|
|
68
|
+
@icons_by_key ||= Dir.glob(File.join(@path, ASSET_GLOB)).to_h do |icon_path|
|
|
69
|
+
icon_key = File.basename(icon_path, ".svg")
|
|
70
|
+
[ icon_key, Unmagic::Icon.new(name: "#{@name}/#{icon_key}", path: icon_path) ]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The whole alias map, normalized to downcased keys and split into exact
|
|
75
|
+
# strings vs glob patterns. Built from the manifest's global `aliases` plus
|
|
76
|
+
# each entry's per-entry `aliases` (the emoji-shaped shortcuts case).
|
|
77
|
+
def alias_index
|
|
78
|
+
@alias_index ||=
|
|
79
|
+
begin
|
|
80
|
+
exact = {}
|
|
81
|
+
patterns = {}
|
|
82
|
+
|
|
83
|
+
manifest_aliases.each do |key, target|
|
|
84
|
+
normalized = key.to_s.downcase
|
|
85
|
+
if normalized.match?(/[*?\[{]/)
|
|
86
|
+
patterns[normalized] = target
|
|
87
|
+
else
|
|
88
|
+
exact[normalized] = target
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
[ exact, patterns.sort_by { |pattern, _| -pattern.length }.to_h ]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def manifest_aliases
|
|
97
|
+
global = manifest["aliases"] || {}
|
|
98
|
+
per_entry = Array(manifest["entries"]).each_with_object({}) do |entry, mapped|
|
|
99
|
+
Array(entry["aliases"]).each { |key| mapped[key] = entry["name"] }
|
|
100
|
+
end
|
|
101
|
+
global.merge(per_entry)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def manifest
|
|
105
|
+
@manifest ||= load_manifest
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def load_manifest
|
|
109
|
+
file = File.join(@path, MANIFEST_FILENAME)
|
|
110
|
+
File.exist?(file) ? JSON.parse(File.read(file)) : {}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
Binary file
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
<title>Unmagic Icon Gallery</title>
|
|
2
|
+
<style>
|
|
3
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
4
|
+
|
|
5
|
+
:root {
|
|
6
|
+
--bg: #fff;
|
|
7
|
+
--fg: #333;
|
|
8
|
+
--border: #e0e0e0;
|
|
9
|
+
--tab-bg: #f5f5f5;
|
|
10
|
+
--tab-hover: #e8e8e8;
|
|
11
|
+
--tab-border: #ddd;
|
|
12
|
+
--active-bg: #333;
|
|
13
|
+
--active-fg: #fff;
|
|
14
|
+
--icon-hover: #f8f8f8;
|
|
15
|
+
--icon-border-hover: #999;
|
|
16
|
+
--input-border: #ccc;
|
|
17
|
+
--input-border-focus: #666;
|
|
18
|
+
--muted: #666;
|
|
19
|
+
--no-results: #999;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
[data-theme="dark"] {
|
|
23
|
+
--bg: #1a1a1a;
|
|
24
|
+
--fg: #e0e0e0;
|
|
25
|
+
--border: #333;
|
|
26
|
+
--tab-bg: #2a2a2a;
|
|
27
|
+
--tab-hover: #333;
|
|
28
|
+
--tab-border: #444;
|
|
29
|
+
--active-bg: #e0e0e0;
|
|
30
|
+
--active-fg: #1a1a1a;
|
|
31
|
+
--icon-hover: #2a2a2a;
|
|
32
|
+
--icon-border-hover: #666;
|
|
33
|
+
--input-border: #444;
|
|
34
|
+
--input-border-focus: #888;
|
|
35
|
+
--muted: #999;
|
|
36
|
+
--no-results: #666;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
body {
|
|
40
|
+
font-family: -apple-system, system-ui, sans-serif;
|
|
41
|
+
background: var(--bg);
|
|
42
|
+
color: var(--fg);
|
|
43
|
+
padding: 20px;
|
|
44
|
+
line-height: 1.5;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.header {
|
|
48
|
+
margin-bottom: 30px;
|
|
49
|
+
padding-bottom: 20px;
|
|
50
|
+
border-bottom: 1px solid var(--border);
|
|
51
|
+
position: relative;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.theme-switcher {
|
|
55
|
+
position: absolute;
|
|
56
|
+
top: 0;
|
|
57
|
+
right: 0;
|
|
58
|
+
padding: 8px 12px;
|
|
59
|
+
background: var(--tab-bg);
|
|
60
|
+
border: 1px solid var(--tab-border);
|
|
61
|
+
border-radius: 4px;
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
font-size: 14px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.theme-switcher:hover {
|
|
67
|
+
background: var(--tab-hover);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.search-box {
|
|
71
|
+
margin-bottom: 20px;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.search-box input {
|
|
75
|
+
width: 100%;
|
|
76
|
+
max-width: 400px;
|
|
77
|
+
padding: 8px 12px;
|
|
78
|
+
font-size: 14px;
|
|
79
|
+
border: 1px solid var(--input-border);
|
|
80
|
+
border-radius: 4px;
|
|
81
|
+
outline: none;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.search-box input:focus {
|
|
85
|
+
border-color: var(--input-border-focus);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.tabs {
|
|
89
|
+
display: flex;
|
|
90
|
+
gap: 10px;
|
|
91
|
+
flex-wrap: wrap;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.tab {
|
|
95
|
+
padding: 8px 16px;
|
|
96
|
+
background: var(--tab-bg);
|
|
97
|
+
border: 1px solid var(--tab-border);
|
|
98
|
+
border-radius: 4px;
|
|
99
|
+
cursor: pointer;
|
|
100
|
+
font-size: 14px;
|
|
101
|
+
text-decoration: none;
|
|
102
|
+
color: var(--fg);
|
|
103
|
+
display: inline-block;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.tab:hover {
|
|
107
|
+
background: var(--tab-hover);
|
|
108
|
+
text-decoration: none;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.tab.active {
|
|
112
|
+
background: var(--active-bg);
|
|
113
|
+
color: var(--active-fg);
|
|
114
|
+
border-color: var(--active-bg);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.icon-grid {
|
|
118
|
+
display: grid;
|
|
119
|
+
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
|
120
|
+
gap: 20px;
|
|
121
|
+
margin-top: 30px;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.icon-item {
|
|
125
|
+
text-align: center;
|
|
126
|
+
padding: 15px;
|
|
127
|
+
border: 1px solid var(--border);
|
|
128
|
+
border-radius: 4px;
|
|
129
|
+
cursor: pointer;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.icon-item:hover {
|
|
133
|
+
background: var(--icon-hover);
|
|
134
|
+
border-color: var(--icon-border-hover);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.icon-item svg {
|
|
138
|
+
width: 48px;
|
|
139
|
+
height: 48px;
|
|
140
|
+
margin-bottom: 8px;
|
|
141
|
+
display: block;
|
|
142
|
+
margin-left: auto;
|
|
143
|
+
margin-right: auto;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.icon-name {
|
|
147
|
+
font-size: 12px;
|
|
148
|
+
color: var(--muted);
|
|
149
|
+
word-break: break-all;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.stats {
|
|
153
|
+
margin-top: 20px;
|
|
154
|
+
font-size: 14px;
|
|
155
|
+
color: var(--muted);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.copy-toast {
|
|
159
|
+
position: fixed;
|
|
160
|
+
bottom: 20px;
|
|
161
|
+
right: 20px;
|
|
162
|
+
background: var(--active-bg);
|
|
163
|
+
color: var(--active-fg);
|
|
164
|
+
padding: 12px 20px;
|
|
165
|
+
border-radius: 4px;
|
|
166
|
+
opacity: 0;
|
|
167
|
+
transform: translateY(20px);
|
|
168
|
+
transition: all 0.3s ease;
|
|
169
|
+
pointer-events: none;
|
|
170
|
+
font-size: 14px;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.copy-toast.show {
|
|
174
|
+
opacity: 1;
|
|
175
|
+
transform: translateY(0);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.no-results {
|
|
179
|
+
text-align: center;
|
|
180
|
+
color: var(--no-results);
|
|
181
|
+
padding: 40px;
|
|
182
|
+
font-size: 14px;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
[hidden] {
|
|
186
|
+
display: none !important;
|
|
187
|
+
}
|
|
188
|
+
</style>
|
|
189
|
+
<link rel="icon" href="<%= url "/public/favicon.png" %>" type="image/png">
|
|
190
|
+
<link rel="apple-touch-icon" href="<%= url "/public/favicon.png" %>">
|
|
191
|
+
</head>
|
|
192
|
+
<body>
|
|
193
|
+
<div class="header">
|
|
194
|
+
<button class="theme-switcher" id="themeSwitcher">🌙</button>
|
|
195
|
+
|
|
196
|
+
<div class="search-box">
|
|
197
|
+
<input
|
|
198
|
+
type="text"
|
|
199
|
+
id="searchInput"
|
|
200
|
+
placeholder="Search icons..."
|
|
201
|
+
value="<%= escape(@search_query) %>"
|
|
202
|
+
autocomplete="off"
|
|
203
|
+
>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<div class="tabs">
|
|
207
|
+
<% @libraries.each do |library| %>
|
|
208
|
+
<a href="<%= url(library.name, @request.params) %>"
|
|
209
|
+
class="tab <%= library == @selected_library ? 'active' : '' %>">
|
|
210
|
+
<%= library.name %> (<%= library.icons.length %>)
|
|
211
|
+
</a>
|
|
212
|
+
<% end %>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<div class="stats" id="stats"></div>
|
|
217
|
+
|
|
218
|
+
<div class="icon-grid" id="iconGrid"></div>
|
|
219
|
+
|
|
220
|
+
<div class="copy-toast" id="copyToast">Copied to clipboard!</div>
|
|
221
|
+
|
|
222
|
+
<script>
|
|
223
|
+
// Icon data for current library only
|
|
224
|
+
const currentLibrary = '<%= @selected_library.name %>';
|
|
225
|
+
const iconsData = <%= @selected_library.icons.to_json %>;
|
|
226
|
+
|
|
227
|
+
// State
|
|
228
|
+
let searchQuery = '<%= @search_query %>';
|
|
229
|
+
|
|
230
|
+
// Elements
|
|
231
|
+
const searchInput = document.getElementById('searchInput');
|
|
232
|
+
const iconGrid = document.getElementById('iconGrid');
|
|
233
|
+
const stats = document.getElementById('stats');
|
|
234
|
+
const copyToast = document.getElementById('copyToast');
|
|
235
|
+
const themeSwitcher = document.getElementById('themeSwitcher');
|
|
236
|
+
|
|
237
|
+
// Functions
|
|
238
|
+
function filterIcons() {
|
|
239
|
+
const query = searchQuery.toLowerCase();
|
|
240
|
+
const filtered = query
|
|
241
|
+
? iconsData.filter(icon => icon.name.toLowerCase().includes(query))
|
|
242
|
+
: iconsData;
|
|
243
|
+
|
|
244
|
+
renderIcons(filtered);
|
|
245
|
+
updateStats(filtered.length);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function renderIcons(icons) {
|
|
249
|
+
if (icons.length === 0) {
|
|
250
|
+
iconGrid.innerHTML = '<div class="no-results">No icons found</div>';
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
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
|
+
}
|
|
263
|
+
|
|
264
|
+
function updateStats(count) {
|
|
265
|
+
stats.textContent = `Showing ${count} icons from ${currentLibrary}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function copyIcon(iconName) {
|
|
269
|
+
const text = `${iconName}`;
|
|
270
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
271
|
+
copyToast.textContent = `Copied: ${text}`;
|
|
272
|
+
copyToast.classList.add('show');
|
|
273
|
+
setTimeout(() => copyToast.classList.remove('show'), 2000);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function updateURL() {
|
|
278
|
+
const params = new URLSearchParams();
|
|
279
|
+
if (searchQuery) {
|
|
280
|
+
params.set('q', searchQuery);
|
|
281
|
+
}
|
|
282
|
+
const queryString = params.toString();
|
|
283
|
+
const newURL = window.location.pathname + (queryString ? '?' + queryString : '');
|
|
284
|
+
window.history.replaceState({}, '', newURL);
|
|
285
|
+
|
|
286
|
+
// Update all tab links with new query
|
|
287
|
+
document.querySelectorAll('.tab').forEach(tab => {
|
|
288
|
+
const href = tab.getAttribute('href').split('?')[0];
|
|
289
|
+
tab.setAttribute('href', href + (queryString ? '?' + queryString : ''));
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Theme management
|
|
294
|
+
function loadTheme() {
|
|
295
|
+
const savedTheme = localStorage.getItem('theme') || 'light';
|
|
296
|
+
document.documentElement.setAttribute('data-theme', savedTheme);
|
|
297
|
+
themeSwitcher.textContent = savedTheme === 'dark' ? '☀️' : '🌙';
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function toggleTheme() {
|
|
301
|
+
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
|
|
302
|
+
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
|
303
|
+
document.documentElement.setAttribute('data-theme', newTheme);
|
|
304
|
+
localStorage.setItem('theme', newTheme);
|
|
305
|
+
themeSwitcher.textContent = newTheme === 'dark' ? '☀️' : '🌙';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Event listeners
|
|
309
|
+
searchInput.addEventListener('input', (e) => {
|
|
310
|
+
searchQuery = e.target.value;
|
|
311
|
+
filterIcons();
|
|
312
|
+
updateURL();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
iconGrid.addEventListener('click', (e) => {
|
|
316
|
+
const iconItem = e.target.closest('.icon-item');
|
|
317
|
+
if (iconItem) {
|
|
318
|
+
copyIcon(iconItem.dataset.icon);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
themeSwitcher.addEventListener('click', toggleTheme);
|
|
323
|
+
|
|
324
|
+
// Initialize
|
|
325
|
+
loadTheme();
|
|
326
|
+
filterIcons();
|
|
327
|
+
</script>
|
|
328
|
+
</body>
|
|
329
|
+
</html>
|