[FEAT] Auth. and user projects
This commit is contained in:
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_POCKETBASE_URL=https://pb1.adhd.sh
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('https://pb1.adhd.sh/api/collections/feedback/records', {
|
const res = await fetch(`${import.meta.env.VITE_POCKETBASE_URL}/api/collections/feedback/records`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
131
src/components/auth/AuthModal.vue
Normal file
131
src/components/auth/AuthModal.vue
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isOpen: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const isLogin = ref(true);
|
||||||
|
const email = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const passwordConfirm = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
emit('close');
|
||||||
|
error.value = '';
|
||||||
|
email.value = '';
|
||||||
|
password.value = '';
|
||||||
|
passwordConfirm.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMode = () => {
|
||||||
|
isLogin.value = !isLogin.value;
|
||||||
|
error.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
if (isLogin.value) {
|
||||||
|
await authStore.login(email.value, password.value);
|
||||||
|
} else {
|
||||||
|
if (password.value !== passwordConfirm.value) {
|
||||||
|
throw new Error("Passwords don't match");
|
||||||
|
}
|
||||||
|
await authStore.register(email.value, password.value, passwordConfirm.value);
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message || 'An error occurred';
|
||||||
|
// Better PB error handling
|
||||||
|
if(e?.data?.message) error.value = e.data.message;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -23,6 +23,29 @@
|
|||||||
|
|
||||||
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700"></div>
|
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700"></div>
|
||||||
|
|
||||||
|
<!-- Auth & Projects -->
|
||||||
|
<div v-if="authStore.user" class="flex items-center gap-3">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 truncate max-w-[150px]">
|
||||||
|
{{ authStore.user.email }}
|
||||||
|
</span>
|
||||||
|
<button @click="openSaveModal" class="btn btn-primary btn-sm">
|
||||||
|
<i class="fas fa-save mr-1.5"></i> Save
|
||||||
|
</button>
|
||||||
|
<button @click="isProjectListOpen = true" class="btn btn-secondary btn-sm">
|
||||||
|
<i class="fas fa-folder-open mr-1.5"></i> Projects
|
||||||
|
</button>
|
||||||
|
<button @click="authStore.logout()" class="text-sm text-gray-500 hover:text-red-500 transition-colors" title="Logout">
|
||||||
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<button @click="isAuthModalOpen = true" class="btn btn-primary btn-sm">
|
||||||
|
Login / Register
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700"></div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<a href="https://gitea.adhd.sh/root/spritesheet-generator" target="_blank" rel="noopener noreferrer" class="text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors" title="Source Code">
|
<a href="https://gitea.adhd.sh/root/spritesheet-generator" target="_blank" rel="noopener noreferrer" class="text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors" title="Source Code">
|
||||||
<i class="fab fa-github text-lg"></i>
|
<i class="fab fa-github text-lg"></i>
|
||||||
@@ -37,6 +60,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AuthModal :is-open="isAuthModalOpen" @close="isAuthModalOpen = false" />
|
||||||
|
<ProjectList :is-open="isProjectListOpen" @close="isProjectListOpen = false" @open-project="handleOpenProject" />
|
||||||
|
<SaveProjectModal
|
||||||
|
:is-open="isSaveProjectModalOpen"
|
||||||
|
:initial-name="projectStore.currentProject?.name"
|
||||||
|
@close="isSaveProjectModalOpen = false"
|
||||||
|
@save="handleSaveProject"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Mobile Menu Button -->
|
<!-- Mobile Menu Button -->
|
||||||
<div class="flex md:hidden">
|
<div class="flex md:hidden">
|
||||||
<DarkModeToggle />
|
<DarkModeToggle />
|
||||||
@@ -80,8 +112,73 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { RouterLink } from 'vue-router';
|
import { RouterLink } from 'vue-router';
|
||||||
import DarkModeToggle from '../utilities/DarkModeToggle.vue';
|
import DarkModeToggle from '../utilities/DarkModeToggle.vue';
|
||||||
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
|
import AuthModal from '@/components/auth/AuthModal.vue';
|
||||||
|
import ProjectList from '@/components/project/ProjectList.vue';
|
||||||
|
import SaveProjectModal from '@/components/project/SaveProjectModal.vue';
|
||||||
|
import { useProjectStore, type Project } from '@/stores/useProjectStore';
|
||||||
|
import { useSettingsStore } from '@/stores/useSettingsStore';
|
||||||
|
import { useExportLayers } from '@/composables/useExportLayers';
|
||||||
|
import { useLayers } from '@/composables/useLayers';
|
||||||
|
import { toRef } from 'vue';
|
||||||
|
|
||||||
defineEmits(['open-help']);
|
defineEmits(['open-help']); // Removed 'open-project' since we handle it here
|
||||||
|
|
||||||
const isMobileMenuOpen = ref(false);
|
const isMobileMenuOpen = ref(false);
|
||||||
|
const isAuthModalOpen = ref(false);
|
||||||
|
const isProjectListOpen = ref(false);
|
||||||
|
const isSaveProjectModalOpen = ref(false);
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const projectStore = useProjectStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const { layers, columns, activeLayerId } = useLayers();
|
||||||
|
|
||||||
|
const { generateProjectJSON, loadProjectData } = useExportLayers(
|
||||||
|
layers,
|
||||||
|
columns,
|
||||||
|
toRef(settingsStore, 'negativeSpacingEnabled'),
|
||||||
|
activeLayerId,
|
||||||
|
toRef(settingsStore, 'backgroundColor'),
|
||||||
|
toRef(settingsStore, 'manualCellSizeEnabled'),
|
||||||
|
toRef(settingsStore, 'manualCellWidth'),
|
||||||
|
toRef(settingsStore, 'manualCellHeight')
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenProject = async (project: Project) => {
|
||||||
|
try {
|
||||||
|
if (project.data) {
|
||||||
|
await loadProjectData(project.data);
|
||||||
|
}
|
||||||
|
projectStore.currentProject = project;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to open project", e);
|
||||||
|
alert("Failed to open project data");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSaveModal = () => {
|
||||||
|
isSaveProjectModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveProject = async (name: string) => {
|
||||||
|
try {
|
||||||
|
const data = await generateProjectJSON();
|
||||||
|
|
||||||
|
if (projectStore.currentProject && projectStore.currentProject.name === name) { // Simple check, ideally check ID but UI only exposes name edit on save for new projects or overwrite?
|
||||||
|
// Actually SaveProjectModal allows editing name.
|
||||||
|
// If it's the same project, we update.
|
||||||
|
await projectStore.updateProject(projectStore.currentProject.id, data);
|
||||||
|
// Update name if changed? updateProject signature might need name.
|
||||||
|
// current implementation of updateProject only updates data.
|
||||||
|
// To update name we need to change store or backend.
|
||||||
|
// For now, let's just update data.
|
||||||
|
} else {
|
||||||
|
await projectStore.createProject(name, data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert("Failed to save project");
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
104
src/components/project/ProjectList.vue
Normal file
104
src/components/project/ProjectList.vue
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<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-2xl p-6 relative border border-gray-200 dark:border-gray-700 max-h-[80vh] flex flex-col">
|
||||||
|
<div class="flex items-center justify-between mb-6 shrink-0">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">My Projects</h2>
|
||||||
|
<button @click="close" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto custom-scrollbar p-1">
|
||||||
|
<div v-if="loading" class="flex justify-center py-8">
|
||||||
|
<i class="fas fa-spinner fa-spin text-2xl text-indigo-500"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="projects.length === 0" class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-folder-open text-4xl mb-3 opacity-50"></i>
|
||||||
|
<p>No projects found. Save your work to see it here!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="project in projects"
|
||||||
|
:key="project.id"
|
||||||
|
class="group p-4 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 hover:border-indigo-500 dark:hover:border-indigo-500 transition-all cursor-pointer relative"
|
||||||
|
@click="selectProject(project)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="font-bold text-gray-900 dark:text-gray-100 truncate pr-2">{{ project.name }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button @click.stop="deleteProject(project.id)" class="text-gray-400 hover:text-red-500 p-1" title="Delete Project">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 flex flex-col gap-1">
|
||||||
|
<span><i class="fas fa-clock mr-1"></i> Updated: {{ formatDate(project.updated) }}</span>
|
||||||
|
<span><i class="fas fa-layer-group mr-1"></i> Layers: {{ project.data?.layers?.length || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 flex justify-end shrink-0">
|
||||||
|
<button @click="close" class="btn btn-secondary text-sm">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, toRefs, watch } from 'vue';
|
||||||
|
import { useProjectStore, type Project } from '@/stores/useProjectStore';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isOpen: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
(e: 'open-project', project: Project): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const projectStore = useProjectStore();
|
||||||
|
const { projects, isLoading: loading } = toRefs(projectStore);
|
||||||
|
|
||||||
|
watch(() => props.isOpen, (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
projectStore.fetchProjects();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.isOpen) {
|
||||||
|
projectStore.fetchProjects();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = () => emit('close');
|
||||||
|
|
||||||
|
const selectProject = (project: Project) => {
|
||||||
|
emit('open-project', project);
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteProject = async (id: string) => {
|
||||||
|
await projectStore.deleteProject(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
64
src/components/project/SaveProjectModal.vue
Normal file
64
src/components/project/SaveProjectModal.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isOpen: boolean;
|
||||||
|
initialName?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
(e: 'save', name: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const projectName = ref('');
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.isOpen,
|
||||||
|
(isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
projectName.value = props.initialName || '';
|
||||||
|
nextTick(() => {
|
||||||
|
inputRef.value?.focus();
|
||||||
|
inputRef.value?.select();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const close = () => emit('close');
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (projectName.value.trim()) {
|
||||||
|
emit('save', projectName.value);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -106,13 +106,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
|||||||
link.click();
|
link.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportSpritesheetJSON = async () => {
|
const generateProjectJSON = async () => {
|
||||||
const visibleLayers = getVisibleLayers();
|
|
||||||
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) {
|
|
||||||
alert('Nothing to export. Please add sprites first.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const layersData = await Promise.all(
|
const layersData = await Promise.all(
|
||||||
layersRef.value.map(async layer => {
|
layersRef.value.map(async layer => {
|
||||||
const sprites = await Promise.all(
|
const sprites = await Promise.all(
|
||||||
@@ -131,7 +125,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const json = {
|
return {
|
||||||
version: 2,
|
version: 2,
|
||||||
columns: columns.value,
|
columns: columns.value,
|
||||||
negativeSpacingEnabled: negativeSpacingEnabled.value,
|
negativeSpacingEnabled: negativeSpacingEnabled.value,
|
||||||
@@ -141,6 +135,16 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
|||||||
manualCellHeight: manualCellHeight?.value || 64,
|
manualCellHeight: manualCellHeight?.value || 64,
|
||||||
layers: layersData,
|
layers: layersData,
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportSpritesheetJSON = async () => {
|
||||||
|
const visibleLayers = getVisibleLayers();
|
||||||
|
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) {
|
||||||
|
alert('Nothing to export. Please add sprites first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await generateProjectJSON();
|
||||||
const jsonString = JSON.stringify(json, null, 2);
|
const jsonString = JSON.stringify(json, null, 2);
|
||||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -151,10 +155,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const importSpritesheetJSON = async (jsonFile: File) => {
|
const loadProjectData = async (data: any) => {
|
||||||
const text = await jsonFile.text();
|
|
||||||
const data = JSON.parse(text);
|
|
||||||
|
|
||||||
const loadSprite = (spriteData: any) =>
|
const loadSprite = (spriteData: any) =>
|
||||||
new Promise<Sprite>(resolve => {
|
new Promise<Sprite>(resolve => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -217,6 +218,12 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
|||||||
throw new Error('Invalid JSON format');
|
throw new Error('Invalid JSON format');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const importSpritesheetJSON = async (jsonFile: File) => {
|
||||||
|
const text = await jsonFile.text();
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
await loadProjectData(data);
|
||||||
|
};
|
||||||
|
|
||||||
const downloadAsGif = (fps: number) => {
|
const downloadAsGif = (fps: number) => {
|
||||||
const visibleLayers = getVisibleLayers();
|
const visibleLayers = getVisibleLayers();
|
||||||
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) {
|
if (!visibleLayers.length || !visibleLayers.some(l => l.sprites.length)) {
|
||||||
@@ -314,5 +321,5 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
return { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip };
|
return { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip, generateProjectJSON, loadProjectData };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Ref } from 'vue';
|
import type { Ref } from 'vue';
|
||||||
import type { Layer } from '@/types/sprites';
|
import type { Layer } from '@/types/sprites';
|
||||||
|
|
||||||
const POCKETBASE_URL = 'https://pb1.adhd.sh';
|
const POCKETBASE_URL = import.meta.env.VITE_POCKETBASE_URL;
|
||||||
const COLLECTION = 'spritesheets';
|
const COLLECTION = 'spritesheets';
|
||||||
|
|
||||||
export interface SpritesheetConfig {
|
export interface SpritesheetConfig {
|
||||||
|
|||||||
39
src/stores/useAuthStore.ts
Normal file
39
src/stores/useAuthStore.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import PocketBase from 'pocketbase';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const pb = new PocketBase(import.meta.env.VITE_POCKETBASE_URL);
|
||||||
|
const user = ref(pb.authStore.model);
|
||||||
|
|
||||||
|
// Sync user state on change
|
||||||
|
pb.authStore.onChange(() => {
|
||||||
|
user.value = pb.authStore.model;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function login(email: string, password: string) {
|
||||||
|
await pb.collection('users').authWithPassword(email, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(email: string, password: string, passwordConfirm: string) {
|
||||||
|
await pb.collection('users').create({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
passwordConfirm,
|
||||||
|
});
|
||||||
|
// Auto login after register
|
||||||
|
await login(email, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
pb.authStore.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pb,
|
||||||
|
user,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
};
|
||||||
|
});
|
||||||
130
src/stores/useProjectStore.ts
Normal file
130
src/stores/useProjectStore.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useAuthStore } from './useAuthStore';
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
data: any; // Store the JSON export of the project here
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProjectStore = defineStore('project', () => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const projects = ref<Project[]>([]);
|
||||||
|
const currentProject = ref<Project | null>(null);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
async function fetchProjects() {
|
||||||
|
if (!authStore.user) return;
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const records = await authStore.pb.collection('projects').getList(1, 50, {
|
||||||
|
sort: '-updated',
|
||||||
|
});
|
||||||
|
projects.value = records.items.map((r: any) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
data: r.data,
|
||||||
|
created: r.created,
|
||||||
|
updated: r.updated,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch projects:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProject(name: string, data: any) {
|
||||||
|
if (!authStore.user) return;
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const record = await authStore.pb.collection('projects').create({
|
||||||
|
name,
|
||||||
|
data,
|
||||||
|
user: authStore.user.id,
|
||||||
|
});
|
||||||
|
currentProject.value = {
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
data: record.data,
|
||||||
|
created: record.created,
|
||||||
|
updated: record.updated,
|
||||||
|
};
|
||||||
|
await fetchProjects();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create project:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProject(id: string, data: any) {
|
||||||
|
if (!authStore.user) return;
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const record = await authStore.pb.collection('projects').update(id, {
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
currentProject.value = { ...currentProject.value!, data: record.data, updated: record.updated };
|
||||||
|
await fetchProjects();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update project:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProject(id: string) {
|
||||||
|
if (!authStore.user) return;
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const record = await authStore.pb.collection('projects').getOne(id);
|
||||||
|
currentProject.value = {
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
data: record.data,
|
||||||
|
created: record.created,
|
||||||
|
updated: record.updated
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load project", error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProject(id: string) {
|
||||||
|
if (!authStore.user) return;
|
||||||
|
if (!confirm('Are you sure you want to delete this project?')) return;
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
await authStore.pb.collection('projects').delete(id);
|
||||||
|
if (currentProject.value?.id === id) {
|
||||||
|
currentProject.value = null;
|
||||||
|
}
|
||||||
|
await fetchProjects();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete project:', error);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects,
|
||||||
|
currentProject,
|
||||||
|
isLoading,
|
||||||
|
fetchProjects,
|
||||||
|
createProject,
|
||||||
|
updateProject,
|
||||||
|
loadProject,
|
||||||
|
deleteProject
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user