mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
Show K-profile value on AMS slot card (#2854)
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Tests for #2532: K-profile value shown directly on the AMS slot card,
|
||||
* not only inside the hover popup.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { render } from '../utils';
|
||||
import { PrintersPage } from '../../pages/PrintersPage';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../mocks/server';
|
||||
|
||||
const mockPrinters = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'X1 Carbon',
|
||||
ip_address: '192.168.1.100',
|
||||
serial_number: '00M09A350100001',
|
||||
access_code: '12345678',
|
||||
model: 'X1C',
|
||||
enabled: true,
|
||||
is_active: true,
|
||||
nozzle_diameter: 0.4,
|
||||
nozzle_type: 'hardened_steel',
|
||||
location: 'Workshop',
|
||||
auto_archive: true,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const mockPrinterStatus = {
|
||||
connected: true,
|
||||
state: 'IDLE',
|
||||
awaiting_plate_clear: false,
|
||||
progress: 0,
|
||||
layer_num: 0,
|
||||
total_layers: 0,
|
||||
temperatures: { nozzle: 25, bed: 25, chamber: 25 },
|
||||
remaining_time: 0,
|
||||
filename: null,
|
||||
wifi_signal: -50,
|
||||
vt_tray: [],
|
||||
};
|
||||
|
||||
describe('PrintersPage - K-profile always-visible display (#2532)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem('printerCardSize');
|
||||
|
||||
server.use(
|
||||
http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
|
||||
http.get('/api/v1/settings/', () => HttpResponse.json({
|
||||
auto_archive: true,
|
||||
save_thumbnails: true,
|
||||
capture_finish_photo: true,
|
||||
default_filament_cost: 25.0,
|
||||
currency: 'USD',
|
||||
ams_humidity_good: 40,
|
||||
ams_humidity_fair: 60,
|
||||
ams_temp_good: 30,
|
||||
ams_temp_fair: 35,
|
||||
require_plate_clear: true,
|
||||
})),
|
||||
http.get('/api/v1/settings/ui-preferences', () => HttpResponse.json({
|
||||
ams_humidity_good: 40,
|
||||
ams_humidity_fair: 60,
|
||||
ams_temp_good: 30,
|
||||
ams_temp_fair: 35,
|
||||
require_plate_clear: true,
|
||||
})),
|
||||
http.get('/api/v1/queue/', () => HttpResponse.json([])),
|
||||
http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
|
||||
http.get('/api/v1/spoolman/settings', () => HttpResponse.json({
|
||||
spoolman_enabled: 'false', spoolman_url: '',
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the K-value on a loaded standard AMS slot without hovering', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
|
||||
...mockPrinterStatus,
|
||||
// A standard (non-HT) AMS unit has 4 trays — htAms in PrintersPage.tsx
|
||||
// filters on tray.length === 1, so a single-tray mock here would
|
||||
// silently exercise the AMS-HT code path instead of this one.
|
||||
ams: [{
|
||||
id: 0,
|
||||
tray: [
|
||||
{
|
||||
id: 0,
|
||||
tray_type: 'PETG',
|
||||
tray_color: 'FF0000FF',
|
||||
tray_sub_brands: 'Bambu PETG HF',
|
||||
k: 0.024,
|
||||
},
|
||||
{ id: 1, tray_type: null, state: 9 },
|
||||
{ id: 2, tray_type: null, state: 9 },
|
||||
{ id: 3, tray_type: null, state: 9 },
|
||||
],
|
||||
}],
|
||||
})),
|
||||
);
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
// No hover/mouseEnter simulated here on purpose — the whole point of
|
||||
// #2532 is that this must be readable without one.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('K 0.024')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The short "K" label is intentional (round-2 review: the full "K
|
||||
// Factor" label was clipping the value at narrow slot widths), but the
|
||||
// full localized name should still be reachable via the title attribute.
|
||||
expect(screen.getByText('K 0.024')).toHaveAttribute('title', 'K Factor');
|
||||
});
|
||||
|
||||
it('does not show a K-value on an empty slot', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
|
||||
...mockPrinterStatus,
|
||||
ams: [{
|
||||
id: 0,
|
||||
tray: [
|
||||
{ id: 0, tray_type: null, state: 9 },
|
||||
{ id: 1, tray_type: null, state: 9 },
|
||||
{ id: 2, tray_type: null, state: 9 },
|
||||
{ id: 3, tray_type: null, state: 9 },
|
||||
],
|
||||
}],
|
||||
})),
|
||||
);
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
// All 4 slots are empty, so several "Empty" labels render.
|
||||
expect(screen.getAllByText('Empty').length).toBeGreaterThan(0);
|
||||
});
|
||||
// ... but formatKValue() defaults to 0.020 when there's no tray data,
|
||||
// so this specifically guards against that default leaking onto an
|
||||
// empty slot as a misleading "K 0.020".
|
||||
expect(screen.queryByText(/^K 0\.020$/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show a fabricated K-value on a loaded slot with no reported K (review #2854)', async () => {
|
||||
// Regression test for maziggy's review on #2854: a slot can be loaded
|
||||
// (tray_type present) while the printer has simply never reported a K
|
||||
// value for it — k is null, not merely absent. This is common on X1C,
|
||||
// where K comes from a cali_idx lookup that can legitimately miss.
|
||||
// formatKValue() defaults null to 0.020, which reads as a real measured
|
||||
// value on this permanent, uncaptioned line, so the row must not render
|
||||
// at all in this case rather than showing that default.
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
|
||||
...mockPrinterStatus,
|
||||
// 4 trays — see comment on the first test above re: htAms filtering.
|
||||
ams: [{
|
||||
id: 0,
|
||||
tray: [
|
||||
{
|
||||
id: 0,
|
||||
tray_type: 'PETG',
|
||||
tray_color: 'FF0000FF',
|
||||
tray_sub_brands: 'Bambu PETG HF',
|
||||
k: null,
|
||||
},
|
||||
{ id: 1, tray_type: null, state: 9 },
|
||||
{ id: 2, tray_type: null, state: 9 },
|
||||
{ id: 3, tray_type: null, state: 9 },
|
||||
],
|
||||
}],
|
||||
})),
|
||||
);
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
// The slot itself is loaded and visible ...
|
||||
expect(screen.getByText('PETG')).toBeInTheDocument();
|
||||
});
|
||||
// ... but no K-value line, fabricated or otherwise, should appear. Read the
|
||||
// slot's own text rather than only querying for a "K " label: a guard that
|
||||
// leaks a value without the label would pass the label query.
|
||||
expect(screen.getByText('PETG').parentElement).toHaveTextContent(/^1PETG$/);
|
||||
});
|
||||
|
||||
it('does not show a K-value when the printer reports exactly 0 (review #2854, round 2)', async () => {
|
||||
// maziggy's round-2 question: does tray.k != null let a real firmware "0"
|
||||
// through as a misleading "K 0.000"? It does -- printer_manager.py assigns
|
||||
// the tray's own reported k verbatim and only filters falsy values when
|
||||
// falling back to a stored K-profile, so a 0 does reach this component.
|
||||
// Gate on a truthy tray.k, and render it through a ternary: `tray.k && ...`
|
||||
// evaluates to the number 0, and React renders numbers, so the guard itself
|
||||
// would print a bare "0" where the value belongs.
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
|
||||
...mockPrinterStatus,
|
||||
ams: [{
|
||||
id: 0,
|
||||
tray: [
|
||||
{
|
||||
id: 0,
|
||||
tray_type: 'PETG',
|
||||
tray_color: 'FF0000FF',
|
||||
tray_sub_brands: 'Bambu PETG HF',
|
||||
k: 0,
|
||||
},
|
||||
{ id: 1, tray_type: null, state: 9 },
|
||||
{ id: 2, tray_type: null, state: 9 },
|
||||
{ id: 3, tray_type: null, state: 9 },
|
||||
],
|
||||
}],
|
||||
})),
|
||||
);
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('PETG')).toBeInTheDocument();
|
||||
});
|
||||
// The slot number and the material, and nothing else -- no "K 0.000", and
|
||||
// no stray "0" leaked by the guard.
|
||||
expect(screen.getByText('PETG').parentElement).toHaveTextContent(/^1PETG$/);
|
||||
});
|
||||
|
||||
it('does not leak a stray 0 on an external or AMS-HT slot reporting exactly 0', async () => {
|
||||
// Same guard, same defect, on the two other slot types.
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
|
||||
...mockPrinterStatus,
|
||||
ams: [{ id: 1, tray: [{ id: 0, tray_type: 'ASA', tray_color: 'FFFFFFFF', k: 0 }] }],
|
||||
vt_tray: [{ id: 254, tray_type: 'PLA', tray_color: '000000FF', k: 0 }],
|
||||
})),
|
||||
);
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('ASA')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('ASA').parentElement).toHaveTextContent(/^1ASA$/);
|
||||
expect(screen.getByText('PLA').parentElement).toHaveTextContent(/^1PLA$/);
|
||||
});
|
||||
|
||||
it('shows the K-value on a loaded AMS-HT slot without hovering', async () => {
|
||||
// AMS-HT units are identified by having exactly one tray in their
|
||||
// `tray` array (vs. four for a standard AMS unit) — see the htAms
|
||||
// filter in PrintersPage.tsx.
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
|
||||
...mockPrinterStatus,
|
||||
ams: [{
|
||||
id: 1,
|
||||
tray: [{
|
||||
id: 0,
|
||||
tray_type: 'ASA',
|
||||
tray_color: 'FFFFFFFF',
|
||||
tray_sub_brands: 'Bambu ASA',
|
||||
k: 0.018,
|
||||
}],
|
||||
}],
|
||||
})),
|
||||
);
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('K 0.018')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the K-value on a loaded external/dual-nozzle slot without hovering', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
|
||||
...mockPrinterStatus,
|
||||
vt_tray: [{
|
||||
id: 254,
|
||||
tray_type: 'PLA',
|
||||
tray_color: '000000FF',
|
||||
tray_sub_brands: 'Bambu PLA Basic',
|
||||
k: 0.022,
|
||||
}],
|
||||
})),
|
||||
);
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('K 0.022')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('reserves the row on slots without a K-value so fill bars stay aligned', async () => {
|
||||
// The K line is an extra block child, so on a partially calibrated unit --
|
||||
// the normal state, not an edge case -- the calibrated slot's fill bar would
|
||||
// sit a line below its neighbours'. The slots without a value hold the row
|
||||
// open instead. A card with no calibrated slot anywhere keeps its old
|
||||
// height, which the tests above assert.
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
|
||||
...mockPrinterStatus,
|
||||
ams: [{
|
||||
id: 0,
|
||||
tray: [
|
||||
{ id: 0, tray_type: 'PETG', tray_color: 'FF0000FF', k: 0.024 },
|
||||
{ id: 1, tray_type: 'PLA', tray_color: '00FF00FF', k: null },
|
||||
{ id: 2, tray_type: null, state: 9 },
|
||||
{ id: 3, tray_type: null, state: 9 },
|
||||
],
|
||||
}],
|
||||
})),
|
||||
);
|
||||
|
||||
render(<PrintersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('K 0.024')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const calibrated = screen.getByText('PETG').parentElement as HTMLElement;
|
||||
const uncalibrated = screen.getByText('PLA').parentElement as HTMLElement;
|
||||
// Same number of children, so the fill bar sits at the same offset in both.
|
||||
expect(uncalibrated.children.length).toBe(calibrated.children.length);
|
||||
// The reserved row carries no readable text of its own.
|
||||
expect(uncalibrated).toHaveTextContent(/^2PLA$/);
|
||||
});
|
||||
});
|
||||
@@ -4897,6 +4897,7 @@ export default {
|
||||
externalSpool: 'Externe Spule',
|
||||
profile: 'Profil',
|
||||
kFactor: 'K-Faktor',
|
||||
kFactorShort: 'K',
|
||||
fill: 'Füllstand',
|
||||
configure: 'Konfigurieren',
|
||||
used: 'verwendet',
|
||||
|
||||
@@ -4941,6 +4941,7 @@ export default {
|
||||
externalSpool: 'External Spool',
|
||||
profile: 'Profile',
|
||||
kFactor: 'K Factor',
|
||||
kFactorShort: 'K',
|
||||
fill: 'Fill',
|
||||
configure: 'Configure',
|
||||
used: 'used',
|
||||
|
||||
@@ -4904,6 +4904,7 @@ export default {
|
||||
externalSpool: 'Bobina externa',
|
||||
profile: 'Perfil',
|
||||
kFactor: 'Factor K',
|
||||
kFactorShort: 'K',
|
||||
fill: 'Rellenar',
|
||||
configure: 'Configurar',
|
||||
used: 'usado',
|
||||
|
||||
@@ -4886,6 +4886,7 @@ export default {
|
||||
externalSpool: 'Bobine externe',
|
||||
profile: 'Profil',
|
||||
kFactor: 'Facteur K',
|
||||
kFactorShort: 'K',
|
||||
fill: 'Remplir',
|
||||
configure: 'Configurer',
|
||||
used: 'utilisé',
|
||||
|
||||
@@ -4885,6 +4885,7 @@ export default {
|
||||
externalSpool: 'Bobina esterna',
|
||||
profile: 'Profilo',
|
||||
kFactor: 'Fattore K',
|
||||
kFactorShort: 'K',
|
||||
fill: 'Livello',
|
||||
configure: 'Configura',
|
||||
used: 'utilizzato',
|
||||
|
||||
@@ -4897,6 +4897,7 @@ export default {
|
||||
externalSpool: '外部スプール',
|
||||
profile: 'プロファイル',
|
||||
kFactor: 'K値',
|
||||
kFactorShort: 'K値',
|
||||
fill: '充填率',
|
||||
configure: '設定',
|
||||
used: '使用済み',
|
||||
|
||||
@@ -4666,6 +4666,7 @@ export default {
|
||||
externalSpool: '외부 스풀',
|
||||
profile: '프로필',
|
||||
kFactor: 'K 계수',
|
||||
kFactorShort: 'K',
|
||||
fill: '채우기',
|
||||
configure: '구성',
|
||||
used: '사용됨',
|
||||
|
||||
@@ -4885,6 +4885,7 @@ export default {
|
||||
externalSpool: 'Carretel Externo',
|
||||
profile: 'Perfil',
|
||||
kFactor: 'Fator K',
|
||||
kFactorShort: 'K',
|
||||
fill: 'Preencher',
|
||||
configure: 'Configurar',
|
||||
used: 'usado',
|
||||
|
||||
@@ -4658,6 +4658,7 @@ export default {
|
||||
externalSpool: "Внешняя катушка",
|
||||
profile: "Профиль",
|
||||
kFactor: "Коэффициент K",
|
||||
kFactorShort: "K",
|
||||
fill: "Заполнить",
|
||||
configure: "Настроить",
|
||||
used: "использовано",
|
||||
|
||||
@@ -4874,6 +4874,7 @@ export default {
|
||||
externalSpool: 'Harici Makara',
|
||||
profile: 'Profil',
|
||||
kFactor: 'K Faktörü',
|
||||
kFactorShort: 'K',
|
||||
fill: 'Doldur',
|
||||
configure: 'Yapılandır',
|
||||
used: 'kullanılan',
|
||||
|
||||
@@ -4939,6 +4939,7 @@ export default {
|
||||
externalSpool: "Зовнішня котушка",
|
||||
profile: "Профіль",
|
||||
kFactor: "Фактор K",
|
||||
kFactorShort: "K",
|
||||
fill: "Рівень заповнення",
|
||||
configure: "Налаштувати",
|
||||
used: "використовується",
|
||||
|
||||
@@ -4885,6 +4885,7 @@ export default {
|
||||
externalSpool: '外置耗材',
|
||||
profile: '配置',
|
||||
kFactor: 'K 值',
|
||||
kFactorShort: 'K 值',
|
||||
fill: '填充',
|
||||
configure: '配置',
|
||||
used: '已使用',
|
||||
|
||||
@@ -4885,6 +4885,7 @@ export default {
|
||||
externalSpool: '外接耗材',
|
||||
profile: '設定',
|
||||
kFactor: 'K 值',
|
||||
kFactorShort: 'K 值',
|
||||
fill: '填充',
|
||||
configure: '設定',
|
||||
used: '已使用',
|
||||
|
||||
@@ -214,6 +214,36 @@ function formatKValue(k: number | null | undefined): string {
|
||||
return value.toFixed(3);
|
||||
}
|
||||
|
||||
// K-profile value shown on the slot card itself (#2532) rather than only inside
|
||||
// the hover popup, the way BambuStudio shows it per slot.
|
||||
//
|
||||
// `k` arrives already gated on the slot being loaded; falsy means the printer
|
||||
// never reported a calibration for it. formatKValue() substitutes 0.020 for a
|
||||
// missing value, which is captioned inside the hover card but would read as a
|
||||
// real measurement on this permanent, uncaptioned line -- so an uncalibrated
|
||||
// slot gets no value at all. A ternary rather than `k && <div/>`: a firmware-
|
||||
// reported 0 makes that expression evaluate to the number 0, and React renders
|
||||
// numbers, putting a bare "0" under the material name.
|
||||
//
|
||||
// `reserve` holds the row's height open on the slots without a value whenever
|
||||
// some other slot on the same card has one, so every fill bar stays on one line.
|
||||
function KValueLine({ k, reserve }: { k: number | null | undefined; reserve: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const className = 'text-[length:var(--pc-t8,8px)] text-bambu-gray tabular-nums leading-none truncate';
|
||||
if (!k) {
|
||||
return reserve ? <div className={className} aria-hidden="true"> </div> : null;
|
||||
}
|
||||
// Short label, full localized name on the title: "K Factor" / "K-Faktor" /
|
||||
// "Facteur K" clipped the value itself below ~70px, and the slot grid floor is
|
||||
// 3.5rem. tabular-nums rather than font-mono, which resolved to a different
|
||||
// family per browser and so measured differently in Safari.
|
||||
return (
|
||||
<div className={className} title={t('ams.kFactor')}>
|
||||
{t('ams.kFactorShort')} {formatKValue(k)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Nozzle side indicators (Bambu Lab style - square badge with L/R)
|
||||
function NozzleBadge({ side }: { side: 'L' | 'R' }) {
|
||||
const { mode } = useTheme();
|
||||
@@ -2489,6 +2519,13 @@ function PrinterCard({
|
||||
}
|
||||
}, [status?.ams]);
|
||||
const amsData = (status?.ams && status.ams.length > 0) ? status.ams : cachedAmsData.current;
|
||||
// #2532: the K-profile line only exists on slots the printer has actually
|
||||
// calibrated. The AMS units and the external-spool group are flex siblings in
|
||||
// one row, so on a card that shows a value anywhere, the slots without one
|
||||
// reserve the same height -- otherwise their fill bars sit a line above their
|
||||
// neighbours'. A card with no calibrated slot at all is unaffected.
|
||||
const anySlotHasKValue = amsData.some(unit => unit.tray.some(tray => tray.k))
|
||||
|| (status?.vt_tray ?? []).some(tray => tray.k);
|
||||
|
||||
// Confirm a drying cycle actually started (#2533). Firmware answers
|
||||
// ams_filament_drying with result=success even when it then silently declines,
|
||||
@@ -5467,6 +5504,7 @@ function PrinterCard({
|
||||
<div className="text-[length:var(--pc-t9,9px)] text-white font-bold truncate">
|
||||
{tray?.tray_type || t(emptyKind === 'reset' ? 'ams.slotUnconfigured' : 'ams.slotEmpty')}
|
||||
</div>
|
||||
<KValueLine k={filamentData ? tray?.k : null} reserve={anySlotHasKValue} />
|
||||
{/* Fill bar */}
|
||||
<div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
|
||||
{effectiveFill !== null && effectiveFill >= 0 && !isEmpty && tray && (
|
||||
@@ -5755,6 +5793,7 @@ function PrinterCard({
|
||||
<div className="text-[length:var(--pc-t9,9px)] text-white font-bold truncate">
|
||||
{tray?.tray_type || t(emptyKind === 'reset' ? 'ams.slotUnconfigured' : 'ams.slotEmpty')}
|
||||
</div>
|
||||
<KValueLine k={filamentData ? tray?.k : null} reserve={anySlotHasKValue} />
|
||||
{/* Fill bar */}
|
||||
<div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
|
||||
{htEffectiveFill !== null && htEffectiveFill >= 0 && !isEmpty && (
|
||||
@@ -6140,6 +6179,7 @@ function PrinterCard({
|
||||
<div className={`text-[length:var(--pc-t9,9px)] font-bold truncate ${isEmpty ? 'text-white/40' : 'text-white'}`}>
|
||||
{extTray.tray_type || t('ams.slotEmpty')}
|
||||
</div>
|
||||
<KValueLine k={isEmpty ? null : extTray.k} reserve={anySlotHasKValue} />
|
||||
<div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
|
||||
{extEffectiveFill !== null && extEffectiveFill >= 0 && !isEmpty && (
|
||||
<div
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-3zC9-lDQ.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BumpPKBp.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-kSJGQrMr.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user