feat(home): replace horizontal carousel with vertical scroll-snap stack
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
For multi-frame setups, switch from side-swipe carousel + dot indicators to a vertical scroll-snap stack of full-size cards. Each frame gets its own page-height slide; flicking up/down moves between frames with the same snap-stop feel as the horizontal version. Removes ~30 lines of carousel scroll-tracking JS and the dot navigation. Single-frame layout unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -106,8 +106,8 @@ describe('HomeView', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// HV-01: N devices renders a carousel of N large FrameCard stubs + N dots
|
// HV-01: N devices renders a vertical stack of N large FrameCard stubs
|
||||||
it('renders one FrameCard per device in a carousel when multiple devices present', async () => {
|
it('renders one FrameCard per device in a vertical stack when multiple devices present', async () => {
|
||||||
const devicesStore = useDevicesStore()
|
const devicesStore = useDevicesStore()
|
||||||
devicesStore.devices = [
|
devicesStore.devices = [
|
||||||
makeDevice({ id: 1, name: 'Frame A' }),
|
makeDevice({ id: 1, name: 'Frame A' }),
|
||||||
@@ -119,58 +119,27 @@ describe('HomeView', () => {
|
|||||||
const wrapper = mountView()
|
const wrapper = mountView()
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
expect(wrapper.find('.home-view__carousel').exists()).toBe(true)
|
expect(wrapper.find('.home-view__stack').exists()).toBe(true)
|
||||||
|
expect(wrapper.findAll('.home-view__slide')).toHaveLength(3)
|
||||||
expect(wrapper.findAll('.frame-card-stub')).toHaveLength(3)
|
expect(wrapper.findAll('.frame-card-stub')).toHaveLength(3)
|
||||||
// All cards should be the large variant (no more compact stack)
|
// All cards should be the large variant (no compact / no carousel)
|
||||||
const cards = wrapper.findAllComponents({ name: 'FrameCard' })
|
const cards = wrapper.findAllComponents({ name: 'FrameCard' })
|
||||||
for (const c of cards) expect(c.props('size')).toBe('large')
|
for (const c of cards) expect(c.props('size')).toBe('large')
|
||||||
// One navigation dot per device
|
|
||||||
expect(wrapper.findAll('.home-view__dot')).toHaveLength(3)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('marks the first dot active by default and updates active dot on scroll', async () => {
|
it('labels each slide with the device name for accessibility', async () => {
|
||||||
const devicesStore = useDevicesStore()
|
const devicesStore = useDevicesStore()
|
||||||
devicesStore.devices = [
|
devicesStore.devices = [
|
||||||
makeDevice({ id: 1, name: 'A' }),
|
makeDevice({ id: 1, name: 'Living Room' }),
|
||||||
makeDevice({ id: 2, name: 'B' }),
|
makeDevice({ id: 2, name: 'Bedroom' }),
|
||||||
]
|
]
|
||||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||||
|
|
||||||
const wrapper = mountView()
|
const wrapper = mountView()
|
||||||
await wrapper.vm.$nextTick()
|
await wrapper.vm.$nextTick()
|
||||||
let dots = wrapper.findAll('.home-view__dot')
|
const slides = wrapper.findAll('.home-view__slide')
|
||||||
expect(dots[0].classes()).toContain('home-view__dot--active')
|
expect(slides[0].attributes('aria-label')).toBe('Living Room')
|
||||||
expect(dots[1].classes()).not.toContain('home-view__dot--active')
|
expect(slides[1].attributes('aria-label')).toBe('Bedroom')
|
||||||
|
|
||||||
// Simulate the carousel having scrolled to the second slide
|
|
||||||
const carousel = wrapper.find('.home-view__carousel').element as HTMLElement
|
|
||||||
Object.defineProperty(carousel, 'clientWidth', { configurable: true, value: 360 })
|
|
||||||
Object.defineProperty(carousel, 'scrollLeft', { configurable: true, value: 360 })
|
|
||||||
await wrapper.find('.home-view__carousel').trigger('scroll')
|
|
||||||
await wrapper.vm.$nextTick()
|
|
||||||
|
|
||||||
dots = wrapper.findAll('.home-view__dot')
|
|
||||||
expect(dots[1].classes()).toContain('home-view__dot--active')
|
|
||||||
expect(dots[0].classes()).not.toContain('home-view__dot--active')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clicking a dot scrolls the carousel to that slide', async () => {
|
|
||||||
const devicesStore = useDevicesStore()
|
|
||||||
devicesStore.devices = [makeDevice({ id: 1 }), makeDevice({ id: 2 }), makeDevice({ id: 3 })]
|
|
||||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
|
||||||
|
|
||||||
const wrapper = mountView()
|
|
||||||
await wrapper.vm.$nextTick()
|
|
||||||
|
|
||||||
const carousel = wrapper.find('.home-view__carousel').element as HTMLElement
|
|
||||||
Object.defineProperty(carousel, 'clientWidth', { configurable: true, value: 360 })
|
|
||||||
const scrollToSpy = vi.fn()
|
|
||||||
;(carousel as any).scrollTo = scrollToSpy
|
|
||||||
|
|
||||||
await wrapper.findAll('.home-view__dot')[2].trigger('click')
|
|
||||||
await wrapper.vm.$nextTick()
|
|
||||||
|
|
||||||
expect(scrollToSpy).toHaveBeenCalledWith(expect.objectContaining({ left: 720, behavior: 'smooth' }))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// HV-01b: single device still renders one FrameCard (large variant branch)
|
// HV-01b: single device still renders one FrameCard (large variant branch)
|
||||||
|
|||||||
@@ -37,51 +37,34 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Multiple devices — swipeable carousel of large cards -->
|
<!-- Multiple devices — vertical scroll-snap stack of large cards -->
|
||||||
<template v-else>
|
<div
|
||||||
|
v-else
|
||||||
|
class="home-view__stack"
|
||||||
|
role="list"
|
||||||
|
aria-label="Frames"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref="carouselEl"
|
v-for="device in devicesStore.devices"
|
||||||
class="home-view__carousel"
|
:key="device.id"
|
||||||
role="region"
|
class="home-view__slide"
|
||||||
aria-roledescription="carousel"
|
role="listitem"
|
||||||
aria-label="Frames"
|
:aria-label="device.name"
|
||||||
@scroll.passive="onCarouselScroll"
|
|
||||||
>
|
>
|
||||||
<div
|
<FrameCard
|
||||||
v-for="device in devicesStore.devices"
|
:deviceId="device.id"
|
||||||
:key="device.id"
|
:name="device.name"
|
||||||
class="home-view__slide"
|
size="large"
|
||||||
role="group"
|
:status="deviceStatus(device)"
|
||||||
:aria-roledescription="`Slide`"
|
:orientation="device.orientation"
|
||||||
:aria-label="device.name"
|
:thumbnailUrl="previewUrl(device)"
|
||||||
>
|
:lastSync="lastSyncLabel(device)"
|
||||||
<FrameCard
|
:nextSync="nextSyncLabel(device)"
|
||||||
:deviceId="device.id"
|
@add-photo="onAddPhoto"
|
||||||
:name="device.name"
|
@edit="onEdit"
|
||||||
size="large"
|
|
||||||
:status="deviceStatus(device)"
|
|
||||||
:orientation="device.orientation"
|
|
||||||
:thumbnailUrl="previewUrl(device)"
|
|
||||||
:lastSync="lastSyncLabel(device)"
|
|
||||||
:nextSync="nextSyncLabel(device)"
|
|
||||||
@add-photo="onAddPhoto"
|
|
||||||
@edit="onEdit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="home-view__dots" role="tablist" aria-label="Frame selector">
|
|
||||||
<button
|
|
||||||
v-for="(d, i) in devicesStore.devices"
|
|
||||||
:key="d.id"
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
:class="['home-view__dot', { 'home-view__dot--active': i === activeSlide }]"
|
|
||||||
:aria-label="`Go to ${d.name}`"
|
|
||||||
:aria-selected="i === activeSlide"
|
|
||||||
@click="goToSlide(i)"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Frame settings sheet -->
|
<!-- Frame settings sheet -->
|
||||||
@@ -131,7 +114,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, nextTick } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useDevicesStore } from '@/stores/devices'
|
import { useDevicesStore } from '@/stores/devices'
|
||||||
import { useUploadStore } from '@/stores/upload'
|
import { useUploadStore } from '@/stores/upload'
|
||||||
@@ -209,25 +192,6 @@ onMounted(() => {
|
|||||||
devicesStore.fetchDevices()
|
devicesStore.fetchDevices()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Multi-device carousel ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const carouselEl = ref<HTMLElement | null>(null)
|
|
||||||
const activeSlide = ref(0)
|
|
||||||
|
|
||||||
function onCarouselScroll() {
|
|
||||||
const el = carouselEl.value
|
|
||||||
if (!el) return
|
|
||||||
// Each slide is 100% of the carousel width — stride is clientWidth.
|
|
||||||
activeSlide.value = Math.round(el.scrollLeft / el.clientWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function goToSlide(i: number) {
|
|
||||||
await nextTick()
|
|
||||||
const el = carouselEl.value
|
|
||||||
if (!el) return
|
|
||||||
el.scrollTo({ left: i * el.clientWidth, behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function onAddPhoto(deviceId: number) {
|
function onAddPhoto(deviceId: number) {
|
||||||
// File picker must be triggered in the user-gesture context (the click handler)
|
// File picker must be triggered in the user-gesture context (the click handler)
|
||||||
// before navigating, otherwise browsers block it as a popup.
|
// before navigating, otherwise browsers block it as a popup.
|
||||||
@@ -403,55 +367,33 @@ async function saveSettings() {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Carousel: full-bleed horizontal scroll-snap. The container ignores the
|
// Vertical scroll-snap stack of full-size cards. Each slide takes a full
|
||||||
// page's side padding so cards reach edge-to-edge; each slide pads itself
|
// screen-port height and snaps as the user scrolls, so flipping between
|
||||||
// back in so the card visually aligns with the rest of the page.
|
// frames feels deliberate (like swiping pages) rather than continuous
|
||||||
&__carousel {
|
// scrolling through a feed.
|
||||||
|
&__stack {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow-x: auto;
|
flex-direction: column;
|
||||||
overflow-y: hidden;
|
gap: var(--space-3);
|
||||||
scroll-snap-type: x mandatory;
|
overflow-y: auto;
|
||||||
|
scroll-snap-type: y mandatory;
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
margin: 0 calc(-1 * var(--space-4));
|
|
||||||
|
|
||||||
&::-webkit-scrollbar { display: none; }
|
&::-webkit-scrollbar { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
&__slide {
|
&__slide {
|
||||||
flex: 0 0 100%;
|
flex: 0 0 auto;
|
||||||
|
min-height: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0 var(--space-4);
|
|
||||||
scroll-snap-align: start;
|
|
||||||
scroll-snap-stop: always;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
scroll-snap-align: start;
|
||||||
|
scroll-snap-stop: always;
|
||||||
&__dots {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding-top: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
background: var(--color-border);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--duration-fast), transform var(--duration-fast);
|
|
||||||
|
|
||||||
&--active {
|
|
||||||
background: var(--color-primary);
|
|
||||||
transform: scale(1.25);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__sheet-title {
|
&__sheet-title {
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
import{_ as e,d as t,g as n,j as r,k as i,l as a,o,p as s,pt as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,t as m}from"./BaseBottomSheet-CXcLrct9.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=a(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(a,d)=>(i(),t(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>a.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=u(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=u(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),u(`div`,h,[(i(!0),s(o,null,r(l.devices,e=>(i(),s(`label`,{key:e.id,class:`device-picker__row`},[u(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),u(`span`,_,c(e.name),1),u(`span`,v,c(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>a.$emit(`confirm`)},{default:f(()=>[n(c(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
|
import{_ as e,d as t,g as n,j as r,k as i,l as a,o,p as s,pt as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,t as m}from"./BaseBottomSheet-DYbXyS7e.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=a(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(a,d)=>(i(),t(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>a.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=u(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=u(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),u(`div`,h,[(i(!0),s(o,null,r(l.devices,e=>(i(),s(`label`,{key:e.id,class:`device-picker__row`},[u(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),u(`span`,_,c(e.name),1),u(`span`,v,c(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>a.$emit(`confirm`)},{default:f(()=>[n(c(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
|
||||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
import{K as e,dt as t,f as n,ft as r,j as i,k as a,l as o,o as s,p as c,pt as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,r as m,t as h}from"./index-aP_uBWCi.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=o(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(o,u)=>(a(),c(`main`,g,[u[5]||=d(`h1`,{class:`settings__title`},`Settings`,-1),d(`section`,_,[u[1]||=d(`h2`,{class:`settings__section-title`},`Theme`,-1),d(`div`,v,[(a(!0),c(s,null,i(e(h),e=>(a(),c(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:t([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[u[0]||=d(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[d(`span`,{class:`theme-swatch__bar`}),d(`span`,{class:`theme-swatch__dot`})],-1),d(`span`,b,l(e.label),1),E.value===e.id?(a(),c(`span`,x,`✓`)):n(``,!0)],14,y))),128))])]),d(`section`,S,[u[3]||=d(`h2`,{class:`settings__section-title`},`Account`,-1),d(`div`,C,[u[2]||=d(`span`,{class:`settings__row-label`},`Signed in as`,-1),d(`span`,w,l(e(f).user?.email),1)]),u[4]||=d(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
|
import{K as e,dt as t,f as n,ft as r,j as i,k as a,l as o,o as s,p as c,pt as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DVo1OUMD.js";import{n as p,r as m,t as h}from"./index-Dt4UyE7n.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=o(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(o,u)=>(a(),c(`main`,g,[u[5]||=d(`h1`,{class:`settings__title`},`Settings`,-1),d(`section`,_,[u[1]||=d(`h2`,{class:`settings__section-title`},`Theme`,-1),d(`div`,v,[(a(!0),c(s,null,i(e(h),e=>(a(),c(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:t([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[u[0]||=d(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[d(`span`,{class:`theme-swatch__bar`}),d(`span`,{class:`theme-swatch__dot`})],-1),d(`span`,b,l(e.label),1),E.value===e.id?(a(),c(`span`,x,`✓`)):n(``,!0)],14,y))),128))])]),d(`section`,S,[u[3]||=d(`h2`,{class:`settings__section-title`},`Account`,-1),d(`div`,C,[u[2]||=d(`span`,{class:`settings__row-label`},`Signed in as`,-1),d(`span`,w,l(e(f).user?.email),1)]),u[4]||=d(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -14,7 +14,7 @@
|
|||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
|
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
|
||||||
<script type="module" crossorigin src="/build/assets/index-aP_uBWCi.js"></script>
|
<script type="module" crossorigin src="/build/assets/index-Dt4UyE7n.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DVo1OUMD.js">
|
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DVo1OUMD.js">
|
||||||
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Reference in New Issue
Block a user