From 8f769543f41304f4084add388214fa75404de081 Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Wed, 4 Mar 2026 00:14:05 +0300 Subject: [PATCH] Refactor frontend components and enhance API integration - Updated frontend-nuxt.mdc to specify usage of composables for API calls. - Added new AuthCard and ConfirmModal components for improved UI consistency. - Introduced UserAvatar component for user profile display, replacing previous Gravatar implementation. - Implemented useFormSubmit composable for handling form submissions with loading and error states. - Enhanced vitest.config.ts to include coverage reporting for composables and components. - Removed deprecated useAdminApi and useAuth composables to streamline API interactions. - Updated login and setup pages to utilize new components and composables for better user experience. --- .cursor/rules/frontend-nuxt.mdc | 2 +- frontend-nuxt/components/AuthCard.vue | 12 +++ frontend-nuxt/components/ConfirmModal.vue | 71 ++++++++++++++ frontend-nuxt/components/UserAvatar.vue | 42 ++++++++ .../components/__tests__/UserAvatar.test.ts | 40 ++++++++ .../__tests__/useMapBookmarks.test.ts | 79 +++++++++++++++ .../composables/__tests__/useToast.test.ts | 49 ++++++++++ frontend-nuxt/composables/useAdminApi.ts | 23 ----- frontend-nuxt/composables/useAuth.ts | 15 --- frontend-nuxt/composables/useFormSubmit.ts | 24 +++++ frontend-nuxt/layouts/default.vue | 46 +-------- frontend-nuxt/pages/admin/index.vue | 59 +++++------- frontend-nuxt/pages/admin/maps/[id].vue | 4 +- .../pages/admin/users/[username].vue | 27 +++--- frontend-nuxt/pages/login.vue | 95 ++++++++----------- frontend-nuxt/pages/profile.vue | 20 +--- frontend-nuxt/pages/setup.vue | 58 +++++------ frontend-nuxt/vitest.config.ts | 6 ++ internal/app/handlers/admin.go | 42 ++++---- internal/app/handlers/auth.go | 60 +++++------- internal/app/handlers/client.go | 20 +++- internal/app/handlers/handlers.go | 19 ++++ internal/app/handlers/handlers_test.go | 76 +++++++++++++++ internal/app/handlers/map.go | 18 ++-- internal/app/migrations.go | 8 +- internal/app/services/admin.go | 50 +++++++--- internal/app/services/admin_test.go | 40 ++++---- internal/app/services/auth.go | 51 +++++++--- internal/app/services/client.go | 27 ++++-- internal/app/services/client_test.go | 59 ++++++++++++ internal/app/services/export.go | 21 +++- internal/app/services/export_test.go | 64 +++++++++++++ internal/app/services/map.go | 28 ++++-- internal/app/topic.go | 2 + 34 files changed, 878 insertions(+), 379 deletions(-) create mode 100644 frontend-nuxt/components/AuthCard.vue create mode 100644 frontend-nuxt/components/ConfirmModal.vue create mode 100644 frontend-nuxt/components/UserAvatar.vue create mode 100644 frontend-nuxt/components/__tests__/UserAvatar.test.ts create mode 100644 frontend-nuxt/composables/__tests__/useMapBookmarks.test.ts create mode 100644 frontend-nuxt/composables/__tests__/useToast.test.ts delete mode 100644 frontend-nuxt/composables/useAdminApi.ts delete mode 100644 frontend-nuxt/composables/useAuth.ts create mode 100644 frontend-nuxt/composables/useFormSubmit.ts create mode 100644 internal/app/services/export_test.go diff --git a/.cursor/rules/frontend-nuxt.mdc b/.cursor/rules/frontend-nuxt.mdc index ece827e..715b185 100644 --- a/.cursor/rules/frontend-nuxt.mdc +++ b/.cursor/rules/frontend-nuxt.mdc @@ -7,5 +7,5 @@ alwaysApply: false # Frontend (Nuxt 3) - All frontend source lives in **frontend-nuxt/** (pages, components, composables, layouts, plugins, `public/gfx`). Production build output goes to `frontend/` and is served by the Go backend. -- Public API to backend: use composables — e.g. `composables/useMapApi.ts`, `useAuth.ts`, `useAdminApi.ts` — not raw fetch in components. +- Public API to backend: use `useMapApi()` from `composables/useMapApi.ts` for all API calls (auth, admin, config, maps, etc.); do not use raw fetch in components. - Nuxt 3 conventions; ensure dev proxy in `nuxt.config.ts` points at backend when running locally (e.g. 3080 or 8080). diff --git a/frontend-nuxt/components/AuthCard.vue b/frontend-nuxt/components/AuthCard.vue new file mode 100644 index 0000000..c86aebd --- /dev/null +++ b/frontend-nuxt/components/AuthCard.vue @@ -0,0 +1,12 @@ + + + diff --git a/frontend-nuxt/components/ConfirmModal.vue b/frontend-nuxt/components/ConfirmModal.vue new file mode 100644 index 0000000..7f5751b --- /dev/null +++ b/frontend-nuxt/components/ConfirmModal.vue @@ -0,0 +1,71 @@ + + + diff --git a/frontend-nuxt/components/UserAvatar.vue b/frontend-nuxt/components/UserAvatar.vue new file mode 100644 index 0000000..46d1aba --- /dev/null +++ b/frontend-nuxt/components/UserAvatar.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend-nuxt/components/__tests__/UserAvatar.test.ts b/frontend-nuxt/components/__tests__/UserAvatar.test.ts new file mode 100644 index 0000000..2566355 --- /dev/null +++ b/frontend-nuxt/components/__tests__/UserAvatar.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import UserAvatar from '../UserAvatar.vue' + +const useGravatarUrlMock = vi.fn(() => 'https://gravatar.example/avatar') +vi.stubGlobal('useGravatarUrl', useGravatarUrlMock) + +describe('UserAvatar', () => { + beforeEach(() => { + useGravatarUrlMock.mockReturnValue('https://gravatar.example/avatar') + }) + + it('renders fallback initial when no email', () => { + const wrapper = mount(UserAvatar, { + props: { username: 'alice' }, + global: { + stubs: { useGravatarUrl: false }, + }, + }) + const fallback = wrapper.find('.bg-primary') + expect(fallback.exists()).toBe(true) + expect(fallback.text()).toBe('A') + }) + + it('renders fallback initial for username', () => { + const wrapper = mount(UserAvatar, { + props: { username: 'bob' }, + }) + expect(wrapper.find('.bg-primary').text()).toBe('B') + }) + + it('uses size for style', () => { + const wrapper = mount(UserAvatar, { + props: { username: 'x', size: 40 }, + }) + const div = wrapper.find('.rounded-full') + expect(div.attributes('style')).toContain('width: 40px') + expect(div.attributes('style')).toContain('height: 40px') + }) +}) diff --git a/frontend-nuxt/composables/__tests__/useMapBookmarks.test.ts b/frontend-nuxt/composables/__tests__/useMapBookmarks.test.ts new file mode 100644 index 0000000..54c353c --- /dev/null +++ b/frontend-nuxt/composables/__tests__/useMapBookmarks.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ref } from 'vue' + +const stateByKey: Record> = {} +const useStateMock = vi.fn((key: string, init: () => unknown) => { + if (!stateByKey[key]) { + stateByKey[key] = ref(init()) + } + return stateByKey[key] +}) +vi.stubGlobal('useState', useStateMock) +vi.stubGlobal('onMounted', vi.fn()) + +const storage: Record = {} +const localStorageMock = { + getItem: vi.fn((key: string) => storage[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + storage[key] = value + }), + clear: vi.fn(() => { + for (const k of Object.keys(storage)) delete storage[k] + }), +} +vi.stubGlobal('localStorage', localStorageMock) +vi.stubGlobal('import.meta.server', false) +vi.stubGlobal('import.meta.client', true) + +import { useMapBookmarks } from '../useMapBookmarks' + +describe('useMapBookmarks', () => { + beforeEach(() => { + storage['hnh-map-bookmarks'] = '[]' + stateByKey['hnh-map-bookmarks-state'] = ref([]) + localStorageMock.getItem.mockImplementation((key: string) => storage[key] ?? null) + localStorageMock.setItem.mockImplementation((key: string, value: string) => { + storage[key] = value + }) + }) + + it('add adds a bookmark and refresh updates state', () => { + const { bookmarks, add, refresh } = useMapBookmarks() + refresh() + expect(bookmarks.value).toEqual([]) + + const id = add({ name: 'Home', mapId: 1, x: 0, y: 0 }) + expect(id).toBeDefined() + expect(bookmarks.value).toHaveLength(1) + expect(bookmarks.value[0].name).toBe('Home') + expect(bookmarks.value[0].mapId).toBe(1) + expect(bookmarks.value[0].id).toBe(id) + }) + + it('update changes name', () => { + const { bookmarks, add, update, refresh } = useMapBookmarks() + refresh() + const id = add({ name: 'Old', mapId: 1, x: 0, y: 0 }) + const ok = update(id, { name: 'New' }) + expect(ok).toBe(true) + expect(bookmarks.value[0].name).toBe('New') + }) + + it('remove deletes bookmark', () => { + const { bookmarks, add, remove, refresh } = useMapBookmarks() + refresh() + const id = add({ name: 'X', mapId: 1, x: 0, y: 0 }) + expect(bookmarks.value).toHaveLength(1) + remove(id) + expect(bookmarks.value).toHaveLength(0) + }) + + it('clear empties bookmarks', () => { + const { bookmarks, add, clear, refresh } = useMapBookmarks() + refresh() + add({ name: 'A', mapId: 1, x: 0, y: 0 }) + expect(bookmarks.value).toHaveLength(1) + clear() + expect(bookmarks.value).toHaveLength(0) + }) +}) diff --git a/frontend-nuxt/composables/__tests__/useToast.test.ts b/frontend-nuxt/composables/__tests__/useToast.test.ts new file mode 100644 index 0000000..9f084e3 --- /dev/null +++ b/frontend-nuxt/composables/__tests__/useToast.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ref } from 'vue' + +const stateByKey: Record> = {} +const useStateMock = vi.fn((key: string, init: () => unknown) => { + if (!stateByKey[key]) { + stateByKey[key] = ref(init()) + } + return stateByKey[key] +}) +vi.stubGlobal('useState', useStateMock) + +import { useToast } from '../useToast' + +describe('useToast', () => { + beforeEach(() => { + stateByKey['hnh-map-toasts'] = ref([]) + }) + + it('exposes toasts and show/dismiss', () => { + const { toasts, success, dismiss } = useToast() + expect(toasts.value).toEqual([]) + + success('Done!') + expect(toasts.value).toHaveLength(1) + expect(toasts.value[0].type).toBe('success') + expect(toasts.value[0].text).toBe('Done!') + + const id = toasts.value[0].id + dismiss(id) + expect(toasts.value).toHaveLength(0) + }) + + it('error and info set type', () => { + const { toasts, error, info } = useToast() + error('Failed') + expect(toasts.value[0].type).toBe('error') + info('Note') + expect(toasts.value[1].type).toBe('info') + }) + + it('each toast has unique id', () => { + const { toasts, success } = useToast() + success('A') + success('B') + const ids = toasts.value.map((t) => t.id) + expect(new Set(ids).size).toBe(2) + }) +}) diff --git a/frontend-nuxt/composables/useAdminApi.ts b/frontend-nuxt/composables/useAdminApi.ts deleted file mode 100644 index 887c0d6..0000000 --- a/frontend-nuxt/composables/useAdminApi.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** Admin API composable. Uses useMapApi internally. */ -export function useAdminApi() { - const api = useMapApi() - - return { - adminUsers: api.adminUsers, - adminUserByName: api.adminUserByName, - adminUserPost: api.adminUserPost, - adminUserDelete: api.adminUserDelete, - adminSettings: api.adminSettings, - adminSettingsPost: api.adminSettingsPost, - adminMaps: api.adminMaps, - adminMapPost: api.adminMapPost, - adminMapToggleHidden: api.adminMapToggleHidden, - adminWipe: api.adminWipe, - adminRebuildZooms: api.adminRebuildZooms, - adminExportUrl: api.adminExportUrl, - adminMerge: api.adminMerge, - adminWipeTile: api.adminWipeTile, - adminSetCoords: api.adminSetCoords, - adminHideMarker: api.adminHideMarker, - } -} diff --git a/frontend-nuxt/composables/useAuth.ts b/frontend-nuxt/composables/useAuth.ts deleted file mode 100644 index 6c803b2..0000000 --- a/frontend-nuxt/composables/useAuth.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { MeResponse } from '~/types/api' - -/** Auth composable: login, logout, me, OAuth, setup. Uses useMapApi internally. */ -export function useAuth() { - const api = useMapApi() - - return { - login: api.login, - logout: api.logout, - me: api.me, - oauthLoginUrl: api.oauthLoginUrl, - oauthProviders: api.oauthProviders, - setupRequired: api.setupRequired, - } -} diff --git a/frontend-nuxt/composables/useFormSubmit.ts b/frontend-nuxt/composables/useFormSubmit.ts new file mode 100644 index 0000000..ab371ff --- /dev/null +++ b/frontend-nuxt/composables/useFormSubmit.ts @@ -0,0 +1,24 @@ +/** + * Composable for form submit with loading and error state. + * Use run(fn) to execute an async action; loading and error are updated automatically. + */ +export function useFormSubmit(defaultError = 'Something went wrong') { + const loading = ref(false) + const error = ref('') + + async function run(fn: () => Promise): Promise { + error.value = '' + loading.value = true + try { + const result = await fn() + return result + } catch (e: unknown) { + error.value = e instanceof Error ? e.message : defaultError + return undefined + } finally { + loading.value = false + } + } + + return { loading, error, run } +} diff --git a/frontend-nuxt/layouts/default.vue b/frontend-nuxt/layouts/default.vue index 43f5fdc..6973b93 100644 --- a/frontend-nuxt/layouts/default.vue +++ b/frontend-nuxt/layouts/default.vue @@ -37,23 +37,7 @@ diff --git a/frontend-nuxt/pages/profile.vue b/frontend-nuxt/pages/profile.vue index 42e54bd..19d95c7 100644 --- a/frontend-nuxt/pages/profile.vue +++ b/frontend-nuxt/pages/profile.vue @@ -16,23 +16,7 @@