diff --git a/Dockerfile b/Dockerfile index 7737ca2..14344dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,8 @@ FROM node:24.10-alpine3.22 AS builder WORKDIR /app +RUN apk add --no-cache python3 make g++ libc6-compat + COPY package*.json ./ RUN npm ci --prefer-offline --no-audit diff --git a/package.json b/package.json index 3c12e7c..88ed54a 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,6 @@ "prettier": "3.5.3", "vfonts": "^0.0.3", "vite": "^6.2.1", - "vite-plugin-vue-devtools": "^7.7.2" + "vite-plugin-vue-devtools": "^8.0.3" } } 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 @@ + + + + + {{ text }} + + + + 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 @@ + + + + Manage labels + + + 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 }) - + - + diff --git a/src/views/CollectionView/CollectionMerchCard.vue b/src/views/CollectionView/CollectionMerchCard.vue index bb90992..c3ca223 100644 --- a/src/views/CollectionView/CollectionMerchCard.vue +++ b/src/views/CollectionView/CollectionMerchCard.vue @@ -1,14 +1,33 @@ - Add merch + Add merch - + @@ -49,13 +60,12 @@ watch(localValue, (newVal) => { placeholder="Select label" clearable multiple - v-model:value="value" - :options="options" - class="mobile-full-width" /> + v-model:value="selectedLabelUuids" + :options="labelOptions" + class="mobile-full-width" + /> - + diff --git a/src/views/DetailsView.vue b/src/views/DetailsView.vue index 95b27c0..7a2e53d 100644 --- a/src/views/DetailsView.vue +++ b/src/views/DetailsView.vue @@ -8,6 +8,7 @@ import { useChartsApi } from '@/api/charts.js' import EditLink from '@/views/DetailsView/EditLink.vue' import CopyToClipboard from '@/components/CopyToClipboard.vue' import DetailsViewImages from '@/views/DetailsView/DetailsViewImages.vue' +import AttachLabel from '@/views/DetailsView/AttachLabel.vue' const { getMerchDetails, deleteMerch } = useMerchApi() const { getDistinctPrices } = useChartsApi() @@ -107,6 +108,7 @@ onMounted(() => { Uuid: {{ merchDetails.merch_uuid }} Name: {{ merchDetails.name }} + diff --git a/src/views/DetailsView/AttachLabel.vue b/src/views/DetailsView/AttachLabel.vue new file mode 100644 index 0000000..18ca912 --- /dev/null +++ b/src/views/DetailsView/AttachLabel.vue @@ -0,0 +1,147 @@ + + + + Select labels to attach: + + + + Attach + + + + Attached labels. Click label to detach it. + + + + + + + + Manage labels + + + + + + diff --git a/src/views/DetailsView/DetailsViewImages.vue b/src/views/DetailsView/DetailsViewImages.vue index 7284760..dc7a258 100644 --- a/src/views/DetailsView/DetailsViewImages.vue +++ b/src/views/DetailsView/DetailsViewImages.vue @@ -54,37 +54,21 @@ function onFileInputChange(event) { event.target.value = '' } -async function handleUpload({ fileList: newFileList }) { - const file = newFileList[newFileList.length - 1] +async function fetchImage(bustCache = false) { try { - await uploadImage(props.merchUuid, file.file) + let imgUrl = getImageUrl(props.merchUuid, 'full') - const { imgUrl } = await getImageUrl(props.merchUuid, 'full') - - message.success('Image uploaded successfully.') - - fileList.value = [ - { - name: file.name, - url: imgUrl, - status: 'finished', - }, - ] - } catch (error) { - message.error('Upload error: ' + (error.message || 'Unknown error.')) - } -} - -onMounted(async () => { - try { - const imgUrl = getImageUrl(props.merchUuid, 'full'); + if (bustCache) { + const separator = imgUrl.includes('?') ? '&' : '?' + imgUrl += `${separator}_t=${Date.now()}` + } await new Promise((resolve, reject) => { - const img = new Image(); - img.src = imgUrl; - img.onload = () => resolve(imgUrl); - img.onerror = () => reject(new Error('Image not found')); - }); + const img = new Image() + img.src = imgUrl + img.onload = () => resolve(imgUrl) + img.onerror = () => reject(new Error('Image not found')) + }) fileList.value = [ { @@ -92,13 +76,28 @@ onMounted(async () => { url: imgUrl, status: 'finished', }, - ]; + ] } catch (error) { - fileList.value = []; + fileList.value = [] if (!error.message.includes('404')) { - console.error('Error getting image: ', error); + console.error('Error getting image:', error) } } +} + +async function handleUpload({ fileList: newFileList }) { + const file = newFileList[newFileList.length - 1] + try { + await uploadImage(props.merchUuid, file.file) + message.success('Image uploaded successfully.') + await fetchImage(true) + } catch (error) { + message.error('Upload error: ' + (error.message || 'Unknown error.')) + } +} + +onMounted(async () => { + await fetchImage(false) }); const showConfirmDelete = ref(false) diff --git a/src/views/LabelsView.vue b/src/views/LabelsView.vue new file mode 100644 index 0000000..00637d7 --- /dev/null +++ b/src/views/LabelsView.vue @@ -0,0 +1,78 @@ + + + + Manage labels + Create label + + + Enter new values to create label. + + + + Create + Cancel + + + + + Create Label + + + + Current labels + Tip: click on a record to edit / delete label. + + + + + + + + + diff --git a/src/views/LabelsView/LabelCard.vue b/src/views/LabelsView/LabelCard.vue new file mode 100644 index 0000000..0b9d4e7 --- /dev/null +++ b/src/views/LabelsView/LabelCard.vue @@ -0,0 +1,143 @@ + + + + + + + + Name: {{ labelData.name }} + UUID: {{ labelData.label_uuid }} + + + + + + + + + + + + + + + + + + Enter new values to update. Or hit delete button to delete record. + + + + + + Check to confirm delete + + + Update + Delete + Cancel + + + + + + + diff --git a/src/views/LabelsView/LabelForm.vue b/src/views/LabelsView/LabelForm.vue new file mode 100644 index 0000000..c10e70c --- /dev/null +++ b/src/views/LabelsView/LabelForm.vue @@ -0,0 +1,36 @@ + + + + + + Preview: + + + + + + + + + + + + + + + + diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue index 0a2015e..756bb8d 100644 --- a/src/views/LoginView.vue +++ b/src/views/LoginView.vue @@ -2,16 +2,23 @@ import { reactive, ref } from 'vue' import { useAuthStore } from '@/stores/authStore.js' import { storeToRefs } from 'pinia' +import { useMessage } from 'naive-ui' const store = useAuthStore() +const messages = useMessage() const { activeTab } = storeToRefs(store) const signInEmail = ref('') const signInPassword = ref('') -const onSignIn = () => { - store.login(signInEmail.value, signInPassword.value) +const onSignIn = async () => { + try{ + await store.login(signInEmail.value, signInPassword.value) + messages.success('Login success') + } catch (error) { + messages.error("Login error") + } } const signUp = reactive({ @@ -20,8 +27,35 @@ const signUp = reactive({ reenterPassword: '', }) -const onSignUp = () => { - store.register(signUp.email, signUp.password) +const onSignUp = async () => { + if (!signUp.email.trim()) { + messages.error('Email is required') + return + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(signUp.email)) { + messages.error('Please enter a valid email address') + return + } + + if (!signUp.password.trim()) { + messages.error('Password is required') + return + } + + if (signUp.password !== signUp.reenterPassword) { + messages.error('Passwords do not match') + return + } + + try{ + await store.register(signUp.email, signUp.password) + messages.success('Register success') + activeTab.value = 'signin' + } catch (error) { + messages.error("Register error") + } } @@ -43,7 +77,7 @@ const onSignUp = () => { - Sign In + Sign In @@ -58,7 +92,7 @@ const onSignUp = () => { - Sign up + Sign up diff --git a/src/views/PersonalView/PersonalMainBlock.vue b/src/views/PersonalView/PersonalMainBlock.vue index 0e89baf..cc45fca 100644 --- a/src/views/PersonalView/PersonalMainBlock.vue +++ b/src/views/PersonalView/PersonalMainBlock.vue @@ -14,27 +14,26 @@ onMounted(() => { - + Main - + {{ userData?.email || '---' }} - + {{ userData?.username || '---' }} - + {{ userData?.created_at || '---' }} - diff --git a/src/views/PersonalView/PersonalSessionBlock.vue b/src/views/PersonalView/PersonalSessionBlock.vue index f8192d4..a67743a 100644 --- a/src/views/PersonalView/PersonalSessionBlock.vue +++ b/src/views/PersonalView/PersonalSessionBlock.vue @@ -35,16 +35,16 @@ const onLogout = () => { - + Session - + {{ currentSession?.uuid || '---' }} - + {{ formattedDate }} @@ -52,7 +52,6 @@ const onLogout = () => { Log out - diff --git a/src/views/ZeroPricesView/PeriodSelectTab.vue b/src/views/ZeroPricesView/PeriodSelectTab.vue new file mode 100644 index 0000000..4239f15 --- /dev/null +++ b/src/views/ZeroPricesView/PeriodSelectTab.vue @@ -0,0 +1,54 @@ + + + + + + Delete + + + + diff --git a/src/views/ZeroPricesView/TargetZeroesTab.vue b/src/views/ZeroPricesView/TargetZeroesTab.vue new file mode 100644 index 0000000..ba71ba5 --- /dev/null +++ b/src/views/ZeroPricesView/TargetZeroesTab.vue @@ -0,0 +1,71 @@ + + + + + Zero prices + No data + + + + + + + + + + + + diff --git a/src/views/ZeroPricesView/ZeroPriceCard.vue b/src/views/ZeroPricesView/ZeroPriceCard.vue new file mode 100644 index 0000000..32bd31f --- /dev/null +++ b/src/views/ZeroPricesView/ZeroPriceCard.vue @@ -0,0 +1,71 @@ + + + + + + + + Delete + + + Name: {{ props.zeroPrice.name }} + Created: {{ props.zeroPrice.created_at }} + Origin: + + {{ props.zeroPrice.origin }} + + + + + + + diff --git a/src/views/ZeroPricesView/ZeroPricesToolbar.vue b/src/views/ZeroPricesView/ZeroPricesToolbar.vue new file mode 100644 index 0000000..abe01cb --- /dev/null +++ b/src/views/ZeroPricesView/ZeroPricesToolbar.vue @@ -0,0 +1,56 @@ + + + + + + Select records to delete + + + {{ props.selected.length }} items selected + + + Click here to select all + + + + + Delete Selected + + + + + +
Uuid: {{ merchDetails.merch_uuid }}
Name: {{ merchDetails.name }}
Select labels to attach:
Attached labels. Click label to detach it.
Enter new values to create label.
Enter new values to update. Or hit delete button to delete record.