From 7e51896d0048ee33a71e869e63f0e5e60c02d0f1 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 1 Jan 2026 18:23:42 +0100 Subject: [PATCH] [FEAT] Auth. and user projects --- .env | 1 + .env.example | 1 + src/components/FeedbackModal.vue | 2 +- src/components/auth/AuthModal.vue | 131 ++++++++++++++++++++ src/components/layout/Navbar.vue | 99 ++++++++++++++- src/components/project/ProjectList.vue | 104 ++++++++++++++++ src/components/project/SaveProjectModal.vue | 64 ++++++++++ src/composables/useExportLayers.ts | 33 +++-- src/composables/useShare.ts | 2 +- src/stores/useAuthStore.ts | 39 ++++++ src/stores/useProjectStore.ts | 130 +++++++++++++++++++ 11 files changed, 590 insertions(+), 16 deletions(-) create mode 100644 .env create mode 100644 .env.example create mode 100644 src/components/auth/AuthModal.vue create mode 100644 src/components/project/ProjectList.vue create mode 100644 src/components/project/SaveProjectModal.vue create mode 100644 src/stores/useAuthStore.ts create mode 100644 src/stores/useProjectStore.ts diff --git a/.env b/.env new file mode 100644 index 0000000..73695e1 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_POCKETBASE_URL=https://pb1.adhd.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..73695e1 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_POCKETBASE_URL=https://pb1.adhd.sh diff --git a/src/components/FeedbackModal.vue b/src/components/FeedbackModal.vue index cccbf8d..c1fc49b 100644 --- a/src/components/FeedbackModal.vue +++ b/src/components/FeedbackModal.vue @@ -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', diff --git a/src/components/auth/AuthModal.vue b/src/components/auth/AuthModal.vue new file mode 100644 index 0000000..078d0b8 --- /dev/null +++ b/src/components/auth/AuthModal.vue @@ -0,0 +1,131 @@ + + + diff --git a/src/components/layout/Navbar.vue b/src/components/layout/Navbar.vue index 14c9260..21fd310 100644 --- a/src/components/layout/Navbar.vue +++ b/src/components/layout/Navbar.vue @@ -23,6 +23,29 @@
+ +
+ + {{ authStore.user.email }} + + + + +
+
+ +
+ +
+
@@ -37,6 +60,15 @@
+ + + +
@@ -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"); + } + }; diff --git a/src/components/project/ProjectList.vue b/src/components/project/ProjectList.vue new file mode 100644 index 0000000..41fbc4c --- /dev/null +++ b/src/components/project/ProjectList.vue @@ -0,0 +1,104 @@ + + + diff --git a/src/components/project/SaveProjectModal.vue b/src/components/project/SaveProjectModal.vue new file mode 100644 index 0000000..e45aa90 --- /dev/null +++ b/src/components/project/SaveProjectModal.vue @@ -0,0 +1,64 @@ + + + diff --git a/src/composables/useExportLayers.ts b/src/composables/useExportLayers.ts index c474220..4a46b35 100644 --- a/src/composables/useExportLayers.ts +++ b/src/composables/useExportLayers.ts @@ -106,13 +106,7 @@ export const useExportLayers = (layersRef: Ref, columns: Ref, 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, columns: Ref, n }) ); - const json = { + return { version: 2, columns: columns.value, negativeSpacingEnabled: negativeSpacingEnabled.value, @@ -141,6 +135,16 @@ export const useExportLayers = (layersRef: Ref, columns: Ref, 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, columns: Ref, 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(resolve => { const img = new Image(); @@ -217,6 +218,12 @@ export const useExportLayers = (layersRef: Ref, columns: Ref, 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, columns: Ref, n URL.revokeObjectURL(url); }; - return { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip }; + return { downloadSpritesheet, exportSpritesheetJSON, importSpritesheetJSON, downloadAsGif, downloadAsZip, generateProjectJSON, loadProjectData }; }; diff --git a/src/composables/useShare.ts b/src/composables/useShare.ts index 5134f8b..26aab45 100644 --- a/src/composables/useShare.ts +++ b/src/composables/useShare.ts @@ -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 { diff --git a/src/stores/useAuthStore.ts b/src/stores/useAuthStore.ts new file mode 100644 index 0000000..d259895 --- /dev/null +++ b/src/stores/useAuthStore.ts @@ -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, + }; +}); diff --git a/src/stores/useProjectStore.ts b/src/stores/useProjectStore.ts new file mode 100644 index 0000000..6537a4f --- /dev/null +++ b/src/stores/useProjectStore.ts @@ -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([]); + const currentProject = ref(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 + }; +});