Merge pull request #129 from misenhower/codex/stable-image-loading

Stabilize image loading and add inline page loading states
This commit is contained in:
Matt Isenhower
2026-09-27 16:38:25 -07:00
committed by GitHub
53 changed files with 922 additions and 291 deletions

View File

@@ -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.">

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -1,4 +1,9 @@
{
"loading": {
"label": "Caricamento…",
"error": "Impossibile caricare i dati di questa pagina. Riprova.",
"retry": "Riprova"
},
"times": {
"now": "In corso!",
"next": "Prossimo",

View File

@@ -1,4 +1,9 @@
{
"loading": {
"label": "読み込み中…",
"error": "このページのデータを読み込めませんでした。もう一度お試しください。",
"retry": "再試行"
},
"times": {
"now": "オープン!",
"next": "つぎ",

View File

@@ -1,4 +1,9 @@
{
"loading": {
"label": "불러오는 중…",
"error": "이 페이지의 데이터를 불러오지 못했습니다. 다시 시도해 주세요.",
"retry": "다시 시도"
},
"times": {
"now": "오픈!",
"next": "다음",

View File

@@ -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",

View File

@@ -1,4 +1,9 @@
{
"loading": {
"label": "Загрузка…",
"error": "Не удалось загрузить данные этой страницы. Попробуйте ещё раз.",
"retry": "Повторить"
},
"times": {
"now": "Сейчас",
"next": "Следующий",

View File

@@ -1,4 +1,9 @@
{
"loading": {
"label": "正在加载…",
"error": "无法加载此页面的数据。请重试。",
"retry": "重试"
},
"times": {
"now": "开放!",
"next": "下次",

View File

@@ -1,4 +1,9 @@
{
"loading": {
"label": "載入中…",
"error": "無法載入此頁面的資料。請再試一次。",
"retry": "重試"
},
"times": {
"now": "開放!",
"next": "下次",

View 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>

View File

@@ -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">

View File

@@ -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">
&nbsp;
</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') }}
&ndash;
{{ $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">&#57445;</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,

View File

@@ -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>

View File

@@ -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">
&nbsp;
</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">
&nbsp;
</div>
<div class="text-sm mx-8 bg-zinc-500 rounded-sm animate-pulse">
&nbsp;
</div>
<div class="text-sm mx-4 bg-zinc-500 rounded-sm animate-pulse">
&nbsp;
</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();

View File

@@ -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') }}
&ndash;
{{ $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') }}
&ndash;
{{ $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) {

View File

@@ -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">

View File

@@ -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>

View File

@@ -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">

View File

@@ -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">
&nbsp;
</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: {

View File

@@ -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>

View File

@@ -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">
&nbsp;
</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();

View File

@@ -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') }}
&ndash;
{{ $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') }}
&ndash;
{{ $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();

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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>

View 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>

View 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>

View 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);
});
});

View File

@@ -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') }}
&ndash;
{{ $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,
});

View File

@@ -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)"

View File

@@ -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,

View File

@@ -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') }}
&ndash;
{{ $d(schedule.endTime, 'dateTimeShort') }}
<SkeletonBlock v-if="loading" class="h-6 w-full" />
<template v-else>
{{ $d(schedule.startTime, 'dateTimeShortWeekday') }}
&ndash;
{{ $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>

View File

@@ -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',

View File

@@ -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>

View File

@@ -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 };
});
}

View File

@@ -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);
});
});

View File

@@ -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 };

View File

@@ -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, {

View 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];
}

View 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();
});
});

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)),