[FEAT] Auth. and user projects

This commit is contained in:
2026-01-01 18:23:42 +01:00
parent 8e1b5fa77c
commit 7e51896d00
11 changed files with 590 additions and 16 deletions

View File

@@ -68,7 +68,7 @@
loading.value = true;
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',
headers: {
'Content-Type': 'application/json',

View 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>

View File

@@ -23,6 +23,29 @@
<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">
<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>
@@ -37,6 +60,15 @@
</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 -->
<div class="flex md:hidden">
<DarkModeToggle />
@@ -80,8 +112,73 @@
import { ref } from 'vue';
import { RouterLink } from 'vue-router';
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 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>

View 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>

View 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>

View File

@@ -106,13 +106,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
link.click();
};
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 generateProjectJSON = async () => {
const layersData = await Promise.all(
layersRef.value.map(async layer => {
const sprites = await Promise.all(
@@ -131,7 +125,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
})
);
const json = {
return {
version: 2,
columns: columns.value,
negativeSpacingEnabled: negativeSpacingEnabled.value,
@@ -141,6 +135,16 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
manualCellHeight: manualCellHeight?.value || 64,
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 blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
@@ -151,10 +155,7 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
URL.revokeObjectURL(url);
};
const importSpritesheetJSON = async (jsonFile: File) => {
const text = await jsonFile.text();
const data = JSON.parse(text);
const loadProjectData = async (data: any) => {
const loadSprite = (spriteData: any) =>
new Promise<Sprite>(resolve => {
const img = new Image();
@@ -217,6 +218,12 @@ export const useExportLayers = (layersRef: Ref<Layer[]>, columns: Ref<number>, n
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 visibleLayers = getVisibleLayers();
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);
};
return { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip };
return { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip, generateProjectJSON, loadProjectData };
};

View File

@@ -1,7 +1,7 @@
import type { Ref } from 'vue';
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';
export interface SpritesheetConfig {

View 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,
};
});

View 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
};
});