feat: 세력·장수일람 모바일 배치와 근접 미리보기 추가
This commit is contained in:
@@ -416,43 +416,45 @@ test('nation directory reuses only the public general-directory row on hover and
|
|||||||
|
|
||||||
const preview = page.locator('#nation-general-preview');
|
const preview = page.locator('#nation-general-preview');
|
||||||
await expect(preview).toBeVisible();
|
await expect(preview).toBeVisible();
|
||||||
await expect(preview.locator('tr[data-general-id]')).toHaveCount(1);
|
await expect(preview.locator('[data-general-card-id]')).toHaveCount(1);
|
||||||
await expect(preview.locator('tr[data-general-id="10"]')).toContainText('조조');
|
await expect(preview.locator('[data-general-card-id="10"]')).toContainText('조조');
|
||||||
await expect(preview.locator('tr[data-general-id="10"]')).toContainText('대담');
|
await expect(preview.locator('[data-general-card-id="10"]')).toContainText('대담');
|
||||||
await expect(preview.locator('tr[data-general-id="10"]')).toContainText('상재 / 귀모');
|
await expect(preview.locator('[data-general-card-id="10"]')).toContainText('상재 / 귀모');
|
||||||
await expect(preview).not.toContainText('user-');
|
await expect(preview).not.toContainText('user-');
|
||||||
await expect(preview).not.toContainText('secret');
|
await expect(preview).not.toContainText('secret');
|
||||||
|
|
||||||
const previewGeometry = await preview.evaluate((element) => {
|
const previewGeometry = await preview.evaluate((element) => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
const style = getComputedStyle(element);
|
const style = getComputedStyle(element);
|
||||||
const row = element.querySelector('tr[data-general-id]')!.getBoundingClientRect();
|
const card = element.querySelector('[data-general-card-id]')!.getBoundingClientRect();
|
||||||
|
const trigger = document.querySelector('[data-general-preview-trigger="10"]')!.getBoundingClientRect();
|
||||||
|
const verticalGap = rect.bottom <= trigger.top ? trigger.top - rect.bottom : rect.top - trigger.bottom;
|
||||||
return {
|
return {
|
||||||
x: rect.x,
|
x: rect.x,
|
||||||
|
right: window.innerWidth - rect.right,
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
bottom: window.innerHeight - rect.bottom,
|
verticalGap,
|
||||||
position: style.position,
|
position: style.position,
|
||||||
rowHeight: row.height,
|
cardHeight: card.height,
|
||||||
documentWidth: document.documentElement.scrollWidth,
|
documentWidth: document.documentElement.scrollWidth,
|
||||||
documentHeight: document.documentElement.scrollHeight,
|
documentHeight: document.documentElement.scrollHeight,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(previewGeometry).toEqual({
|
expect(previewGeometry.position).toBe('fixed');
|
||||||
x: viewport.name === 'desktop' ? 100 : 0,
|
expect(previewGeometry.width).toBe(viewport.name === 'desktop' ? 500 : 484);
|
||||||
width: 1000,
|
expect(previewGeometry.x).toBeGreaterThanOrEqual(8);
|
||||||
bottom: 12,
|
expect(previewGeometry.right).toBeGreaterThanOrEqual(8);
|
||||||
position: 'fixed',
|
expect(previewGeometry.verticalGap).toBe(8);
|
||||||
rowHeight: 65,
|
expect(previewGeometry.cardHeight).toBeGreaterThan(100);
|
||||||
documentWidth: Math.max(viewport.width, 1000),
|
expect(previewGeometry.documentWidth).toBe(viewport.width);
|
||||||
documentHeight: documentHeightBefore,
|
expect(previewGeometry.documentHeight).toBe(documentHeightBefore);
|
||||||
});
|
|
||||||
|
|
||||||
await page.locator('.nation-title').first().hover();
|
await page.locator('.nation-title').first().hover();
|
||||||
await expect(preview).toHaveCount(0);
|
await expect(preview).toHaveCount(0);
|
||||||
|
|
||||||
const foreignTrigger = page.locator('[data-general-preview-trigger="20"]');
|
const foreignTrigger = page.locator('[data-general-preview-trigger="20"]');
|
||||||
await foreignTrigger.focus();
|
await foreignTrigger.focus();
|
||||||
await expect(preview.locator('tr[data-general-id="20"]')).toContainText('유비');
|
await expect(preview.locator('[data-general-card-id="20"]')).toContainText('유비');
|
||||||
expect(await foreignTrigger.getAttribute('aria-expanded')).toBe('true');
|
expect(await foreignTrigger.getAttribute('aria-expanded')).toBe('true');
|
||||||
expect(await foreignTrigger.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe('dashed');
|
expect(await foreignTrigger.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe('dashed');
|
||||||
|
|
||||||
@@ -477,6 +479,135 @@ test('nation directory reuses only the public general-directory row on hover and
|
|||||||
expect(requestedOperations).not.toContain('general.me');
|
expect(requestedOperations).not.toContain('general.me');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('nation and general directories rearrange for mobile and keep the tapped preview near its trigger', async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
screen: { width: 390, height: 844 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
isMobile: true,
|
||||||
|
hasTouch: true,
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
const requestedOperations: string[] = [];
|
||||||
|
const mobileMeasurements: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await install(page, 'general', [], requestedOperations);
|
||||||
|
await page.goto('nation-list');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const nationMetrics = await page.locator('.directory-page').evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: rect.x,
|
||||||
|
width: rect.width,
|
||||||
|
innerWidth: window.innerWidth,
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(nationMetrics.x).toBe(0);
|
||||||
|
expect(nationMetrics.width).toBe(500);
|
||||||
|
expect(nationMetrics.scrollWidth).toBeLessThanOrEqual(nationMetrics.innerWidth);
|
||||||
|
mobileMeasurements.nation = nationMetrics;
|
||||||
|
|
||||||
|
const mobileNationTables = page.locator('.nation-table');
|
||||||
|
await expect(mobileNationTables).toHaveCount(2);
|
||||||
|
for (const nationTable of await mobileNationTables.all()) {
|
||||||
|
const mobileNationRows = nationTable.locator('.mobile-only:visible');
|
||||||
|
await expect(mobileNationRows).toHaveCount(7);
|
||||||
|
expect(await mobileNationRows.evaluateAll((rows) => rows.map((row) => row.children.length))).toEqual([
|
||||||
|
4, 4, 4, 4, 4, 4, 4,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await expect(page.locator('.nation-table .desktop-only:visible')).toHaveCount(0);
|
||||||
|
const nationCellWidths = await page
|
||||||
|
.locator('.nation-table .mobile-only:visible')
|
||||||
|
.first()
|
||||||
|
.locator('td')
|
||||||
|
.evaluateAll((cells) => cells.map((cell) => cell.getBoundingClientRect().width));
|
||||||
|
expect(nationCellWidths).toHaveLength(4);
|
||||||
|
expect(nationCellWidths[0]).toBeCloseTo(80, 0);
|
||||||
|
expect(nationCellWidths[1]).toBeCloseTo(170, 0);
|
||||||
|
mobileMeasurements.nationCellWidths = nationCellWidths;
|
||||||
|
|
||||||
|
const firstTrigger = page.locator('[data-general-preview-trigger="10"]');
|
||||||
|
await firstTrigger.tap();
|
||||||
|
const preview = page.locator('#nation-general-preview');
|
||||||
|
await expect(preview).toBeVisible();
|
||||||
|
await expect(preview.locator('[data-general-card-id="10"]')).toContainText('조조');
|
||||||
|
const touchGeometry = await preview.evaluate((element) => {
|
||||||
|
const previewRect = element.getBoundingClientRect();
|
||||||
|
const triggerRect = document.querySelector('[data-general-preview-trigger="10"]')!.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
left: previewRect.left,
|
||||||
|
right: window.innerWidth - previewRect.right,
|
||||||
|
width: previewRect.width,
|
||||||
|
verticalGap:
|
||||||
|
previewRect.bottom <= triggerRect.top
|
||||||
|
? triggerRect.top - previewRect.bottom
|
||||||
|
: previewRect.top - triggerRect.bottom,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(touchGeometry.left).toBe(8);
|
||||||
|
expect(touchGeometry.right).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(touchGeometry.width).toBe(484);
|
||||||
|
expect(touchGeometry.verticalGap).toBe(8);
|
||||||
|
mobileMeasurements.touchPreview = touchGeometry;
|
||||||
|
expect(requestedOperations.filter((operation) => operation === 'world.getGeneralDirectory')).toHaveLength(1);
|
||||||
|
|
||||||
|
await page.goto('general-list');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await expect(page.locator('.general-table')).toBeHidden();
|
||||||
|
await expect(page.locator('.general-card-list')).toBeVisible();
|
||||||
|
await expect(page.locator('.general-card-list [data-general-card-id]')).toHaveCount(2);
|
||||||
|
await expect(page.locator('[data-general-card-id="10"]')).toContainText('상재 / 귀모');
|
||||||
|
await expect(page.locator('[data-general-card-id="10"]')).toContainText('통솔81+6');
|
||||||
|
|
||||||
|
const generalMetrics = await page.locator('.directory-page').evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const cardElement = element.querySelector('[data-general-card-id]')!;
|
||||||
|
const card = cardElement.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
width: rect.width,
|
||||||
|
cardWidth: card.width,
|
||||||
|
cardHeight: card.height,
|
||||||
|
cardGridColumns: getComputedStyle(cardElement).gridTemplateColumns,
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
innerWidth: window.innerWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(generalMetrics.width).toBe(500);
|
||||||
|
expect(generalMetrics.cardWidth).toBe(500);
|
||||||
|
expect(generalMetrics.cardHeight).toBeGreaterThan(100);
|
||||||
|
expect(generalMetrics.cardGridColumns).toMatch(/^66px /u);
|
||||||
|
expect(generalMetrics.scrollWidth).toBe(generalMetrics.innerWidth);
|
||||||
|
mobileMeasurements.general = generalMetrics;
|
||||||
|
|
||||||
|
const artifactRoot = process.env.DIRECTORY_PARITY_ARTIFACT_DIR;
|
||||||
|
if (artifactRoot) {
|
||||||
|
const output = resolve(artifactRoot);
|
||||||
|
await mkdir(output, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
resolve(output, 'mobile-computed-dom.json'),
|
||||||
|
`${JSON.stringify(mobileMeasurements, null, 2)}\n`
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: resolve(output, 'general-directory-mobile.png'), fullPage: true });
|
||||||
|
await page.goto('nation-list');
|
||||||
|
await firstTrigger.tap();
|
||||||
|
await expect(preview).toBeVisible();
|
||||||
|
await page.screenshot({ path: resolve(output, 'nation-directory-tap-mobile.png'), fullPage: true });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(requestedOperations).not.toContain('nation.getSecretGeneralList');
|
||||||
|
expect(requestedOperations).not.toContain('nation.getPersonnelInfo');
|
||||||
|
expect(requestedOperations).not.toContain('general.me');
|
||||||
|
});
|
||||||
|
|
||||||
test('a reused image element falls back for each newly broken account icon', async ({ page }) => {
|
test('a reused image element falls back for each newly broken account icon', async ({ page }) => {
|
||||||
await install(page);
|
await install(page);
|
||||||
await page.route('https://sam-image.hided.net/icons/**', (route) => {
|
await page.route('https://sam-image.hided.net/icons/**', (route) => {
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ withDefaults(
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
generals: GeneralDirectoryGeneral[];
|
generals: GeneralDirectoryGeneral[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
layout?: 'responsive' | 'card';
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
loading: false,
|
loading: false,
|
||||||
|
layout: 'responsive',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<table class="directory-table general-table legacy-bg0">
|
<table class="directory-table general-table legacy-bg0" :class="{ 'layout-card': layout === 'card' }">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style="width: 64px" />
|
<col style="width: 64px" />
|
||||||
<col style="width: 140px" />
|
<col style="width: 140px" />
|
||||||
@@ -124,6 +126,100 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<div class="general-card-list" :class="{ 'layout-card': layout === 'card' }">
|
||||||
|
<div v-if="loading" class="general-card-loading legacy-bg0">불러오는 중...</div>
|
||||||
|
<article
|
||||||
|
v-for="general in generals"
|
||||||
|
v-else
|
||||||
|
:key="general.id"
|
||||||
|
class="general-card legacy-bg0"
|
||||||
|
:data-general-card-id="general.id"
|
||||||
|
:data-general-wounded="general.injury"
|
||||||
|
:data-general-leadership="general.leadership"
|
||||||
|
:data-general-leadership-bonus="general.leadershipBonus"
|
||||||
|
:data-general-strength="general.strength"
|
||||||
|
:data-general-intel="general.intelligence"
|
||||||
|
:data-is-npc="general.npcState >= 2"
|
||||||
|
:data-npc-type="general.npcState"
|
||||||
|
>
|
||||||
|
<div class="general-card-portrait">
|
||||||
|
<img
|
||||||
|
class="general-icon"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
:src="resolveGeneralIconUrl(general)"
|
||||||
|
alt=""
|
||||||
|
@error="useDefaultGeneralIcon"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="general-card-field identity-field name-field">
|
||||||
|
<span class="field-label">이름</span>
|
||||||
|
<span :style="{ color: getNpcColor(general.npcState) }">{{ general.name }}</span>
|
||||||
|
<small v-if="general.ownerName">({{ general.ownerName }})</small>
|
||||||
|
</div>
|
||||||
|
<div class="general-card-field identity-field nation-field">
|
||||||
|
<span class="field-label">국가</span>
|
||||||
|
<span>{{ general.nationName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="general-card-field">
|
||||||
|
<span class="field-label">연령</span><span>{{ general.age }}세</span>
|
||||||
|
</div>
|
||||||
|
<div class="general-card-field">
|
||||||
|
<span class="field-label">성격</span>
|
||||||
|
<span :title="general.personality.info">{{ general.personality.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="general-card-field">
|
||||||
|
<span class="field-label">특기</span>
|
||||||
|
<span :title="`${general.specialDomestic.info} / ${general.specialWar.info}`"
|
||||||
|
>{{ general.specialDomestic.name }} / {{ general.specialWar.name }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="general-card-field">
|
||||||
|
<span class="field-label">레벨</span><span>Lv {{ general.experienceLevel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="general-card-field">
|
||||||
|
<span class="field-label">명성</span><span>{{ general.honorText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="general-card-field">
|
||||||
|
<span class="field-label">계급</span><span>{{ general.dedicationText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="general-card-field">
|
||||||
|
<span class="field-label">관직</span>
|
||||||
|
<span>{{ formatOfficerLevelText(general.officerLevel, general.nationLevel) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="general-card-field">
|
||||||
|
<span class="field-label">삭턴</span><span>{{ general.killturn }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="general-card-field">
|
||||||
|
<span class="field-label">통솔</span>
|
||||||
|
<span>
|
||||||
|
<span :class="{ wounded: general.injury > 0 }">{{
|
||||||
|
general.injury > 0 ? injuredStat(general.leadership, general.injury) : general.leadership
|
||||||
|
}}</span
|
||||||
|
><span v-if="general.leadershipBonus > 0" class="leadership-bonus"
|
||||||
|
>+{{ general.leadershipBonus }}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="general-card-field">
|
||||||
|
<span class="field-label">무력</span>
|
||||||
|
<span :class="{ wounded: general.injury > 0 }">{{
|
||||||
|
general.injury > 0 ? injuredStat(general.strength, general.injury) : general.strength
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="general-card-field">
|
||||||
|
<span class="field-label">지력</span>
|
||||||
|
<span :class="{ wounded: general.injury > 0 }">{{
|
||||||
|
general.injury > 0 ? injuredStat(general.intelligence, general.injury) : general.intelligence
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="general-card-field penalty-field">
|
||||||
|
<span class="field-label">벌점</span>
|
||||||
|
<span>{{ general.refreshScoreTotal }} 【{{ general.refreshText }}】</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -169,4 +265,91 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value
|
|||||||
height: 64px;
|
height: 64px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.general-card-list {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.general-table.layout-card {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.general-card-list.layout-card {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
.general-card,
|
||||||
|
.general-card-loading {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid gray;
|
||||||
|
}
|
||||||
|
.general-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 66px repeat(4, minmax(0, 1fr));
|
||||||
|
color: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.general-card + .general-card {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
.general-card-portrait {
|
||||||
|
grid-row: 1 / span 4;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
border-right: 1px solid gray;
|
||||||
|
}
|
||||||
|
.general-card-field {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 29px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-right: 1px solid gray;
|
||||||
|
border-bottom: 1px solid gray;
|
||||||
|
padding: 2px 3px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.general-card-field:nth-child(3),
|
||||||
|
.general-card-field:nth-child(7),
|
||||||
|
.general-card-field:nth-child(11),
|
||||||
|
.penalty-field {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
.general-card-field:nth-last-child(-n + 4) {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.identity-field {
|
||||||
|
min-height: 32px;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.name-field,
|
||||||
|
.nation-field {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
.field-label {
|
||||||
|
color: #a8d8bd;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.identity-field .field-label {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.general-card-loading {
|
||||||
|
min-height: 65px;
|
||||||
|
padding: 22px 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.general-table:not(.layout-card) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.general-card-list {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -136,4 +136,19 @@ onMounted(() => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: #ff7373;
|
color: #ff7373;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.directory-page {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.directory-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.directory-error {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue';
|
import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue';
|
||||||
import type { GeneralDirectoryGeneral } from '../types/directory';
|
import type { GeneralDirectoryGeneral } from '../types/directory';
|
||||||
@@ -19,6 +19,8 @@ const generalDetailsLoaded = ref(false);
|
|||||||
const generalDetailsLoading = ref(false);
|
const generalDetailsLoading = ref(false);
|
||||||
const generalDetailsError = ref('');
|
const generalDetailsError = ref('');
|
||||||
const activeGeneralId = ref<number | null>(null);
|
const activeGeneralId = ref<number | null>(null);
|
||||||
|
const generalPreviewStyle = ref({ left: '8px', top: '8px', visibility: 'hidden' as 'hidden' | 'visible' });
|
||||||
|
const generalPreviewAnchor = ref<HTMLElement | null>(null);
|
||||||
let generalDetailsRequest: Promise<void> | null = null;
|
let generalDetailsRequest: Promise<void> | null = null;
|
||||||
|
|
||||||
const generalDetailsById = computed(
|
const generalDetailsById = computed(
|
||||||
@@ -54,6 +56,37 @@ const roamingCityName = (nation: Nation): string => {
|
|||||||
return nation.cities.find((city) => city.id === chief?.cityId)?.name ?? '-';
|
return nation.cities.find((city) => city.id === chief?.cityId)?.name ?? '-';
|
||||||
};
|
};
|
||||||
const closeWindow = () => window.close();
|
const closeWindow = () => window.close();
|
||||||
|
const officerLevelAt = (row: number, column: number, columns: number) => 13 - ((row - 1) * columns + column);
|
||||||
|
|
||||||
|
const positionGeneralPreview = async (): Promise<void> => {
|
||||||
|
await nextTick();
|
||||||
|
const anchor = generalPreviewAnchor.value;
|
||||||
|
const preview = document.getElementById('nation-general-preview');
|
||||||
|
if (!anchor || !preview || activeGeneralId.value === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportMargin = 8;
|
||||||
|
const anchorGap = 8;
|
||||||
|
const anchorRect = anchor.getBoundingClientRect();
|
||||||
|
const previewRect = preview.getBoundingClientRect();
|
||||||
|
const maxLeft = Math.max(viewportMargin, window.innerWidth - previewRect.width - viewportMargin);
|
||||||
|
const left = Math.min(
|
||||||
|
maxLeft,
|
||||||
|
Math.max(viewportMargin, anchorRect.left + anchorRect.width / 2 - previewRect.width / 2)
|
||||||
|
);
|
||||||
|
const below = anchorRect.bottom + anchorGap;
|
||||||
|
const above = anchorRect.top - previewRect.height - anchorGap;
|
||||||
|
const maxTop = Math.max(viewportMargin, window.innerHeight - previewRect.height - viewportMargin);
|
||||||
|
const top =
|
||||||
|
below + previewRect.height <= window.innerHeight - viewportMargin ? below : Math.max(viewportMargin, above);
|
||||||
|
|
||||||
|
generalPreviewStyle.value = {
|
||||||
|
left: `${left}px`,
|
||||||
|
top: `${Math.min(maxTop, top)}px`,
|
||||||
|
visibility: 'visible',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const loadGeneralDetails = async (): Promise<void> => {
|
const loadGeneralDetails = async (): Promise<void> => {
|
||||||
if (generalDetailsLoaded.value) {
|
if (generalDetailsLoaded.value) {
|
||||||
@@ -67,6 +100,7 @@ const loadGeneralDetails = async (): Promise<void> => {
|
|||||||
.then((result) => {
|
.then((result) => {
|
||||||
generalDetails.value = result.generals;
|
generalDetails.value = result.generals;
|
||||||
generalDetailsLoaded.value = true;
|
generalDetailsLoaded.value = true;
|
||||||
|
void positionGeneralPreview();
|
||||||
})
|
})
|
||||||
.catch((cause: unknown) => {
|
.catch((cause: unknown) => {
|
||||||
generalDetailsError.value =
|
generalDetailsError.value =
|
||||||
@@ -80,14 +114,26 @@ const loadGeneralDetails = async (): Promise<void> => {
|
|||||||
await generalDetailsRequest;
|
await generalDetailsRequest;
|
||||||
};
|
};
|
||||||
|
|
||||||
const showGeneralDetails = (generalId: number): void => {
|
const showGeneralDetails = (event: Event, generalId: number): void => {
|
||||||
|
if (event.currentTarget instanceof HTMLElement) {
|
||||||
|
generalPreviewAnchor.value = event.currentTarget;
|
||||||
|
}
|
||||||
activeGeneralId.value = generalId;
|
activeGeneralId.value = generalId;
|
||||||
void loadGeneralDetails();
|
generalPreviewStyle.value = { ...generalPreviewStyle.value, visibility: 'hidden' };
|
||||||
|
void positionGeneralPreview();
|
||||||
|
void loadGeneralDetails().then(positionGeneralPreview);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showGeneralDetailsFromTouch = (event: PointerEvent, generalId: number): void => {
|
||||||
|
if (event.pointerType === 'touch') {
|
||||||
|
showGeneralDetails(event, generalId);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hideGeneralDetails = (generalId: number): void => {
|
const hideGeneralDetails = (generalId: number): void => {
|
||||||
if (activeGeneralId.value === generalId) {
|
if (activeGeneralId.value === generalId) {
|
||||||
activeGeneralId.value = null;
|
activeGeneralId.value = null;
|
||||||
|
generalPreviewAnchor.value = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,6 +146,13 @@ const hideGeneralDetailsFromPointer = (event: PointerEvent, generalId: number):
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadDirectory();
|
void loadDirectory();
|
||||||
|
window.addEventListener('resize', positionGeneralPreview);
|
||||||
|
window.addEventListener('scroll', positionGeneralPreview, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', positionGeneralPreview);
|
||||||
|
window.removeEventListener('scroll', positionGeneralPreview, true);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -132,7 +185,7 @@ onMounted(() => {
|
|||||||
【 {{ nation.name }} 】
|
【 {{ nation.name }} 】
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr class="desktop-only">
|
||||||
<td class="label-cell">성 향</td>
|
<td class="label-cell">성 향</td>
|
||||||
<td class="value-wide type-name">{{ Array.from(nation.type.name).join(' ') }}</td>
|
<td class="value-wide type-name">{{ Array.from(nation.type.name).join(' ') }}</td>
|
||||||
<td class="label-cell">작 위</td>
|
<td class="label-cell">작 위</td>
|
||||||
@@ -142,27 +195,59 @@ onMounted(() => {
|
|||||||
<td class="label-cell">장수 / 속령</td>
|
<td class="label-cell">장수 / 속령</td>
|
||||||
<td class="value-wide">{{ nation.generalCount }} / {{ nation.cityCount }}</td>
|
<td class="value-wide">{{ nation.generalCount }} / {{ nation.cityCount }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="row in 2" :key="row">
|
<tr class="mobile-only">
|
||||||
|
<td class="label-cell">성 향</td>
|
||||||
|
<td class="value-wide type-name">{{ Array.from(nation.type.name).join(' ') }}</td>
|
||||||
|
<td class="label-cell">작 위</td>
|
||||||
|
<td class="value-wide">{{ formatNationLevelText(nation.level) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="mobile-only">
|
||||||
|
<td class="label-cell">국 력</td>
|
||||||
|
<td class="value-wide">{{ nation.power }}</td>
|
||||||
|
<td class="label-cell">장수 / 속령</td>
|
||||||
|
<td class="value-wide">{{ nation.generalCount }} / {{ nation.cityCount }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="row in 2" :key="`desktop-officers-${row}`" class="desktop-only">
|
||||||
<template v-for="column in 4" :key="column">
|
<template v-for="column in 4" :key="column">
|
||||||
<td class="label-cell">
|
<td class="label-cell">
|
||||||
{{ formatOfficerLevelText(13 - ((row - 1) * 4 + column), nation.level) }}
|
{{ formatOfficerLevelText(officerLevelAt(row, column, 4), nation.level) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="value-wide">
|
<td class="value-wide">
|
||||||
<span
|
<span
|
||||||
v-if="officerName(nation, 13 - ((row - 1) * 4 + column))"
|
v-if="officerName(nation, officerLevelAt(row, column, 4))"
|
||||||
:style="{
|
:style="{
|
||||||
color: getNpcColor(
|
color: getNpcColor(
|
||||||
officerName(nation, 13 - ((row - 1) * 4 + column))?.npcState ?? 0
|
officerName(nation, officerLevelAt(row, column, 4))?.npcState ?? 0
|
||||||
),
|
),
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
{{ displayGeneralName(officerName(nation, 13 - ((row - 1) * 4 + column))!) }}
|
{{ displayGeneralName(officerName(nation, officerLevelAt(row, column, 4))!) }}
|
||||||
</span>
|
</span>
|
||||||
<template v-else>-</template>
|
<template v-else>-</template>
|
||||||
</td>
|
</td>
|
||||||
</template>
|
</template>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr v-for="row in 4" :key="`mobile-officers-${row}`" class="mobile-only">
|
||||||
|
<template v-for="column in 2" :key="column">
|
||||||
|
<td class="label-cell">
|
||||||
|
{{ formatOfficerLevelText(officerLevelAt(row, column, 2), nation.level) }}
|
||||||
|
</td>
|
||||||
|
<td class="value-wide">
|
||||||
|
<span
|
||||||
|
v-if="officerName(nation, officerLevelAt(row, column, 2))"
|
||||||
|
:style="{
|
||||||
|
color: getNpcColor(
|
||||||
|
officerName(nation, officerLevelAt(row, column, 2))?.npcState ?? 0
|
||||||
|
),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ displayGeneralName(officerName(nation, officerLevelAt(row, column, 2))!) }}
|
||||||
|
</span>
|
||||||
|
<template v-else>-</template>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
<tr class="desktop-only">
|
||||||
<td class="label-cell">외교권자</td>
|
<td class="label-cell">외교권자</td>
|
||||||
<td colspan="5">
|
<td colspan="5">
|
||||||
{{ nation.ambassadorNames.map((name) => displayAmbassadorName(nation, name)).join(', ') }}
|
{{ nation.ambassadorNames.map((name) => displayAmbassadorName(nation, name)).join(', ') }}
|
||||||
@@ -170,6 +255,14 @@ onMounted(() => {
|
|||||||
<td class="label-cell">조언자</td>
|
<td class="label-cell">조언자</td>
|
||||||
<td class="value-wide">{{ nation.auditorCount }}명</td>
|
<td class="value-wide">{{ nation.auditorCount }}명</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr class="mobile-only">
|
||||||
|
<td class="label-cell">외교권자</td>
|
||||||
|
<td class="value-wide">
|
||||||
|
{{ nation.ambassadorNames.map((name) => displayAmbassadorName(nation, name)).join(', ') }}
|
||||||
|
</td>
|
||||||
|
<td class="label-cell">조언자</td>
|
||||||
|
<td class="value-wide">{{ nation.auditorCount }}명</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8">
|
<td colspan="8">
|
||||||
<template v-if="nation.level > 0">
|
<template v-if="nation.level > 0">
|
||||||
@@ -199,9 +292,10 @@ onMounted(() => {
|
|||||||
:aria-describedby="
|
:aria-describedby="
|
||||||
activeGeneralId === general.id ? 'nation-general-preview' : undefined
|
activeGeneralId === general.id ? 'nation-general-preview' : undefined
|
||||||
"
|
"
|
||||||
@pointerenter="showGeneralDetails(general.id)"
|
@pointerdown="showGeneralDetailsFromTouch($event, general.id)"
|
||||||
|
@pointerenter="showGeneralDetails($event, general.id)"
|
||||||
@pointerleave="hideGeneralDetailsFromPointer($event, general.id)"
|
@pointerleave="hideGeneralDetailsFromPointer($event, general.id)"
|
||||||
@focus="showGeneralDetails(general.id)"
|
@focus="showGeneralDetails($event, general.id)"
|
||||||
@blur="hideGeneralDetails(general.id)"
|
@blur="hideGeneralDetails(general.id)"
|
||||||
>
|
>
|
||||||
{{ displayGeneralName(general) }}</button
|
{{ displayGeneralName(general) }}</button
|
||||||
@@ -244,9 +338,10 @@ onMounted(() => {
|
|||||||
:aria-describedby="
|
:aria-describedby="
|
||||||
activeGeneralId === general.id ? 'nation-general-preview' : undefined
|
activeGeneralId === general.id ? 'nation-general-preview' : undefined
|
||||||
"
|
"
|
||||||
@pointerenter="showGeneralDetails(general.id)"
|
@pointerdown="showGeneralDetailsFromTouch($event, general.id)"
|
||||||
|
@pointerenter="showGeneralDetails($event, general.id)"
|
||||||
@pointerleave="hideGeneralDetailsFromPointer($event, general.id)"
|
@pointerleave="hideGeneralDetailsFromPointer($event, general.id)"
|
||||||
@focus="showGeneralDetails(general.id)"
|
@focus="showGeneralDetails($event, general.id)"
|
||||||
@blur="hideGeneralDetails(general.id)"
|
@blur="hideGeneralDetails(general.id)"
|
||||||
>
|
>
|
||||||
{{ displayGeneralName(general) }}</button
|
{{ displayGeneralName(general) }}</button
|
||||||
@@ -264,8 +359,9 @@ onMounted(() => {
|
|||||||
class="general-hover-preview"
|
class="general-hover-preview"
|
||||||
role="tooltip"
|
role="tooltip"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
|
:style="generalPreviewStyle"
|
||||||
>
|
>
|
||||||
<GeneralDirectoryTable v-if="activeGeneral" :generals="[activeGeneral]" />
|
<GeneralDirectoryTable v-if="activeGeneral" :generals="[activeGeneral]" layout="card" />
|
||||||
<div v-else class="general-hover-status">
|
<div v-else class="general-hover-status">
|
||||||
<template v-if="generalDetailsLoading">장수 기본 정보를 불러오는 중...</template>
|
<template v-if="generalDetailsLoading">장수 기본 정보를 불러오는 중...</template>
|
||||||
<template v-else-if="generalDetailsError">{{ generalDetailsError }}</template>
|
<template v-else-if="generalDetailsError">{{ generalDetailsError }}</template>
|
||||||
@@ -380,6 +476,9 @@ onMounted(() => {
|
|||||||
.center {
|
.center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.mobile-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.general-preview-trigger {
|
.general-preview-trigger {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -396,9 +495,7 @@ onMounted(() => {
|
|||||||
.general-hover-preview {
|
.general-hover-preview {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
bottom: 12px;
|
width: min(500px, calc(100vw - 16px));
|
||||||
left: max(0px, calc(50% - 500px));
|
|
||||||
width: 1000px;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: #000;
|
background: #000;
|
||||||
@@ -407,7 +504,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
.general-hover-status {
|
.general-hover-status {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 1000px;
|
width: 100%;
|
||||||
min-height: 65px;
|
min-height: 65px;
|
||||||
border: 1px solid gray;
|
border: 1px solid gray;
|
||||||
padding: 22px 8px;
|
padding: 22px 8px;
|
||||||
@@ -434,4 +531,43 @@ onMounted(() => {
|
|||||||
.directory-error {
|
.directory-error {
|
||||||
color: #ff7373;
|
color: #ff7373;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.directory-page {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.directory-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.desktop-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.mobile-only {
|
||||||
|
display: table-row;
|
||||||
|
}
|
||||||
|
.label-cell {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
.value-wide {
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
|
.neutral-spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.neutral-label,
|
||||||
|
.neutral-value {
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
.general-preview-trigger {
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
.directory-error,
|
||||||
|
.directory-loading {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user