feat: add user icon library and in-game selection

This commit is contained in:
2026-08-01 03:40:36 +00:00
parent 395b60cdbe
commit a275e6a234
26 changed files with 1214 additions and 69 deletions
@@ -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;