Compare commits

...

2 Commits

Author SHA1 Message Date
54ef9121c7 [FEAT] Add blog 2025-11-26 16:41:39 +01:00
7152482687 [FEAT] Added vue router and pages 2025-11-26 15:59:34 +01:00
21 changed files with 1124 additions and 530 deletions

241
package-lock.json generated
View File

@@ -9,18 +9,22 @@
"version": "0.0.0",
"dependencies": {
"@tailwindcss/vite": "^4.1.1",
"buffer": "^6.0.3",
"gif.js": "^0.2.0",
"gray-matter": "^4.0.3",
"jszip": "^3.10.1",
"marked": "^15.0.7",
"pinia": "^3.0.1",
"pocketbase": "^0.26.2",
"vue": "^3.5.13"
"vue": "^3.5.13",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
"@tailwindcss/typography": "^0.5.19",
"@tsconfig/node22": "^22.0.1",
"@types/gif.js": "^0.2.5",
"@types/node": "^22.13.14",
"@types/node": "^22.19.1",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.21",
@@ -1933,6 +1937,19 @@
"node": ">= 10"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz",
@@ -2328,6 +2345,15 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/autoprefixer": {
"version": "10.4.22",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
@@ -2373,6 +2399,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.31",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz",
@@ -2449,6 +2495,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-builder": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz",
@@ -2582,6 +2652,19 @@
"node": ">= 8"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
},
"engines": {
"node": ">=4"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2758,6 +2841,19 @@
"node": ">=6"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
@@ -2791,6 +2887,18 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"license": "MIT",
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/figures": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
@@ -2902,6 +3010,21 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
"license": "MIT",
"dependencies": {
"js-yaml": "^3.13.1",
"kind-of": "^6.0.2",
"section-matter": "^1.0.0",
"strip-bom-string": "^1.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -2938,6 +3061,26 @@
"node": ">=18.18.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@@ -2973,6 +3116,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -3124,6 +3276,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -3185,6 +3350,15 @@
"setimmediate": "^1.0.5"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/kolorist": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
@@ -3814,6 +3988,20 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
@@ -4330,6 +4518,19 @@
"node": ">=14.0.0"
}
},
"node_modules/section-matter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
"license": "MIT",
"dependencies": {
"extend-shallow": "^2.0.1",
"kind-of": "^6.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -4431,6 +4632,12 @@
"node": ">=0.10.0"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
@@ -4440,6 +4647,15 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/strip-bom-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strip-final-newline": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
@@ -4905,6 +5121,27 @@
}
}
},
"node_modules/vue-router": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz",
"integrity": "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/vue-router/node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/vue-tsc": {
"version": "2.2.12",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz",

View File

@@ -13,18 +13,22 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.1.1",
"buffer": "^6.0.3",
"gif.js": "^0.2.0",
"gray-matter": "^4.0.3",
"jszip": "^3.10.1",
"marked": "^15.0.7",
"pinia": "^3.0.1",
"pocketbase": "^0.26.2",
"vue": "^3.5.13"
"vue": "^3.5.13",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
"@tailwindcss/typography": "^0.5.19",
"@tsconfig/node22": "^22.0.1",
"@types/gif.js": "^0.2.5",
"@types/node": "^22.13.14",
"@types/node": "^22.19.1",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.21",

BIN
public/blog/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

View File

@@ -4,12 +4,30 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<!-- created with Free Online Sitemap Generator www.xml-sitemaps.com -->
<url>
<loc>https://spritesheetgenerator.online/</loc>
<lastmod>2025-11-25T17:52:14+00:00</lastmod>
<lastmod>2025-11-26T15:50:00+00:00</lastmod>
</url>
<url>
<loc>https://spritesheetgenerator.online/about</loc>
<lastmod>2025-11-26T15:50:00+00:00</lastmod>
</url>
<url>
<loc>https://spritesheetgenerator.online/contact</loc>
<lastmod>2025-11-26T15:50:00+00:00</lastmod>
</url>
<url>
<loc>https://spritesheetgenerator.online/privacy-policy</loc>
<lastmod>2025-11-26T15:50:00+00:00</lastmod>
</url>
<url>
<loc>https://spritesheetgenerator.online/blog</loc>
<lastmod>2025-11-26T15:50:00+00:00</lastmod>
</url>
<url>
<loc>https://spritesheetgenerator.online/blog/welcome</loc>
<lastmod>2025-11-26T15:50:00+00:00</lastmod>
</url>

View File

@@ -1,340 +1,51 @@
<template>
<div class="min-h-screen flex flex-col p-4 sm:p-8 bg-slate-50 dark:bg-gray-950 transition-colors duration-300" :class="{ 'lg:h-screen': layers.some(l => l.sprites.length) }">
<div class="flex flex-col flex-1" :class="{ 'lg:overflow-hidden': layers.some(l => l.sprites.length) }">
<div class="min-h-screen flex flex-col p-4 sm:p-8 bg-slate-50 dark:bg-gray-950 transition-colors duration-300" :class="{ 'lg:h-screen': layers.some(l => l.sprites.length) && $route.name === 'home' }">
<div class="flex flex-col flex-1" :class="{ 'lg:overflow-hidden': layers.some(l => l.sprites.length) && $route.name === 'home' }">
<header class="mb-6 sm:mb-5">
<div class="flex flex-col sm:flex-row justify-between items-center gap-6 mb-8">
<div class="text-center sm:text-left">
<h1 class="text-3xl sm:text-5xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-gray-900 to-gray-700 dark:from-white dark:to-gray-300 tracking-tight mb-3">Spritesheet generator</h1>
<router-link to="/" class="block group">
<h1 class="text-3xl sm:text-5xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-gray-900 to-gray-700 dark:from-white dark:to-gray-300 tracking-tight mb-3 group-hover:opacity-80 transition-opacity">Spritesheet generator</h1>
</router-link>
<p class="text-sm sm:text-base text-gray-600 dark:text-gray-400 font-medium">Create professional spritesheets for your game development projects</p>
</div>
<nav class="flex flex-wrap items-center justify-center gap-3">
<a
href="https://gitea.adhd.sh/root/spritesheet-generator"
target="_blank"
class="btn btn-secondary hover:shadow-md"
data-rybbit-event="source-link"
>
<i class="fab fa-github"></i>
<span class="font-medium">Source</span>
</a>
<a
href="https://discord.gg/JTev3nzeDa"
target="_blank"
class="btn btn-secondary hover:shadow-md"
data-rybbit-event="discord-link"
>
<i class="fab fa-discord"></i>
<span class="font-medium">Discord</span>
</a>
<a
href="#"
@click.prevent="openHelpModal"
class="btn btn-secondary hover:shadow-md"
data-rybbit-event="help-link"
>
<i class="fas fa-question-circle"></i>
<span class="font-medium">Help</span>
</a>
<a
href="#"
@click.prevent="openFeedbackModal"
class="btn btn-secondary hover:shadow-md"
data-rybbit-event="feedback-link"
>
<i class="fas fa-comment-dots"></i>
<span class="font-medium">Feedback</span>
</a>
<dark-mode-toggle />
</nav>
<div class="flex flex-col items-center sm:items-end gap-3">
<nav class="flex flex-wrap items-center justify-center gap-3">
<a href="https://gitea.adhd.sh/root/spritesheet-generator" target="_blank" class="btn btn-secondary hover:shadow-md" data-rybbit-event="source-link">
<i class="fab fa-github"></i>
<span class="font-medium">Source</span>
</a>
<a href="https://discord.gg/JTev3nzeDa" target="_blank" class="btn btn-secondary hover:shadow-md" data-rybbit-event="discord-link">
<i class="fab fa-discord"></i>
<span class="font-medium">Discord</span>
</a>
<a href="#" @click.prevent="openHelpModal" class="btn btn-secondary hover:shadow-md" data-rybbit-event="help-link">
<i class="fas fa-question-circle"></i>
<span class="font-medium">Help</span>
</a>
<a href="#" @click.prevent="openFeedbackModal" class="btn btn-secondary hover:shadow-md" data-rybbit-event="feedback-link">
<i class="fas fa-comment-dots"></i>
<span class="font-medium">Feedback</span>
</a>
<dark-mode-toggle />
</nav>
<div class="flex gap-4 text-sm font-medium text-gray-600 dark:text-gray-400">
<router-link to="/" class="hover:text-gray-900 dark:hover:text-white transition-colors">Home</router-link>
<router-link to="/blog" class="hover:text-gray-900 dark:hover:text-white transition-colors">Blog</router-link>
<router-link to="/about" class="hover:text-gray-900 dark:hover:text-white transition-colors">About Us</router-link>
<router-link to="/contact" class="hover:text-gray-900 dark:hover:text-white transition-colors">Contact</router-link>
<router-link to="/privacy-policy" class="hover:text-gray-900 dark:hover:text-white transition-colors">Privacy Policy</router-link>
<a href="/sitemap.xml" target="_blank" class="hover:text-gray-900 dark:hover:text-white transition-colors">Sitemap</a>
</div>
</div>
</div>
</header>
<!-- <div class="flex-shrink-0 p-4 mb-6 bg-gradient-to-r from-blue-50 to-purple-50 dark:from-gray-800 dark:to-gray-700 border border-blue-100 dark:border-gray-600 rounded-2xl shadow-sm">-->
<!-- <div class="flex flex-col sm:flex-row items-center justify-between gap-2">-->
<!-- <div class="flex items-center gap-2 text-center sm:text-left">-->
<!-- <i class="text-lg text-blue-600 dark:text-blue-400 fas fa-ad"></i>-->
<!-- <span class="text-sm font-semibold text-gray-800 dark:text-gray-100">Your ad here</span>-->
<!-- <span class="hidden sm:inline text-xs text-gray-600 dark:text-gray-400">- Reach thousands of game developers and creative professionals</span>-->
<!-- </div>-->
<!-- <div class="flex gap-2">-->
<!-- <a href="mailto:root@adhd.sh" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 rounded transition-all">-->
<!-- <i class="text-xs fas fa-envelope"></i>-->
<!-- <span class="hidden sm:inline">root@adhd.sh</span>-->
<!-- <span class="sm:hidden">Email</span>-->
<!-- </a>-->
<!-- <a href="https://discord.gg/JTev3nzeDa" target="_blank" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 rounded transition-all">-->
<!-- <i class="text-xs fab fa-discord"></i>-->
<!-- <span class="hidden sm:inline">nu11ed</span>-->
<!-- <span class="sm:hidden">Discord</span>-->
<!-- </a>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<main class="flex flex-col flex-1 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md rounded-3xl shadow-2xl border border-gray-200/50 dark:border-gray-700/50 transition-all duration-300" :class="{ 'lg:overflow-hidden': layers.some(l => l.sprites.length) }">
<!-- Welcome state -->
<div v-if="!layers.some(l => l.sprites.length)" class="p-6 sm:p-10">
<div class="mb-8">
<h2 class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-1">Upload sprites or single image</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">Drag and drop images or import from JSON</p>
</div>
<file-uploader @upload-sprites="handleSpritesUpload" />
<div class="mt-10">
<div class="prose prose-lg dark:prose-invert max-w-none">
<div>
<h3 class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-4">Welcome to Spritesheet generator</h3>
<p class="text-gray-700 dark:text-gray-300 mb-4">Create spritesheets for your game development and animation projects with our completely free, open-source Spritesheet generator.</p>
<p class="text-gray-700 dark:text-gray-300 mb-6">This powerful online tool lets you upload individual sprite images and automatically arrange them into optimized sprite sheets with customizable layouts - perfect for indie developers, animators, and studios of any size.</p>
<h3 class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-4">Key features of this sprite editor</h3>
<ul class="text-gray-700 dark:text-gray-300 mb-6 space-y-2 list-disc">
<li><strong>Free sprite editor</strong>: Edit, organize, and optimize your game sprites directly in your browser</li>
<li><strong>Automatic spritesheet generation</strong>: Convert multiple PNG, JPG, or GIF images into efficient sprite atlases</li>
<li><strong>Customizable grid layouts</strong>: Adjust spacing, padding, and arrangement for pixel-perfect results</li>
<li><strong>Animation preview</strong>: Test your sprite animations before exporting</li>
<li><strong>Cross-platform compatibility</strong>: Works with Unity, Godot, Phaser, Pygame, and other game engines</li>
<li><strong>Zero installation required</strong>: No downloads - use our web-based sprite sheet maker instantly</li>
<li><strong>Batch processing</strong>: Upload and process multiple sprites simultaneously</li>
<li><strong>Export options</strong>: Download spritesheet as PNG, JPG, GIF, ZIP or JSON.</li>
</ul>
<div>
<h4 class="text-xl font-bold text-gray-800 dark:text-gray-100 mb-4 flex items-center gap-2">
<i class="fas fa-play-circle text-gray-800 dark:text-gray-200"></i>
How it works
</h4>
<video controls playsinline class="w-full rounded-lg shadow-lg border border-gray-200 dark:border-gray-700" title="Spritesheet generator tutorial" aria-label="Spritesheet generator tutorial">
<source src="@/assets/demo.mp4" type="video/mp4" />
</video>
</div>
</div>
</div>
</div>
</div>
<!-- Two-column layout: Left controls, Right preview -->
<div v-if="layers.some(l => l.sprites.length)" class="flex flex-col flex-1 lg:grid lg:grid-cols-[380px_1fr] xl:grid-cols-[420px_1fr] lg:overflow-hidden">
<!-- Left sidebar - Controls -->
<div class="p-6 bg-gray-50/50 dark:bg-gray-900/30 border-b lg:border-b-0 lg:border-r border-gray-200 dark:border-gray-700 lg:overflow-y-auto lg:overflow-x-auto">
<div class="space-y-8">
<!-- Upload Section -->
<section>
<div class="flex items-center justify-between mb-3">
<h3 class="flex items-center gap-2 text-base font-bold text-gray-800 dark:text-gray-100">
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-upload"></i>
Upload
</h3>
<button @click="openJSONImportDialog" class="btn btn-dark btn-sm" data-rybbit-event="import-json">
<i class="text-xs fas fa-file-import"></i>
<span>JSON</span>
</button>
</div>
<button class="w-full p-6 text-center border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl hover:border-gray-500 dark:hover:border-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-all cursor-pointer focus:outline-none focus:ring-2 focus:ring-gray-500 group" @click="openFileDialog">
<i class="fas fa-plus-circle text-3xl text-gray-400 dark:text-gray-500 group-hover:text-gray-600 dark:group-hover:text-gray-300 mb-3 transition-colors"></i>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-gray-200 transition-colors">Add sprites</p>
</button>
<input ref="uploadInput" type="file" multiple accept="image/*" class="hidden" @change="handleUploadChange" />
<input ref="jsonFileInput" type="file" accept=".json,application/json" class="hidden" @change="handleJSONFileChange" />
</section>
<!-- Layers -->
<section>
<div class="flex items-center justify-between mb-3">
<h3 class="flex items-center gap-2 text-base font-bold text-gray-800 dark:text-gray-100">
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-layer-group"></i>
Layers
</h3>
<button @click="addLayer()" class="btn btn-dark btn-sm">
<i class="text-xs fas fa-plus"></i>
<span>Add</span>
</button>
</div>
<div class="space-y-2">
<div
v-for="layer in layers"
:key="layer.id"
class="flex items-center gap-2 px-3 py-2 bg-white dark:bg-gray-800 border rounded-lg transition-all"
:class="[layer.id === activeLayerId ? 'border-gray-800 ring-1 ring-gray-800 dark:border-gray-400 dark:ring-gray-400' : 'border-gray-200 dark:border-gray-700', !layer.visible ? 'opacity-50' : '']"
>
<button @click.stop="layer.visible = !layer.visible" class="btn btn-ghost btn-icon-sm rounded" :title="layer.visible ? 'Hide layer' : 'Show layer'">
<i :class="layer.visible ? 'text-sm text-gray-800 dark:text-gray-200 fas fa-eye' : 'text-sm text-gray-400 dark:text-gray-500 fas fa-eye-slash'"></i>
</button>
<input
v-if="editingLayerId === layer.id"
type="text"
v-model="editingLayerName"
@blur="finishEditingLayer"
@keyup.enter="finishEditingLayer"
@keyup.esc="cancelEditingLayer"
class="flex-1 px-2 py-1 text-sm border border-gray-800 dark:border-gray-400 dark:bg-gray-700 dark:text-gray-100 rounded outline-none focus:ring-2 focus:ring-gray-800 dark:focus:ring-gray-400"
ref="layerNameInput"
@click.stop
/>
<button v-else @click="activeLayerId = layer.id" class="flex-1 px-2 py-1 text-sm font-medium text-left rounded transition-all cursor-pointer" :class="layer.id === activeLayerId ? 'text-gray-900 dark:text-gray-100' : 'text-gray-700 dark:text-gray-300'">
{{ layer.name }}
<span v-if="layer.sprites.length" class="ml-1 text-xs opacity-60">({{ layer.sprites.length }})</span>
</button>
<button v-if="editingLayerId !== layer.id" @click="startEditingLayer(layer.id, layer.name)" class="btn btn-ghost btn-icon-xs rounded" title="Rename">
<i class="text-xs text-gray-600 dark:text-gray-400 fas fa-pen"></i>
</button>
<button @click="moveLayer(layer.id, 'up')" class="btn btn-ghost btn-icon-xs rounded" title="Move up">
<i class="text-xs text-gray-600 dark:text-gray-400 fas fa-chevron-up"></i>
</button>
<button @click="moveLayer(layer.id, 'down')" class="btn btn-ghost btn-icon-xs rounded" title="Move down">
<i class="text-xs text-gray-600 dark:text-gray-400 fas fa-chevron-down"></i>
</button>
<button v-if="layers.length > 1" @click="removeLayer(layer.id)" class="btn btn-danger btn-icon-xs rounded" title="Delete">
<i class="text-xs fas fa-trash"></i>
</button>
</div>
</div>
</section>
<!-- Grid Settings -->
<section>
<h3 class="flex items-center gap-2 mb-3 text-base font-bold text-gray-800 dark:text-gray-100">
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-th"></i>
Grid
</h3>
<div class="space-y-3">
<div class="flex items-center justify-between px-3 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
<label for="columns" class="text-sm font-medium text-gray-700 dark:text-gray-200">Columns</label>
<input id="columns" type="number" v-model.number="columns" min="1" max="10" class="input-field w-16" />
</div>
<div class="px-3 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
<label class="flex items-center justify-between mb-2 cursor-pointer">
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Manual size</span>
<input type="checkbox" v-model="settingsStore.manualCellSizeEnabled" class="w-4 h-4 rounded" />
</label>
<div v-if="settingsStore.manualCellSizeEnabled" class="flex items-center gap-1.5 mt-2">
<input
type="number"
v-model.number="settingsStore.manualCellWidth"
min="1"
max="2048"
class="input-field w-full min-w-0"
placeholder="W"
/>
<span class="flex-shrink-0 text-gray-500 dark:text-gray-400">×</span>
<input
type="number"
v-model.number="settingsStore.manualCellHeight"
min="1"
max="2048"
class="input-field w-full min-w-0"
placeholder="H"
/>
</div>
<div v-else class="mt-1 text-xs font-mono text-gray-500 dark:text-gray-400 break-words">{{ cellSize.width }} × {{ cellSize.height }}px</div>
</div>
</div>
</section>
<!-- Alignment -->
<section>
<h3 class="flex items-center gap-2 mb-3 text-base font-bold text-gray-800 dark:text-gray-100">
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-align-center"></i>
Align
</h3>
<div class="grid grid-cols-3 gap-2">
<button @click="alignSprites('left')" class="btn btn-secondary btn-sm" title="Left">
<i class="fas fa-arrow-left"></i>
</button>
<button @click="alignSprites('center')" class="btn btn-secondary btn-sm" title="Center">
<i class="fas fa-arrows-left-right"></i>
</button>
<button @click="alignSprites('right')" class="btn btn-secondary btn-sm" title="Right">
<i class="fas fa-arrow-right"></i>
</button>
<button @click="alignSprites('top')" class="btn btn-secondary btn-sm" title="Top">
<i class="fas fa-arrow-up"></i>
</button>
<button @click="alignSprites('middle')" class="btn btn-secondary btn-sm" title="Middle">
<i class="fas fa-arrows-up-down"></i>
</button>
<button @click="alignSprites('bottom')" class="btn btn-secondary btn-sm" title="Bottom">
<i class="fas fa-arrow-down"></i>
</button>
</div>
</section>
<!-- Export -->
<section>
<h3 class="flex items-center gap-2 mb-3 text-base font-bold text-gray-800 dark:text-gray-100">
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-download"></i>
Export
</h3>
<div class="grid grid-cols-2 gap-2">
<button @click="downloadSpritesheet" class="btn btn-dark btn-sm" data-rybbit-event="download-spritesheet">
<i class="fas fa-image"></i>
<span>PNG</span>
</button>
<button @click="exportSpritesheetJSON" class="btn btn-dark btn-sm" data-rybbit-event="export-json">
<i class="fas fa-file-code"></i>
<span>JSON</span>
</button>
<button @click="openGifFpsModal" class="btn btn-dark btn-sm" data-rybbit-event="download-gif">
<i class="fas fa-film"></i>
<span>GIF</span>
</button>
<button @click="downloadAsZip" class="btn btn-dark btn-sm" data-rybbit-event="download-zip">
<i class="fas fa-file-archive"></i>
<span>ZIP</span>
</button>
</div>
</section>
</div>
</div>
<!-- Right panel - Tabs -->
<div class="flex flex-col overflow-hidden">
<!-- Tab Navigation -->
<div class="bg-gray-50/50 dark:bg-gray-900/30 border-b border-gray-200 dark:border-gray-700">
<div class="flex gap-1 p-2">
<button
@click="activeTab = 'canvas'"
class="border-gray-600 border"
:class="[
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all cursor-pointer',
activeTab === 'canvas' ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-600 dark:text-gray-400 hover:bg-white/50 dark:hover:bg-gray-800/50',
]"
>
<i class="fas fa-th"></i>
<span>Canvas</span>
</button>
<button
@click="activeTab = 'preview'"
class="border-gray-600 border"
:class="[
'flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all cursor-pointer',
activeTab === 'preview' ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-600 dark:text-gray-400 hover:bg-white/50 dark:hover:bg-gray-800/50',
]"
data-rybbit-event="preview-animation"
>
<i class="fas fa-play"></i>
<span>Preview</span>
</button>
</div>
</div>
<!-- Tab Content -->
<div class="p-6 lg:flex-1 lg:overflow-auto">
<div v-if="activeTab === 'canvas'">
<sprite-canvas :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-cell="updateSpriteCell" @remove-sprite="removeSprite" @replace-sprite="replaceSprite" @add-sprite="addSprite" />
</div>
<div v-if="activeTab === 'preview'">
<sprite-preview :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-in-layer="updateSpriteInLayer" />
</div>
</div>
</div>
</div>
</main>
<router-view />
</div>
<HelpModal :is-open="isHelpModalOpen" @close="closeHelpModal" />
<FeedbackModal :is-open="isFeedbackModalOpen" @close="closeFeedbackModal" />
<SpritesheetSplitter :is-open="isSpritesheetSplitterOpen" :image-url="spritesheetImageUrl" :image-file="spritesheetImageFile" @close="closeSpritesheetSplitter" @split="handleSplitSpritesheet" />
<GifFpsModal :is-open="isGifFpsModalOpen" @close="closeGifFpsModal" @confirm="downloadAsGif" :default-fps="10" />
<!-- One-time feedback popup -->
<div v-if="showFeedbackPopup" class="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm">
@@ -354,111 +65,18 @@
</template>
<script setup lang="ts">
import { ref, onMounted, toRef, computed } from 'vue';
import FileUploader from './components/FileUploader.vue';
import SpriteCanvas from './components/SpriteCanvas.vue';
import SpritePreview from './components/SpritePreview.vue';
import { ref, onMounted } from 'vue';
import { RouterView, RouterLink } from 'vue-router';
import HelpModal from './components/HelpModal.vue';
import FeedbackModal from './components/FeedbackModal.vue';
import SpritesheetSplitter from './components/SpritesheetSplitter.vue';
import GifFpsModal from './components/GifFpsModal.vue';
import DarkModeToggle from './components/utilities/DarkModeToggle.vue';
import { useExportLayers } from './composables/useExportLayers';
import { useLayers } from './composables/useLayers';
import { getMaxDimensionsAcrossLayers } from './composables/useLayers';
import { useSettingsStore } from './stores/useSettingsStore';
import { calculateNegativeSpacing } from './composables/useNegativeSpacing';
import type { SpriteFile } from './types/sprites';
const settingsStore = useSettingsStore();
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteInLayer, updateSpriteCell, removeSprite, replaceSprite, addSprite, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer } = useLayers();
const { layers } = useLayers();
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExportLayers(
layers,
columns,
toRef(settingsStore, 'negativeSpacingEnabled'),
activeLayerId,
toRef(settingsStore, 'backgroundColor'),
toRef(settingsStore, 'manualCellSizeEnabled'),
toRef(settingsStore, 'manualCellWidth'),
toRef(settingsStore, 'manualCellHeight')
);
const getCellSize = () => {
if (!visibleLayers.value.length) return { width: 0, height: 0 };
if (settingsStore.manualCellSizeEnabled) {
return { width: settingsStore.manualCellWidth, height: settingsStore.manualCellHeight };
}
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers.value);
const allSprites = visibleLayers.value.flatMap(l => l.sprites);
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
return { width: maxWidth + negativeSpacing, height: maxHeight + negativeSpacing };
};
const cellSize = computed(getCellSize);
const activeTab = ref<'canvas' | 'preview'>('canvas');
const isHelpModalOpen = ref(false);
const isFeedbackModalOpen = ref(false);
const isSpritesheetSplitterOpen = ref(false);
const isGifFpsModalOpen = ref(false);
const uploadInput = ref<HTMLInputElement | null>(null);
const jsonFileInput = ref<HTMLInputElement | null>(null);
const spritesheetImageUrl = ref('');
const spritesheetImageFile = ref<File | null>(null);
const showFeedbackPopup = ref(false);
const editingLayerId = ref<string | null>(null);
const editingLayerName = ref('');
const layerNameInput = ref<HTMLInputElement | null>(null);
const handleSpritesUpload = async (files: File[]) => {
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
if (jsonFile) {
await handleJSONImport(jsonFile);
return;
}
if (files.length === 1 && files[0].type.startsWith('image/')) {
const file = files[0];
const reader = new FileReader();
reader.onload = e => {
const url = e.target?.result as string;
const img = new Image();
img.onload = () => {
if (confirm('This looks like it might be a spritesheet. Would you like to split it into individual sprites?')) {
spritesheetImageUrl.value = url;
spritesheetImageFile.value = file;
isSpritesheetSplitterOpen.value = true;
return;
}
processImageFiles([file]);
};
img.onerror = () => {
console.error('Failed to load image:', file.name);
};
img.src = url;
};
reader.onerror = () => {
console.error('Failed to read image file:', file.name);
};
reader.readAsDataURL(file);
return;
}
processImageFiles(files);
};
const handleJSONImport = async (jsonFile: File) => {
try {
await importSpritesheetJSON(jsonFile);
} catch (error) {
console.error('Error importing JSON:', error);
alert('Failed to import JSON file. Please check the file format.');
}
};
const openHelpModal = () => {
isHelpModalOpen.value = true;
@@ -476,45 +94,6 @@
isFeedbackModalOpen.value = false;
};
const closeSpritesheetSplitter = () => {
isSpritesheetSplitterOpen.value = false;
if (spritesheetImageUrl.value && spritesheetImageUrl.value.startsWith('blob:')) {
try {
URL.revokeObjectURL(spritesheetImageUrl.value);
} catch {}
}
spritesheetImageUrl.value = '';
spritesheetImageFile.value = null;
};
const openGifFpsModal = () => {
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
alert('Please upload or import sprites before generating a GIF.');
return;
}
isGifFpsModalOpen.value = true;
};
const closeGifFpsModal = () => {
isGifFpsModalOpen.value = false;
};
const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => {
processImageFiles(spriteFiles.map(s => s.file));
};
const openJSONImportDialog = () => {
jsonFileInput.value?.click();
};
const handleJSONFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
await handleJSONImport(input.files[0]);
input.value = '';
}
};
onMounted(() => {
const hasShownFeedbackPopup = localStorage.getItem('hasShownFeedbackPopup');
if (!hasShownFeedbackPopup) {
@@ -532,42 +111,4 @@
openFeedbackModal();
}
};
const openFileDialog = () => {
uploadInput.value?.click();
};
const handleUploadChange = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
await handleSpritesUpload(Array.from(input.files));
input.value = '';
}
};
const startEditingLayer = (layerId: string, currentName: string) => {
editingLayerId.value = layerId;
editingLayerName.value = currentName;
// Focus the input on next tick
setTimeout(() => {
layerNameInput.value?.focus();
layerNameInput.value?.select();
}, 0);
};
const finishEditingLayer = () => {
if (editingLayerId.value && editingLayerName.value.trim()) {
const layer = layers.value.find(l => l.id === editingLayerId.value);
if (layer) {
layer.name = editingLayerName.value.trim();
}
}
editingLayerId.value = null;
editingLayerName.value = '';
};
const cancelEditingLayer = () => {
editingLayerId.value = null;
editingLayerName.value = '';
};
</script>

View File

@@ -90,4 +90,89 @@ html.dark {
.card {
@apply bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg;
}
/* Custom prose styles for blog content */
.prose {
@apply text-gray-700 dark:text-gray-300 leading-7;
}
.prose h1 {
@apply text-4xl font-bold text-gray-900 dark:text-white mb-4 mt-0;
}
.prose h2 {
@apply text-3xl font-bold text-gray-900 dark:text-white mt-8 mb-4;
}
.prose h3 {
@apply text-2xl font-bold text-gray-900 dark:text-white mt-6 mb-3;
}
.prose h4 {
@apply text-xl font-bold text-gray-900 dark:text-white mt-4 mb-2;
}
.prose p {
@apply text-gray-700 dark:text-gray-300 mb-4 leading-7;
}
.prose a {
@apply text-blue-600 dark:text-blue-400 underline hover:text-blue-800 dark:hover:text-blue-300;
}
.prose strong {
@apply text-gray-900 dark:text-white font-semibold;
}
.prose em {
@apply italic;
}
.prose code {
@apply text-sm font-mono bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 px-1 py-0.5 rounded;
}
.prose pre {
@apply bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 p-4 rounded-lg overflow-x-auto mb-4;
}
.prose pre code {
@apply bg-transparent p-0;
}
.prose ul {
@apply list-disc pl-6 mb-4 text-gray-700 dark:text-gray-300;
}
.prose ol {
@apply list-decimal pl-6 mb-4 text-gray-700 dark:text-gray-300;
}
.prose li {
@apply mb-2 leading-7;
}
.prose blockquote {
@apply border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-4;
}
.prose img {
@apply rounded-lg shadow-md my-6;
}
.prose hr {
@apply border-gray-300 dark:border-gray-700 my-8;
}
.prose table {
@apply border-collapse w-full my-6;
}
.prose th {
@apply bg-gray-100 dark:bg-gray-800 p-2 text-left font-semibold border border-gray-300 dark:border-gray-700;
}
.prose td {
@apply border border-gray-300 dark:border-gray-700 p-2;
}
}

23
src/blog/welcome.md Normal file
View File

@@ -0,0 +1,23 @@
---
title: 'Welcome to the Spritesheet generator blog'
date: '2025-11-26'
description: 'This is the first post on our new blog. Learn about how we built this tool.'
image: '/blog/1.png'
---
## Welcome!
We are excited to launch our new blog. Here we will share updates, tutorials, and tips on how to get the most out of the Sprite Sheet Generator.
### What is this tool?
This tool allows you to easily combine multiple images into a single sprite sheet. It's perfect for game developers and web designers.
### Features
- Drag and drop interface
- Customizable grid size
- Export to PNG and JSON
- Dark mode support
Stay tuned for more updates!

View File

@@ -20,9 +20,7 @@
<div class="space-y-6 w-full max-w-full overflow-hidden">
<div class="bg-cyan-50 dark:bg-cyan-900/20 rounded-lg p-3 border border-cyan-100 dark:border-cyan-800/50 flex items-start gap-3">
<i class="fas fa-info-circle text-cyan-600 dark:text-cyan-400 mt-0.5 flex-shrink-0"></i>
<p class="text-sm text-cyan-800 dark:text-cyan-200">
<span class="font-semibold">Tip:</span> Right-click any sprite to open the context menu for quick actions: add, replace, or remove sprites.
</p>
<p class="text-sm text-cyan-800 dark:text-cyan-200"><span class="font-semibold">Tip:</span> Right-click any sprite to open the context menu for quick actions: add, replace, or remove sprites.</p>
</div>
<section class="w-full bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-1 shadow-sm">
@@ -63,7 +61,11 @@
<div class="flex items-center gap-2 px-3 py-2">
<label for="bg-color" class="text-sm font-medium text-gray-600 dark:text-gray-400">Bg:</label>
<div class="flex items-center gap-2">
<select id="bg-color" v-model="bgSelectValue" class="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 dark:text-gray-200 text-sm focus:ring-2 focus:ring-blue-500 outline-none transition-all cursor-pointer hover:border-gray-400 dark:hover:border-gray-500">
<select
id="bg-color"
v-model="bgSelectValue"
class="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 dark:text-gray-200 text-sm focus:ring-2 focus:ring-blue-500 outline-none transition-all cursor-pointer hover:border-gray-400 dark:hover:border-gray-500"
>
<option value="transparent">Transparent</option>
<option value="#ffffff">White</option>
<option value="#000000">Black</option>

View File

@@ -110,7 +110,7 @@
<!-- Animation Settings -->
<div class="p-4 border-b border-gray-100 dark:border-gray-700 space-y-5">
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">Animation</h3>
<!-- Frame Navigation -->
<div class="space-y-2">
<div class="flex justify-between items-center">
@@ -148,16 +148,20 @@
<label class="flex items-center justify-between cursor-pointer group">
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 transition-colors">Pixel perfect</span>
<div class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="settingsStore.pixelPerfect" class="sr-only peer">
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<input type="checkbox" v-model="settingsStore.pixelPerfect" class="sr-only peer" />
<div
class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"
></div>
</div>
</label>
<label class="flex items-center justify-between cursor-pointer group">
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 transition-colors">Reposition mode</span>
<div class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="isDraggable" class="sr-only peer">
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<input type="checkbox" v-model="isDraggable" class="sr-only peer" />
<div
class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"
></div>
</div>
</label>
@@ -171,8 +175,10 @@
<label class="flex items-center justify-between cursor-pointer group">
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 transition-colors">Compare sprites</span>
<div class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="showAllSprites" class="sr-only peer">
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
<input type="checkbox" v-model="showAllSprites" class="sr-only peer" />
<div
class="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"
></div>
</div>
</label>
</div>
@@ -199,7 +205,13 @@
<div class="max-h-[150px] overflow-y-auto pr-1 custom-scrollbar">
<div class="space-y-1">
<div v-for="(sprite, index) in compositeFrames" :key="sprite.id" class="flex items-center gap-3 px-2 py-1.5 hover:bg-white dark:hover:bg-gray-700 cursor-pointer rounded-md transition-colors group" @click="toggleHiddenFrame(index)">
<input type="checkbox" :checked="!hiddenFrames.includes(index)" class="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" @click.stop @change="toggleHiddenFrame(index)" />
<input
type="checkbox"
:checked="!hiddenFrames.includes(index)"
class="w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
@click.stop
@change="toggleHiddenFrame(index)"
/>
<div class="w-8 h-8 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded flex items-center justify-center overflow-hidden flex-shrink-0 shadow-sm">
<img :src="sprite.url" class="max-w-full max-h-full object-contain" :style="settingsStore.pixelPerfect ? { 'image-rendering': 'pixelated' } : {}" />
</div>

View File

@@ -1,12 +1,5 @@
<template>
<button
@click="settingsStore.toggleDarkMode()"
class="btn btn-secondary mr-1"
aria-label="Toggle dark mode"
data-rybbit-event="toggle-dark-mode"
>
<i :class="settingsStore.darkMode ? 'fas fa-sun' : 'fas fa-moon'"></i> Theme
</button>
<button @click="settingsStore.toggleDarkMode()" class="btn btn-secondary mr-1" aria-label="Toggle dark mode" data-rybbit-event="toggle-dark-mode"><i :class="settingsStore.darkMode ? 'fas fa-sun' : 'fas fa-moon'"></i> Theme</button>
</template>
<script setup lang="ts">

View File

@@ -0,0 +1,44 @@
import matter from 'gray-matter';
export interface BlogPost {
slug: string;
title: string;
date: string;
description: string;
image: string;
content: string;
}
export function useBlog() {
const getPosts = async (): Promise<BlogPost[]> => {
const modules = import.meta.glob('../blog/*.md', { query: '?raw', import: 'default' });
const posts: BlogPost[] = [];
for (const path in modules) {
const content = (await modules[path]()) as string;
const { data, content: markdownContent } = matter(content);
const slug = path.split('/').pop()?.replace('.md', '') || '';
posts.push({
slug,
title: data.title,
date: data.date,
description: data.description,
image: data.image,
content: markdownContent,
});
}
return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
};
const getPost = async (slug: string): Promise<BlogPost | undefined> => {
const posts = await getPosts();
return posts.find(post => post.slug === slug);
};
return {
getPosts,
getPost,
};
}

View File

@@ -11,10 +11,11 @@ export const createEmptyLayer = (name: string): Layer => ({
locked: false,
});
const layers = ref<Layer[]>([createEmptyLayer('Base')]);
const activeLayerId = ref<string>(layers.value[0].id);
const columns = ref(4);
export const useLayers = () => {
const layers = ref<Layer[]>([createEmptyLayer('Base')]);
const activeLayerId = ref<string>(layers.value[0].id);
const columns = ref(4);
const settingsStore = useSettingsStore();
watch(columns, val => {
@@ -27,6 +28,8 @@ export const useLayers = () => {
const getMaxDimensions = (sprites: Sprite[]) => getMaxDimensionsSingle(sprites);
const visibleLayers = computed(() => layers.value.filter(l => l.visible));
const updateSpritePosition = (id: string, x: number, y: number) => {
const l = activeLayer.value;
if (!l) return;
@@ -217,8 +220,6 @@ export const useLayers = () => {
}
};
const visibleLayers = computed(() => layers.value.filter(l => l.visible));
return {
layers,
visibleLayers,

View File

@@ -1,11 +1,18 @@
import { Buffer } from 'buffer';
// @ts-ignore
window.Buffer = Buffer;
import './assets/main.css';
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount('#app');

47
src/router/index.ts Normal file
View File

@@ -0,0 +1,47 @@
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/about',
name: 'about',
component: () => import('../views/AboutUs.vue'),
},
{
path: '/contact',
name: 'contact',
component: () => import('../views/Contact.vue'),
},
{
path: '/privacy-policy',
name: 'privacy-policy',
component: () => import('../views/PrivacyPolicy.vue'),
},
{
path: '/blog',
name: 'blog-overview',
component: () => import('../views/BlogOverview.vue'),
},
{
path: '/blog/:slug',
name: 'blog-detail',
component: () => import('../views/BlogDetail.vue'),
},
],
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition;
} else {
return { top: 0 };
}
},
});
export default router;

17
src/views/AboutUs.vue Normal file
View File

@@ -0,0 +1,17 @@
<template>
<div class="w-full">
<div class="bg-white/80 dark:bg-gray-900/80 backdrop-blur-md rounded-3xl shadow-2xl border border-gray-200/50 dark:border-gray-700/50 p-8 sm:p-12">
<h1 class="text-3xl sm:text-4xl font-extrabold text-gray-900 dark:text-white mb-6">About Us</h1>
<div class="space-y-6">
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">Welcome to Spritesheet Generator, a tool designed to help game developers and artists streamline their workflow.</p>
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">
Our mission is to provide a simple, powerful, and free tool for creating optimized spritesheets directly in your browser. Whether you are an indie developer, a hobbyist, or part of a large studio, we hope this tool makes your life easier.
</p>
<div class="space-y-3">
<h3 class="text-xl font-bold text-gray-900 dark:text-white">Our Story</h3>
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">This project started as a small utility for personal game jams and has grown into a full-featured spritesheet packer. We believe in open source and community-driven development.</p>
</div>
</div>
</div>
</div>
</template>

44
src/views/BlogDetail.vue Normal file
View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useBlog, type BlogPost } from '../composables/useBlog';
import { marked } from 'marked';
const route = useRoute();
const router = useRouter();
const { getPost } = useBlog();
const post = ref<BlogPost | undefined>(undefined);
const renderedContent = ref('');
onMounted(async () => {
const slug = route.params.slug as string;
post.value = await getPost(slug);
if (post.value) {
renderedContent.value = await marked(post.value.content);
} else {
router.push({ name: 'blog-overview' });
}
});
</script>
<template>
<div class="w-full">
<div v-if="post">
<RouterLink :to="{ name: 'blog-overview' }" class="inline-flex items-center text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white mb-6 transition-colors">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back to overview
</RouterLink>
<h1 class="text-4xl font-bold mb-4 text-gray-900 dark:text-white">{{ post.title }}</h1>
<p class="text-gray-600 dark:text-gray-400 text-sm mb-8">{{ post.date }}</p>
<!-- Image is intentionally omitted here as per requirements -->
<div class="prose max-w-none" v-html="renderedContent"></div>
</div>
<div v-else class="text-center py-12">
<p class="text-xl text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useBlog, type BlogPost } from '../composables/useBlog';
import { RouterLink } from 'vue-router';
const { getPosts } = useBlog();
const posts = ref<BlogPost[]>([]);
onMounted(async () => {
posts.value = await getPosts();
});
</script>
<template>
<div class="w-full">
<h1 class="text-4xl font-bold mb-8 text-gray-900 dark:text-white">Blog</h1>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<article v-for="post in posts" :key="post.slug" class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300 flex flex-col h-full">
<RouterLink :to="{ name: 'blog-detail', params: { slug: post.slug } }" class="flex flex-col h-full">
<img :src="post.image" :alt="post.title" class="w-full h-48 object-cover" />
<div class="p-6 flex-1 flex flex-col">
<h2 class="text-xl font-bold mb-2 text-gray-900 dark:text-white line-clamp-2">{{ post.title }}</h2>
<p class="text-gray-600 dark:text-gray-400 text-xs mb-3">{{ post.date }}</p>
<p class="text-gray-700 dark:text-gray-300 text-sm line-clamp-3 flex-1">{{ post.description }}</p>
</div>
</RouterLink>
</article>
</div>
</div>
</template>

27
src/views/Contact.vue Normal file
View File

@@ -0,0 +1,27 @@
<template>
<div class="w-full">
<div class="bg-white/80 dark:bg-gray-900/80 backdrop-blur-md rounded-3xl shadow-2xl border border-gray-200/50 dark:border-gray-700/50 p-8 sm:p-12">
<h1 class="text-3xl sm:text-4xl font-extrabold text-gray-900 dark:text-white mb-6">Contact Us</h1>
<div class="space-y-6">
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">We'd love to hear from you! Whether you have a question, feedback, or just want to say hi, feel free to reach out.</p>
<div class="flex flex-col gap-4 mt-8">
<a href="https://discord.gg/JTev3nzeDa" target="_blank" class="flex items-center gap-3 p-4 bg-gray-100 dark:bg-gray-800 rounded-xl hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors no-underline">
<i class="fab fa-discord text-2xl text-[#5865F2]"></i>
<div>
<div class="font-bold text-gray-900 dark:text-white">Join our Discord</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Chat with the community and developers</div>
</div>
</a>
<a href="https://gitea.adhd.sh/root/spritesheet-generator" target="_blank" class="flex items-center gap-3 p-4 bg-gray-100 dark:bg-gray-800 rounded-xl hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors no-underline">
<i class="fab fa-github text-2xl text-gray-900 dark:text-white"></i>
<div>
<div class="font-bold text-gray-900 dark:text-white">Source Code</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Report bugs or contribute on Gitea</div>
</div>
</a>
</div>
</div>
</div>
</div>
</template>

425
src/views/HomeView.vue Normal file
View File

@@ -0,0 +1,425 @@
<template>
<main class="flex flex-col flex-1 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md rounded-3xl shadow-2xl border border-gray-200/50 dark:border-gray-700/50 transition-all duration-300" :class="{ 'lg:overflow-hidden': layers.some(l => l.sprites.length) }">
<!-- Welcome state -->
<div v-if="!layers.some(l => l.sprites.length)" class="p-6 sm:p-10">
<div class="mb-8">
<h2 class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-1">Upload sprites or single image</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">Drag and drop images or import from JSON</p>
</div>
<file-uploader @upload-sprites="handleSpritesUpload" />
<div class="mt-10">
<div class="prose prose-lg dark:prose-invert max-w-none">
<div>
<h3 class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-4">Welcome to Spritesheet generator</h3>
<p class="text-gray-700 dark:text-gray-300 mb-4">Create spritesheets for your game development and animation projects with our completely free, open-source Spritesheet generator.</p>
<p class="text-gray-700 dark:text-gray-300 mb-6">This powerful online tool lets you upload individual sprite images and automatically arrange them into optimized sprite sheets with customizable layouts - perfect for indie developers, animators, and studios of any size.</p>
<h3 class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-4">Key features of this sprite editor</h3>
<ul class="text-gray-700 dark:text-gray-300 mb-6 space-y-2 list-disc">
<li><strong>Free sprite editor</strong>: Edit, organize, and optimize your game sprites directly in your browser</li>
<li><strong>Automatic spritesheet generation</strong>: Convert multiple PNG, JPG, or GIF images into efficient sprite atlases</li>
<li><strong>Customizable grid layouts</strong>: Adjust spacing, padding, and arrangement for pixel-perfect results</li>
<li><strong>Animation preview</strong>: Test your sprite animations before exporting</li>
<li><strong>Cross-platform compatibility</strong>: Works with Unity, Godot, Phaser, Pygame, and other game engines</li>
<li><strong>Zero installation required</strong>: No downloads - use our web-based sprite sheet maker instantly</li>
<li><strong>Batch processing</strong>: Upload and process multiple sprites simultaneously</li>
<li><strong>Export options</strong>: Download spritesheet as PNG, JPG, GIF, ZIP or JSON.</li>
</ul>
<div>
<h4 class="text-xl font-bold text-gray-800 dark:text-gray-100 mb-4 flex items-center gap-2">
<i class="fas fa-play-circle text-gray-800 dark:text-gray-200"></i>
How it works
</h4>
<video controls playsinline class="w-full rounded-lg shadow-lg border border-gray-200 dark:border-gray-700" title="Spritesheet generator tutorial" aria-label="Spritesheet generator tutorial">
<source src="@/assets/demo.mp4" type="video/mp4" />
</video>
</div>
</div>
</div>
</div>
</div>
<!-- Two-column layout: Left controls, Right preview -->
<div v-if="layers.some(l => l.sprites.length)" class="flex flex-col flex-1 lg:grid lg:grid-cols-[380px_1fr] xl:grid-cols-[420px_1fr] lg:overflow-hidden">
<!-- Left sidebar - Controls -->
<div class="p-6 bg-gray-50/50 dark:bg-gray-900/30 border-b lg:border-b-0 lg:border-r border-gray-200 dark:border-gray-700 lg:overflow-y-auto lg:overflow-x-auto">
<div class="space-y-8">
<!-- Upload Section -->
<section>
<div class="flex items-center justify-between mb-3">
<h3 class="flex items-center gap-2 text-base font-bold text-gray-800 dark:text-gray-100">
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-upload"></i>
Upload
</h3>
<button @click="openJSONImportDialog" class="btn btn-dark btn-sm" data-rybbit-event="import-json">
<i class="text-xs fas fa-file-import"></i>
<span>JSON</span>
</button>
</div>
<button
class="w-full p-6 text-center border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl hover:border-gray-500 dark:hover:border-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-all cursor-pointer focus:outline-none focus:ring-2 focus:ring-gray-500 group"
@click="openFileDialog"
>
<i class="fas fa-plus-circle text-3xl text-gray-400 dark:text-gray-500 group-hover:text-gray-600 dark:group-hover:text-gray-300 mb-3 transition-colors"></i>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-gray-200 transition-colors">Add sprites</p>
</button>
<input ref="uploadInput" type="file" multiple accept="image/*" class="hidden" @change="handleUploadChange" />
<input ref="jsonFileInput" type="file" accept=".json,application/json" class="hidden" @change="handleJSONFileChange" />
</section>
<!-- Layers -->
<section>
<div class="flex items-center justify-between mb-3">
<h3 class="flex items-center gap-2 text-base font-bold text-gray-800 dark:text-gray-100">
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-layer-group"></i>
Layers
</h3>
<button @click="addLayer()" class="btn btn-dark btn-sm">
<i class="text-xs fas fa-plus"></i>
<span>Add</span>
</button>
</div>
<div class="space-y-2">
<div
v-for="layer in layers"
:key="layer.id"
class="flex items-center gap-2 px-3 py-2 bg-white dark:bg-gray-800 border rounded-lg transition-all"
:class="[layer.id === activeLayerId ? 'border-gray-800 ring-1 ring-gray-800 dark:border-gray-400 dark:ring-gray-400' : 'border-gray-200 dark:border-gray-700', !layer.visible ? 'opacity-50' : '']"
>
<button @click.stop="layer.visible = !layer.visible" class="btn btn-ghost btn-icon-sm rounded" :title="layer.visible ? 'Hide layer' : 'Show layer'">
<i :class="layer.visible ? 'text-sm text-gray-800 dark:text-gray-200 fas fa-eye' : 'text-sm text-gray-400 dark:text-gray-500 fas fa-eye-slash'"></i>
</button>
<input
v-if="editingLayerId === layer.id"
type="text"
v-model="editingLayerName"
@blur="finishEditingLayer"
@keyup.enter="finishEditingLayer"
@keyup.esc="cancelEditingLayer"
class="flex-1 px-2 py-1 text-sm border border-gray-800 dark:border-gray-400 dark:bg-gray-700 dark:text-gray-100 rounded outline-none focus:ring-2 focus:ring-gray-800 dark:focus:ring-gray-400"
ref="layerNameInput"
@click.stop
/>
<button v-else @click="activeLayerId = layer.id" class="flex-1 px-2 py-1 text-sm font-medium text-left rounded transition-all cursor-pointer" :class="layer.id === activeLayerId ? 'text-gray-900 dark:text-gray-100' : 'text-gray-700 dark:text-gray-300'">
{{ layer.name }}
<span v-if="layer.sprites.length" class="ml-1 text-xs opacity-60">({{ layer.sprites.length }})</span>
</button>
<button v-if="editingLayerId !== layer.id" @click="startEditingLayer(layer.id, layer.name)" class="btn btn-ghost btn-icon-xs rounded" title="Rename">
<i class="text-xs text-gray-600 dark:text-gray-400 fas fa-pen"></i>
</button>
<button @click="moveLayer(layer.id, 'up')" class="btn btn-ghost btn-icon-xs rounded" title="Move up">
<i class="text-xs text-gray-600 dark:text-gray-400 fas fa-chevron-up"></i>
</button>
<button @click="moveLayer(layer.id, 'down')" class="btn btn-ghost btn-icon-xs rounded" title="Move down">
<i class="text-xs text-gray-600 dark:text-gray-400 fas fa-chevron-down"></i>
</button>
<button v-if="layers.length > 1" @click="removeLayer(layer.id)" class="btn btn-danger btn-icon-xs rounded" title="Delete">
<i class="text-xs fas fa-trash"></i>
</button>
</div>
</div>
</section>
<!-- Grid Settings -->
<section>
<h3 class="flex items-center gap-2 mb-3 text-base font-bold text-gray-800 dark:text-gray-100">
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-th"></i>
Grid
</h3>
<div class="space-y-3">
<div class="flex items-center justify-between px-3 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
<label for="columns" class="text-sm font-medium text-gray-700 dark:text-gray-200">Columns</label>
<input id="columns" type="number" v-model.number="columns" min="1" max="10" class="input-field w-16" />
</div>
<div class="px-3 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
<label class="flex items-center justify-between mb-2 cursor-pointer">
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Manual size</span>
<input type="checkbox" v-model="settingsStore.manualCellSizeEnabled" class="w-4 h-4 rounded" />
</label>
<div v-if="settingsStore.manualCellSizeEnabled" class="flex items-center gap-1.5 mt-2">
<input type="number" v-model.number="settingsStore.manualCellWidth" min="1" max="2048" class="input-field w-full min-w-0" placeholder="W" />
<span class="flex-shrink-0 text-gray-500 dark:text-gray-400">×</span>
<input type="number" v-model.number="settingsStore.manualCellHeight" min="1" max="2048" class="input-field w-full min-w-0" placeholder="H" />
</div>
<div v-else class="mt-1 text-xs font-mono text-gray-500 dark:text-gray-400 break-words">{{ cellSize.width }} × {{ cellSize.height }}px</div>
</div>
</div>
</section>
<!-- Alignment -->
<section>
<h3 class="flex items-center gap-2 mb-3 text-base font-bold text-gray-800 dark:text-gray-100">
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-align-center"></i>
Align
</h3>
<div class="grid grid-cols-3 gap-2">
<button @click="alignSprites('left')" class="btn btn-secondary btn-sm" title="Left">
<i class="fas fa-arrow-left"></i>
</button>
<button @click="alignSprites('center')" class="btn btn-secondary btn-sm" title="Center">
<i class="fas fa-arrows-left-right"></i>
</button>
<button @click="alignSprites('right')" class="btn btn-secondary btn-sm" title="Right">
<i class="fas fa-arrow-right"></i>
</button>
<button @click="alignSprites('top')" class="btn btn-secondary btn-sm" title="Top">
<i class="fas fa-arrow-up"></i>
</button>
<button @click="alignSprites('middle')" class="btn btn-secondary btn-sm" title="Middle">
<i class="fas fa-arrows-up-down"></i>
</button>
<button @click="alignSprites('bottom')" class="btn btn-secondary btn-sm" title="Bottom">
<i class="fas fa-arrow-down"></i>
</button>
</div>
</section>
<!-- Export -->
<section>
<h3 class="flex items-center gap-2 mb-3 text-base font-bold text-gray-800 dark:text-gray-100">
<i class="text-sm text-gray-700 dark:text-gray-300 fas fa-download"></i>
Export
</h3>
<div class="grid grid-cols-2 gap-2">
<button @click="downloadSpritesheet" class="btn btn-dark btn-sm" data-rybbit-event="download-spritesheet">
<i class="fas fa-image"></i>
<span>PNG</span>
</button>
<button @click="exportSpritesheetJSON" class="btn btn-dark btn-sm" data-rybbit-event="export-json">
<i class="fas fa-file-code"></i>
<span>JSON</span>
</button>
<button @click="openGifFpsModal" class="btn btn-dark btn-sm" data-rybbit-event="download-gif">
<i class="fas fa-film"></i>
<span>GIF</span>
</button>
<button @click="downloadAsZip" class="btn btn-dark btn-sm" data-rybbit-event="download-zip">
<i class="fas fa-file-archive"></i>
<span>ZIP</span>
</button>
</div>
</section>
</div>
</div>
<!-- Right panel - Tabs -->
<div class="flex flex-col overflow-hidden">
<!-- Tab Navigation -->
<div class="bg-gray-50/50 dark:bg-gray-900/30 border-b border-gray-200 dark:border-gray-700">
<div class="flex gap-1 p-2">
<button
@click="activeTab = 'canvas'"
class="border-gray-600 border"
:class="['flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all cursor-pointer', activeTab === 'canvas' ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-600 dark:text-gray-400 hover:bg-white/50 dark:hover:bg-gray-800/50']"
>
<i class="fas fa-th"></i>
<span>Canvas</span>
</button>
<button
@click="activeTab = 'preview'"
class="border-gray-600 border"
:class="['flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all cursor-pointer', activeTab === 'preview' ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm' : 'text-gray-600 dark:text-gray-400 hover:bg-white/50 dark:hover:bg-gray-800/50']"
data-rybbit-event="preview-animation"
>
<i class="fas fa-play"></i>
<span>Preview</span>
</button>
</div>
</div>
<!-- Tab Content -->
<div class="p-6 lg:flex-1 lg:overflow-auto">
<div v-if="activeTab === 'canvas'">
<sprite-canvas :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-cell="updateSpriteCell" @remove-sprite="removeSprite" @replace-sprite="replaceSprite" @add-sprite="addSprite" />
</div>
<div v-if="activeTab === 'preview'">
<sprite-preview :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-in-layer="updateSpriteInLayer" />
</div>
</div>
</div>
</div>
<SpritesheetSplitter :is-open="isSpritesheetSplitterOpen" :image-url="spritesheetImageUrl" :image-file="spritesheetImageFile" @close="closeSpritesheetSplitter" @split="handleSplitSpritesheet" />
<GifFpsModal :is-open="isGifFpsModalOpen" @close="closeGifFpsModal" @confirm="downloadAsGif" :default-fps="10" />
</main>
</template>
<script setup lang="ts">
import { ref, toRef, computed } from 'vue';
import FileUploader from '@/components/FileUploader.vue';
import SpriteCanvas from '@/components/SpriteCanvas.vue';
import SpritePreview from '@/components/SpritePreview.vue';
import SpritesheetSplitter from '@/components/SpritesheetSplitter.vue';
import GifFpsModal from '@/components/GifFpsModal.vue';
import { useExportLayers } from '@/composables/useExportLayers';
import { useLayers } from '@/composables/useLayers';
import { getMaxDimensionsAcrossLayers } from '@/composables/useLayers';
import { useSettingsStore } from '@/stores/useSettingsStore';
import { calculateNegativeSpacing } from '@/composables/useNegativeSpacing';
import type { SpriteFile } from '@/types/sprites';
const settingsStore = useSettingsStore();
const { layers, visibleLayers, activeLayer, activeLayerId, columns, updateSpritePosition, updateSpriteInLayer, updateSpriteCell, removeSprite, replaceSprite, addSprite, processImageFiles, alignSprites, addLayer, removeLayer, moveLayer } = useLayers();
const { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip } = useExportLayers(
layers,
columns,
toRef(settingsStore, 'negativeSpacingEnabled'),
activeLayerId,
toRef(settingsStore, 'backgroundColor'),
toRef(settingsStore, 'manualCellSizeEnabled'),
toRef(settingsStore, 'manualCellWidth'),
toRef(settingsStore, 'manualCellHeight')
);
const getCellSize = () => {
if (!visibleLayers.value.length) return { width: 0, height: 0 };
if (settingsStore.manualCellSizeEnabled) {
return { width: settingsStore.manualCellWidth, height: settingsStore.manualCellHeight };
}
const { maxWidth, maxHeight } = getMaxDimensionsAcrossLayers(visibleLayers.value);
const allSprites = visibleLayers.value.flatMap(l => l.sprites);
const negativeSpacing = calculateNegativeSpacing(allSprites, settingsStore.negativeSpacingEnabled);
return { width: maxWidth + negativeSpacing, height: maxHeight + negativeSpacing };
};
const cellSize = computed(getCellSize);
const activeTab = ref<'canvas' | 'preview'>('canvas');
const isSpritesheetSplitterOpen = ref(false);
const isGifFpsModalOpen = ref(false);
const uploadInput = ref<HTMLInputElement | null>(null);
const jsonFileInput = ref<HTMLInputElement | null>(null);
const spritesheetImageUrl = ref('');
const spritesheetImageFile = ref<File | null>(null);
const editingLayerId = ref<string | null>(null);
const editingLayerName = ref('');
const layerNameInput = ref<HTMLInputElement | null>(null);
const handleSpritesUpload = async (files: File[]) => {
const jsonFile = files.find(file => file.type === 'application/json' || file.name.endsWith('.json'));
if (jsonFile) {
await handleJSONImport(jsonFile);
return;
}
if (files.length === 1 && files[0].type.startsWith('image/')) {
const file = files[0];
const reader = new FileReader();
reader.onload = e => {
const url = e.target?.result as string;
const img = new Image();
img.onload = () => {
if (confirm('This looks like it might be a spritesheet. Would you like to split it into individual sprites?')) {
spritesheetImageUrl.value = url;
spritesheetImageFile.value = file;
isSpritesheetSplitterOpen.value = true;
return;
}
processImageFiles([file]);
};
img.onerror = () => {
console.error('Failed to load image:', file.name);
};
img.src = url;
};
reader.onerror = () => {
console.error('Failed to read image file:', file.name);
};
reader.readAsDataURL(file);
return;
}
processImageFiles(files);
};
const handleJSONImport = async (jsonFile: File) => {
try {
await importSpritesheetJSON(jsonFile);
} catch (error) {
console.error('Error importing JSON:', error);
alert('Failed to import JSON file. Please check the file format.');
}
};
const closeSpritesheetSplitter = () => {
isSpritesheetSplitterOpen.value = false;
if (spritesheetImageUrl.value && spritesheetImageUrl.value.startsWith('blob:')) {
try {
URL.revokeObjectURL(spritesheetImageUrl.value);
} catch {}
}
spritesheetImageUrl.value = '';
spritesheetImageFile.value = null;
};
const openGifFpsModal = () => {
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
alert('Please upload or import sprites before generating a GIF.');
return;
}
isGifFpsModalOpen.value = true;
};
const closeGifFpsModal = () => {
isGifFpsModalOpen.value = false;
};
const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => {
processImageFiles(spriteFiles.map(s => s.file));
};
const openJSONImportDialog = () => {
jsonFileInput.value?.click();
};
const handleJSONFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
await handleJSONImport(input.files[0]);
input.value = '';
}
};
const openFileDialog = () => {
uploadInput.value?.click();
};
const handleUploadChange = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
await handleSpritesUpload(Array.from(input.files));
input.value = '';
}
};
const startEditingLayer = (layerId: string, currentName: string) => {
editingLayerId.value = layerId;
editingLayerName.value = currentName;
// Focus the input on next tick
setTimeout(() => {
layerNameInput.value?.focus();
layerNameInput.value?.select();
}, 0);
};
const finishEditingLayer = () => {
if (editingLayerId.value && editingLayerName.value.trim()) {
const layer = layers.value.find(l => l.id === editingLayerId.value);
if (layer) {
layer.name = editingLayerName.value.trim();
}
}
editingLayerId.value = null;
editingLayerName.value = '';
};
const cancelEditingLayer = () => {
editingLayerId.value = null;
editingLayerName.value = '';
};
</script>

View File

@@ -0,0 +1,35 @@
<template>
<div class="w-full">
<div class="bg-white/80 dark:bg-gray-900/80 backdrop-blur-md rounded-3xl shadow-2xl border border-gray-200/50 dark:border-gray-700/50 p-8 sm:p-12">
<h1 class="text-3xl sm:text-4xl font-extrabold text-gray-900 dark:text-white mb-6">Privacy Policy</h1>
<div class="space-y-6">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-8">Last updated: November 26, 2025</p>
<div class="space-y-3">
<h3 class="text-xl font-bold text-gray-900 dark:text-white">1. Introduction</h3>
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">We respect your privacy. This Spritesheet Generator is a client-side tool, meaning your images are processed directly in your browser and are not uploaded to our servers.</p>
</div>
<div class="space-y-3">
<h3 class="text-xl font-bold text-gray-900 dark:text-white">2. Data Collection</h3>
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">We do not collect any personal data or uploaded images. All processing happens locally on your device.</p>
</div>
<div class="space-y-3">
<h3 class="text-xl font-bold text-gray-900 dark:text-white">3. Local Storage</h3>
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">We use your browser's Local Storage to save your preferences (such as dark mode and grid settings) to improve your experience.</p>
</div>
<div class="space-y-3">
<h3 class="text-xl font-bold text-gray-900 dark:text-white">4. Third-Party Services</h3>
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">We may use third-party services for analytics or hosting (e.g., Cloudflare, Vercel) which may collect standard server logs.</p>
</div>
<div class="space-y-3">
<h3 class="text-xl font-bold text-gray-900 dark:text-white">5. Contact</h3>
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">If you have any questions about this Privacy Policy, please contact us via our Discord server.</p>
</div>
</div>
</div>
</div>
</template>

View File

@@ -68,5 +68,7 @@ export default {
}),
},
},
plugins: [],
plugins: [
require('@tailwindcss/typography'),
],
}