diff --git a/frontend/src/composables/useAsyncAction.js b/frontend/src/composables/useAsyncAction.js new file mode 100644 index 0000000..a391625 --- /dev/null +++ b/frontend/src/composables/useAsyncAction.js @@ -0,0 +1,28 @@ +import { ref } from 'vue' + +// Owns the loading/error lifecycle shared by most Pinia stores. `run` +// flips loading on, clears error, runs `fn`, captures any throw into error +// (swallowed so callers can read `store.error`), and always resets loading. +// Returns fn's resolved value on success, or undefined if it threw. +// +// `errorAs` matches each store's existing convention so migrations don't +// change what `store.error` holds: 'raw' keeps the thrown object (the +// ApiError, with .status/.body), 'message' keeps just the string. +export function useAsyncAction({ errorAs = 'raw' } = {}) { + const loading = ref(false) + const error = ref(null) + + async function run(fn) { + loading.value = true + error.value = null + try { + return await fn() + } catch (e) { + error.value = errorAs === 'message' ? (e?.message ?? String(e)) : e + } finally { + loading.value = false + } + } + + return { loading, error, run } +} diff --git a/frontend/src/stores/artistDirectory.js b/frontend/src/stores/artistDirectory.js index 7550c36..111ff9f 100644 --- a/frontend/src/stores/artistDirectory.js +++ b/frontend/src/stores/artistDirectory.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useApi } from '../composables/useApi.js' +import { useAsyncAction } from '../composables/useAsyncAction.js' const PAGE = 60 @@ -8,8 +9,7 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => { const api = useApi() const cards = ref([]) const nextCursor = ref(null) - const loading = ref(false) - const error = ref(null) + const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) const q = ref('') const platform = ref(null) let started = false @@ -17,9 +17,7 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => { async function loadMore() { if (loading.value) return if (started && nextCursor.value === null) return - loading.value = true - error.value = null - try { + await run(async () => { const params = { limit: PAGE } if (q.value) params.q = q.value if (platform.value) params.platform = platform.value @@ -28,11 +26,7 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => { cards.value.push(...body.cards) nextCursor.value = body.next_cursor started = true - } catch (e) { - error.value = e.message - } finally { - loading.value = false - } + }) } async function reset() { diff --git a/frontend/src/stores/credentials.js b/frontend/src/stores/credentials.js index 8fbf4ae..c3558e3 100644 --- a/frontend/src/stores/credentials.js +++ b/frontend/src/stores/credentials.js @@ -1,28 +1,22 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { useApi } from '../composables/useApi.js' +import { useAsyncAction } from '../composables/useAsyncAction.js' export const useCredentialsStore = defineStore('credentials', () => { const api = useApi() const byPlatform = ref(new Map()) const extensionKey = ref(null) - const loading = ref(false) - const error = ref(null) + const { loading, error, run } = useAsyncAction() async function loadAll() { - loading.value = true - error.value = null - try { + await run(async () => { const arr = await api.get('/api/credentials') const m = new Map() for (const c of arr) m.set(c.platform, c) byPlatform.value = m - } catch (e) { - error.value = e - } finally { - loading.value = false - } + }) } async function upload(platform, credential_type, data) { diff --git a/frontend/src/stores/downloads.js b/frontend/src/stores/downloads.js index cc72e7b..564cb68 100644 --- a/frontend/src/stores/downloads.js +++ b/frontend/src/stores/downloads.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { useApi } from '../composables/useApi.js' +import { useAsyncAction } from '../composables/useAsyncAction.js' export const useDownloadsStore = defineStore('downloads', () => { const api = useApi() @@ -13,8 +14,7 @@ export const useDownloadsStore = defineStore('downloads', () => { from_date: null, to_date: null, }) const selected = ref(null) - const loading = ref(false) - const error = ref(null) + const { loading, error, run } = useAsyncAction() const stats = ref({ pending: 0, running: 0, ok: 0, error: 0, skipped: 0 }) const activity = ref({ hours: 24, buckets: [] }) const failing = ref([]) @@ -28,33 +28,22 @@ export const useDownloadsStore = defineStore('downloads', () => { } async function loadFirst() { - loading.value = true - error.value = null - try { + await run(async () => { const body = await api.get('/api/downloads', { params: _params() }) events.value = body cursor.value = body.length ? body[body.length - 1].id : null hasMore.value = body.length === 50 - } catch (e) { - error.value = e - } finally { - loading.value = false - } + }) } async function loadMore() { if (!hasMore.value || cursor.value == null) return - loading.value = true - try { + await run(async () => { const body = await api.get('/api/downloads', { params: _params({ before: cursor.value }) }) events.value.push(...body) cursor.value = body.length ? body[body.length - 1].id : cursor.value hasMore.value = body.length === 50 - } catch (e) { - error.value = e - } finally { - loading.value = false - } + }) } async function loadOne(id) { diff --git a/frontend/src/stores/ml.js b/frontend/src/stores/ml.js index 1323268..5c55982 100644 --- a/frontend/src/stores/ml.js +++ b/frontend/src/stores/ml.js @@ -1,23 +1,17 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { useApi } from '../composables/useApi.js' +import { useAsyncAction } from '../composables/useAsyncAction.js' export const useMLStore = defineStore('ml', () => { const api = useApi() const settings = ref(null) - const loading = ref(false) - const error = ref(null) + const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) async function loadSettings() { - loading.value = true - error.value = null - try { + await run(async () => { settings.value = await api.get('/api/ml/settings') - } catch (e) { - error.value = e.message - } finally { - loading.value = false - } + }) } async function patchSettings(patch) { diff --git a/frontend/src/stores/modal.js b/frontend/src/stores/modal.js index 2efbe42..83f60be 100644 --- a/frontend/src/stores/modal.js +++ b/frontend/src/stores/modal.js @@ -2,14 +2,14 @@ import { defineStore } from 'pinia' import { toast } from '../utils/toast.js' import { ref, computed } from 'vue' import { useApi } from '../composables/useApi.js' +import { useAsyncAction } from '../composables/useAsyncAction.js' export const useModalStore = defineStore('modal', () => { const api = useApi() const currentImageId = ref(null) const current = ref(null) - const loading = ref(false) - const error = ref(null) + const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) // Post-scoped cycle. When set, prev/next cycles within this array // (used by PostCard's expanded-mosaic PostImageGrid clicks). When @@ -20,8 +20,7 @@ export const useModalStore = defineStore('modal', () => { async function open (id, opts = {}) { currentImageId.value = id - loading.value = true - error.value = null + current.value = null // cleared upfront so it stays null on error // Update post-scoped state if caller passed it; otherwise clear so // the next open() from gallery context uses neighbors mode. if (opts.postImageIds != null) { @@ -32,14 +31,9 @@ export const useModalStore = defineStore('modal', () => { postImageIds.value = null postImageIndex.value = 0 } - try { + await run(async () => { current.value = await api.get(`/api/gallery/image/${id}`) - } catch (e) { - error.value = e.message - current.value = null - } finally { - loading.value = false - } + }) } async function close () { diff --git a/frontend/src/stores/posts.js b/frontend/src/stores/posts.js index d5b18c7..926bb63 100644 --- a/frontend/src/stores/posts.js +++ b/frontend/src/stores/posts.js @@ -1,15 +1,15 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { useApi } from '../composables/useApi.js' +import { useAsyncAction } from '../composables/useAsyncAction.js' export const usePostsStore = defineStore('posts', () => { const api = useApi() const items = ref([]) const cursor = ref(null) - const loading = ref(false) + const { loading, error, run } = useAsyncAction() const done = ref(false) - const error = ref(null) const filters = ref({ artist_id: null, platform: null }) function _qs() { @@ -38,18 +38,12 @@ export const usePostsStore = defineStore('posts', () => { async function loadMore() { if (loading.value || done.value) return - loading.value = true - error.value = null - try { + await run(async () => { const body = await api.get('/api/posts', { params: _qs() }) items.value.push(...body.items) cursor.value = body.next_cursor if (body.next_cursor == null) done.value = true - } catch (e) { - error.value = e - } finally { - loading.value = false - } + }) } async function getPostFull(id) { diff --git a/frontend/src/stores/seriesReader.js b/frontend/src/stores/seriesReader.js index f79b8d6..beac66d 100644 --- a/frontend/src/stores/seriesReader.js +++ b/frontend/src/stores/seriesReader.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { useApi } from '../composables/useApi.js' +import { useAsyncAction } from '../composables/useAsyncAction.js' // IR reader.js "viewport-center-in-third" rule, pure for unit tests. // metrics: [{page_number, top, height}] in page order. @@ -33,23 +34,16 @@ export const useSeriesReaderStore = defineStore('seriesReader', () => { const series = ref(null) const pages = ref([]) - const loading = ref(false) - const error = ref(null) + const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) async function load(tagId) { - loading.value = true - error.value = null - try { + series.value = null // cleared upfront so they stay reset on error + pages.value = [] + await run(async () => { const body = await api.get(`/api/series/${tagId}/pages`) series.value = body.series pages.value = body.pages - } catch (e) { - error.value = e.message - series.value = null - pages.value = [] - } finally { - loading.value = false - } + }) } return { series, pages, loading, error, load } diff --git a/frontend/src/stores/showcase.js b/frontend/src/stores/showcase.js index 7e72488..2b93d3d 100644 --- a/frontend/src/stores/showcase.js +++ b/frontend/src/stores/showcase.js @@ -1,32 +1,26 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useApi } from '../composables/useApi.js' +import { useAsyncAction } from '../composables/useAsyncAction.js' const PAGE = 60 export const useShowcaseStore = defineStore('showcase', () => { const api = useApi() const images = ref([]) - const loading = ref(false) - const error = ref(null) + const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) const exhausted = ref(false) const seen = new Set() async function fetchPage() { if (loading.value) return - loading.value = true - error.value = null - try { + await run(async () => { const body = await api.get('/api/showcase', { params: { limit: PAGE } }) const fresh = body.images.filter(i => !seen.has(i.id)) if (fresh.length === 0) { exhausted.value = true; return } for (const i of fresh) seen.add(i.id) images.value.push(...fresh) - } catch (e) { - error.value = e.message - } finally { - loading.value = false - } + }) } async function shuffle() { diff --git a/frontend/src/stores/sources.js b/frontend/src/stores/sources.js index d3955b1..77ece67 100644 --- a/frontend/src/stores/sources.js +++ b/frontend/src/stores/sources.js @@ -1,42 +1,30 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useApi } from '../composables/useApi.js' +import { useAsyncAction } from '../composables/useAsyncAction.js' export const useSourcesStore = defineStore('sources', () => { const api = useApi() // keyed cache: null => all sources, number => artist_id filtered const byArtist = ref(new Map()) - const loading = ref(false) - const error = ref(null) + const { loading, error, run } = useAsyncAction() const scheduleStatus = ref(null) const allSources = computed(() => byArtist.value.get(null) ?? []) async function loadAll() { - loading.value = true - error.value = null - try { + await run(async () => { const body = await api.get('/api/sources') byArtist.value.set(null, body) - } catch (e) { - error.value = e - } finally { - loading.value = false - } + }) } async function loadForArtist(artistId) { - loading.value = true - error.value = null - try { + await run(async () => { const body = await api.get('/api/sources', { params: { artist_id: artistId } }) byArtist.value.set(artistId, body) - } catch (e) { - error.value = e - } finally { - loading.value = false - } + }) } function _invalidate(artistId) { diff --git a/frontend/src/stores/suggestions.js b/frontend/src/stores/suggestions.js index 0e4447c..124bee3 100644 --- a/frontend/src/stores/suggestions.js +++ b/frontend/src/stores/suggestions.js @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { toast } from '../utils/toast.js' import { ref } from 'vue' import { useApi } from '../composables/useApi.js' +import { useAsyncAction } from '../composables/useAsyncAction.js' // Category display order: people/sources first, general last. export const CATEGORY_ORDER = ['artist', 'character', 'copyright', 'general'] @@ -15,23 +16,16 @@ export const CATEGORY_LABELS = { export const useSuggestionsStore = defineStore('suggestions', () => { const api = useApi() const byCategory = ref({}) // { category: [suggestion, ...] } - const loading = ref(false) - const error = ref(null) + const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) let currentImageId = null async function load(imageId) { currentImageId = imageId - loading.value = true - error.value = null - try { + byCategory.value = {} // cleared upfront so it stays empty on error + await run(async () => { const body = await api.get(`/api/images/${imageId}/suggestions`) byCategory.value = body.by_category || {} - } catch (e) { - error.value = e.message - byCategory.value = {} - } finally { - loading.value = false - } + }) } function _drop(category, predicate) { diff --git a/frontend/src/stores/tagDirectory.js b/frontend/src/stores/tagDirectory.js index 4528a17..fc1b838 100644 --- a/frontend/src/stores/tagDirectory.js +++ b/frontend/src/stores/tagDirectory.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useApi } from '../composables/useApi.js' +import { useAsyncAction } from '../composables/useAsyncAction.js' const PAGE = 60 @@ -8,8 +9,7 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => { const api = useApi() const cards = ref([]) const nextCursor = ref(null) - const loading = ref(false) - const error = ref(null) + const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) const kind = ref(null) const q = ref('') let started = false @@ -17,9 +17,7 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => { async function loadMore() { if (loading.value) return if (started && nextCursor.value === null) return - loading.value = true - error.value = null - try { + await run(async () => { const params = { limit: PAGE } if (kind.value) params.kind = kind.value if (q.value) params.q = q.value @@ -28,11 +26,7 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => { cards.value.push(...body.cards) nextCursor.value = body.next_cursor started = true - } catch (e) { - error.value = e.message - } finally { - loading.value = false - } + }) } async function reset() {