feat: add user icon library and in-game selection
This commit is contained in:
@@ -62,6 +62,8 @@ const activeProfiles = [
|
||||
|
||||
const installFixture = async (page: Page, options: FixtureOptions = {}) => {
|
||||
let deleteIconCount = 0;
|
||||
let preferredIconCount = 0;
|
||||
let retireIconCount = 0;
|
||||
let hweAdjustCount = 0;
|
||||
const operations = new Map<string, string[]>([
|
||||
['che:903', []],
|
||||
@@ -83,6 +85,28 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
|
||||
oauthType: 'NONE',
|
||||
createdAt: '2026-07-30T00:00:00.000Z',
|
||||
iconUrl: '/gateway/api/user-icons/old.png',
|
||||
icons: [
|
||||
{
|
||||
id: '3f804277-584f-4f44-b39c-9ecf40d1ed31',
|
||||
picture: 'old.png',
|
||||
imageServer: 1,
|
||||
createdAt: '2026-07-30T00:00:00.000Z',
|
||||
retiredAt: null,
|
||||
url: '/gateway/api/user-icons/old.png',
|
||||
},
|
||||
{
|
||||
id: '9bc328b0-3fc8-44ec-a845-287e438e8edf',
|
||||
picture: 'second.png',
|
||||
imageServer: 1,
|
||||
createdAt: '2026-07-31T00:00:00.000Z',
|
||||
retiredAt: null,
|
||||
url: '/gateway/api/user-icons/second.png',
|
||||
},
|
||||
],
|
||||
preferredPicture: 'old.png',
|
||||
maxActiveIcons: 5,
|
||||
nextUploadAt: null,
|
||||
nextRetireAt: null,
|
||||
thirdPartyUse: false,
|
||||
deleteAfter: null,
|
||||
});
|
||||
@@ -96,6 +120,20 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
|
||||
flushPublished: true,
|
||||
});
|
||||
}
|
||||
if (operation === 'account.setPreferredIcon') {
|
||||
preferredIconCount += 1;
|
||||
return response({ ok: true, revision: '2026-08-01T00:00:00.001Z', flushPublished: true });
|
||||
}
|
||||
if (operation === 'account.retireIcon') {
|
||||
retireIconCount += 1;
|
||||
return response({
|
||||
ok: true,
|
||||
revision: '2026-08-01T00:00:00.002Z',
|
||||
preferredChanged: false,
|
||||
iconUrl: '/gateway/api/user-icons/old.png',
|
||||
flushPublished: true,
|
||||
});
|
||||
}
|
||||
if (operation === 'account.deleteIcon') {
|
||||
deleteIconCount += 1;
|
||||
return response({
|
||||
@@ -182,9 +220,28 @@ const installFixture = async (page: Page, options: FixtureOptions = {}) => {
|
||||
return {
|
||||
operations,
|
||||
deleteIconCount: () => deleteIconCount,
|
||||
preferredIconCount: () => preferredIconCount,
|
||||
retireIconCount: () => retireIconCount,
|
||||
};
|
||||
};
|
||||
|
||||
test('chooses a preferred library icon and retires an icon only after confirmation', async ({ page }) => {
|
||||
const fixture = await installFixture(page);
|
||||
await page.goto('account');
|
||||
await expect(page.locator('.account-icon-card')).toHaveCount(2);
|
||||
await expect(page.getByText('2 / 5개')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '대표로 설정' }).click();
|
||||
await expect.poll(fixture.preferredIconCount).toBe(1);
|
||||
|
||||
page.once('dialog', async (dialog) => {
|
||||
expect(dialog.message()).toContain('과거 기록의 이미지는 보존됩니다');
|
||||
await dialog.accept();
|
||||
});
|
||||
await page.getByRole('button', { name: '목록에서 내리기' }).first().click();
|
||||
await expect.poll(fixture.retireIconCount).toBe(1);
|
||||
});
|
||||
|
||||
const uploadIcon = async (page: Page): Promise<void> => {
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: 'new-icon.png',
|
||||
|
||||
@@ -303,10 +303,36 @@ const changeIcon = async (event?: Event): Promise<void> => {
|
||||
successMessage.value = result.flushPublished
|
||||
? '전용 아이콘을 변경했습니다.'
|
||||
: '전용 아이콘을 변경했습니다. 로그인 갱신 알림은 지연될 수 있습니다.';
|
||||
await loadAccount();
|
||||
openIconServerModal(result.profiles, returnFocus);
|
||||
});
|
||||
};
|
||||
|
||||
const setPreferredIcon = async (iconId: string): Promise<void> => {
|
||||
await runAction(async () => {
|
||||
const token = sessionToken();
|
||||
if (!token) throw new Error('로그인이 필요합니다.');
|
||||
await trpc.account.setPreferredIcon.mutate({ sessionToken: token, iconId });
|
||||
successMessage.value = '대표 전용 아이콘을 변경했습니다.';
|
||||
await loadAccount();
|
||||
});
|
||||
};
|
||||
|
||||
const retireIcon = async (iconId: string): Promise<void> => {
|
||||
if (
|
||||
!window.confirm('목록에서 내린 아이콘은 다시 선택할 수 없습니다. 과거 기록의 이미지는 보존됩니다. 계속할까요?')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await runAction(async () => {
|
||||
const token = sessionToken();
|
||||
if (!token) throw new Error('로그인이 필요합니다.');
|
||||
await trpc.account.retireIcon.mutate({ sessionToken: token, iconId });
|
||||
successMessage.value = '전용 아이콘을 목록에서 내렸습니다. 기존 URL과 과거 기록은 보존됩니다.';
|
||||
await loadAccount();
|
||||
});
|
||||
};
|
||||
|
||||
const deleteIcon = async (event?: Event): Promise<void> => {
|
||||
const returnFocus = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||
if (!window.confirm('아이콘을 제거할까요?')) return;
|
||||
@@ -497,6 +523,41 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="legacy-bg1">전콘<br />목록</th>
|
||||
<td colspan="5">
|
||||
<div class="account-icon-library">
|
||||
<div v-for="icon in account.icons" :key="icon.id" class="account-icon-card">
|
||||
<img :src="icon.url" width="64" height="64" alt="전용 아이콘" />
|
||||
<span v-if="icon.picture === account.preferredPicture" class="preferred-label"
|
||||
>대표</span
|
||||
>
|
||||
<button
|
||||
v-else
|
||||
class="skin-button compact"
|
||||
type="button"
|
||||
:disabled="busy"
|
||||
@click="setPreferredIcon(icon.id)"
|
||||
>
|
||||
대표로 설정
|
||||
</button>
|
||||
<button
|
||||
class="skin-button compact"
|
||||
type="button"
|
||||
:disabled="busy"
|
||||
@click="retireIcon(icon.id)"
|
||||
>
|
||||
목록에서 내리기
|
||||
</button>
|
||||
</div>
|
||||
<span v-if="account.icons.length === 0">등록한 전용 아이콘이 없습니다.</span>
|
||||
</div>
|
||||
<p class="icon-policy">
|
||||
{{ account.icons.length }} / {{ account.maxActiveIcons }}개 · 업로드는 24시간에 1회 ·
|
||||
목록에서 내리기는 7일에 1회
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody v-else>
|
||||
<tr>
|
||||
@@ -1043,6 +1104,29 @@ onBeforeUnmount(() => {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.account-icon-library {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.account-icon-card {
|
||||
display: grid;
|
||||
width: 104px;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.preferred-label {
|
||||
color: #ffbf00;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.icon-policy {
|
||||
margin: 0 8px 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#account-container {
|
||||
margin-left: 0;
|
||||
|
||||
Reference in New Issue
Block a user