feat: 404 page

This commit is contained in:
TrèsAbhi committed 2025-05-26 20:07:33 -05:00
1 parent 5adf90bab2
commit bc6bb4f10e
28 files changed
+1505 -97

No files matched your search

+9 -7
View File
@@ -1,5 +1,5 @@
import locales from '@blitzkit/i18n/locales.json' with { type: 'json' };
import { deburr } from 'lodash-es';
import { SUPPORTED_LOCALES } from '../../../i18n/src/strings';
import { I18nString } from '../protos';
import { fetchCamouflageDefinitions } from './camouflageDefinitions';
import { fetchTankDefinitions } from './tankDefinitions';
@@ -26,8 +26,8 @@ export async function fetchTankNames() {
searchableNameDeburr,
camouflages: tank.camouflages
?.map((id) =>
SUPPORTED_LOCALES.map(
(locale) =>
locales.supported.map(
({ locale }) =>
camouflageDefinitions.camouflages[id]?.name.locales[locale],
),
)
@@ -42,9 +42,11 @@ export async function fetchTankNames() {
}
export const SEARCH_KEYS = [
...SUPPORTED_LOCALES.map((locale) => [
`searchableName.locales.${locale}`,
`searchableNameDeburr.locales.${locale}`,
]).flat(),
...locales.supported
.map(({ locale }) => [
`searchableName.locales.${locale}`,
`searchableNameDeburr.locales.${locale}`,
])
.flat(),
'camouflages',
];
+13 -86
View File
@@ -1,89 +1,8 @@
import { merge } from 'ts-deepmerge';
import en from '../strings/en.json';
import es from '../strings/es.json';
import fr from '../strings/fr.json';
import ja from '../strings/ja.json';
import pl from '../strings/pl.json';
import pt from '../strings/pt.json';
import ru from '../strings/ru.json';
import uk from '../strings/uk.json';
import zh from '../strings/zh.json';
import type en from '../strings/en.json';
export type BlitzKitStrings = typeof en;
const stringsRaw = { en, es, fr, ja, pl, pt, ru, uk, zh };
export const SUPPORTED_LOCALES = Object.keys(stringsRaw) as SupportedLocale[];
export const DEFAULT_LOCALE = SUPPORTED_LOCALES[0];
function redact(string: string) {
const matches = [...string.matchAll(/%s\d+/g)];
const characters = string
.split('')
.map((char) => (char === ' ' ? ' ' : '█')) as string[];
matches.forEach((match) => {
const start = match.index;
const matchText = match[0];
for (let i = 0; i < matchText.length; i++) {
characters[start + i] = matchText[i];
}
});
return characters.join('');
}
function nuke(object: TranslationTree) {
for (const key in object) {
const typedKey = key as keyof typeof object;
if (typeof object[typedKey] === 'string') {
const value = object[typedKey];
object[typedKey] = redact(value as string);
} else nuke(object[typedKey] as TranslationTree);
}
}
const stringsPartial: Partial<Record<SupportedLocale, TranslationTree>> = {};
for (const locale in stringsRaw) {
if (locale === DEFAULT_LOCALE) {
stringsPartial[DEFAULT_LOCALE] = stringsRaw[DEFAULT_LOCALE];
continue;
}
const localeStrings = stringsRaw[locale as SupportedLocale];
stringsPartial[locale as SupportedLocale] = merge(
stringsRaw[DEFAULT_LOCALE],
localeStrings,
);
if (import.meta.env.PUBLIC_DEBUG_MISSING_I18N === 'true') {
nuke(stringsPartial[locale as SupportedLocale]!);
}
}
export const STRINGS = stringsPartial as Record<
SupportedLocale,
BlitzKitStrings
>;
export type SupportedLocale = keyof typeof stringsRaw;
export const SUPPORTED_LOCALE_BLITZ_MAP: Record<SupportedLocale, string> = {
en: 'en',
es: 'es',
fr: 'fr',
ja: 'ja',
pt: 'pt',
ru: 'ru',
uk: 'uk',
zh: 'zh-Hans',
pl: 'pl',
};
export const SUPPORTED_LOCALE_FLAGS: Record<SupportedLocale, string> = {
export const SUPPORTED_LOCALE_FLAGS: Record<string, string> = {
en: '🇺🇸',
es: '🇪🇸',
fr: '🇫🇷',
@@ -95,6 +14,14 @@ export const SUPPORTED_LOCALE_FLAGS: Record<SupportedLocale, string> = {
pl: '🇵🇱',
};
export type TranslationTree = { [key: string]: TranslationNode };
export type TranslationNode = string | TranslationTree;
export const LOCALE_NAMES: Record<string, string> = {
en: 'English',
es: 'Español',
fr: 'Français',
ja: '日本語',
pt: 'Português',
ru: 'Русский',
uk: 'Українська',
zh: '中文',
pl: 'Polski',
};
+2 -2
View File
@@ -1,5 +1,5 @@
import { I18nString } from '@blitzkit/core';
import { DEFAULT_LOCALE } from './strings';
import locales from '../locales.json' with { type: 'json' };
export function unwrapper(locale: string) {
return function (i18nString: I18nString) {
@@ -7,6 +7,6 @@ export function unwrapper(locale: string) {
return i18nString.locales[locale];
}
return i18nString.locales[DEFAULT_LOCALE];
return i18nString.locales[locales.default];
};
}
+3 -2
View File
@@ -5,5 +5,6 @@ import react from '@astrojs/react';
// https://astro.build/config
export default defineConfig({
integrations: [react()]
});
integrations: [react()],
devToolbar: { enabled: false },
});
@@ -0,0 +1,30 @@
import { Theme, type ThemeProps } from '@radix-ui/themes';
import type { ReactNode } from 'react';
interface BlitzKitThemeProps extends ThemeProps {
children: ReactNode;
}
export function BlitzKitTheme({
children,
className,
style,
...props
}: BlitzKitThemeProps) {
return (
<Theme
style={{ minHeight: 'unset', display: 'contents', ...style }}
className={`blitzkit-theme ${className}`}
grayColor="mauve"
appearance="dark"
panelBackground="solid"
radius="full"
accentColor="amber"
suppressHydrationWarning
suppressContentEditableWarning
{...props}
>
{children}
</Theme>
);
}
@@ -0,0 +1,18 @@
.policies-agreement {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--color-overlay);
z-index: 2;
}
.policies-agreement .content-wrapper {
width: 100%;
background: var(--color-panel-solid);
}
.policies-agreement .content {
max-width: 60rem;
}
@@ -0,0 +1,157 @@
import { assertSecret, idToRegion } from '@blitzkit/core';
import { Button, Flex, Heading, Text } from '@radix-ui/themes';
import { useEffect, useState } from 'react';
import {
LocaleProvider,
useLocale,
type LocaleAcceptorProps,
} from '../../hooks/useLocale';
import { App } from '../../stores/app';
import { CURRENT_POLICIES_AGREEMENT_INDEX } from '../../stores/app/constants';
import { LinkI18n } from '../LinkI18n';
import './index.css';
type Extension =
| {
status: 'ok';
data: {
access_token: string;
account_id: number;
expires_at: number;
};
}
| {
status: 'error';
};
export function Checks({ locale }: LocaleAcceptorProps) {
return (
<LocaleProvider locale={locale}>
<App.Provider>
<Content />
</App.Provider>
</LocaleProvider>
);
}
/**
* Wargaming: 14 (refresh: 7)
*/
function Content() {
const { locale } = useLocale();
const logins = App.use((state) => state.logins);
const mutateApp = App.useMutation();
const appStore = App.useStore();
const policiesAgreementIndex = App.use(
(state) => state.policiesAgreementIndex,
);
const isLegal = window.location.pathname.startsWith('/legal');
const [showPoliciesAgreement, setShowPoliciesAgreement] = useState(false);
useEffect(() => {
function unsubscribe() {
window.removeEventListener('scroll', show);
window.removeEventListener('pointermove', show);
window.removeEventListener('pointerdown', show);
}
function show() {
setShowPoliciesAgreement(true);
unsubscribe();
}
if (
policiesAgreementIndex !== CURRENT_POLICIES_AGREEMENT_INDEX &&
!isLegal
) {
window.addEventListener('scroll', show);
window.addEventListener('pointermove', show);
window.addEventListener('pointerdown', show);
}
return unsubscribe;
}, [policiesAgreementIndex, isLegal]);
useEffect(() => {
if (logins.wargaming) {
const expiresIn = logins.wargaming.expires - Date.now();
const expiresInDays = expiresIn / 1000 / 60 / 60 / 24;
if (expiresInDays < 0) {
mutateApp((draft) => {
draft.logins.wargaming = undefined;
});
} else if (expiresInDays < 7) {
const { wargaming } = appStore.getState().logins;
if (!wargaming) return;
fetch(
`https://api.worldoftanks.${idToRegion(
wargaming.id,
)}/wot/auth/prolongate/?application_id=${assertSecret(
import.meta.env.PUBLIC_WARGAMING_APPLICATION_ID,
)}&access_token=${wargaming.token}`,
)
.then((response) => response.json() as Promise<Extension>)
.then((json) => {
mutateApp((draft) => {
if (json.status === 'error') {
draft.logins.wargaming = undefined;
} else {
draft.logins.wargaming = {
id: wargaming.id,
token: json.data.access_token,
expires: json.data.expires_at * 1000,
};
}
});
});
}
}
});
if (!showPoliciesAgreement) return null;
return (
<Flex className="policies-agreement" align="end">
<Flex p="8" className="content-wrapper" justify="center">
<Flex align="start" gap="4" className="content" direction="column">
<Flex direction="column" gap="2">
<Heading>
{policiesAgreementIndex === -1
? "BlitzKit's policies"
: "BlitzKit's policies have changed"}
</Heading>
<Text>
This website utilizes cookies to perform analytics and personalize
your experience. You can learn more through{' '}
<LinkI18n locale={locale} href="/docs/legal/privacy-policy">
our privacy policy
</LinkI18n>
. By using BlitzKit, you also agree to our{' '}
<LinkI18n locale={locale} href="/docs/legal/terms-of-service">
terms of service
</LinkI18n>
.
</Text>
</Flex>
<Button
mt="2"
onClick={() => {
appStore.setState({
policiesAgreementIndex: CURRENT_POLICIES_AGREEMENT_INDEX,
});
setShowPoliciesAgreement(false);
}}
>
I agree
</Button>
</Flex>
</Flex>
</Flex>
);
}
@@ -0,0 +1,184 @@
---
import { assertSecret, fetchGameDefinitions } from '@blitzkit/core';
import locales from '@blitzkit/i18n/locales.json' with { type: 'json' };
import { Box, Code, Flex, Heading, Link, Text } from '@radix-ui/themes';
import packageJson from '../../../../package.json';
import { getStrings } from '../core/i18n/getStrings';
import { LinkI18n } from './LinkI18n';
import { LocaleSwitcherThemeWrapper } from './LocaleSwitcher';
const gameDefinitions = await fetchGameDefinitions();
const PUBLIC_ASSET_BRANCH = assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH);
const locale = Astro.params.locale ?? locales.default;
const strings = await getStrings(locale);
---
<style>
.footer {
background-color: var(--color-panel);
}
</style>
<Flex className="footer" gap="6" p="6" direction="column" align="center">
<Flex
align="center"
justify="between"
maxWidth="40rem"
width="100%"
flexGrow="1"
gap="5"
position="relative"
direction={{ initial: 'column', sm: 'row' }}
>
<Flex
direction="column"
align={{
initial: 'center',
sm: 'start',
}}
>
<Heading>BlitzKit</Heading>
<Text color="gray">{strings.website.footer.motto}</Text>
<Text size="1" color="gray">
{strings.website.footer.copyright}
</Text>
<Box mt="2">
<LocaleSwitcherThemeWrapper {locale} client:load />
</Box>
</Flex>
<Flex
gap="6"
width={{
initial: '100%',
sm: 'auto',
}}
>
<Flex
direction="column"
gap="1"
align={{
initial: 'end',
sm: 'start',
}}
flexGrow={{
initial: '1',
sm: '0',
}}
flexBasis={{
initial: '0',
sm: 'auto',
}}
>
<Heading size="3">{strings.website.footer.about.title}</Heading>
<Flex
direction="column"
align={{
initial: 'end',
sm: 'start',
}}
>
<LinkI18n {locale} href="/docs/about/credits" color="gray">
{strings.website.footer.about.credits}
</LinkI18n>
<Link
href="https://discord.gg/nDt7AjGJQH"
target="_blank"
color="gray"
>
{strings.website.footer.about.discord}
</Link>
<Link
href="https://github.com/tresabhi/blitzkit"
target="_blank"
color="gray"
>
{strings.website.footer.about.github}
</Link>
<LinkI18n {locale} href="/docs/patches" color="gray">
{strings.website.footer.about.patches}
</LinkI18n>
</Flex>
</Flex>
<Flex
direction="column"
gap="1"
flexGrow={{
initial: '1',
sm: '0',
}}
flexBasis={{
initial: '0',
sm: 'auto',
}}
>
<Heading size="3">{strings.website.footer.legal.title}</Heading>
<Flex direction="column">
<LinkI18n {locale} href="/docs/legal/privacy-policy" color="gray">
{strings.website.footer.legal.privacy}
</LinkI18n>
<LinkI18n {locale} href="/docs/legal/terms-of-service" color="gray">
{strings.website.footer.legal.terms}
</LinkI18n>
</Flex>
</Flex>
</Flex>
</Flex>
<Flex gap="5" display={{ initial: 'flex', sm: 'none' }}>
<Flex direction="column" justify="between" align="end">
<Text size="2" color="gray">BlitzKit</Text>
{
PUBLIC_ASSET_BRANCH !== 'preview' && (
<Text size="2" color="gray">
WoTB
</Text>
)
}
<Text size="2" color="gray">CDN</Text>
</Flex>
<Flex direction="column" justify="between" align="start">
<Code size="2" color="gray">
{packageJson.version}
</Code>
{
PUBLIC_ASSET_BRANCH !== 'preview' && (
<Code size="2" color="gray">
{gameDefinitions.version}
</Code>
)
}
<Code size="2" color="gray">
{PUBLIC_ASSET_BRANCH}
</Code>
</Flex>
</Flex>
<Flex gap="2" align="center" display={{ initial: 'none', sm: 'flex' }}>
<Text size="2" color="gray">BlitzKit</Text>
<Code size="2" color="gray">
{packageJson.version}
</Code>
{
PUBLIC_ASSET_BRANCH !== 'preview' && (
<>
<Text size="2" color="gray">
WoTB
</Text>
<Code size="2" color="gray">
{gameDefinitions.version}
</Code>
</>
)
}
<Text size="2" color="gray">CDN</Text>
<Code size="2" color="gray">
{PUBLIC_ASSET_BRANCH}
</Code>
</Flex>
</Flex>
@@ -0,0 +1,15 @@
import locales from '@blitzkit/i18n/locales.json' with { type: 'json' };
import { Link, type LinkProps } from '@radix-ui/themes';
import type { LocaleAcceptorProps } from '../hooks/useLocale';
export function LinkI18n({
locale,
href,
...props
}: LinkProps & LocaleAcceptorProps) {
const prefix = locale === locales.default ? '' : `/${locale}`;
return (
<Link href={href?.startsWith('/') ? `${prefix}${href}` : href} {...props} />
);
}
@@ -0,0 +1,49 @@
import locales from '@blitzkit/i18n/locales.json' with { type: 'json' };
import { Select } from '@radix-ui/themes';
import { LOCALE_NAMES, SUPPORTED_LOCALE_FLAGS } from 'packages/i18n/src';
import type { LocaleAcceptorProps } from '../hooks/useLocale';
import { BlitzKitTheme } from './BlitzKitTheme';
export function LocaleSwitcherThemeWrapper({ locale }: LocaleAcceptorProps) {
return (
<BlitzKitTheme style={{ background: 'transparent' }}>
<LocaleSwitcher locale={locale} />
</BlitzKitTheme>
);
}
export function LocaleSwitcher({ locale }: LocaleAcceptorProps) {
return (
<Select.Root
defaultValue={locale}
onValueChange={(locale) => {
localStorage.setItem('preferred-locale', locale);
let rawPath = window.location.pathname;
for (const supportedLocale of locales.supported) {
if (
window.location.pathname.startsWith(`/${supportedLocale.locale}`)
) {
rawPath = rawPath.replace(`/${supportedLocale.locale}`, '');
break;
}
}
window.location.pathname = `${
locale === locales.default ? '' : `/${locale}`
}${rawPath}`;
}}
>
<Select.Trigger variant="classic" />
<Select.Content>
{locales.supported.map(({ locale }) => (
<Select.Item key={locale} value={locale}>
{SUPPORTED_LOCALE_FLAGS[locale]} {LOCALE_NAMES[locale]}
</Select.Item>
))}
</Select.Content>
</Select.Root>
);
}
@@ -0,0 +1,270 @@
---
import { assertSecret } from '@blitzkit/core';
import {
Cross1Icon,
DiscordLogoIcon,
GearIcon,
HamburgerMenuIcon,
} from '@radix-ui/react-icons';
import { Box, Code, Flex, IconButton, Link } from '@radix-ui/themes';
import { TOOLS } from '../constants/tools';
import { resolveBranchName } from '../core/blitzkit/branches';
import { getStrings } from '../core/i18n/getStrings';
import { Var } from '../core/radix/var';
import { LinkI18n } from './LinkI18n';
export interface Props {
integrated?: boolean;
locale: string;
}
const { integrated, locale } = Astro.props;
const toolsFiltered = TOOLS.filter((tool) => !('href' in tool));
const branchName = resolveBranchName(locale);
const isBranchNamed = branchName !== undefined;
const strings = await getStrings(locale);
---
<style>
.navbar,
.padder {
transition-duration: 1s;
}
body.no-navbar .navbar {
transform: translateY(-100%);
}
body.no-navbar .padder {
height: 0;
}
.navbar[data-expanded='false'] *[data-expanded] {
display: none;
}
.navbar[data-expanded='true'] {
height: 100%;
}
.navbar[data-expanded='false'] .content {
background-color: var(--black-a8);
backdrop-filter: blur(4rem);
box-shadow: var(--shadow-3);
}
.navbar[data-expanded='true'] .content {
background-color: var(--gray-1);
}
.navbar[data-expanded='true'] *[data-closed] {
display: none;
}
</style>
<script>
const navbar = document.querySelector<HTMLDivElement>('.navbar');
const hamburger = document.querySelector<HTMLButtonElement>('.hamburger');
navbar?.addEventListener('click', () => {
navbar.dataset.expanded = 'false';
});
hamburger?.addEventListener('click', (event) => {
if (!navbar) return;
event.stopPropagation();
navbar.dataset.expanded = `${navbar.dataset.expanded === 'false'}`;
});
</script>
{
!integrated && (
<Box className="padder" height={{ initial: '3.5rem', sm: '3rem' }} />
)
}
<Flex
direction="column"
align="center"
data-expanded="false"
className="navbar"
width="100%"
top="0"
position="fixed"
style={{
zIndex: 1,
backdropFilter: 'blur(4rem)',
}}
>
<Flex
className="content"
width="100%"
p={{ initial: '4', sm: '3' }}
direction="column"
align="center"
style={{ backgroundColor: integrated ? undefined : Var('gray-1') }}
>
<Flex gap="4" direction="column" maxWidth="80rem" width="100%">
<Flex align="center">
<LinkI18n
{locale}
color="gray"
highContrast
href="/"
underline="hover"
weight="medium"
>
<Flex align="center" gap="1">
BlitzKit
{
isBranchNamed && (
<Code color="gray" size="1" variant="outline">
{branchName}
</Code>
)
}
</Flex>
</LinkI18n>
<Box flexGrow="1" />
<Flex
align="center"
gap={{ initial: '4', md: '6' }}
display={{ initial: 'none', sm: 'flex' }}
justify="center"
className="tools"
>
{
toolsFiltered.map((tool) => {
const unavailableOnBranch = tool.branches?.every(
(branch) =>
branch !== assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH),
);
const selected = Astro.url.pathname.includes(`/${tool.id}`);
const toolStrings =
strings.website.tools[
(tool.stringsId ??
tool.id) as keyof typeof strings.website.tools
];
if (unavailableOnBranch) return null;
return (
<LinkI18n
{locale}
weight={selected ? 'medium' : 'regular'}
color="gray"
highContrast={selected}
size="2"
href={`/${tool.id}`}
underline="hover"
>
{toolStrings.name}
</LinkI18n>
);
})
}
</Flex>
<Box flexGrow="1" />
<Flex align="center" gap="4">
<Link
style={{ display: 'flex', alignContent: 'center' }}
color="gray"
href="https://discord.gg/nDt7AjGJQH"
underline="none"
target="_blank"
>
<DiscordLogoIcon width="1em" height="1em" />
</Link>
<LinkI18n
{locale}
style={{ display: 'flex', alignContent: 'center' }}
color="gray"
href="/settings"
underline="none"
>
<GearIcon width="1em" height="1em" />
</LinkI18n>
<!-- <NavbarAccount client:idle /> -->
<Flex align="center" display={{ initial: 'flex', sm: 'none' }}>
<IconButton variant="ghost" color="gray" className="hamburger">
<Cross1Icon data-expanded />
<HamburgerMenuIcon data-closed />
</IconButton>
</Flex>
</Flex>
</Flex>
<Flex
direction="column"
data-expanded
gap="3"
display={{ initial: 'flex', sm: 'none' }}
>
{
toolsFiltered.map((tool) => {
const unavailableOnBranch = tool.branches?.every(
(branch) =>
branch !== assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH),
);
if (unavailableOnBranch) return null;
const toolStrings =
strings.website.tools[
(tool.stringsId ??
tool.id) as keyof typeof strings.website.tools
];
return (
<Link
color="gray"
highContrast
style={{ display: 'contents' }}
href={`/${tool.id}`}
>
<Box
style={{
borderRadius: Var('radius-2'),
backgroundImage: `url(/assets/images/${tool.id}.webp)`,
backgroundSize: '200%',
backgroundPosition: 'right center',
}}
>
<Flex
py="4"
justify="end"
flexGrow="1"
style={{
background: `linear-gradient(90deg, transparent, ${Var('gray-1')} 90%)`,
}}
>
{toolStrings.name}
</Flex>
</Box>
</Link>
);
})
}
</Flex>
</Flex>
</Flex>
<Box
data-expanded
flexGrow="1"
width="100%"
display={{ initial: 'block', sm: 'none' }}
style={{
backgroundColor: Var('black-a7'),
backdropFilter: 'blur(0.5rem)',
WebkitBackdropFilter: 'blur(0.5rem)',
}}
/>
</Flex>
@@ -0,0 +1,54 @@
import { Flex, type FlexProps, Theme } from '@radix-ui/themes';
import type { ComponentProps } from 'react';
import type { RadixSize } from '../stores/embedState';
import { BlitzKitTheme } from './BlitzKitTheme';
type PageWrapperProps = FlexProps & {
color?: ComponentProps<typeof Theme>['accentColor'];
containerProps?: ComponentProps<typeof Theme>;
noFlex1?: boolean;
padding?: RadixSize;
noMinHeight?: boolean;
};
export function PageWrapper({
style,
color,
children,
noFlex1 = false,
containerProps,
noMinHeight = false,
padding = '4',
...props
}: PageWrapperProps) {
return (
<BlitzKitTheme
accentColor={color}
style={{
flex: noFlex1 ? undefined : 1,
display: 'flex',
minHeight: noMinHeight ? 'unset' : 'calc(100svh - 3rem)',
flexDirection: 'column',
...containerProps?.style,
}}
{...containerProps}
>
<Flex
direction="column"
gap="4"
p={padding}
width="100%"
maxWidth="80rem"
style={{
margin: 'auto',
boxSizing: 'border-box',
flex: 1,
...style,
}}
{...props}
>
{children}
</Flex>
</BlitzKitTheme>
);
}
@@ -0,0 +1,47 @@
---
import { assertSecret } from '@blitzkit/core';
const PUBLIC_GOOGLE_TAG_MANAGER_ID = assertSecret(
import.meta.env.PUBLIC_GOOGLE_TAG_MANAGER_ID,
);
---
{/* google analytics */}
<script
is:inline
async
type="text/partytown"
src={`https://www.googletagmanager.com/gtag/js?id=${PUBLIC_GOOGLE_TAG_MANAGER_ID}`}
></script>
<script
is:inline
define:vars={{ PUBLIC_GOOGLE_TAG_MANAGER_ID }}
type="text/partytown"
>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', PUBLIC_GOOGLE_TAG_MANAGER_ID);
</script>
{/* nitro ads */}
{
/*
<script is:inline data-cfasync="false">
window.nitroAds = window.nitroAds || {
createAd: function () {
return new Promise((e) => {
window.nitroAds.queue.push(['createAd', arguments, e]);
});
},
addUserToken: function () {
window.nitroAds.queue.push(['addUserToken', arguments]);
},
queue: [],
};
</script>
*/
}
+141
View File
@@ -0,0 +1,141 @@
import { Button } from '@radix-ui/themes';
import type { ComponentProps } from 'react';
export interface Tool {
id: string;
stringsId?: string;
disabled?: boolean;
href?: string;
button: {
highContrast?: boolean;
color: ComponentProps<typeof Button>['color'];
};
significant?: boolean;
branches?: string[];
}
export const tankopediaTool: Tool = {
id: 'tanks',
stringsId: 'tankopedia',
button: {
color: 'purple',
},
significant: true,
branches: ['main', 'dev', 'opentest', 'preview'],
};
// export const ratingTool: Tool = {
// id: 'rating',
// image: 'WEyMZH3',
// button: {
// color: 'orange',
// },
// branches: ['main', 'dev'],
// };
export const compareTool: Tool = {
id: 'compare',
button: {
color: 'crimson',
},
branches: ['main', 'dev', 'opentest', 'preview'],
};
export const sessionTool: Tool = {
// TODO: rename to tracker
id: 'session',
button: {
color: 'blue',
},
branches: ['main', 'dev'],
};
export const discordTool: Tool = {
id: 'discord',
href: 'https://discord.com/application-directory/1097673957865443370',
button: {
color: 'indigo',
},
};
export const moreTool: Tool = {
id: 'more',
href: 'https://discord.gg/nDt7AjGJQH',
button: {
color: 'plum',
},
};
export const playersTool: Tool = {
id: 'players',
button: {
color: 'blue',
},
href: '_', // TODO: remove this to re-enable link
branches: ['main', 'dev'],
};
export const performanceTool: Tool = {
id: 'performance',
button: {
color: 'jade',
},
branches: ['main', 'dev'],
};
export const embedTool: Tool = {
id: 'embed',
button: {
color: 'red',
},
branches: ['main', 'dev'],
};
export const homeTool: Tool = {
id: 'home',
button: { color: 'blue' },
};
export const galleryTool: Tool = {
id: 'gallery',
button: {
color: 'gold',
},
branches: ['main', 'dev'],
};
export const tierListTool: Tool = {
id: 'tier-list',
stringsId: 'tier_list',
button: {
color: 'orange',
},
branches: ['main', 'dev', 'opentest', 'preview'],
};
export const chartsTool: Tool = {
id: 'charts',
button: {
color: 'bronze',
},
branches: ['main', 'dev'],
};
export const guessTool: Tool = {
id: 'guess',
button: { color: 'cyan' },
branches: ['main', 'dev', 'opentest', 'preview'],
};
export const TOOLS: Tool[] = [
tankopediaTool,
compareTool,
performanceTool,
// guessTool,
// chartsTool,
galleryTool,
sessionTool,
tierListTool,
embedTool,
// ratingTool,
];
@@ -0,0 +1,9 @@
import { assertSecret } from 'packages/core/src';
export function resolveBranchName(locale: string) {
if (assertSecret(import.meta.env.PUBLIC_ASSET_BRANCH) === 'main') {
return undefined;
}
return import.meta.env.PUBLIC_ASSET_BRANCH;
}
@@ -0,0 +1,15 @@
import locales from '@blitzkit/i18n/locales.json' with { type: 'json' };
export function getBestLocale() {
const desiredLocale =
localStorage.getItem('preferred-locale') ??
navigator.language.split('-')[0];
const isSupported = locales.supported.some(
({ locale }) => locale === desiredLocale,
);
const isDefault = desiredLocale === locales.default;
if (!isSupported || isDefault) return undefined;
return desiredLocale;
}
@@ -0,0 +1,8 @@
import en from '@strings/en.json' with { type: 'json' };
import { merge } from 'lodash-es';
import type { BlitzKitStrings } from 'packages/i18n/src';
export async function getStrings(locale: string) {
const strings = await import(`../../../../i18n/strings/${locale}.json`);
return merge({}, en, strings) as BlitzKitStrings;
}
+21
View File
@@ -0,0 +1,21 @@
import * as ColorsRaw from '@radix-ui/colors';
type ExcludeSuffix<T> =
T extends `${string}${'A' | `${'P3' | 'Dark'}${'' | 'A'}`}` ? never : T;
export type RadixColor = ExcludeSuffix<keyof typeof ColorsRaw>;
export type VarName =
| `color-${'background' | `panel-${'solid' | 'translucent'}` | 'surface' | 'overlay'}`
| `radius-${1 | 2 | 3 | 4 | 5 | 6 | 'full'}`
| `space-${1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}`
| `shadow-${1 | 2 | 3 | 4 | 5 | 6}`
| `${RadixColor | 'accent' | 'gray' | 'black' | 'white'}-${
| `${'' | 'a'}${1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12}`
| 'surface'
| 'indicator'
| 'track'
| 'contrast'}`;
export function Var(name: VarName) {
return `var(--${name})`;
}
@@ -0,0 +1,87 @@
import { type Draft, produce } from 'immer';
import {
createContext,
type ReactNode,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { type StoreApi, useStore as useStoreZustand } from 'zustand';
type StoreProviderProps<InitData> = {
children: ReactNode;
} & (InitData extends unknown ? { data?: InitData } : { data: InitData });
// idk why zustand doesn't export this
type ExtractState<S> = S extends {
getState: () => infer T;
}
? T
: never;
export function createContextualStore<
Store extends StoreApi<unknown>,
InitData,
>(initialize: (data: InitData) => Store) {
type Type = Store extends StoreApi<infer T> ? T : never;
const Context = createContext<Store | undefined>(undefined);
function Provider(props: StoreProviderProps<InitData>) {
const storeRef = useRef<Store | undefined>(undefined);
if (!storeRef.current) {
storeRef.current = initialize((props as { data: InitData }).data);
}
return (
<Context.Provider value={storeRef.current}>
{props.children}
</Context.Provider>
);
}
function useStore() {
const store = useContext(Context);
if (!store) throw new Error('useStore must be used within Provider');
return store as Store;
}
function use<Slice = Type>(
selector: (store: Type) => Slice = (state) => state as Slice,
) {
const store = useStore();
return useStoreZustand(
store,
selector as (state: ExtractState<Store>) => Slice,
);
}
function useDeferred<Slice = Type>(
selector: (store: Type) => Slice = (state) => state as Slice,
initial: Slice,
) {
const [state, setState] = useState(initial);
const trueState = use(selector);
useEffect(() => {
setState(trueState);
}, [trueState]);
return state;
}
function useMutation() {
const counterStoreContext = useStore();
return function (recipe: (draft: Draft<Type>) => void) {
counterStoreContext.setState(
produce<Type>((draft) => {
recipe(draft);
}),
);
};
}
return { Provider, use, useMutation, useStore, useDeferred };
}
@@ -0,0 +1,51 @@
import locales from '@blitzkit/i18n/locales.json' with { type: 'json' };
import type { BlitzKitStrings } from 'packages/i18n/src';
import { createContext, use, useContext } from 'react';
const LocaleContext = createContext<string | null>(null);
export interface LocaleAcceptorProps {
locale: string;
}
interface LocaleProviderProps extends LocaleAcceptorProps {
children: React.ReactNode;
}
export function LocaleProvider({ locale, children }: LocaleProviderProps) {
const isSupported = locales.supported.some(
(supported) => supported.locale === locale,
);
if (!isSupported) throw new Error(`Unsupported locale: ${locale}`);
return (
<LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>
);
}
const cache = new Map<string, Promise<BlitzKitStrings>>();
function loadLocale(locale: string) {
if (cache.has(locale)) return cache.get(locale)!;
const promise = fetch(`/api/strings/${locale}.json`).then((response) => {
if (!response.ok) throw new Error('Failed to load locale');
return response.json();
});
cache.set(locale, promise);
return promise;
}
export function useLocale() {
const locale = useContext(LocaleContext);
if (locale === null) {
throw new Error('useLocale must be used within a LocaleProvider');
}
const strings = use(loadLocale(locale));
return { locale, strings };
}
@@ -0,0 +1,80 @@
---
import { Flex } from '@radix-ui/themes';
import { BlitzKitTheme } from '../components/BlitzKitTheme';
import { Checks } from '../components/Checks';
import Footer from '../components/Footer.astro';
import Navbar from '../components/Navbar.astro';
import Party3 from '../components/Party3.astro';
export interface Props {
noFooter?: boolean;
noNavbar?: boolean;
integrated?: boolean;
locale: string;
}
const { noFooter, noNavbar, integrated, locale } = Astro.props;
---
<html lang={locale}>
<head>
<meta property="og:site_name" content="BlitzKit" />
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="sitemap" href="/sitemap-index.xml" />
<Party3 />
<script>
import locales from '@blitzkit/i18n/locales.json' with { type: 'json' };
import { getBestLocale } from '../core/i18n/getBestLocale';
const isAPI = window.location.pathname.startsWith('/api');
const isI18nPath = locales.supported.some(({ locale }) =>
window.location.pathname.startsWith(`/${locale}`),
);
const bestLocale = getBestLocale();
const currentPathLocale = locales.supported.find(({ locale }) =>
window.location.pathname.startsWith(`/${locale}`),
);
if (
!isAPI && // not an api, duh!
localStorage.getItem('preferred-locale') !== null && // no preference, so probably google's crawler
currentPathLocale &&
bestLocale !== currentPathLocale.locale // we need to redirect
) {
window.location.pathname = isI18nPath
? window.location.pathname.replace(
`/${currentPathLocale.locale}`,
bestLocale === undefined ? '' : `/${bestLocale}`,
)
: `/${bestLocale}${window.location.pathname}`;
}
</script>
<style is:global>
@import '@radix-ui/themes/styles.css';
body {
margin: 0;
}
</style>
<slot name="head" />
</head>
<body class={noNavbar ? 'no-navbar' : ''}>
<BlitzKitTheme>
<Flex direction="column" minHeight="100vh">
<Navbar {locale} {integrated} />
<Checks {locale} client:only="react" />
<slot />
{!noFooter && <Footer />}
</Flex>
</BlitzKitTheme>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
---
import locales from '@blitzkit/i18n/locales.json';
import { DiscordLogoIcon, HomeIcon } from '@radix-ui/react-icons';
import { Button, Flex, Heading, Link, Text } from '@radix-ui/themes';
import { LinkI18n } from '../components/LinkI18n';
import { PageWrapper } from '../components/PageWrapper';
import BaseLayout from '../layouts/BaseLayout.astro';
const locale = locales.default;
---
<BaseLayout {locale}>
<PageWrapper align="center" justify="center" gap="0">
<Heading>404: page not found</Heading>
<Text color="gray">Where would you like to go?</Text>
<Flex mt="4" gap="2">
<LinkI18n {locale} href="/">
<Button>
<HomeIcon /> Home
</Button>
</LinkI18n>
<Link href="https://discord.gg/nDt7AjGJQH">
<Button variant="outline">
<DiscordLogoIcon /> Discord
</Button>
</Link>
</Flex>
</PageWrapper>
</BaseLayout>
@@ -0,0 +1,14 @@
import locales from '@blitzkit/i18n/locales.json' with { type: 'json' };
import type { APIRoute, GetStaticPaths, GetStaticPathsItem } from 'astro';
import { getStrings } from 'packages/website-ue/src/core/i18n/getStrings';
export const getStaticPaths: GetStaticPaths = () => {
return locales.supported.map(
({ locale }) => ({ params: { locale } }) satisfies GetStaticPathsItem,
);
};
export const GET: APIRoute = async ({ params, request }) => {
const strings = await getStrings(params.locale!);
return Response.json(strings);
};
@@ -0,0 +1 @@
export const CURRENT_POLICIES_AGREEMENT_INDEX = 0;
@@ -0,0 +1,31 @@
import { merge } from 'lodash-es';
import { create } from 'zustand';
import { persist, subscribeWithSelector } from 'zustand/middleware';
import { createContextualStore } from '../../core/zustand/createContextualStore';
export interface WargamingLogin {
token: string;
id: number;
expires: number;
}
interface AppStore {
developerMode: boolean;
policiesAgreementIndex: number;
logins: {
wargaming?: WargamingLogin;
};
}
export const App = createContextualStore(() =>
create<AppStore>()(
persist(
subscribeWithSelector<AppStore>(() => ({
developerMode: false,
policiesAgreementIndex: -1,
logins: {},
})),
{ name: 'app', merge: (a, b) => merge(b, a) },
),
),
);
@@ -0,0 +1,27 @@
import type { EmbedConfigItem } from '.';
export enum EmbedItemType {
Radius,
Size,
SizeWithout0,
Color,
String,
RichText,
Boolean,
Slider,
Enum,
}
export const radixGrays = [
'gray',
'mauve',
'slate',
'sage',
'olive',
'sand',
] as const;
export const radixTextWeights = ['light', 'regular', 'medium', 'bold'] as const;
export type EmbedConfigItemType<Type extends EmbedItemType> =
EmbedConfigItem & { type: Type };
@@ -0,0 +1,109 @@
import * as radixColors from '@radix-ui/colors';
import { create } from 'zustand';
import { createContextualStore } from '../../core/zustand/createContextualStore';
import {
type EmbedConfigItemType,
EmbedItemType,
radixGrays,
radixTextWeights,
} from './constants';
export type RadixSize = `${0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}`;
export type RadixSizeWithout0 = Exclude<RadixSize, '0'>;
export type RadixRadius = `${1 | 2 | 3 | 4}` | 'full';
export type RadixTextWeight = (typeof radixTextWeights)[number];
export type RadixColorCompound = {
base: RadixColor;
variant: RadixColorVariant;
};
export type RadixTextColor =
| Exclude<RadixColor, RadixColorGrays>
| 'gray'
| undefined;
export type RadixColorVariantRaw = `${
| 1
| 2
| 3
| 4
| 5
| 6
| 7
| 8
| 9
| 10
| 11
| 12}`;
export type RadixColorVariant = `${'' | 'a'}${RadixColorVariantRaw}`;
export type RadixColor = Exclude<
keyof typeof radixColors,
`${string}${'A' | 'P3' | 'Dark'}`
>;
export type RadixColorGrays = (typeof radixGrays)[number];
export type ExtractEmbedConfigTypes<Config extends EmbedConfig> = {
[Key in keyof Config]: ({
type: Config[Key]['type'];
} & EmbedConfigItem)['default'];
};
export type EmbedConfig = {
width: EmbedConfigItemType<EmbedItemType.Slider>;
height: EmbedConfigItemType<EmbedItemType.Slider>;
} & Record<string, EmbedConfigItem>;
export type EmbedConfigItem = (
| {
type: EmbedItemType.Slider;
default: number;
min: number;
max: number;
}
| {
type: EmbedItemType.Boolean;
default: boolean;
}
| {
type: EmbedItemType.String;
default: string;
}
| {
type: EmbedItemType.Radius;
default: RadixRadius;
}
| {
type: EmbedItemType.Size;
default: RadixSize;
}
| {
type: EmbedItemType.SizeWithout0;
default: RadixSizeWithout0;
}
| {
type: EmbedItemType.Color;
default: string;
}
| {
type: EmbedItemType.RichText;
default: {
color: string;
size: RadixSizeWithout0;
weight: RadixTextWeight;
};
}
| {
type: EmbedItemType.Enum;
default: string;
options: {
value: string;
locales: Record<string, string>;
}[];
}
) & {
pad?: boolean;
};
export type EmbedStateStore = Record<string, EmbedConfigItem['default']>;
export const EmbedState = createContextualStore((init: EmbedStateStore) =>
create<EmbedStateStore>()(() => init),
);
@@ -0,0 +1,30 @@
import type { TextProps } from '@radix-ui/themes';
import { EmbedState, type EmbedConfig, type ExtractEmbedConfigTypes } from '.';
import type { EmbedConfigItemType, EmbedItemType } from './constants';
export function useEmbedStateCurry<Config extends EmbedConfig>() {
function useEmbedState<Key extends keyof Config>(key: Key) {
return EmbedState.use((state) => {
return (state as ExtractEmbedConfigTypes<Config>)[key];
});
}
function useRichText<
Key extends {
[K in keyof Config]: Config[K] extends { type: EmbedItemType.RichText }
? K
: never;
}[keyof Config],
>(key: Key) {
const { color, ...state } = useEmbedState(
key,
) as EmbedConfigItemType<EmbedItemType.RichText>['default'];
return {
style: { color },
...state,
} satisfies TextProps;
}
return { useEmbedState, useRichText };
}