[FEAT] Add sharing function, UI enhancement
This commit is contained in:
39
package-lock.json
generated
39
package-lock.json
generated
@@ -535,9 +535,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@bufbuild/protobuf": {
|
"node_modules/@bufbuild/protobuf": {
|
||||||
"version": "2.10.1",
|
"version": "2.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.2.tgz",
|
||||||
"integrity": "sha512-ckS3+vyJb5qGpEYv/s1OebUHDi/xSNtfgw1wqKZo7MR9F2z+qXr0q5XagafAG/9O0QPVIUfST0smluYSTpYFkg==",
|
"integrity": "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||||
},
|
},
|
||||||
@@ -1996,9 +1996,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.2",
|
"version": "22.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
|
||||||
"integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==",
|
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2441,9 +2441,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/autoprefixer": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.22",
|
"version": "10.4.23",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
||||||
"integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==",
|
"integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2461,10 +2461,9 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"browserslist": "^4.27.0",
|
"browserslist": "^4.28.1",
|
||||||
"caniuse-lite": "^1.0.30001754",
|
"caniuse-lite": "^1.0.30001760",
|
||||||
"fraction.js": "^5.3.4",
|
"fraction.js": "^5.3.4",
|
||||||
"normalize-range": "^0.1.2",
|
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"postcss-value-parser": "^4.2.0"
|
"postcss-value-parser": "^4.2.0"
|
||||||
},
|
},
|
||||||
@@ -2506,9 +2505,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.6",
|
"version": "2.9.7",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.6.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.7.tgz",
|
||||||
"integrity": "sha512-v9BVVpOTLB59C9E7aSnmIF8h7qRsFpx+A2nugVMTszEOMcfjlZMsXRm4LF23I3Z9AJxc8ANpIvzbzONoX9VJlg==",
|
"integrity": "sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -3842,16 +3841,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/normalize-range": {
|
|
||||||
"version": "0.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
|
|
||||||
"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/npm-normalize-package-bin": {
|
"node_modules/npm-normalize-package-bin": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz",
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [1.9.0] - 2025-12-14
|
||||||
|
- You can now share spritesheets to edit them later or share them with others
|
||||||
|
|
||||||
## [1.8.0] - 2025-11-23
|
## [1.8.0] - 2025-11-23
|
||||||
- Fix context menu location
|
- Fix context menu location
|
||||||
- You can now reposition all sprites in current frame
|
- You can now reposition all sprites in current frame
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
google.com, pub-3715260871588949, DIRECT, f08c47fec0942fa0
|
|
||||||
119
src/components/ShareModal.vue
Normal file
119
src/components/ShareModal.vue
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<Modal :is-open="isOpen" @close="close" title="Share spritesheet" :initialWidth="500" :initialHeight="280">
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<div class="flex-1 overflow-auto p-4 space-y-4 dark:bg-gray-800">
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="loading" class="flex flex-col items-center justify-center py-8">
|
||||||
|
<i class="fas fa-circle-notch fa-spin text-3xl text-gray-400 dark:text-gray-500 mb-3"></i>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">Uploading spritesheet...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success state -->
|
||||||
|
<div v-else-if="shareUrl" class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">Your spritesheet is ready to share! Copy the link below:</p>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="text" :value="shareUrl" readonly class="flex-1 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 text-sm font-mono" @focus="($event.target as HTMLInputElement).select()" />
|
||||||
|
<button @click="copyToClipboard" class="px-4 py-2 rounded-lg bg-blue-500 hover:bg-blue-600 text-white text-sm font-medium transition-colors" :title="copied ? 'Copied!' : 'Copy to clipboard'">
|
||||||
|
<i :class="copied ? 'fas fa-check' : 'fas fa-copy'"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="copied" class="text-sm text-green-600 dark:text-green-400">Link copied to clipboard!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-else-if="error" class="space-y-4">
|
||||||
|
<p class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||||
|
<button @click="retry" class="px-4 py-2 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-100 text-sm font-medium transition-colors"><i class="fas fa-redo mr-2"></i>Try again</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Initial state (shouldn't normally be visible) -->
|
||||||
|
<div v-else class="flex flex-col items-center justify-center py-8">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">Preparing to share...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 p-3 flex justify-end">
|
||||||
|
<button type="button" class="px-4 py-2 rounded-lg bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-100" @click="close">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import Modal from './utilities/Modal.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isOpen: boolean;
|
||||||
|
shareFunction: () => Promise<{ id: string; url: string }>;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void }>();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const shareUrl = ref('');
|
||||||
|
const error = ref('');
|
||||||
|
const copied = ref(false);
|
||||||
|
|
||||||
|
const close = () => emit('close');
|
||||||
|
|
||||||
|
const performShare = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
shareUrl.value = '';
|
||||||
|
copied.value = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await props.shareFunction();
|
||||||
|
shareUrl.value = result.url;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to share spritesheet:', e);
|
||||||
|
error.value = 'Failed to share spritesheet. Please try again later.';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const retry = () => {
|
||||||
|
performShare();
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareUrl.value);
|
||||||
|
copied.value = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
copied.value = false;
|
||||||
|
}, 2000);
|
||||||
|
} catch {
|
||||||
|
// Fallback for older browsers
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.value = shareUrl.value;
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(input);
|
||||||
|
copied.value = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
copied.value = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start sharing when modal opens
|
||||||
|
watch(
|
||||||
|
() => props.isOpen,
|
||||||
|
isOpen => {
|
||||||
|
if (isOpen) {
|
||||||
|
performShare();
|
||||||
|
} else {
|
||||||
|
// Reset state when closing
|
||||||
|
loading.value = false;
|
||||||
|
shareUrl.value = '';
|
||||||
|
error.value = '';
|
||||||
|
copied.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="spritesheet-preview w-full h-full">
|
<div class="spritesheet-preview w-full h-full">
|
||||||
<div class="flex flex-col lg:flex-row gap-4 h-full">
|
<div class="flex flex-col lg:flex-row gap-4 h-full min-h-0">
|
||||||
<div class="flex-1 min-w-0 flex flex-col">
|
<div class="flex-1 min-w-0 flex flex-col min-h-0">
|
||||||
<div
|
<div
|
||||||
class="relative bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg overflow-auto flex-1 min-h-[300px] max-h-[calc(100vh-12rem)] shadow-sm hover:shadow-md transition-shadow duration-200"
|
class="relative bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg overflow-auto flex-1 min-h-[200px] sm:min-h-[300px] max-h-[50vh] lg:max-h-none shadow-sm hover:shadow-md transition-shadow duration-200"
|
||||||
@mousemove="drag"
|
@mousemove="drag"
|
||||||
@mouseup="stopDrag"
|
@mouseup="stopDrag"
|
||||||
@mouseleave="stopDrag"
|
@mouseleave="stopDrag"
|
||||||
@@ -82,10 +82,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lg:w-80 xl:w-96 flex-shrink-0">
|
<div class="lg:w-80 xl:w-96 flex-shrink-0 lg:h-full lg:min-h-0 flex flex-col">
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden flex-1 flex flex-col lg:overflow-y-auto">
|
||||||
<!-- Playback Controls -->
|
<!-- Playback Controls -->
|
||||||
<div class="p-4 border-b border-gray-100 dark:border-gray-700">
|
<div class="p-4 border-b border-gray-100 dark:border-gray-700 flex-shrink-0">
|
||||||
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">Playback</h3>
|
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">Playback</h3>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button @click="togglePlayback" class="flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2.5 rounded-lg transition-all cursor-pointer flex-1 shadow-sm active:scale-95">
|
<button @click="togglePlayback" class="flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2.5 rounded-lg transition-all cursor-pointer flex-1 shadow-sm active:scale-95">
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Animation Settings -->
|
<!-- Animation Settings -->
|
||||||
<div class="p-4 border-b border-gray-100 dark:border-gray-700 space-y-5">
|
<div class="p-4 border-b border-gray-100 dark:border-gray-700 space-y-5 flex-shrink-0">
|
||||||
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">Animation</h3>
|
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">Animation</h3>
|
||||||
|
|
||||||
<!-- Frame Navigation -->
|
<!-- Frame Navigation -->
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- View Options -->
|
<!-- View Options -->
|
||||||
<div class="p-4 space-y-5">
|
<div class="p-4 space-y-5 flex-1">
|
||||||
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">View Options</h3>
|
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-1">View Options</h3>
|
||||||
|
|
||||||
<!-- Zoom Control -->
|
<!-- Zoom Control -->
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Current frame offset display -->
|
<!-- Current frame offset display -->
|
||||||
<div v-if="currentFrameSprite" class="px-4 pb-4">
|
<div v-if="currentFrameSprite" class="px-4 pb-4 flex-shrink-0">
|
||||||
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-100 dark:border-gray-700 flex items-center justify-between">
|
<div class="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-100 dark:border-gray-700 flex items-center justify-between">
|
||||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Offset</span>
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Offset</span>
|
||||||
<span class="text-xs font-mono font-bold text-gray-700 dark:text-gray-200">X: {{ currentFrameSprite.x }} <span class="text-gray-300 dark:text-gray-600 mx-1">|</span> Y: {{ currentFrameSprite.y }}</span>
|
<span class="text-xs font-mono font-bold text-gray-700 dark:text-gray-200">X: {{ currentFrameSprite.x }} <span class="text-gray-300 dark:text-gray-600 mx-1">|</span> Y: {{ currentFrameSprite.y }}</span>
|
||||||
@@ -193,7 +193,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Frame Selection (when Compare sprites is enabled) -->
|
<!-- Frame Selection (when Compare sprites is enabled) -->
|
||||||
<div v-if="showAllSprites" class="border-t border-gray-100 dark:border-gray-700 p-4">
|
<div v-if="showAllSprites" class="border-t border-gray-100 dark:border-gray-700 p-4 flex-shrink-0">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Visible Frames</h3>
|
<h3 class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider">Visible Frames</h3>
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
@@ -513,4 +513,36 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar for controls panel */
|
||||||
|
.custom-scrollbar::-webkit-scrollbar,
|
||||||
|
.lg\:overflow-y-auto::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track,
|
||||||
|
.lg\:overflow-y-auto::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb,
|
||||||
|
.lg\:overflow-y-auto::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(156, 163, 175, 0.5);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover,
|
||||||
|
.lg\:overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(156, 163, 175, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .custom-scrollbar::-webkit-scrollbar-thumb,
|
||||||
|
:global(.dark) .lg\:overflow-y-auto::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(75, 85, 99, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .custom-scrollbar::-webkit-scrollbar-thumb:hover,
|
||||||
|
:global(.dark) .lg\:overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(75, 85, 99, 0.8);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
135
src/composables/useShare.ts
Normal file
135
src/composables/useShare.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import type { Ref } from 'vue';
|
||||||
|
import type { Layer } from '@/types/sprites';
|
||||||
|
|
||||||
|
const POCKETBASE_URL = 'https://pb1.adhd.sh';
|
||||||
|
const COLLECTION = 'spritesheets';
|
||||||
|
|
||||||
|
export interface SpritesheetConfig {
|
||||||
|
version: number;
|
||||||
|
columns: number;
|
||||||
|
negativeSpacingEnabled: boolean;
|
||||||
|
backgroundColor: string;
|
||||||
|
manualCellSizeEnabled: boolean;
|
||||||
|
manualCellWidth: number;
|
||||||
|
manualCellHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpritesheetRecord {
|
||||||
|
id: string;
|
||||||
|
config: SpritesheetConfig;
|
||||||
|
sprites: any[];
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareResult {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the shareable URL for a spritesheet
|
||||||
|
*/
|
||||||
|
export const buildShareUrl = (id: string): string => {
|
||||||
|
return `${window.location.origin}/share/${id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share a spritesheet by uploading to PocketBase
|
||||||
|
*/
|
||||||
|
export const shareSpritesheet = async (layersRef: Ref<Layer[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>): Promise<ShareResult> => {
|
||||||
|
// Build layers data with base64 sprites (same format as exportSpritesheetJSON)
|
||||||
|
const layersData = await Promise.all(
|
||||||
|
layersRef.value.map(async layer => {
|
||||||
|
const sprites = await Promise.all(
|
||||||
|
layer.sprites.map(async sprite => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
canvas.width = sprite.width;
|
||||||
|
canvas.height = sprite.height;
|
||||||
|
ctx.drawImage(sprite.img, 0, 0);
|
||||||
|
const base64 = canvas.toDataURL('image/png');
|
||||||
|
return {
|
||||||
|
id: sprite.id,
|
||||||
|
width: sprite.width,
|
||||||
|
height: sprite.height,
|
||||||
|
x: sprite.x,
|
||||||
|
y: sprite.y,
|
||||||
|
base64,
|
||||||
|
name: sprite.file.name,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: layer.id,
|
||||||
|
name: layer.name,
|
||||||
|
visible: layer.visible,
|
||||||
|
locked: layer.locked,
|
||||||
|
sprites: sprites.filter(Boolean),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const config: SpritesheetConfig = {
|
||||||
|
version: 2,
|
||||||
|
columns: columns.value,
|
||||||
|
negativeSpacingEnabled: negativeSpacingEnabled.value,
|
||||||
|
backgroundColor: backgroundColor?.value || 'transparent',
|
||||||
|
manualCellSizeEnabled: manualCellSizeEnabled?.value || false,
|
||||||
|
manualCellWidth: manualCellWidth?.value || 64,
|
||||||
|
manualCellHeight: manualCellHeight?.value || 64,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${POCKETBASE_URL}/api/collections/${COLLECTION}/records`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
config,
|
||||||
|
sprites: layersData,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
throw new Error(text || `Request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await response.json();
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
url: buildShareUrl(record.id),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a shared spritesheet from PocketBase
|
||||||
|
*/
|
||||||
|
export const fetchSpritesheet = async (id: string): Promise<SpritesheetRecord> => {
|
||||||
|
const response = await fetch(`${POCKETBASE_URL}/api/collections/${COLLECTION}/records/${id}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new Error('Spritesheet not found');
|
||||||
|
}
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
throw new Error(text || `Request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable hook for share functionality
|
||||||
|
*/
|
||||||
|
export const useShare = (layersRef: Ref<Layer[]>, columns: Ref<number>, negativeSpacingEnabled: Ref<boolean>, backgroundColor?: Ref<string>, manualCellSizeEnabled?: Ref<boolean>, manualCellWidth?: Ref<number>, manualCellHeight?: Ref<number>) => {
|
||||||
|
const share = () => shareSpritesheet(layersRef, columns, negativeSpacingEnabled, backgroundColor, manualCellSizeEnabled, manualCellWidth, manualCellHeight);
|
||||||
|
|
||||||
|
return {
|
||||||
|
share,
|
||||||
|
fetchSpritesheet,
|
||||||
|
buildShareUrl,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -39,6 +39,11 @@ const router = createRouter({
|
|||||||
name: 'blog-detail',
|
name: 'blog-detail',
|
||||||
component: () => import('../views/BlogDetail.vue'),
|
component: () => import('../views/BlogDetail.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/share/:id',
|
||||||
|
name: 'share',
|
||||||
|
component: () => import('../views/ShareView.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
scrollBehavior(to, from, savedPosition) {
|
scrollBehavior(to, from, savedPosition) {
|
||||||
if (savedPosition) {
|
if (savedPosition) {
|
||||||
|
|||||||
@@ -45,9 +45,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Two-column layout: Left controls, Right preview -->
|
<!-- 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">
|
<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 min-h-0">
|
||||||
<!-- Left sidebar - Controls -->
|
<!-- 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="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 lg:min-h-0">
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<!-- Upload Section -->
|
<!-- Upload Section -->
|
||||||
<section>
|
<section>
|
||||||
@@ -203,20 +203,28 @@
|
|||||||
<i class="fas fa-file-archive"></i>
|
<i class="fas fa-file-archive"></i>
|
||||||
<span>ZIP</span>
|
<span>ZIP</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button @click="openShareModal" class="btn btn-dark btn-sm col-span-2" data-rybbit-event="share-spritesheet">
|
||||||
|
<i class="fas fa-share-alt"></i>
|
||||||
|
<span>Share</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right panel - Tabs -->
|
<!-- Right panel - Tabs -->
|
||||||
<div class="flex flex-col overflow-hidden">
|
<div class="flex flex-col overflow-hidden min-h-0">
|
||||||
<!-- Tab Navigation -->
|
<!-- Tab Navigation -->
|
||||||
<div class="bg-gray-50/50 dark:bg-gray-900/30 border-b border-gray-200 dark:border-gray-700">
|
<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">
|
<div class="flex items-center justify-between gap-1 p-2">
|
||||||
|
<div class="flex gap-1">
|
||||||
<button
|
<button
|
||||||
@click="activeTab = 'canvas'"
|
@click="activeTab = 'canvas'"
|
||||||
class="border-gray-600 border"
|
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']"
|
: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>
|
<i class="fas fa-th"></i>
|
||||||
<span>Canvas</span>
|
<span>Canvas</span>
|
||||||
@@ -224,21 +232,29 @@
|
|||||||
<button
|
<button
|
||||||
@click="activeTab = 'preview'"
|
@click="activeTab = 'preview'"
|
||||||
class="border-gray-600 border"
|
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']"
|
: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"
|
data-rybbit-event="preview-animation"
|
||||||
>
|
>
|
||||||
<i class="fas fa-play"></i>
|
<i class="fas fa-play"></i>
|
||||||
<span>Preview</span>
|
<span>Preview</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button @click="openShareModal" class="flex items-center gap-2 px-4 py-2 mr-2.5 text-sm font-medium rounded-lg bg-gradient-to-r from-blue-500 to-purple-600 text-white shadow-sm hover:shadow-md transition-all cursor-pointer" data-rybbit-event="share-spritesheet-header">
|
||||||
|
<i class="fas fa-share-alt"></i>
|
||||||
|
<span>Share spritesheet</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab Content -->
|
<!-- Tab Content -->
|
||||||
<div class="p-6 lg:flex-1 lg:overflow-auto">
|
<div class="p-6 lg:flex-1 lg:overflow-auto lg:min-h-0">
|
||||||
<div v-if="activeTab === 'canvas'">
|
<div v-if="activeTab === 'canvas'" class="h-full">
|
||||||
<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" />
|
<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>
|
||||||
<div v-if="activeTab === 'preview'">
|
<div v-if="activeTab === 'preview'" class="h-full">
|
||||||
<sprite-preview :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-in-layer="updateSpriteInLayer" />
|
<sprite-preview :layers="layers" :active-layer-id="activeLayerId" :columns="columns" @update-sprite="updateSpritePosition" @update-sprite-in-layer="updateSpriteInLayer" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,6 +263,7 @@
|
|||||||
|
|
||||||
<SpritesheetSplitter :is-open="isSpritesheetSplitterOpen" :image-url="spritesheetImageUrl" :image-file="spritesheetImageFile" @close="closeSpritesheetSplitter" @split="handleSplitSpritesheet" />
|
<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" />
|
<GifFpsModal :is-open="isGifFpsModalOpen" @close="closeGifFpsModal" @confirm="downloadAsGif" :default-fps="10" />
|
||||||
|
<ShareModal :is-open="isShareModalOpen" :share-function="shareFunction" @close="closeShareModal" />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -257,7 +274,9 @@
|
|||||||
import SpritePreview from '@/components/SpritePreview.vue';
|
import SpritePreview from '@/components/SpritePreview.vue';
|
||||||
import SpritesheetSplitter from '@/components/SpritesheetSplitter.vue';
|
import SpritesheetSplitter from '@/components/SpritesheetSplitter.vue';
|
||||||
import GifFpsModal from '@/components/GifFpsModal.vue';
|
import GifFpsModal from '@/components/GifFpsModal.vue';
|
||||||
|
import ShareModal from '@/components/ShareModal.vue';
|
||||||
import { useExportLayers } from '@/composables/useExportLayers';
|
import { useExportLayers } from '@/composables/useExportLayers';
|
||||||
|
import { useShare } from '@/composables/useShare';
|
||||||
import { useLayers } from '@/composables/useLayers';
|
import { useLayers } from '@/composables/useLayers';
|
||||||
import { getMaxDimensionsAcrossLayers } from '@/composables/useLayers';
|
import { getMaxDimensionsAcrossLayers } from '@/composables/useLayers';
|
||||||
import { useSettingsStore } from '@/stores/useSettingsStore';
|
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||||
@@ -299,6 +318,7 @@
|
|||||||
const activeTab = ref<'canvas' | 'preview'>('canvas');
|
const activeTab = ref<'canvas' | 'preview'>('canvas');
|
||||||
const isSpritesheetSplitterOpen = ref(false);
|
const isSpritesheetSplitterOpen = ref(false);
|
||||||
const isGifFpsModalOpen = ref(false);
|
const isGifFpsModalOpen = ref(false);
|
||||||
|
const isShareModalOpen = ref(false);
|
||||||
const uploadInput = ref<HTMLInputElement | null>(null);
|
const uploadInput = ref<HTMLInputElement | null>(null);
|
||||||
const jsonFileInput = ref<HTMLInputElement | null>(null);
|
const jsonFileInput = ref<HTMLInputElement | null>(null);
|
||||||
const spritesheetImageUrl = ref('');
|
const spritesheetImageUrl = ref('');
|
||||||
@@ -378,6 +398,23 @@
|
|||||||
isGifFpsModalOpen.value = false;
|
isGifFpsModalOpen.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Share functionality
|
||||||
|
const { share } = useShare(layers, columns, toRef(settingsStore, 'negativeSpacingEnabled'), toRef(settingsStore, 'backgroundColor'), toRef(settingsStore, 'manualCellSizeEnabled'), toRef(settingsStore, 'manualCellWidth'), toRef(settingsStore, 'manualCellHeight'));
|
||||||
|
|
||||||
|
const shareFunction = () => share();
|
||||||
|
|
||||||
|
const openShareModal = () => {
|
||||||
|
if (!visibleLayers.value.some(l => l.sprites.length > 0)) {
|
||||||
|
alert('Please upload or import sprites before sharing.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isShareModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeShareModal = () => {
|
||||||
|
isShareModalOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => {
|
const handleSplitSpritesheet = (spriteFiles: SpriteFile[]) => {
|
||||||
processImageFiles(spriteFiles.map(s => s.file));
|
processImageFiles(spriteFiles.map(s => s.file));
|
||||||
};
|
};
|
||||||
|
|||||||
286
src/views/ShareView.vue
Normal file
286
src/views/ShareView.vue
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
<template>
|
||||||
|
<main class="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">
|
||||||
|
<div class="p-6 sm:p-10 max-w-4xl mx-auto">
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="loading" class="flex flex-col items-center justify-center py-24">
|
||||||
|
<div class="relative">
|
||||||
|
<div class="w-16 h-16 border-4 border-gray-200 dark:border-gray-700 rounded-full"></div>
|
||||||
|
<div class="absolute top-0 left-0 w-16 h-16 border-4 border-transparent border-t-blue-500 rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-6 text-lg font-medium text-gray-600 dark:text-gray-300">Loading shared spritesheet...</p>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Please wait while we fetch your spritesheet</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-else-if="error" class="flex flex-col items-center justify-center py-24">
|
||||||
|
<div class="w-20 h-20 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-6">
|
||||||
|
<i class="fas fa-exclamation-triangle text-3xl text-red-500 dark:text-red-400"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-2">Oops! Something went wrong</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-6 text-center max-w-md">{{ error }}</p>
|
||||||
|
<router-link to="/" class="btn btn-dark px-6 py-3">
|
||||||
|
<i class="fas fa-home mr-2"></i>
|
||||||
|
Back to Home
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success state - show spritesheet info -->
|
||||||
|
<div v-else-if="spritesheetData">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="text-center mb-10">
|
||||||
|
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl shadow-lg mb-4">
|
||||||
|
<i class="fas fa-share-alt text-2xl text-white"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-3xl sm:text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-gray-900 to-gray-700 dark:from-white dark:to-gray-300 mb-2">Shared spritesheet</h1>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Shared {{ formatDate(spritesheetData.created) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
|
||||||
|
<div class="group p-5 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-800/20 rounded-2xl border border-blue-200/50 dark:border-blue-700/30">
|
||||||
|
<div class="flex items-center justify-center w-10 h-10 bg-blue-500/20 dark:bg-blue-500/30 rounded-xl mb-3">
|
||||||
|
<i class="fas fa-columns text-blue-600 dark:text-blue-400"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs font-medium text-blue-600/80 dark:text-blue-400/80 uppercase tracking-wider">Columns</p>
|
||||||
|
<p class="text-2xl font-bold text-blue-700 dark:text-blue-300">{{ spritesheetData.config.columns }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="group p-5 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-800/20 rounded-2xl border border-purple-200/50 dark:border-purple-700/30">
|
||||||
|
<div class="flex items-center justify-center w-10 h-10 bg-purple-500/20 dark:bg-purple-500/30 rounded-xl mb-3">
|
||||||
|
<i class="fas fa-layer-group text-purple-600 dark:text-purple-400"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs font-medium text-purple-600/80 dark:text-purple-400/80 uppercase tracking-wider">Layers</p>
|
||||||
|
<p class="text-2xl font-bold text-purple-700 dark:text-purple-300">{{ spritesheetData.sprites.length }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="group p-5 bg-gradient-to-br from-emerald-50 to-emerald-100 dark:from-emerald-900/20 dark:to-emerald-800/20 rounded-2xl border border-emerald-200/50 dark:border-emerald-700/30">
|
||||||
|
<div class="flex items-center justify-center w-10 h-10 bg-emerald-500/20 dark:bg-emerald-500/30 rounded-xl mb-3">
|
||||||
|
<i class="fas fa-images text-emerald-600 dark:text-emerald-400"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs font-medium text-emerald-600/80 dark:text-emerald-400/80 uppercase tracking-wider">Sprites</p>
|
||||||
|
<p class="text-2xl font-bold text-emerald-700 dark:text-emerald-300">{{ totalSprites }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="group p-5 bg-gradient-to-br from-amber-50 to-amber-100 dark:from-amber-900/20 dark:to-amber-800/20 rounded-2xl border border-amber-200/50 dark:border-amber-700/30">
|
||||||
|
<div class="flex items-center justify-center w-10 h-10 bg-amber-500/20 dark:bg-amber-500/30 rounded-xl mb-3">
|
||||||
|
<i class="fas fa-fill-drip text-amber-600 dark:text-amber-400"></i>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs font-medium text-amber-600/80 dark:text-amber-400/80 uppercase tracking-wider">Background</p>
|
||||||
|
<p class="text-lg font-bold text-amber-700 dark:text-amber-300 truncate">{{ spritesheetData.config.backgroundColor }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview Section -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h3 class="flex items-center gap-2 text-lg font-bold text-gray-800 dark:text-gray-100 mb-4">
|
||||||
|
<i class="fas fa-eye text-gray-500 dark:text-gray-400"></i>
|
||||||
|
Sprite preview
|
||||||
|
</h3>
|
||||||
|
<div class="p-6 bg-gray-50 dark:bg-gray-800/50 rounded-2xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<div v-for="(layer, layerIndex) in spritesheetData.sprites" :key="layerIndex" class="mb-6 last:mb-0">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="inline-flex items-center justify-center w-6 h-6 bg-gray-200 dark:bg-gray-700 rounded-full text-xs font-bold text-gray-600 dark:text-gray-300">
|
||||||
|
{{ layerIndex + 1 }}
|
||||||
|
</span>
|
||||||
|
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ layer.name || `Layer ${layerIndex + 1}` }}</p>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">({{ layer.sprites.length }} sprites)</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<div v-for="(sprite, spriteIndex) in layer.sprites.slice(0, 12)" :key="spriteIndex" class="relative group">
|
||||||
|
<div class="w-16 h-16 bg-checkerboard rounded-lg border-2 border-gray-200 dark:border-gray-600 overflow-hidden transition-transform group-hover:scale-110 group-hover:border-blue-400 group-hover:shadow-lg">
|
||||||
|
<img :src="sprite.base64" :alt="`Sprite ${spriteIndex + 1}`" class="w-full h-full object-contain" style="image-rendering: pixelated" />
|
||||||
|
</div>
|
||||||
|
<div class="absolute -bottom-1 -right-1 w-5 h-5 bg-gray-800 dark:bg-gray-600 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<span class="text-[10px] font-bold text-white">{{ spriteIndex + 1 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="layer.sprites.length > 12" class="w-16 h-16 flex flex-col items-center justify-center bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
|
||||||
|
<span class="text-lg font-bold text-gray-500 dark:text-gray-400">+{{ layer.sprites.length - 12 }}</span>
|
||||||
|
<span class="text-[10px] text-gray-400 dark:text-gray-500">more</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
|
<button @click="loadIntoEditor" class="btn btn-dark px-8 py-3 text-base font-semibold shadow-lg hover:shadow-xl transition-shadow">
|
||||||
|
<i class="fas fa-edit mr-2"></i>
|
||||||
|
Edit in a copy
|
||||||
|
</button>
|
||||||
|
<button @click="downloadJSON" class="btn btn-secondary px-8 py-3 text-base font-semibold">
|
||||||
|
<i class="fas fa-download mr-2"></i>
|
||||||
|
Download JSON
|
||||||
|
</button>
|
||||||
|
<router-link to="/" class="btn btn-ghost px-8 py-3 text-base font-semibold text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200">
|
||||||
|
<i class="fas fa-plus-circle mr-2"></i>
|
||||||
|
Create new
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { fetchSpritesheet, type SpritesheetRecord } from '@/composables/useShare';
|
||||||
|
import { useLayers } from '@/composables/useLayers';
|
||||||
|
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||||
|
import type { Layer, Sprite } from '@/types/sprites';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const { layers, activeLayerId, columns } = useLayers();
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref('');
|
||||||
|
const spritesheetData = ref<SpritesheetRecord | null>(null);
|
||||||
|
|
||||||
|
const totalSprites = computed(() => {
|
||||||
|
if (!spritesheetData.value) return 0;
|
||||||
|
return spritesheetData.value.sprites.reduce((sum, layer) => sum + (layer.sprites?.length || 0), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'just now';
|
||||||
|
if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||||
|
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadIntoEditor = async () => {
|
||||||
|
if (!spritesheetData.value) return;
|
||||||
|
|
||||||
|
const data = spritesheetData.value;
|
||||||
|
|
||||||
|
// Apply config settings
|
||||||
|
columns.value = data.config.columns;
|
||||||
|
settingsStore.negativeSpacingEnabled = data.config.negativeSpacingEnabled;
|
||||||
|
settingsStore.backgroundColor = data.config.backgroundColor;
|
||||||
|
settingsStore.manualCellSizeEnabled = data.config.manualCellSizeEnabled;
|
||||||
|
settingsStore.manualCellWidth = data.config.manualCellWidth;
|
||||||
|
settingsStore.manualCellHeight = data.config.manualCellHeight;
|
||||||
|
|
||||||
|
// Load sprites into layers
|
||||||
|
const loadSprite = (spriteData: any): Promise<Sprite> =>
|
||||||
|
new Promise(resolve => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const byteString = atob(spriteData.base64.split(',')[1]);
|
||||||
|
const mimeType = spriteData.base64.split(',')[0].split(':')[1].split(';')[0];
|
||||||
|
const ab = new ArrayBuffer(byteString.length);
|
||||||
|
const ia = new Uint8Array(ab);
|
||||||
|
for (let i = 0; i < byteString.length; i++) ia[i] = byteString.charCodeAt(i);
|
||||||
|
const blob = new Blob([ab], { type: mimeType });
|
||||||
|
const fileName = spriteData.name || `sprite-${spriteData.id}.png`;
|
||||||
|
const file = new File([blob], fileName, { type: mimeType });
|
||||||
|
resolve({
|
||||||
|
id: spriteData.id || crypto.randomUUID(),
|
||||||
|
file,
|
||||||
|
img,
|
||||||
|
url: spriteData.base64,
|
||||||
|
width: spriteData.width,
|
||||||
|
height: spriteData.height,
|
||||||
|
x: spriteData.x || 0,
|
||||||
|
y: spriteData.y || 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
img.src = spriteData.base64;
|
||||||
|
});
|
||||||
|
|
||||||
|
const newLayers: Layer[] = [];
|
||||||
|
for (const layerData of data.sprites) {
|
||||||
|
const sprites: Sprite[] = await Promise.all(layerData.sprites.map((s: any) => loadSprite(s)));
|
||||||
|
newLayers.push({
|
||||||
|
id: layerData.id || crypto.randomUUID(),
|
||||||
|
name: layerData.name || 'Layer',
|
||||||
|
visible: layerData.visible !== false,
|
||||||
|
locked: !!layerData.locked,
|
||||||
|
sprites,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
layers.value = newLayers;
|
||||||
|
if (newLayers.length > 0) {
|
||||||
|
const firstWithSprites = newLayers.find(l => l.sprites.length > 0);
|
||||||
|
activeLayerId.value = firstWithSprites ? firstWithSprites.id : newLayers[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to home
|
||||||
|
router.push('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadJSON = () => {
|
||||||
|
if (!spritesheetData.value) return;
|
||||||
|
|
||||||
|
const json = {
|
||||||
|
version: spritesheetData.value.config.version,
|
||||||
|
columns: spritesheetData.value.config.columns,
|
||||||
|
negativeSpacingEnabled: spritesheetData.value.config.negativeSpacingEnabled,
|
||||||
|
backgroundColor: spritesheetData.value.config.backgroundColor,
|
||||||
|
manualCellSizeEnabled: spritesheetData.value.config.manualCellSizeEnabled,
|
||||||
|
manualCellWidth: spritesheetData.value.config.manualCellWidth,
|
||||||
|
manualCellHeight: spritesheetData.value.config.manualCellHeight,
|
||||||
|
layers: spritesheetData.value.sprites,
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(json, null, 2);
|
||||||
|
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'spritesheet.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const id = route.params.id as string;
|
||||||
|
if (!id) {
|
||||||
|
error.value = 'No spritesheet ID provided';
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
spritesheetData.value = await fetchSpritesheet(id);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to load spritesheet:', e);
|
||||||
|
error.value = e.message || 'Failed to load spritesheet';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bg-checkerboard {
|
||||||
|
background-color: #fff;
|
||||||
|
background-image: linear-gradient(45deg, #e5e5e5 25%, transparent 25%), linear-gradient(-45deg, #e5e5e5 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e5e5e5 75%), linear-gradient(-45deg, transparent 75%, #e5e5e5 75%);
|
||||||
|
background-size: 12px 12px;
|
||||||
|
background-position:
|
||||||
|
0 0,
|
||||||
|
0 6px,
|
||||||
|
6px -6px,
|
||||||
|
-6px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-checkerboard {
|
||||||
|
background-color: #374151;
|
||||||
|
background-image: linear-gradient(45deg, #4b5563 25%, transparent 25%), linear-gradient(-45deg, #4b5563 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #4b5563 75%), linear-gradient(-45deg, transparent 75%, #4b5563 75%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user