diff --git a/src/api/zeroPrices.js b/src/api/zeroPrices.js new file mode 100644 index 0000000..a7db41a --- /dev/null +++ b/src/api/zeroPrices.js @@ -0,0 +1,40 @@ +import { apiClient } from '@/services/apiClient.js' + +export const useZeroPrices = () => { + const getZeroPrices = async () => { + try { + return await apiClient.get('/merch/zeroprices') + } catch (error) { + console.log('Get zero prices error: ', error) + throw error + } + } + + const deleteZeroPrices = async (list) => { + try { + await apiClient.delete('/merch/zeroprices', list) + } + catch (error) { + console.log('Delete target zero prices error: ', error) + throw error + } + } + + const deleteZeroPricesPeriod = async (start, end) => { + const params = new URLSearchParams({ start, end }).toString() + const url = `/merch/zeroprices/period${params ? `?${params}` : ''}` + const response = await apiClient.delete(url) + + if (response.status === 200) { + return response + } else { + console.log('Delete period select zero prices error: ', response) + } + } + + return { + getZeroPrices, + deleteZeroPrices, + deleteZeroPricesPeriod, + } +} diff --git a/src/components/ChartBlock.vue b/src/components/ChartBlock.vue index 96a5243..11bd874 100644 --- a/src/components/ChartBlock.vue +++ b/src/components/ChartBlock.vue @@ -13,6 +13,7 @@ import { } from 'chart.js' import { Line } from 'vue-chartjs' import 'chartjs-adapter-date-fns' +import { originColors } from '@/services/colors.js' ChartJS.register( Title, @@ -33,11 +34,6 @@ const props = defineProps({ }, }) -const originColors = { - surugaya: '#2d3081', - mandarake: '#924646', -} - const chartData = ref({ datasets: [], }) diff --git a/src/components/LabelDotTemplate.vue b/src/components/LabelDotTemplate.vue new file mode 100644 index 0000000..c37db3f --- /dev/null +++ b/src/components/LabelDotTemplate.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/src/components/LabelTemplate.vue b/src/components/LabelTemplate.vue new file mode 100644 index 0000000..f26f669 --- /dev/null +++ b/src/components/LabelTemplate.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/src/components/ManageLabels.vue b/src/components/ManageLabels.vue new file mode 100644 index 0000000..6d57f82 --- /dev/null +++ b/src/components/ManageLabels.vue @@ -0,0 +1,7 @@ + diff --git a/src/components/Navbar/NavBar.vue b/src/components/Navbar/NavBar.vue index d8774fb..a05c67c 100644 --- a/src/components/Navbar/NavBar.vue +++ b/src/components/Navbar/NavBar.vue @@ -17,6 +17,7 @@ const mainMenu = computed(() => { { label: 'Collection', key: 'collection' }, { label: 'Charts', key: 'charts' }, { label: 'Parsers', key: 'parsers' }, + { label: 'Zero prices', key: 'zeroprices' }, ] }) @@ -28,6 +29,7 @@ const authMenu = computed(() => { } return [ + { label: 'Labels', key: 'labels' }, { label: 'Personal', key: 'personal' }, ] }) @@ -137,7 +139,7 @@ const renderLabel = (option) => { display: flex; align-items: center; margin-left: auto; - min-width: 150px; + min-width: 250px; padding-right: 16px; box-sizing: border-box; } diff --git a/src/router/index.js b/src/router/index.js index 53e4751..0a0ff85 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -7,6 +7,8 @@ import ParsersView from '@/views/ParsersView.vue' import PersonalView from '@/views/PersonalView.vue' import AddMerchView from '@/views/AddMerchView.vue' import DetailsView from '@/views/DetailsView.vue' +import LabelsView from '@/views/LabelsView.vue' +import ZeroPricesView from '@/views/ZeroPricesView.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -52,6 +54,16 @@ const router = createRouter({ component: DetailsView, props: true, }, + { + path: '/labels', + name: 'labels', + component: LabelsView, + }, + { + path: '/zeroprices', + name: 'zeroprices', + component: ZeroPricesView, + }, ], }) diff --git a/src/services/apiClient.js b/src/services/apiClient.js index 60d5d46..639fb84 100644 --- a/src/services/apiClient.js +++ b/src/services/apiClient.js @@ -6,23 +6,25 @@ let isRefreshing = false let refreshPromise = null function createConfig(options = {}) { - const authStore = useAuthStore() - const headers = { - 'Content-Type': 'application/json', - ...options.headers, - } + const token = localStorage.getItem('accessToken'); + const isFormData = options.body instanceof FormData; - if (authStore.accessToken) { - headers['Authorization'] = `Bearer ${authStore.accessToken}` + const headers = { + ...(isFormData ? {} : { 'Content-Type': 'application/json' }), + ...options.headers, + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; } return { headers, - credentials: 'include', ...options, - } + }; } + async function refreshAccessToken() { const authStore = useAuthStore() @@ -53,7 +55,8 @@ async function refreshAccessToken() { }) .catch((error) => { - throw error + console.error('Refresh error:', error) + throw new Error('REFRESH_FAILED') }) .finally(() => { @@ -66,29 +69,32 @@ async function refreshAccessToken() { async function request(url, options = {}, isRetry = false) { const config = createConfig(options) - const response = await fetch(`${BASE_URL}${url}`, config) if (response.status === 401 && !isRetry) { try { const data = await refreshAccessToken() - const token = data.access_token - if (!token) { - throw new Error('Refresh response did not contain access_token') - } + if (!token) throw new Error('Refresh response did not contain access_token') + const newOptions = { ...options, headers: { ...options.headers, - 'Authorization': `Bearer ${token}`, + Authorization: `Bearer ${token}`, }, } + return await request(url, newOptions, true) } catch (e) { - const authStore = useAuthStore() - authStore.forceLogout() + if (e.message === 'REFRESH_FAILED') { + const authStore = useAuthStore() + authStore.forceLogout() + console.warn('Force logout (refresh failed)', url) + } else { + console.error('Unexpected error during refresh', e) + } throw e } } @@ -100,9 +106,15 @@ async function request(url, options = {}, isRetry = false) { } catch { errorData = {} } - throw new Error(errorData.message || `HTTP Error: ${response.status}`) + + return { + status: response.status, + ok: false, + error: errorData.message || `HTTP Error: ${response.status}`, + } } + let data = null const contentType = response.headers.get('content-type') if (contentType?.includes('application/json')) { @@ -113,11 +125,7 @@ async function request(url, options = {}, isRetry = false) { } } - return { - status: response.status, - ok: response.ok, - data, - } + return { status: response.status, ok: response.ok, data } } export const apiClient = { @@ -132,14 +140,14 @@ export const apiClient = { return request(url, { method: 'POST', body: isFormData ? data : JSON.stringify(data), - headers: isFormData - ? {} - : { 'Content-Type': 'application/json' } }) }, put: (url, data) => request(url, { method: 'PUT', body: JSON.stringify(data), }), - delete: (url) => request(url, { method: 'DELETE' }), + delete: (url, data) => request(url, { + method: 'DELETE', + body: data ? JSON.stringify(data) : undefined, + }), } diff --git a/src/services/colors.js b/src/services/colors.js new file mode 100644 index 0000000..c337f2a --- /dev/null +++ b/src/services/colors.js @@ -0,0 +1,4 @@ +export const originColors = { + surugaya: '#2d3081', + mandarake: '#924646', +}; diff --git a/src/stores/authStore.js b/src/stores/authStore.js index 7cdfc82..977cd25 100644 --- a/src/stores/authStore.js +++ b/src/stores/authStore.js @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { computed, ref } from 'vue' +import { computed, nextTick, ref } from 'vue' import { apiClient } from '@/services/apiClient' import router from '@/router/index.js' @@ -54,9 +54,11 @@ export const useAuthStore = defineStore('auth', () => { try { const response = await apiClient.post('/user/auth/login', { email, password }) setToken(response.data.access_token) + await nextTick() router.push({ name: 'collection'}) } catch (error) { console.error('Login error:', error) + throw error } } diff --git a/src/stores/labelsStore.js b/src/stores/labelsStore.js new file mode 100644 index 0000000..ab8ef28 --- /dev/null +++ b/src/stores/labelsStore.js @@ -0,0 +1,107 @@ +import { defineStore } from 'pinia' +import { apiClient } from '@/services/apiClient.js' +import { ref } from 'vue' + +function safeParseJSON(str, fallback = []) { + if (str === null || str === undefined || str === 'null' || str === '') { + return fallback + } + try { + const parsed = JSON.parse(str) + return Array.isArray(parsed) ? parsed : fallback + } catch (e) { + console.warn('Failed to parse localStorage item "labels", using fallback:', e) + return fallback + } +} + +export const useLabelsStore = defineStore('labels', () => { + //state + const labels = ref(safeParseJSON(localStorage.getItem('labels'), [])) + + //getters + + //action + const createLabel = async (newLabel) => { + try { + await apiClient.post('/merch/labels', newLabel) + await getLabels() + } catch (error) { + console.error('Failed to create label:', error) + throw error + } + } + + const getLabels = async () => { + try { + const response = await apiClient.get(`/merch/labels?_=${Date.now()}`) + const labelList = Array.isArray(response.data) ? response.data : [] + labels.value = labelList + localStorage.setItem('labels', JSON.stringify(labelList)) + } catch (error) { + console.error('Failed to fetch labels:', error) + labels.value = [] + } + } + + const updateLabel = async (uuid, updatedData) => { + try { + await apiClient.put(`/merch/labels/${uuid}?_=${Date.now()}`, updatedData) + await getLabels() + } catch (error) { + console.error('Failed to update label:', error) + throw error + } + } + + const deleteLabel = async (uuid) => { + try { + await apiClient.delete(`/merch/labels/${uuid}`) + await getLabels() + } catch (error) { + console.error('Failed to delete label:', error) + throw error + } + } + + const attachLabel = async (merchUuid, labelUuid) => { + try { + const payload = { merch_uuid: merchUuid, label_uuid: labelUuid } + await apiClient.post('/merch/labels/attach', payload) + } catch (error) { + console.error('Failed to attach label:', error) + throw error + } + } + + const detachLabel = async (merchUuid, labelUuid) => { + try { + const payload = { merch_uuid: merchUuid, label_uuid: labelUuid } + await apiClient.post('/merch/labels/detach', payload) + } catch (error) { + console.error('Failed to detach label:', error) + throw error + } + } + + const getMerchLabels = async (merchUuid) => { + try { + const response = await apiClient.get(`/merch/labels/${merchUuid}`) + return response + } catch (error) { + console.error('Failed to get merch labels:', error) + throw error + } + } + + return { + labels, + createLabel, + getLabels, + updateLabel, + deleteLabel, + attachLabel, + detachLabel, + getMerchLabels, + } +}) diff --git a/src/styles/styles.css b/src/styles/styles.css index 8219206..815f71a 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -2,6 +2,10 @@ margin-top: 10px; } +.mt-20 { + margin-top: 20px; +} + .mb-10 { margin-bottom: 10px; } @@ -166,3 +170,35 @@ body, max-height: 70vh; object-fit: contain; } + +.router-link-button{ + color: inherit; + text-decoration: none; +} + +.label-container { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.label-column { + display: flex; + flex-direction: column; +} + +.label-row { + display: flex; + align-items: center; + gap: 10px; +} + +.label-preview { + justify-content: center; + margin-bottom: 20px; +} + +.padding-lr-30 { + padding-left: 30px; + padding-right: 30px; +} diff --git a/src/views/CollectionView.vue b/src/views/CollectionView.vue index d3fc1a5..52818c0 100644 --- a/src/views/CollectionView.vue +++ b/src/views/CollectionView.vue @@ -4,12 +4,14 @@ import CollectionMerchCard from '@/views/CollectionView/CollectionMerchCard.vue' import { computed, onMounted, ref } from 'vue' import { useMerchApi } from '@/api/merch.js' import ScrollToTopButton from '@/components/ScrollToTopButton.vue' +import { useLabelsStore } from '@/stores/labelsStore' const merchList = ref(null) const loading = ref(true) const error = ref(null) const { getMerchList } = useMerchApi() +const { getLabels } = useLabelsStore() const fetchMerch = async () => { try { @@ -25,27 +27,39 @@ const fetchMerch = async () => { onMounted(() => { fetchMerch() + getLabels() }) const searchQuery = ref('') +const selectedLabelUuids = ref([]) const filteredMerch = computed(() => { - if (!searchQuery.value.trim()) { - return merchList.value + let result = merchList.value || [] + + if (searchQuery.value.trim()) { + const q = searchQuery.value.toLowerCase() + result = result.filter((item) => item.name.toLowerCase().includes(q)) } - const q = searchQuery.value.toLowerCase() - return merchList.value.filter((item) => item.name.toLowerCase().includes(q)) + + if (selectedLabelUuids.value.length > 0) { + const selectedSet = new Set(selectedLabelUuids.value) + result = result.filter((item) => { + return item.labels && item.labels.some((labelUuid) => selectedSet.has(labelUuid)) + }) + } + + return result })