mirror of
https://github.com/misenhower/splatoon3.ink.git
synced 2026-10-02 11:56:44 -05:00
Merge pull request #129 from misenhower/codex/stable-image-loading
Stabilize image loading and add inline page loading states
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preload" href="/src/assets/fonts/Splatoon1-common.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="preload" href="/src/assets/fonts/Splatoon2-common.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<title>Splatoon3.ink</title>
|
||||
<meta property="og:title" content="Splatoon 3 Schedules and Gear">
|
||||
<meta property="og:description" content="Current stages, Salmon Run schedules, Splatnet Gear, and more.">
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"loading": {
|
||||
"label": "Wird geladen…",
|
||||
"error": "Die Daten dieser Seite konnten nicht geladen werden. Bitte versuche es erneut.",
|
||||
"retry": "Erneut versuchen"
|
||||
},
|
||||
"times": {
|
||||
"now": "Aktuell",
|
||||
"next": "Demnächst",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"loading": {
|
||||
"label": "Loading…",
|
||||
"error": "Couldn’t load this page’s data. Please try again.",
|
||||
"retry": "Try again"
|
||||
},
|
||||
"times": {
|
||||
"now": "Now",
|
||||
"next": "Next",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"loading": {
|
||||
"label": "Loading…",
|
||||
"error": "Couldn’t load this page’s data. Please try again.",
|
||||
"retry": "Try again"
|
||||
},
|
||||
"times": {
|
||||
"now": "Now",
|
||||
"next": "Next",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"loading": {
|
||||
"label": "Cargando…",
|
||||
"error": "No se pudieron cargar los datos de esta página. Inténtalo de nuevo.",
|
||||
"retry": "Reintentar"
|
||||
},
|
||||
"times": {
|
||||
"now": "Actual",
|
||||
"next": "Próximo",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"loading": {
|
||||
"label": "Cargando…",
|
||||
"error": "No se pudieron cargar los datos de esta página. Inténtalo de nuevo.",
|
||||
"retry": "Reintentar"
|
||||
},
|
||||
"times": {
|
||||
"now": "Actual",
|
||||
"next": "Siguiente",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"loading": {
|
||||
"label": "Chargement…",
|
||||
"error": "Impossible de charger les données de cette page. Veuillez réessayer.",
|
||||
"retry": "Réessayer"
|
||||
},
|
||||
"times": {
|
||||
"now": "Actuel",
|
||||
"next": "Suivant",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"loading": {
|
||||
"label": "Chargement…",
|
||||
"error": "Impossible de charger les données de cette page. Veuillez réessayer.",
|
||||
"retry": "Réessayer"
|
||||
},
|
||||
"times": {
|
||||
"now": "Actuel",
|
||||
"next": "Suivant",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"loading": {
|
||||
"label": "Caricamento…",
|
||||
"error": "Impossibile caricare i dati di questa pagina. Riprova.",
|
||||
"retry": "Riprova"
|
||||
},
|
||||
"times": {
|
||||
"now": "In corso!",
|
||||
"next": "Prossimo",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"loading": {
|
||||
"label": "読み込み中…",
|
||||
"error": "このページのデータを読み込めませんでした。もう一度お試しください。",
|
||||
"retry": "再試行"
|
||||
},
|
||||
"times": {
|
||||
"now": "オープン!",
|
||||
"next": "つぎ",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"loading": {
|
||||
"label": "불러오는 중…",
|
||||
"error": "이 페이지의 데이터를 불러오지 못했습니다. 다시 시도해 주세요.",
|
||||
"retry": "다시 시도"
|
||||
},
|
||||
"times": {
|
||||
"now": "오픈!",
|
||||
"next": "다음",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"loading": {
|
||||
"label": "Laden…",
|
||||
"error": "De gegevens van deze pagina konden niet worden geladen. Probeer het opnieuw.",
|
||||
"retry": "Opnieuw proberen"
|
||||
},
|
||||
"times": {
|
||||
"now": "Nu",
|
||||
"next": "Volgende",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"loading": {
|
||||
"label": "Загрузка…",
|
||||
"error": "Не удалось загрузить данные этой страницы. Попробуйте ещё раз.",
|
||||
"retry": "Повторить"
|
||||
},
|
||||
"times": {
|
||||
"now": "Сейчас",
|
||||
"next": "Следующий",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"loading": {
|
||||
"label": "正在加载…",
|
||||
"error": "无法加载此页面的数据。请重试。",
|
||||
"retry": "重试"
|
||||
},
|
||||
"times": {
|
||||
"now": "开放!",
|
||||
"next": "下次",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"loading": {
|
||||
"label": "載入中…",
|
||||
"error": "無法載入此頁面的資料。請再試一次。",
|
||||
"retry": "重試"
|
||||
},
|
||||
"times": {
|
||||
"now": "開放!",
|
||||
"next": "下次",
|
||||
|
||||
22
src/components/Debug/DevControls.vue
Normal file
22
src/components/Debug/DevControls.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap justify-center items-center gap-2 text-xs">
|
||||
<TimeOffsetSelector />
|
||||
|
||||
<label class="flex items-center gap-2 bg-zinc-700 p-1 rounded-sm">
|
||||
<span class="bg-zinc-500 rounded-sm font-semibold px-1">Page state</span>
|
||||
<select v-model="preview.mode" class="bg-zinc-800">
|
||||
<option value="live">Live data</option>
|
||||
<option value="loading">Loading</option>
|
||||
<option value="empty">Empty</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import TimeOffsetSelector from './TimeOffsetSelector.vue';
|
||||
import { usePagePreviewStore } from '@/stores/pagePreview.mjs';
|
||||
|
||||
const preview = usePagePreviewStore();
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<ProductContainer v-if="gear" class="pt-10 pb-4 px-10" bg="bg-zinc-500 bg-monsters">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<div>
|
||||
<img :src="gear.image.url" />
|
||||
<img :src="gear.image.url" width="256" height="256" />
|
||||
</div>
|
||||
<div>
|
||||
<SquidTape class="font-splatoon2 text-2xl bg-splatoon-salmonRun -rotate-2" squid-size="16px">
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<template>
|
||||
<ProductContainer :bg="type.bg" class="w-full pt-10 pb-4">
|
||||
<ProductContainer
|
||||
:bg="type.bg"
|
||||
class="w-full pt-10"
|
||||
:class="loading || store.activeSchedule || nextSchedule ? 'pb-4' : 'pb-1'"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2 mx-2">
|
||||
<img :src="type.img" />
|
||||
<img :src="type.img" width="40" height="40" />
|
||||
<div class="font-splatoon1 lg:text-2xl xl:text-3xl text-shadow">
|
||||
{{ $t(type.name) }}
|
||||
</div>
|
||||
@@ -11,26 +15,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900/70 backdrop-blur-xs pt-2 pb-6 px-2 mx-1 rounded-lg space-y-2">
|
||||
<div v-if="loading || store.activeSchedule?.settings" class="bg-zinc-900/70 backdrop-blur-xs pt-2 pb-6 px-2 mx-1 rounded-lg space-y-2">
|
||||
<div class="flex items-center justify-between font-splatoon2">
|
||||
<div class="flex items-center space-x-2 text-sm lg:text-lg">
|
||||
<template v-if="store.activeSchedule && store.activeSchedule.settings">
|
||||
<div>
|
||||
<RuleIcon :rule="store.activeSchedule.settings.vsRule" class="h-5 lg:h-6 drop-shadow-ruleIcon" />
|
||||
</div>
|
||||
<div class="text-shadow">
|
||||
{{ $t(`splatnet.rules.${store.activeSchedule.settings.vsRule.id}.name`, store.activeSchedule.settings.vsRule.name) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="w-32 bg-zinc-600 rounded-sm animate-pulse">
|
||||
|
||||
</div>
|
||||
</template>
|
||||
<SkeletonBlock v-if="loading" class="h-5 lg:h-7 w-32" />
|
||||
<div v-else class="flex items-center space-x-2 text-sm lg:text-lg">
|
||||
<div>
|
||||
<RuleIcon :rule="store.activeSchedule.settings.vsRule" class="h-5 lg:h-6 drop-shadow-ruleIcon" />
|
||||
</div>
|
||||
<div class="text-shadow">
|
||||
{{ $t(`splatnet.rules.${store.activeSchedule.settings.vsRule.id}.name`, store.activeSchedule.settings.vsRule.name) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="store.activeSchedule" class="justify-end text-xs lg:text-sm bg-zinc-100/80 rounded-sm text-black px-2">
|
||||
<div v-if="!loading && store.activeSchedule" class="justify-end text-xs lg:text-sm bg-zinc-100/80 rounded-sm text-black px-2">
|
||||
{{ $d(store.activeSchedule.startTime, 'time') }}
|
||||
–
|
||||
{{ $d(store.activeSchedule.endTime, 'time') }}
|
||||
@@ -40,42 +37,53 @@
|
||||
<div class="flex space-x-1">
|
||||
<StageImage
|
||||
class="flex-1"
|
||||
:loading="loading"
|
||||
img-class="rounded-l-xl"
|
||||
:stage="store.activeSchedule?.settings?.vsStages[0]"
|
||||
/>
|
||||
<StageImage
|
||||
class="flex-1"
|
||||
:loading="loading"
|
||||
img-class="rounded-r-xl"
|
||||
:stage="store.activeSchedule?.settings?.vsStages[1]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="nextSchedule && nextSchedule.settings" class="mx-2 space-y-2">
|
||||
<p
|
||||
v-else
|
||||
class="bg-zinc-900/70 mx-1 rounded-t-lg py-12 text-center font-splatoon2"
|
||||
:class="store.activeSchedule || nextSchedule ? 'rounded-b-lg' : 'rounded-b-xl'"
|
||||
>
|
||||
{{ $t('times.checkback') }}
|
||||
</p>
|
||||
|
||||
<div v-if="loading || nextSchedule?.settings" class="mx-2 space-y-2">
|
||||
<SquidTape class="font-splatoon2 text-sm drop-shadow-sm -rotate-6 -mx-2">
|
||||
<div class="px-2">
|
||||
{{ $t('times.next') }}
|
||||
</div>
|
||||
</SquidTape>
|
||||
|
||||
<ScheduleRow :schedule="nextSchedule" />
|
||||
<ScheduleRow :schedule="nextSchedule" :loading="loading" />
|
||||
</div>
|
||||
|
||||
<div class="text-center pt-2">
|
||||
<button class="bg-zinc-300/50 hover:bg-zinc-300/70 px-2 py-1 rounded-full font-splatoon2 text-shadow" @click="open = true">
|
||||
<div v-if="loading || store.activeSchedule || nextSchedule" class="text-center pt-2">
|
||||
<button :disabled="loading" class="bg-zinc-300/50 enabled:hover:bg-zinc-300/70 px-2 py-1 rounded-full font-splatoon2 text-shadow" @click="open = true">
|
||||
<span class="inline-block rotate-25 text-red"></span>
|
||||
{{ $t('schedule.all-upcoming') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScheduleDialog :type="props.type" :show="open" @close="open = false" />
|
||||
<ScheduleDialog :type="props.type" :show="open && !loading" @close="open = false" />
|
||||
</ProductContainer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import ProductContainer from './ProductContainer.vue';
|
||||
import SkeletonBlock from './loading/SkeletonBlock.vue';
|
||||
import StageImage from './StageImage.vue';
|
||||
import ScheduleRow from './ScheduleRow.vue';
|
||||
import RuleIcon from './RuleIcon.vue';
|
||||
@@ -84,6 +92,7 @@ import { useScheduleTypes } from './concerns/scheduleTypes.mjs';
|
||||
import ScheduleDialog from './ScheduleDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
loading: Boolean,
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="relative h-full overflow-hidden">
|
||||
<div class="absolute inset-x-0 bg-zinc-800/40 backdrop-blur-md z-30 pt-10 pb-4">
|
||||
<div class="flex items-center space-x-2 mx-2">
|
||||
<img :src="type.img" />
|
||||
<img :src="type.img" width="40" height="40" />
|
||||
<div class="font-splatoon1 lg:text-2xl xl:text-3xl text-shadow">
|
||||
{{ $t(type.name) }}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div v-if="props.schedule" class="font-splatoon2 sm:hidden">
|
||||
<div v-if="!loading && props.schedule" class="font-splatoon2 sm:hidden">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div>
|
||||
<RuleIcon :rule="props.schedule.settings.vsRule" class="h-5 drop-shadow-ruleIcon" />
|
||||
@@ -21,14 +21,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="sm:hidden bg-zinc-500 rounded-sm animate-pulse w-32">
|
||||
|
||||
</div>
|
||||
<SkeletonBlock v-else class="sm:hidden h-9" />
|
||||
|
||||
<div class="flex space-x-1">
|
||||
<div class="flex-1 items-center text-center font-splatoon2 hidden sm:flex">
|
||||
<div class="w-full space-y-1">
|
||||
<template v-if="props.schedule">
|
||||
<template v-if="!loading && props.schedule">
|
||||
<div class="flex items-center justify-center space-x-2">
|
||||
<div>
|
||||
<RuleIcon :rule="props.schedule.settings.vsRule" class="h-5 lg:h-6 drop-shadow-ruleIcon" />
|
||||
@@ -55,25 +53,31 @@
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="text-xs lg:text-lg bg-zinc-500 rounded-sm animate-pulse">
|
||||
|
||||
</div>
|
||||
<div class="text-sm mx-8 bg-zinc-500 rounded-sm animate-pulse">
|
||||
|
||||
</div>
|
||||
<div class="text-sm mx-4 bg-zinc-500 rounded-sm animate-pulse">
|
||||
|
||||
</div>
|
||||
<SkeletonBlock class="h-5 lg:h-7" />
|
||||
<SkeletonBlock class="h-5 mx-8" />
|
||||
<SkeletonBlock class="h-5 mx-4" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<StageImage class="flex-1" img-class="rounded-l-xl" text-size="text-xs" :stage="props.schedule?.settings.vsStages[0]" />
|
||||
<StageImage
|
||||
class="w-full"
|
||||
:loading="loading"
|
||||
img-class="rounded-l-xl"
|
||||
text-size="text-xs"
|
||||
:stage="props.schedule?.settings.vsStages[0]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<StageImage class="flex-1" img-class="rounded-r-xl" text-size="text-xs" :stage="props.schedule?.settings.vsStages[1]" />
|
||||
<StageImage
|
||||
class="w-full"
|
||||
:loading="loading"
|
||||
img-class="rounded-r-xl"
|
||||
text-size="text-xs"
|
||||
:stage="props.schedule?.settings.vsStages[1]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,14 +86,13 @@
|
||||
<script setup>
|
||||
import StageImage from './StageImage.vue';
|
||||
import RuleIcon from './RuleIcon.vue';
|
||||
import SkeletonBlock from './loading/SkeletonBlock.vue';
|
||||
import { formatDurationFromNow } from '@/common/time';
|
||||
import { useTimeStore } from '@/stores/time.mjs';
|
||||
|
||||
const props = defineProps({
|
||||
schedule: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
schedule: Object,
|
||||
loading: Boolean,
|
||||
});
|
||||
|
||||
const time = useTimeStore();
|
||||
|
||||
@@ -3,24 +3,39 @@
|
||||
<div class="space-y-2">
|
||||
<div class="font-splatoon1 text-2xl xl:text-3xl text-shadow mx-2">
|
||||
{{ $t(title) }}
|
||||
{{ festival.regions.length < 4 ? ` (${festival.regions.join('/')})` : '' }}
|
||||
<template v-if="!loading">
|
||||
{{ festival.regions.length < 4 ? ` (${festival.regions.join('/')})` : '' }}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mx-2">
|
||||
<div class="font-splatoon2 text-zinc-200 text-center text-shadow text-sm lg:text-lg bg-zinc-700/50 px-4 py-1 rounded-full backdrop-blur-xs">
|
||||
{{ $t(`splatnet.festivals.${festival.__splatoon3ink_id}.title`, festival.title) }}
|
||||
<SkeletonBlock v-if="loading" class="h-5 lg:h-7 w-60 max-w-full" />
|
||||
<template v-else>
|
||||
{{ $t(`splatnet.festivals.${festival.__splatoon3ink_id}.title`, festival.title) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<img :src="props.festival.image.url" loading="lazy" />
|
||||
<SkeletonBlock v-if="loading" class="aspect-[1740/680]" />
|
||||
<img
|
||||
v-else
|
||||
:src="props.festival.image.url"
|
||||
width="1740"
|
||||
height="680"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<div class="flex -mt-3 mb-4">
|
||||
<template v-for="(team, i) in festival.teams" :key="team.id">
|
||||
<template v-for="(team, i) in loading ? [null, null, null] : festival.teams" :key="team?.id ?? i">
|
||||
<div class="flex-1 flex justify-center items-center">
|
||||
<SquidTape class="font-splatoon2 text-shadow text-sm lg:text-base -rotate-3" bg="" :style="`background-color: ${toRgba(team.color)};`">
|
||||
<SquidTape class="font-splatoon2 text-shadow text-sm lg:text-base -rotate-3" bg="" :style="loading ? undefined : `background-color: ${toRgba(team.color)};`">
|
||||
<div class="px-2">
|
||||
{{ $t(`splatnet.festivals.${ festival.__splatoon3ink_id }.teams.${i}.teamName`, team.teamName) }}
|
||||
<SkeletonBlock v-if="loading" class="h-5 lg:h-6 w-12" />
|
||||
<template v-else>
|
||||
{{ $t(`splatnet.festivals.${ festival.__splatoon3ink_id }.teams.${i}.teamName`, team.teamName) }}
|
||||
</template>
|
||||
</div>
|
||||
</SquidTape>
|
||||
</div>
|
||||
@@ -29,15 +44,19 @@
|
||||
</div>
|
||||
|
||||
<div class="font-splatoon2 text-splatoon-yellow text-center text-sm lg:text-base text-shadow mx-2 ss:hidden">
|
||||
{{ $d(festival.startTime, 'dateTimeShortWeekday') }}
|
||||
–
|
||||
{{ $d(festival.endTime, 'dateTimeShortWeekday') }}
|
||||
<SkeletonBlock v-if="loading" class="h-5 lg:h-6 w-4/5 mx-auto" />
|
||||
<template v-else>
|
||||
{{ $d(festival.startTime, 'dateTimeShortWeekday') }}
|
||||
–
|
||||
{{ $d(festival.endTime, 'dateTimeShortWeekday') }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</ProductContainer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SkeletonBlock from './loading/SkeletonBlock.vue';
|
||||
import { computed } from 'vue';
|
||||
import ProductContainer from './ProductContainer.vue';
|
||||
import SquidTape from './SquidTape.vue';
|
||||
@@ -46,10 +65,11 @@ import { STATUS_PAST, STATUS_ACTIVE, STATUS_UPCOMING } from '@/stores/splatfests
|
||||
const props = defineProps({
|
||||
festival: Object,
|
||||
historyMode: Boolean,
|
||||
loading: Boolean,
|
||||
});
|
||||
|
||||
const title = computed(() => {
|
||||
if (props.historyMode) {
|
||||
if (props.historyMode || props.loading) {
|
||||
return 'festival.active';
|
||||
}
|
||||
switch (props.festival.status) {
|
||||
|
||||
@@ -12,7 +12,12 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<img :src="festival.image.url" loading="lazy" />
|
||||
<img
|
||||
:src="festival.image.url"
|
||||
width="1740"
|
||||
height="680"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<div class="flex -mt-3 mb-4">
|
||||
<template v-for="(team, i) in festival.teams" :key="team.id">
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<ProductContainer v-if="winner" class="pt-10 pb-4" bg="bg-camo-purple" :bg-style="`background-color: ${toRgba(winner.color)};`">
|
||||
<ProductContainer
|
||||
v-if="loading || winner"
|
||||
class="pt-10 pb-4"
|
||||
:bg="loading ? 'bg-camo-purple bg-splatoon-purple' : 'bg-camo-purple'"
|
||||
:bg-style="loading ? undefined : `background-color: ${toRgba(winner.color)};`"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="font-splatoon1 text-2xl lg:text-3xl text-shadow mx-2">
|
||||
{{ $t('festival.results.title') }}
|
||||
@@ -8,9 +13,10 @@
|
||||
<div class="mx-2 px-1 bg-zinc-700/50 backdrop-blur-xs rounded-lg">
|
||||
<div class="flex justify-center md:justify-center py-2">
|
||||
<div class="w-36 sm:mx-4 lg:-mx-1" />
|
||||
<template v-for="team in festival.teams" :key="team.id">
|
||||
<div class="w-12 mx-2 sm:w-20 flex justify-center py-1 rounded-sm" :style="`background-color: ${toRgba(team.color)};`">
|
||||
<img :src="team.image.url" class="w-6 h-6" loading="lazy" />
|
||||
<template v-for="(team, i) in loading ? [null, null, null] : festival.teams" :key="team?.id ?? i">
|
||||
<div class="w-12 mx-2 sm:w-20 flex justify-center py-1 rounded-sm" :style="loading ? undefined : `background-color: ${toRgba(team.color)};`">
|
||||
<SkeletonBlock v-if="loading" class="w-6 h-6" />
|
||||
<img v-else :src="team.image.url" class="w-6 h-6" loading="lazy" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -23,7 +29,8 @@
|
||||
|
||||
<div class="flex bg-zinc-700/70 rounded-full py-1">
|
||||
<div v-for="(result, i) in row.results" :key="i" class="w-16 lg:w-20 sm:mx-2">
|
||||
<div :class="result.isTop ? 'text-splatoon-yellow' : 'text-zinc-300'">
|
||||
<SkeletonBlock v-if="loading" class="h-5 lg:h-6 mx-auto w-12" />
|
||||
<div v-else :class="result.isTop ? 'text-splatoon-yellow' : 'text-zinc-300'">
|
||||
{{ (result.ratio * 100).toFixed(2) }}%
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,18 +40,23 @@
|
||||
</div>
|
||||
|
||||
<div class="font-splatoon2 text-splatoon-yellow text-center text-shadow text-sm lg:text-base mx-2 ss:hidden">
|
||||
{{ $t('festival.results.won', { team: $t(`splatnet.festivals.${ festival.__splatoon3ink_id }.teams.${winnerIndex}.teamName`, winner.teamName) }) }}
|
||||
<SkeletonBlock v-if="loading" class="h-5 lg:h-6 w-48 mx-auto" />
|
||||
<template v-else>
|
||||
{{ $t('festival.results.won', { team: $t(`splatnet.festivals.${ festival.__splatoon3ink_id }.teams.${winnerIndex}.teamName`, winner.teamName) }) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</ProductContainer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SkeletonBlock from './loading/SkeletonBlock.vue';
|
||||
import { computed } from 'vue';
|
||||
import ProductContainer from './ProductContainer.vue';
|
||||
|
||||
const props = defineProps({
|
||||
festival: Object,
|
||||
loading: Boolean,
|
||||
});
|
||||
|
||||
function toRgba(color) {
|
||||
@@ -52,6 +64,8 @@ function toRgba(color) {
|
||||
}
|
||||
|
||||
function results(ratioKey, topKey) {
|
||||
if (props.loading) return [null, null, null];
|
||||
|
||||
return props.festival.teams.map(team => ({
|
||||
ratio: team.result[ratioKey],
|
||||
isTop: team.result[topKey],
|
||||
@@ -78,7 +92,7 @@ const resultRows = computed(() => {
|
||||
},
|
||||
];
|
||||
|
||||
if (props.festival.teams.find(t => t.result.tricolorContributionRatio !== null)) {
|
||||
if (props.loading || props.festival.teams.find(t => t.result.tricolorContributionRatio !== null)) {
|
||||
rows.push({
|
||||
title: 'festival.results.tricolor',
|
||||
results: results('tricolorContributionRatio', 'isTricolorContributionRatioTop'),
|
||||
@@ -88,7 +102,7 @@ const resultRows = computed(() => {
|
||||
return rows;
|
||||
});
|
||||
|
||||
const winnerIndex = computed(() => props.festival.teams.findIndex(t => t.result.isWinner));
|
||||
const winnerIndex = computed(() => props.festival?.teams.findIndex(t => t.result.isWinner) ?? -1);
|
||||
const winner = computed(() => winnerIndex.value >= 0 ? props.festival.teams[winnerIndex.value] : null);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<span class="sr-only">Close</span>
|
||||
<XMarkIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<img v-if="imgUrl" :src="imgUrl" />
|
||||
<img v-if="imgUrl" :src="imgUrl" width="800" height="450" />
|
||||
</div>
|
||||
|
||||
<div class="absolute -top-8 w-full text-center">
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<template>
|
||||
<button class="block relative" @click.prevent="open = true">
|
||||
<div class="bg-zinc-700 aspect-2/1 overflow-hidden" :class="imgClass">
|
||||
<img v-if="lowRes" :src="lowRes" />
|
||||
<div v-else class="bg-zinc-500 animate-pulse h-full" :class="imgClass">
|
||||
|
||||
</div>
|
||||
<button :disabled="loading || !stage" class="block relative" @click.prevent="open = true">
|
||||
<div class="aspect-2/1 overflow-hidden" :class="[imgClass, { 'bg-white/20': !loading && lowRes }]">
|
||||
<img v-if="!loading && lowRes" :src="lowRes" width="400" height="200" />
|
||||
<SkeletonBlock v-else class="h-full rounded-none" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!hideLabel && stage"
|
||||
v-if="!loading && !hideLabel && stage"
|
||||
class="
|
||||
absolute
|
||||
bg-zinc-900
|
||||
@@ -29,15 +27,17 @@
|
||||
{{ $t(`splatnet.stages.${stage.id}.name`, stage.name) }}
|
||||
</div>
|
||||
|
||||
<StageDialog :stage="stage" :show="open" @close="open = false" />
|
||||
<StageDialog :stage="stage" :show="open && !loading" @close="open = false" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import StageDialog from './StageDialog.vue';
|
||||
import SkeletonBlock from './loading/SkeletonBlock.vue';
|
||||
|
||||
const props = defineProps({
|
||||
loading: Boolean,
|
||||
stage: Object,
|
||||
imgClass: String,
|
||||
textSize: {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<ProductContainer :bg="type.bg" class="w-full pt-10 pb-4">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2 mx-2">
|
||||
<img :src="type.img" />
|
||||
<img :src="type.img" width="40" height="40" />
|
||||
<div class="font-splatoon1 lg:text-2xl xl:text-3xl text-shadow">
|
||||
{{ $t(type.name) }}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<template>
|
||||
<template v-if="event">
|
||||
<template v-if="loading || event">
|
||||
<ProductContainer :bg="type.bg" class="w-full pt-10 pb-2">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2 mx-2">
|
||||
<img :src="type.img" />
|
||||
<div class="font-splatoon1 lg:text-2xl xl:text-3xl text-shadow">
|
||||
<img :src="type.img" width="66" height="65" />
|
||||
<div v-if="loading" class="grow space-y-2">
|
||||
<SkeletonBlock class="h-6 lg:h-8 xl:h-9 w-3/4" />
|
||||
<SkeletonBlock class="h-12 xl:h-14" />
|
||||
</div>
|
||||
<div v-else class="font-splatoon1 lg:text-2xl xl:text-3xl text-shadow">
|
||||
{{ $t(`splatnet.events.${event.settings.leagueMatchEvent.id}.name`, event.settings.leagueMatchEvent.name) }}
|
||||
|
||||
<div class="font-splatoon2 lg:text-md xl:text-xl text-shadow whitespace-pre-line">
|
||||
@@ -16,7 +20,7 @@
|
||||
<div class="bg-zinc-900/70 backdrop-blur-xs pt-2 pb-1 px-2 mx-2 rounded-lg space-y-2">
|
||||
<div class="flex items-center justify-between font-splatoon2">
|
||||
<div class="flex items-center space-x-2 text-sm lg:text-lg">
|
||||
<template v-if="event">
|
||||
<template v-if="!loading && event">
|
||||
<div>
|
||||
<RuleIcon :rule="event.settings.vsRule" class="h-5 lg:h-6 drop-shadow-ruleIcon" />
|
||||
</div>
|
||||
@@ -26,13 +30,13 @@
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="w-32 bg-zinc-600 rounded-sm animate-pulse">
|
||||
<div class="h-5 lg:h-7 w-32 bg-zinc-600 rounded-sm motion-safe:animate-pulse" aria-hidden="true">
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="event.activeTimePeriod">
|
||||
<div v-if="!loading && event.activeTimePeriod">
|
||||
<SquidTape class="font-splatoon2 text-sm drop-shadow-sm rotate-6">
|
||||
<div class="px-2">
|
||||
{{ $t('events.now_open') }}
|
||||
@@ -45,36 +49,38 @@
|
||||
<StageImage
|
||||
class="flex-1"
|
||||
img-class="rounded-l-xl"
|
||||
:stage="event.settings?.vsStages[0]"
|
||||
:stage="event?.settings?.vsStages[0]"
|
||||
:loading="loading"
|
||||
/>
|
||||
<StageImage
|
||||
class="flex-1"
|
||||
img-class="rounded-r-xl"
|
||||
:stage="event.settings?.vsStages[1]"
|
||||
:stage="event?.settings?.vsStages[1]"
|
||||
:loading="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mx-2 space-y-2 ss:hidden">
|
||||
<!-- Past time periods -->
|
||||
<template v-if="event.pastTimePeriods?.length">
|
||||
<template v-if="!loading && event.pastTimePeriods?.length">
|
||||
<div class="divide-y-2 divide-dashed divide-zinc-600 font-splatoon">
|
||||
<div v-for="timePeriod in event.pastTimePeriods" :key="timePeriod.startTime" class="flex flex-row justify-center">
|
||||
<ChallengeScheduleRow :event="event" :time-period="timePeriod" class="my-2" />
|
||||
<ChallengeScheduleRow :event="event" :time-period="timePeriod" :loading="loading" class="my-2" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Current/future time periods -->
|
||||
<template v-if="event.currentTimePeriods?.length">
|
||||
<template v-if="loading || event.currentTimePeriods?.length">
|
||||
<SquidTape class="font-splatoon2 text-sm drop-shadow-sm -rotate-6 -mx-2 mt-4">
|
||||
<div class="px-2">
|
||||
{{ event.activeTimePeriod ? $t('events.now') : $t('events.available') }}
|
||||
{{ !loading && event.activeTimePeriod ? $t('events.now') : $t('events.available') }}
|
||||
</div>
|
||||
</SquidTape>
|
||||
|
||||
<div class="divide-y-2 divide-dashed divide-zinc-400 font-splatoon">
|
||||
<div v-for="timePeriod in event.currentTimePeriods" :key="timePeriod.startTime" class="flex flex-row justify-center">
|
||||
<ChallengeScheduleRow :event="event" :time-period="timePeriod" class="my-2" />
|
||||
<div v-for="(timePeriod, i) in loading ? Array(6).fill(null) : event.currentTimePeriods" :key="timePeriod?.startTime ?? i" class="flex flex-row justify-center">
|
||||
<ChallengeScheduleRow :event="event" :time-period="timePeriod" :loading="loading" class="my-2" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -82,7 +88,15 @@
|
||||
</div>
|
||||
|
||||
<div class="font-splatoon2 mx-2 p-2 text-zinc-200 bg-zinc-900/50 backdrop-blur-xs rounded-lg whitespace-pre-line">
|
||||
{{ br2nl($t(`splatnet.events.${event.settings.leagueMatchEvent.id}.regulation`, event.settings.leagueMatchEvent.regulation)) }}
|
||||
<div v-if="loading" class="space-y-3 py-1">
|
||||
<SkeletonBlock class="h-6" />
|
||||
<SkeletonBlock class="h-6 w-5/6" />
|
||||
<SkeletonBlock class="h-6" />
|
||||
<SkeletonBlock class="h-6 w-2/3" />
|
||||
</div>
|
||||
<template v-else>
|
||||
{{ br2nl($t(`splatnet.events.${event.settings.leagueMatchEvent.id}.regulation`, event.settings.leagueMatchEvent.regulation)) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</ProductContainer>
|
||||
@@ -90,6 +104,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SkeletonBlock from '@/components/loading/SkeletonBlock.vue';
|
||||
import { computed } from 'vue';
|
||||
import ChallengeScheduleRow from './ChallengeScheduleRow.vue';
|
||||
import RuleIcon from '@/components/RuleIcon.vue';
|
||||
@@ -104,10 +119,8 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
event: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
event: Object,
|
||||
loading: Boolean,
|
||||
});
|
||||
|
||||
const { types } = useScheduleTypes();
|
||||
|
||||
@@ -1,42 +1,41 @@
|
||||
<template>
|
||||
<template v-if="event && timePeriod">
|
||||
<div class="flex items-center w-full font-splatoon2" :class="isCurrent ? 'text-zinc-100' : 'text-zinc-300 opacity-60'">
|
||||
<RuleIcon :rule="event.settings.vsRule" class="h-5 lg:h-6 drop-shadow-ruleIcon" />
|
||||
<div class="px-2 text-shadow whitespace-pre">
|
||||
{{ $d(timePeriod.startTime, 'dateTimeShortWeekday') }}
|
||||
–
|
||||
{{ $d(timePeriod.endTime, 'dateTimeShort') }}
|
||||
</div>
|
||||
|
||||
<div v-if="isCurrent" class="flex justify-end w-full">
|
||||
<div class="hidden sm:block text-xs bg-zinc-100/80 rounded-sm text-black px-2">
|
||||
<template v-if="isActive">
|
||||
{{ $t('time.remaining', { time: formatDurationFromNow(event.activeTimePeriod.endTime) }) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('time.in', { time: formatDurationFromNow(timePeriod.startTime, true) }) }}
|
||||
</template>
|
||||
<template v-if="loading || (event && timePeriod)">
|
||||
<div class="flex items-center w-full font-splatoon2" :class="loading || isCurrent ? 'text-zinc-100' : 'text-zinc-300 opacity-60'">
|
||||
<SkeletonBlock v-if="loading" class="h-6 w-full" />
|
||||
<template v-else>
|
||||
<RuleIcon :rule="event.settings.vsRule" class="h-5 lg:h-6 drop-shadow-ruleIcon" />
|
||||
<div class="px-2 text-shadow whitespace-pre">
|
||||
{{ $d(timePeriod.startTime, 'dateTimeShortWeekday') }}
|
||||
–
|
||||
{{ $d(timePeriod.endTime, 'dateTimeShort') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isCurrent" class="flex justify-end w-full">
|
||||
<div class="hidden sm:block text-xs bg-zinc-100/80 rounded-sm text-black px-2">
|
||||
<template v-if="isActive">
|
||||
{{ $t('time.remaining', { time: formatDurationFromNow(event.activeTimePeriod.endTime) }) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('time.in', { time: formatDurationFromNow(timePeriod.startTime, true) }) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SkeletonBlock from '@/components/loading/SkeletonBlock.vue';
|
||||
import { computed } from 'vue';
|
||||
import RuleIcon from '@/components/RuleIcon.vue';
|
||||
import { useTimeStore } from '@/stores/time.mjs';
|
||||
import { formatDurationFromNow } from '@/common/time';
|
||||
|
||||
const props = defineProps({
|
||||
event: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
timePeriod: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
event: Object,
|
||||
timePeriod: Object,
|
||||
loading: Boolean,
|
||||
});
|
||||
|
||||
const time = useTimeStore();
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
<ProductContainer class="pt-10 pb-4" bg="bg-splatoon-blue bg-circles">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div v-if="brand" class="-mb-10">
|
||||
<img :src="brand.image.url" />
|
||||
<div v-if="loading || brand" class="-mb-10">
|
||||
<SkeletonBlock v-if="loading" class="aspect-2/1" />
|
||||
<img v-else :src="brand.image.url" width="1600" height="800" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center -space-y-2">
|
||||
<SquidTape
|
||||
@@ -17,10 +18,11 @@
|
||||
</div>
|
||||
</SquidTape>
|
||||
|
||||
<div v-if="brand" class="relative -rotate-2">
|
||||
<img src="@/assets/img/gesotown-daily-drop-bg.png" class="w-64" />
|
||||
<div v-if="loading || brand" class="relative -rotate-2">
|
||||
<img width="840" height="146" src="@/assets/img/gesotown-daily-drop-bg.png" class="w-64" />
|
||||
<div class="absolute inset-0 flex items-center ml-4">
|
||||
<div class="font-splatoon2 text-lg">
|
||||
<SkeletonBlock v-if="loading" class="h-7 w-32" />
|
||||
<div v-else class="font-splatoon2 text-lg">
|
||||
{{ $t(`splatnet.brands.${brand.brand.id}.name`, brand.brand.name) }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,23 +30,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="brand">
|
||||
<template v-if="loading || brand">
|
||||
<div class="text-center font-splatoon2 text-splatoon-yellow">
|
||||
{{ $t('time.until', { time: $d(brand?.saleEndTime, 'dateTimeShortWeekday') }) }}
|
||||
<SkeletonBlock v-if="loading" class="h-6 w-52 mx-auto" />
|
||||
<template v-else>
|
||||
{{ $t('time.until', { time: $d(brand?.saleEndTime, 'dateTimeShortWeekday') }) }}
|
||||
</template>
|
||||
</div>
|
||||
<div class="space-y-4 px-4">
|
||||
<GearCardHorizontal
|
||||
v-for="gear in gears"
|
||||
:key="gear.id"
|
||||
v-for="(gear, i) in loading ? [null, null, null] : gears"
|
||||
:key="gear?.id ?? i"
|
||||
class="bg-zinc-100/20 backdrop-blur-xs border border-zinc-50/20 rounded-lg"
|
||||
:gear="gear"
|
||||
:loading="loading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="h-24 flex items-center justify-center">
|
||||
<div class="font-splatoon2 text-splatoon-yellow">
|
||||
<div class="font-splatoon2">
|
||||
{{ $t('times.checkback') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,11 +61,14 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import SkeletonBlock from '@/components/loading/SkeletonBlock.vue';
|
||||
import GearCardHorizontal from './GearCardHorizontal.vue';
|
||||
import ProductContainer from '@/components/ProductContainer.vue';
|
||||
import SquidTape from '@/components/SquidTape.vue';
|
||||
import { useGearStore } from '@/stores/gear.mjs';
|
||||
|
||||
defineProps({ loading: Boolean });
|
||||
|
||||
const gearStore = useGearStore();
|
||||
const brand = computed(() => gearStore.dailyDropBrand);
|
||||
const gears = computed(() => gearStore.dailyDropGear);
|
||||
|
||||
@@ -1,55 +1,64 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<img src="@/assets/img/gesotown-tape-blob-bg.png" />
|
||||
<img width="952" height="972" src="@/assets/img/gesotown-tape-blob-bg.png" />
|
||||
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-evenly my-4">
|
||||
<!-- Gear Image -->
|
||||
<div>
|
||||
<img :src="gear.image.url" class="h-20 w-20 lg:h-24 lg:w-24 xl:h-28 xl:w-28" />
|
||||
</div>
|
||||
|
||||
<!-- Powers -->
|
||||
<div>
|
||||
<div class="flex items-center space-x-px">
|
||||
<div :title="$t(`splatnet.powers.${gear.primaryGearPower.__splatoon3ink_id}.name`, gear.primaryGearPower.name)" class="bg-black rounded-full">
|
||||
<img :src="gear.primaryGearPower.image.url" class="h-8" />
|
||||
</div>
|
||||
|
||||
<div v-for="(power, i) in gear.additionalGearPowers" :key="i" :title="$t(`splatnet.powers.${power.__splatoon3ink_id}.name`, power.name)" class="bg-black rounded-full">
|
||||
<img :src="power.image.url" class="h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="relative text-center">
|
||||
<div class="mx-6">
|
||||
<img src="@/assets/img/gesotown-tape.svg" />
|
||||
</div>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="font-splatoon2 text-sm xl:text-base ss:text-lg text-shadow">
|
||||
{{ $t(`splatnet.gear.${gear.__splatoon3ink_id}.name`, gear.name) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Price -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<template v-if="loading">
|
||||
<SkeletonBlock class="h-20 w-20 lg:h-24 lg:w-24 xl:h-28 xl:w-28" />
|
||||
<SkeletonBlock class="h-8 w-24" />
|
||||
<SkeletonBlock class="h-5 w-3/4" />
|
||||
<SkeletonBlock class="h-6 w-16" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Gear Image -->
|
||||
<div>
|
||||
<img src="@/assets/img/gesotown-coin.svg" />
|
||||
<img :src="gear.image.url" class="h-20 w-20 lg:h-24 lg:w-24 xl:h-28 xl:w-28" />
|
||||
</div>
|
||||
<div class="font-splatoon1">
|
||||
{{ price }}
|
||||
|
||||
<!-- Powers -->
|
||||
<div>
|
||||
<div class="flex items-center space-x-px">
|
||||
<div :title="$t(`splatnet.powers.${gear.primaryGearPower.__splatoon3ink_id}.name`, gear.primaryGearPower.name)" class="bg-black rounded-full">
|
||||
<img :src="gear.primaryGearPower.image.url" class="h-8 w-8" />
|
||||
</div>
|
||||
|
||||
<div v-for="(power, i) in gear.additionalGearPowers" :key="i" :title="$t(`splatnet.powers.${power.__splatoon3ink_id}.name`, power.name)" class="bg-black rounded-full">
|
||||
<img :src="power.image.url" class="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="relative text-center">
|
||||
<div class="mx-6">
|
||||
<img width="246" height="38" src="@/assets/img/gesotown-tape.svg" />
|
||||
</div>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="font-splatoon2 text-sm xl:text-base ss:text-lg text-shadow">
|
||||
{{ $t(`splatnet.gear.${gear.__splatoon3ink_id}.name`, gear.name) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Price -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<div>
|
||||
<img width="19" height="17" src="@/assets/img/gesotown-coin.svg" />
|
||||
</div>
|
||||
<div class="font-splatoon1">
|
||||
{{ price }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Brand -->
|
||||
<div class="absolute top-0 right-10">
|
||||
<div class="relative rotate-2">
|
||||
<img src="@/assets/img/gesotown-brand-bg.png" class="w-10" />
|
||||
<img width="82" height="71" src="@/assets/img/gesotown-brand-bg.png" class="w-10" />
|
||||
<div class="absolute inset-0 p-2">
|
||||
<img :src="gear.brand.image.url" class="w-full h-full" :title="$t(`splatnet.brands.${gear.brand.id}.name`, gear.brand.name)" />
|
||||
<SkeletonBlock v-if="loading" class="w-full h-full" />
|
||||
<img v-else :src="gear.brand.image.url" class="w-full h-full" :title="$t(`splatnet.brands.${gear.brand.id}.name`, gear.brand.name)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,18 +66,23 @@
|
||||
<!-- Time left -->
|
||||
<div class="absolute top-1 left-6">
|
||||
<div class="inline-block text-xs bg-zinc-200/30 rounded-sm px-1 py-px font-semibold">
|
||||
{{ $t('time.left', { time: formatDurationHoursFromNow(props.gear.saleEndTime) }) }}
|
||||
<SkeletonBlock v-if="loading" class="h-4 w-16" />
|
||||
<template v-else>
|
||||
{{ $t('time.left', { time: formatDurationHoursFromNow(props.gear.saleEndTime) }) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SkeletonBlock from '@/components/loading/SkeletonBlock.vue';
|
||||
import { computed } from 'vue';
|
||||
import { formatDurationHoursFromNow } from '@/common/time';
|
||||
|
||||
const props = defineProps({
|
||||
gear: Object,
|
||||
loading: Boolean,
|
||||
});
|
||||
|
||||
const price = computed(() => props.gear.price);
|
||||
|
||||
@@ -2,47 +2,55 @@
|
||||
<div class="flex items-center space-x-4 py-2 relative">
|
||||
<!-- Gear Image -->
|
||||
<div class="ml-2 shrink-0">
|
||||
<img :src="gear.image.url" class="h-20 w-20" />
|
||||
<SkeletonBlock v-if="loading" class="h-20 w-20" />
|
||||
<img v-else :src="gear.image.url" class="h-20 w-20" />
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<div class="grow min-w-0 flex flex-col justify-evenly space-y-2">
|
||||
<div class="flex">
|
||||
<div class="inline-block text-xs bg-zinc-200/30 rounded-sm px-1 py-px font-semibold">
|
||||
{{ $t('time.left', { time: formatDurationHoursFromNow(props.gear.saleEndTime) }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="bg-white h-6 aspect-square rounded-sm">
|
||||
<img :src="gear.brand.image.url" :title="$t(`splatnet.brands.${gear.brand.id}.name`, gear.brand.name)" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 font-splatoon2 text-shadow overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ $t(`splatnet.gear.${gear.__splatoon3ink_id}.name`, gear.name) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<div class="flex items-center space-x-px">
|
||||
<div :title="$t(`splatnet.powers.${gear.primaryGearPower.__splatoon3ink_id}.name`, gear.primaryGearPower.name)" class="bg-black rounded-full">
|
||||
<img :src="gear.primaryGearPower.image.url" class="h-8" />
|
||||
</div>
|
||||
|
||||
<div v-for="(power, i) in gear.additionalGearPowers" :key="i" :title="$t(`splatnet.powers.${power.__splatoon3ink_id}.name`, power.name)" class="bg-black rounded-full">
|
||||
<img :src="power.image.url" class="h-6" />
|
||||
<template v-if="loading">
|
||||
<SkeletonBlock class="h-[18px] w-20" />
|
||||
<SkeletonBlock class="h-6 w-3/4" />
|
||||
<SkeletonBlock class="h-8 w-full" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex">
|
||||
<div class="inline-block text-xs bg-zinc-200/30 rounded-sm px-1 py-px font-semibold">
|
||||
{{ $t('time.left', { time: formatDurationHoursFromNow(props.gear.saleEndTime) }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 bg-price w-28 pl-2 -mr-px">
|
||||
<div>
|
||||
<img src="@/assets/img/gesotown-coin.svg" />
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="bg-white h-6 w-6 shrink-0 rounded-sm">
|
||||
<img class="h-full w-full" :src="gear.brand.image.url" :title="$t(`splatnet.brands.${gear.brand.id}.name`, gear.brand.name)" />
|
||||
</div>
|
||||
<div class="font-splatoon1">
|
||||
{{ price }}
|
||||
|
||||
<div class="flex-1 font-splatoon2 text-shadow overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ $t(`splatnet.gear.${gear.__splatoon3ink_id}.name`, gear.name) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<div class="flex items-center space-x-px">
|
||||
<div :title="$t(`splatnet.powers.${gear.primaryGearPower.__splatoon3ink_id}.name`, gear.primaryGearPower.name)" class="bg-black rounded-full">
|
||||
<img :src="gear.primaryGearPower.image.url" class="h-8 w-8" />
|
||||
</div>
|
||||
|
||||
<div v-for="(power, i) in gear.additionalGearPowers" :key="i" :title="$t(`splatnet.powers.${power.__splatoon3ink_id}.name`, power.name)" class="bg-black rounded-full">
|
||||
<img :src="power.image.url" class="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 bg-price w-28 pl-2 -mr-px">
|
||||
<div>
|
||||
<img width="19" height="17" src="@/assets/img/gesotown-coin.svg" />
|
||||
</div>
|
||||
<div class="font-splatoon1">
|
||||
{{ price }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Order button -->
|
||||
@@ -82,7 +90,7 @@
|
||||
|
||||
<div class="flex items-center space-x-2 bg-price w-28 pl-2 -mr-px">
|
||||
<div>
|
||||
<img src="@/assets/img/gesotown-coin.svg" />
|
||||
<img width="19" height="17" src="@/assets/img/gesotown-coin.svg" />
|
||||
</div>
|
||||
<div class="font-splatoon1">
|
||||
{{ price }}
|
||||
@@ -93,6 +101,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SkeletonBlock from '@/components/loading/SkeletonBlock.vue';
|
||||
import { computed } from 'vue';
|
||||
import SquidTape from '@/components/SquidTape.vue';
|
||||
import { formatDurationHoursFromNow } from '@/common/time';
|
||||
@@ -100,6 +109,7 @@ import { getGesotownGearUrl } from '@/common/links';
|
||||
|
||||
const props = defineProps({
|
||||
gear: Object,
|
||||
loading: Boolean,
|
||||
});
|
||||
|
||||
const price = computed(() => props.gear.price);
|
||||
|
||||
@@ -27,17 +27,17 @@
|
||||
</SquidTape>
|
||||
</div>
|
||||
|
||||
<template v-if="gears && gears.length">
|
||||
<template v-if="loading || gears?.length">
|
||||
<div class="md:hidden px-2">
|
||||
<div v-for="gear in gears" :key="gear.id" class="bg-horiz-card">
|
||||
<GearCardHorizontal :gear="gear" />
|
||||
<div v-for="(gear, i) in displayedGears" :key="gear?.id ?? i" class="bg-horiz-card">
|
||||
<GearCardHorizontal :gear="gear" :loading="loading" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:block px-2">
|
||||
<div class="flex flex-wrap justify-center max-w-3xl">
|
||||
<div v-for="gear in gears" :key="gear.id" class="my-6 w-1/3 flex justify-center">
|
||||
<GearCard :gear="gear" />
|
||||
<div v-for="(gear, i) in displayedGears" :key="gear?.id ?? i" class="my-6 w-1/3 flex justify-center">
|
||||
<GearCard :gear="gear" :loading="loading" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,8 +61,11 @@ import GearCardHorizontal from './GearCardHorizontal.vue';
|
||||
import SquidTape from '@/components/SquidTape.vue';
|
||||
import { useGearStore } from '@/stores/gear.mjs';
|
||||
|
||||
const props = defineProps({ loading: Boolean });
|
||||
|
||||
const gearStore = useGearStore();
|
||||
const gears = computed(() => gearStore.regularGear);
|
||||
const displayedGears = computed(() => props.loading ? Array(6).fill(null) : gears.value);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
57
src/components/loading/PageDataState.vue
Normal file
57
src/components/loading/PageDataState.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="grow flex items-start justify-center" :aria-busy="!ready && !failed">
|
||||
<template v-if="!failed">
|
||||
<p v-if="!ready" role="status" class="sr-only">
|
||||
{{ $t('loading.label') }}
|
||||
</p>
|
||||
<slot :loading="!ready" />
|
||||
</template>
|
||||
|
||||
<div v-else class="mx-4 md:mx-12 w-full">
|
||||
<div class="min-h-[32rem] flex items-center justify-center">
|
||||
<div role="alert" class="rounded-2xl bg-zinc-800/90 p-8 text-center space-y-4 max-w-md">
|
||||
<p class="font-splatoon2 text-lg">
|
||||
{{ $t('loading.error') }}
|
||||
</p>
|
||||
<button class="rounded-full bg-splatoon-blue px-5 py-2 font-splatoon2 hover:bg-splatoon-purple focus-visible:outline-2 focus-visible:outline-offset-4" @click="retry">
|
||||
{{ $t('loading.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { usePagePreviewStore } from '@/stores/pagePreview.mjs';
|
||||
|
||||
const props = defineProps({
|
||||
sources: { type: Array, required: true },
|
||||
});
|
||||
|
||||
const preview = import.meta.env.DEV ? usePagePreviewStore() : null;
|
||||
|
||||
const ready = computed(() => {
|
||||
if (import.meta.env.DEV && ['loading', 'error'].includes(preview.mode)) return false;
|
||||
|
||||
return props.sources.every(source => source.data !== null);
|
||||
});
|
||||
|
||||
const failed = computed(() => {
|
||||
if (import.meta.env.DEV && preview.mode === 'loading') return false;
|
||||
if (import.meta.env.DEV && preview.mode === 'error') return true;
|
||||
|
||||
return props.sources.some(source => source.data === null && source.error);
|
||||
});
|
||||
|
||||
function retry() {
|
||||
if (import.meta.env.DEV && preview.mode === 'error') {
|
||||
preview.mode = 'live';
|
||||
}
|
||||
|
||||
for (const source of props.sources) {
|
||||
if (source.data === null) source.update();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
13
src/components/loading/SkeletonBlock.vue
Normal file
13
src/components/loading/SkeletonBlock.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="skeleton-block" aria-hidden="true" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@/assets/css/base.css";
|
||||
|
||||
@layer components {
|
||||
.skeleton-block {
|
||||
@apply rounded-md bg-white/20 motion-safe:animate-pulse;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
46
src/components/loading/loading.test.mjs
Normal file
46
src/components/loading/loading.test.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createSSRApp } from 'vue';
|
||||
import { renderToString } from 'vue/server-renderer';
|
||||
import { createPinia } from 'pinia';
|
||||
import ScheduleBox from '@/components/ScheduleBox.vue';
|
||||
import DailyDropGear from '@/components/gear/DailyDropGear.vue';
|
||||
import RegularGear from '@/components/gear/RegularGear.vue';
|
||||
import SalmonRunBox from '@/components/salmonrun/SalmonRunBox.vue';
|
||||
import ChallengeScheduleBox from '@/components/challenge/ChallengeScheduleBox.vue';
|
||||
import SplatfestBox from '@/components/SplatfestBox.vue';
|
||||
import SplatfestResultsBox from '@/components/SplatfestResultsBox.vue';
|
||||
|
||||
async function render(component, props) {
|
||||
const app = createSSRApp(component, props);
|
||||
app.use(createPinia());
|
||||
app.config.globalProperties.$t = key => key;
|
||||
|
||||
return renderToString(app);
|
||||
}
|
||||
|
||||
describe('loading cards without fetched data', () => {
|
||||
it.each([
|
||||
['schedule', ScheduleBox, { type: 'regular' }, 'bg-tapes', 'schedule.types.regular'],
|
||||
['daily drop', DailyDropGear, {}, 'bg-circles', 'gear.dailydrop'],
|
||||
['regular gear', RegularGear, {}, 'bg-paper', 'gear.sale'],
|
||||
['salmon run', SalmonRunBox, {}, 'bg-character', 'salmonrun.title'],
|
||||
['challenges', ChallengeScheduleBox, { type: 'challenge' }, 'bg-tapes', 'events.available'],
|
||||
['splatfest', SplatfestBox, {}, 'bg-camo-purple', 'festival.active'],
|
||||
['splatfest results', SplatfestResultsBox, {}, 'bg-camo-purple', 'festival.results.title'],
|
||||
])('renders %s with its normal artwork and static labels', async (_name, component, props, artwork, label) => {
|
||||
const html = await render(component, { ...props, loading: true });
|
||||
|
||||
expect(html).toContain(artwork);
|
||||
expect(html).toContain(label);
|
||||
expect(html).not.toContain('times.checkback');
|
||||
expect(html).not.toContain('events.not_available');
|
||||
});
|
||||
|
||||
it('disables stage previews and the upcoming schedule button while loading', async () => {
|
||||
const html = await render(ScheduleBox, { type: 'regular', loading: true });
|
||||
const buttons = html.match(/<button\b[^>]*>/g);
|
||||
|
||||
expect(buttons).toHaveLength(5);
|
||||
expect(buttons.every(button => button.includes('disabled'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,23 @@
|
||||
<template>
|
||||
<div v-if="schedule" class="font-splatoon2 space-y-1">
|
||||
<div v-if="loading || schedule" class="font-splatoon2 space-y-1">
|
||||
<div class="flex gap-2">
|
||||
<img v-if="eggstra" src="@/assets/img/modes/coop.eggstra.svg" :title="$t('salmonrun.bigrun')" class="w-6 -mr-1" />
|
||||
<img
|
||||
v-if="eggstra"
|
||||
width="40"
|
||||
height="40"
|
||||
src="@/assets/img/modes/coop.eggstra.svg"
|
||||
:title="$t('salmonrun.bigrun')"
|
||||
class="w-6 -mr-1"
|
||||
/>
|
||||
|
||||
<div class="text-lg text-shadow text-zinc-200 ss:hidden">
|
||||
<SkeletonBlock v-if="loading" class="h-7 w-full" />
|
||||
<div v-else class="text-lg text-shadow text-zinc-200 ss:hidden">
|
||||
{{ $d(schedule.startTime, 'dateTimeShort') }}
|
||||
–
|
||||
{{ $d(schedule.endTime, 'dateTimeShort') }}
|
||||
</div>
|
||||
|
||||
<div class="hidden ss:block text-shadow text-white text-xl">
|
||||
<div v-if="!loading" class="hidden ss:block text-shadow text-white text-xl">
|
||||
<KingSalmonid v-if="!eggstra" :schedule="schedule" class="inline-block -mb-1 mr-2 drop-shadow-ruleIcon" />
|
||||
|
||||
<div v-if="time.isUpcoming(schedule.startTime)" class="inline-block">
|
||||
@@ -22,22 +30,31 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="schedule.isBigRun"
|
||||
v-if="!loading && schedule.isBigRun"
|
||||
class="bg-zinc-800/80 text-sm text-white rounded-lg px-2 border-2 border-splatoon-bigRun"
|
||||
>
|
||||
<img src="@/assets/img/modes/coop.bigrun.svg" :title="$t('salmonrun.bigrun')" class="w-4 inline-block" />
|
||||
<img
|
||||
width="25"
|
||||
height="22"
|
||||
src="@/assets/img/modes/coop.bigrun.svg"
|
||||
:title="$t('salmonrun.bigrun')"
|
||||
class="w-4 inline-block"
|
||||
/>
|
||||
{{ $t('salmonrun.bigrun') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!time.isUpcoming(schedule.startTime)" class="text-shadow text-zinc-300 ss:hidden">
|
||||
<KingSalmonid v-if="!eggstra" :schedule="schedule" class="inline-block align-middle drop-shadow-ruleIcon" />
|
||||
<div v-if="loading || !time.isUpcoming(schedule.startTime)" class="text-shadow text-zinc-300 ss:hidden">
|
||||
<SkeletonBlock v-if="loading" class="inline-block align-middle h-6 w-40" />
|
||||
<template v-else>
|
||||
<KingSalmonid v-if="!eggstra" :schedule="schedule" class="inline-block align-middle drop-shadow-ruleIcon" />
|
||||
|
||||
{{ $t('time.remaining', { time: formatDurationFromNow(schedule.endTime) }) }}
|
||||
{{ $t('time.remaining', { time: formatDurationFromNow(schedule.endTime) }) }}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<StageImage class="flex-1" img-class="rounded-lg" :stage="schedule.settings.coopStage" />
|
||||
<StageImage class="flex-1" img-class="rounded-lg" :stage="schedule?.settings.coopStage" :loading="loading" />
|
||||
|
||||
<div class="flex flex-col items-center space-y-1">
|
||||
<div class="text-sm text-center text-shadow text-zinc-200">
|
||||
@@ -45,7 +62,7 @@
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-900/30 rounded-full backdrop-blur-xs px-2">
|
||||
<SalmonRunWeapons :weapons="schedule.settings.weapons" weapon-class="w-10 sm:w-14" />
|
||||
<SalmonRunWeapons :weapons="schedule?.settings.weapons" :loading="loading" weapon-class="w-10 sm:w-14" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,6 +70,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SkeletonBlock from '@/components/loading/SkeletonBlock.vue';
|
||||
import KingSalmonid from './KingSalmonid.vue';
|
||||
import SalmonRunWeapons from './SalmonRunWeapons.vue';
|
||||
import { useTimeStore } from '@/stores/time.mjs';
|
||||
@@ -61,6 +79,7 @@ import { formatDurationFromNow, formatDurationHoursFromNow, formatShortDurationF
|
||||
|
||||
defineProps({
|
||||
schedule: Object,
|
||||
loading: Boolean,
|
||||
eggstra: Boolean,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,24 +2,32 @@
|
||||
<div>
|
||||
<img
|
||||
v-if="boss?.name === 'Cohozuna'"
|
||||
width="420"
|
||||
height="421"
|
||||
src="@/assets/img/king-cohozuna.png"
|
||||
:class="size"
|
||||
:title="$t(`splatnet.bosses.${boss.id}.name`, boss.name)"
|
||||
/>
|
||||
<img
|
||||
v-if="boss?.name === 'Horrorboros'"
|
||||
width="420"
|
||||
height="421"
|
||||
src="@/assets/img/king-horrorboros.png"
|
||||
:class="size"
|
||||
:title="$t(`splatnet.bosses.${boss.id}.name`, boss.name)"
|
||||
/>
|
||||
<img
|
||||
v-if="schedule.setting.boss.name === 'Megalodontia'"
|
||||
width="420"
|
||||
height="421"
|
||||
src="@/assets/img/king-megalodontia.png"
|
||||
:class="size"
|
||||
:title="$t(`splatnet.bosses.${schedule.setting.boss.id}.name`, schedule.setting.boss.name)"
|
||||
/>
|
||||
<img
|
||||
v-if="schedule.setting.boss.name === 'Triumvirate'"
|
||||
width="400"
|
||||
height="400"
|
||||
src="@/assets/img/king-triumvirate.png"
|
||||
:class="size"
|
||||
:title="$t(`splatnet.bosses.${schedule.setting.boss.id}.name`, schedule.setting.boss.name)"
|
||||
|
||||
@@ -8,24 +8,28 @@
|
||||
{{ $t('salmonrun.title') }}
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<p v-if="!loading && !activeSchedule && !upcomingSchedules.length" class="py-12 text-center font-splatoon2">
|
||||
{{ $t('times.checkback') }}
|
||||
</p>
|
||||
|
||||
<div v-else class="flex">
|
||||
<!-- Character graphic -->
|
||||
<div v-if="eggstra" class="flex-1 bg-eggstra hidden md:block" />
|
||||
<div v-else class="flex-1 bg-character hidden md:block" />
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="md:w-2/3 mx-2 pb-2">
|
||||
<div v-if="activeSchedule" class="mb-6 space-y-2">
|
||||
<div class="w-full md:w-2/3 mx-2 pb-2">
|
||||
<div v-if="loading || activeSchedule" class="mb-6 space-y-2">
|
||||
<SquidTape class="font-splatoon2 text-sm drop-shadow-sm -rotate-6 -mx-2">
|
||||
<div class="px-2">
|
||||
{{ $t('times.now') }}
|
||||
</div>
|
||||
</SquidTape>
|
||||
|
||||
<ExpandedSalmonRunRow :schedule="activeSchedule" :eggstra="eggstra" />
|
||||
<ExpandedSalmonRunRow :schedule="activeSchedule" :loading="loading" :eggstra="eggstra" />
|
||||
</div>
|
||||
|
||||
<div v-if="upcomingSchedules.length" class="py-1 bg-zinc-900/70 rounded-lg backdrop-blur-xs">
|
||||
<div v-if="loading || upcomingSchedules.length" class="py-1 bg-zinc-900/70 rounded-lg backdrop-blur-xs">
|
||||
<SquidTape class="font-splatoon2 text-sm drop-shadow-sm -rotate-6 -mx-2">
|
||||
<div class="px-2">
|
||||
{{ $t('times.future') }}
|
||||
@@ -33,9 +37,15 @@
|
||||
</SquidTape>
|
||||
|
||||
<div class="mx-2 divide-y-2 divide-dashed divide-zinc-400">
|
||||
<div v-for="schedule in upcomingSchedules" :key="schedule.startTime">
|
||||
<ExpandedSalmonRunRow v-if="isScreenshot || eggstra" class="my-3" :schedule="schedule" :eggstra="eggstra" />
|
||||
<SalmonRunRow v-else class="my-2" :schedule="schedule" />
|
||||
<div v-for="(schedule, i) in loading ? Array(4).fill(null) : upcomingSchedules" :key="schedule?.startTime ?? i">
|
||||
<ExpandedSalmonRunRow
|
||||
v-if="isScreenshot || eggstra"
|
||||
class="my-3"
|
||||
:schedule="schedule"
|
||||
:loading="loading"
|
||||
:eggstra="eggstra"
|
||||
/>
|
||||
<SalmonRunRow v-else class="my-2" :schedule="schedule" :loading="loading" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,6 +64,7 @@ import SquidTape from '@/components/SquidTape.vue';
|
||||
import { useSalmonRunSchedulesStore, useEggstraWorkSchedulesStore } from '@/stores/schedules.mjs';
|
||||
|
||||
const props = defineProps({
|
||||
loading: Boolean,
|
||||
isScreenshot: Boolean,
|
||||
startTime: String,
|
||||
eggstra: Boolean,
|
||||
|
||||
@@ -1,43 +1,70 @@
|
||||
<template>
|
||||
<div v-if="schedule" class="font-splatoon2 space-y-1">
|
||||
<div v-if="loading || schedule" class="font-splatoon2 space-y-1">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<img v-if="schedule.isBigRun" src="@/assets/img/modes/coop.bigrun.svg" :title="$t('salmonrun.bigrun')" class="w-6 mr-1" />
|
||||
<img v-else src="@/assets/img/modes/coop.svg" :title="$t('salmonrun.title')" class="w-6 mr-1" />
|
||||
<img
|
||||
v-if="!loading && schedule.isBigRun"
|
||||
width="25"
|
||||
height="22"
|
||||
src="@/assets/img/modes/coop.bigrun.svg"
|
||||
:title="$t('salmonrun.bigrun')"
|
||||
class="w-6 mr-1"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
width="40"
|
||||
height="40"
|
||||
src="@/assets/img/modes/coop.svg"
|
||||
:title="$t('salmonrun.title')"
|
||||
class="w-6 mr-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 text-shadow text-zinc-200">
|
||||
{{ $d(schedule.startTime, 'dateTimeShortWeekday') }}
|
||||
–
|
||||
{{ $d(schedule.endTime, 'dateTimeShort') }}
|
||||
<SkeletonBlock v-if="loading" class="h-6 w-full" />
|
||||
<template v-else>
|
||||
{{ $d(schedule.startTime, 'dateTimeShortWeekday') }}
|
||||
–
|
||||
{{ $d(schedule.endTime, 'dateTimeShort') }}
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:block text-xs bg-zinc-100/80 rounded-sm text-black px-2">
|
||||
<div v-if="!loading" class="hidden sm:block text-xs bg-zinc-100/80 rounded-sm text-black px-2">
|
||||
{{ $t('time.in', { time: formatDurationFromNow(schedule.startTime, true) }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<StageImage class="w-1/5" img-class="rounded-sm" :stage="schedule.settings.coopStage" hide-label />
|
||||
<StageImage
|
||||
class="w-1/5"
|
||||
img-class="rounded-sm"
|
||||
:stage="schedule?.settings.coopStage"
|
||||
:loading="loading"
|
||||
hide-label
|
||||
/>
|
||||
|
||||
<div class="flex-1 text-sm text-zinc-300 text-shadow">
|
||||
<KingSalmonid :schedule="schedule" class="inline-block align-middle drop-shadow-ruleIcon" size="w-5" />
|
||||
<SkeletonBlock v-if="loading" class="inline-block align-middle h-10 sm:h-5 w-full" />
|
||||
<template v-else>
|
||||
<KingSalmonid :schedule="schedule" class="inline-block align-middle drop-shadow-ruleIcon" size="w-5" />
|
||||
|
||||
{{ $t(`splatnet.stages.${schedule.settings.coopStage.id}.name`, schedule.settings.coopStage.name) }}
|
||||
{{ $t(`splatnet.stages.${schedule.settings.coopStage.id}.name`, schedule.settings.coopStage.name) }}
|
||||
|
||||
<span v-if="schedule.isBigRun" class="text-xs inline-block bg-splatoon-bigRun/80 text-white rounded-sm px-2">
|
||||
{{ $t('salmonrun.bigrun') }}
|
||||
</span>
|
||||
<span v-if="!loading && schedule.isBigRun" class="text-xs inline-block bg-splatoon-bigRun/80 text-white rounded-sm px-2">
|
||||
{{ $t('salmonrun.bigrun') }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center space-y-1">
|
||||
<SalmonRunWeapons :weapons="schedule.settings.weapons" weapon-class="w-8 sm:w-10" />
|
||||
<SalmonRunWeapons :weapons="schedule?.settings.weapons" :loading="loading" weapon-class="w-8 sm:w-10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SkeletonBlock from '@/components/loading/SkeletonBlock.vue';
|
||||
import KingSalmonid from './KingSalmonid.vue';
|
||||
import SalmonRunWeapons from './SalmonRunWeapons.vue';
|
||||
import StageImage from '@/components/StageImage.vue';
|
||||
@@ -45,5 +72,6 @@ import { formatDurationFromNow } from '@/common/time';
|
||||
|
||||
defineProps({
|
||||
schedule: Object,
|
||||
loading: Boolean,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<div class="flex justify-center">
|
||||
<div v-for="(weapon, i) in props.weapons" :key="i">
|
||||
<div v-for="(weapon, i) in loading ? Array(4).fill(null) : props.weapons" :key="i">
|
||||
<SkeletonBlock v-if="loading" class="aspect-square" :class="weaponClass" />
|
||||
<img
|
||||
v-else
|
||||
:src="weapon.image.url"
|
||||
:title="$t(`splatnet.weapons.${weapon.__splatoon3ink_id}.name`, weapon.name)"
|
||||
class="aspect-square hover:scale-[1.15] hover:z-10 transition-transform"
|
||||
@@ -12,11 +14,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SkeletonBlock from '@/components/loading/SkeletonBlock.vue';
|
||||
|
||||
const props = defineProps({
|
||||
weapons: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
weapons: Array,
|
||||
loading: Boolean,
|
||||
weaponClass: {
|
||||
type: String,
|
||||
default: 'w-10',
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
<div class="m-2 text-center text-xs text-zinc-500">
|
||||
<div>
|
||||
<img src="@/assets/img/little-buddy.png" class="mx-auto mb-4" width="50" />
|
||||
<img src="@/assets/img/little-buddy.png" class="mx-auto mb-4 w-[50px]" width="131" height="175" />
|
||||
</div>
|
||||
<div>
|
||||
{{ $t('footer.term') }}
|
||||
@@ -50,20 +50,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TimeOffsetSelector v-if="isDev" class="mb-4" />
|
||||
<DevControls v-if="isDev" class="mb-4" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
import NavButtons from '@/components/NavButtons.vue';
|
||||
import LanguageButton from '@/components/LanguageButton.vue';
|
||||
import TimeOffsetSelector from '@/components/Debug/TimeOffsetSelector.vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
});
|
||||
|
||||
const isDev = import.meta.env.DEV;
|
||||
const DevControls = import.meta.env.DEV
|
||||
? defineAsyncComponent(() => import('@/components/Debug/DevControls.vue'))
|
||||
: null;
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,30 +1,48 @@
|
||||
import { acceptHMRUpdate, defineStore } from 'pinia';
|
||||
import { computed, ref, shallowRef } from 'vue';
|
||||
import { getEmptyPreviewData, usePagePreviewStore } from './pagePreview.mjs';
|
||||
|
||||
// Endpoint store definition (used for each individual data endpoint)
|
||||
function defineEndpointStore(id, endpoint, transform = null) {
|
||||
return defineStore(`data/${id}`, () => {
|
||||
const data = shallowRef(null);
|
||||
const loadedData = shallowRef(null);
|
||||
const preview = import.meta.env.DEV ? usePagePreviewStore() : null;
|
||||
const data = import.meta.env.DEV
|
||||
? computed(() => preview.mode === 'empty' ? getEmptyPreviewData(id) : loadedData.value)
|
||||
: loadedData;
|
||||
const isUpdating = ref(false);
|
||||
const error = ref(null);
|
||||
let pending;
|
||||
|
||||
function update() {
|
||||
if (pending) return pending;
|
||||
|
||||
async function update() {
|
||||
isUpdating.value = true;
|
||||
error.value = null;
|
||||
|
||||
pending = fetchData().finally(() => {
|
||||
isUpdating.value = false;
|
||||
pending = null;
|
||||
});
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
let baseUrl = import.meta.env.VITE_DATA_FROM || '';
|
||||
let response = await fetch(baseUrl + endpoint);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch ${endpoint}: ${response.status} ${response.statusText}`);
|
||||
|
||||
return;
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
let json = await response.json();
|
||||
|
||||
setData(json);
|
||||
} finally {
|
||||
isUpdating.value = false;
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause);
|
||||
console.error(`Failed to fetch ${endpoint}: ${error.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,10 +51,15 @@ function defineEndpointStore(id, endpoint, transform = null) {
|
||||
json = transform(json);
|
||||
}
|
||||
|
||||
data.value = json;
|
||||
if (json === null || typeof json !== 'object') {
|
||||
throw new Error('Missing data payload');
|
||||
}
|
||||
|
||||
loadedData.value = json;
|
||||
error.value = null;
|
||||
}
|
||||
|
||||
return { data, update, setData, isUpdating };
|
||||
return { data, loadedData, update, setData, isUpdating, error };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import {
|
||||
useCoopDataStore,
|
||||
@@ -27,3 +27,93 @@ describe('useDataStore', () => {
|
||||
expect(data.isLoaded).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('endpoint loading states', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('distinguishes an initial request from a successfully loaded empty result', async () => {
|
||||
let resolveResponse;
|
||||
vi.stubGlobal('fetch', vi.fn(() => new Promise(resolve => {
|
||||
resolveResponse = resolve;
|
||||
})));
|
||||
const store = useGearDataStore();
|
||||
|
||||
expect(store.data).toBe(null);
|
||||
expect(store.error).toBe(null);
|
||||
|
||||
const request = store.update();
|
||||
expect(store.isUpdating).toBe(true);
|
||||
expect(store.data).toBe(null);
|
||||
|
||||
resolveResponse(new Response(JSON.stringify({ data: { gesotown: null } })));
|
||||
await request;
|
||||
|
||||
expect(store.data).toEqual({ gesotown: null });
|
||||
expect(store.isUpdating).toBe(false);
|
||||
expect(store.error).toBe(null);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['HTTP error', () => Promise.resolve(new Response('', { status: 503 }))],
|
||||
['network error', () => Promise.reject(new TypeError('Failed to fetch'))],
|
||||
['invalid JSON', () => Promise.resolve(new Response('not JSON'))],
|
||||
['missing payload', () => Promise.resolve(new Response('{}'))],
|
||||
])('exposes %s and recovers on retry', async (_name, fail) => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const fetch = vi.fn(fail);
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const store = useGearDataStore();
|
||||
|
||||
await store.update();
|
||||
|
||||
expect(store.data).toBe(null);
|
||||
expect(store.error).toBeTruthy();
|
||||
expect(store.isUpdating).toBe(false);
|
||||
|
||||
fetch.mockResolvedValue(new Response(JSON.stringify({ data: { gesotown: null } })));
|
||||
const retry = store.update();
|
||||
expect(store.error).toBe(null);
|
||||
await retry;
|
||||
|
||||
expect(store.data).toEqual({ gesotown: null });
|
||||
expect(store.error).toBe(null);
|
||||
});
|
||||
|
||||
it('keeps the last successful data during and after a failed refresh', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Offline')));
|
||||
const store = useGearDataStore();
|
||||
store.setData({ data: { gesotown: { limitedGears: [] } } });
|
||||
|
||||
const refresh = store.update();
|
||||
expect(store.data).toEqual({ gesotown: { limitedGears: [] } });
|
||||
await refresh;
|
||||
|
||||
expect(store.data).toEqual({ gesotown: { limitedGears: [] } });
|
||||
expect(store.error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shares an in-flight request with retries and background updates', async () => {
|
||||
let resolveResponse;
|
||||
const fetch = vi.fn(() => new Promise(resolve => {
|
||||
resolveResponse = resolve;
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetch);
|
||||
const store = useGearDataStore();
|
||||
|
||||
const first = store.update();
|
||||
const second = store.update();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveResponse(new Response(JSON.stringify({ data: {} })));
|
||||
await Promise.all([first, second]);
|
||||
expect(store.isUpdating).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ export const useGearStore = defineStore('gear', () => {
|
||||
const gesotown = computed(() => gear.data?.gesotown);
|
||||
|
||||
const dailyDropBrand = computed(() => whenCurrent(gesotown.value?.pickupBrand));
|
||||
const dailyDropGear = computed(() => currentNodes(gesotown.value?.pickupBrand.brandGears));
|
||||
const dailyDropGear = computed(() => currentNodes(gesotown.value?.pickupBrand?.brandGears));
|
||||
const regularGear = computed(() => currentNodes(gesotown.value?.limitedGears));
|
||||
|
||||
return { dailyDropBrand, dailyDropGear, regularGear };
|
||||
|
||||
@@ -44,6 +44,15 @@ describe('useGearStore', () => {
|
||||
});
|
||||
|
||||
describe('dailyDropGear', () => {
|
||||
it('handles a successfully loaded shop with no daily drop', () => {
|
||||
setGearData(gearData, { pickupBrand: null, limitedGears: [] });
|
||||
gear = useGearStore();
|
||||
|
||||
expect(gear.dailyDropBrand).toBeNull();
|
||||
expect(gear.dailyDropGear).toBeUndefined();
|
||||
expect(gear.regularGear).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters to only current gear items', () => {
|
||||
time.setNow(Date.parse('2024-06-15T12:00:00Z'));
|
||||
setGearData(gearData, {
|
||||
|
||||
31
src/stores/pagePreview.mjs
Normal file
31
src/stores/pagePreview.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export const usePagePreviewStore = import.meta.env.DEV
|
||||
? defineStore('debug/pagePreview', {
|
||||
state: () => ({ mode: 'live' }),
|
||||
})
|
||||
: null;
|
||||
|
||||
const emptyData = {
|
||||
schedules: {
|
||||
currentFest: null,
|
||||
regularSchedules: { nodes: [] },
|
||||
bankaraSchedules: { nodes: [] },
|
||||
xSchedules: { nodes: [] },
|
||||
festSchedules: { nodes: [] },
|
||||
eventSchedules: { nodes: [] },
|
||||
vsStages: { nodes: [] },
|
||||
coopGroupingSchedule: {
|
||||
regularSchedules: { nodes: [] },
|
||||
bigRunSchedules: { nodes: [] },
|
||||
teamContestSchedules: { nodes: [] },
|
||||
},
|
||||
},
|
||||
gear: { gesotown: { pickupBrand: null, limitedGears: [] } },
|
||||
coop: { coopResult: { monthlyGear: null } },
|
||||
festivals: {},
|
||||
};
|
||||
|
||||
export function getEmptyPreviewData(endpoint) {
|
||||
return emptyData[endpoint];
|
||||
}
|
||||
71
src/stores/pagePreview.test.mjs
Normal file
71
src/stores/pagePreview.test.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { useGearDataStore, useSchedulesDataStore } from './data.mjs';
|
||||
import { usePagePreviewStore } from './pagePreview.mjs';
|
||||
import { useGearStore } from './gear.mjs';
|
||||
import {
|
||||
useRegularSchedulesStore,
|
||||
useAnarchySeriesSchedulesStore,
|
||||
useAnarchyOpenSchedulesStore,
|
||||
useXSchedulesStore,
|
||||
useEventSchedulesStore,
|
||||
useSalmonRunSchedulesStore,
|
||||
useEggstraWorkSchedulesStore,
|
||||
} from './schedules.mjs';
|
||||
import { useUSSplatfestsStore } from './splatfests.mjs';
|
||||
|
||||
describe('page state preview', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it('provides valid empty results for every data page before requests complete', () => {
|
||||
usePagePreviewStore().mode = 'empty';
|
||||
|
||||
expect(useGearDataStore().data).not.toBeNull();
|
||||
expect(useSchedulesDataStore().data).not.toBeNull();
|
||||
expect(useGearStore().dailyDropBrand).toBeNull();
|
||||
expect(useGearStore().regularGear).toEqual([]);
|
||||
expect(useUSSplatfestsStore().festivals).toEqual([]);
|
||||
|
||||
const scheduleStores = [
|
||||
useRegularSchedulesStore,
|
||||
useAnarchySeriesSchedulesStore,
|
||||
useAnarchyOpenSchedulesStore,
|
||||
useXSchedulesStore,
|
||||
useEventSchedulesStore,
|
||||
useSalmonRunSchedulesStore,
|
||||
useEggstraWorkSchedulesStore,
|
||||
];
|
||||
|
||||
for (const useStore of scheduleStores) {
|
||||
expect(useStore().currentSchedules).toEqual([]);
|
||||
expect(useStore().activeSchedule).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the empty preview through data updates and restores the latest live data', () => {
|
||||
const preview = usePagePreviewStore();
|
||||
const gear = useGearDataStore();
|
||||
gear.setData({ data: { version: 1 } });
|
||||
|
||||
preview.mode = 'empty';
|
||||
gear.setData({ data: { version: 2 } });
|
||||
expect(gear.data.gesotown.limitedGears).toEqual([]);
|
||||
|
||||
preview.mode = 'live';
|
||||
expect(gear.data).toEqual({ version: 2 });
|
||||
});
|
||||
|
||||
it('restores the pending state when leaving an empty preview without live data', () => {
|
||||
const preview = usePagePreviewStore();
|
||||
const gear = useGearDataStore();
|
||||
|
||||
preview.mode = 'empty';
|
||||
expect(gear.data).not.toBeNull();
|
||||
|
||||
preview.mode = 'live';
|
||||
expect(gear.data).toBeNull();
|
||||
expect(gear.error).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<MainLayout :title="$t('events.title')">
|
||||
<div class="grow flex items-center justify-center">
|
||||
<PageDataState v-slot="{ loading }" :sources="sources">
|
||||
<div class="mx-4 md:mx-12 max-w-(--breakpoint-2xl) w-full my-6">
|
||||
<div v-if="store.currentSchedules?.length" class="flex flex-col lg:flex-row items-center justify-center gap-10">
|
||||
<div v-for="(event, i) in store.currentSchedules" :key="i" class="max-w-xl">
|
||||
<div v-if="loading || store.currentSchedules?.length" class="flex flex-col lg:flex-row items-center justify-center gap-10">
|
||||
<div v-for="(event, i) in loading ? [null, null] : store.currentSchedules" :key="i" class="w-full max-w-xl">
|
||||
<ChallengeScheduleBox
|
||||
:event="event"
|
||||
:loading="loading"
|
||||
type="challenge"
|
||||
class="w-full"
|
||||
:class="(i % 2) ? 'md:rotate-1' : 'md:-rotate-1'"
|
||||
@@ -17,16 +18,18 @@
|
||||
<ProductContainer v-else :bg="type.bg" class="w-full mx-auto max-w-lg pt-10 pb-4 -rotate-1">
|
||||
<div class="flex justify-center">
|
||||
<div class="inline-flex items-center font-splatoon2 text-xl text-shadow m-2">
|
||||
<img :src="type.img" />
|
||||
<img :src="type.img" width="66" height="65" />
|
||||
{{ $t('events.not_available') }}
|
||||
</div>
|
||||
</div>
|
||||
</ProductContainer>
|
||||
</div>
|
||||
</div>
|
||||
</PageDataState>
|
||||
</MainLayout>
|
||||
</template>
|
||||
<script setup>
|
||||
import PageDataState from '@/components/loading/PageDataState.vue';
|
||||
import { useSchedulesDataStore } from '@/stores/data.mjs';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import ProductContainer from '@/components/ProductContainer.vue';
|
||||
@@ -40,4 +43,6 @@ const store = useEventSchedulesStore();
|
||||
const { types } = useScheduleTypes();
|
||||
|
||||
const type = computed(() => types['challenge']);
|
||||
|
||||
const sources = [useSchedulesDataStore()];
|
||||
</script>
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
<template>
|
||||
<MainLayout :title="$t('gear.title')">
|
||||
<div class="grow flex items-center justify-center">
|
||||
<PageDataState v-slot="{ loading }" :sources="sources">
|
||||
<div class="mx-4 md:mx-12 w-full">
|
||||
<div class="flex items-center justify-center flex-col lg:flex-row lg:items-start space-y-10 lg:space-y-0 lg:space-x-16">
|
||||
<div class="flex-none">
|
||||
<DailyDropGear class="md:min-w-[24rem] max-w-sm xl:max-w-md md:-rotate-1" />
|
||||
<div class="w-full max-w-sm xl:max-w-md shrink-0">
|
||||
<DailyDropGear :loading="loading" class="w-full md:-rotate-1" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<RegularGear class="md:rotate-1" />
|
||||
<div class="w-full max-w-3xl lg:flex-1 min-w-0">
|
||||
<RegularGear :loading="loading" class="md:rotate-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageDataState>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import PageDataState from '@/components/loading/PageDataState.vue';
|
||||
import { useGearDataStore } from '@/stores/data.mjs';
|
||||
import DailyDropGear from '@/components/gear/DailyDropGear.vue';
|
||||
import RegularGear from '@/components/gear/RegularGear.vue';
|
||||
import MainLayout from '@/layouts/MainLayout.vue';
|
||||
|
||||
const sources = [useGearDataStore()];
|
||||
</script>
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
<template>
|
||||
<MainLayout :title="$t('schedule.title')">
|
||||
<div class="grow flex items-center justify-center">
|
||||
<PageDataState v-slot="{ loading }" :sources="sources">
|
||||
<div class="mx-4 md:mx-12 w-full space-y-10">
|
||||
<template v-if="activeFestivals.length > 0">
|
||||
<template v-if="!loading && activeFestivals.length > 0">
|
||||
<template v-if="activeFestivals.length > 1">
|
||||
<div class="flex flex-wrap justify-center items-center gap-6">
|
||||
<SplatfestBox v-for="festival in activeFestivals" :key="festival.id" :festival="festival" class="md:max-w-md 2xl:max-w-lg md:-rotate-1" />
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center items-center gap-6">
|
||||
<ScheduleBox type="splatfestOpen" class="md:max-w-md 2xl:max-w-lg md:-rotate-1" />
|
||||
<ScheduleBox type="splatfestPro" class="md:max-w-md 2xl:max-w-lg md:-rotate-1" />
|
||||
<ScheduleBox :loading="loading" type="splatfestOpen" class="md:max-w-md 2xl:max-w-lg md:-rotate-1" />
|
||||
<ScheduleBox :loading="loading" type="splatfestPro" class="md:max-w-md 2xl:max-w-lg md:-rotate-1" />
|
||||
<TricolorBox v-if="isTricolorActive" class="md:max-w-md 2xl:max-w-lg md:rotate-1" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-wrap justify-center items-center gap-6">
|
||||
<SplatfestBox :festival="activeFestivals[0]" class="md:max-w-md 2xl:max-w-lg md:rotate-1" />
|
||||
<ScheduleBox type="splatfestOpen" class="md:max-w-md 2xl:max-w-lg md:-rotate-1" />
|
||||
<ScheduleBox type="splatfestPro" class="md:max-w-md 2xl:max-w-lg md:rotate-1" />
|
||||
<ScheduleBox :loading="loading" type="splatfestOpen" class="md:max-w-md 2xl:max-w-lg md:-rotate-1" />
|
||||
<ScheduleBox :loading="loading" type="splatfestPro" class="md:max-w-md 2xl:max-w-lg md:rotate-1" />
|
||||
<TricolorBox v-if="isTricolorActive" class="md:max-w-md 2xl:max-w-lg md:-rotate-1" />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-wrap justify-center items-center gap-6">
|
||||
<ScheduleBox type="regular" class="md:max-w-md 2xl:max-w-lg md:-rotate-1" />
|
||||
<ScheduleBox type="anarchySeries" class="md:max-w-md 2xl:max-w-lg md:rotate-1" />
|
||||
<ScheduleBox type="anarchyOpen" class="md:max-w-md 2xl:max-w-lg md:-rotate-1" />
|
||||
<ScheduleBox type="xMatch" class="md:max-w-md 2xl:max-w-lg md:rotate-1" />
|
||||
<ScheduleBox :loading="loading" type="regular" class="md:max-w-md 2xl:max-w-lg md:-rotate-1" />
|
||||
<ScheduleBox :loading="loading" type="anarchySeries" class="md:max-w-md 2xl:max-w-lg md:rotate-1" />
|
||||
<ScheduleBox :loading="loading" type="anarchyOpen" class="md:max-w-md 2xl:max-w-lg md:-rotate-1" />
|
||||
<ScheduleBox :loading="loading" type="xMatch" class="md:max-w-md 2xl:max-w-lg md:rotate-1" />
|
||||
|
||||
<SplatfestBox
|
||||
v-for="festival in upcomingFestivals"
|
||||
v-for="festival in loading ? [] : upcomingFestivals"
|
||||
:key="festival.id"
|
||||
:festival="festival"
|
||||
class="md:max-w-md 2xl:max-w-lg md:-rotate-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-for="festival in recentFestivals" :key="festival.id" class="flex flex-wrap items-center justify-center gap-y-6 md:gap-x-6">
|
||||
<div v-for="festival in loading ? [] : recentFestivals" :key="festival.id" class="flex flex-wrap items-center justify-center gap-y-6 md:gap-x-6">
|
||||
<SplatfestBox
|
||||
:festival="festival"
|
||||
class="max-w-md md:-rotate-1"
|
||||
@@ -50,11 +50,13 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageDataState>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import PageDataState from '@/components/loading/PageDataState.vue';
|
||||
import { useSchedulesDataStore, useFestivalsDataStore } from '@/stores/data.mjs';
|
||||
import { computed } from 'vue';
|
||||
import { uniqBy } from 'lodash';
|
||||
import ScheduleBox from '@/components/ScheduleBox.vue';
|
||||
@@ -73,4 +75,6 @@ const upcomingFestivals = computed(() => uniqBy([usSplatfests.upcomingFestival,
|
||||
const recentFestivals = computed(() => uniqBy([usSplatfests.recentFestival, euSplatfests.recentFestival, jpSplatfests.recentFestival, apSplatfests.recentFestival].filter(festival => festival), '__splatoon3ink_id'));
|
||||
const activeFestivals = computed(() => uniqBy([usSplatfests.activeFestival, euSplatfests.activeFestival, jpSplatfests.activeFestival, apSplatfests.activeFestival].filter(festival => festival), '__splatoon3ink_id'));
|
||||
const isTricolorActive = computed(() => usSplatfests.tricolor?.isTricolorActive || euSplatfests.tricolor?.isTricolorActive || jpSplatfests.tricolor?.isTricolorActive || apSplatfests.tricolor?.isTricolorActive);
|
||||
|
||||
const sources = [useSchedulesDataStore(), useFestivalsDataStore()];
|
||||
</script>
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
<template>
|
||||
<MainLayout :title="$t('salmonrun.title')">
|
||||
<div class="grow flex items-center justify-center">
|
||||
<PageDataState v-slot="{ loading }" :sources="sources">
|
||||
<div class="mx-4 md:mx-12 w-full">
|
||||
<div class="flex items-center justify-center flex-col lg:flex-row lg:items-start space-y-10 lg:space-y-0 lg:space-x-16">
|
||||
<div class="w-full max-w-2xl">
|
||||
<SalmonRunBox class="md:-rotate-1" />
|
||||
<SalmonRunBox :loading="loading" class="md:-rotate-1" />
|
||||
</div>
|
||||
<div v-if="eggstraWork.currentSchedules.length" class="w-full max-w-2xl">
|
||||
<div v-if="!loading && eggstraWork.currentSchedules.length" class="w-full max-w-2xl">
|
||||
<SalmonRunBox class="md:rotate-1" eggstra />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageDataState>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import PageDataState from '@/components/loading/PageDataState.vue';
|
||||
import { useSchedulesDataStore } from '@/stores/data.mjs';
|
||||
import SalmonRunBox from '@/components/salmonrun/SalmonRunBox.vue';
|
||||
import { useEggstraWorkSchedulesStore } from '@/stores/schedules.mjs';
|
||||
import MainLayout from '@/layouts/MainLayout.vue';
|
||||
const eggstraWork = useEggstraWorkSchedulesStore();
|
||||
|
||||
const sources = [useSchedulesDataStore()];
|
||||
</script>
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
<template>
|
||||
<MainLayout :title="$t('festival.title')">
|
||||
<div class="grow flex items-center justify-center">
|
||||
<PageDataState v-slot="{ loading }" :sources="sources">
|
||||
<div class="mx-4 md:mx-12 w-full space-y-10">
|
||||
<div v-for="festival in festivalsWithResults" :key="festival.id" class="flex flex-wrap items-center justify-center gap-y-6 md:gap-x-6">
|
||||
<p v-if="!loading && !festivalsWithResults.length" class="py-24 text-center font-splatoon2">
|
||||
{{ $t('times.checkback') }}
|
||||
</p>
|
||||
<div v-for="(festival, i) in loading ? [null, null] : festivalsWithResults" :key="festival?.id ?? i" class="flex flex-wrap items-center justify-center gap-y-6 md:gap-x-6">
|
||||
<SplatfestBox
|
||||
:festival="festival"
|
||||
class="max-w-md md:-rotate-1"
|
||||
:loading="loading"
|
||||
class="w-full max-w-md md:-rotate-1"
|
||||
history-mode
|
||||
/>
|
||||
|
||||
<SplatfestResultsBox
|
||||
:festival="festival"
|
||||
:loading="loading"
|
||||
class="w-full sm:max-w-md md:rotate-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageDataState>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import PageDataState from '@/components/loading/PageDataState.vue';
|
||||
import { useFestivalsDataStore } from '@/stores/data.mjs';
|
||||
import { computed } from 'vue';
|
||||
import { sortBy, uniqBy } from 'lodash';
|
||||
import SplatfestBox from '@/components/SplatfestBox.vue';
|
||||
@@ -33,4 +40,5 @@ const jpSplatfests = useJPSplatfestsStore();
|
||||
const apSplatfests = useAPSplatfestsStore();
|
||||
const festivalsWithResults = computed(() => sortBy(uniqBy([...usSplatfests.festivals, ...euSplatfests.festivals, ...jpSplatfests.festivals, ...apSplatfests.festivals].filter(festival => festival?.hasResults), '__splatoon3ink_id'), 'startTime').reverse());
|
||||
|
||||
const sources = [useFestivalsDataStore()];
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { fileURLToPath, URL } from 'url';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
|
||||
Reference in New Issue
Block a user