mirror of
https://github.com/misenhower/splatoon3.ink.git
synced 2026-03-21 17:54:13 -05:00
commit
0793cde63e
|
|
@ -82,3 +82,15 @@ export function getXRankSeasonId(id) {
|
|||
? `${parts[1]}-${parts[2]}`
|
||||
: id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cache expiry timestamp with 5-minute buffer.
|
||||
* @param {number} expiresIn - Seconds until expiry
|
||||
* @returns {number} Timestamp to expire the cache (5 minutes early)
|
||||
*/
|
||||
export function calculateCacheExpiry(expiresIn) {
|
||||
let expires = Date.now() + expiresIn * 1000;
|
||||
|
||||
// Expire 5min early to make sure we have time to execute requests
|
||||
return expires - 5 * 60 * 1000;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,7 +102,10 @@ export class LocalizationProcessor {
|
|||
|
||||
return JSON.parse(result) || {};
|
||||
} catch (e) {
|
||||
//
|
||||
// File doesn't exist yet or is invalid - return empty object
|
||||
if (e.code !== 'ENOENT') {
|
||||
console.warn(`Failed to read localization file ${this.filename}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default class CoopUpdater extends DataUpdater
|
|||
},
|
||||
];
|
||||
|
||||
getData(locale) {
|
||||
async getData(locale) {
|
||||
return this.splatnet(locale).getCoopHistoryData();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export default class GearUpdater extends DataUpdater
|
|||
},
|
||||
];
|
||||
|
||||
getData(locale) {
|
||||
async getData(locale) {
|
||||
return this.splatnet(locale).getGesotownData();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,40 +31,50 @@ export default class BlueskyClient extends Client
|
|||
}
|
||||
|
||||
async send(status, generator) {
|
||||
await this.login();
|
||||
if (!status.media?.length) {
|
||||
console.error(`[${this.name}] No media provided for ${generator.key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload images
|
||||
let images = await Promise.all(
|
||||
status.media.map(async m => {
|
||||
// We have to convert the PNG to a JPG for Bluesky because of size limits
|
||||
let jpeg = sharp(m.file).jpeg();
|
||||
let metadata = await jpeg.metadata();
|
||||
let buffer = await jpeg.toBuffer();
|
||||
try {
|
||||
await this.login();
|
||||
|
||||
let response = await this.#agent.uploadBlob(buffer, { encoding: 'image/jpeg' });
|
||||
// Upload images
|
||||
let images = await Promise.all(
|
||||
status.media.map(async m => {
|
||||
// We have to convert the PNG to a JPG for Bluesky because of size limits
|
||||
let jpeg = sharp(m.file).jpeg();
|
||||
let metadata = await jpeg.metadata();
|
||||
let buffer = await jpeg.toBuffer();
|
||||
|
||||
return {
|
||||
image: response.data.blob,
|
||||
alt: m.altText || '',
|
||||
aspectRatio: { width: metadata.width, height: metadata.height },
|
||||
};
|
||||
}),
|
||||
);
|
||||
let response = await this.#agent.uploadBlob(buffer, { encoding: 'image/jpeg' });
|
||||
|
||||
// Send status
|
||||
const rt = new RichText({
|
||||
text: status.status,
|
||||
});
|
||||
return {
|
||||
image: response.data.blob,
|
||||
alt: m.altText || '',
|
||||
aspectRatio: { width: metadata.width, height: metadata.height },
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
await rt.detectFacets(this.#agent);
|
||||
// Send status
|
||||
const rt = new RichText({
|
||||
text: status.status,
|
||||
});
|
||||
|
||||
await this.#agent.post({
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
embed: {
|
||||
images,
|
||||
$type: 'app.bsky.embed.images',
|
||||
},
|
||||
});
|
||||
await rt.detectFacets(this.#agent);
|
||||
|
||||
await this.#agent.post({
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
embed: {
|
||||
images,
|
||||
$type: 'app.bsky.embed.images',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[${this.name}] Failed to post ${generator.key}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,18 +11,20 @@ export default class FileWriter extends Client {
|
|||
async send(status, generator) {
|
||||
await mkdirp(this.dir);
|
||||
|
||||
let imgFilename = `${this.dir}/${generator.key}.png`;
|
||||
await fs.writeFile(imgFilename, status.media[0].file);
|
||||
if (status.media?.length > 0) {
|
||||
let imgFilename = `${this.dir}/${generator.key}.png`;
|
||||
await fs.writeFile(imgFilename, status.media[0].file);
|
||||
|
||||
let text = [
|
||||
'Status:',
|
||||
status.status,
|
||||
'',
|
||||
'Alt text:',
|
||||
status.media[0].altText,
|
||||
].join('\n');
|
||||
let text = [
|
||||
'Status:',
|
||||
status.status,
|
||||
'',
|
||||
'Alt text:',
|
||||
status.media[0].altText,
|
||||
].join('\n');
|
||||
|
||||
let textFilename = `${this.dir}/${generator.key}.txt`;
|
||||
await fs.writeFile(textFilename, text);
|
||||
let textFilename = `${this.dir}/${generator.key}.txt`;
|
||||
await fs.writeFile(textFilename, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ export default class ImageWriter extends Client {
|
|||
dir = 'dist/status-screenshots'; // `/screenshots` points to the page used by puppeteer
|
||||
|
||||
async send(status, generator) {
|
||||
if (!status.media?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mkdirp(this.dir);
|
||||
|
||||
let imgFilename = `${this.dir}/${generator.key}.png`;
|
||||
|
|
|
|||
|
|
@ -23,35 +23,45 @@ export default class MastodonClient extends Client
|
|||
}
|
||||
|
||||
async send(status, generator) {
|
||||
// Mastodon API
|
||||
const masto = await createRestAPIClient({
|
||||
url: this.#url,
|
||||
accessToken: this.#accessToken,
|
||||
disableVersionCheck: true,
|
||||
disableExperimentalWarning: true,
|
||||
});
|
||||
if (!status.media?.length) {
|
||||
console.error(`[${this.name}] No media provided for ${generator.key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload images
|
||||
let mediaIds = await Promise.all(
|
||||
status.media.map(async m => {
|
||||
let request = { file: new Blob([m.file]) };
|
||||
if (m.altText) {
|
||||
request.description = m.altText;
|
||||
}
|
||||
try {
|
||||
// Mastodon API
|
||||
const masto = await createRestAPIClient({
|
||||
url: this.#url,
|
||||
accessToken: this.#accessToken,
|
||||
disableVersionCheck: true,
|
||||
disableExperimentalWarning: true,
|
||||
});
|
||||
|
||||
let attachment = await masto.v2.media.create(request);
|
||||
// Upload images
|
||||
let mediaIds = await Promise.all(
|
||||
status.media.map(async m => {
|
||||
let request = { file: new Blob([m.file]) };
|
||||
if (m.altText) {
|
||||
request.description = m.altText;
|
||||
}
|
||||
|
||||
return attachment.id;
|
||||
}),
|
||||
);
|
||||
let attachment = await masto.v2.media.create(request);
|
||||
|
||||
// Send status
|
||||
await masto.v1.statuses.create({
|
||||
status: status.status,
|
||||
spoilerText: status.contentWrapper,
|
||||
sensitive: !!status.contentWrapper, // Without the sensitive property the image is still visible
|
||||
mediaIds,
|
||||
visibility: this.#visibility,
|
||||
});
|
||||
return attachment.id;
|
||||
}),
|
||||
);
|
||||
|
||||
// Send status
|
||||
await masto.v1.statuses.create({
|
||||
status: status.status,
|
||||
spoilerText: status.contentWrapper,
|
||||
sensitive: !!status.contentWrapper, // Without the sensitive property the image is still visible
|
||||
mediaIds,
|
||||
visibility: this.#visibility,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[${this.name}] Failed to post ${generator.key}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,11 +29,21 @@ export default class ThreadsClient extends Client {
|
|||
}
|
||||
|
||||
async send(status, generator) {
|
||||
let jpeg = await sharp(status.media[0].file).jpeg().toBuffer();
|
||||
if (!status.media?.length) {
|
||||
console.error(`[${this.name}] No media provided for ${generator.key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#api.publish({
|
||||
text: status.status,
|
||||
image: { type: 'image/jpeg', data: jpeg },
|
||||
});
|
||||
try {
|
||||
let jpeg = await sharp(status.media[0].file).jpeg().toBuffer();
|
||||
|
||||
await this.#api.publish({
|
||||
text: status.status,
|
||||
image: { type: 'image/jpeg', data: jpeg },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[${this.name}] Failed to post ${generator.key}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,20 +30,30 @@ export default class TwitterClient extends Client
|
|||
}
|
||||
|
||||
async send(status, generator) {
|
||||
// Upload images
|
||||
let mediaIds = await Promise.all(
|
||||
status.media.map(async m => {
|
||||
let id = await this.api().v1.uploadMedia(Buffer.from(m.file), { mimeType: m.type });
|
||||
if (!status.media?.length) {
|
||||
console.error(`[${this.name}] No media provided for ${generator.key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (m.altText) {
|
||||
await this.api().v1.createMediaMetadata(id, { alt_text: { text: m.altText } });
|
||||
}
|
||||
try {
|
||||
// Upload images
|
||||
let mediaIds = await Promise.all(
|
||||
status.media.map(async m => {
|
||||
let id = await this.api().v1.uploadMedia(Buffer.from(m.file), { mimeType: m.type });
|
||||
|
||||
return id;
|
||||
}),
|
||||
);
|
||||
if (m.altText) {
|
||||
await this.api().v1.createMediaMetadata(id, { alt_text: { text: m.altText } });
|
||||
}
|
||||
|
||||
// Send status
|
||||
await this.api().v2.tweet(status.status, { media: { media_ids: mediaIds } });
|
||||
return id;
|
||||
}),
|
||||
);
|
||||
|
||||
// Send status
|
||||
await this.api().v2.tweet(status.status, { media: { media_ids: mediaIds } });
|
||||
} catch (error) {
|
||||
console.error(`[${this.name}] Failed to post ${generator.key}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { addUserAgent } from 'nxapi';
|
|||
import pLimit from 'p-limit';
|
||||
import ValueCache from '../common/ValueCache.mjs';
|
||||
import prefixedConsole from '../common/prefixedConsole.mjs';
|
||||
import { calculateCacheExpiry } from '../common/util.mjs';
|
||||
|
||||
const coralLimit = pLimit(1);
|
||||
const webServiceLimit = pLimit(1);
|
||||
|
|
@ -62,13 +63,6 @@ export default class NsoClient
|
|||
return `nso.${token}`;
|
||||
}
|
||||
|
||||
_calculateCacheExpiry(expiresIn) {
|
||||
let expires = Date.now() + expiresIn * 1000;
|
||||
|
||||
// Expire 5min early to make sure we have time to execute requests
|
||||
return expires - 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
// Coral API
|
||||
|
||||
_getCoralCache() {
|
||||
|
|
@ -93,7 +87,7 @@ export default class NsoClient
|
|||
this.console.info('Creating Coral session...');
|
||||
let { data } = await CoralApi.createWithSessionToken(this.nintendoToken);
|
||||
|
||||
let expires = this._calculateCacheExpiry(data.credential.expiresIn);
|
||||
let expires = calculateCacheExpiry(data.credential.expiresIn);
|
||||
this.console.debug(`Caching Coral session until: ${expires}`);
|
||||
await this._getCoralCache().setData(data, expires);
|
||||
|
||||
|
|
@ -127,7 +121,7 @@ export default class NsoClient
|
|||
this.console.info(`Creating web service token for ID ${id}...`);
|
||||
let { result } = await coral.getWebServiceToken(id);
|
||||
|
||||
let expires = this._calculateCacheExpiry(result.expiresIn);
|
||||
let expires = calculateCacheExpiry(result.expiresIn);
|
||||
this.console.debug(`Caching web service token for ID ${id} until: ${expires}`);
|
||||
await tokenCache.setData(result, expires);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import fs from 'fs/promises';
|
|||
import pLimit from 'p-limit';
|
||||
import ValueCache from '../common/ValueCache.mjs';
|
||||
import prefixedConsole from '../common/prefixedConsole.mjs';
|
||||
import { calculateCacheExpiry } from '../common/util.mjs';
|
||||
|
||||
export const SPLATNET3_WEB_SERVICE_ID = '4834290508791808';
|
||||
|
||||
|
|
@ -24,13 +25,6 @@ export default class SplatNet3Client
|
|||
return !!this.bulletToken;
|
||||
}
|
||||
|
||||
_calculateCacheExpiry(expiresIn) {
|
||||
let expires = Date.now() + expiresIn * 1000;
|
||||
|
||||
// Expire 5min early to make sure we have time to execute requests
|
||||
return expires - 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
// Query hashes
|
||||
async _loadQueryHashes() {
|
||||
if (!this.queryHashes) {
|
||||
|
|
@ -100,7 +94,7 @@ export default class SplatNet3Client
|
|||
let bulletToken = await response.json();
|
||||
|
||||
// We can assume the token expires after 7200 seconds
|
||||
let expiry = this._calculateCacheExpiry(7200);
|
||||
let expiry = calculateCacheExpiry(7200);
|
||||
await bulletTokenCache.setData(bulletToken, expiry);
|
||||
|
||||
this.console.debug(`Caching bullet token until: ${expiry}`);
|
||||
|
|
|
|||
10
src/App.vue
10
src/App.vue
|
|
@ -16,13 +16,9 @@ const data = useDataStore();
|
|||
onMounted(() => data.startUpdating());
|
||||
onUnmounted(() => data.stopUpdating());
|
||||
|
||||
try {
|
||||
// Detect mobile browsers
|
||||
if (navigator.userAgent.match(/iPhone|iPad|Android/i)) {
|
||||
document.body.classList.add('is-mobile');
|
||||
}
|
||||
} catch (e) {
|
||||
//
|
||||
// Detect mobile browsers
|
||||
if (navigator.userAgent.match(/iPhone|iPad|Android/i)) {
|
||||
document.body.classList.add('is-mobile');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ const datetimeFormats = {
|
|||
};
|
||||
|
||||
let i18n = null;
|
||||
let storageListenerAdded = false;
|
||||
|
||||
export function initializeI18n() {
|
||||
if (!i18n) {
|
||||
|
|
@ -69,8 +70,11 @@ export function initializeI18n() {
|
|||
},
|
||||
});
|
||||
|
||||
// Listen for local storage changes
|
||||
window.addEventListener('storage', reload);
|
||||
// Listen for local storage changes (guard prevents duplicate listeners in HMR)
|
||||
if (!storageListenerAdded) {
|
||||
window.addEventListener('storage', reload);
|
||||
storageListenerAdded = true;
|
||||
}
|
||||
reload();
|
||||
}
|
||||
|
||||
|
|
@ -122,7 +126,7 @@ async function loadLocale() {
|
|||
let response = await fetch(`/data/locale/${locale}.json`);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(response);
|
||||
console.error(`Failed to load locale ${locale}: ${response.status} ${response.statusText}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<ProductContainer class="pt-10 pb-4" bg="bg-camo-purple" :bg-style="`background-color: ${toRgba(winner.color)};`">
|
||||
<ProductContainer v-if="winner" class="pt-10 pb-4" bg="bg-camo-purple" :bg-style="`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') }}
|
||||
|
|
@ -89,7 +89,7 @@ const resultRows = computed(() => {
|
|||
});
|
||||
|
||||
const winnerIndex = computed(() => props.festival.teams.findIndex(t => t.result.isWinner));
|
||||
const winner = computed(() => props.festival.teams[winnerIndex.value]);
|
||||
const winner = computed(() => winnerIndex.value >= 0 ? props.festival.teams[winnerIndex.value] : null);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
</div>
|
||||
|
||||
<div v-if="tricolor?.tricolorStages">
|
||||
<div v-if="tricolor?.tricolorStages.length == 1" class="flex space-x-1">
|
||||
<div v-if="tricolor?.tricolorStages.length === 1" class="flex space-x-1">
|
||||
<StageImage
|
||||
img-class="rounded-xl"
|
||||
:stage="tricolor?.tricolorStages?.[0]"
|
||||
|
|
|
|||
|
|
@ -54,23 +54,8 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Time left/Order button -->
|
||||
<div class="absolute top-1 left-6 space-y-2">
|
||||
<div v-if="false" class="hidden mobile:block -ml-4">
|
||||
<a :href="shopUrl">
|
||||
<SquidTape
|
||||
class="font-splatoon2 text-sm text-black rounded-sm -rotate-3"
|
||||
bg="bg-splatoon-yellow"
|
||||
squid-bg="bg-black"
|
||||
border="border border-black"
|
||||
>
|
||||
<div class="px-1">
|
||||
{{ $t('gear.order') }}
|
||||
</div>
|
||||
</SquidTape>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Time left -->
|
||||
<div class="absolute top-1 left-6">
|
||||
<div class="inline-block text-xs bg-zinc-200 bg-opacity-30 rounded px-1 py-px font-semibold">
|
||||
{{ $t('time.left', { time: formatDurationHoursFromNow(props.gear.saleEndTime) }) }}
|
||||
</div>
|
||||
|
|
@ -80,9 +65,7 @@
|
|||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import SquidTape from '@/components/SquidTape.vue';
|
||||
import { formatDurationHoursFromNow } from '@/common/time';
|
||||
import { getGesotownGearUrl } from '@/common/links';
|
||||
|
||||
const props = defineProps({
|
||||
gear: Object,
|
||||
|
|
@ -90,8 +73,6 @@ const props = defineProps({
|
|||
|
||||
const price = computed(() => props.gear.price);
|
||||
const gear = computed(() => props.gear.gear);
|
||||
|
||||
const shopUrl = computed(() => getGesotownGearUrl(props.gear.id));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ function defineEndpointStore(id, endpoint, transform = null) {
|
|||
let response = await fetch(baseUrl + endpoint);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(response);
|
||||
console.error(`Failed to fetch ${endpoint}: ${response.status} ${response.statusText}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,4 +36,5 @@ export const useCoopGearStore = defineStore('coopGear', () => {
|
|||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useGearStore, import.meta.hot));
|
||||
import.meta.hot.accept(acceptHMRUpdate(useCoopGearStore, import.meta.hot));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user