feat: synchronize account icons across game profiles
This commit is contained in:
@@ -0,0 +1,616 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const errorResponse = (path: string, message: string) => ({
|
||||
error: {
|
||||
message,
|
||||
code: -32603,
|
||||
data: {
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
httpStatus: 500,
|
||||
path,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const operationNames = (route: Route): string[] => {
|
||||
const url = new URL(route.request().url());
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const fulfillTrpc = async (route: Route, results: unknown[]): Promise<void> => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'access-control-allow-origin': '*',
|
||||
},
|
||||
body: JSON.stringify(results),
|
||||
});
|
||||
};
|
||||
|
||||
const profileInputAt = (body: string, index: number): string | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as Record<string, { profile?: unknown; json?: { profile?: unknown } }>;
|
||||
const input = parsed[String(index)];
|
||||
const profile = input?.profile ?? input?.json?.profile;
|
||||
return typeof profile === 'string' ? profile : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
type FixtureOptions = {
|
||||
failHweAdjustOnce?: boolean;
|
||||
delayHweAdjust?: boolean;
|
||||
};
|
||||
|
||||
const activeProfiles = [
|
||||
{
|
||||
profileName: 'che:903',
|
||||
profile: 'che',
|
||||
apiPort: 15003,
|
||||
korName: 'CHE 서버',
|
||||
},
|
||||
{
|
||||
profileName: 'hwe:903',
|
||||
profile: 'hwe',
|
||||
apiPort: 15015,
|
||||
korName: '훼',
|
||||
},
|
||||
];
|
||||
|
||||
const installFixture = async (page: Page, options: FixtureOptions = {}) => {
|
||||
let deleteIconCount = 0;
|
||||
let hweAdjustCount = 0;
|
||||
const operations = new Map<string, string[]>([
|
||||
['che:903', []],
|
||||
['hwe:903', []],
|
||||
]);
|
||||
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-session-token', 'account-session');
|
||||
});
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
const body = route.request().postData() ?? '';
|
||||
const results = operationNames(route).map((operation, index) => {
|
||||
if (operation === 'account.get') {
|
||||
return response({
|
||||
id: 'account-user',
|
||||
username: 'account-user',
|
||||
displayName: '계정 사용자',
|
||||
roles: ['user'],
|
||||
oauthType: 'NONE',
|
||||
createdAt: '2026-07-30T00:00:00.000Z',
|
||||
iconUrl: '/gateway/api/user-icons/old.png',
|
||||
thirdPartyUse: false,
|
||||
deleteAfter: null,
|
||||
});
|
||||
}
|
||||
if (operation === 'account.changeIcon') {
|
||||
return response({
|
||||
ok: true,
|
||||
iconUrl: '/gateway/api/user-icons/new.png',
|
||||
revision: '2026-07-31T09:00:00.001Z',
|
||||
profiles: activeProfiles,
|
||||
flushPublished: true,
|
||||
});
|
||||
}
|
||||
if (operation === 'account.deleteIcon') {
|
||||
deleteIconCount += 1;
|
||||
return response({
|
||||
ok: true,
|
||||
iconUrl: null,
|
||||
revision: '2026-07-31T09:00:00.002Z',
|
||||
profiles: [activeProfiles[1]],
|
||||
flushPublished: true,
|
||||
});
|
||||
}
|
||||
if (operation === 'account.prepareIconSync') {
|
||||
return response({
|
||||
iconUrl: '/gateway/api/user-icons/new.png',
|
||||
projection: {
|
||||
revision: '2026-07-31T09:00:00.001Z',
|
||||
picture: 'new.png',
|
||||
imageServer: 1,
|
||||
},
|
||||
profiles: activeProfiles,
|
||||
});
|
||||
}
|
||||
if (operation === 'auth.issueGameSession') {
|
||||
const profileName = profileInputAt(body, index);
|
||||
expect(profileName === 'che:903' || profileName === 'hwe:903').toBe(true);
|
||||
if (profileName !== 'che:903' && profileName !== 'hwe:903') {
|
||||
throw new Error('issueGameSession profile input was not encoded in the request.');
|
||||
}
|
||||
operations.get(profileName)?.push('issueGameSession');
|
||||
return response({
|
||||
profile: profileName,
|
||||
gameToken: `gateway-token-${profileName}`,
|
||||
expiresAt: '2026-07-31T01:00:00.000Z',
|
||||
});
|
||||
}
|
||||
throw new Error(`Unhandled gateway tRPC operation: ${operation}`);
|
||||
});
|
||||
await fulfillTrpc(route, results);
|
||||
});
|
||||
|
||||
const installGameRoute = async (profileName: 'che:903' | 'hwe:903'): Promise<void> => {
|
||||
const handle = async (route: Route): Promise<void> => {
|
||||
expect(new URL(route.request().url()).pathname).toBe(
|
||||
`/${profileName.split(':')[0]}/api/trpc/${operationNames(route).join(',')}`
|
||||
);
|
||||
const results = [];
|
||||
for (const operation of operationNames(route)) {
|
||||
if (operation === 'auth.exchangeGatewayToken') {
|
||||
operations.get(profileName)?.push('exchangeGatewayToken');
|
||||
results.push(
|
||||
response({
|
||||
accessToken: `access-token-${profileName}`,
|
||||
profile: profileName,
|
||||
expiresAt: '2026-07-31T01:00:00.000Z',
|
||||
})
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (operation === 'general.adjustIcon') {
|
||||
operations.get(profileName)?.push('adjustIcon');
|
||||
if (profileName === 'hwe:903') {
|
||||
hweAdjustCount += 1;
|
||||
if (options.delayHweAdjust) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
if (options.failHweAdjustOnce && hweAdjustCount === 1) {
|
||||
results.push(errorResponse(operation, 'HWE 아이콘 적용 실패'));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
expect(route.request().headers().authorization).toBe(`Bearer access-token-${profileName}`);
|
||||
results.push(response({ generalId: 101, updated: true }));
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unhandled ${profileName} tRPC operation: ${operation}`);
|
||||
}
|
||||
await fulfillTrpc(route, results);
|
||||
};
|
||||
await page.route(`**/${profileName.split(':')[0]}/api/trpc/**`, handle);
|
||||
};
|
||||
|
||||
await installGameRoute('che:903');
|
||||
await installGameRoute('hwe:903');
|
||||
|
||||
return {
|
||||
operations,
|
||||
deleteIconCount: () => deleteIconCount,
|
||||
};
|
||||
};
|
||||
|
||||
const uploadIcon = async (page: Page): Promise<void> => {
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: 'new-icon.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
),
|
||||
});
|
||||
await page.getByRole('button', { name: '아이콘 변경' }).click();
|
||||
};
|
||||
|
||||
test('selects every returned server and applies only checked servers in issue-exchange-adjust order', async ({
|
||||
page,
|
||||
}) => {
|
||||
const fixture = await installFixture(page, { delayHweAdjust: true });
|
||||
await page.goto('account');
|
||||
await uploadIcon(page);
|
||||
|
||||
const modal = page.getByTestId('icon-server-modal');
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(modal).toContainText('완료되었습니다.');
|
||||
await expect(page.getByTestId('icon-server-option-che:903')).toBeChecked();
|
||||
await expect(page.getByTestId('icon-server-option-hwe:903')).toBeChecked();
|
||||
await expect(page.getByTestId('icon-server-option-stopped:903')).toHaveCount(0);
|
||||
await expect(page.locator('.icon-server-dialog')).toBeFocused();
|
||||
|
||||
await page.getByTestId('icon-server-apply').focus();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.getByTestId('icon-server-close')).toBeFocused();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(page.getByTestId('icon-server-apply')).toBeFocused();
|
||||
|
||||
await page.getByTestId('icon-server-option-che:903').uncheck();
|
||||
await page.getByTestId('icon-server-apply').click();
|
||||
await expect(page.getByTestId('icon-server-apply')).toBeDisabled();
|
||||
await expect(page.getByTestId('icon-server-close')).toBeDisabled();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.getByTestId('icon-server-apply').evaluate((element) => ({
|
||||
opacity: getComputedStyle(element).opacity,
|
||||
transitionDuration: getComputedStyle(element).transitionDuration,
|
||||
}))
|
||||
)
|
||||
.toEqual({
|
||||
opacity: '0.65',
|
||||
transitionDuration: '0.15s, 0.15s, 0.15s, 0.15s',
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const dialog = document.querySelector<HTMLElement>('.icon-server-dialog');
|
||||
return Boolean(dialog && dialog.contains(document.activeElement));
|
||||
})
|
||||
)
|
||||
.toBe(true);
|
||||
await expect(page.getByTestId('icon-server-result-hwe:903')).toContainText('적용 중');
|
||||
await expect(page.getByTestId('icon-server-result-hwe:903')).toContainText('적용됨');
|
||||
|
||||
expect(fixture.operations.get('che:903')).toEqual([]);
|
||||
expect(fixture.operations.get('hwe:903')).toEqual(['issueGameSession', 'exchangeGatewayToken', 'adjustIcon']);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(modal).toBeVisible();
|
||||
await page.getByTestId('icon-server-close').click();
|
||||
await expect(modal).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: '아이콘 변경' })).toBeFocused();
|
||||
});
|
||||
|
||||
test('keeps per-server failures visible and retries only failed servers', async ({ page }) => {
|
||||
const fixture = await installFixture(page, { failHweAdjustOnce: true });
|
||||
await page.goto('account');
|
||||
await uploadIcon(page);
|
||||
await page.getByTestId('icon-server-apply').click();
|
||||
|
||||
await expect(page.getByTestId('icon-server-result-che:903')).toContainText('적용됨');
|
||||
await expect(page.getByTestId('icon-server-result-hwe:903')).toContainText('HWE 아이콘 적용 실패');
|
||||
await expect(page.getByTestId('icon-server-retry')).toBeVisible();
|
||||
|
||||
await page.getByTestId('icon-server-retry').click();
|
||||
await expect(page.getByTestId('icon-server-result-hwe:903')).toContainText('적용됨');
|
||||
await expect(page.getByTestId('icon-server-retry')).toHaveCount(0);
|
||||
|
||||
expect(fixture.operations.get('che:903')).toEqual(['issueGameSession', 'exchangeGatewayToken', 'adjustIcon']);
|
||||
expect(fixture.operations.get('hwe:903')).toEqual([
|
||||
'issueGameSession',
|
||||
'exchangeGatewayToken',
|
||||
'adjustIcon',
|
||||
'issueGameSession',
|
||||
'exchangeGatewayToken',
|
||||
'adjustIcon',
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses the Ref delete confirmation and opens the modal only after acceptance', async ({ page }, testInfo) => {
|
||||
const fixture = await installFixture(page);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto('account');
|
||||
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toBe('아이콘을 제거할까요?');
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await page.getByRole('button', { name: '아이콘 제거' }).click();
|
||||
await expect(page.getByTestId('icon-server-modal')).toHaveCount(0);
|
||||
expect(fixture.deleteIconCount()).toBe(0);
|
||||
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toBe('아이콘을 제거할까요?');
|
||||
await dialog.accept();
|
||||
});
|
||||
await page.getByRole('button', { name: '아이콘 제거' }).click();
|
||||
await expect(page.getByTestId('icon-server-modal')).toBeVisible();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await expect(page.getByTestId('icon-server-option-hwe:903')).toBeChecked();
|
||||
await expect(page.getByTestId('icon-server-option-che:903')).toHaveCount(0);
|
||||
expect(fixture.deleteIconCount()).toBe(1);
|
||||
await page.locator('.icon-server-dialog').screenshot({
|
||||
path: testInfo.outputPath('core-icon-modal-desktop.png'),
|
||||
animations: 'disabled',
|
||||
});
|
||||
|
||||
const geometry = await page.evaluate(() => {
|
||||
const rect = (selector: string) => {
|
||||
const value = document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||
return { x: value.x, y: value.y, width: value.width, height: value.height };
|
||||
};
|
||||
const dialogStyle = getComputedStyle(document.querySelector<HTMLElement>('.icon-server-dialog')!);
|
||||
const titleStyle = getComputedStyle(document.querySelector<HTMLElement>('.icon-server-header h2')!);
|
||||
const closeStyle = getComputedStyle(document.querySelector<HTMLElement>('[data-testid="icon-server-close"]')!);
|
||||
const secondaryStyle = getComputedStyle(document.querySelector<HTMLElement>('.icon-server-footer .secondary')!);
|
||||
const applyStyle = getComputedStyle(document.querySelector<HTMLElement>('[data-testid="icon-server-apply"]')!);
|
||||
const backdropStyle = getComputedStyle(document.querySelector<HTMLElement>('.icon-server-backdrop')!);
|
||||
const motionStyle = getComputedStyle(document.querySelector<HTMLElement>('.icon-server-dialog')!);
|
||||
return {
|
||||
dialog: rect('.icon-server-dialog'),
|
||||
header: rect('.icon-server-header'),
|
||||
title: rect('.icon-server-header h2'),
|
||||
body: rect('.icon-server-body'),
|
||||
footer: rect('.icon-server-footer'),
|
||||
close: rect('[data-testid="icon-server-close"]'),
|
||||
secondary: rect('.icon-server-footer .secondary'),
|
||||
apply: rect('[data-testid="icon-server-apply"]'),
|
||||
dialogStyle: {
|
||||
color: dialogStyle.color,
|
||||
backgroundColor: dialogStyle.backgroundColor,
|
||||
borderColor: dialogStyle.borderColor,
|
||||
borderRadius: dialogStyle.borderRadius,
|
||||
boxShadow: dialogStyle.boxShadow,
|
||||
fontFamily: dialogStyle.fontFamily,
|
||||
fontSize: dialogStyle.fontSize,
|
||||
lineHeight: dialogStyle.lineHeight,
|
||||
},
|
||||
titleStyle: {
|
||||
fontSize: titleStyle.fontSize,
|
||||
fontWeight: titleStyle.fontWeight,
|
||||
lineHeight: titleStyle.lineHeight,
|
||||
},
|
||||
closeStyle: {
|
||||
color: closeStyle.color,
|
||||
backgroundColor: closeStyle.backgroundColor,
|
||||
borderColor: closeStyle.borderColor,
|
||||
borderRadius: closeStyle.borderRadius,
|
||||
fontSize: closeStyle.fontSize,
|
||||
fontWeight: closeStyle.fontWeight,
|
||||
lineHeight: closeStyle.lineHeight,
|
||||
padding: closeStyle.padding,
|
||||
opacity: closeStyle.opacity,
|
||||
},
|
||||
secondaryStyle: {
|
||||
color: secondaryStyle.color,
|
||||
backgroundColor: secondaryStyle.backgroundColor,
|
||||
borderColor: secondaryStyle.borderColor,
|
||||
borderRadius: secondaryStyle.borderRadius,
|
||||
fontSize: secondaryStyle.fontSize,
|
||||
fontWeight: secondaryStyle.fontWeight,
|
||||
lineHeight: secondaryStyle.lineHeight,
|
||||
padding: secondaryStyle.padding,
|
||||
},
|
||||
applyStyle: {
|
||||
color: applyStyle.color,
|
||||
backgroundColor: applyStyle.backgroundColor,
|
||||
borderColor: applyStyle.borderColor,
|
||||
borderRadius: applyStyle.borderRadius,
|
||||
fontSize: applyStyle.fontSize,
|
||||
fontWeight: applyStyle.fontWeight,
|
||||
lineHeight: applyStyle.lineHeight,
|
||||
padding: applyStyle.padding,
|
||||
transitionDuration: applyStyle.transitionDuration,
|
||||
transitionProperty: applyStyle.transitionProperty,
|
||||
},
|
||||
backdropStyle: {
|
||||
transitionDuration: backdropStyle.transitionDuration,
|
||||
transitionProperty: backdropStyle.transitionProperty,
|
||||
},
|
||||
motionStyle: {
|
||||
transitionDuration: motionStyle.transitionDuration,
|
||||
transitionProperty: motionStyle.transitionProperty,
|
||||
transitionTimingFunction: motionStyle.transitionTimingFunction,
|
||||
},
|
||||
};
|
||||
});
|
||||
expect(geometry.dialog).toEqual({ x: 470, y: 28, width: 500, height: 224 });
|
||||
expect(geometry.header).toEqual({ x: 471, y: 29, width: 498, height: 93 });
|
||||
expect(geometry.title).toEqual({ x: 487, y: 45, width: 297, height: 60 });
|
||||
expect(geometry.body).toEqual({ x: 471, y: 122, width: 498, height: 56 });
|
||||
expect(geometry.footer).toEqual({ x: 471, y: 178, width: 498, height: 73 });
|
||||
expect(geometry.close).toEqual({ x: 927, y: 60, width: 26, height: 30 });
|
||||
expect(geometry.secondary).toEqual({ x: 805, y: 195, width: 54, height: 40 });
|
||||
expect(geometry.apply).toEqual({ x: 867, y: 195, width: 86, height: 40 });
|
||||
expect(geometry.dialogStyle).toMatchObject({
|
||||
color: 'rgb(255, 255, 255)',
|
||||
backgroundColor: 'rgb(48, 48, 48)',
|
||||
borderColor: 'rgb(68, 68, 68)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'none',
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
});
|
||||
expect(geometry.dialogStyle.fontFamily).toContain('Pretendard');
|
||||
expect(geometry.titleStyle).toEqual({ fontSize: '20px', fontWeight: '500', lineHeight: '30px' });
|
||||
expect(geometry.closeStyle).toEqual({
|
||||
color: 'rgb(255, 255, 255)',
|
||||
backgroundColor: 'rgb(107, 107, 107)',
|
||||
borderColor: 'rgb(255, 255, 255)',
|
||||
borderRadius: '0px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '400',
|
||||
lineHeight: '24px',
|
||||
padding: '1px 6px',
|
||||
opacity: '1',
|
||||
});
|
||||
expect(geometry.secondaryStyle).toEqual({
|
||||
color: 'rgb(255, 255, 255)',
|
||||
backgroundColor: 'rgb(68, 68, 68)',
|
||||
borderColor: 'rgb(61, 61, 61)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '700',
|
||||
lineHeight: '24px',
|
||||
padding: '6px 12px',
|
||||
});
|
||||
expect(geometry.applyStyle).toEqual({
|
||||
color: 'rgb(255, 255, 255)',
|
||||
backgroundColor: 'rgb(55, 90, 127)',
|
||||
borderColor: 'rgb(50, 81, 114)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '700',
|
||||
lineHeight: '24px',
|
||||
padding: '6px 12px',
|
||||
transitionDuration: '0.15s, 0.15s, 0.15s, 0.15s',
|
||||
transitionProperty: 'color, background-color, border-color, box-shadow',
|
||||
});
|
||||
expect(geometry.backdropStyle).toEqual({
|
||||
transitionDuration: '0.15s',
|
||||
transitionProperty: 'opacity',
|
||||
});
|
||||
expect(geometry.motionStyle).toEqual({
|
||||
transitionDuration: '0.3s',
|
||||
transitionProperty: 'transform',
|
||||
transitionTimingFunction: 'ease-out',
|
||||
});
|
||||
const apply = page.getByTestId('icon-server-apply');
|
||||
const baseBackground = await apply.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
await apply.hover();
|
||||
expect(await apply.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe(baseBackground);
|
||||
await page.getByTestId('icon-server-option-hwe:903').focus();
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(apply).toBeFocused();
|
||||
expect(
|
||||
await apply.evaluate((element) => ({
|
||||
outlineStyle: getComputedStyle(element).outlineStyle,
|
||||
outlineWidth: getComputedStyle(element).outlineWidth,
|
||||
}))
|
||||
).toEqual({ outlineStyle: 'none', outlineWidth: '0px' });
|
||||
const applyBox = await apply.boundingBox();
|
||||
expect(applyBox).not.toBeNull();
|
||||
await page.mouse.move(applyBox!.x + applyBox!.width / 2, applyBox!.y + applyBox!.height / 2);
|
||||
await page.mouse.down();
|
||||
expect(await apply.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe(baseBackground);
|
||||
await page.mouse.move(5, 5);
|
||||
await page.mouse.up();
|
||||
await page.mouse.click(5, 5);
|
||||
await expect(page.getByTestId('icon-server-modal')).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator('.icon-server-dialog').evaluate((element) => getComputedStyle(element).transform !== 'none')
|
||||
)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() => page.locator('.icon-server-dialog').evaluate((element) => getComputedStyle(element).transform))
|
||||
.toBe('none');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByTestId('icon-server-modal')).toBeVisible();
|
||||
});
|
||||
|
||||
test('contains focus and long failure content inside a 320px viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await installFixture(page, { failHweAdjustOnce: true });
|
||||
await page.goto('account');
|
||||
await uploadIcon(page);
|
||||
await page.getByTestId('icon-server-apply').click();
|
||||
await expect(page.getByTestId('icon-server-result-hwe:903')).toContainText('HWE 아이콘 적용 실패');
|
||||
|
||||
const containment = await page.evaluate(() => {
|
||||
const selectors = [
|
||||
'.icon-server-backdrop',
|
||||
'.icon-server-dialog',
|
||||
'.icon-server-header',
|
||||
'.icon-server-body',
|
||||
'.icon-server-footer',
|
||||
];
|
||||
const dialog = document.querySelector<HTMLElement>('.icon-server-dialog')!;
|
||||
const rect = dialog.getBoundingClientRect();
|
||||
return {
|
||||
rect: {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
},
|
||||
regionsFit: selectors.every((selector) => {
|
||||
const element = document.querySelector<HTMLElement>(selector)!;
|
||||
return element.scrollWidth <= element.clientWidth;
|
||||
}),
|
||||
focusInside: dialog.contains(document.activeElement),
|
||||
};
|
||||
});
|
||||
expect(containment.rect.left).toBeGreaterThanOrEqual(0);
|
||||
expect(containment.rect.right).toBeLessThanOrEqual(320);
|
||||
expect(containment.regionsFit).toBe(true);
|
||||
expect(containment.focusInside).toBe(true);
|
||||
|
||||
await page.getByRole('button', { name: '아이콘 변경' }).focus();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
document.querySelector<HTMLElement>('.icon-server-dialog')?.contains(document.activeElement)
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('matches the Ref one-server modal geometry at 320px', async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await installFixture(page);
|
||||
await page.goto('account');
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await page.getByRole('button', { name: '아이콘 제거' }).click();
|
||||
await expect(page.getByTestId('icon-server-modal')).toBeVisible();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.locator('.icon-server-dialog').screenshot({
|
||||
path: testInfo.outputPath('core-icon-modal-mobile.png'),
|
||||
animations: 'disabled',
|
||||
});
|
||||
|
||||
const geometry = await page.evaluate(() => {
|
||||
const rect = (selector: string) => {
|
||||
const value = document.querySelector<HTMLElement>(selector)!.getBoundingClientRect();
|
||||
return { x: value.x, y: value.y, width: value.width, height: value.height };
|
||||
};
|
||||
const textLines = (selector: string) => {
|
||||
const root = document.querySelector(selector)!;
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
const lines = new Map<number, string>();
|
||||
let node = walker.nextNode();
|
||||
while (node) {
|
||||
for (let offset = 0; offset < (node.textContent?.length ?? 0); offset += 1) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, offset);
|
||||
range.setEnd(node, offset + 1);
|
||||
const box = range.getBoundingClientRect();
|
||||
if (box.width > 0) {
|
||||
const top = Math.round(box.top);
|
||||
lines.set(top, `${lines.get(top) ?? ''}${node.textContent?.[offset] ?? ''}`);
|
||||
}
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
return [...lines.entries()].sort(([left], [right]) => left - right).map(([, text]) => text);
|
||||
};
|
||||
return {
|
||||
dialog: rect('.icon-server-dialog'),
|
||||
header: rect('.icon-server-header'),
|
||||
title: rect('.icon-server-header h2'),
|
||||
body: rect('.icon-server-body'),
|
||||
footer: rect('.icon-server-footer'),
|
||||
close: rect('[data-testid="icon-server-close"]'),
|
||||
apply: rect('[data-testid="icon-server-apply"]'),
|
||||
titleLines: textLines('.icon-server-header h2'),
|
||||
titleFontMetrics: (() => {
|
||||
const style = getComputedStyle(document.querySelector<HTMLElement>('.icon-server-header h2')!);
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d')!;
|
||||
context.font = style.font;
|
||||
return {
|
||||
font: context.font,
|
||||
textWidth: context.measureText('새 아이콘을 적용할 서버를 선택하세요.').width,
|
||||
selectionPrefixWidth: context.measureText('새 아이콘을 적용할 서버를 선택').width,
|
||||
};
|
||||
})(),
|
||||
};
|
||||
});
|
||||
expect(geometry).toEqual({
|
||||
dialog: { x: 8, y: 8, width: 304, height: 254 },
|
||||
header: { x: 9, y: 9, width: 302, height: 123 },
|
||||
title: { x: 25, y: 25, width: 244, height: 90 },
|
||||
body: { x: 9, y: 132, width: 302, height: 56 },
|
||||
footer: { x: 9, y: 188, width: 302, height: 73 },
|
||||
close: { x: 269, y: 55, width: 26, height: 30 },
|
||||
apply: { x: 209, y: 205, width: 86, height: 40 },
|
||||
titleLines: ['완료되었습니다.', '새 아이콘을 적용할 서버를 선택', '하세요.'],
|
||||
titleFontMetrics: {
|
||||
font: '500 20px Pretendard, "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic"',
|
||||
textWidth: 297,
|
||||
selectionPrefixWidth: 241,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('reopens server synchronization without consuming the daily icon change', async ({ page }) => {
|
||||
await installFixture(page);
|
||||
await page.goto('account');
|
||||
await page.getByRole('button', { name: '현재 아이콘 서버 적용' }).click();
|
||||
|
||||
await expect(page.getByTestId('icon-server-modal')).toBeVisible();
|
||||
await expect(page.getByTestId('icon-server-option-che:903')).toBeChecked();
|
||||
await expect(page.getByTestId('icon-server-option-hwe:903')).toBeChecked();
|
||||
});
|
||||
@@ -100,7 +100,8 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
|
||||
});
|
||||
await fulfillTrpc(route, results);
|
||||
});
|
||||
await page.route('http://localhost:15015/api/trpc/**', async (route) => {
|
||||
await page.route('**/hwe/api/trpc/**', async (route) => {
|
||||
expect(new URL(route.request().url()).pathname).toContain('/hwe/api/trpc/');
|
||||
const authorization = route.request().headers().authorization;
|
||||
const results = operationNames(route).map((operation) => {
|
||||
gameOperations.push({ operation, authorization });
|
||||
|
||||
@@ -12,6 +12,7 @@ export default defineConfig({
|
||||
'lobby-admin-navigation.spec.ts',
|
||||
'lobby-game-auth.spec.ts',
|
||||
'logout.spec.ts',
|
||||
'account-icon-sync.spec.ts',
|
||||
],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
@@ -31,7 +32,7 @@ export default defineConfig({
|
||||
},
|
||||
webServer: {
|
||||
command:
|
||||
"VITE_APP_BASE_PATH=/gateway VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' pnpm --filter @sammo-ts/gateway-frontend preview --host 127.0.0.1 --port 15130",
|
||||
"export VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' VITE_GAME_API_URL_TEMPLATE='/{profile}/api/trpc'; pnpm --filter @sammo-ts/gateway-frontend build && pnpm --filter @sammo-ts/gateway-frontend preview --host 127.0.0.1 --port 15130",
|
||||
cwd: repositoryRoot,
|
||||
url: 'http://127.0.0.1:15130/gateway/',
|
||||
reuseExistingServer: false,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>삼국지 모의전투 HiDCHe - Gateway</title>
|
||||
</head>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"test:e2e:operations": "VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' pnpm build && playwright test --config e2e/playwright.config.mjs",
|
||||
"test:e2e:operations": "playwright test --config e2e/playwright.config.mjs",
|
||||
"test:e2e:hwe-lifecycle": "playwright test --config e2e/hwe-lifecycle.playwright.config.mjs",
|
||||
"test:e2e:general-icons": "playwright test --config e2e/general-icon-lifecycle.playwright.config.mjs",
|
||||
"build": "vue-tsc && vite build",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||
import { createGameTrpc } from '../utils/gameTrpc';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { sealPassword } from '../utils/passwordEnvelope';
|
||||
|
||||
type Account = Awaited<ReturnType<typeof trpc.account.get.query>>;
|
||||
type IconSyncProfile = Awaited<ReturnType<typeof trpc.account.changeIcon.mutate>>['profiles'][number];
|
||||
type IconSyncState = 'idle' | 'pending' | 'success' | 'error';
|
||||
type IconSyncRow = IconSyncProfile & {
|
||||
selected: boolean;
|
||||
state: IconSyncState;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const account = ref<Account | null>(null);
|
||||
@@ -20,6 +28,15 @@ const newPasswordConfirm = ref('');
|
||||
const deletePassword = ref('');
|
||||
const iconData = ref('');
|
||||
const iconFilename = ref('');
|
||||
const iconServerModalOpen = ref(false);
|
||||
const iconServerBusy = ref(false);
|
||||
const iconServerStaticFeedback = ref(false);
|
||||
const iconServerMessage = ref('');
|
||||
const iconServerRows = ref<IconSyncRow[]>([]);
|
||||
const iconServerDialog = ref<HTMLElement | null>(null);
|
||||
let iconServerReturnFocus: HTMLElement | null = null;
|
||||
let previousBodyOverflow = '';
|
||||
let iconServerStaticTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const sessionToken = (): string | null => window.localStorage.getItem('sammo-session-token');
|
||||
|
||||
@@ -29,6 +46,11 @@ const gradeLabel = computed(() => {
|
||||
return '일반회원';
|
||||
});
|
||||
|
||||
const selectedIconServerCount = computed(
|
||||
() => iconServerRows.value.filter((row) => row.selected && row.state !== 'success').length
|
||||
);
|
||||
const failedIconServerCount = computed(() => iconServerRows.value.filter((row) => row.state === 'error').length);
|
||||
|
||||
const runAction = async (action: () => Promise<void>): Promise<void> => {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
@@ -108,6 +130,148 @@ const scheduleDeletion = async (): Promise<void> => {
|
||||
});
|
||||
};
|
||||
|
||||
const focusIconServerModal = async (preferDialog = false): Promise<void> => {
|
||||
await nextTick();
|
||||
if (preferDialog) {
|
||||
iconServerDialog.value?.focus();
|
||||
return;
|
||||
}
|
||||
const target =
|
||||
iconServerDialog.value?.querySelector<HTMLElement>('input:not(:disabled)') ??
|
||||
iconServerDialog.value?.querySelector<HTMLElement>('button:not(:disabled)');
|
||||
(target ?? iconServerDialog.value)?.focus();
|
||||
};
|
||||
|
||||
const handleIconServerFocusIn = (event: FocusEvent): void => {
|
||||
if (!iconServerModalOpen.value || !iconServerDialog.value) return;
|
||||
if (event.target instanceof Node && iconServerDialog.value.contains(event.target)) return;
|
||||
void focusIconServerModal(iconServerBusy.value);
|
||||
};
|
||||
|
||||
const openIconServerModal = (profiles: IconSyncProfile[], returnFocus?: HTMLElement | null): void => {
|
||||
iconServerReturnFocus =
|
||||
returnFocus ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null);
|
||||
iconServerRows.value = profiles.map((profile) => ({
|
||||
...profile,
|
||||
selected: true,
|
||||
state: 'idle',
|
||||
errorMessage: '',
|
||||
}));
|
||||
iconServerMessage.value = profiles.length === 0 ? '현재 아이콘을 적용할 수 있는 실행 중인 서버가 없습니다.' : '';
|
||||
iconServerModalOpen.value = true;
|
||||
previousBodyOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.addEventListener('focusin', handleIconServerFocusIn);
|
||||
void focusIconServerModal(true);
|
||||
};
|
||||
|
||||
const closeIconServerModal = (): void => {
|
||||
if (iconServerBusy.value) return;
|
||||
iconServerModalOpen.value = false;
|
||||
document.removeEventListener('focusin', handleIconServerFocusIn);
|
||||
document.body.style.overflow = previousBodyOverflow;
|
||||
const returnFocus = iconServerReturnFocus;
|
||||
iconServerReturnFocus = null;
|
||||
void nextTick(() => returnFocus?.focus());
|
||||
};
|
||||
|
||||
const showIconServerStaticFeedback = async (): Promise<void> => {
|
||||
if (iconServerStaticTimer) {
|
||||
clearTimeout(iconServerStaticTimer);
|
||||
}
|
||||
iconServerStaticFeedback.value = false;
|
||||
await nextTick();
|
||||
iconServerStaticFeedback.value = true;
|
||||
iconServerStaticTimer = setTimeout(() => {
|
||||
iconServerStaticFeedback.value = false;
|
||||
iconServerStaticTimer = null;
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleIconServerModalKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
void showIconServerStaticFeedback();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab' || !iconServerDialog.value) return;
|
||||
const focusable = Array.from(
|
||||
iconServerDialog.value.querySelectorAll<HTMLElement>('input:not(:disabled), button:not(:disabled)')
|
||||
);
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
iconServerDialog.value.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
if (!first || !last) return;
|
||||
if (!iconServerDialog.value.contains(document.activeElement)) {
|
||||
event.preventDefault();
|
||||
(event.shiftKey ? last : first).focus();
|
||||
} else if (
|
||||
event.shiftKey &&
|
||||
(document.activeElement === first || document.activeElement === iconServerDialog.value)
|
||||
) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const syncIconToServer = async (row: IconSyncRow, token: string): Promise<void> => {
|
||||
row.state = 'pending';
|
||||
row.errorMessage = '';
|
||||
try {
|
||||
const issued = await trpc.auth.issueGameSession.mutate({
|
||||
sessionToken: token,
|
||||
profile: row.profileName,
|
||||
});
|
||||
const publicGameTrpc = createGameTrpc(row.profile, row.apiPort);
|
||||
const exchanged = await publicGameTrpc.auth.exchangeGatewayToken.mutate({
|
||||
gatewayToken: issued.gameToken,
|
||||
});
|
||||
const gameTrpc = createGameTrpc(row.profile, row.apiPort, exchanged.accessToken);
|
||||
await gameTrpc.general.adjustIcon.mutate();
|
||||
row.state = 'success';
|
||||
} catch (error) {
|
||||
row.state = 'error';
|
||||
row.errorMessage = error instanceof Error ? error.message : '서버 적용에 실패했습니다.';
|
||||
}
|
||||
};
|
||||
|
||||
const applyIconToSelectedServers = async (retryFailedOnly = false): Promise<void> => {
|
||||
if (iconServerBusy.value) return;
|
||||
const targets = iconServerRows.value.filter(
|
||||
(row) => row.selected && row.state !== 'success' && (!retryFailedOnly || row.state === 'error')
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
iconServerMessage.value = retryFailedOnly ? '재시도할 실패 서버가 없습니다.' : '적용할 서버를 선택해 주세요.';
|
||||
return;
|
||||
}
|
||||
const token = sessionToken();
|
||||
if (!token) {
|
||||
iconServerMessage.value = '로그인이 필요합니다.';
|
||||
return;
|
||||
}
|
||||
iconServerBusy.value = true;
|
||||
iconServerMessage.value = '';
|
||||
await focusIconServerModal(true);
|
||||
try {
|
||||
await Promise.all(targets.map((row) => syncIconToServer(row, token)));
|
||||
const failed = targets.filter((row) => row.state === 'error').length;
|
||||
iconServerMessage.value =
|
||||
failed === 0
|
||||
? '선택한 서버에 아이콘을 적용했습니다.'
|
||||
: `${failed}개 서버에 적용하지 못했습니다. 실패한 서버만 다시 시도할 수 있습니다.`;
|
||||
} finally {
|
||||
iconServerBusy.value = false;
|
||||
await focusIconServerModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const selectIcon = async (event: Event): Promise<void> => {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
@@ -126,7 +290,8 @@ const selectIcon = async (event: Event): Promise<void> => {
|
||||
});
|
||||
};
|
||||
|
||||
const changeIcon = async (): Promise<void> => {
|
||||
const changeIcon = async (event?: Event): Promise<void> => {
|
||||
const returnFocus = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||
await runAction(async () => {
|
||||
const token = sessionToken();
|
||||
if (!token) throw new Error('로그인이 필요합니다.');
|
||||
@@ -135,23 +300,51 @@ const changeIcon = async (): Promise<void> => {
|
||||
if (account.value) account.value = { ...account.value, iconUrl: result.iconUrl };
|
||||
iconData.value = '';
|
||||
iconFilename.value = '';
|
||||
successMessage.value = '전용 아이콘을 변경했습니다.';
|
||||
successMessage.value = result.flushPublished
|
||||
? '전용 아이콘을 변경했습니다.'
|
||||
: '전용 아이콘을 변경했습니다. 로그인 갱신 알림은 지연될 수 있습니다.';
|
||||
openIconServerModal(result.profiles, returnFocus);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteIcon = async (): Promise<void> => {
|
||||
const deleteIcon = async (event?: Event): Promise<void> => {
|
||||
const returnFocus = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||
if (!window.confirm('아이콘을 제거할까요?')) return;
|
||||
await runAction(async () => {
|
||||
const token = sessionToken();
|
||||
if (!token) throw new Error('로그인이 필요합니다.');
|
||||
await trpc.account.deleteIcon.mutate({ sessionToken: token });
|
||||
const result = await trpc.account.deleteIcon.mutate({ sessionToken: token });
|
||||
if (account.value) account.value = { ...account.value, iconUrl: null };
|
||||
successMessage.value = '전용 아이콘을 제거했습니다.';
|
||||
successMessage.value = result.flushPublished
|
||||
? '전용 아이콘을 제거했습니다.'
|
||||
: '전용 아이콘을 제거했습니다. 로그인 갱신 알림은 지연될 수 있습니다.';
|
||||
openIconServerModal(result.profiles, returnFocus);
|
||||
});
|
||||
};
|
||||
|
||||
const prepareIconSync = async (event?: Event): Promise<void> => {
|
||||
const returnFocus = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||
await runAction(async () => {
|
||||
const token = sessionToken();
|
||||
if (!token) throw new Error('로그인이 필요합니다.');
|
||||
const result = await trpc.account.prepareIconSync.query({ sessionToken: token });
|
||||
openIconServerModal(result.profiles, returnFocus);
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void loadAccount();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('focusin', handleIconServerFocusIn);
|
||||
if (iconServerModalOpen.value) {
|
||||
document.body.style.overflow = previousBodyOverflow;
|
||||
}
|
||||
if (iconServerStaticTimer) {
|
||||
clearTimeout(iconServerStaticTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -265,24 +458,41 @@ onMounted(() => {
|
||||
<tr>
|
||||
<th class="legacy-bg1">전용<br />아이콘</th>
|
||||
<td class="icon-preview" colspan="2">
|
||||
<img v-if="account.iconUrl" :src="account.iconUrl" width="64" height="64" alt="현재 아이콘" />
|
||||
<img
|
||||
v-if="account.iconUrl"
|
||||
:src="account.iconUrl"
|
||||
width="64"
|
||||
height="64"
|
||||
alt="현재 아이콘"
|
||||
/>
|
||||
<span v-else>기본 아이콘</span>
|
||||
<img v-if="iconData" :src="iconData" width="64" height="64" alt="새 아이콘 미리보기" />
|
||||
</td>
|
||||
<td class="icon-actions" colspan="3">
|
||||
<input class="skin-input filename" :value="iconFilename" readonly aria-label="선택한 아이콘" />
|
||||
<input
|
||||
class="skin-input filename"
|
||||
:value="iconFilename"
|
||||
readonly
|
||||
aria-label="선택한 아이콘"
|
||||
/>
|
||||
<label class="skin-button file-button">
|
||||
찾아보기
|
||||
<input
|
||||
type="file"
|
||||
accept=".avif,.webp,.jpg,.jpeg,.png,.gif"
|
||||
@change="selectIcon"
|
||||
/>
|
||||
<input type="file" accept=".avif,.webp,.jpg,.jpeg,.png,.gif" @change="selectIcon" />
|
||||
</label>
|
||||
<button class="skin-button half-button" type="button" :disabled="busy" @click="changeIcon">
|
||||
<button
|
||||
class="skin-button half-button"
|
||||
type="button"
|
||||
:disabled="busy || iconServerBusy"
|
||||
@click="changeIcon"
|
||||
>
|
||||
아이콘 변경
|
||||
</button>
|
||||
<button class="skin-button half-button" type="button" :disabled="busy" @click="deleteIcon">
|
||||
<button
|
||||
class="skin-button half-button"
|
||||
type="button"
|
||||
:disabled="busy || iconServerBusy"
|
||||
@click="deleteIcon"
|
||||
>
|
||||
아이콘 제거
|
||||
</button>
|
||||
</td>
|
||||
@@ -298,6 +508,16 @@ onMounted(() => {
|
||||
<th class="legacy-bg1">도움말</th>
|
||||
<td colspan="5" class="help-cell">
|
||||
<p>아이콘은 64 x 64픽셀 ~ 128 x 128픽셀 사이, 50KB 이하 파일만 가능합니다.</p>
|
||||
<p>
|
||||
<button
|
||||
class="skin-button"
|
||||
type="button"
|
||||
:disabled="busy || iconServerBusy"
|
||||
@click="prepareIconSync"
|
||||
>
|
||||
현재 아이콘 서버 적용
|
||||
</button>
|
||||
</p>
|
||||
<p class="warning">탈퇴시 1개월간 정보가 보존되며, 1개월간 재가입이 불가능합니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -306,6 +526,109 @@ onMounted(() => {
|
||||
<p v-if="successMessage" class="feedback success" role="status">{{ successMessage }}</p>
|
||||
<p v-if="errorMessage" class="feedback error" role="alert">{{ errorMessage }}</p>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<Transition name="icon-server-modal">
|
||||
<div
|
||||
v-if="iconServerModalOpen"
|
||||
class="icon-server-backdrop"
|
||||
:class="{ 'is-static': iconServerStaticFeedback }"
|
||||
data-testid="icon-server-modal"
|
||||
@click.self="showIconServerStaticFeedback"
|
||||
@keydown="handleIconServerModalKeydown"
|
||||
>
|
||||
<section
|
||||
ref="iconServerDialog"
|
||||
class="icon-server-dialog"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
:aria-busy="iconServerBusy"
|
||||
aria-modal="true"
|
||||
aria-labelledby="icon-server-title"
|
||||
>
|
||||
<header class="icon-server-header">
|
||||
<h2 id="icon-server-title">완료되었습니다.<br />새 아이콘을 적용할 서버를 선택하세요.</h2>
|
||||
<button
|
||||
class="icon-server-dismiss"
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
data-testid="icon-server-close"
|
||||
:disabled="iconServerBusy"
|
||||
@click="closeIconServerModal"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div class="icon-server-body">
|
||||
<form class="icon-server-form" @submit.prevent="applyIconToSelectedServers(false)">
|
||||
<label
|
||||
v-for="(row, index) in iconServerRows"
|
||||
:key="row.profileName"
|
||||
class="icon-server-option"
|
||||
:for="`icon-server-${index}`"
|
||||
>
|
||||
<input
|
||||
:id="`icon-server-${index}`"
|
||||
v-model="row.selected"
|
||||
type="checkbox"
|
||||
:disabled="iconServerBusy || row.state === 'success'"
|
||||
:data-testid="`icon-server-option-${row.profileName}`"
|
||||
/>
|
||||
<span>{{ row.korName }}</span>
|
||||
<span
|
||||
class="icon-server-result"
|
||||
:class="`is-${row.state}`"
|
||||
:data-testid="`icon-server-result-${row.profileName}`"
|
||||
>
|
||||
<template v-if="row.state === 'pending'">적용 중...</template>
|
||||
<template v-else-if="row.state === 'success'">적용됨</template>
|
||||
<template v-else-if="row.state === 'error'">
|
||||
실패<span v-if="row.errorMessage">: {{ row.errorMessage }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</label>
|
||||
</form>
|
||||
<p
|
||||
v-if="iconServerMessage"
|
||||
class="icon-server-message"
|
||||
:class="{ 'is-error': failedIconServerCount > 0 }"
|
||||
role="status"
|
||||
>
|
||||
{{ iconServerMessage }}
|
||||
</p>
|
||||
</div>
|
||||
<footer class="icon-server-footer">
|
||||
<button
|
||||
class="modal-button secondary"
|
||||
type="button"
|
||||
:disabled="iconServerBusy"
|
||||
@click="closeIconServerModal"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
<button
|
||||
v-if="failedIconServerCount > 0"
|
||||
class="modal-button retry"
|
||||
type="button"
|
||||
data-testid="icon-server-retry"
|
||||
:disabled="iconServerBusy"
|
||||
@click="applyIconToSelectedServers(true)"
|
||||
>
|
||||
실패 서버 재시도
|
||||
</button>
|
||||
<button
|
||||
class="modal-button primary"
|
||||
type="button"
|
||||
data-testid="icon-server-apply"
|
||||
:disabled="iconServerBusy || selectedIconServerCount === 0"
|
||||
@click="applyIconToSelectedServers(false)"
|
||||
>
|
||||
{{ iconServerBusy ? '적용 중...' : '서버 적용' }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</DefaultLayout>
|
||||
</template>
|
||||
|
||||
@@ -535,9 +858,225 @@ onMounted(() => {
|
||||
color: #ff9c9c;
|
||||
}
|
||||
|
||||
.icon-server-backdrop {
|
||||
position: fixed;
|
||||
z-index: 1050;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
background: rgb(0 0 0 / 50%);
|
||||
padding: 0 16px;
|
||||
transition: opacity 0.15s linear;
|
||||
}
|
||||
|
||||
.icon-server-modal-enter-from,
|
||||
.icon-server-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.icon-server-dialog {
|
||||
width: 500px;
|
||||
max-width: calc(100vw - 32px);
|
||||
margin: 28px auto;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
background: #303030;
|
||||
color: #fff;
|
||||
font-family: Pretendard, 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic';
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
text-align: left;
|
||||
transform: none;
|
||||
transition: transform 0.3s ease-out;
|
||||
}
|
||||
|
||||
.icon-server-modal-enter-from .icon-server-dialog,
|
||||
.icon-server-modal-leave-to .icon-server-dialog {
|
||||
transform: translateY(-50px);
|
||||
}
|
||||
|
||||
.icon-server-backdrop.is-static .icon-server-dialog {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.icon-server-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
border-bottom: 1px solid #444;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.icon-server-header h2 {
|
||||
width: 297px;
|
||||
flex: 0 0 297px;
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon-server-dismiss {
|
||||
width: 26px;
|
||||
height: 30px;
|
||||
margin: auto 0 auto auto;
|
||||
border: 2px outset #fff;
|
||||
border-radius: 0;
|
||||
background: #6b6b6b;
|
||||
padding: 1px 6px;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.icon-server-dismiss:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.icon-server-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.icon-server-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.icon-server-option {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
margin-right: 7px;
|
||||
cursor: pointer;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.icon-server-option input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 3px 0 0;
|
||||
}
|
||||
|
||||
.icon-server-option:has(input:disabled) {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.icon-server-result {
|
||||
max-width: 260px;
|
||||
color: #aaa;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.icon-server-result.is-success {
|
||||
color: #9cff9c;
|
||||
}
|
||||
|
||||
.icon-server-result.is-error,
|
||||
.icon-server-message.is-error {
|
||||
color: #ff9c9c;
|
||||
}
|
||||
|
||||
.icon-server-message {
|
||||
margin: 14px 0 0;
|
||||
color: #ddd;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.icon-server-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
border-top: 1px solid #444;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-button {
|
||||
border: 1px solid transparent;
|
||||
min-height: 40px;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color 0.15s ease-in-out,
|
||||
background-color 0.15s ease-in-out,
|
||||
border-color 0.15s ease-in-out,
|
||||
box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.modal-button.secondary {
|
||||
width: 54px;
|
||||
border-color: #3d3d3d;
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.modal-button.primary {
|
||||
width: 86px;
|
||||
border-color: #325172;
|
||||
background: #375a7f;
|
||||
}
|
||||
|
||||
.modal-button.retry {
|
||||
border-color: #a65f00;
|
||||
background: #a65f00;
|
||||
}
|
||||
|
||||
.modal-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.modal-button:focus,
|
||||
.icon-server-dismiss:focus,
|
||||
.icon-server-option input:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#account-container {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.icon-server-backdrop {
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.icon-server-dialog {
|
||||
max-width: calc(100vw - 16px);
|
||||
margin: 8px auto;
|
||||
}
|
||||
|
||||
.icon-server-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.icon-server-header h2 {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.icon-server-dismiss {
|
||||
flex: 0 0 26px;
|
||||
}
|
||||
|
||||
.icon-server-result,
|
||||
.icon-server-message {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,6 @@ type AdminUserSanctions = {
|
||||
warningCount?: number;
|
||||
flags?: string[];
|
||||
notes?: string;
|
||||
profileIconResetAt?: string;
|
||||
serverRestrictions?: Record<
|
||||
string,
|
||||
{
|
||||
@@ -29,7 +28,6 @@ type AdminSanctionsPatch = {
|
||||
warningCount?: number | null;
|
||||
flags?: string[] | null;
|
||||
notes?: string | null;
|
||||
profileIconResetAt?: string | null;
|
||||
serverRestrictions?: Record<
|
||||
string,
|
||||
{
|
||||
@@ -50,6 +48,7 @@ type AdminUser = {
|
||||
oauthType: string;
|
||||
oauthId?: string;
|
||||
email?: string;
|
||||
profileIconResetAt?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -188,7 +187,10 @@ type AdminClient = {
|
||||
}) => Promise<{ sanctions: AdminUserSanctions }>;
|
||||
};
|
||||
resetProfileIcon: {
|
||||
mutate: (input: { userId: string }) => Promise<{ profileIconResetAt?: string }>;
|
||||
mutate: (input: { userId: string }) => Promise<{
|
||||
profileIconResetAt: string;
|
||||
flushPublished: boolean;
|
||||
}>;
|
||||
};
|
||||
forceDelete: {
|
||||
mutate: (input: { userId: string }) => Promise<{ ok: boolean }>;
|
||||
@@ -971,12 +973,11 @@ const resetProfileIcon = async () => {
|
||||
});
|
||||
userResult.value = {
|
||||
...userResult.value,
|
||||
sanctions: {
|
||||
...userResult.value.sanctions,
|
||||
profileIconResetAt: result.profileIconResetAt,
|
||||
},
|
||||
profileIconResetAt: result.profileIconResetAt,
|
||||
};
|
||||
profileIconStatus.value = '아이콘 초기화 요청 완료';
|
||||
profileIconStatus.value = result.flushPublished
|
||||
? '아이콘 초기화 요청 완료'
|
||||
: '아이콘은 초기화됐지만 실행 중 서버 알림에 실패했습니다. 다시 요청해 주세요.';
|
||||
} catch (error) {
|
||||
profileIconStatus.value = '아이콘 초기화 실패';
|
||||
}
|
||||
|
||||
@@ -43,18 +43,27 @@ const userIconBaseUrl = import.meta.env.VITE_GATEWAY_USER_ICON_BASE_URL ?? '/gat
|
||||
|
||||
const formatGraceEndsAt = (value: string | null | undefined): string =>
|
||||
value ? new Date(value).toLocaleString('ko-KR') : '';
|
||||
const encodeLegacyIconPath = (value: string): string =>
|
||||
value
|
||||
.split('/')
|
||||
.map((segment) => {
|
||||
if (segment === '.') return '%2E';
|
||||
if (segment === '..') return '%2E%2E';
|
||||
return encodeURIComponent(segment);
|
||||
})
|
||||
.join('/');
|
||||
const resolveGeneralPicture = (general: LobbyGeneral): string => {
|
||||
const picture = general.picture?.trim() || 'default.jpg';
|
||||
return general.imageServer
|
||||
? `${userIconBaseUrl.replace(/\/$/, '')}/${encodeURIComponent(picture)}`
|
||||
: `/image/icons/${encodeURIComponent(picture)}`;
|
||||
: `/image/icons/${encodeLegacyIconPath(picture)}`;
|
||||
};
|
||||
const handleGeneralPictureError = (event: Event): void => {
|
||||
const image = event.currentTarget as HTMLImageElement;
|
||||
if (image.dataset.fallbackApplied === 'true') {
|
||||
if (image.dataset.generalIconFallbackSource === image.currentSrc) {
|
||||
return;
|
||||
}
|
||||
image.dataset.fallbackApplied = 'true';
|
||||
image.dataset.generalIconFallbackSource = image.currentSrc;
|
||||
image.src = '/image/icons/default.jpg';
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user