Compare commits

..

No commits in common. "b12c79fa213cd1031d9dc9ce8dd715e375b75779" and "6ca9d89b8d9f8cbb3db7a9df0c00ad1c1ec47c21" have entirely different histories.

11 changed files with 92 additions and 319 deletions

View file

@ -1,40 +0,0 @@
on:
push:
tags:
- 'v[0-9]+*'
workflow_dispatch:
env:
IMAGE_NAME: mtv2-frontend
jobs:
build-and-push:
name: Make image
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Forgejo
uses: docker/login-action@v3
with:
registry: repo.nqws.ru
username: ${{ secrets.MAINTAINER_USERNAME }}
password: ${{ secrets.MAINTAINER_TOKEN }}
- name: Extract version from tag
id: extract_version
run: |
VERSION=${GITHUB_REF#refs/tags/}
echo "VERSION=${VERSION}" >> $GITHUB_ENV
- name: Make image
run: |
docker buildx build --platform linux/amd64 \
--tag repo.nqws.ru/${{ github.repository }}:latest \
--tag repo.nqws.ru/${{ github.repository }}:${{ env.VERSION }} \
--push .

View file

@ -1,17 +1,5 @@
FROM node:24.10-alpine3.22 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --prefer-offline --no-audit
COPY . .
RUN npm run build
FROM nginx:alpine FROM nginx:alpine
COPY dist/ /usr/share/nginx/html
COPY --from=builder /app/dist/ /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.conf /etc/nginx/nginx.conf

View file

@ -1,40 +1,23 @@
<script setup> <script setup>
import NavBar from '@/components/Navbar/NavBar.vue' import NavBar from '@/components/Navbar/NavBar.vue'
import Footer from '@/components/Footer/Footer.vue'
</script> </script>
<template> <template>
<n-config-provider> <NavBar />
<n-message-provider>
<n-dialog-provider>
<n-notification-provider>
<NavBar />
<n-grid <n-grid responsive="screen" item-responsive cols="24" :x-gap="16" :y-gap="16" class="shift">
responsive="screen" <n-gi span="xs:1 s:1 m:2 l:2 xl:3 xxl:3" />
item-responsive
cols="24"
:x-gap="16"
:y-gap="16"
class="shift"
>
<n-gi span="xs:1 s:1 m:2 l:2 xl:3 xxl:3" />
<n-gi span="xs:22 s:22 m:20 l:20 xl:18 xxl:18"> <n-gi span="xs:22 s:22 m:20 l:20 xl:18 xxl:18">
<router-view /> <router-view />
</n-gi> </n-gi>
<n-gi span="xs:1 s:1 m:2 l:2 xl:3 xxl:3" /> <n-gi span="xs:1 s:1 m:2 l:2 xl:3 xxl:3" />
</n-grid> </n-grid>
</n-notification-provider>
</n-dialog-provider>
</n-message-provider>
</n-config-provider>
<Footer />
</template> </template>
<style scoped> <style scoped>
.shift { .shift{
padding-top: 10px; padding-top: 10px;
} }
</style> </style>

View file

@ -137,9 +137,11 @@ const chartOptions = {
watch( watch(
() => props.chartsData, () => props.chartsData,
(newData) => { (newData) => {
// Сброс состояния
showPlaceholder.value = false showPlaceholder.value = false
placeholderMessage.value = '' placeholderMessage.value = ''
// Случай 1: null или undefined
if (newData === null || newData === undefined) { if (newData === null || newData === undefined) {
showPlaceholder.value = true showPlaceholder.value = true
placeholderMessage.value = 'No prices found' placeholderMessage.value = 'No prices found'
@ -147,6 +149,7 @@ watch(
return return
} }
// Случай 2: явная ошибка в объекте
if (typeof newData === 'object' && newData.error) { if (typeof newData === 'object' && newData.error) {
showPlaceholder.value = true showPlaceholder.value = true
placeholderMessage.value = newData.error placeholderMessage.value = newData.error
@ -154,11 +157,13 @@ watch(
return return
} }
// Случай 3: нормальный объект пробуем преобразовать
let datasets = [] let datasets = []
if (newData.origins && Array.isArray(newData.origins)) { if (newData.origins && Array.isArray(newData.origins)) {
datasets = transformToChartJSSeries(newData) datasets = transformToChartJSSeries(newData)
} }
// 🔥 Ключевое изменение: проверяем, есть ли хоть один датасет
if (datasets.length === 0) { if (datasets.length === 0) {
showPlaceholder.value = true showPlaceholder.value = true
placeholderMessage.value = 'No prices found' placeholderMessage.value = 'No prices found'

View file

@ -1,48 +0,0 @@
<script setup>
import { useMessage } from 'naive-ui'
const message = useMessage()
const props = defineProps({
text: {
type: String,
required: true,
},
})
const copyToClipboard = async () => {
if (!props.text || props.text.trim() === '') {
message.error('Nothing to copy to clipboard')
return
}
try {
await navigator.clipboard.writeText(props.text)
copySuccess()
} catch (err) {
console.error('Error copy to clipboard', err)
copyError()
}
}
function copySuccess() {
const displayText = props.text.length > 50
? props.text.substring(0, 50) + '...'
: props.text
message.success(`Copied to clipboard: ${displayText}`)
}
function copyError() {
message.error('Nothing to copy to clipboard')
}
</script>
<template>
<span @click="copyToClipboard">
<slot />
</span>
</template>
<style scoped></style>

View file

@ -1,12 +0,0 @@
<script setup>
</script>
<template>
<n-divider class="mt-10" />
<p class="text-center text-muted">Merch tracker 2024 - 2025 </p>
</template>
<style scoped>
</style>

View file

@ -7,10 +7,7 @@ import App from './App.vue'
import router from './router' import router from './router'
const app = createApp(App) const app = createApp(App)
export const BASE_URL = 'https://api.nqws.ru/api/v2' export const BASE_URL = 'http://localhost:9090/api/v2'
// export const BASE_URL = 'http://localhost:9090/api/v2'
export const BASE_MANDARAKE_LINK = 'https://order.mandarake.co.jp/order/listPage/list?soldOut=1&keyword='
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)

View file

@ -2,10 +2,6 @@
margin-top: 10px; margin-top: 10px;
} }
.mb-10 {
margin-bottom: 10px;
}
.mb-20 { .mb-20 {
margin-bottom: 30px; margin-bottom: 30px;
} }
@ -57,10 +53,6 @@
text-align: center; text-align: center;
} }
.text-muted {
opacity: 0.6;
}
.sticky-search-container { .sticky-search-container {
position: sticky; position: sticky;
top: 0; top: 0;
@ -108,15 +100,3 @@
:deep(.mobile-full-width) { :deep(.mobile-full-width) {
width: 100%; width: 100%;
} }
.default-color{
color: #18a058;
}
.underline {
text-decoration: underline;
}
.link-like-text {
cursor: pointer;
}

View file

@ -2,10 +2,11 @@
import router from '@/router/index.js' import router from '@/router/index.js'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useMerchApi } from '@/api/merch.js' import { useMerchApi } from '@/api/merch.js'
import { BASE_MANDARAKE_LINK } from '@/main.js'
const { addMerch } = useMerchApi() const { addMerch } = useMerchApi()
const mandarakeLink = 'https://order.mandarake.co.jp/order/listPage/list?soldOut=1&keyword='
const name = ref('') const name = ref('')
// surugaya block // surugaya block
@ -16,7 +17,7 @@ const checkAutoComplete = ref(true)
const customLink = ref('') const customLink = ref('')
const mandarakeAutocomplete = computed(() => { const mandarakeAutocomplete = computed(() => {
return `${BASE_MANDARAKE_LINK}${name.value}` return `${mandarakeLink}${name.value}`
}) })
const mandarakeResultLink = computed({ const mandarakeResultLink = computed({

View file

@ -6,7 +6,6 @@ import PeriodSelector from '@/components/PeriodSelector.vue'
import ChartBlock from '@/components/ChartBlock.vue' import ChartBlock from '@/components/ChartBlock.vue'
import { useChartsApi } from '@/api/charts.js' import { useChartsApi } from '@/api/charts.js'
import EditLink from '@/views/DetailsView/EditLink.vue' import EditLink from '@/views/DetailsView/EditLink.vue'
import CopyToClipboard from '@/components/CopyToClipboard.vue'
const { getMerchDetails, deleteMerch } = useMerchApi() const { getMerchDetails, deleteMerch } = useMerchApi()
const { getDistinctPrices } = useChartsApi() const { getDistinctPrices } = useChartsApi()
@ -18,11 +17,6 @@ const props = defineProps({
}, },
}) })
const editing = ref({
surugaya: false,
mandarake: false,
})
const merchDetails = ref(null) const merchDetails = ref(null)
const loading = ref(true) const loading = ref(true)
const error = ref(null) const error = ref(null)
@ -82,15 +76,11 @@ const fetchPrices = async (days = 7) => {
} }
} }
function handleLinkUpdate(origin, newLink) {
merchDetails.value[`origin_${origin}`].link = newLink
editing.value[origin] = false
}
function handleSelectDays(days) { function handleSelectDays(days) {
fetchPrices(days) fetchPrices(days)
} }
onMounted(() => { onMounted(() => {
fetchMerch() fetchMerch()
fetchPrices(7) fetchPrices(7)
@ -112,71 +102,20 @@ onMounted(() => {
<ChartBlock :charts-data="prices" /> <ChartBlock :charts-data="prices" />
</div> </div>
<!-- Surugaya --> <n-divider title-placement="left">Surugaya</n-divider>
<CopyToClipboard :text="merchDetails.origin_surugaya?.link">
<n-divider title-placement="left">Surugaya</n-divider>
</CopyToClipboard>
<div v-if="!editing.surugaya">
<template v-if="merchDetails.origin_surugaya?.link">
<a
:href="merchDetails.origin_surugaya.link"
target="_blank"
rel="noopener"
class="default-color"
>
{{ merchDetails.origin_surugaya.link }}
</a>
<n-button type="primary" style="margin-left: 8px" @click="editing.surugaya = true">
Edit link
</n-button>
</template>
<template v-else>
<span class="default-color underline link-like-text" @click="editing.surugaya = true">Add link</span>
</template>
</div>
<EditLink <EditLink
v-else
:merch-uuid="merch_uuid" :merch-uuid="merch_uuid"
origin="surugaya" origin="surugaya"
:name="merchDetails.name" :name="merchDetails.name"
:model-value="merchDetails.origin_surugaya?.link || ''" v-model="merchDetails.origin_surugaya.link" />
@update:model-value="handleLinkUpdate('surugaya', $event)"
@cancel-edit="editing.surugaya = false"
/>
<!-- Mandarake -->
<CopyToClipboard :text="merchDetails.origin_mandarake?.link">
<n-divider title-placement="left">Mandarake</n-divider>
</CopyToClipboard>
<div v-if="!editing.mandarake">
<template v-if="merchDetails.origin_mandarake?.link">
<a
:href="merchDetails.origin_mandarake.link"
target="_blank"
rel="noopener"
class="default-color"
>
{{ merchDetails.origin_mandarake.link }}
</a>
<n-button type="primary" style="margin-left: 8px" @click="editing.mandarake = true">
Edit link
</n-button>
</template>
<template v-else>
<span class="default-color underline link-like-text" @click="editing.mandarake = true">Add link</span>
</template>
</div>
<n-divider title-placement="left">Mandarake</n-divider>
<EditLink <EditLink
v-else
:merch-uuid="merch_uuid" :merch-uuid="merch_uuid"
origin="mandarake" origin="mandarake"
:name="merchDetails.name" :name="merchDetails.name"
:model-value="merchDetails.origin_mandarake?.link || ''" v-model="merchDetails.origin_mandarake.link" />
@update:model-value="handleLinkUpdate('mandarake', $event)"
@cancel-edit="editing.mandarake = false"
/>
</n-card> </n-card>
<div v-else>Not found</div> <div v-else>Not found</div>

View file

@ -1,26 +1,33 @@
<script setup> <script setup>
import { ref, nextTick } from 'vue' import { ref, nextTick, watch, computed } from 'vue'
import { useMerchApi } from '@/api/merch.js' import { useMerchApi } from '@/api/merch.js'
import { BASE_MANDARAKE_LINK } from '@/main.js'
const { updateMerch } = useMerchApi() const { updateMerch } = useMerchApi()
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: String, type: String,
required: true, required: true
},
type: {
type: String,
default: 'text'
}, },
placeholder: { placeholder: {
type: String, type: String,
default: 'Enter link here...', default: ''
},
emptyText: {
type: String,
default: 'Add link'
}, },
merchUuid: { merchUuid: {
type: String, type: String,
required: true, required: true
}, },
name: { name: {
type: String, type: String,
required: true, required: true
}, },
origin: { origin: {
type: String, type: String,
@ -28,31 +35,32 @@ const props = defineProps({
}, },
}) })
const emit = defineEmits(['update:modelValue', 'cancel-edit']) const emit = defineEmits(['update:modelValue'])
const isEditing = ref(false)
const tempValue = ref('') const tempValue = ref('')
const inputRef = ref(null) const inputRef = ref(null)
const loading = ref(false) const loading = ref(false)
tempValue.value = props.modelValue const displayValue = computed(() => {
const val = props.modelValue.trim()
nextTick(() => { return val === '' || val == null ? props.emptyText : props.modelValue
inputRef.value?.focus?.()
}) })
const startEditing = () => {
tempValue.value = props.modelValue
isEditing.value = true
nextTick(() => {
inputRef.value?.focus?.()
})
}
const save = async () => { const save = async () => {
const newValue = tempValue.value.trim() const newValue = tempValue.value.trim()
if (newValue !== props.modelValue) {
if (newValue === '') { emit('update:modelValue', newValue)
emit('cancel-edit')
return
} }
isEditing.value = false
if (newValue === props.modelValue.trim()) {
emit('cancel-edit')
return
}
loading.value = true loading.value = true
try { try {
@ -60,7 +68,7 @@ const save = async () => {
merch_uuid: props.merchUuid, merch_uuid: props.merchUuid,
name: props.name, name: props.name,
origin: props.origin, origin: props.origin,
link: newValue, link: newValue
}) })
emit('update:modelValue', newValue) emit('update:modelValue', newValue)
@ -68,91 +76,63 @@ const save = async () => {
console.error('Update link error:', error) console.error('Update link error:', error)
} finally { } finally {
loading.value = false loading.value = false
isEditing.value = false
} }
} }
const showModal = ref(false)
const clearLink = async () => {
showModal.value = true
}
const confirmClearLink = async () => {
loading.value = true
try {
await updateMerch({
merch_uuid: props.merchUuid,
name: props.name,
origin: props.origin,
link: '',
})
emit('update:modelValue', '')
} catch (error) {
console.error('Update link error:', error)
} finally {
loading.value = false
}
showModal.value = false
cancel()
}
const cancel = () => { const cancel = () => {
emit('cancel-edit') isEditing.value = false
}
const insertAutoCompletedLink = () => {
tempValue.value = BASE_MANDARAKE_LINK+props.name
} }
watch(() => props.modelValue, (newVal) => {
if (isEditing.value) {
tempValue.value = newVal
}
})
</script> </script>
<template> <template>
<div v-if="props.origin === 'mandarake'" class="center-button-container mb-10">
<n-button type="warning" class="center-button" @click="insertAutoCompletedLink" <span v-if="!isEditing" @click="startEditing" class="editable-text">
>Insert auto-completed link</n-button {{ displayValue }}
> </span>
</div> <div v-else class="editing-area">
<div class="editing-area">
<n-input <n-input
v-model:value="tempValue" v-model:value="tempValue"
type="textarea" type="text"
size="large" size="small"
ref="inputRef" ref="inputRef"
@keyup.enter="save" @keyup.enter="save"
@keyup.esc="cancel" @keyup.esc="cancel"
:placeholder="placeholder" :placeholder="placeholder || 'Enter link here...'"
:loading="loading"
/> />
<n-button size="small" type="primary" :loading="loading" @click="save">Save</n-button> <n-button size="small" type="primary" @click="save"></n-button>
<n-button size="small" @click="cancel">Cancel</n-button> <n-button size="small" @click="cancel"></n-button>
</div> </div>
<div class="center-button-container">
<n-button type="error" class="center-button" @click="clearLink">Clear link</n-button>
</div>
<n-modal v-model:show="showModal" preset="dialog" title="Confirmation">
<template #default>
<p>Confirm clear link</p>
</template>
<template #action>
<n-button @click="confirmClearLink" type="error">Clear</n-button>
<n-button @click="showModal = false">Cancel</n-button>
</template>
</n-modal>
</template> </template>
<style scoped> <style scoped>
.editing-area { .editable-text {
display: flex; cursor: pointer;
gap: 6px; text-decoration: underline;
align-items: center; color: #18a058;
width: 100%; padding: 2px 4px;
flex-wrap: wrap; border-radius: 4px;
transition: background 0.2s;
min-height: 1.2em;
display: inline-block;
min-width: 50px;
} }
.editing-area :deep(.n-input) { .editable-text:hover {
flex: 1; background-color: #f0f9ff;
min-width: 200px; }
.editing-area {
display: inline-flex;
gap: 6px;
align-items: center;
vertical-align: middle;
width: 100%;
} }
</style> </style>