From 6bb338d262912f6c5c18ed6f902eeda11d182343 Mon Sep 17 00:00:00 2001 From: pphatdev Date: Fri, 24 Jul 2026 18:00:26 +0700 Subject: [PATCH 1/8] feat: add library of brand icons and implement demo server infrastructure --- brands/7zip.json | 4 ++-- brands/algolia.json | 4 ++-- brands/angular.json | 4 ++-- brands/ansible.json | 4 ++-- brands/antdesign.json | 4 ++-- brands/apachekafka.json | 4 ++-- brands/apple.json | 4 ++-- brands/archlinux.json | 4 ++-- brands/argo.json | 4 ++-- brands/awesomelists.json | 4 ++-- brands/bitcoincash.json | 4 ++-- brands/bootstrap.json | 4 ++-- brands/brave.json | 4 ++-- brands/bun.json | 4 ++-- brands/canonical.json | 4 ++-- brands/chartdotjs.json | 4 ++-- brands/claude.json | 4 ++-- brands/cline.json | 4 ++-- brands/cloudflare.json | 4 ++-- brands/cloudways.json | 4 ++-- brands/codeberg.json | 4 ++-- brands/codecov.json | 4 ++-- brands/codeproject.json | 4 ++-- brands/coderabbit.json | 4 ++-- brands/composer.json | 4 ++-- brands/cplusplus.json | 4 ++-- brands/createreactapp.json | 4 ++-- brands/csharp.json | 4 ++-- brands/css.json | 4 ++-- brands/dart.json | 4 ++-- brands/datadog.json | 4 ++-- brands/deno.json | 4 ++-- brands/denodeploy.json | 4 ++-- brands/devdotto.json | 4 ++-- brands/digitalocean.json | 4 ++-- brands/django.json | 4 ++-- brands/docker.json | 4 ++-- brands/dotnet.json | 4 ++-- brands/drizzle.json | 4 ++-- brands/editorconfig.json | 4 ++-- brands/ejs.json | 4 ++-- brands/emberdotjs.json | 4 ++-- brands/ethereum.json | 4 ++-- brands/expo.json | 4 ++-- brands/express.json | 4 ++-- brands/facebook.json | 4 ++-- brands/fastapi.json | 4 ++-- brands/fastify.json | 4 ++-- brands/flutter.json | 4 ++-- brands/flydotio.json | 4 ++-- brands/fontawesome.json | 4 ++-- brands/gatsby.json | 4 ++-- brands/git.json | 4 ++-- brands/gitbook.json | 4 ++-- brands/gitcode.json | 4 ++-- brands/github.json | 4 ++-- brands/githubactions.json | 4 ++-- brands/githubcopilot.json | 4 ++-- brands/gitignoredotio.json | 4 ++-- brands/gitkraken.json | 4 ++-- brands/gitlab.json | 4 ++-- brands/gitpod.json | 4 ++-- brands/go.json | 4 ++-- brands/googledrive.json | 4 ++-- brands/googlegemini.json | 4 ++-- brands/grafana.json | 4 ++-- brands/graphql.json | 4 ++-- brands/gravatar.json | 4 ++-- brands/gsap.json | 4 ++-- brands/handlebarsjs.json | 4 ++-- brands/happycow.json | 4 ++-- brands/heroui.json | 4 ++-- brands/html.json | 4 ++-- brands/htmx.json | 4 ++-- brands/huggingface.json | 2 +- brands/hugo.json | 4 ++-- brands/i18next.json | 4 ++-- brands/iconjar.json | 4 ++-- brands/immersivetranslate.json | 4 ++-- brands/instagram.json | 4 ++-- brands/intellijidea.json | 4 ++-- brands/itunes.json | 4 ++-- brands/javascript.json | 4 ++-- brands/jenkins.json | 4 ++-- brands/jinja.json | 4 ++-- brands/jitsi.json | 4 ++-- brands/jquery.json | 4 ++-- brands/json.json | 4 ++-- brands/kalilinux.json | 4 ++-- brands/kotlin.json | 4 ++-- brands/kubernetes.json | 4 ++-- brands/laravel.json | 4 ++-- brands/less.json | 4 ++-- brands/line.json | 4 ++-- brands/linear.json | 4 ++-- brands/linux.json | 4 ++-- brands/lit.json | 4 ++-- brands/livewire.json | 4 ++-- brands/mariadb.json | 4 ++-- brands/mariadbfoundation.json | 4 ++-- brands/markdown.json | 4 ++-- brands/marko.json | 4 ++-- brands/mcp.json | 4 ++-- brands/meilisearch.json | 4 ++-- brands/mistralai.json | 4 ++-- brands/mongodb.json | 4 ++-- brands/mui.json | 4 ++-- brands/mysql.json | 4 ++-- brands/n8n.json | 4 ++-- brands/neovim.json | 4 ++-- brands/nestjs.json | 4 ++-- brands/netlify.json | 4 ++-- brands/nextjs.json | 4 ++-- brands/nginx.json | 4 ++-- brands/nodejs.json | 4 ++-- brands/notion.json | 4 ++-- brands/npm.json | 4 ++-- brands/nuxt.json | 4 ++-- brands/nvm.json | 4 ++-- brands/opensourceinitiative.json | 4 ++-- brands/peerlist.json | 4 ++-- brands/php.json | 4 ++-- brands/phpstorm.json | 4 ++-- brands/pm2.json | 4 ++-- brands/postcss.json | 4 ++-- brands/postgresql.json | 4 ++-- brands/postman.json | 4 ++-- brands/preact.json | 4 ++-- brands/primereact.json | 4 ++-- brands/primevue.json | 4 ++-- brands/prisma.json | 4 ++-- brands/prometheus.json | 4 ++-- brands/pug.json | 4 ++-- brands/pwa.json | 4 ++-- brands/python.json | 4 ++-- brands/quasar.json | 4 ++-- brands/qwik.json | 4 ++-- brands/railway.json | 4 ++-- brands/rapid.json | 4 ++-- brands/react.json | 4 ++-- brands/reactos.json | 4 ++-- brands/reactquery.json | 4 ++-- brands/reactrouter.json | 4 ++-- brands/redis.json | 4 ++-- brands/redwoodjs.json | 4 ++-- brands/remix.json | 4 ++-- brands/republicofgamers.json | 4 ++-- brands/runrundotit.json | 4 ++-- brands/rust.json | 4 ++-- brands/samsung.json | 4 ++-- brands/sap.json | 4 ++-- brands/sass.json | 4 ++-- brands/shadcnui.json | 4 ++-- brands/sharp.json | 4 ++-- brands/solid.json | 4 ++-- brands/sonar.json | 4 ++-- brands/soundcloud.json | 4 ++-- brands/spring.json | 4 ++-- brands/sqlite.json | 4 ++-- brands/stencil.json | 4 ++-- brands/styledcomponents.json | 4 ++-- brands/sublimetext.json | 4 ++-- brands/supabase.json | 4 ++-- brands/svelte.json | 4 ++-- brands/swift.json | 4 ++-- brands/tailwindcss.json | 4 ++-- brands/tanstack.json | 4 ++-- brands/telegram.json | 4 ++-- brands/termius.json | 4 ++-- brands/terraform.json | 4 ++-- brands/threedotjs.json | 4 ++-- brands/tor.json | 4 ++-- brands/travisci.json | 4 ++-- brands/tsnode.json | 4 ++-- brands/typescript.json | 4 ++-- brands/ubuntu.json | 4 ++-- brands/unsplash.json | 4 ++-- brands/vercel.json | 4 ++-- brands/vim.json | 4 ++-- brands/vite.json | 4 ++-- brands/vitepress.json | 4 ++-- brands/vitess.json | 4 ++-- brands/vuedotjs.json | 4 ++-- brands/vuetify.json | 4 ++-- brands/vueuse.json | 4 ++-- brands/warp.json | 4 ++-- brands/webassembly.json | 4 ++-- brands/wondersharefilmora.json | 4 ++-- brands/xampp.json | 4 ++-- brands/xml.json | 4 ++-- brands/yaml.json | 4 ++-- brands/youtube.json | 4 ++-- brands/youtubemusic.json | 4 ++-- brands/youtubeshorts.json | 4 ++-- 194 files changed, 387 insertions(+), 387 deletions(-) diff --git a/brands/7zip.json b/brands/7zip.json index cd85adc..ea540b3 100644 --- a/brands/7zip.json +++ b/brands/7zip.json @@ -3,7 +3,7 @@ "files": [ { "path": "7zip.svg", - "content": "\n \n \n 7Zip\n \n" + "content": "\n 7Zip\n" } ] -} \ No newline at end of file +} diff --git a/brands/algolia.json b/brands/algolia.json index 38d8ff5..2e87a99 100644 --- a/brands/algolia.json +++ b/brands/algolia.json @@ -3,7 +3,7 @@ "files": [ { "path": "algolia.svg", - "content": "\n \n \n \n Algolia\n \n" + "content": "\n \n Algolia\n" } ] -} \ No newline at end of file +} diff --git a/brands/angular.json b/brands/angular.json index 3a7c651..e14673e 100644 --- a/brands/angular.json +++ b/brands/angular.json @@ -3,7 +3,7 @@ "files": [ { "path": "angular.svg", - "content": "\n \n \n \n Angular\n \n" + "content": "\n \n Angular\n" } ] -} \ No newline at end of file +} diff --git a/brands/ansible.json b/brands/ansible.json index 5318ef6..052e0ea 100644 --- a/brands/ansible.json +++ b/brands/ansible.json @@ -3,7 +3,7 @@ "files": [ { "path": "ansible.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/antdesign.json b/brands/antdesign.json index bb7ca28..731d67d 100644 --- a/brands/antdesign.json +++ b/brands/antdesign.json @@ -3,7 +3,7 @@ "files": [ { "path": "antdesign.svg", - "content": "\n \n \n \n Ant Design\n \n" + "content": "\n \n Ant Design\n" } ] -} \ No newline at end of file +} diff --git a/brands/apachekafka.json b/brands/apachekafka.json index abed7ef..b647678 100644 --- a/brands/apachekafka.json +++ b/brands/apachekafka.json @@ -3,7 +3,7 @@ "files": [ { "path": "apachekafka.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/apple.json b/brands/apple.json index 2ebf3ef..7a7e905 100644 --- a/brands/apple.json +++ b/brands/apple.json @@ -3,7 +3,7 @@ "files": [ { "path": "apple.svg", - "content": "\n \n \n Apple\n \n" + "content": "\n Apple\n" } ] -} \ No newline at end of file +} diff --git a/brands/archlinux.json b/brands/archlinux.json index d3744e2..85060cd 100644 --- a/brands/archlinux.json +++ b/brands/archlinux.json @@ -3,7 +3,7 @@ "files": [ { "path": "archlinux.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/argo.json b/brands/argo.json index a9f2ef0..89ce596 100644 --- a/brands/argo.json +++ b/brands/argo.json @@ -3,7 +3,7 @@ "files": [ { "path": "argo.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/awesomelists.json b/brands/awesomelists.json index 416a707..04aa3cd 100644 --- a/brands/awesomelists.json +++ b/brands/awesomelists.json @@ -3,7 +3,7 @@ "files": [ { "path": "awesomelists.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/bitcoincash.json b/brands/bitcoincash.json index 97c3942..473a47a 100644 --- a/brands/bitcoincash.json +++ b/brands/bitcoincash.json @@ -3,7 +3,7 @@ "files": [ { "path": "bitcoincash.svg", - "content": "\n \n \n \n Bitcoin Cash\n \n" + "content": "\n \n Bitcoin Cash\n" } ] -} \ No newline at end of file +} diff --git a/brands/bootstrap.json b/brands/bootstrap.json index 300d39d..34b7e4e 100644 --- a/brands/bootstrap.json +++ b/brands/bootstrap.json @@ -3,7 +3,7 @@ "files": [ { "path": "bootstrap.svg", - "content": "\n \n \n \n Bootstrap\n \n" + "content": "\n \n Bootstrap\n" } ] -} \ No newline at end of file +} diff --git a/brands/brave.json b/brands/brave.json index 5398430..2ab8144 100644 --- a/brands/brave.json +++ b/brands/brave.json @@ -3,7 +3,7 @@ "files": [ { "path": "brave.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/bun.json b/brands/bun.json index 4e63ead..f5fbe62 100644 --- a/brands/bun.json +++ b/brands/bun.json @@ -3,7 +3,7 @@ "files": [ { "path": "bun.svg", - "content": "\n \n \n \n Bun\n \n" + "content": "\n \n Bun\n" } ] -} \ No newline at end of file +} diff --git a/brands/canonical.json b/brands/canonical.json index 2342a12..b0de262 100644 --- a/brands/canonical.json +++ b/brands/canonical.json @@ -3,7 +3,7 @@ "files": [ { "path": "canonical.svg", - "content": "\n \n \n Canonical\n \n" + "content": "\n Canonical\n" } ] -} \ No newline at end of file +} diff --git a/brands/chartdotjs.json b/brands/chartdotjs.json index b78e02d..81ceb52 100644 --- a/brands/chartdotjs.json +++ b/brands/chartdotjs.json @@ -3,7 +3,7 @@ "files": [ { "path": "chartdotjs.svg", - "content": "\n \n \n Chart.js\n \n" + "content": "\n Chart.js\n" } ] -} \ No newline at end of file +} diff --git a/brands/claude.json b/brands/claude.json index 469114f..77fd4a0 100644 --- a/brands/claude.json +++ b/brands/claude.json @@ -3,7 +3,7 @@ "files": [ { "path": "claude.svg", - "content": "\n \n \n Claude\n \n" + "content": "\n Claude\n" } ] -} \ No newline at end of file +} diff --git a/brands/cline.json b/brands/cline.json index 8e16c51..2e8bf67 100644 --- a/brands/cline.json +++ b/brands/cline.json @@ -3,7 +3,7 @@ "files": [ { "path": "cline.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/cloudflare.json b/brands/cloudflare.json index 04545ca..5204cde 100644 --- a/brands/cloudflare.json +++ b/brands/cloudflare.json @@ -3,7 +3,7 @@ "files": [ { "path": "cloudflare.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/cloudways.json b/brands/cloudways.json index 86ba033..13cf8c1 100644 --- a/brands/cloudways.json +++ b/brands/cloudways.json @@ -3,7 +3,7 @@ "files": [ { "path": "cloudways.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/codeberg.json b/brands/codeberg.json index a7483ad..bc80553 100644 --- a/brands/codeberg.json +++ b/brands/codeberg.json @@ -3,7 +3,7 @@ "files": [ { "path": "codeberg.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/codecov.json b/brands/codecov.json index cbc91af..303c8b7 100644 --- a/brands/codecov.json +++ b/brands/codecov.json @@ -3,7 +3,7 @@ "files": [ { "path": "codecov.svg", - "content": "\n \n \n Codecov\n \n" + "content": "\n Codecov\n" } ] -} \ No newline at end of file +} diff --git a/brands/codeproject.json b/brands/codeproject.json index d1ba928..cbde719 100644 --- a/brands/codeproject.json +++ b/brands/codeproject.json @@ -3,7 +3,7 @@ "files": [ { "path": "codeproject.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/coderabbit.json b/brands/coderabbit.json index 20d4163..6a0f5cb 100644 --- a/brands/coderabbit.json +++ b/brands/coderabbit.json @@ -3,7 +3,7 @@ "files": [ { "path": "coderabbit.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/composer.json b/brands/composer.json index a3efe9e..2fd3006 100644 --- a/brands/composer.json +++ b/brands/composer.json @@ -3,7 +3,7 @@ "files": [ { "path": "composer.svg", - "content": "\n \n \n Composer\n \n" + "content": "\n Composer\n" } ] -} \ No newline at end of file +} diff --git a/brands/cplusplus.json b/brands/cplusplus.json index 644281c..c410a6b 100644 --- a/brands/cplusplus.json +++ b/brands/cplusplus.json @@ -3,7 +3,7 @@ "files": [ { "path": "cplusplus.svg", - "content": "\n \n \n C++\n \n" + "content": "\n C++\n" } ] -} \ No newline at end of file +} diff --git a/brands/createreactapp.json b/brands/createreactapp.json index 6b04557..ec6f9c2 100644 --- a/brands/createreactapp.json +++ b/brands/createreactapp.json @@ -3,7 +3,7 @@ "files": [ { "path": "createreactapp.svg", - "content": "\n \n \n Create React App\n \n" + "content": "\n Create React App\n" } ] -} \ No newline at end of file +} diff --git a/brands/csharp.json b/brands/csharp.json index 3e75e41..fec4761 100644 --- a/brands/csharp.json +++ b/brands/csharp.json @@ -3,7 +3,7 @@ "files": [ { "path": "csharp.svg", - "content": "\n \n \n \n \n \n \n \n \n \n" + "content": "\n \n \n \n \n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/css.json b/brands/css.json index faec997..faaccfa 100644 --- a/brands/css.json +++ b/brands/css.json @@ -3,7 +3,7 @@ "files": [ { "path": "css.svg", - "content": "\n \n \n CSS\n \n" + "content": "\n CSS\n" } ] -} \ No newline at end of file +} diff --git a/brands/dart.json b/brands/dart.json index 0cee144..170abc3 100644 --- a/brands/dart.json +++ b/brands/dart.json @@ -3,7 +3,7 @@ "files": [ { "path": "dart.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/datadog.json b/brands/datadog.json index 80bb0b5..09a9ada 100644 --- a/brands/datadog.json +++ b/brands/datadog.json @@ -3,7 +3,7 @@ "files": [ { "path": "datadog.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/deno.json b/brands/deno.json index 4e1abd4..ec11057 100644 --- a/brands/deno.json +++ b/brands/deno.json @@ -3,7 +3,7 @@ "files": [ { "path": "deno.svg", - "content": "\n \n \n Deno\n \n" + "content": "\n Deno\n" } ] -} \ No newline at end of file +} diff --git a/brands/denodeploy.json b/brands/denodeploy.json index e8c24ab..bcd999c 100644 --- a/brands/denodeploy.json +++ b/brands/denodeploy.json @@ -3,7 +3,7 @@ "files": [ { "path": "denodeploy.svg", - "content": "\n \n \n Deno Deploy\n \n \n" + "content": "\n Deno Deploy\n \n" } ] -} \ No newline at end of file +} diff --git a/brands/devdotto.json b/brands/devdotto.json index 35f36ef..3cc852a 100644 --- a/brands/devdotto.json +++ b/brands/devdotto.json @@ -3,7 +3,7 @@ "files": [ { "path": "devdotto.svg", - "content": "\n \n \n dev.to\n \n" + "content": "\n dev.to\n" } ] -} \ No newline at end of file +} diff --git a/brands/digitalocean.json b/brands/digitalocean.json index 67cd7e9..940e383 100644 --- a/brands/digitalocean.json +++ b/brands/digitalocean.json @@ -3,7 +3,7 @@ "files": [ { "path": "digitalocean.svg", - "content": "\n \n \n DigitalOcean\n \n" + "content": "\n DigitalOcean\n" } ] -} \ No newline at end of file +} diff --git a/brands/django.json b/brands/django.json index ae8f764..b2fde44 100644 --- a/brands/django.json +++ b/brands/django.json @@ -3,7 +3,7 @@ "files": [ { "path": "django.svg", - "content": "\n \n\n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/docker.json b/brands/docker.json index b37779d..41fd357 100644 --- a/brands/docker.json +++ b/brands/docker.json @@ -3,7 +3,7 @@ "files": [ { "path": "docker.svg", - "content": "\n \n \n \n Docker\n \n" + "content": "\n \n Docker\n" } ] -} \ No newline at end of file +} diff --git a/brands/dotnet.json b/brands/dotnet.json index 12298cb..19fd53d 100644 --- a/brands/dotnet.json +++ b/brands/dotnet.json @@ -3,7 +3,7 @@ "files": [ { "path": "dotnet.svg", - "content": "\n \n \n .NET\n \n" + "content": "\n .NET\n" } ] -} \ No newline at end of file +} diff --git a/brands/drizzle.json b/brands/drizzle.json index 2f00055..4763fde 100644 --- a/brands/drizzle.json +++ b/brands/drizzle.json @@ -3,7 +3,7 @@ "files": [ { "path": "drizzle.svg", - "content": "\n \n \n Drizzle\n \n" + "content": "\n Drizzle\n" } ] -} \ No newline at end of file +} diff --git a/brands/editorconfig.json b/brands/editorconfig.json index f5c74b3..28ead6c 100644 --- a/brands/editorconfig.json +++ b/brands/editorconfig.json @@ -3,7 +3,7 @@ "files": [ { "path": "editorconfig.svg", - "content": "\n \n \n \n EditorConfig\n \n" + "content": "\n \n EditorConfig\n" } ] -} \ No newline at end of file +} diff --git a/brands/ejs.json b/brands/ejs.json index a44e168..f35e979 100644 --- a/brands/ejs.json +++ b/brands/ejs.json @@ -3,7 +3,7 @@ "files": [ { "path": "ejs.svg", - "content": "\n \n \n \n EJS\n \n" + "content": "\n \n EJS\n" } ] -} \ No newline at end of file +} diff --git a/brands/emberdotjs.json b/brands/emberdotjs.json index a1cf422..b4800d6 100644 --- a/brands/emberdotjs.json +++ b/brands/emberdotjs.json @@ -3,7 +3,7 @@ "files": [ { "path": "emberdotjs.svg", - "content": "\n \n \n \n Ember.js\n \n" + "content": "\n \n Ember.js\n" } ] -} \ No newline at end of file +} diff --git a/brands/ethereum.json b/brands/ethereum.json index 43bbdf8..bee5339 100644 --- a/brands/ethereum.json +++ b/brands/ethereum.json @@ -3,7 +3,7 @@ "files": [ { "path": "ethereum.svg", - "content": "\n \n \n Ethereum\n \n" + "content": "\n Ethereum\n" } ] -} \ No newline at end of file +} diff --git a/brands/expo.json b/brands/expo.json index 4073ae3..b765c11 100644 --- a/brands/expo.json +++ b/brands/expo.json @@ -3,7 +3,7 @@ "files": [ { "path": "expo.svg", - "content": "\n \n \n \n Expo\n \n" + "content": "\n \n Expo\n" } ] -} \ No newline at end of file +} diff --git a/brands/express.json b/brands/express.json index 14f5078..02ea299 100644 --- a/brands/express.json +++ b/brands/express.json @@ -3,7 +3,7 @@ "files": [ { "path": "express.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/facebook.json b/brands/facebook.json index b150ce4..11cd741 100644 --- a/brands/facebook.json +++ b/brands/facebook.json @@ -3,7 +3,7 @@ "files": [ { "path": "facebook.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/fastapi.json b/brands/fastapi.json index 0352aa6..ee10386 100644 --- a/brands/fastapi.json +++ b/brands/fastapi.json @@ -3,7 +3,7 @@ "files": [ { "path": "fastapi.svg", - "content": "\n \n \n FastAPI\n \n" + "content": "\n FastAPI\n" } ] -} \ No newline at end of file +} diff --git a/brands/fastify.json b/brands/fastify.json index e4daeed..3c84f0b 100644 --- a/brands/fastify.json +++ b/brands/fastify.json @@ -3,7 +3,7 @@ "files": [ { "path": "fastify.svg", - "content": "\n \n \n \n Fastify\n \n" + "content": "\n \n Fastify\n" } ] -} \ No newline at end of file +} diff --git a/brands/flutter.json b/brands/flutter.json index 8bd05ea..59a9b82 100644 --- a/brands/flutter.json +++ b/brands/flutter.json @@ -3,7 +3,7 @@ "files": [ { "path": "flutter.svg", - "content": "\n \n \n Flutter\n \n" + "content": "\n Flutter\n" } ] -} \ No newline at end of file +} diff --git a/brands/flydotio.json b/brands/flydotio.json index a55b273..193c89b 100644 --- a/brands/flydotio.json +++ b/brands/flydotio.json @@ -3,7 +3,7 @@ "files": [ { "path": "flydotio.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/fontawesome.json b/brands/fontawesome.json index 9bf7628..bdc5302 100644 --- a/brands/fontawesome.json +++ b/brands/fontawesome.json @@ -3,7 +3,7 @@ "files": [ { "path": "fontawesome.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/gatsby.json b/brands/gatsby.json index bd8f0a8..45ce60e 100644 --- a/brands/gatsby.json +++ b/brands/gatsby.json @@ -3,7 +3,7 @@ "files": [ { "path": "gatsby.svg", - "content": "\n \n \n Gatsby\n \n" + "content": "\n Gatsby\n" } ] -} \ No newline at end of file +} diff --git a/brands/git.json b/brands/git.json index 409a66b..573299b 100644 --- a/brands/git.json +++ b/brands/git.json @@ -3,7 +3,7 @@ "files": [ { "path": "git.svg", - "content": "\n \n \n Git\n \n" + "content": "\n Git\n" } ] -} \ No newline at end of file +} diff --git a/brands/gitbook.json b/brands/gitbook.json index 4ed446f..9e8f1a1 100644 --- a/brands/gitbook.json +++ b/brands/gitbook.json @@ -3,7 +3,7 @@ "files": [ { "path": "gitbook.svg", - "content": "\n \n \n GitBook\n \n" + "content": "\n GitBook\n" } ] -} \ No newline at end of file +} diff --git a/brands/gitcode.json b/brands/gitcode.json index 698c983..6230a5e 100644 --- a/brands/gitcode.json +++ b/brands/gitcode.json @@ -3,7 +3,7 @@ "files": [ { "path": "gitcode.svg", - "content": "\n \n \n \n GitCode\n \n" + "content": "\n \n GitCode\n" } ] -} \ No newline at end of file +} diff --git a/brands/github.json b/brands/github.json index cc1a57d..d34ebce 100644 --- a/brands/github.json +++ b/brands/github.json @@ -3,7 +3,7 @@ "files": [ { "path": "github.svg", - "content": "\n \n \n GitHub\n \n" + "content": "\n GitHub\n" } ] -} \ No newline at end of file +} diff --git a/brands/githubactions.json b/brands/githubactions.json index 6a3252c..6e217fd 100644 --- a/brands/githubactions.json +++ b/brands/githubactions.json @@ -3,7 +3,7 @@ "files": [ { "path": "githubactions.svg", - "content": "\n \n \n \n GitHub Actions\n \n" + "content": "\n \n GitHub Actions\n" } ] -} \ No newline at end of file +} diff --git a/brands/githubcopilot.json b/brands/githubcopilot.json index b9fd071..1a8e8ad 100644 --- a/brands/githubcopilot.json +++ b/brands/githubcopilot.json @@ -3,7 +3,7 @@ "files": [ { "path": "githubcopilot.svg", - "content": "\n \n \n \n GitHub Copilot\n \n" + "content": "\n \n GitHub Copilot\n" } ] -} \ No newline at end of file +} diff --git a/brands/gitignoredotio.json b/brands/gitignoredotio.json index 62fca7e..5752b9b 100644 --- a/brands/gitignoredotio.json +++ b/brands/gitignoredotio.json @@ -3,7 +3,7 @@ "files": [ { "path": "gitignoredotio.svg", - "content": "\n \n \n \n gitignore.io\n \n" + "content": "\n \n gitignore.io\n" } ] -} \ No newline at end of file +} diff --git a/brands/gitkraken.json b/brands/gitkraken.json index 3d2923d..8fe2c43 100644 --- a/brands/gitkraken.json +++ b/brands/gitkraken.json @@ -3,7 +3,7 @@ "files": [ { "path": "gitkraken.svg", - "content": "\n \n \n GitKraken\n \n" + "content": "\n GitKraken\n" } ] -} \ No newline at end of file +} diff --git a/brands/gitlab.json b/brands/gitlab.json index e099086..c00e92c 100644 --- a/brands/gitlab.json +++ b/brands/gitlab.json @@ -3,7 +3,7 @@ "files": [ { "path": "gitlab.svg", - "content": "\n \n \n GitLab\n \n" + "content": "\n GitLab\n" } ] -} \ No newline at end of file +} diff --git a/brands/gitpod.json b/brands/gitpod.json index da2602c..ceb93dd 100644 --- a/brands/gitpod.json +++ b/brands/gitpod.json @@ -3,7 +3,7 @@ "files": [ { "path": "gitpod.svg", - "content": "\n \n \n Gitpod\n \n" + "content": "\n Gitpod\n" } ] -} \ No newline at end of file +} diff --git a/brands/go.json b/brands/go.json index 5ddd087..269248e 100644 --- a/brands/go.json +++ b/brands/go.json @@ -3,7 +3,7 @@ "files": [ { "path": "go.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/googledrive.json b/brands/googledrive.json index 88942e0..b1ef5d6 100644 --- a/brands/googledrive.json +++ b/brands/googledrive.json @@ -3,7 +3,7 @@ "files": [ { "path": "googledrive.svg", - "content": "\n \n \n \n Google Drive\n \n" + "content": "\n \n Google Drive\n" } ] -} \ No newline at end of file +} diff --git a/brands/googlegemini.json b/brands/googlegemini.json index 9901f88..87de888 100644 --- a/brands/googlegemini.json +++ b/brands/googlegemini.json @@ -3,7 +3,7 @@ "files": [ { "path": "googlegemini.svg", - "content": "\n \n \n Google Gemini\n \n" + "content": "\n Google Gemini\n" } ] -} \ No newline at end of file +} diff --git a/brands/grafana.json b/brands/grafana.json index 651b265..dffc465 100644 --- a/brands/grafana.json +++ b/brands/grafana.json @@ -3,7 +3,7 @@ "files": [ { "path": "grafana.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/graphql.json b/brands/graphql.json index 39b3124..6d9028f 100644 --- a/brands/graphql.json +++ b/brands/graphql.json @@ -3,7 +3,7 @@ "files": [ { "path": "graphql.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/gravatar.json b/brands/gravatar.json index 37f62ff..044b579 100644 --- a/brands/gravatar.json +++ b/brands/gravatar.json @@ -3,7 +3,7 @@ "files": [ { "path": "gravatar.svg", - "content": "\n \n \n \n Gravatar\n \n" + "content": "\n \n Gravatar\n" } ] -} \ No newline at end of file +} diff --git a/brands/gsap.json b/brands/gsap.json index c712243..1db56ad 100644 --- a/brands/gsap.json +++ b/brands/gsap.json @@ -3,7 +3,7 @@ "files": [ { "path": "gsap.svg", - "content": "\n \n \n GSAP\n \n" + "content": "\n GSAP\n" } ] -} \ No newline at end of file +} diff --git a/brands/handlebarsjs.json b/brands/handlebarsjs.json index 3e04b90..29864ba 100644 --- a/brands/handlebarsjs.json +++ b/brands/handlebarsjs.json @@ -3,7 +3,7 @@ "files": [ { "path": "handlebarsjs.svg", - "content": "\n \n \n \n Handlebars.js\n \n" + "content": "\n \n Handlebars.js\n" } ] -} \ No newline at end of file +} diff --git a/brands/happycow.json b/brands/happycow.json index aedd1ba..0425af8 100644 --- a/brands/happycow.json +++ b/brands/happycow.json @@ -3,7 +3,7 @@ "files": [ { "path": "happycow.svg", - "content": "\n \n \n HappyCow\n \n" + "content": "\n HappyCow\n" } ] -} \ No newline at end of file +} diff --git a/brands/heroui.json b/brands/heroui.json index e955842..ba2d30f 100644 --- a/brands/heroui.json +++ b/brands/heroui.json @@ -3,7 +3,7 @@ "files": [ { "path": "heroui.svg", - "content": "\n \n \n HeroUI\n \n" + "content": "\n HeroUI\n" } ] -} \ No newline at end of file +} diff --git a/brands/html.json b/brands/html.json index 42ea522..f1d2733 100644 --- a/brands/html.json +++ b/brands/html.json @@ -3,7 +3,7 @@ "files": [ { "path": "html.svg", - "content": "\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n" + "content": "\r\n \n \r\n \r\n \r\n \r\n \r\n \n" } ] -} \ No newline at end of file +} diff --git a/brands/htmx.json b/brands/htmx.json index b0ce830..ad4e640 100644 --- a/brands/htmx.json +++ b/brands/htmx.json @@ -3,7 +3,7 @@ "files": [ { "path": "htmx.svg", - "content": "\n \n \n \n htmx\n \n" + "content": "\n \n htmx\n" } ] -} \ No newline at end of file +} diff --git a/brands/huggingface.json b/brands/huggingface.json index fa5f50d..6530585 100644 --- a/brands/huggingface.json +++ b/brands/huggingface.json @@ -3,7 +3,7 @@ "files": [ { "path": "huggingface.svg", - "content": "\n \n \n \n Hugging Face\n \n" + "content": "\n \n \n" } ] } \ No newline at end of file diff --git a/brands/hugo.json b/brands/hugo.json index f6b35b5..efb11e0 100644 --- a/brands/hugo.json +++ b/brands/hugo.json @@ -3,7 +3,7 @@ "files": [ { "path": "hugo.svg", - "content": "\n \n \n Hugo\n \n" + "content": "\n Hugo\n" } ] -} \ No newline at end of file +} diff --git a/brands/i18next.json b/brands/i18next.json index 34e056c..fcb5f8a 100644 --- a/brands/i18next.json +++ b/brands/i18next.json @@ -3,7 +3,7 @@ "files": [ { "path": "i18next.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/iconjar.json b/brands/iconjar.json index e6063ea..ef66886 100644 --- a/brands/iconjar.json +++ b/brands/iconjar.json @@ -3,7 +3,7 @@ "files": [ { "path": "iconjar.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/immersivetranslate.json b/brands/immersivetranslate.json index 10e0466..e7d6295 100644 --- a/brands/immersivetranslate.json +++ b/brands/immersivetranslate.json @@ -3,7 +3,7 @@ "files": [ { "path": "immersivetranslate.svg", - "content": "\n \n \n \n Immersive Translate\n \n" + "content": "\n \n Immersive Translate\n" } ] -} \ No newline at end of file +} diff --git a/brands/instagram.json b/brands/instagram.json index 2b06ad8..e3a081b 100644 --- a/brands/instagram.json +++ b/brands/instagram.json @@ -3,7 +3,7 @@ "files": [ { "path": "instagram.svg", - "content": "\n \n \n \n Instagram\n \n" + "content": "\n \n Instagram\n" } ] -} \ No newline at end of file +} diff --git a/brands/intellijidea.json b/brands/intellijidea.json index 9800e1e..27c286f 100644 --- a/brands/intellijidea.json +++ b/brands/intellijidea.json @@ -3,7 +3,7 @@ "files": [ { "path": "intellijidea.svg", - "content": "\n \n \n \n IntelliJ IDEA\n \n" + "content": "\n \n IntelliJ IDEA\n" } ] -} \ No newline at end of file +} diff --git a/brands/itunes.json b/brands/itunes.json index 4ab757e..79af1e9 100644 --- a/brands/itunes.json +++ b/brands/itunes.json @@ -3,7 +3,7 @@ "files": [ { "path": "itunes.svg", - "content": "\n \n \n \n iTunes\n \n" + "content": "\n \n iTunes\n" } ] -} \ No newline at end of file +} diff --git a/brands/javascript.json b/brands/javascript.json index 1bd9f73..8819aaf 100644 --- a/brands/javascript.json +++ b/brands/javascript.json @@ -3,7 +3,7 @@ "files": [ { "path": "javascript.svg", - "content": "\n \n \n JavaScript\n \n" + "content": "\n JavaScript\n" } ] -} \ No newline at end of file +} diff --git a/brands/jenkins.json b/brands/jenkins.json index 8561d50..7bb1e6f 100644 --- a/brands/jenkins.json +++ b/brands/jenkins.json @@ -3,7 +3,7 @@ "files": [ { "path": "jenkins.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/jinja.json b/brands/jinja.json index b4cb7ab..e1fc773 100644 --- a/brands/jinja.json +++ b/brands/jinja.json @@ -3,7 +3,7 @@ "files": [ { "path": "jinja.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/jitsi.json b/brands/jitsi.json index 56d39c9..22fedd9 100644 --- a/brands/jitsi.json +++ b/brands/jitsi.json @@ -3,7 +3,7 @@ "files": [ { "path": "jitsi.svg", - "content": "\n \n \n \n Jitsi\n \n" + "content": "\n \n Jitsi\n" } ] -} \ No newline at end of file +} diff --git a/brands/jquery.json b/brands/jquery.json index 4bb6b2e..3fb1e66 100644 --- a/brands/jquery.json +++ b/brands/jquery.json @@ -3,7 +3,7 @@ "files": [ { "path": "jquery.svg", - "content": "\n \n \n jQuery\n \n" + "content": "\n jQuery\n" } ] -} \ No newline at end of file +} diff --git a/brands/json.json b/brands/json.json index 70ce40c..6cf6169 100644 --- a/brands/json.json +++ b/brands/json.json @@ -3,7 +3,7 @@ "files": [ { "path": "json.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/kalilinux.json b/brands/kalilinux.json index 22c0a6b..edae3da 100644 --- a/brands/kalilinux.json +++ b/brands/kalilinux.json @@ -3,7 +3,7 @@ "files": [ { "path": "kalilinux.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/kotlin.json b/brands/kotlin.json index 3157594..f3a824b 100644 --- a/brands/kotlin.json +++ b/brands/kotlin.json @@ -3,7 +3,7 @@ "files": [ { "path": "kotlin.svg", - "content": "\n \n \n Kotlin\n \n" + "content": "\n Kotlin\n" } ] -} \ No newline at end of file +} diff --git a/brands/kubernetes.json b/brands/kubernetes.json index aa9cdab..dfcd46f 100644 --- a/brands/kubernetes.json +++ b/brands/kubernetes.json @@ -3,7 +3,7 @@ "files": [ { "path": "kubernetes.svg", - "content": "\n \n \n Kubernetes\n \n" + "content": "\n Kubernetes\n" } ] -} \ No newline at end of file +} diff --git a/brands/laravel.json b/brands/laravel.json index 6cacf9e..5220e47 100644 --- a/brands/laravel.json +++ b/brands/laravel.json @@ -3,7 +3,7 @@ "files": [ { "path": "laravel.svg", - "content": "\n \n \n Laravel\n \n" + "content": "\n Laravel\n" } ] -} \ No newline at end of file +} diff --git a/brands/less.json b/brands/less.json index aad95e9..c61e419 100644 --- a/brands/less.json +++ b/brands/less.json @@ -3,7 +3,7 @@ "files": [ { "path": "less.svg", - "content": "\n \n \n Less\n \n" + "content": "\n Less\n" } ] -} \ No newline at end of file +} diff --git a/brands/line.json b/brands/line.json index df647e2..ffe464c 100644 --- a/brands/line.json +++ b/brands/line.json @@ -3,7 +3,7 @@ "files": [ { "path": "line.svg", - "content": "\n \n \n \n LINE\n \n" + "content": "\n \n LINE\n" } ] -} \ No newline at end of file +} diff --git a/brands/linear.json b/brands/linear.json index 40903a4..8e9a12d 100644 --- a/brands/linear.json +++ b/brands/linear.json @@ -3,7 +3,7 @@ "files": [ { "path": "linear.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/linux.json b/brands/linux.json index 5c2df10..fd7c005 100644 --- a/brands/linux.json +++ b/brands/linux.json @@ -3,7 +3,7 @@ "files": [ { "path": "linux.svg", - "content": "\n \n \n \n Linux\n \n" + "content": "\n \n Linux\n" } ] -} \ No newline at end of file +} diff --git a/brands/lit.json b/brands/lit.json index 910e6c8..3d896d8 100644 --- a/brands/lit.json +++ b/brands/lit.json @@ -3,7 +3,7 @@ "files": [ { "path": "lit.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/livewire.json b/brands/livewire.json index e736de2..6d1a1be 100644 --- a/brands/livewire.json +++ b/brands/livewire.json @@ -3,7 +3,7 @@ "files": [ { "path": "livewire.svg", - "content": "\n \n \n \n Livewire\n \n" + "content": "\n \n Livewire\n" } ] -} \ No newline at end of file +} diff --git a/brands/mariadb.json b/brands/mariadb.json index f487299..3738ebd 100644 --- a/brands/mariadb.json +++ b/brands/mariadb.json @@ -3,7 +3,7 @@ "files": [ { "path": "mariadb.svg", - "content": "\n \n \n MariaDB\n \n" + "content": "\n MariaDB\n" } ] -} \ No newline at end of file +} diff --git a/brands/mariadbfoundation.json b/brands/mariadbfoundation.json index 52ad623..24cd5bd 100644 --- a/brands/mariadbfoundation.json +++ b/brands/mariadbfoundation.json @@ -3,7 +3,7 @@ "files": [ { "path": "mariadbfoundation.svg", - "content": "\n \n \n MariaDB Foundation\n \n" + "content": "\n MariaDB Foundation\n" } ] -} \ No newline at end of file +} diff --git a/brands/markdown.json b/brands/markdown.json index 4962d01..865fa30 100644 --- a/brands/markdown.json +++ b/brands/markdown.json @@ -3,7 +3,7 @@ "files": [ { "path": "markdown.svg", - "content": "\n \n \n Markdown\n \n" + "content": "\n Markdown\n" } ] -} \ No newline at end of file +} diff --git a/brands/marko.json b/brands/marko.json index 198249e..5b97cdb 100644 --- a/brands/marko.json +++ b/brands/marko.json @@ -3,7 +3,7 @@ "files": [ { "path": "marko.svg", - "content": "\n \n \n Marko\n \n" + "content": "\n Marko\n" } ] -} \ No newline at end of file +} diff --git a/brands/mcp.json b/brands/mcp.json index 823842f..5420012 100644 --- a/brands/mcp.json +++ b/brands/mcp.json @@ -3,7 +3,7 @@ "files": [ { "path": "mcp.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/meilisearch.json b/brands/meilisearch.json index d03b840..c02c72c 100644 --- a/brands/meilisearch.json +++ b/brands/meilisearch.json @@ -3,7 +3,7 @@ "files": [ { "path": "meilisearch.svg", - "content": "\n \n \n \n Meilisearch\n \n" + "content": "\n \n Meilisearch\n" } ] -} \ No newline at end of file +} diff --git a/brands/mistralai.json b/brands/mistralai.json index 7605ef8..2a160d8 100644 --- a/brands/mistralai.json +++ b/brands/mistralai.json @@ -3,7 +3,7 @@ "files": [ { "path": "mistralai.svg", - "content": "\n \n \n Mistral AI\n \n" + "content": "\n Mistral AI\n" } ] -} \ No newline at end of file +} diff --git a/brands/mongodb.json b/brands/mongodb.json index 9125e1e..106ef43 100644 --- a/brands/mongodb.json +++ b/brands/mongodb.json @@ -3,7 +3,7 @@ "files": [ { "path": "mongodb.svg", - "content": "\n \n \n MongoDB\n \n" + "content": "\n MongoDB\n" } ] -} \ No newline at end of file +} diff --git a/brands/mui.json b/brands/mui.json index e8444ae..3a88f9e 100644 --- a/brands/mui.json +++ b/brands/mui.json @@ -3,7 +3,7 @@ "files": [ { "path": "mui.svg", - "content": "\n \n \n \n MUI\n \n" + "content": "\n \n MUI\n" } ] -} \ No newline at end of file +} diff --git a/brands/mysql.json b/brands/mysql.json index 2afaacd..ed11456 100644 --- a/brands/mysql.json +++ b/brands/mysql.json @@ -3,7 +3,7 @@ "files": [ { "path": "mysql.svg", - "content": "\n \n \n MySQL\n \n" + "content": "\n MySQL\n" } ] -} \ No newline at end of file +} diff --git a/brands/n8n.json b/brands/n8n.json index 955ad83..dbcfafc 100644 --- a/brands/n8n.json +++ b/brands/n8n.json @@ -3,7 +3,7 @@ "files": [ { "path": "n8n.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/neovim.json b/brands/neovim.json index 6631e72..bad5e2d 100644 --- a/brands/neovim.json +++ b/brands/neovim.json @@ -3,7 +3,7 @@ "files": [ { "path": "neovim.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/nestjs.json b/brands/nestjs.json index 0cbc673..5aed098 100644 --- a/brands/nestjs.json +++ b/brands/nestjs.json @@ -3,7 +3,7 @@ "files": [ { "path": "nestjs.svg", - "content": "\n \n \n NestJS\n \n" + "content": "\n NestJS\n" } ] -} \ No newline at end of file +} diff --git a/brands/netlify.json b/brands/netlify.json index ce2097f..7dfe409 100644 --- a/brands/netlify.json +++ b/brands/netlify.json @@ -3,7 +3,7 @@ "files": [ { "path": "netlify.svg", - "content": "\n \n \n \n Netlify\n \n" + "content": "\n \n Netlify\n" } ] -} \ No newline at end of file +} diff --git a/brands/nextjs.json b/brands/nextjs.json index fc6c89d..ffebe05 100644 --- a/brands/nextjs.json +++ b/brands/nextjs.json @@ -3,7 +3,7 @@ "files": [ { "path": "nextjs.svg", - "content": "\n \n \n Next.js\n \n" + "content": "\n Next.js\n" } ] -} \ No newline at end of file +} diff --git a/brands/nginx.json b/brands/nginx.json index 332975b..82c20e5 100644 --- a/brands/nginx.json +++ b/brands/nginx.json @@ -3,7 +3,7 @@ "files": [ { "path": "nginx.svg", - "content": "\n \n \n NGINX\n \n" + "content": "\n NGINX\n" } ] -} \ No newline at end of file +} diff --git a/brands/nodejs.json b/brands/nodejs.json index 2da8c2e..583fc39 100644 --- a/brands/nodejs.json +++ b/brands/nodejs.json @@ -3,7 +3,7 @@ "files": [ { "path": "nodejs.svg", - "content": "\r\n \r\n \r\n Node.js\r\n \r\n" + "content": "\n Node.js\n" } ] -} \ No newline at end of file +} diff --git a/brands/notion.json b/brands/notion.json index 54da7dc..b51c575 100644 --- a/brands/notion.json +++ b/brands/notion.json @@ -3,7 +3,7 @@ "files": [ { "path": "notion.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/npm.json b/brands/npm.json index 998f0cd..ad8f926 100644 --- a/brands/npm.json +++ b/brands/npm.json @@ -3,7 +3,7 @@ "files": [ { "path": "npm.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/nuxt.json b/brands/nuxt.json index cbde127..e9791ff 100644 --- a/brands/nuxt.json +++ b/brands/nuxt.json @@ -3,7 +3,7 @@ "files": [ { "path": "nuxt.svg", - "content": "\n \n \n Nuxt\n \n" + "content": "\n Nuxt\n" } ] -} \ No newline at end of file +} diff --git a/brands/nvm.json b/brands/nvm.json index a71e9bd..a685049 100644 --- a/brands/nvm.json +++ b/brands/nvm.json @@ -3,7 +3,7 @@ "files": [ { "path": "nvm.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/opensourceinitiative.json b/brands/opensourceinitiative.json index 89e95bc..9acce10 100644 --- a/brands/opensourceinitiative.json +++ b/brands/opensourceinitiative.json @@ -3,7 +3,7 @@ "files": [ { "path": "opensourceinitiative.svg", - "content": "\n \n \n \n Open Source Initiative\n \n" + "content": "\n \n Open Source Initiative\n" } ] -} \ No newline at end of file +} diff --git a/brands/peerlist.json b/brands/peerlist.json index 759dab6..fcb5bdd 100644 --- a/brands/peerlist.json +++ b/brands/peerlist.json @@ -3,7 +3,7 @@ "files": [ { "path": "peerlist.svg", - "content": "\n \n \n \n Peerlist\n \n" + "content": "\n \n Peerlist\n" } ] -} \ No newline at end of file +} diff --git a/brands/php.json b/brands/php.json index ed20c35..469e5ec 100644 --- a/brands/php.json +++ b/brands/php.json @@ -3,7 +3,7 @@ "files": [ { "path": "php.svg", - "content": "\n PHP\n \n \n \n \n" + "content": "\n PHP\n \n" } ] -} \ No newline at end of file +} diff --git a/brands/phpstorm.json b/brands/phpstorm.json index 9fa7dc2..46cf62a 100644 --- a/brands/phpstorm.json +++ b/brands/phpstorm.json @@ -3,7 +3,7 @@ "files": [ { "path": "phpstorm.svg", - "content": "\n \n \n PhpStorm\n \n" + "content": "\n PhpStorm\n" } ] -} \ No newline at end of file +} diff --git a/brands/pm2.json b/brands/pm2.json index 56b0ed4..8118a4f 100644 --- a/brands/pm2.json +++ b/brands/pm2.json @@ -3,7 +3,7 @@ "files": [ { "path": "pm2.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/postcss.json b/brands/postcss.json index 4bada62..966027f 100644 --- a/brands/postcss.json +++ b/brands/postcss.json @@ -3,7 +3,7 @@ "files": [ { "path": "postcss.svg", - "content": "\n \n \n \n PostCSS\n \n" + "content": "\n \n PostCSS\n" } ] -} \ No newline at end of file +} diff --git a/brands/postgresql.json b/brands/postgresql.json index 24d2202..23edc51 100644 --- a/brands/postgresql.json +++ b/brands/postgresql.json @@ -3,7 +3,7 @@ "files": [ { "path": "postgresql.svg", - "content": "\n \n \n PostgreSQL\n \n" + "content": "\n PostgreSQL\n" } ] -} \ No newline at end of file +} diff --git a/brands/postman.json b/brands/postman.json index 915fc98..8d5812d 100644 --- a/brands/postman.json +++ b/brands/postman.json @@ -3,7 +3,7 @@ "files": [ { "path": "postman.svg", - "content": "\n \n \n \n Postman\n \n" + "content": "\n \n Postman\n" } ] -} \ No newline at end of file +} diff --git a/brands/preact.json b/brands/preact.json index 208d0a3..1adfacb 100644 --- a/brands/preact.json +++ b/brands/preact.json @@ -3,7 +3,7 @@ "files": [ { "path": "preact.svg", - "content": "\n \n \n Preact\n \n" + "content": "\n Preact\n" } ] -} \ No newline at end of file +} diff --git a/brands/primereact.json b/brands/primereact.json index fa91510..236a855 100644 --- a/brands/primereact.json +++ b/brands/primereact.json @@ -3,7 +3,7 @@ "files": [ { "path": "primereact.svg", - "content": "\n \n \n PrimeReact\n \n" + "content": "\n PrimeReact\n" } ] -} \ No newline at end of file +} diff --git a/brands/primevue.json b/brands/primevue.json index b3ddc57..7db924c 100644 --- a/brands/primevue.json +++ b/brands/primevue.json @@ -3,7 +3,7 @@ "files": [ { "path": "primevue.svg", - "content": "\n \n \n \n PrimeVue\n \n" + "content": "\n \n PrimeVue\n" } ] -} \ No newline at end of file +} diff --git a/brands/prisma.json b/brands/prisma.json index df4fe4a..61d607e 100644 --- a/brands/prisma.json +++ b/brands/prisma.json @@ -3,7 +3,7 @@ "files": [ { "path": "prisma.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/prometheus.json b/brands/prometheus.json index f1cae05..821f12e 100644 --- a/brands/prometheus.json +++ b/brands/prometheus.json @@ -3,7 +3,7 @@ "files": [ { "path": "prometheus.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/pug.json b/brands/pug.json index 4e1ff51..93c307e 100644 --- a/brands/pug.json +++ b/brands/pug.json @@ -3,7 +3,7 @@ "files": [ { "path": "pug.svg", - "content": "\n \n \n \n Pug\n \n" + "content": "\n \n Pug\n" } ] -} \ No newline at end of file +} diff --git a/brands/pwa.json b/brands/pwa.json index 8613fb9..6fc2108 100644 --- a/brands/pwa.json +++ b/brands/pwa.json @@ -3,7 +3,7 @@ "files": [ { "path": "pwa.svg", - "content": "\n \n \n \n PWA\n \n" + "content": "\n \n PWA\n" } ] -} \ No newline at end of file +} diff --git a/brands/python.json b/brands/python.json index 359a133..890437e 100644 --- a/brands/python.json +++ b/brands/python.json @@ -3,7 +3,7 @@ "files": [ { "path": "python.svg", - "content": "\n \n \n Python\n \n \n" + "content": "\n Python\n \n" } ] -} \ No newline at end of file +} diff --git a/brands/quasar.json b/brands/quasar.json index ebe33b2..511b71b 100644 --- a/brands/quasar.json +++ b/brands/quasar.json @@ -3,7 +3,7 @@ "files": [ { "path": "quasar.svg", - "content": "\n \n \n \n Quasar\n \n" + "content": "\n \n Quasar\n" } ] -} \ No newline at end of file +} diff --git a/brands/qwik.json b/brands/qwik.json index 5163f92..9df6c8d 100644 --- a/brands/qwik.json +++ b/brands/qwik.json @@ -3,7 +3,7 @@ "files": [ { "path": "qwik.svg", - "content": "\n \n \n \n Qwik\n \n" + "content": "\n \n Qwik\n" } ] -} \ No newline at end of file +} diff --git a/brands/railway.json b/brands/railway.json index efdafe0..ddac57e 100644 --- a/brands/railway.json +++ b/brands/railway.json @@ -3,7 +3,7 @@ "files": [ { "path": "railway.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/rapid.json b/brands/rapid.json index 4a63416..6430cd1 100644 --- a/brands/rapid.json +++ b/brands/rapid.json @@ -3,7 +3,7 @@ "files": [ { "path": "rapid.svg", - "content": "\n \n \n Rapid\n \n" + "content": "\n Rapid\n" } ] -} \ No newline at end of file +} diff --git a/brands/react.json b/brands/react.json index ad8bd53..9d78418 100644 --- a/brands/react.json +++ b/brands/react.json @@ -3,7 +3,7 @@ "files": [ { "path": "react.svg", - "content": "\n \n \n React\n \n" + "content": "\n React\n" } ] -} \ No newline at end of file +} diff --git a/brands/reactos.json b/brands/reactos.json index 751eefd..5e1e21f 100644 --- a/brands/reactos.json +++ b/brands/reactos.json @@ -3,7 +3,7 @@ "files": [ { "path": "reactos.svg", - "content": "\n \n \n ReactOS\n \n" + "content": "\n ReactOS\n" } ] -} \ No newline at end of file +} diff --git a/brands/reactquery.json b/brands/reactquery.json index ac30605..9fa9341 100644 --- a/brands/reactquery.json +++ b/brands/reactquery.json @@ -3,7 +3,7 @@ "files": [ { "path": "reactquery.svg", - "content": "\n \n \n React Query\n \n" + "content": "\n React Query\n" } ] -} \ No newline at end of file +} diff --git a/brands/reactrouter.json b/brands/reactrouter.json index 263a848..680cd3c 100644 --- a/brands/reactrouter.json +++ b/brands/reactrouter.json @@ -3,7 +3,7 @@ "files": [ { "path": "reactrouter.svg", - "content": "\n \n \n React Router\n \n" + "content": "\n React Router\n" } ] -} \ No newline at end of file +} diff --git a/brands/redis.json b/brands/redis.json index eb2b1eb..5abe762 100644 --- a/brands/redis.json +++ b/brands/redis.json @@ -3,7 +3,7 @@ "files": [ { "path": "redis.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/redwoodjs.json b/brands/redwoodjs.json index b7c9343..f1efac7 100644 --- a/brands/redwoodjs.json +++ b/brands/redwoodjs.json @@ -3,7 +3,7 @@ "files": [ { "path": "redwoodjs.svg", - "content": "\n \n \n \n RedwoodJS\n \n" + "content": "\n \n RedwoodJS\n" } ] -} \ No newline at end of file +} diff --git a/brands/remix.json b/brands/remix.json index 2bec323..3b321c9 100644 --- a/brands/remix.json +++ b/brands/remix.json @@ -3,7 +3,7 @@ "files": [ { "path": "remix.svg", - "content": "\n \n \n Remix\n \n" + "content": "\n Remix\n" } ] -} \ No newline at end of file +} diff --git a/brands/republicofgamers.json b/brands/republicofgamers.json index ea8f313..fc09324 100644 --- a/brands/republicofgamers.json +++ b/brands/republicofgamers.json @@ -3,7 +3,7 @@ "files": [ { "path": "republicofgamers.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/runrundotit.json b/brands/runrundotit.json index 57f395a..c877b7c 100644 --- a/brands/runrundotit.json +++ b/brands/runrundotit.json @@ -3,7 +3,7 @@ "files": [ { "path": "runrundotit.svg", - "content": "\n \n \n \n Runrun.it\n \n" + "content": "\n \n Runrun.it\n" } ] -} \ No newline at end of file +} diff --git a/brands/rust.json b/brands/rust.json index 6506e01..92a1a27 100644 --- a/brands/rust.json +++ b/brands/rust.json @@ -3,7 +3,7 @@ "files": [ { "path": "rust.svg", - "content": "\n \n \n Rust\n \n" + "content": "\n Rust\n" } ] -} \ No newline at end of file +} diff --git a/brands/samsung.json b/brands/samsung.json index 363ce8f..1001c11 100644 --- a/brands/samsung.json +++ b/brands/samsung.json @@ -3,7 +3,7 @@ "files": [ { "path": "samsung.svg", - "content": "\n\n\n\n\n\n\n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/sap.json b/brands/sap.json index e89055a..6054e77 100644 --- a/brands/sap.json +++ b/brands/sap.json @@ -3,7 +3,7 @@ "files": [ { "path": "sap.svg", - "content": "\n \n \n SAP\n \n" + "content": "\n SAP\n" } ] -} \ No newline at end of file +} diff --git a/brands/sass.json b/brands/sass.json index 34ed688..73186f8 100644 --- a/brands/sass.json +++ b/brands/sass.json @@ -3,7 +3,7 @@ "files": [ { "path": "sass.svg", - "content": "\n \n \n Sass\n \n" + "content": "\n Sass\n" } ] -} \ No newline at end of file +} diff --git a/brands/shadcnui.json b/brands/shadcnui.json index 4b03a97..3e3d9f9 100644 --- a/brands/shadcnui.json +++ b/brands/shadcnui.json @@ -3,7 +3,7 @@ "files": [ { "path": "shadcnui.svg", - "content": "\n \n \n shadcn/ui\n \n" + "content": "\n shadcn/ui\n" } ] -} \ No newline at end of file +} diff --git a/brands/sharp.json b/brands/sharp.json index f2eab12..2a89f81 100644 --- a/brands/sharp.json +++ b/brands/sharp.json @@ -3,7 +3,7 @@ "files": [ { "path": "sharp.svg", - "content": "\n \n \n sharp\n \n" + "content": "\n sharp\n" } ] -} \ No newline at end of file +} diff --git a/brands/solid.json b/brands/solid.json index 04619fa..f4fe17f 100644 --- a/brands/solid.json +++ b/brands/solid.json @@ -3,7 +3,7 @@ "files": [ { "path": "solid.svg", - "content": "\n \n \n Solid\n \n" + "content": "\n Solid\n" } ] -} \ No newline at end of file +} diff --git a/brands/sonar.json b/brands/sonar.json index a700e3d..7fdcda2 100644 --- a/brands/sonar.json +++ b/brands/sonar.json @@ -3,7 +3,7 @@ "files": [ { "path": "sonar.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/soundcloud.json b/brands/soundcloud.json index d9d718a..45f6cbb 100644 --- a/brands/soundcloud.json +++ b/brands/soundcloud.json @@ -3,7 +3,7 @@ "files": [ { "path": "soundcloud.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/spring.json b/brands/spring.json index fb49734..3644513 100644 --- a/brands/spring.json +++ b/brands/spring.json @@ -3,7 +3,7 @@ "files": [ { "path": "spring.svg", - "content": "\n \n \n Spring\n \n" + "content": "\n Spring\n" } ] -} \ No newline at end of file +} diff --git a/brands/sqlite.json b/brands/sqlite.json index 487adf3..4c2c7c3 100644 --- a/brands/sqlite.json +++ b/brands/sqlite.json @@ -3,7 +3,7 @@ "files": [ { "path": "sqlite.svg", - "content": "\n \n \n \n SQLite\n \n" + "content": "\n \n SQLite\n" } ] -} \ No newline at end of file +} diff --git a/brands/stencil.json b/brands/stencil.json index 7044c41..1a050bf 100644 --- a/brands/stencil.json +++ b/brands/stencil.json @@ -3,7 +3,7 @@ "files": [ { "path": "stencil.svg", - "content": "\n \n \n \n Stencil\n \n" + "content": "\n \n Stencil\n" } ] -} \ No newline at end of file +} diff --git a/brands/styledcomponents.json b/brands/styledcomponents.json index 086b8a5..fd11353 100644 --- a/brands/styledcomponents.json +++ b/brands/styledcomponents.json @@ -3,7 +3,7 @@ "files": [ { "path": "styledcomponents.svg", - "content": "\n\n \n\n\n\n\n\n" + "content": "\n\n\n" } ] -} \ No newline at end of file +} diff --git a/brands/sublimetext.json b/brands/sublimetext.json index 12e8518..38013a5 100644 --- a/brands/sublimetext.json +++ b/brands/sublimetext.json @@ -3,7 +3,7 @@ "files": [ { "path": "sublimetext.svg", - "content": "\n \n \n \n Sublime Text\n \n" + "content": "\n \n Sublime Text\n" } ] -} \ No newline at end of file +} diff --git a/brands/supabase.json b/brands/supabase.json index fec5997..996d57b 100644 --- a/brands/supabase.json +++ b/brands/supabase.json @@ -3,7 +3,7 @@ "files": [ { "path": "supabase.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/svelte.json b/brands/svelte.json index a5a1c14..a4435dc 100644 --- a/brands/svelte.json +++ b/brands/svelte.json @@ -3,7 +3,7 @@ "files": [ { "path": "svelte.svg", - "content": "\n \n \n Svelte\n \n" + "content": "\n Svelte\n" } ] -} \ No newline at end of file +} diff --git a/brands/swift.json b/brands/swift.json index 8c5bc5d..96f4e8a 100644 --- a/brands/swift.json +++ b/brands/swift.json @@ -3,7 +3,7 @@ "files": [ { "path": "swift.svg", - "content": "\n \n \n Swift\n \n" + "content": "\n Swift\n" } ] -} \ No newline at end of file +} diff --git a/brands/tailwindcss.json b/brands/tailwindcss.json index 1f6fd9d..f8abd58 100644 --- a/brands/tailwindcss.json +++ b/brands/tailwindcss.json @@ -3,7 +3,7 @@ "files": [ { "path": "tailwindcss.svg", - "content": "\n \n \n \n Tailwind CSS\n \n \n" + "content": "\n \n Tailwind CSS\n \n" } ] -} \ No newline at end of file +} diff --git a/brands/tanstack.json b/brands/tanstack.json index 6ae85e3..9cd10e5 100644 --- a/brands/tanstack.json +++ b/brands/tanstack.json @@ -3,7 +3,7 @@ "files": [ { "path": "tanstack.svg", - "content": "\n \n \n TanStack\n \n" + "content": "\n TanStack\n" } ] -} \ No newline at end of file +} diff --git a/brands/telegram.json b/brands/telegram.json index bd95a3a..20adf27 100644 --- a/brands/telegram.json +++ b/brands/telegram.json @@ -3,7 +3,7 @@ "files": [ { "path": "telegram.svg", - "content": "\n \n \n \n Telegram\n \n" + "content": "\n \n Telegram\n" } ] -} \ No newline at end of file +} diff --git a/brands/termius.json b/brands/termius.json index 76d52f3..9313f26 100644 --- a/brands/termius.json +++ b/brands/termius.json @@ -3,7 +3,7 @@ "files": [ { "path": "termius.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/terraform.json b/brands/terraform.json index 888752a..a8a994a 100644 --- a/brands/terraform.json +++ b/brands/terraform.json @@ -3,7 +3,7 @@ "files": [ { "path": "terraform.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/threedotjs.json b/brands/threedotjs.json index 0d7e121..bbf9872 100644 --- a/brands/threedotjs.json +++ b/brands/threedotjs.json @@ -3,7 +3,7 @@ "files": [ { "path": "threedotjs.svg", - "content": "\n \n \n Three.js\n \n" + "content": "\n Three.js\n" } ] -} \ No newline at end of file +} diff --git a/brands/tor.json b/brands/tor.json index 8cc9b8f..274febb 100644 --- a/brands/tor.json +++ b/brands/tor.json @@ -3,7 +3,7 @@ "files": [ { "path": "tor.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/travisci.json b/brands/travisci.json index f4bfe3e..b9c4f93 100644 --- a/brands/travisci.json +++ b/brands/travisci.json @@ -3,7 +3,7 @@ "files": [ { "path": "travisci.svg", - "content": "\n \n \n \n \n \n" + "content": "\n \n \n" } ] -} \ No newline at end of file +} diff --git a/brands/tsnode.json b/brands/tsnode.json index e4c3b20..b798177 100644 --- a/brands/tsnode.json +++ b/brands/tsnode.json @@ -3,7 +3,7 @@ "files": [ { "path": "tsnode.svg", - "content": "\n \n \n ts-node\n \n" + "content": "\n ts-node\n" } ] -} \ No newline at end of file +} diff --git a/brands/typescript.json b/brands/typescript.json index 5294703..234fd65 100644 --- a/brands/typescript.json +++ b/brands/typescript.json @@ -3,7 +3,7 @@ "files": [ { "path": "typescript.svg", - "content": "\n \n \n TypeScript\n \n" + "content": "\n TypeScript\n" } ] -} \ No newline at end of file +} diff --git a/brands/ubuntu.json b/brands/ubuntu.json index ad49806..14857a3 100644 --- a/brands/ubuntu.json +++ b/brands/ubuntu.json @@ -3,7 +3,7 @@ "files": [ { "path": "ubuntu.svg", - "content": "\n\n\n\n\n\n\n\n\n" + "content": "\n\n\n\n" } ] -} \ No newline at end of file +} diff --git a/brands/unsplash.json b/brands/unsplash.json index c5593b0..eb974cb 100644 --- a/brands/unsplash.json +++ b/brands/unsplash.json @@ -3,7 +3,7 @@ "files": [ { "path": "unsplash.svg", - "content": "\n \n \n Unsplash\n \n" + "content": "\n Unsplash\n" } ] -} \ No newline at end of file +} diff --git a/brands/vercel.json b/brands/vercel.json index eba7110..27d9c38 100644 --- a/brands/vercel.json +++ b/brands/vercel.json @@ -3,7 +3,7 @@ "files": [ { "path": "vercel.svg", - "content": "\n \n \n \n Vercel\n \n" + "content": "\n \n Vercel\n" } ] -} \ No newline at end of file +} diff --git a/brands/vim.json b/brands/vim.json index 2b8e047..18a542d 100644 --- a/brands/vim.json +++ b/brands/vim.json @@ -3,7 +3,7 @@ "files": [ { "path": "vim.svg", - "content": "\n \n \n Vim\n \n" + "content": "\n Vim\n" } ] -} \ No newline at end of file +} diff --git a/brands/vite.json b/brands/vite.json index 50b15fc..d7db01e 100644 --- a/brands/vite.json +++ b/brands/vite.json @@ -3,7 +3,7 @@ "files": [ { "path": "vite.svg", - "content": "\n \n \n \n Vite\n \n" + "content": "\n \n Vite\n" } ] -} \ No newline at end of file +} diff --git a/brands/vitepress.json b/brands/vitepress.json index c01b6e5..ddb8893 100644 --- a/brands/vitepress.json +++ b/brands/vitepress.json @@ -3,7 +3,7 @@ "files": [ { "path": "vitepress.svg", - "content": "\n \n \n \n VitePress\n \n" + "content": "\n \n VitePress\n" } ] -} \ No newline at end of file +} diff --git a/brands/vitess.json b/brands/vitess.json index 55dc9f5..10c602c 100644 --- a/brands/vitess.json +++ b/brands/vitess.json @@ -3,7 +3,7 @@ "files": [ { "path": "vitess.svg", - "content": "\n \n \n Vitess\n \n" + "content": "\n Vitess\n" } ] -} \ No newline at end of file +} diff --git a/brands/vuedotjs.json b/brands/vuedotjs.json index 34a4093..109b8a4 100644 --- a/brands/vuedotjs.json +++ b/brands/vuedotjs.json @@ -3,7 +3,7 @@ "files": [ { "path": "vuedotjs.svg", - "content": "\n \n \n Vue.js\n \n" + "content": "\n Vue.js\n" } ] -} \ No newline at end of file +} diff --git a/brands/vuetify.json b/brands/vuetify.json index 81b5b7c..3e658e8 100644 --- a/brands/vuetify.json +++ b/brands/vuetify.json @@ -3,7 +3,7 @@ "files": [ { "path": "vuetify.svg", - "content": "\n \n \n Vuetify\n \n" + "content": "\n Vuetify\n" } ] -} \ No newline at end of file +} diff --git a/brands/vueuse.json b/brands/vueuse.json index f295ed1..8661561 100644 --- a/brands/vueuse.json +++ b/brands/vueuse.json @@ -3,7 +3,7 @@ "files": [ { "path": "vueuse.svg", - "content": "\n \n \n VueUse\n \n" + "content": "\n VueUse\n" } ] -} \ No newline at end of file +} diff --git a/brands/warp.json b/brands/warp.json index ffb442e..f2b3e1b 100644 --- a/brands/warp.json +++ b/brands/warp.json @@ -3,7 +3,7 @@ "files": [ { "path": "warp.svg", - "content": "\n \n \n Warp\n \n" + "content": "\n Warp\n" } ] -} \ No newline at end of file +} diff --git a/brands/webassembly.json b/brands/webassembly.json index 172cb3b..e7cbd79 100644 --- a/brands/webassembly.json +++ b/brands/webassembly.json @@ -3,7 +3,7 @@ "files": [ { "path": "webassembly.svg", - "content": "\n \n \n WebAssembly\n \n" + "content": "\n WebAssembly\n" } ] -} \ No newline at end of file +} diff --git a/brands/wondersharefilmora.json b/brands/wondersharefilmora.json index 2ab3eb9..ba043cb 100644 --- a/brands/wondersharefilmora.json +++ b/brands/wondersharefilmora.json @@ -3,7 +3,7 @@ "files": [ { "path": "wondersharefilmora.svg", - "content": "\n \n \n \n Wondershare Filmora\n \n" + "content": "\n \n Wondershare Filmora\n" } ] -} \ No newline at end of file +} diff --git a/brands/xampp.json b/brands/xampp.json index 49b0f81..9db4540 100644 --- a/brands/xampp.json +++ b/brands/xampp.json @@ -3,7 +3,7 @@ "files": [ { "path": "xampp.svg", - "content": "\n \n \n XAMPP\n \n" + "content": "\n XAMPP\n" } ] -} \ No newline at end of file +} diff --git a/brands/xml.json b/brands/xml.json index e9a9aa2..5945a82 100644 --- a/brands/xml.json +++ b/brands/xml.json @@ -3,7 +3,7 @@ "files": [ { "path": "xml.svg", - "content": "\n \n \n \n XML\n \n" + "content": "\n \n XML\n" } ] -} \ No newline at end of file +} diff --git a/brands/yaml.json b/brands/yaml.json index 09be6dc..c2d1e3d 100644 --- a/brands/yaml.json +++ b/brands/yaml.json @@ -3,7 +3,7 @@ "files": [ { "path": "yaml.svg", - "content": "\n \n \n \n YAML\n \n" + "content": "\n \n YAML\n" } ] -} \ No newline at end of file +} diff --git a/brands/youtube.json b/brands/youtube.json index e5ed7f9..04cd94d 100644 --- a/brands/youtube.json +++ b/brands/youtube.json @@ -3,7 +3,7 @@ "files": [ { "path": "youtube.svg", - "content": "\n \n \n \n YouTube\n \n" + "content": "\n \n YouTube\n" } ] -} \ No newline at end of file +} diff --git a/brands/youtubemusic.json b/brands/youtubemusic.json index 6723896..86fe284 100644 --- a/brands/youtubemusic.json +++ b/brands/youtubemusic.json @@ -3,7 +3,7 @@ "files": [ { "path": "youtubemusic.svg", - "content": "\n \n \n \n YouTube Music\n \n" + "content": "\n \n YouTube Music\n" } ] -} \ No newline at end of file +} diff --git a/brands/youtubeshorts.json b/brands/youtubeshorts.json index f5d6aa0..8154335 100644 --- a/brands/youtubeshorts.json +++ b/brands/youtubeshorts.json @@ -3,7 +3,7 @@ "files": [ { "path": "youtubeshorts.svg", - "content": "\n \n \n \n YouTube Shorts\n \n" + "content": "\n \n YouTube Shorts\n" } ] -} \ No newline at end of file +} From 14b1b8a5a0a98eafbdbab43086ba3df6af73df52 Mon Sep 17 00:00:00 2001 From: pphatdev Date: Fri, 24 Jul 2026 18:01:26 +0700 Subject: [PATCH 2/8] Add sample svg --- .gitignore | 2 ++ sample.svg | 5 +++++ 2 files changed, 7 insertions(+) create mode 100644 sample.svg diff --git a/.gitignore b/.gitignore index aed35c4..7916cf2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .github/scripts/dist/ node_modules/ + +.claude/ \ No newline at end of file diff --git a/sample.svg b/sample.svg new file mode 100644 index 0000000..614ef93 --- /dev/null +++ b/sample.svg @@ -0,0 +1,5 @@ + + + Angular + + \ No newline at end of file From 78b805710591db1c1c7ac2362b4aff2ad60d769b Mon Sep 17 00:00:00 2001 From: pphatdev Date: Fri, 24 Jul 2026 18:23:48 +0700 Subject: [PATCH 3/8] feat: introduce icon management studio with local demo server and UI tools --- .agents/RULES.md | 19 +++++++++++++++++++ AGENTS.md | 6 ++++++ README.md | 12 ++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 .agents/RULES.md create mode 100644 AGENTS.md diff --git a/.agents/RULES.md b/.agents/RULES.md new file mode 100644 index 0000000..6bb8a2c --- /dev/null +++ b/.agents/RULES.md @@ -0,0 +1,19 @@ +# Icon Generation Rules + +## Generate Icon Rule: +- size: 48x48px +- path: Clean Code +- color: all type of icons must be currentColor case icons has 2 colors using currentColor for main for white for foreground +- must: background have opacity 0.1 of main color for each icon (rounded) +- category: brands, regular, solid, thin, light, ... + +## Must have icon title + +### Example +```xml + + + Sample Title + + +``` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e73e732 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,6 @@ +# Agent Instructions + +> **IMPORTANT**: All AI agents working on this repository MUST read and adhere to all rule and instruction files located inside the [.agents](.agents) directory (for example, [.agents/RULES.md](.agents/RULES.md)). + +## Project Rules & Guidelines +Before generating or modifying icons, code, or documentation in this repository, inspect all files in the [.agents](.agents) directory and strictly follow the guidelines defined within them. diff --git a/README.md b/README.md index 45f59cb..8774ac6 100644 --- a/README.md +++ b/README.md @@ -50,3 +50,15 @@ To add a new icon to the registry: 2. That's it! You do not need to manually update `brands.json` or `regular.json`. A GitHub Actions workflow automatically compiles these index files when a Pull Request is opened or code is merged. For more detailed information, please see our [CONTRIBUTING.md](CONTRIBUTING.md). + + +## Preview Demo + +A local demo UI is provided for browsing and testing every icon in the repo — filter by category, search by name, live-recolor via `currentColor`, and copy the SVG markup. + +```bash +npm --prefix .github/scripts install # first time only +npm --prefix .github/scripts run demo +``` + +Then open http://localhost:5173/. See [`.github/scripts/README.md`](.github/scripts/README.md) for details. From 30a3cc5e203c1d13172babc6099ef99eb96f907d Mon Sep 17 00:00:00 2001 From: pphatdev Date: Fri, 24 Jul 2026 18:57:55 +0700 Subject: [PATCH 4/8] feat: add internal icon studio, browser, and graph visualization demo tools --- .github/scripts/README.md | 107 +++ .github/scripts/demo-server.ts | 83 +++ .github/scripts/demo/assets/css/main.css | 288 ++++++++ .github/scripts/demo/assets/js/browse.js | 82 +++ .github/scripts/demo/assets/js/data.js | 61 ++ .github/scripts/demo/assets/js/graph.js | 73 ++ .github/scripts/demo/assets/js/shell.js | 27 + .github/scripts/demo/assets/js/studio.js | 839 +++++++++++++++++++++++ .github/scripts/demo/browse.html | 56 ++ .github/scripts/demo/graph.html | 52 ++ .github/scripts/demo/studio.html | 223 ++++++ .github/scripts/package.json | 3 +- 12 files changed, 1893 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/README.md create mode 100644 .github/scripts/demo-server.ts create mode 100644 .github/scripts/demo/assets/css/main.css create mode 100644 .github/scripts/demo/assets/js/browse.js create mode 100644 .github/scripts/demo/assets/js/data.js create mode 100644 .github/scripts/demo/assets/js/graph.js create mode 100644 .github/scripts/demo/assets/js/shell.js create mode 100644 .github/scripts/demo/assets/js/studio.js create mode 100644 .github/scripts/demo/browse.html create mode 100644 .github/scripts/demo/graph.html create mode 100644 .github/scripts/demo/studio.html diff --git a/.github/scripts/README.md b/.github/scripts/README.md new file mode 100644 index 0000000..d7c82ff --- /dev/null +++ b/.github/scripts/README.md @@ -0,0 +1,107 @@ +# Icons Studio + +Local multi-page UI for browsing, visualizing, and editing every SVG icon in this repo. + +## Quick start + +From the repo root: + +```bash +npm --prefix .github/scripts install # first time only +npm --prefix .github/scripts run demo +``` + +Then open http://localhost:5173/ (redirects to `/browse`). Override the port with `PORT=8080`. + +## Pages + +Each tab is a real URL — deep-link, bookmark, or share. + +### `/browse` + +Grid view of every icon. Filter by category, search by name, recolor via the color picker (swaps `currentColor`), and resize with the slider. Click a tile to open the detail dialog with copyable SVG markup and an "Open in Studio" shortcut. + +### `/studio`  ·  `/studio?icon=/` for deep links + +Vector editor for SVG icons. + +- **Left panel** — searchable icon picker. Includes both registry icons and any `custom/*` icons you have created (persisted in `localStorage`). +- **Tools palette** (floating, top-left of canvas) — pick a tool then click+drag on the canvas to create a shape. Tools: Select, Rectangle, Ellipse, Line. After a shape is drawn the tool auto-switches back to Select. +- **Canvas** — the SVG is mounted live. Click any shape to select it. Drag to reposition. Background click (with Select tool) deselects. +- **Right panel — Element Tree** — flat list of every shape. +- **Right panel — Properties** — always shows the SVG root section (`title`, `width`, `height`, `viewBox`). When a shape is selected, sections for `fill`/`stroke`/`opacity`, `transform`, and per-element actions appear. + +#### Toolbar + +- `+ New Icon` — blank 48×48 SVG under a `custom` category, persisted to `localStorage` +- `Undo` / `Redo` — 100-step per-icon history +- `Reset` — restore the original SVG (clears history) +- `Center Selected` — clear the transform on the selected element +- `Zoom` — scale canvas rendering (source `viewBox` unchanged) +- `Copy SVG` / `Download` — export the edited SVG + +#### Keyboard shortcuts + +| Shortcut | Action | +| --- | --- | +| `V` | Select tool | +| `R` / `O` / `L` | Rectangle / Ellipse / Line tool | +| `Ctrl/Cmd + Z` | Undo | +| `Ctrl + Y` / `Ctrl + Shift + Z` | Redo | +| `Ctrl + C` / `Ctrl + V` | Copy / paste selected shape | +| `Ctrl + D` | Duplicate selected shape (offset by 10px) | +| `Delete` / `Backspace` | Remove selected shape | +| Arrow keys | Nudge selected shape 1px (Shift = 10px) | +| `Ctrl + ]` / `Ctrl + [` | Bring forward / send backward | +| `Ctrl + Shift + ]` / `Ctrl + Shift + [` | Bring to front / send to back | + +Registry files are never modified. Use `Copy SVG` or `Download` to persist changes off-server. + +### `/graph` + +Read-only dashboard of registry-wide metrics: + +- Total icons, categories, average shapes/icon, total & average SVG source size +- Icons per category (horizontal bar chart) +- Complexity distribution (bucketed by shape count) +- Size distribution (bucketed by SVG source bytes) +- Top 10 largest icons + +## Project layout + +``` +.github/scripts/ +├── demo-server.ts # Static file server (paths + fallback to repo root) +├── demo/ +│ ├── browse.html # /browse +│ ├── studio.html # /studio +│ ├── graph.html # /graph +│ └── assets/ +│ ├── css/ +│ │ └── main.css # Shared styles for all three pages +│ └── js/ +│ ├── data.js # Icon loading (index.json → *.json → SVGs) +│ ├── shell.js # Nav active state, stats badge, toast +│ ├── browse.js # /browse logic +│ ├── studio.js # /studio editor (drag, undo/redo, props, new icon) +│ └── graph.js # /graph metrics + charts +├── update-category.ts # Regenerates .json index files +├── package.json # Scripts: demo, update-category, build +└── README.md +``` + +## How the server routes requests + +`demo-server.ts` handles four kinds of requests: + +1. `/` — 302 redirect to `/browse` +2. `/browse`, `/studio`, `/graph` — served from `demo/*.html` +3. `/assets/**` — served from `demo/assets/**` +4. Anything else — served from the repo root (so `/index.json`, `/brands.json`, `/brands/react.json` etc. all work) + +Path traversal is blocked by requiring the resolved path to stay inside the intended root. + +## Notes + +- No build step, no runtime dependencies. Everything is vanilla ES modules loaded directly by the browser. +- Custom icons are stored under the `kfe-custom-icons` `localStorage` key. Clear browser storage to reset. diff --git a/.github/scripts/demo-server.ts b/.github/scripts/demo-server.ts new file mode 100644 index 0000000..7ba67da --- /dev/null +++ b/.github/scripts/demo-server.ts @@ -0,0 +1,83 @@ +import http from 'http'; +import fs from 'fs'; +import path from 'path'; + +const PORT = Number(process.env.PORT) || 5173; +const REPO_ROOT = path.resolve(__dirname, '../..'); +const DEMO_ROOT = path.join(__dirname, 'demo'); + +const MIME: Record = { + '.html': 'text/html; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2', +}; + +// Route → HTML page under demo/ +const PAGE_ROUTES: Record = { + '/browse': 'browse.html', + '/browse/': 'browse.html', + '/studio': 'studio.html', + '/studio/': 'studio.html', + '/graph': 'graph.html', + '/graph/': 'graph.html', +}; + +const server = http.createServer((req, res) => { + const url = decodeURIComponent((req.url ?? '/').split('?')[0]); + + // Root → redirect to /browse + if (url === '/' || url === '') { + res.writeHead(302, { Location: '/browse' }); + res.end(); + return; + } + + // Named routes → HTML pages + if (PAGE_ROUTES[url]) { + servePath(res, path.join(DEMO_ROOT, PAGE_ROUTES[url])); + return; + } + + // /assets/* → demo/assets/* + if (url.startsWith('/assets/')) { + const rel = url.slice('/assets/'.length); + const full = path.join(DEMO_ROOT, 'assets', rel); + if (!full.startsWith(path.join(DEMO_ROOT, 'assets'))) return send(res, 403, 'text/plain', 'Forbidden'); + servePath(res, full); + return; + } + + // Fallback: serve from repo root (index.json, brands.json, brands/*.json, ...) + const safe = path.normalize(url).replace(/^([/\\])+/, ''); + const full = path.join(REPO_ROOT, safe); + if (!full.startsWith(REPO_ROOT)) return send(res, 403, 'text/plain', 'Forbidden'); + servePath(res, full); +}); + +function servePath(res: http.ServerResponse, full: string) { + fs.stat(full, (err, stat) => { + if (err || !stat.isFile()) return send(res, 404, 'text/plain', 'Not found'); + fs.readFile(full, (err2, data) => { + if (err2) return send(res, 500, 'text/plain', 'Read error'); + const mime = MIME[path.extname(full).toLowerCase()] ?? 'application/octet-stream'; + send(res, 200, mime, data); + }); + }); +} + +function send(res: http.ServerResponse, status: number, type: string, body: string | Buffer) { + res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' }); + res.end(body); +} + +server.listen(PORT, () => { + console.log(`KFE icons demo → http://localhost:${PORT}/`); + console.log(` demo root: ${DEMO_ROOT}`); + console.log(` repo root: ${REPO_ROOT}`); +}); diff --git a/.github/scripts/demo/assets/css/main.css b/.github/scripts/demo/assets/css/main.css new file mode 100644 index 0000000..eaa6933 --- /dev/null +++ b/.github/scripts/demo/assets/css/main.css @@ -0,0 +1,288 @@ +:root { + color-scheme: light dark; + --bg: #0b0c0f; + --panel: #14161b; + --panel-2: #1a1d24; + --border: #23262d; + --text: #e6e8ec; + --muted: #8b8f99; + --accent: #7cc4ff; + --accent-2: #4f9dff; + --tile: #1a1d24; + --tile-hover: #232732; + --danger: #ff6b6b; + --success: #51cf66; +} +@media (prefers-color-scheme: light) { + :root { + --bg: #f5f6f8; + --panel: #ffffff; + --panel-2: #f0f2f5; + --border: #e2e5eb; + --text: #111318; + --muted: #6b7280; + --accent: #2563eb; + --accent-2: #1e4fd6; + --tile: #ffffff; + --tile-hover: #eef1f6; + } +} + +* { box-sizing: border-box; } +html, body { + margin: 0; padding: 0; height: 100%; + background: var(--bg); color: var(--text); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + overflow: hidden; +} +body { display: flex; flex-direction: column; } + +/* --- Header + nav --- */ +header { + background: var(--panel); + border-bottom: 1px solid var(--border); + padding: 10px 16px; + display: flex; align-items: center; gap: 16px; + flex-shrink: 0; +} +h1 { font-size: 15px; margin: 0; font-weight: 600; } +h1 .accent { color: var(--accent); } +nav { display: flex; gap: 4px; background: var(--panel-2); padding: 3px; border-radius: 8px; } +nav a { + background: transparent; border: none; color: var(--muted); + padding: 6px 14px; border-radius: 6px; font-size: 13px; + font-family: inherit; cursor: pointer; text-decoration: none; + transition: background .12s, color .12s; +} +nav a:hover { color: var(--text); } +nav a.active { background: var(--accent); color: white; } +.stats-badge { margin-left: auto; color: var(--muted); font-size: 12px; } + +main { flex: 1; overflow: hidden; position: relative; display: flex; flex-direction: column; } + +/* --- Form controls --- */ +input[type="search"], input[type="text"], input[type="number"], select, textarea { + background: var(--panel-2); color: var(--text); + border: 1px solid var(--border); border-radius: 6px; + padding: 6px 10px; font-size: 13px; font-family: inherit; +} +input[type="color"] { + background: var(--panel-2); border: 1px solid var(--border); + border-radius: 6px; padding: 2px; width: 34px; height: 30px; cursor: pointer; +} +input[type="range"] { accent-color: var(--accent); } +label { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; } + +button.btn { + background: var(--accent); color: white; border: none; + padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; + font-family: inherit; +} +button.btn:hover { background: var(--accent-2); } +button.btn:disabled { opacity: 0.5; cursor: not-allowed; } +button.btn.ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); } +button.btn.ghost:hover { color: var(--text); background: var(--panel-2); } +button.btn.danger { background: var(--danger); } + +.empty { color: var(--muted); padding: 40px; text-align: center; } + +/* --- Browse --- */ +.browse-toolbar { + padding: 10px 16px; border-bottom: 1px solid var(--border); + display: flex; gap: 10px; align-items: center; flex-wrap: wrap; + background: var(--panel); +} +.browse-toolbar input[type="search"] { width: 240px; } +.grid { + padding: 16px; overflow: auto; flex: 1; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 10px; + align-content: start; +} +.tile { + background: var(--tile); border: 1px solid var(--border); border-radius: 8px; + padding: 12px 8px; display: flex; flex-direction: column; + align-items: center; gap: 8px; cursor: pointer; + transition: background .12s, transform .12s, border-color .12s; +} +.tile:hover { background: var(--tile-hover); border-color: var(--accent); transform: translateY(-1px); } +.tile svg { width: var(--icon-size, 48px); height: var(--icon-size, 48px); display: block; } +.tile .name { font-size: 11px; color: var(--muted); text-align: center; word-break: break-all; } +.grid .empty { grid-column: 1 / -1; } + +/* --- Studio layout --- */ +.studio { display: flex; flex-direction: row; flex: 1; overflow: hidden; } +.studio-side { + width: 240px; background: var(--panel); border-right: 1px solid var(--border); + display: flex; flex-direction: column; overflow: hidden; +} +.studio-side.right { border-right: none; border-left: 1px solid var(--border); width: 300px; } +.side-head { + padding: 10px 12px; border-bottom: 1px solid var(--border); + font-size: 12px; color: var(--muted); + text-transform: uppercase; letter-spacing: 0.05em; +} +.side-body { flex: 1; overflow: auto; padding: 8px; } + +.icon-list-item { + display: flex; align-items: center; gap: 10px; + padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 12px; +} +.icon-list-item:hover { background: var(--panel-2); } +.icon-list-item.active { background: var(--accent); color: white; } +.icon-list-item svg { width: 20px; height: 20px; flex-shrink: 0; } +.icon-list-item .n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.icon-list-item .cat { color: var(--muted); font-size: 10px; } +.icon-list-item.active .cat { color: rgba(255, 255, 255, 0.7); } + +.studio-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: var(--bg); } +.studio-toolbar { + padding: 8px 12px; border-bottom: 1px solid var(--border); + display: flex; gap: 8px; align-items: center; background: var(--panel); + flex-wrap: wrap; +} +.studio-toolbar .sep { width: 1px; height: 22px; background: var(--border); } +.studio-toolbar .spacer { margin-left: auto; color: var(--muted); font-size: 12px; } + +.canvas-wrap { + position: relative; + flex: 1; overflow: auto; padding: 24px; + display: flex; align-items: center; justify-content: center; + background-image: + linear-gradient(45deg, rgba(128, 128, 128, 0.08) 25%, transparent 25%), + linear-gradient(-45deg, rgba(128, 128, 128, 0.08) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(128, 128, 128, 0.08) 75%), + linear-gradient(-45deg, transparent 75%, rgba(128, 128, 128, 0.08) 75%); + background-size: 20px 20px; + background-position: 0 0, 0 10px, 10px -10px, -10px 0; +} +.tools-palette { + position: absolute; top: 16px; left: 16px; + background: var(--panel); border: 1px solid var(--border); + border-radius: 10px; padding: 4px; + display: flex; flex-direction: column; gap: 2px; + z-index: 10; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); +} +.tools-palette button { + width: 36px; height: 36px; + background: transparent; border: none; border-radius: 6px; + color: var(--text); cursor: pointer; + display: flex; align-items: center; justify-content: center; + padding: 0; +} +.tools-palette button:hover { background: var(--panel-2); } +.tools-palette button.active { background: var(--accent); color: white; } +.tools-palette button svg { width: 18px; height: 18px; display: block; } +#canvas { + background: var(--panel); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); + border: 1px solid var(--border); border-radius: 4px; + display: block; +} +#canvas [data-shape] { cursor: move; } +#canvas [data-shape].selected { outline: 2px dashed var(--accent); outline-offset: 2px; } + +.tree-node { + padding: 4px 8px; border-radius: 4px; cursor: pointer; + display: flex; align-items: center; gap: 6px; + font-size: 12px; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; +} +.tree-node:hover { background: var(--panel-2); } +.tree-node.selected { background: var(--accent); color: white; } +.tree-node .tag { color: var(--accent); } +.tree-node.selected .tag { color: white; } +.tree-node .idx { color: var(--muted); } + +.props { display: grid; gap: 12px; padding: 4px; } +.prop-row { display: grid; grid-template-columns: 90px 1fr; gap: 8px; align-items: center; } +.prop-row label { color: var(--muted); font-size: 11px; } +.prop-row input, .prop-row select { width: 100%; padding: 4px 8px; font-size: 12px; } +.prop-group { + padding: 10px; background: var(--panel-2); border-radius: 6px; + display: grid; gap: 8px; +} +.prop-group h4 { + margin: 0 0 4px 0; font-size: 11px; color: var(--muted); + text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; +} +.no-selection { color: var(--muted); text-align: center; padding: 40px 20px; font-size: 12px; } + +/* --- Graph view --- */ +.graph-page { padding: 24px; overflow: auto; flex: 1; } +.graph-header { margin-bottom: 24px; } +.graph-header h2 { margin: 0 0 4px 0; font-size: 18px; } +.graph-header p { margin: 0; color: var(--muted); font-size: 13px; } +.metrics { + display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; margin-bottom: 24px; +} +.metric-card { + background: var(--panel); border: 1px solid var(--border); + border-radius: 10px; padding: 16px; +} +.metric-card .value { font-size: 24px; font-weight: 600; color: var(--accent); } +.metric-card .label { + font-size: 12px; color: var(--muted); + text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px; +} +.charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +@media (max-width: 900px) { .charts { grid-template-columns: 1fr; } } +.chart-card { + background: var(--panel); border: 1px solid var(--border); + border-radius: 10px; padding: 16px; +} +.chart-card h3 { + margin: 0 0 12px 0; font-size: 13px; color: var(--muted); + text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; +} +.bar-row { + display: grid; grid-template-columns: 120px 1fr 60px; gap: 8px; + align-items: center; margin-bottom: 6px; font-size: 12px; +} +.bar-row .bar { + height: 14px; background: var(--accent); border-radius: 3px; + min-width: 2px; transition: width .3s; +} +.bar-row .count { text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; } +.chart-legend { font-size: 11px; color: var(--muted); margin-top: 6px; } + +/* --- Dialog + toast --- */ +dialog { + background: var(--panel); color: var(--text); + border: 1px solid var(--border); border-radius: 10px; + max-width: 640px; width: 90%; padding: 0; +} +dialog::backdrop { background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px); } +.dialog-head { + display: flex; justify-content: space-between; align-items: center; + padding: 12px 16px; border-bottom: 1px solid var(--border); +} +.dialog-head strong { font-size: 14px; } +.dialog-head button { background: none; border: none; color: var(--muted); font-size: 22px; cursor: pointer; } +.dialog-body { padding: 16px; display: grid; gap: 12px; } +.preview { + display: flex; justify-content: center; padding: 24px; + background: var(--panel-2); border-radius: 8px; border: 1px solid var(--border); +} +.preview svg { width: 96px; height: 96px; } +textarea.code { + width: 100%; min-height: 140px; + background: var(--panel-2); color: var(--text); + border: 1px solid var(--border); border-radius: 6px; + padding: 10px; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 12px; resize: vertical; +} +.flex-between { display: flex; justify-content: space-between; align-items: center; } + +.toast { + position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); + background: var(--panel); color: var(--text); + padding: 10px 16px; border-radius: 8px; border: 1px solid var(--border); + font-size: 13px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); + opacity: 0; transition: opacity .2s, transform .2s; pointer-events: none; + z-index: 100; +} +.toast.show { opacity: 1; transform: translateX(-50%) translateY(-4px); } diff --git a/.github/scripts/demo/assets/js/browse.js b/.github/scripts/demo/assets/js/browse.js new file mode 100644 index 0000000..5f35da9 --- /dev/null +++ b/.github/scripts/demo/assets/js/browse.js @@ -0,0 +1,82 @@ +import { loadAll, recolor } from './data.js'; +import { initShell, setStats, showToast } from './shell.js'; + +initShell('browse'); + +const state = { + icons: [], + filter: '', + category: 'all', + color: '#7cc4ff', + size: 48, +}; + +const grid = document.getElementById('grid'); +const search = document.getElementById('search'); +const catSel = document.getElementById('category'); +const colorInput = document.getElementById('color'); +const sizeInput = document.getElementById('size'); +const sizeLabel = document.getElementById('sizeLabel'); +const detail = document.getElementById('detail'); +const dTitle = document.getElementById('d-title'); +const dPreview = document.getElementById('d-preview'); +const dCode = document.getElementById('d-code'); +const dCopy = document.getElementById('d-copy'); +const dEdit = document.getElementById('d-edit'); + +function render() { + const q = state.filter.toLowerCase(); + const filtered = state.icons.filter(i => + (state.category === 'all' || i.category === state.category) && + (!q || i.name.toLowerCase().includes(q)) + ); + setStats(`${filtered.length} of ${state.icons.length} icons`); + document.documentElement.style.setProperty('--icon-size', state.size + 'px'); + + if (filtered.length === 0) { grid.innerHTML = '
No matches.
'; return; } + const frag = document.createDocumentFragment(); + for (const icon of filtered) { + const tile = document.createElement('div'); + tile.className = 'tile'; + tile.innerHTML = recolor(icon.svg, state.color) + `
${icon.name}
`; + tile.addEventListener('click', () => openDetail(icon)); + frag.appendChild(tile); + } + grid.replaceChildren(frag); +} + +function openDetail(icon) { + dTitle.textContent = `${icon.category} / ${icon.name}`; + dPreview.innerHTML = recolor(icon.svg, state.color); + dCode.value = icon.svg; + dCopy.onclick = async () => { await navigator.clipboard.writeText(icon.svg); showToast('Copied'); }; + dEdit.onclick = () => { + location.href = `/studio?icon=${encodeURIComponent(icon.category + '/' + icon.name)}`; + }; + detail.showModal(); +} + +search.addEventListener('input', e => { state.filter = e.target.value; render(); }); +catSel.addEventListener('change', e => { state.category = e.target.value; render(); }); +colorInput.addEventListener('input', e => { state.color = e.target.value; render(); }); +sizeInput.addEventListener('input', e => { + state.size = +e.target.value; + sizeLabel.textContent = state.size; + render(); +}); + +(async () => { + try { + const { categories, icons } = await loadAll(); + state.icons = icons; + for (const cat of categories) { + const opt = document.createElement('option'); + opt.value = cat.name; + opt.textContent = `${cat.name} (${cat.items.length})`; + catSel.appendChild(opt); + } + render(); + } catch (err) { + grid.innerHTML = `
Failed to load: ${err.message}
`; + } +})(); diff --git a/.github/scripts/demo/assets/js/data.js b/.github/scripts/demo/assets/js/data.js new file mode 100644 index 0000000..065d0ce --- /dev/null +++ b/.github/scripts/demo/assets/js/data.js @@ -0,0 +1,61 @@ +export const REPO_ROOT = '/'; +export const CUSTOM_STORAGE_KEY = 'kfe-custom-icons'; + +export async function fetchJSON(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(`${url} → ${res.status}`); + return res.json(); +} + +export function loadCustomIcons() { + try { + const raw = localStorage.getItem(CUSTOM_STORAGE_KEY); + if (!raw) return []; + const items = JSON.parse(raw); + return Array.isArray(items) ? items : []; + } catch { return []; } +} + +export function saveCustomIcons(items) { + try { localStorage.setItem(CUSTOM_STORAGE_KEY, JSON.stringify(items)); } + catch (err) { console.warn('Could not persist custom icons:', err); } +} + +export async function loadAll() { + const index = await fetchJSON(REPO_ROOT + 'index.json'); + const categories = await Promise.all(index.map(async (cat) => { + try { + const items = await fetchJSON(REPO_ROOT + cat.target); + return { name: cat.name, items }; + } catch { return { name: cat.name, items: [] }; } + })); + + const entries = []; + for (const cat of categories) for (const item of cat.items) entries.push({ ...item, category: cat.name }); + + const icons = (await Promise.all(entries.map(async (e) => { + try { + const data = await fetchJSON(REPO_ROOT + e.target); + const file = Array.isArray(data.files) ? data.files[0] : null; + if (!file?.content) return null; + return { name: e.name, category: e.category, svg: file.content }; + } catch { return null; } + }))).filter(Boolean); + + const custom = loadCustomIcons(); + if (custom.length) { + categories.push({ name: 'custom', items: custom.map(c => ({ name: c.name, type: 'custom' })) }); + for (const c of custom) icons.push({ name: c.name, category: 'custom', svg: c.svg }); + } + + return { categories, icons }; +} + +export function recolor(svg, color) { + return svg.replace(/currentColor/g, color); +} + +export function countShapes(svg) { + const m = svg.match(/<(path|rect|circle|ellipse|polygon|polyline|line)\b/g); + return m ? m.length : 0; +} diff --git a/.github/scripts/demo/assets/js/graph.js b/.github/scripts/demo/assets/js/graph.js new file mode 100644 index 0000000..009818c --- /dev/null +++ b/.github/scripts/demo/assets/js/graph.js @@ -0,0 +1,73 @@ +import { loadAll, countShapes } from './data.js'; +import { initShell, setStats } from './shell.js'; + +initShell('graph'); + +(async () => { + const { categories, icons } = await loadAll(); + setStats(`${icons.length} icons across ${categories.length} categories`); + renderGraph(icons, categories); +})(); + +function renderGraph(icons, categories) { + const metrics = document.getElementById('g-metrics'); + const total = icons.length; + const avgShapes = total ? (icons.reduce((s, i) => s + countShapes(i.svg), 0) / total).toFixed(1) : 0; + const totalBytes = icons.reduce((s, i) => s + i.svg.length, 0); + const avgBytes = total ? Math.round(totalBytes / total) : 0; + metrics.innerHTML = ` +
${total}
Total Icons
+
${categories.length}
Categories
+
${avgShapes}
Avg Shapes/Icon
+
${(totalBytes / 1024).toFixed(1)}KB
Total SVG Size
+
${avgBytes}B
Avg Size/Icon
+ `; + + // Per-category + const perCat = {}; + for (const i of icons) perCat[i.category] = (perCat[i.category] || 0) + 1; + const catEntries = Object.entries(perCat).sort((a, b) => b[1] - a[1]); + const maxCat = Math.max(...catEntries.map(e => e[1]), 1); + document.getElementById('g-cat-bars').innerHTML = catEntries.map(([n, c]) => + `
${n}
${c}
` + ).join('') || '
No data
'; + + // Complexity buckets + const buckets = { '1': 0, '2-3': 0, '4-6': 0, '7-10': 0, '11-20': 0, '20+': 0 }; + for (const i of icons) { + const c = countShapes(i.svg); + if (c <= 1) buckets['1']++; + else if (c <= 3) buckets['2-3']++; + else if (c <= 6) buckets['4-6']++; + else if (c <= 10) buckets['7-10']++; + else if (c <= 20) buckets['11-20']++; + else buckets['20+']++; + } + const maxB = Math.max(...Object.values(buckets), 1); + document.getElementById('g-complexity').innerHTML = Object.entries(buckets).map(([k, v]) => + `
${k} shape${k === '1' ? '' : 's'}
${v}
` + ).join(''); + + // Size distribution + const sizeBuckets = { '<500B': 0, '500B-1KB': 0, '1-2KB': 0, '2-4KB': 0, '4-8KB': 0, '>8KB': 0 }; + for (const i of icons) { + const b = i.svg.length; + if (b < 500) sizeBuckets['<500B']++; + else if (b < 1024) sizeBuckets['500B-1KB']++; + else if (b < 2048) sizeBuckets['1-2KB']++; + else if (b < 4096) sizeBuckets['2-4KB']++; + else if (b < 8192) sizeBuckets['4-8KB']++; + else sizeBuckets['>8KB']++; + } + const maxS = Math.max(...Object.values(sizeBuckets), 1); + document.getElementById('g-size-dist').innerHTML = Object.entries(sizeBuckets).map(([k, v]) => + `
${k}
${v}
` + ).join(''); + + // Top 10 largest + const top = [...icons].sort((a, b) => b.svg.length - a.svg.length).slice(0, 10); + const maxT = top[0]?.svg.length || 1; + document.getElementById('g-largest').innerHTML = top.map(i => + `
${i.name}
${i.svg.length}B
` + ).join(''); +} diff --git a/.github/scripts/demo/assets/js/shell.js b/.github/scripts/demo/assets/js/shell.js new file mode 100644 index 0000000..d211c9c --- /dev/null +++ b/.github/scripts/demo/assets/js/shell.js @@ -0,0 +1,27 @@ +const TAB_TITLES = { browse: 'Browse', studio: 'Studio', graph: 'Graph' }; + +export function initShell(activeTab) { + document.querySelectorAll('nav a').forEach(a => { + a.classList.toggle('active', a.dataset.tab === activeTab); + }); + const label = TAB_TITLES[activeTab] || 'Demo'; + document.title = `${label} · KFE Icons Studio`; +} + +export function setStats(text) { + const el = document.getElementById('stats'); + if (el) el.textContent = text; +} + +let toastEl = null; +export function showToast(msg) { + if (!toastEl) { + toastEl = document.createElement('div'); + toastEl.className = 'toast'; + document.body.appendChild(toastEl); + } + toastEl.textContent = msg; + toastEl.classList.add('show'); + clearTimeout(showToast._t); + showToast._t = setTimeout(() => toastEl.classList.remove('show'), 1400); +} diff --git a/.github/scripts/demo/assets/js/studio.js b/.github/scripts/demo/assets/js/studio.js new file mode 100644 index 0000000..43aa801 --- /dev/null +++ b/.github/scripts/demo/assets/js/studio.js @@ -0,0 +1,839 @@ +import { loadAll, recolor, loadCustomIcons, saveCustomIcons } from './data.js'; +import { setStats, showToast } from './shell.js'; + +// The Studio page has its own title/nav in Tailwind so we skip initShell. +document.title = 'Studio · KFE Icons Studio'; + +const HISTORY_LIMIT = 100; +const SVG_NS = 'http://www.w3.org/2000/svg'; + +const state = { + icons: [], categories: [], selectedIcon: null, + svgDoc: null, + selectedEl: null, + tool: 'select', + zoom: 4, + original: null, + history: { past: [], future: [] }, + clipboard: null, + nextIdx: 0, +}; + +// ---- DOM refs ---- +const iconList = document.getElementById('icon-list'); +const sSearch = document.getElementById('icon-search'); +const canvasHost = document.getElementById('canvas-host'); +const canvasWrap = document.getElementById('canvas-wrap'); +const canvasLabel = document.getElementById('canvas-label'); +const handlesEl = document.getElementById('handles'); +const treeEl = document.getElementById('tree'); +const propsEl = document.getElementById('props'); +const nameEl = document.getElementById('current-name'); +const zoomEl = document.getElementById('zoom'); +const zoomLabel = document.getElementById('zoom-label'); +const undoBtn = document.getElementById('undo'); +const redoBtn = document.getElementById('redo'); +const newBtn = document.getElementById('new-icon'); +const resetBtn = document.getElementById('reset'); +const centerBtn = document.getElementById('center'); +const copyBtn = document.getElementById('copy'); +const downloadBtn = document.getElementById('download'); +const paletteEl = document.getElementById('tools-palette'); +const toolDuplicateBtn = document.getElementById('tool-duplicate'); +const toolDeleteBtn = document.getElementById('tool-delete'); + +// ---- Helpers ---- +function escapeAttr(v) { + return String(v ?? '') + .replace(/&/g, '&').replace(/"/g, '"') + .replace(//g, '>'); +} +function parseTranslate(t) { + const m = /translate\(\s*([-\d.]+)\s*[, ]\s*([-\d.]+)\s*\)/.exec(t); + return m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : { x: 0, y: 0 }; +} +function setTranslate(el, x, y) { + const cur = el.getAttribute('transform') || ''; + const stripped = cur.replace(/translate\([^)]*\)\s*/g, '').trim(); + const t = `translate(${x.toFixed(3)}, ${y.toFixed(3)})`; + el.setAttribute('transform', stripped ? `${t} ${stripped}` : t); +} +function clientToSvg(svg, x, y) { + const rect = svg.getBoundingClientRect(); + const vb = (svg.getAttribute('viewBox') || `0 0 ${svg.getAttribute('width')} ${svg.getAttribute('height')}`).split(/\s+/).map(Number); + const [vx, vy, vw, vh] = vb; + return { x: vx + ((x - rect.left) / rect.width) * vw, y: vy + ((y - rect.top) / rect.height) * vh }; +} +function tagShape(el, idx) { el.setAttribute('data-shape', 'true'); el.setAttribute('data-idx', idx); } +function nextIdx() { return String(state.nextIdx++); } + +// ---- Icon list ---- +function renderIconList() { + const q = sSearch.value.toLowerCase(); + const list = state.icons.filter(i => !q || i.name.toLowerCase().includes(q)); + const frag = document.createDocumentFragment(); + for (const icon of list) { + const item = document.createElement('div'); + const active = state.selectedIcon?.name === icon.name && state.selectedIcon?.category === icon.category; + item.className = 'icon-list-item' + (active ? ' active' : ''); + item.innerHTML = `${recolor(icon.svg, 'currentColor')}
${icon.name}
${icon.category}
`; + item.addEventListener('click', () => loadIntoStudio(icon)); + frag.appendChild(item); + } + iconList.replaceChildren(frag); +} +sSearch.addEventListener('input', renderIconList); + +// ---- Load icon ---- +function loadIntoStudio(icon) { + state.selectedIcon = icon; + state.original = icon.svg; + state.selectedEl = null; + state.history = { past: [], future: [] }; + parseAndMount(icon.svg); + const first = state.svgDoc?.querySelector('[data-shape]'); + if (first) { + state.selectedEl = first.getAttribute('data-idx'); + applySelection(); + } + nameEl.textContent = `${icon.category} / ${icon.name}`; + canvasLabel.textContent = icon.name; + history.replaceState(null, '', `/studio?icon=${encodeURIComponent(icon.category + '/' + icon.name)}`); + renderIconList(); + renderTree(); + renderProps(); + updateHistoryButtons(); + renderHandles(); +} + +function parseAndMount(svgString) { + const doc = new DOMParser().parseFromString(svgString, 'image/svg+xml'); + const svg = doc.documentElement; + state.nextIdx = 0; + svg.querySelectorAll('path, rect, circle, ellipse, polygon, polyline, line').forEach(el => { + tagShape(el, nextIdx()); + }); + state.svgDoc = svg; + mountCanvas(); +} + +function mountCanvas() { + canvasHost.innerHTML = ''; + if (!state.svgDoc) return; + const svg = state.svgDoc.cloneNode(true); + const size = 48 * state.zoom; + svg.setAttribute('width', size); + svg.setAttribute('height', size); + svg.setAttribute('id', 'canvas'); + svg.querySelectorAll('[data-shape]').forEach(el => el.addEventListener('pointerdown', onShapePointerDown)); + svg.addEventListener('pointerdown', onCanvasPointerDown); + canvasHost.appendChild(svg); + applySelection(); + updateCanvasCursor(); + renderHandles(); +} +function currentCanvasSvg() { return canvasHost.querySelector('svg'); } +function updateCanvasCursor() { + const svg = currentCanvasSvg(); + if (svg) svg.style.cursor = state.tool === 'select' ? 'default' : 'crosshair'; +} + +function selectElement(idx) { + state.selectedEl = idx; + applySelection(); + renderTree(); + renderProps(); + renderHandles(); +} +function applySelection() { + const svg = currentCanvasSvg(); + if (!svg) return; + svg.querySelectorAll('[data-shape]').forEach(el => { + el.classList.toggle('selected', el.getAttribute('data-idx') === String(state.selectedEl)); + }); +} + +// ---- Selection handles overlay ---- +function renderHandles() { + handlesEl.innerHTML = ''; + if (state.selectedEl === null) return; + const svg = currentCanvasSvg(); + if (!svg) return; + const el = svg.querySelector(`[data-idx="${state.selectedEl}"]`); + if (!el) return; + let bbox; + try { bbox = el.getBBox(); } catch { return; } + const ctm = el.getScreenCTM(); + if (!ctm) return; + const wrapRect = canvasWrap.getBoundingClientRect(); + + const toLocal = (x, y) => { + const pt = svg.createSVGPoint(); + pt.x = x; pt.y = y; + const p = pt.matrixTransform(ctm); + return { x: p.x - wrapRect.left, y: p.y - wrapRect.top }; + }; + // All four corners in local (overlay) coords — handles rotation/skew safely + const corners = [ + toLocal(bbox.x, bbox.y), + toLocal(bbox.x + bbox.width, bbox.y), + toLocal(bbox.x, bbox.y + bbox.height), + toLocal(bbox.x + bbox.width, bbox.y + bbox.height), + ]; + const xs = corners.map(c => c.x); + const ys = corners.map(c => c.y); + const left = Math.min(...xs); + const right = Math.max(...xs); + const top = Math.min(...ys); + const bottom = Math.max(...ys); + const w = right - left; + const h = bottom - top; + + // Axis-aligned bounding rect (matches Figma's selection frame) + const border = document.createElement('div'); + border.className = 'sel-border'; + border.style.left = left + 'px'; + border.style.top = top + 'px'; + border.style.width = w + 'px'; + border.style.height = h + 'px'; + handlesEl.appendChild(border); + + // Corner handles on the axis-aligned box + const cornerPts = [ + { x: left, y: top }, { x: right, y: top }, + { x: left, y: bottom }, { x: right, y: bottom }, + ]; + for (const c of cornerPts) { + const hEl = document.createElement('div'); + hEl.className = 'handle'; + hEl.style.left = c.x + 'px'; + hEl.style.top = c.y + 'px'; + handlesEl.appendChild(hEl); + } + + const label = document.createElement('div'); + label.className = 'm-label'; + label.textContent = `${bbox.width.toFixed(1)} × ${bbox.height.toFixed(1)}`; + label.style.left = (left + w / 2) + 'px'; + label.style.top = (bottom + 8) + 'px'; + handlesEl.appendChild(label); +} + +// ---- Tools palette ---- +function setTool(tool) { + state.tool = tool; + paletteEl.querySelectorAll('button[data-tool]').forEach(b => b.classList.toggle('active', b.dataset.tool === tool)); + updateCanvasCursor(); +} +paletteEl.querySelectorAll('button[data-tool]').forEach(btn => btn.addEventListener('click', () => setTool(btn.dataset.tool))); + +// ---- Canvas pointerdown ---- +function onCanvasPointerDown(e) { + if (e.target.closest('[data-shape]')) return; + if (state.tool === 'select') { selectElement(null); return; } + beginDraw(e); +} + +// ---- History ---- +let pendingBaseline = null; +function snapshot() { + if (!state.svgDoc) return null; + return { svg: new XMLSerializer().serializeToString(state.svgDoc), sel: state.selectedEl, nextIdx: state.nextIdx }; +} +function markBaseline() { pendingBaseline = snapshot(); } +function commitIfChanged() { + if (!pendingBaseline) return; + const now = snapshot(); + if (!now || now.svg === pendingBaseline.svg) { pendingBaseline = null; return; } + state.history.past.push(pendingBaseline); + if (state.history.past.length > HISTORY_LIMIT) state.history.past.shift(); + state.history.future.length = 0; + pendingBaseline = null; + updateHistoryButtons(); +} +function withEdit(fn) { markBaseline(); fn(); commitIfChanged(); } +function restore(snap) { + const doc = new DOMParser().parseFromString(snap.svg, 'image/svg+xml'); + state.svgDoc = doc.documentElement; + state.selectedEl = snap.sel; + state.nextIdx = snap.nextIdx ?? state.nextIdx; + mountCanvas(); + renderTree(); + renderProps(); +} +function undo() { + const h = state.history; + if (h.past.length === 0) return; + const cur = snapshot(); + const prev = h.past.pop(); + if (cur) h.future.push(cur); + restore(prev); + updateHistoryButtons(); +} +function redo() { + const h = state.history; + if (h.future.length === 0) return; + const cur = snapshot(); + const next = h.future.pop(); + if (cur) h.past.push(cur); + restore(next); + updateHistoryButtons(); +} +function updateHistoryButtons() { + undoBtn.disabled = state.history.past.length === 0; + redoBtn.disabled = state.history.future.length === 0; +} +undoBtn.addEventListener('click', undo); +redoBtn.addEventListener('click', redo); + +// ---- Shape drag ---- +let dragCtx = null; +function onShapePointerDown(e) { + e.stopPropagation(); + if (state.tool !== 'select') return; + const idx = e.currentTarget.getAttribute('data-idx'); + selectElement(idx); + const svg = currentCanvasSvg(); + const pt = clientToSvg(svg, e.clientX, e.clientY); + const el = e.currentTarget; + const existing = parseTranslate(el.getAttribute('transform') || ''); + markBaseline(); + dragCtx = { el, startX: pt.x, startY: pt.y, baseTx: existing.x, baseTy: existing.y }; + el.setPointerCapture(e.pointerId); + el.addEventListener('pointermove', onShapePointerMove); + el.addEventListener('pointerup', onShapePointerUp, { once: true }); +} +function onShapePointerMove(e) { + if (!dragCtx) return; + const svg = currentCanvasSvg(); + const pt = clientToSvg(svg, e.clientX, e.clientY); + const nx = dragCtx.baseTx + (pt.x - dragCtx.startX); + const ny = dragCtx.baseTy + (pt.y - dragCtx.startY); + setTranslate(dragCtx.el, nx, ny); + syncElementToDoc(dragCtx.el); + renderProps(); + renderHandles(); +} +function onShapePointerUp(e) { + if (dragCtx?.el) dragCtx.el.releasePointerCapture(e.pointerId); + dragCtx = null; + commitIfChanged(); +} +function syncElementToDoc(el) { + const idx = el.getAttribute('data-idx'); + const target = state.svgDoc.querySelector(`[data-idx="${idx}"]`); + if (!target) return; + for (const attr of el.attributes) target.setAttribute(attr.name, attr.value); +} + +// ---- Drawing ---- +let drawCtx = null; +function createDrawElement(tool, pt) { + const doc = state.svgDoc.ownerDocument; + switch (tool) { + case 'rect': { + const el = doc.createElementNS(SVG_NS, 'rect'); + el.setAttribute('x', pt.x); el.setAttribute('y', pt.y); + el.setAttribute('width', 0); el.setAttribute('height', 0); + el.setAttribute('fill', 'currentColor'); + return el; + } + case 'ellipse': { + const el = doc.createElementNS(SVG_NS, 'ellipse'); + el.setAttribute('cx', pt.x); el.setAttribute('cy', pt.y); + el.setAttribute('rx', 0); el.setAttribute('ry', 0); + el.setAttribute('fill', 'currentColor'); + return el; + } + case 'line': { + const el = doc.createElementNS(SVG_NS, 'line'); + el.setAttribute('x1', pt.x); el.setAttribute('y1', pt.y); + el.setAttribute('x2', pt.x); el.setAttribute('y2', pt.y); + el.setAttribute('stroke', 'currentColor'); el.setAttribute('stroke-width', '1'); el.setAttribute('fill', 'none'); + return el; + } + } + return null; +} +function updateDrawShape(el, tool, start, current) { + switch (tool) { + case 'rect': + el.setAttribute('x', Math.min(start.x, current.x)); + el.setAttribute('y', Math.min(start.y, current.y)); + el.setAttribute('width', Math.abs(current.x - start.x)); + el.setAttribute('height', Math.abs(current.y - start.y)); + break; + case 'ellipse': + el.setAttribute('cx', (start.x + current.x) / 2); + el.setAttribute('cy', (start.y + current.y) / 2); + el.setAttribute('rx', Math.abs(current.x - start.x) / 2); + el.setAttribute('ry', Math.abs(current.y - start.y) / 2); + break; + case 'line': + el.setAttribute('x2', current.x); + el.setAttribute('y2', current.y); + break; + } +} +function isDegenerate(tool, start, current) { + const dx = Math.abs(current.x - start.x); + const dy = Math.abs(current.y - start.y); + if (tool === 'line') return dx < 0.5 && dy < 0.5; + return dx < 0.5 || dy < 0.5; +} +function beginDraw(e) { + const canvas = currentCanvasSvg(); + const pt = clientToSvg(canvas, e.clientX, e.clientY); + markBaseline(); + const el = createDrawElement(state.tool, pt); + if (!el) { pendingBaseline = null; return; } + const idx = nextIdx(); + tagShape(el, idx); + state.svgDoc.appendChild(el); + drawCtx = { tool: state.tool, startPt: pt, idx }; + mountCanvas(); + selectElement(idx); + window.addEventListener('pointermove', onDrawMove); + window.addEventListener('pointerup', onDrawEnd, { once: true }); +} +function onDrawMove(e) { + if (!drawCtx) return; + const canvas = currentCanvasSvg(); + if (!canvas) return; + const pt = clientToSvg(canvas, e.clientX, e.clientY); + const src = state.svgDoc.querySelector(`[data-idx="${drawCtx.idx}"]`); + const live = canvas.querySelector(`[data-idx="${drawCtx.idx}"]`); + if (src) updateDrawShape(src, drawCtx.tool, drawCtx.startPt, pt); + if (live) updateDrawShape(live, drawCtx.tool, drawCtx.startPt, pt); + renderProps(); + renderHandles(); +} +function onDrawEnd(e) { + window.removeEventListener('pointermove', onDrawMove); + if (!drawCtx) return; + const canvas = currentCanvasSvg(); + const pt = clientToSvg(canvas, e.clientX, e.clientY); + if (isDegenerate(drawCtx.tool, drawCtx.startPt, pt)) { + state.svgDoc.querySelector(`[data-idx="${drawCtx.idx}"]`)?.remove(); + pendingBaseline = null; + state.selectedEl = null; + mountCanvas(); + renderTree(); + renderProps(); + } else { + commitIfChanged(); + } + drawCtx = null; + setTool('select'); +} + +// ---- Tree ---- +function renderTree() { + if (!state.svgDoc) { treeEl.innerHTML = '
Load an icon.
'; return; } + const shapes = state.svgDoc.querySelectorAll('[data-shape]'); + if (shapes.length === 0) { treeEl.innerHTML = '
No shapes.
'; return; } + const frag = document.createDocumentFragment(); + shapes.forEach(el => { + const idx = el.getAttribute('data-idx'); + const node = document.createElement('div'); + node.className = 'tree-node' + (idx === String(state.selectedEl) ? ' selected' : ''); + node.innerHTML = `${String(idx).padStart(2, '0')}<${el.tagName}>`; + node.addEventListener('click', () => selectElement(idx)); + frag.appendChild(node); + }); + treeEl.replaceChildren(frag); +} + +// ---- Clipboard / duplicate / delete / layer order ---- +function copySelection() { + if (state.selectedEl === null) return; + const el = state.svgDoc.querySelector(`[data-idx="${state.selectedEl}"]`); + if (!el) return; + state.clipboard = new XMLSerializer().serializeToString(el); + showToast('Copied'); +} +function paste(offset = 10) { + if (!state.clipboard) return; + const wrapper = new DOMParser().parseFromString(`${state.clipboard}`, 'image/svg+xml'); + const src = wrapper.documentElement.firstElementChild; + if (!src) return; + markBaseline(); + const imported = state.svgDoc.ownerDocument.importNode(src, true); + const idx = nextIdx(); + tagShape(imported, idx); + const existingT = imported.getAttribute('transform') || ''; + imported.setAttribute('transform', `translate(${offset}, ${offset}) ${existingT}`.trim()); + state.svgDoc.appendChild(imported); + state.selectedEl = idx; + mountCanvas(); + renderTree(); + renderProps(); + commitIfChanged(); + showToast('Pasted'); +} +function duplicateSelection() { if (state.selectedEl === null) return; copySelection(); paste(); } +function deleteSelection() { + if (state.selectedEl === null) return; + markBaseline(); + state.svgDoc.querySelector(`[data-idx="${state.selectedEl}"]`)?.remove(); + state.selectedEl = null; + mountCanvas(); + renderTree(); + renderProps(); + commitIfChanged(); +} +function bringToFront() { + if (state.selectedEl === null) return; + const el = state.svgDoc.querySelector(`[data-idx="${state.selectedEl}"]`); + if (!el?.parentNode) return; + markBaseline(); el.parentNode.appendChild(el); mountCanvas(); commitIfChanged(); +} +function sendToBack() { + if (state.selectedEl === null) return; + const el = state.svgDoc.querySelector(`[data-idx="${state.selectedEl}"]`); + if (!el?.parentNode) return; + markBaseline(); el.parentNode.insertBefore(el, el.parentNode.firstChild); mountCanvas(); commitIfChanged(); +} +function bringForward() { + if (state.selectedEl === null) return; + const el = state.svgDoc.querySelector(`[data-idx="${state.selectedEl}"]`); + const next = el?.nextElementSibling; + if (!next) return; + markBaseline(); el.parentNode.insertBefore(next, el); mountCanvas(); commitIfChanged(); +} +function sendBackward() { + if (state.selectedEl === null) return; + const el = state.svgDoc.querySelector(`[data-idx="${state.selectedEl}"]`); + const prev = el?.previousElementSibling; + if (!prev) return; + markBaseline(); el.parentNode.insertBefore(el, prev); mountCanvas(); commitIfChanged(); +} + +toolDuplicateBtn.addEventListener('click', duplicateSelection); +toolDeleteBtn.addEventListener('click', deleteSelection); + +// ---- Nudge ---- +let nudgeTimer = null; +function nudge(dx, dy) { + if (state.selectedEl === null) return; + const target = state.svgDoc.querySelector(`[data-idx="${state.selectedEl}"]`); + if (!target) return; + if (!nudgeTimer) markBaseline(); + clearTimeout(nudgeTimer); + const cur = parseTranslate(target.getAttribute('transform') || ''); + setTranslate(target, cur.x + dx, cur.y + dy); + const live = currentCanvasSvg()?.querySelector(`[data-idx="${state.selectedEl}"]`); + if (live) setTranslate(live, cur.x + dx, cur.y + dy); + renderProps(); renderHandles(); + nudgeTimer = setTimeout(() => { commitIfChanged(); nudgeTimer = null; }, 500); +} + +// ---- Keyboard shortcuts ---- +window.addEventListener('keydown', (e) => { + const tgt = e.target; + const inInput = tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA' || tgt.tagName === 'SELECT'); + const mod = e.ctrlKey || e.metaKey; + + if (mod) { + const k = e.key.toLowerCase(); + if (k === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; } + if ((k === 'z' && e.shiftKey) || k === 'y') { e.preventDefault(); redo(); return; } + if (!inInput) { + if (k === 'c') { e.preventDefault(); copySelection(); return; } + if (k === 'v') { e.preventDefault(); paste(); return; } + if (k === 'd') { e.preventDefault(); duplicateSelection(); return; } + if (k === ']') { e.preventDefault(); e.shiftKey ? bringToFront() : bringForward(); return; } + if (k === '[') { e.preventDefault(); e.shiftKey ? sendToBack() : sendBackward(); return; } + } + return; + } + if (inInput) return; + const toolKeys = { v: 'select', r: 'rect', o: 'ellipse', l: 'line' }; + if (toolKeys[e.key.toLowerCase()]) { e.preventDefault(); setTool(toolKeys[e.key.toLowerCase()]); return; } + if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); deleteSelection(); return; } + if (e.key.startsWith('Arrow') && state.selectedEl !== null) { + e.preventDefault(); + const step = e.shiftKey ? 10 : 1; + const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0; + const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0; + nudge(dx, dy); + } +}); + +// ---- Props panel (Figma-style sections) ---- +function renderProps() { + const svg = state.svgDoc; + if (!svg) { propsEl.innerHTML = '
Load an icon.
'; return; } + + const titleEl = svg.querySelector('title'); + const title = titleEl?.textContent || ''; + const width = svg.getAttribute('width') || ''; + const height = svg.getAttribute('height') || ''; + const viewBox = svg.getAttribute('viewBox') || ''; + + let target = null; + if (state.selectedEl !== null) target = svg.querySelector(`[data-idx="${state.selectedEl}"]`); + + let html = ''; + + // Element header + html += `
+
+ ${target ? escapeAttr(target.tagName) : 'Icon'} +
+
+ + + + +
+
`; + + // Element position + fill/stroke sections (only if selected) + if (target) { + const fill = target.getAttribute('fill') || 'currentColor'; + const stroke = target.getAttribute('stroke') || ''; + const strokeWidth = target.getAttribute('stroke-width') || ''; + const opacity = target.getAttribute('opacity') || '1'; + const transform = target.getAttribute('transform') || ''; + const trans = parseTranslate(transform); + const fillIsCurrent = fill === 'currentColor'; + const fillColor = /^#[0-9a-fA-F]+$/.test(fill) ? fill : '#7cc4ff'; + const strokeIsCurrent = stroke === 'currentColor'; + const strokeColor = /^#[0-9a-fA-F]+$/.test(stroke) ? stroke : '#000000'; + + html += `
+
Position
+
+
X
+
Y
+
+
`; + + html += `
+
Fill
+
+
+ +
+
+ +
+
+ +
`; + + html += `
+
Stroke
+
+
+ +
+
+ +
+
+
+
W
+
α
+
+
`; + + html += `
+
Transform
+
T
+ +
`; + } else { + html += `
+ Select a shape on the canvas or in the tree to edit its properties, or pick a draw tool from the palette to create a new shape. +
`; + } + + // Icon root (always shown) + html += `
+
Icon Root
+
t
+
+
W
+
H
+
+
+
`; + + propsEl.innerHTML = html; + + // Wire icon-root + const setSvgAttr = (name, val) => { + if (val === '' || val == null) svg.removeAttribute(name); else svg.setAttribute(name, val); + const live = currentCanvasSvg(); + if (live) { if (val === '' || val == null) live.removeAttribute(name); else live.setAttribute(name, val); } + }; + const setTitle = (val) => { + let t = svg.querySelector('title'); + if (!val) { if (t) t.remove(); return; } + if (!t) { t = svg.ownerDocument.createElementNS(SVG_NS, 'title'); svg.insertBefore(t, svg.firstChild); } + t.textContent = val; + }; + ['p-icon-title', 'p-icon-w', 'p-icon-h', 'p-icon-vb'].forEach(id => { + const el = document.getElementById(id); + if (!el) return; + el.addEventListener('focus', markBaseline); + el.addEventListener('change', commitIfChanged); + }); + document.getElementById('p-icon-title').addEventListener('input', e => setTitle(e.target.value)); + document.getElementById('p-icon-w').addEventListener('input', e => setSvgAttr('width', e.target.value)); + document.getElementById('p-icon-h').addEventListener('input', e => setSvgAttr('height', e.target.value)); + document.getElementById('p-icon-vb').addEventListener('input', e => setSvgAttr('viewBox', e.target.value)); + + // Wire header action buttons + document.getElementById('p-to-front').addEventListener('click', bringToFront); + document.getElementById('p-to-back').addEventListener('click', sendToBack); + document.getElementById('p-duplicate').addEventListener('click', duplicateSelection); + document.getElementById('p-remove').addEventListener('click', deleteSelection); + + if (!target) return; + + const setAttr = (name, val) => { + if (val === '' || val == null) target.removeAttribute(name); else target.setAttribute(name, val); + const live = currentCanvasSvg()?.querySelector(`[data-idx="${state.selectedEl}"]`); + if (live) { if (val === '' || val == null) live.removeAttribute(name); else live.setAttribute(name, val); } + }; + ['p-fill-color', 'p-fill-text', 'p-stroke', 'p-stroke-color', 'p-stroke-w', 'p-opacity', 'p-tx', 'p-ty', 'p-transform'].forEach(id => { + const el = document.getElementById(id); + if (!el) return; + el.addEventListener('focus', markBaseline); + el.addEventListener('pointerdown', markBaseline); + el.addEventListener('change', commitIfChanged); + }); + document.getElementById('p-fill-color').addEventListener('input', e => { setAttr('fill', e.target.value); document.getElementById('p-fill-text').value = e.target.value; }); + document.getElementById('p-fill-text').addEventListener('input', e => { setAttr('fill', e.target.value); renderHandles(); }); + document.getElementById('p-fill-current').addEventListener('change', e => withEdit(() => { + if (e.target.checked) setAttr('fill', 'currentColor'); + else setAttr('fill', document.getElementById('p-fill-color').value); + renderProps(); + })); + document.getElementById('p-stroke').addEventListener('input', e => { setAttr('stroke', e.target.value); renderProps(); }); + document.getElementById('p-stroke-color').addEventListener('input', e => { setAttr('stroke', e.target.value); renderProps(); }); + document.getElementById('p-stroke-w').addEventListener('input', e => setAttr('stroke-width', e.target.value)); + document.getElementById('p-opacity').addEventListener('input', e => setAttr('opacity', e.target.value)); + document.getElementById('p-tx').addEventListener('input', e => { + const y = parseFloat(document.getElementById('p-ty').value) || 0; + setTranslate(target, parseFloat(e.target.value) || 0, y); + const live = currentCanvasSvg()?.querySelector(`[data-idx="${state.selectedEl}"]`); + if (live) setTranslate(live, parseFloat(e.target.value) || 0, y); + renderHandles(); + }); + document.getElementById('p-ty').addEventListener('input', e => { + const x = parseFloat(document.getElementById('p-tx').value) || 0; + setTranslate(target, x, parseFloat(e.target.value) || 0); + const live = currentCanvasSvg()?.querySelector(`[data-idx="${state.selectedEl}"]`); + if (live) setTranslate(live, x, parseFloat(e.target.value) || 0); + renderHandles(); + }); + document.getElementById('p-transform').addEventListener('input', e => { setAttr('transform', e.target.value); renderHandles(); }); + document.getElementById('p-clear-t').addEventListener('click', () => withEdit(() => { setAttr('transform', null); renderProps(); renderHandles(); })); +} + +// ---- Toolbar ---- +zoomEl.addEventListener('input', e => { + state.zoom = +e.target.value; + zoomLabel.textContent = `${Math.round(state.zoom * 100)}%`; + mountCanvas(); +}); +zoomLabel.textContent = `${Math.round(state.zoom * 100)}%`; +resetBtn.addEventListener('click', () => { if (state.selectedIcon) loadIntoStudio({ ...state.selectedIcon, svg: state.original }); }); +centerBtn.addEventListener('click', () => withEdit(() => { + if (state.selectedEl === null) return; + const target = state.svgDoc.querySelector(`[data-idx="${state.selectedEl}"]`); + if (!target) return; + target.removeAttribute('transform'); + const live = currentCanvasSvg()?.querySelector(`[data-idx="${state.selectedEl}"]`); + if (live) live.removeAttribute('transform'); + renderProps(); renderHandles(); +})); + +function serializeStudio() { + if (!state.svgDoc) return ''; + const clone = state.svgDoc.cloneNode(true); + clone.querySelectorAll('[data-shape]').forEach(el => { + el.removeAttribute('data-shape'); + el.removeAttribute('data-idx'); + el.classList.remove('selected'); + if (el.getAttribute('class') === '') el.removeAttribute('class'); + }); + return new XMLSerializer().serializeToString(clone); +} +copyBtn.addEventListener('click', async () => { + const svg = serializeStudio(); + if (!svg) return; + await navigator.clipboard.writeText(svg); + showToast('Copied SVG'); +}); +downloadBtn.addEventListener('click', () => { + const svg = serializeStudio(); + if (!svg) return; + const blob = new Blob([svg], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${state.selectedIcon?.name || 'icon'}.svg`; + a.click(); + URL.revokeObjectURL(url); +}); + +// ---- New icon ---- +function createNewIcon() { + const custom = loadCustomIcons(); + let n = custom.length + 1; + let name = `untitled-${n}`; + while (custom.some(c => c.name === name) || state.icons.some(i => i.category === 'custom' && i.name === name)) { + n++; name = `untitled-${n}`; + } + const svg = ` + ${name} + +`; + const icon = { name, category: 'custom', svg }; + custom.push({ name, svg }); + saveCustomIcons(custom); + state.icons.push(icon); + if (!state.categories.find(c => c.name === 'custom')) state.categories.push({ name: 'custom', items: [] }); + loadIntoStudio(icon); + showToast(`Created ${name}`); +} +newBtn.addEventListener('click', createNewIcon); + +// Re-position handles when the window resizes +window.addEventListener('resize', renderHandles); + +// ---- Boot ---- +(async () => { + try { + const { categories, icons } = await loadAll(); + state.categories = categories; + state.icons = icons; + setStats(`${icons.length} icons`); + renderIconList(); + + const params = new URLSearchParams(location.search); + const iconRef = params.get('icon'); + if (iconRef) { + const [cat, ...rest] = iconRef.split('/'); + const name = rest.join('/'); + const match = icons.find(i => i.category === cat && i.name === name); + if (match) { loadIntoStudio(match); return; } + } + if (icons.length > 0) loadIntoStudio(icons[0]); + } catch (err) { + canvasHost.innerHTML = `
Failed to load: ${err.message}
`; + } +})(); diff --git a/.github/scripts/demo/browse.html b/.github/scripts/demo/browse.html new file mode 100644 index 0000000..d13dd7d --- /dev/null +++ b/.github/scripts/demo/browse.html @@ -0,0 +1,56 @@ + + + + + +Browse · KFE Icons Studio + + + + +
+

KFE Icons Studio

+ + loading… +
+ +
+
+ + + + +
+
Loading…
+
+ + +
+ icon + +
+
+
+
+ SVG markup +
+ + +
+
+ +
+
+ + + + diff --git a/.github/scripts/demo/graph.html b/.github/scripts/demo/graph.html new file mode 100644 index 0000000..a67cf02 --- /dev/null +++ b/.github/scripts/demo/graph.html @@ -0,0 +1,52 @@ + + + + + +Graph · KFE Icons Studio + + + + +
+

KFE Icons Studio

+ + loading… +
+ +
+
+
+

Icon Library Overview

+

Distribution and complexity metrics across the registry.

+
+
+
+
+

Icons per Category

+
+
+
+

Complexity Distribution (paths per icon)

+
+
Bucketed by number of shape elements.
+
+
+

Size Distribution (SVG source bytes)

+
+
+
+

Top 10 Largest Icons

+
+
+
+
+
+ + + + diff --git a/.github/scripts/demo/studio.html b/.github/scripts/demo/studio.html new file mode 100644 index 0000000..5623d95 --- /dev/null +++ b/.github/scripts/demo/studio.html @@ -0,0 +1,223 @@ + + + + + +Studio · KFE Icons Studio + + + + + + + + +
+
+
+ KFE Icons Studio +
+ / + no icon selected +
+ +
+ loading… + + +
+
+ +
+ + + + + +
+
+
+
+
+
+
+
+ + + +
+
+ + + + +
+ + +
+ + +
+
+ + +
+
+ + 400% +
+
+
+ + + +
+ + + + diff --git a/.github/scripts/package.json b/.github/scripts/package.json index 11545e7..45da656 100644 --- a/.github/scripts/package.json +++ b/.github/scripts/package.json @@ -6,7 +6,8 @@ "main": "index.js", "scripts": { "build": "tsup update-category.ts --format esm --clean", - "update-category": "tsx update-category.ts" + "update-category": "tsx update-category.ts", + "demo": "tsx demo-server.ts" }, "keywords": [], "author": "", From c1580696ea3ce29c7b5e028e046ee72bb9dfdb32 Mon Sep 17 00:00:00 2001 From: pphatdev Date: Fri, 24 Jul 2026 19:13:43 +0700 Subject: [PATCH 5/8] feat: add interactive icon studio editor with shape manipulation and history management --- .github/scripts/demo/assets/js/studio.js | 525 +++++++++++++++++++---- .github/scripts/demo/studio.html | 372 ++++++++++------ 2 files changed, 684 insertions(+), 213 deletions(-) diff --git a/.github/scripts/demo/assets/js/studio.js b/.github/scripts/demo/assets/js/studio.js index 43aa801..f216483 100644 --- a/.github/scripts/demo/assets/js/studio.js +++ b/.github/scripts/demo/assets/js/studio.js @@ -29,11 +29,14 @@ const handlesEl = document.getElementById('handles'); const treeEl = document.getElementById('tree'); const propsEl = document.getElementById('props'); const nameEl = document.getElementById('current-name'); -const zoomEl = document.getElementById('zoom'); const zoomLabel = document.getElementById('zoom-label'); +const zoomInBtn = document.getElementById('zoom-in'); +const zoomOutBtn = document.getElementById('zoom-out'); +const zoomDisplay = document.getElementById('zoom-display'); const undoBtn = document.getElementById('undo'); const redoBtn = document.getElementById('redo'); const newBtn = document.getElementById('new-icon'); +const newBtnSm = document.getElementById('new-icon-sm'); const resetBtn = document.getElementById('reset'); const centerBtn = document.getElementById('center'); const copyBtn = document.getElementById('copy'); @@ -41,6 +44,7 @@ const downloadBtn = document.getElementById('download'); const paletteEl = document.getElementById('tools-palette'); const toolDuplicateBtn = document.getElementById('tool-duplicate'); const toolDeleteBtn = document.getElementById('tool-delete'); +const treeCountEl = document.getElementById('tree-count'); // ---- Helpers ---- function escapeAttr(v) { @@ -198,16 +202,20 @@ function renderHandles() { border.style.height = h + 'px'; handlesEl.appendChild(border); - // Corner handles on the axis-aligned box + // Corner handles on the axis-aligned box (interactive: drag to resize) const cornerPts = [ - { x: left, y: top }, { x: right, y: top }, - { x: left, y: bottom }, { x: right, y: bottom }, + { x: left, y: top, id: 'tl' }, + { x: right, y: top, id: 'tr' }, + { x: left, y: bottom, id: 'bl' }, + { x: right, y: bottom, id: 'br' }, ]; for (const c of cornerPts) { const hEl = document.createElement('div'); hEl.className = 'handle'; + hEl.dataset.corner = c.id; hEl.style.left = c.x + 'px'; hEl.style.top = c.y + 'px'; + hEl.addEventListener('pointerdown', (ev) => onHandlePointerDown(c.id, ev)); handlesEl.appendChild(hEl); } @@ -326,6 +334,114 @@ function syncElementToDoc(el) { for (const attr of el.attributes) target.setAttribute(attr.name, attr.value); } +// ---- Resize (corner handles) ---- +// Replaces the element's translate()+scale() while preserving any other +// transform components. Non-uniform scale is applied by default; hold Shift +// on the pointer to constrain proportionally. +function setTransformResize(el, tx, ty, sx, sy) { + const cur = el.getAttribute('transform') || ''; + const stripped = cur + .replace(/translate\([^)]*\)\s*/g, '') + .replace(/scale\([^)]*\)\s*/g, '') + .trim(); + const newT = `translate(${tx.toFixed(3)}, ${ty.toFixed(3)}) scale(${sx.toFixed(4)}, ${sy.toFixed(4)})`; + el.setAttribute('transform', stripped ? `${newT} ${stripped}` : newT); +} + +let resizeCtx = null; +function onHandlePointerDown(corner, e) { + e.stopPropagation(); + e.preventDefault(); + if (state.selectedEl === null) return; + const canvas = currentCanvasSvg(); + const src = state.svgDoc.querySelector(`[data-idx="${state.selectedEl}"]`); + const live = canvas?.querySelector(`[data-idx="${state.selectedEl}"]`); + if (!src || !live) return; + + let bbox; + try { bbox = live.getBBox(); } catch { return; } + if (bbox.width <= 0 || bbox.height <= 0) return; + + markBaseline(); + + // Effective (post-transform) bbox in the parent's coord space. + const startPt = clientToSvg(canvas, e.clientX, e.clientY); + const ctm = live.getCTM(); // element's transform relative to its nearest viewport + const pt = canvas.createSVGPoint(); + function toParent(x, y) { + pt.x = x; pt.y = y; + const p = pt.matrixTransform(ctm); + return { x: p.x, y: p.y }; + } + const c00 = toParent(bbox.x, bbox.y); + const c11 = toParent(bbox.x + bbox.width, bbox.y + bbox.height); + const effLeft = Math.min(c00.x, c11.x); + const effTop = Math.min(c00.y, c11.y); + const effRight = Math.max(c00.x, c11.x); + const effBottom = Math.max(c00.y, c11.y); + + // Anchor = corner opposite the one grabbed. + let anchorX = 0, anchorY = 0; + if (corner === 'tl') { anchorX = effRight; anchorY = effBottom; } + else if (corner === 'tr') { anchorX = effLeft; anchorY = effBottom; } + else if (corner === 'bl') { anchorX = effRight; anchorY = effTop; } + else if (corner === 'br') { anchorX = effLeft; anchorY = effTop; } + + resizeCtx = { + corner, src, live, bbox, + anchorX, anchorY, + startPt, + aspect: bbox.width / bbox.height, + }; + window.addEventListener('pointermove', onResizeMove); + window.addEventListener('pointerup', onResizeEnd, { once: true }); +} +function onResizeMove(e) { + if (!resizeCtx) return; + const canvas = currentCanvasSvg(); + if (!canvas) return; + const pt = clientToSvg(canvas, e.clientX, e.clientY); + let { bbox, anchorX, anchorY, aspect, src, live } = resizeCtx; + + let dx = pt.x - anchorX; + let dy = pt.y - anchorY; + + if (e.shiftKey) { + // Constrain to the original aspect ratio. + const abs = Math.max(Math.abs(dx), Math.abs(dy)); + const sx = Math.sign(dx) || 1; + const sy = Math.sign(dy) || 1; + if (Math.abs(dx) / Math.abs(dy || 1) > aspect) { + dy = sy * (abs / aspect); + } else { + dx = sx * (abs * aspect); + } + } + + const newLeft = Math.min(anchorX + dx, anchorX); + const newTop = Math.min(anchorY + dy, anchorY); + const newWidth = Math.abs(dx); + const newHeight = Math.abs(dy); + + if (newWidth < 0.5 || newHeight < 0.5) return; + + const sx = newWidth / bbox.width; + const sy = newHeight / bbox.height; + const tx = newLeft - bbox.x * sx; + const ty = newTop - bbox.y * sy; + + setTransformResize(src, tx, ty, sx, sy); + setTransformResize(live, tx, ty, sx, sy); + + renderProps(); + renderHandles(); +} +function onResizeEnd() { + window.removeEventListener('pointermove', onResizeMove); + resizeCtx = null; + commitIfChanged(); +} + // ---- Drawing ---- let drawCtx = null; function createDrawElement(tool, pt) { @@ -428,16 +544,23 @@ function onDrawEnd(e) { } // ---- Tree ---- +const TAG_ICONS = { + path: 'polyline', rect: 'crop_square', circle: 'circle', + ellipse: 'circle', line: 'horizontal_rule', + polygon: 'change_history', polyline: 'polyline', +}; function renderTree() { - if (!state.svgDoc) { treeEl.innerHTML = '
Load an icon.
'; return; } + if (!state.svgDoc) { treeEl.innerHTML = '
Load an icon.
'; if (treeCountEl) treeCountEl.textContent = '0'; return; } const shapes = state.svgDoc.querySelectorAll('[data-shape]'); - if (shapes.length === 0) { treeEl.innerHTML = '
No shapes.
'; return; } + if (treeCountEl) treeCountEl.textContent = String(shapes.length); + if (shapes.length === 0) { treeEl.innerHTML = '
No shapes.
'; return; } const frag = document.createDocumentFragment(); shapes.forEach(el => { const idx = el.getAttribute('data-idx'); + const tag = el.tagName.toLowerCase(); const node = document.createElement('div'); node.className = 'tree-node' + (idx === String(state.selectedEl) ? ' selected' : ''); - node.innerHTML = `${String(idx).padStart(2, '0')}<${el.tagName}>`; + node.innerHTML = `${String(idx).padStart(2, '0')}${TAG_ICONS[tag] || 'category'}${tag}`; node.addEventListener('click', () => selectElement(idx)); frag.appendChild(node); }); @@ -560,15 +683,78 @@ window.addEventListener('keydown', (e) => { } }); -// ---- Props panel (Figma-style sections) ---- +// ---- Alignment ---- +function getEffectiveBBox(el) { + const canvas = currentCanvasSvg(); + const live = canvas?.querySelector(`[data-idx="${el.getAttribute('data-idx')}"]`); + if (!live) return null; + let bbox; + try { bbox = live.getBBox(); } catch { return null; } + const ctm = live.getCTM(); + if (!ctm) return null; + const pt = canvas.createSVGPoint(); + function toParent(x, y) { pt.x = x; pt.y = y; return pt.matrixTransform(ctm); } + const p1 = toParent(bbox.x, bbox.y); + const p2 = toParent(bbox.x + bbox.width, bbox.y + bbox.height); + const left = Math.min(p1.x, p2.x), right = Math.max(p1.x, p2.x); + const top = Math.min(p1.y, p2.y), bottom = Math.max(p1.y, p2.y); + return { left, top, right, bottom, width: right - left, height: bottom - top, local: bbox }; +} +function alignSelection(dir) { + if (state.selectedEl === null) return; + const target = state.svgDoc.querySelector(`[data-idx="${state.selectedEl}"]`); + if (!target) return; + const eff = getEffectiveBBox(target); + if (!eff) return; + const vb = (state.svgDoc.getAttribute('viewBox') || `0 0 ${state.svgDoc.getAttribute('width') || 48} ${state.svgDoc.getAttribute('height') || 48}`).split(/\s+/).map(Number); + const [vx, vy, vw, vh] = vb; + + let dx = 0, dy = 0; + switch (dir) { + case 'hleft': dx = vx - eff.left; break; + case 'hcenter': dx = (vx + vw / 2) - (eff.left + eff.width / 2); break; + case 'hright': dx = (vx + vw) - eff.right; break; + case 'vtop': dy = vy - eff.top; break; + case 'vcenter': dy = (vy + vh / 2) - (eff.top + eff.height / 2); break; + case 'vbottom': dy = (vy + vh) - eff.bottom; break; + } + markBaseline(); + const cur = parseTranslate(target.getAttribute('transform') || ''); + setTranslate(target, cur.x + dx, cur.y + dy); + const live = currentCanvasSvg()?.querySelector(`[data-idx="${state.selectedEl}"]`); + if (live) { const l = parseTranslate(live.getAttribute('transform') || ''); setTranslate(live, l.x + dx, l.y + dy); } + renderProps(); renderHandles(); + commitIfChanged(); +} +function resizeToDimensions(w, h) { + if (state.selectedEl === null) return; + const target = state.svgDoc.querySelector(`[data-idx="${state.selectedEl}"]`); + if (!target) return; + const eff = getEffectiveBBox(target); + if (!eff || !eff.local || eff.local.width <= 0 || eff.local.height <= 0) return; + const canvas = currentCanvasSvg(); + const live = canvas?.querySelector(`[data-idx="${state.selectedEl}"]`); + if (!live) return; + markBaseline(); + const sx = w / eff.local.width; + const sy = h / eff.local.height; + const tx = eff.left - eff.local.x * sx; + const ty = eff.top - eff.local.y * sy; + setTransformResize(target, tx, ty, sx, sy); + setTransformResize(live, tx, ty, sx, sy); + renderProps(); renderHandles(); + commitIfChanged(); +} + +// ---- Props panel (Material Design sections) ---- function renderProps() { const svg = state.svgDoc; - if (!svg) { propsEl.innerHTML = '
Load an icon.
'; return; } + if (!svg) { propsEl.innerHTML = '
Load an icon.
'; return; } const titleEl = svg.querySelector('title'); const title = titleEl?.textContent || ''; - const width = svg.getAttribute('width') || ''; - const height = svg.getAttribute('height') || ''; + const iw = svg.getAttribute('width') || ''; + const ih = svg.getAttribute('height') || ''; const viewBox = svg.getAttribute('viewBox') || ''; let target = null; @@ -576,28 +762,25 @@ function renderProps() { let html = ''; - // Element header - html += `
+ // Selection header + const selName = target ? target.tagName.toLowerCase() : 'Icon'; + const selIcon = target ? (TAG_ICONS[selName] || 'category') : 'image'; + html += `
- ${target ? escapeAttr(target.tagName) : 'Icon'} +
+ ${selIcon} +
+ ${selName} + ${target ? `#${state.selectedEl}` : ''}
- - - - + ${target ? ` + + + ` : ''}
`; - // Element position + fill/stroke sections (only if selected) if (target) { const fill = target.getAttribute('fill') || 'currentColor'; const stroke = target.getAttribute('stroke') || ''; @@ -605,72 +788,121 @@ function renderProps() { const opacity = target.getAttribute('opacity') || '1'; const transform = target.getAttribute('transform') || ''; const trans = parseTranslate(transform); + const eff = getEffectiveBBox(target) || { width: 0, height: 0 }; const fillIsCurrent = fill === 'currentColor'; - const fillColor = /^#[0-9a-fA-F]+$/.test(fill) ? fill : '#7cc4ff'; + const fillColor = /^#[0-9a-fA-F]+$/.test(fill) ? fill : '#0d99ff'; const strokeIsCurrent = stroke === 'currentColor'; const strokeColor = /^#[0-9a-fA-F]+$/.test(stroke) ? stroke : '#000000'; + const isRect = target.tagName.toLowerCase() === 'rect'; + const rx = target.getAttribute('rx') || target.getAttribute('ry') || ''; - html += `
+ // Alignment + html += `
+
Alignment
+
+ + + + + + +
+
`; + + // Position (X, Y) + Rounded corner (rect only) + html += `
Position
-
-
X
-
Y
+
+
X
+
Y
+ ${isRect ? `
rounded_corner
` : ''}
`; - html += `
+ // Dimensions (W, H) — editable, resizes proportionally + html += `
+
Dimensions
+
+
W
+
H
+
+
`; + + // Fill + html += `
Fill
-
-
- -
-
- -
+
+ +
- +
`; - html += `
+ // Stroke + html += `
Stroke
-
-
- -
-
- -
+
+ + +
+
+
W
+
opacity
-
-
W
-
α
+
`; + + // Layer order + html += `
+
Layer
+
+ + + +
`; - html += `
+ // Raw transform + html += `
Transform
-
T
- +
transform
+
`; } else { - html += `
+ html += `
Select a shape on the canvas or in the tree to edit its properties, or pick a draw tool from the palette to create a new shape.
`; } - // Icon root (always shown) - html += `
+ // Icon Root — always shown + html += `
Icon Root
-
t
-
-
W
-
H
+
title
+
+
W
+
H
-
+
crop_free
+
`; + + // Export + html += `
+
Export
+
+ + +
+
`; propsEl.innerHTML = html; + // Alignment buttons + propsEl.querySelectorAll('button[data-align]').forEach(b => b.addEventListener('click', () => alignSelection(b.dataset.align))); + // Wire icon-root const setSvgAttr = (name, val) => { if (val === '' || val == null) svg.removeAttribute(name); else svg.setAttribute(name, val); @@ -694,14 +926,51 @@ function renderProps() { document.getElementById('p-icon-h').addEventListener('input', e => setSvgAttr('height', e.target.value)); document.getElementById('p-icon-vb').addEventListener('input', e => setSvgAttr('viewBox', e.target.value)); - // Wire header action buttons - document.getElementById('p-to-front').addEventListener('click', bringToFront); - document.getElementById('p-to-back').addEventListener('click', sendToBack); - document.getElementById('p-duplicate').addEventListener('click', duplicateSelection); - document.getElementById('p-remove').addEventListener('click', deleteSelection); + // Wire header action buttons (only present when a shape is selected) + document.getElementById('p-duplicate')?.addEventListener('click', duplicateSelection); + document.getElementById('p-remove')?.addEventListener('click', deleteSelection); + + // Export buttons (always present) + document.getElementById('p-copy-svg')?.addEventListener('click', () => copyBtn.click()); + document.getElementById('p-download-svg')?.addEventListener('click', downloadSvgFile); + document.getElementById('p-save-json')?.addEventListener('click', openSaveDialog); if (!target) return; + // Layer order buttons (only present when selected) + document.getElementById('p-to-front')?.addEventListener('click', bringToFront); + document.getElementById('p-to-back')?.addEventListener('click', sendToBack); + document.getElementById('p-forward')?.addEventListener('click', bringForward); + document.getElementById('p-backward')?.addEventListener('click', sendBackward); + + // W/H dimension inputs — resize the selection to the target dimensions + const wEl = document.getElementById('p-w'); + const hEl = document.getElementById('p-h'); + if (wEl && hEl) { + const commit = () => { + const w = parseFloat(wEl.value); const h = parseFloat(hEl.value); + if (!isFinite(w) || !isFinite(h) || w <= 0 || h <= 0) return; + resizeToDimensions(w, h); + }; + wEl.addEventListener('change', commit); + hEl.addEventListener('change', commit); + } + + // Corner radius (rect only) + const rxEl = document.getElementById('p-rx'); + rxEl?.addEventListener('focus', markBaseline); + rxEl?.addEventListener('change', commitIfChanged); + rxEl?.addEventListener('input', e => { + const v = e.target.value; + if (v === '' || +v === 0) { target.removeAttribute('rx'); target.removeAttribute('ry'); } + else { target.setAttribute('rx', v); target.setAttribute('ry', v); } + const live = currentCanvasSvg()?.querySelector(`[data-idx="${state.selectedEl}"]`); + if (live) { + if (v === '' || +v === 0) { live.removeAttribute('rx'); live.removeAttribute('ry'); } + else { live.setAttribute('rx', v); live.setAttribute('ry', v); } + } + }); + const setAttr = (name, val) => { if (val === '' || val == null) target.removeAttribute(name); else target.setAttribute(name, val); const live = currentCanvasSvg()?.querySelector(`[data-idx="${state.selectedEl}"]`); @@ -744,12 +1013,16 @@ function renderProps() { } // ---- Toolbar ---- -zoomEl.addEventListener('input', e => { - state.zoom = +e.target.value; - zoomLabel.textContent = `${Math.round(state.zoom * 100)}%`; +function setZoom(z) { + state.zoom = Math.max(1, Math.min(12, z)); + const pct = `${Math.round(state.zoom * 100)}%`; + zoomLabel.textContent = pct; + if (zoomDisplay) zoomDisplay.textContent = pct; mountCanvas(); -}); -zoomLabel.textContent = `${Math.round(state.zoom * 100)}%`; +} +zoomInBtn?.addEventListener('click', () => setZoom(state.zoom + 1)); +zoomOutBtn?.addEventListener('click', () => setZoom(state.zoom - 1)); +setZoom(state.zoom); resetBtn.addEventListener('click', () => { if (state.selectedIcon) loadIntoStudio({ ...state.selectedIcon, svg: state.original }); }); centerBtn.addEventListener('click', () => withEdit(() => { if (state.selectedEl === null) return; @@ -778,7 +1051,7 @@ copyBtn.addEventListener('click', async () => { await navigator.clipboard.writeText(svg); showToast('Copied SVG'); }); -downloadBtn.addEventListener('click', () => { +function downloadSvgFile() { const svg = serializeStudio(); if (!svg) return; const blob = new Blob([svg], { type: 'image/svg+xml' }); @@ -788,8 +1061,111 @@ downloadBtn.addEventListener('click', () => { a.download = `${state.selectedIcon?.name || 'icon'}.svg`; a.click(); URL.revokeObjectURL(url); +} + +// ---- Save as JSON (matches repo's /.json format) ---- +const saveDialog = document.getElementById('save-dialog'); +const saveName = document.getElementById('save-name'); +const saveCat = document.getElementById('save-category'); +const saveNewCatWrap = document.getElementById('save-new-cat-wrap'); +const saveNewCat = document.getElementById('save-new-cat'); +const savePreview = document.getElementById('save-preview-path'); +const saveConfirm = document.getElementById('save-confirm'); + +function openSaveDialog() { + if (!state.svgDoc) { showToast('Load an icon first'); return; } + + // Build category list from what we know about the repo. + saveCat.innerHTML = ''; + const cats = new Set( + state.categories.map(c => c.name).filter(n => n && n !== 'custom') + ); + // Sensible defaults so an empty repo still offers targets. + cats.add('brands'); + cats.add('regular'); + for (const cat of Array.from(cats).sort()) { + const opt = document.createElement('option'); + opt.value = cat; + opt.textContent = cat; + saveCat.appendChild(opt); + } + const newOpt = document.createElement('option'); + newOpt.value = '__new__'; + newOpt.textContent = '+ New category…'; + saveCat.appendChild(newOpt); + + // Sensible defaults. + const cur = state.selectedIcon; + let defaultName = cur?.name || ''; + if (/^untitled-\d+$/.test(defaultName)) defaultName = ''; + saveName.value = defaultName; + if (cur?.category && cats.has(cur.category)) saveCat.value = cur.category; + else saveCat.value = 'brands'; + saveNewCatWrap.classList.add('hidden'); + saveNewCat.value = ''; + + updateSavePreview(); + saveDialog.showModal(); + saveName.focus(); + saveName.select?.(); +} + +function updateSavePreview() { + const name = (saveName.value || '').trim() || 'icon'; + const cat = saveCat.value === '__new__' + ? (saveNewCat.value || '').trim() || 'category' + : saveCat.value; + savePreview.textContent = `${cat}/${name}.json`; +} +saveName.addEventListener('input', updateSavePreview); +saveNewCat.addEventListener('input', updateSavePreview); +saveCat.addEventListener('change', () => { + const isNew = saveCat.value === '__new__'; + saveNewCatWrap.classList.toggle('hidden', !isNew); + if (isNew) saveNewCat.focus(); + updateSavePreview(); }); +function validateSlug(s) { return /^[a-z0-9][a-z0-9-]*$/.test(s); } + +saveConfirm.addEventListener('click', () => { + const name = (saveName.value || '').trim().toLowerCase(); + const cat = (saveCat.value === '__new__' + ? (saveNewCat.value || '').trim().toLowerCase() + : saveCat.value); + if (!validateSlug(name)) { showToast('Invalid name (a-z, 0-9, hyphens)'); saveName.focus(); return; } + if (!validateSlug(cat)) { showToast('Invalid category name'); (saveCat.value === '__new__' ? saveNewCat : saveCat).focus(); return; } + + const svgContent = serializeStudio(); + if (!svgContent) return; + const json = { + name, + files: [{ path: `${name}.svg`, content: svgContent }], + }; + const body = JSON.stringify(json, null, 2) + '\n'; + const blob = new Blob([body], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${name}.json`; + a.click(); + URL.revokeObjectURL(url); + + // Also persist to the session's custom library. + const custom = loadCustomIcons(); + const idx = custom.findIndex(c => c.name === name); + if (idx >= 0) custom[idx] = { name, svg: svgContent }; + else custom.push({ name, svg: svgContent }); + saveCustomIcons(custom); + + saveDialog.close(); + showToast(`Saved ${cat}/${name}.json`); +}); + +// Top "Export" opens the save dialog; a plain .svg download stays available +// via the props panel button below. +downloadBtn.addEventListener('click', openSaveDialog); + // ---- New icon ---- function createNewIcon() { const custom = loadCustomIcons(); @@ -811,6 +1187,7 @@ function createNewIcon() { showToast(`Created ${name}`); } newBtn.addEventListener('click', createNewIcon); +newBtnSm?.addEventListener('click', createNewIcon); // Re-position handles when the window resizes window.addEventListener('resize', renderHandles); diff --git a/.github/scripts/demo/studio.html b/.github/scripts/demo/studio.html index 5623d95..2b97618 100644 --- a/.github/scripts/demo/studio.html +++ b/.github/scripts/demo/studio.html @@ -4,220 +4,314 @@ Studio · KFE Icons Studio - - + + + - + -
-
+
+
- KFE Icons Studio +
+ K +
+ KFE Icons Studio
- / - no icon selected + / +
- -
- loading… - - +
- - - - -
-
-
+ +
+
+ + +
- - -
-
+ +
+
+
-
+
-
+
- -
-
- - 400% + +
+
+ + 400% +
-
`; + const swatchMarkup = (targetKey, currentVal) => PALETTE.map(c => { + const isNone = c === 'none'; + const isCur = c === 'currentColor'; + const cls = isNone ? 'none' : isCur ? 'current' : ''; + const active = (isNone && (currentVal === 'none')) || + (isCur && currentVal === 'currentColor') || + (!isNone && !isCur && currentVal?.toLowerCase() === c.toLowerCase()); + const style = (isNone || isCur) ? '' : `background:${c};`; + return ``; + }).join(''); + // Fill html += `
Fill
@@ -835,6 +1208,7 @@ function renderProps() {
+
${swatchMarkup('fill', fill)}
+
${swatchMarkup('stroke', stroke || 'none')}
W
opacity
@@ -903,11 +1278,43 @@ function renderProps() { // Alignment buttons propsEl.querySelectorAll('button[data-align]').forEach(b => b.addEventListener('click', () => alignSelection(b.dataset.align))); + // Color swatches (Fill/Stroke) + propsEl.querySelectorAll('button.swatch[data-target]').forEach(b => { + b.addEventListener('click', () => { + const c = b.dataset.color; + const t = b.dataset.target; + if (state.selectedEl === null) return; + const el = state.svgDoc.querySelector(`[data-idx="${state.selectedEl}"]`); + if (!el) return; + const attr = t === 'fill' ? 'fill' : 'stroke'; + withEdit(() => { + if (c === 'none') el.setAttribute(attr, 'none'); + else el.setAttribute(attr, c); + const live = currentCanvasSvg()?.querySelector(`[data-idx="${state.selectedEl}"]`); + if (live) { + if (c === 'none') live.setAttribute(attr, 'none'); + else live.setAttribute(attr, c); + } + renderProps(); + }); + }); + }); + // Wire icon-root const setSvgAttr = (name, val) => { if (val === '' || val == null) svg.removeAttribute(name); else svg.setAttribute(name, val); const live = currentCanvasSvg(); - if (live) { if (val === '' || val == null) live.removeAttribute(name); else live.setAttribute(name, val); } + if (!live) return; + // For width/height, the canvas svg is rendered at (source × zoom); other + // attributes (viewBox, class, etc.) are mirrored verbatim. + if (name === 'width' || name === 'height') { + if (val === '' || val == null) live.removeAttribute(name); + else live.setAttribute(name, (parseFloat(val) || 0) * state.zoom); + renderHandles(); + return; + } + if (val === '' || val == null) live.removeAttribute(name); else live.setAttribute(name, val); + if (name === 'viewBox') renderHandles(); }; const setTitle = (val) => { let t = svg.querySelector('title'); @@ -1014,7 +1421,7 @@ function renderProps() { // ---- Toolbar ---- function setZoom(z) { - state.zoom = Math.max(1, Math.min(12, z)); + state.zoom = Math.max(0.5, Math.min(20, z)); const pct = `${Math.round(state.zoom * 100)}%`; zoomLabel.textContent = pct; if (zoomDisplay) zoomDisplay.textContent = pct; @@ -1023,6 +1430,115 @@ function setZoom(z) { zoomInBtn?.addEventListener('click', () => setZoom(state.zoom + 1)); zoomOutBtn?.addEventListener('click', () => setZoom(state.zoom - 1)); setZoom(state.zoom); + +// Ctrl / Cmd + wheel to zoom. Exponential scaling gives a consistent +// relative change per notch, and needs `passive: false` so we can +// preventDefault (otherwise the browser will scroll the page). +const canvasMain = document.querySelector('main'); +canvasMain?.addEventListener('wheel', (e) => { + if (!e.ctrlKey && !e.metaKey) return; + e.preventDefault(); + const factor = Math.exp(-e.deltaY * 0.0018); + setZoom(state.zoom * factor); +}, { passive: false }); + +// Suppress the browser context menu on the canvas — the right button is +// reserved for future pan/context-menu features and shouldn't ever show +// the browser's default one here. +canvasMain?.addEventListener('contextmenu', (e) => e.preventDefault()); + +// Drag an .svg file (or an SVG string) from anywhere onto the canvas to +// import its shapes into the current icon. +canvasMain?.addEventListener('dragover', (e) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; }); +canvasMain?.addEventListener('drop', async (e) => { + e.preventDefault(); + const file = e.dataTransfer?.files?.[0]; + if (file && (file.type === 'image/svg+xml' || /\.svg$/i.test(file.name))) { + importSvgText(await file.text()); + return; + } + const text = e.dataTransfer?.getData('text/plain') || e.dataTransfer?.getData('image/svg+xml'); + if (text && /]/i.test(text)) importSvgText(text); +}); + +// ---- Space + drag pan ---- +// The canvas-wrap holds both the SVG and the handles overlay, so translating +// its CSS transform moves everything (shapes, anchors, handles) together. +// getScreenCTM sees the CSS transform, so clientToSvg still returns correct +// viewBox coords after panning. +state.pan = { x: 0, y: 0 }; +state.spaceDown = false; +function applyPan() { + if (canvasWrap) canvasWrap.style.transform = `translate(${state.pan.x}px, ${state.pan.y}px)`; +} +function setSpaceCursor(active) { + if (!canvasMain) return; + canvasMain.style.cursor = active ? 'grab' : ''; + const svg = currentCanvasSvg(); + if (svg) svg.style.cursor = active ? 'grab' : (state.tool === 'select' ? 'default' : 'crosshair'); +} +window.addEventListener('keydown', (e) => { + if (e.code !== 'Space') return; + const tgt = e.target; + if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA' || tgt.tagName === 'SELECT')) return; + if (e.repeat) { e.preventDefault(); return; } + e.preventDefault(); + state.spaceDown = true; + setSpaceCursor(true); +}); +window.addEventListener('keyup', (e) => { + if (e.code !== 'Space') return; + state.spaceDown = false; + setSpaceCursor(false); +}); +// If the window loses focus mid-pan, don't get stuck with spaceDown = true. +window.addEventListener('blur', () => { state.spaceDown = false; setSpaceCursor(false); }); + +let panCtx = null; +function onPanMove(e) { + if (!panCtx) return; + state.pan.x = panCtx.baseX + (e.clientX - panCtx.startX); + state.pan.y = panCtx.baseY + (e.clientY - panCtx.startY); + applyPan(); + renderHandles(); +} +function onPanUp() { + panCtx = null; + window.removeEventListener('pointermove', onPanMove); + if (canvasMain) canvasMain.style.cursor = state.spaceDown ? 'grab' : ''; +} +// Capture phase so we can pre-empt shape / deselect / draw handlers when +// the user is space-panning. stopPropagation blocks the rest. +canvasMain?.addEventListener('pointerdown', (e) => { + if (!state.spaceDown || e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + panCtx = { startX: e.clientX, startY: e.clientY, baseX: state.pan.x, baseY: state.pan.y }; + if (canvasMain) canvasMain.style.cursor = 'grabbing'; + window.addEventListener('pointermove', onPanMove); + window.addEventListener('pointerup', onPanUp, { once: true }); +}, true); + +// Clicking empty canvas area (not on a shape, handle, anchor, or floating +// UI) deselects the current shape and exits path-edit mode. The existing +// onCanvasPointerDown handler covers clicks inside the SVG background; +// this handler covers the darker workspace around the SVG. +canvasMain?.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return; // left click only + if (state.tool !== 'select') return; // draw tools begin a shape + if (e.target.closest('#canvas')) return; // handled inside the SVG + if (e.target.closest('#tools-palette')) return; + if (e.target.closest('#quick-actions')) return; + if (e.target.closest('#handles')) return; + if (e.target.closest('.glass-panel')) return; + if (e.target.closest('button, input, label')) return; + selectElement(null); + if (state.editingPath) { + state.editingPath = false; + qaEditPath?.classList.remove('active'); + renderHandles(); + } +}); resetBtn.addEventListener('click', () => { if (state.selectedIcon) loadIntoStudio({ ...state.selectedIcon, svg: state.original }); }); centerBtn.addEventListener('click', () => withEdit(() => { if (state.selectedEl === null) return; @@ -1128,7 +1644,7 @@ saveCat.addEventListener('change', () => { function validateSlug(s) { return /^[a-z0-9][a-z0-9-]*$/.test(s); } -saveConfirm.addEventListener('click', () => { +saveConfirm.addEventListener('click', async () => { const name = (saveName.value || '').trim().toLowerCase(); const cat = (saveCat.value === '__new__' ? (saveNewCat.value || '').trim().toLowerCase() @@ -1143,23 +1659,57 @@ saveConfirm.addEventListener('click', () => { files: [{ path: `${name}.svg`, content: svgContent }], }; const body = JSON.stringify(json, null, 2) + '\n'; - const blob = new Blob([body], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${name}.json`; - a.click(); - URL.revokeObjectURL(url); - // Also persist to the session's custom library. - const custom = loadCustomIcons(); - const idx = custom.findIndex(c => c.name === name); - if (idx >= 0) custom[idx] = { name, svg: svgContent }; - else custom.push({ name, svg: svgContent }); - saveCustomIcons(custom); + saveConfirm.disabled = true; + saveConfirm.textContent = 'Saving…'; + try { + const res = await fetch('/api/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ category: cat, name, content: body }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + + // Reflect the save in the current session so it shows up in the sidebar + // and Browse tab without a full reload. + const existing = state.icons.find(i => i.name === name && i.category === cat); + if (existing) existing.svg = svgContent; + else state.icons.push({ name, category: cat, svg: svgContent }); + let catEntry = state.categories.find(c => c.name === cat); + if (!catEntry) { catEntry = { name: cat, items: [] }; state.categories.push(catEntry); } + if (!catEntry.items.some(i => i.name === name)) catEntry.items.push({ name, target: `${cat}/${name}.json`, type: cat }); + + // If this icon is what's loaded, keep the reference in sync so subsequent + // Reset restores the just-saved version rather than the original. + if (state.selectedIcon?.name === name && state.selectedIcon?.category === cat) { + state.selectedIcon.svg = svgContent; + state.original = svgContent; + } else { + // Re-load the icon so the Studio URL/name badge reflects the save. + state.selectedIcon = { name, category: cat, svg: svgContent }; + state.original = svgContent; + nameEl.textContent = `${cat} / ${name}`; + history.replaceState(null, '', `/studio?icon=${encodeURIComponent(cat + '/' + name)}`); + } - saveDialog.close(); - showToast(`Saved ${cat}/${name}.json`); + renderIconList(); + saveDialog.close(); + showToast(`Saved ${data.path || `${cat}/${name}.json`}`); + } catch (err) { + console.error(err); + showToast(`Save failed: ${err.message} — falling back to download`); + // Fallback: browser download so work isn't lost if the server can't write + // (e.g., running the demo hosted without the local Node server). + const blob = new Blob([body], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = `${name}.json`; a.click(); + URL.revokeObjectURL(url); + } finally { + saveConfirm.disabled = false; + saveConfirm.textContent = 'Save to Repo'; + } }); // Top "Export" opens the save dialog; a plain .svg download stays available diff --git a/.github/scripts/demo/browse.html b/.github/scripts/demo/browse.html index d13dd7d..74a1d28 100644 --- a/.github/scripts/demo/browse.html +++ b/.github/scripts/demo/browse.html @@ -1,50 +1,177 @@ - + Browse · KFE Icons Studio - + + + + + - - -
-

KFE Icons Studio

- - loading… -
+ -
-
- - - - +
+
+
K
+ KFE Icons Studio + / + browse
-
Loading…
-
+
+ loading… +
+
+
+ + + + +
+ +
+
+ search + +
+
+ folder + +
+
+ palette + +
+
+ photo_size_select_actual + + 48 +
+
+ +
+
Loading…
+
+
+
+ + -
- icon - +
+
+ image + icon +
+
-
-
-
- SVG markup -
- - +
+
+
+ SVG Markup +
+ +
diff --git a/.github/scripts/demo/graph.html b/.github/scripts/demo/graph.html index a67cf02..f610632 100644 --- a/.github/scripts/demo/graph.html +++ b/.github/scripts/demo/graph.html @@ -1,31 +1,112 @@ - + Graph · KFE Icons Studio - + + + + + - - -
-

KFE Icons Studio

- - loading… + + +
+
+
K
+ KFE Icons Studio + / + graph +
+
+ loading… +
-
-
-
-

Icon Library Overview

-

Distribution and complexity metrics across the registry.

+
+ + +
+
+

Icon Library Overview

+

Distribution and complexity metrics across the registry.

-
-
+ +
+ +

Icons per Category

@@ -44,8 +125,8 @@

Top 10 Largest Icons

-
-
+
+
diff --git a/.github/scripts/demo/studio.html b/.github/scripts/demo/studio.html index 2b97618..5707568 100644 --- a/.github/scripts/demo/studio.html +++ b/.github/scripts/demo/studio.html @@ -50,6 +50,55 @@ @@ -143,23 +211,31 @@
From e2710ec4bca290f1326218dcf27bebe70a8bbfa4 Mon Sep 17 00:00:00 2001 From: pphatdev <65520537+pphatdev@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:48:14 +0000 Subject: [PATCH 8/8] chore: auto update category json files --- brands.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/brands.json b/brands.json index d085a56..0a9c83e 100644 --- a/brands.json +++ b/brands.json @@ -1,9 +1,4 @@ [ - { - "name": "7zip", - "target": "brands/7zip.json", - "type": "brands" - }, { "name": "algolia", "target": "brands/algolia.json", @@ -759,6 +754,11 @@ "target": "brands/sass.json", "type": "brands" }, + { + "name": "seventzip", + "target": "brands/seventzip.json", + "type": "brands" + }, { "name": "shadcnui", "target": "brands/shadcnui.json",