[FEAT] Streamline UI

This commit is contained in:
2026-01-01 19:47:07 +01:00
parent 04f88dd878
commit e51cb4b334
9 changed files with 292 additions and 293 deletions

View File

@@ -33,32 +33,7 @@
</div>
<!-- Copy to Frame Modal -->
<div v-if="showCopyToFrameModal" class="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center" @click.self="closeCopyToFrameModal">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl p-6 min-w-[320px] max-w-md" @click.stop>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 mb-4">Copy sprite to frame</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Target frame</label>
<select v-model.number="copyTargetFrame" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500">
<option v-for="i in maxFrameCount" :key="i" :value="i - 1">Frame {{ i }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Target layer</label>
<select v-model="copyTargetLayerId" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500">
<option v-for="layer in props.layers" :key="layer.id" :value="layer.id">{{ layer.name }}</option>
</select>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button @click="closeCopyToFrameModal" class="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">Cancel</button>
<button @click="confirmCopyToFrame" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">Copy</button>
</div>
</div>
</div>
<CopyToFrameModal :is-open="showCopyToFrameModal" :layers="props.layers" :initial-layer-id="copyTargetLayerId" @close="closeCopyToFrameModal" @copy="confirmCopyToFrame" />
</Teleport>
<div class="h-full w-full flex flex-col p-4">
@@ -206,6 +181,7 @@
import { useFileDrop } from '@/composables/useFileDrop';
import { useGridMetrics } from '@/composables/useGridMetrics';
import { useBackgroundStyles } from '@/composables/useBackgroundStyles';
import CopyToFrameModal from './utilities/CopyToFrameModal.vue';
import type { Layer } from '@/types/sprites';
@@ -302,15 +278,9 @@
// Copy to frame modal state
const showCopyToFrameModal = ref(false);
const copyTargetFrame = ref(0);
const copyTargetLayerId = ref(props.activeLayerId);
const copySpriteId = ref<string | null>(null);
const maxFrameCount = computed(() => {
const maxLen = Math.max(1, ...props.layers.map(l => l.sprites.length));
return maxLen + 1; // Allow copying to one frame beyond current max
});
// Clear selection when toggling multi-select mode
watch(
() => props.isMultiSelectMode,
@@ -556,7 +526,6 @@
if (contextMenuSpriteId.value) {
copySpriteId.value = contextMenuSpriteId.value;
copyTargetLayerId.value = props.activeLayerId;
copyTargetFrame.value = 0;
showCopyToFrameModal.value = true;
showContextMenu.value = false;
}
@@ -567,9 +536,9 @@
copySpriteId.value = null;
};
const confirmCopyToFrame = () => {
const confirmCopyToFrame = (targetLayerId: string, targetFrameIndex: number) => {
if (copySpriteId.value) {
emit('copySpriteToFrame', copySpriteId.value, copyTargetLayerId.value, copyTargetFrame.value);
emit('copySpriteToFrame', copySpriteId.value, targetLayerId, targetFrameIndex);
closeCopyToFrameModal();
}
};

View File

@@ -24,32 +24,7 @@
</div>
<!-- Copy to Frame Modal -->
<div v-if="showCopyToFrameModal" class="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center" @click.self="closeCopyToFrameModal">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl p-6 min-w-[320px] max-w-md" @click.stop>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 mb-4">Copy Sprite to Frame</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Target Frame</label>
<select v-model.number="copyTargetFrame" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500">
<option v-for="i in maxFrameCount" :key="i" :value="i - 1">Frame {{ i }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Target Layer</label>
<select v-model="copyTargetLayerId" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500">
<option v-for="layer in props.layers" :key="layer.id" :value="layer.id">{{ layer.name }}</option>
</select>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button @click="closeCopyToFrameModal" class="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">Cancel</button>
<button @click="confirmCopyToFrame" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">Copy</button>
</div>
</div>
</div>
<CopyToFrameModal :is-open="showCopyToFrameModal" :layers="props.layers" :initial-layer-id="copyTargetLayerId" @close="closeCopyToFrameModal" @copy="confirmCopyToFrame" />
</Teleport>
<div class="spritesheet-preview w-full h-full" @click="hideContextMenu">
@@ -308,6 +283,7 @@
import { useAnimationFrames } from '@/composables/useAnimationFrames';
import { useGridMetrics } from '@/composables/useGridMetrics';
import { useBackgroundStyles } from '@/composables/useBackgroundStyles';
import CopyToFrameModal from './utilities/CopyToFrameModal.vue';
const props = defineProps<{
layers: Layer[];
@@ -373,14 +349,8 @@
// Copy to frame modal state
const showCopyToFrameModal = ref(false);
const copyTargetFrame = ref(0);
const copyTargetLayerId = ref(props.activeLayerId);
const maxFrameCount = computed(() => {
const maxLen = Math.max(1, ...props.layers.map(l => l.sprites.length));
return maxLen + 1; // Allow copying to one frame beyond current max
});
// Drag and drop for new sprites
const onDragOver = () => {
isDragOver.value = true;
@@ -713,7 +683,6 @@
const openCopyToFrameModal = () => {
if (contextMenuSpriteId.value) {
copyTargetLayerId.value = contextMenuLayerId.value || props.activeLayerId;
copyTargetFrame.value = 0;
showCopyToFrameModal.value = true;
showContextMenu.value = false;
}
@@ -723,9 +692,9 @@
showCopyToFrameModal.value = false;
};
const confirmCopyToFrame = () => {
const confirmCopyToFrame = (targetLayerId: string, targetFrameIndex: number) => {
if (contextMenuSpriteId.value) {
emit('copySpriteToFrame', contextMenuSpriteId.value, copyTargetLayerId.value, copyTargetFrame.value);
emit('copySpriteToFrame', contextMenuSpriteId.value, targetLayerId, targetFrameIndex);
closeCopyToFrameModal();
contextMenuSpriteId.value = null;
}

View File

@@ -1,58 +1,52 @@
<template>
<Teleport to="body">
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" @click.self="close">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-md p-6 relative border border-gray-200 dark:border-gray-700">
<button @click="close" class="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<i class="fas fa-times"></i>
<Modal :is-open="isOpen" @close="close" :title="isLogin ? 'Welcome Back' : 'Create Account'" :initialWidth="450">
<div class="space-y-4">
<div class="text-center mb-6">
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ isLogin ? 'Sign in to access your projects' : 'Join to save and manage your spritesheets' }}
</p>
</div>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
<input v-model="email" type="email" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" placeholder="you@example.com" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
<input v-model="password" type="password" required minlength="8" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" placeholder="••••••••" />
</div>
<div v-if="!isLogin">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm Password</label>
<input v-model="passwordConfirm" type="password" required minlength="8" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" placeholder="••••••••" />
</div>
<div v-if="error" class="text-red-500 text-sm text-center bg-red-50 dark:bg-red-900/20 p-2 rounded">
{{ error }}
</div>
<button type="submit" :disabled="loading" class="w-full btn btn-primary py-2.5 flex justify-center items-center">
<i v-if="loading" class="fas fa-spinner fa-spin mr-2"></i>
{{ isLogin ? 'Sign In' : 'Create Account' }}
</button>
</form>
<div class="text-center mb-6">
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ isLogin ? 'Welcome Back' : 'Create Account' }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ isLogin ? 'Sign in to access your projects' : 'Join to save and manage your spritesheets' }}
</p>
</div>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
<input v-model="email" type="email" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" placeholder="you@example.com" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
<input v-model="password" type="password" required minlength="8" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" placeholder="••••••••" />
</div>
<div v-if="!isLogin">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm Password</label>
<input v-model="passwordConfirm" type="password" required minlength="8" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" placeholder="••••••••" />
</div>
<div v-if="error" class="text-red-500 text-sm text-center bg-red-50 dark:bg-red-900/20 p-2 rounded">
{{ error }}
</div>
<button type="submit" :disabled="loading" class="w-full btn btn-primary py-2.5 flex justify-center items-center">
<i v-if="loading" class="fas fa-spinner fa-spin mr-2"></i>
{{ isLogin ? 'Sign In' : 'Create Account' }}
</button>
</form>
<div class="mt-6 text-center text-sm text-gray-600 dark:text-gray-400">
{{ isLogin ? "Don't have an account?" : 'Already have an account?' }}
<button @click="toggleMode" class="text-indigo-600 dark:text-indigo-400 font-medium hover:underline">
{{ isLogin ? 'Sign up' : 'Sign in' }}
</button>
</div>
<div class="mt-6 text-center text-sm text-gray-600 dark:text-gray-400">
{{ isLogin ? "Don't have an account?" : 'Already have an account?' }}
<button @click="toggleMode" class="text-indigo-600 dark:text-indigo-400 font-medium hover:underline">
{{ isLogin ? 'Sign up' : 'Sign in' }}
</button>
</div>
</div>
</Teleport>
</Modal>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useAuthStore } from '@/stores/useAuthStore';
import Modal from '@/components/utilities/Modal.vue';
const props = defineProps<{
isOpen: boolean;

View File

@@ -1,49 +1,47 @@
<template>
<Teleport to="body">
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" @click.self="close">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6 w-full max-w-sm border border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-gray-100">New project</h2>
<form @submit.prevent="handleSubmit">
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 uppercase tracking-wider">Sprite Width</label>
<div class="relative">
<input v-model.number="width" type="number" min="1" required class="w-full pl-3 pr-8 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" />
<span class="absolute right-3 top-2 text-gray-400 text-xs">px</span>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 uppercase tracking-wider">Sprite Height</label>
<div class="relative">
<input v-model.number="height" type="number" min="1" required class="w-full pl-3 pr-8 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" />
<span class="absolute right-3 top-2 text-gray-400 text-xs">px</span>
</div>
<Modal :is-open="isOpen" @close="close" title="New project" :initialWidth="400">
<div class="px-2 pt-2 pb-1">
<form @submit.prevent="handleSubmit">
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 uppercase tracking-wider">Sprite Width</label>
<div class="relative">
<input v-model.number="width" type="number" min="1" required class="w-full pl-3 pr-8 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" />
<span class="absolute right-3 top-2 text-gray-400 text-xs">px</span>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 uppercase tracking-wider">Sprite Height</label>
<div class="relative">
<input v-model.number="height" type="number" min="1" required class="w-full pl-3 pr-8 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" />
<span class="absolute right-3 top-2 text-gray-400 text-xs">px</span>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4 mb-6">
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 uppercase tracking-wider">Columns</label>
<input v-model.number="columns" type="number" min="1" max="20" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" />
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 uppercase tracking-wider">Rows</label>
<input v-model.number="rows" type="number" min="1" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" title="Rows are dynamic but this sets an initial expectation" />
</div>
<div class="grid grid-cols-2 gap-4 mb-6">
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 uppercase tracking-wider">Columns</label>
<input v-model.number="columns" type="number" min="1" max="20" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" />
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 uppercase tracking-wider">Rows</label>
<input v-model.number="rows" type="number" min="1" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" title="Rows are dynamic but this sets an initial expectation" />
</div>
</div>
<div class="flex justify-end gap-3">
<button type="button" @click="close" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="!isValid">Create project</button>
</div>
</form>
</div>
<div class="flex justify-end gap-3">
<button type="button" @click="close" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="!isValid">Create project</button>
</div>
</form>
</div>
</Teleport>
</Modal>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import Modal from '@/components/utilities/Modal.vue';
const props = defineProps<{
isOpen: boolean;

View File

@@ -1,134 +1,126 @@
<template>
<Teleport to="body">
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" @click.self="close">
<div class="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-full max-w-4xl overflow-hidden border border-gray-200 dark:border-gray-800 flex flex-col h-[85vh] animate-fade-in-up">
<!-- Header -->
<div class="px-6 py-5 border-b border-gray-100 dark:border-gray-800 flex flex-col gap-4 bg-gray-50/50 dark:bg-gray-800/20">
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">My projects</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Manage your saved sprite sheets</p>
</div>
<button @click="close" class="w-8 h-8 rounded-full flex items-center justify-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
<i class="fas fa-times"></i>
</button>
</div>
<Modal :is-open="isOpen" @close="close" title="My Projects" :initialWidth="900" :initialHeight="800" noPadding>
<div class="flex flex-col h-full bg-white dark:bg-gray-900">
<!-- Search Header -->
<div class="px-6 py-4 border-b border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/20">
<div class="mb-4">
<p class="text-sm text-gray-500 dark:text-gray-400">Manage your saved sprite sheets</p>
</div>
<!-- Search Bar -->
<div class="relative">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
<input
v-model="searchQuery"
type="text"
placeholder="Search projects..."
class="w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 dark:focus:border-indigo-500 outline-none transition-all text-sm"
/>
<div class="relative">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
<input
v-model="searchQuery"
type="text"
placeholder="Search projects..."
class="w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 dark:focus:border-indigo-500 outline-none transition-all text-sm"
/>
</div>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 bg-gray-50 dark:bg-black/20">
<!-- Loading State -->
<div v-if="loading" class="flex flex-col items-center justify-center py-12">
<div class="w-10 h-10 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin mb-4"></div>
<p class="text-gray-500 font-medium">Loading projects...</p>
</div>
<!-- Empty State -->
<div v-else-if="projects.length === 0" class="flex flex-col items-center justify-center py-12 text-center h-full">
<div class="w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4">
<i class="fas fa-folder-open text-3xl text-gray-400 dark:text-gray-500"></i>
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 mb-2">No projects yet</h3>
<p class="text-gray-500 dark:text-gray-400 max-w-xs mb-6">Create your first project or load an example to get started with Sprite Sheet Generator.</p>
<div class="flex gap-3">
<button @click="loadExample" class="btn btn-secondary flex items-center gap-2"><i class="fas fa-lightbulb"></i> Load Example</button>
</div>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 bg-gray-50 dark:bg-black/20">
<!-- Loading State -->
<div v-if="loading" class="flex flex-col items-center justify-center py-12">
<div class="w-10 h-10 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin mb-4"></div>
<p class="text-gray-500 font-medium">Loading projects...</p>
</div>
<!-- Empty State -->
<div v-else-if="projects.length === 0" class="flex flex-col items-center justify-center py-12 text-center h-full">
<div class="w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4">
<i class="fas fa-folder-open text-3xl text-gray-400 dark:text-gray-500"></i>
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 mb-2">No projects yet</h3>
<p class="text-gray-500 dark:text-gray-400 max-w-xs mb-6">Create your first project or load an example to get started with Sprite Sheet Generator.</p>
<div class="flex gap-3">
<button @click="loadExample" class="btn btn-secondary flex items-center gap-2"><i class="fas fa-lightbulb"></i> Load Example</button>
</div>
</div>
<!-- No Search Results -->
<div v-else-if="filteredProjects.length === 0" class="flex flex-col items-center justify-center py-12 text-center">
<div class="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4">
<i class="fas fa-search text-2xl text-gray-400 dark:text-gray-500"></i>
</div>
<h3 class="text-md font-bold text-gray-900 dark:text-gray-100 mb-1">No matches found</h3>
<p class="text-gray-500 dark:text-gray-400 text-sm">No projects match "{{ searchQuery }}"</p>
</div>
<!-- Project List -->
<div v-else class="flex flex-col gap-3">
<div
v-for="project in filteredProjects"
:key="project.id"
class="group bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-indigo-500 dark:hover:border-indigo-500 hover:shadow-md dark:hover:shadow-indigo-500/10 transition-all cursor-pointer overflow-hidden flex flex-col sm:flex-row h-auto sm:h-32"
@click="selectProject(project)"
>
<!-- Left: Preview Stripe -->
<div class="w-full sm:w-48 bg-gray-100 dark:bg-black/30 flex items-center justify-center relative overflow-hidden border-b sm:border-b-0 sm:border-r border-gray-100 dark:border-gray-700/50 p-2">
<div class="absolute inset-0 bg-checkerboard opacity-50"></div>
<!-- Sprite Stack for Preview -->
<div v-if="getProjectSprites(project, 1).length > 0" class="relative z-10 flex -space-x-4">
<div
v-for="(sprite, idx) in getProjectSprites(project, 3)"
:key="idx"
class="w-16 h-16 rounded-lg bg-white dark:bg-gray-700 border-2 border-white dark:border-gray-600 shadow-lg flex items-center justify-center overflow-hidden transform transition-transform group-hover:scale-105 group-hover:first:-rotate-6 group-hover:last:rotate-6"
:style="{ zIndex: 10 - idx }"
>
<img :src="sprite.base64" class="w-full h-full object-contain" style="image-rendering: pixelated" />
</div>
</div>
<!-- Fallback Icon -->
<div v-else class="relative z-10 w-16 h-16 rounded-lg bg-white dark:bg-gray-700 border-2 border-white dark:border-gray-600 shadow-lg flex items-center justify-center text-indigo-300 dark:text-indigo-400/50 font-bold text-2xl">
{{ project.name.charAt(0).toUpperCase() }}
</div>
</div>
<!-- content -->
<div class="flex-1 p-4 flex flex-col justify-center min-w-0">
<div class="flex items-start justify-between">
<div>
<h3 class="font-bold text-gray-900 dark:text-gray-100 truncate text-lg mb-1 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">{{ project.name }}</h3>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1"><i class="fas fa-layer-group opacity-70"></i> {{ project.data?.layers?.length || 0 }} layers</span>
<span class="flex items-center gap-1"><i class="fas fa-border-all opacity-70"></i> {{ project.data?.columns }} columns</span>
<span class="flex items-center gap-1"><i class="fas fa-images opacity-70"></i> {{ getSpriteCount(project) }} sprites</span>
</div>
</div>
<button @click.stop="deleteProject(project.id)" class="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 p-2 rounded-lg transition-all" title="Delete Project">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="mt-auto pt-3 flex items-center justify-between">
<span class="text-xs text-gray-400"> Updated {{ formatDate(project.updated) }} </span>
<span class="text-xs font-bold text-indigo-600 dark:text-indigo-400 flex items-center gap-1 transform translate-x-2 opacity-0 group-hover:translate-x-0 group-hover:opacity-100 transition-all"> Open Project <i class="fas fa-arrow-right"></i> </span>
</div>
</div>
</div>
<!-- No Search Results -->
<div v-else-if="filteredProjects.length === 0" class="flex flex-col items-center justify-center py-12 text-center">
<div class="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4">
<i class="fas fa-search text-2xl text-gray-400 dark:text-gray-500"></i>
</div>
<h3 class="text-md font-bold text-gray-900 dark:text-gray-100 mb-1">No matches found</h3>
<p class="text-gray-500 dark:text-gray-400 text-sm">No projects match "{{ searchQuery }}"</p>
</div>
<!-- Footer -->
<div class="px-6 py-4 border-t border-gray-100 dark:border-gray-800 flex justify-between items-center bg-white dark:bg-gray-900">
<p class="text-xs text-gray-400" v-if="projects.length > 0">Showing {{ filteredProjects.length }} of {{ projects.length }} project{{ projects.length !== 1 ? 's' : '' }}</p>
<div class="flex gap-2 ml-auto">
<button v-if="projects.length > 0" @click="loadExample" class="text-xs font-medium text-gray-500 hover:text-indigo-600 dark:hover:text-indigo-400 px-3 py-2">Load Example</button>
<button @click="close" class="btn btn-secondary text-sm">Close</button>
<!-- Project List -->
<div v-else class="flex flex-col gap-3">
<div
v-for="project in filteredProjects"
:key="project.id"
class="group bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-indigo-500 dark:hover:border-indigo-500 hover:shadow-md dark:hover:shadow-indigo-500/10 transition-all cursor-pointer overflow-hidden flex flex-col sm:flex-row h-auto sm:h-32"
@click="selectProject(project)"
>
<!-- Left: Preview Stripe -->
<div class="w-full sm:w-48 bg-gray-100 dark:bg-black/30 flex items-center justify-center relative overflow-hidden border-b sm:border-b-0 sm:border-r border-gray-100 dark:border-gray-700/50 p-2">
<div class="absolute inset-0 bg-checkerboard opacity-50"></div>
<!-- Sprite Stack for Preview -->
<div v-if="getProjectSprites(project, 1).length > 0" class="relative z-10 flex -space-x-4">
<div
v-for="(sprite, idx) in getProjectSprites(project, 3)"
:key="idx"
class="w-16 h-16 rounded-lg bg-white dark:bg-gray-700 border-2 border-white dark:border-gray-600 shadow-lg flex items-center justify-center overflow-hidden transform transition-transform group-hover:scale-105 group-hover:first:-rotate-6 group-hover:last:rotate-6"
:style="{ zIndex: 10 - idx }"
>
<img :src="sprite.base64" class="w-full h-full object-contain" style="image-rendering: pixelated" />
</div>
</div>
<!-- Fallback Icon -->
<div v-else class="relative z-10 w-16 h-16 rounded-lg bg-white dark:bg-gray-700 border-2 border-white dark:border-gray-600 shadow-lg flex items-center justify-center text-indigo-300 dark:text-indigo-400/50 font-bold text-2xl">
{{ project.name.charAt(0).toUpperCase() }}
</div>
</div>
<!-- content -->
<div class="flex-1 p-4 flex flex-col justify-center min-w-0">
<div class="flex items-start justify-between">
<div>
<h3 class="font-bold text-gray-900 dark:text-gray-100 truncate text-lg mb-1 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">{{ project.name }}</h3>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1"><i class="fas fa-layer-group opacity-70"></i> {{ project.data?.layers?.length || 0 }} layers</span>
<span class="flex items-center gap-1"><i class="fas fa-border-all opacity-70"></i> {{ project.data?.columns }} columns</span>
<span class="flex items-center gap-1"><i class="fas fa-images opacity-70"></i> {{ getSpriteCount(project) }} sprites</span>
</div>
</div>
<button @click.stop="deleteProject(project.id)" class="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 p-2 rounded-lg transition-all" title="Delete Project">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="mt-auto pt-3 flex items-center justify-between">
<span class="text-xs text-gray-400"> Updated {{ formatDate(project.updated) }} </span>
<span class="text-xs font-bold text-indigo-600 dark:text-indigo-400 flex items-center gap-1 transform translate-x-2 opacity-0 group-hover:translate-x-0 group-hover:opacity-100 transition-all"> Open Project <i class="fas fa-arrow-right"></i> </span>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="px-6 py-4 border-t border-gray-100 dark:border-gray-800 flex justify-between items-center bg-white dark:bg-gray-900">
<p class="text-xs text-gray-400" v-if="projects.length > 0">Showing {{ filteredProjects.length }} of {{ projects.length }} project{{ projects.length !== 1 ? 's' : '' }}</p>
<div class="flex gap-2 ml-auto">
<button v-if="projects.length > 0" @click="loadExample" class="text-xs font-medium text-gray-500 hover:text-indigo-600 dark:hover:text-indigo-400 px-3 py-2">Load Example</button>
<button @click="close" class="btn btn-secondary text-sm">Close</button>
</div>
</div>
</div>
</Teleport>
</Modal>
</template>
<script setup lang="ts">
import { onMounted, toRefs, watch, ref, computed } from 'vue';
import { useProjectStore, type Project } from '@/stores/useProjectStore';
import { useProjectManager } from '@/composables/useProjectManager';
import Modal from '@/components/utilities/Modal.vue';
const props = defineProps<{
isOpen: boolean;

View File

@@ -1,25 +1,23 @@
<template>
<Teleport to="body">
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm" @click.self="close">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6 w-full max-w-sm border border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-gray-100">Save project</h2>
<form @submit.prevent="handleSubmit">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project Name</label>
<input v-model="projectName" type="text" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" placeholder="My Awesome Spritesheet" ref="inputRef" />
</div>
<div class="flex justify-end gap-3">
<button type="button" @click="close" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="!projectName.trim()">Save</button>
</div>
</form>
</div>
<Modal :is-open="isOpen" @close="close" title="Save project" :initialWidth="400">
<div class="px-2 pt-2 pb-1">
<form @submit.prevent="handleSubmit">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project Name</label>
<input v-model="projectName" type="text" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white" placeholder="My Awesome Spritesheet" ref="inputRef" />
</div>
<div class="flex justify-end gap-3">
<button type="button" @click="close" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="!projectName.trim()">Save</button>
</div>
</form>
</div>
</Teleport>
</Modal>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue';
import Modal from '@/components/utilities/Modal.vue';
const props = defineProps<{
isOpen: boolean;

View File

@@ -0,0 +1,72 @@
<template>
<Modal :is-open="isOpen" @close="close" title="Copy sprite to frame" :initialWidth="380" :initialHeight="320">
<div class="p-4 flex flex-col h-full">
<div class="space-y-4 flex-1">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Target frame</label>
<select v-model.number="targetFrame" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500">
<option v-for="i in maxFrameCount" :key="i" :value="i - 1">Frame {{ i }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Target layer</label>
<select v-model="targetLayerId" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500">
<option v-for="layer in layers" :key="layer.id" :value="layer.id">{{ layer.name }}</option>
</select>
</div>
</div>
<div class="flex justify-end gap-3 mt-6 pt-4 border-t border-gray-100 dark:border-gray-700">
<button @click="close" class="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors font-medium text-sm">Cancel</button>
<button @click="confirm" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm">Copy</button>
</div>
</div>
</Modal>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import Modal from './Modal.vue';
import type { Layer } from '@/types/sprites';
const props = defineProps<{
isOpen: boolean;
layers: Layer[];
initialLayerId?: string;
}>();
const emit = defineEmits<{
(e: 'close'): void;
(e: 'copy', targetLayerId: string, targetFrameIndex: number): void;
}>();
const targetFrame = ref(0);
const targetLayerId = ref('');
const maxFrameCount = computed(() => {
const maxLen = Math.max(1, ...props.layers.map(l => l.sprites.length));
return maxLen + 1; // Allow copying to one frame beyond current max
});
watch(
() => props.isOpen,
isOpen => {
if (isOpen) {
targetFrame.value = 0;
targetLayerId.value = props.initialLayerId || (props.layers.length > 0 ? props.layers[0].id : '');
}
}
);
const close = () => {
emit('close');
};
const confirm = () => {
if (targetLayerId.value) {
emit('copy', targetLayerId.value, targetFrame.value);
close();
}
};
</script>

View File

@@ -10,31 +10,39 @@
width: isFullScreen ? '100%' : `${size.width}px`,
height: isFullScreen ? '100%' : `${size.height}px`,
}"
class="bg-white dark:bg-gray-800 rounded-2xl border-2 border-gray-300 dark:border-gray-700 shadow-xl flex flex-col fixed z-50 transition-colors duration-300"
class="bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-2xl flex flex-col fixed z-50 transition-colors duration-300"
:class="{ 'rounded-none border-0': isFullScreen, 'select-none': isDragging }"
>
<!-- Header with drag handle -->
<div class="flex justify-between items-center p-4 border-b border-gray-200 dark:border-gray-700" :class="{ 'cursor-move': !isFullScreen && !isMobile }" @mousedown="startDrag" @touchstart="handleTouchStart">
<h3 class="text-xl sm:text-2xl font-semibold text-gray-900 dark:text-gray-100 truncate pr-2">{{ title }}</h3>
<div class="flex items-center space-x-2">
<button @click="toggleFullScreen" @mousedown.stop @touchstart.stop class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" data-rybbit-event="modal-fullscreen">
<img src="@/assets/images/fullscreen-icon.svg" class="w-4 h-4 dark:invert" alt="Fullscreen" :class="{ 'rotate-180': isFullScreen }" />
<div class="flex justify-between items-center px-5 py-4 border-b border-gray-100 dark:border-gray-700/50" :class="{ 'cursor-move': !isFullScreen && !isMobile }" @mousedown="startDrag" @touchstart="handleTouchStart">
<h3 class="text-lg sm:text-lg font-bold text-gray-800 dark:text-gray-100 truncate pr-2">{{ title }}</h3>
<div class="flex items-center space-x-1">
<button @click="toggleFullScreen" @mousedown.stop @touchstart.stop class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-all" data-rybbit-event="modal-fullscreen">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-if="!isFullScreen">
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-else>
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3" />
</svg>
</button>
<button @click="close" @mousedown.stop @touchstart.stop class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors" data-rybbit-event="modal-close">
<img src="@/assets/images/close-icon.svg" class="w-5 h-5 dark:invert" alt="Close" />
<button @click="close" @mousedown.stop @touchstart.stop class="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md transition-all" data-rybbit-event="modal-close">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
<!-- Body -->
<div class="p-4 sm:p-6 flex-1 overflow-auto">
<div :class="[noPadding ? '' : 'p-5 sm:p-6', 'flex-1 overflow-auto']">
<slot></slot>
</div>
<!-- Resize handle -->
<div v-if="!isFullScreen && !isMobile" class="absolute bottom-0 right-0 w-8 h-8 cursor-se-resize" @mousedown="startResize" @touchstart="startResize">
<svg class="w-8 h-8 text-gray-400 dark:text-gray-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M22 22H20V20H22V22ZM22 18H20V16H22V18ZM18 22H16V20H18V22ZM22 14H20V12H22V14ZM18 18H16V16H18V18ZM14 22H12V20H14V22Z" />
<div v-if="!isFullScreen && !isMobile" class="absolute bottom-0 right-0 w-6 h-6 cursor-se-resize flex items-end justify-end p-1 opacity-50 hover:opacity-100 transition-opacity" @mousedown="startResize" @touchstart="startResize">
<svg class="w-4 h-4 text-gray-400 dark:text-gray-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M22 22H20V20H22V22ZM22 18H20V16H22V18ZM18 22H16V20H18V22Z" />
</svg>
</div>
</div>
@@ -49,6 +57,7 @@
title: string;
initialWidth?: number;
initialHeight?: number;
noPadding?: boolean;
}>();
const emit = defineEmits<{