feat(frontend): align in-game layouts with ref
This commit is contained in:
@@ -47,11 +47,24 @@ export const diplomacyRouter = router({
|
||||
});
|
||||
|
||||
const nations = await ctx.db.nation.findMany({
|
||||
where: { id: { not: me.nationId } },
|
||||
select: { id: true, name: true, color: true, level: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
|
||||
const signerIds = [
|
||||
...new Set(
|
||||
letters.flatMap((letter) =>
|
||||
letter.destSignerId === null ? [letter.srcSignerId] : [letter.srcSignerId, letter.destSignerId]
|
||||
)
|
||||
),
|
||||
];
|
||||
const signers = await ctx.db.general.findMany({
|
||||
where: { id: { in: signerIds } },
|
||||
select: { id: true, name: true, picture: true, imageServer: true },
|
||||
});
|
||||
const nationById = new Map(nations.map((nation) => [nation.id, nation]));
|
||||
const signerById = new Map(signers.map((general) => [general.id, general]));
|
||||
|
||||
const result = letters.map((letter) => {
|
||||
const aux = asRecord(letter.aux);
|
||||
const src = asRecord(aux.src);
|
||||
@@ -60,24 +73,32 @@ export const diplomacyRouter = router({
|
||||
const detail =
|
||||
permission < 3 && letter.textDetail ? '(권한이 부족합니다)' : purifyDiplomacyHtml(letter.textDetail);
|
||||
const reason = asRecord(aux.reason);
|
||||
const srcNation = nationById.get(letter.srcNationId);
|
||||
const destNation = nationById.get(letter.destNationId);
|
||||
const srcSigner = signerById.get(letter.srcSignerId);
|
||||
const destSigner = letter.destSignerId === null ? undefined : signerById.get(letter.destSignerId);
|
||||
|
||||
return {
|
||||
id: letter.id,
|
||||
src: {
|
||||
nationId: letter.srcNationId,
|
||||
nationName: typeof src.nationName === 'string' ? src.nationName : '',
|
||||
nationColor: typeof src.nationColor === 'string' ? src.nationColor : '',
|
||||
generalId: typeof src.generalId === 'number' ? src.generalId : null,
|
||||
generalName: typeof src.generalName === 'string' ? src.generalName : null,
|
||||
nationName: typeof src.nationName === 'string' ? src.nationName : (srcNation?.name ?? ''),
|
||||
nationColor: typeof src.nationColor === 'string' ? src.nationColor : (srcNation?.color ?? ''),
|
||||
generalId: typeof src.generalId === 'number' ? src.generalId : letter.srcSignerId,
|
||||
generalName: typeof src.generalName === 'string' ? src.generalName : (srcSigner?.name ?? null),
|
||||
generalIcon: typeof src.generalIcon === 'string' ? src.generalIcon : null,
|
||||
generalPicture: srcSigner?.picture ?? null,
|
||||
generalImageServer: srcSigner?.imageServer ?? 0,
|
||||
},
|
||||
dest: {
|
||||
nationId: letter.destNationId,
|
||||
nationName: typeof dest.nationName === 'string' ? dest.nationName : '',
|
||||
nationColor: typeof dest.nationColor === 'string' ? dest.nationColor : '',
|
||||
generalId: typeof dest.generalId === 'number' ? dest.generalId : null,
|
||||
generalName: typeof dest.generalName === 'string' ? dest.generalName : null,
|
||||
nationName: typeof dest.nationName === 'string' ? dest.nationName : (destNation?.name ?? ''),
|
||||
nationColor: typeof dest.nationColor === 'string' ? dest.nationColor : (destNation?.color ?? ''),
|
||||
generalId: typeof dest.generalId === 'number' ? dest.generalId : letter.destSignerId,
|
||||
generalName: typeof dest.generalName === 'string' ? dest.generalName : (destSigner?.name ?? null),
|
||||
generalIcon: typeof dest.generalIcon === 'string' ? dest.generalIcon : null,
|
||||
generalPicture: destSigner?.picture ?? null,
|
||||
generalImageServer: destSigner?.imageServer ?? 0,
|
||||
},
|
||||
prevId: letter.prevId,
|
||||
state: mapLetterState(letter.state),
|
||||
@@ -95,7 +116,7 @@ export const diplomacyRouter = router({
|
||||
|
||||
return {
|
||||
letters: result,
|
||||
nations,
|
||||
nations: nations.filter((nation) => nation.id !== me.nationId),
|
||||
myNationId: me.nationId,
|
||||
permission,
|
||||
};
|
||||
|
||||
@@ -78,32 +78,33 @@ const storedLetter = {
|
||||
textBrief: '<p>공개</p><img src=x onerror="globalThis.__briefXss=1"><script>alert(1)</script>',
|
||||
textDetail: '<strong>기밀</strong><a href="javascript:alert(2)" onclick="alert(3)">링크</a>',
|
||||
date: new Date('2026-07-31T00:00:00.000Z'),
|
||||
srcSignerId: 1,
|
||||
destSignerId: 2,
|
||||
aux: {
|
||||
src: { nationName: '위', nationColor: '#0000ff', generalId: 1, generalName: '외교담당' },
|
||||
dest: { nationName: '촉', nationColor: '#ff0000' },
|
||||
},
|
||||
};
|
||||
|
||||
const buildContext = (officerLevel = 12) => {
|
||||
const buildContext = (officerLevel = 12, letter: Record<string, unknown> = storedLetter) => {
|
||||
const create = vi.fn(async () => ({ id: 9 }));
|
||||
const db = {
|
||||
general: {
|
||||
findFirst: vi.fn(async () => buildGeneral(officerLevel)),
|
||||
findMany: vi.fn(async () => [
|
||||
{ id: 1, name: '현재 송신자', picture: 'src.jpg', imageServer: 0 },
|
||||
{ id: 2, name: '현재 수신자', picture: 'dest.jpg', imageServer: 0 },
|
||||
]),
|
||||
},
|
||||
nation: {
|
||||
findUnique: vi.fn(async () => ({ meta: {} })),
|
||||
findMany: vi.fn(async ({ where }: { where: { id?: { in?: number[]; not?: number } } }) => {
|
||||
if (where.id?.in) {
|
||||
return [
|
||||
{ id: 1, name: '위', color: '#0000ff' },
|
||||
{ id: 2, name: '촉', color: '#ff0000' },
|
||||
];
|
||||
}
|
||||
return [{ id: 2, name: '촉', color: '#ff0000', level: 5 }];
|
||||
}),
|
||||
findMany: vi.fn(async () => [
|
||||
{ id: 1, name: '위', color: '#0000ff', level: 5 },
|
||||
{ id: 2, name: '촉', color: '#ff0000', level: 5 },
|
||||
]),
|
||||
},
|
||||
diplomacyLetter: {
|
||||
findMany: vi.fn(async () => [storedLetter]),
|
||||
findMany: vi.fn(async () => [letter]),
|
||||
findFirst: vi.fn(async () => null),
|
||||
create,
|
||||
},
|
||||
@@ -163,6 +164,29 @@ describe('diplomacy HTML API boundary', () => {
|
||||
expect(redacted.letters[0]?.detail).toBe('(권한이 부족합니다)');
|
||||
});
|
||||
|
||||
it('reconstructs imported letters whose snapshot metadata is empty', async () => {
|
||||
const imported = { ...storedLetter, aux: {} };
|
||||
const result = await buildContext(12, imported).caller.diplomacy.getLetters();
|
||||
|
||||
expect(result.nations).toEqual([{ id: 2, name: '촉', color: '#ff0000', level: 5 }]);
|
||||
expect(result.letters[0]).toMatchObject({
|
||||
src: {
|
||||
nationName: '위',
|
||||
nationColor: '#0000ff',
|
||||
generalId: 1,
|
||||
generalName: '현재 송신자',
|
||||
generalPicture: 'src.jpg',
|
||||
},
|
||||
dest: {
|
||||
nationName: '촉',
|
||||
nationColor: '#ff0000',
|
||||
generalId: 2,
|
||||
generalName: '현재 수신자',
|
||||
generalPicture: 'dest.jpg',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects direct send mutations below the Ref diplomacy permission without writing', async () => {
|
||||
const fixture = buildContext(5);
|
||||
await expect(
|
||||
|
||||
@@ -307,9 +307,9 @@ test('keeps the shared main and chief shell geometry and interaction states', as
|
||||
});
|
||||
expect(mainGeometry).toEqual({
|
||||
width: 1000,
|
||||
padding: '24px',
|
||||
gap: '16px',
|
||||
headerWidth: 952,
|
||||
padding: '0px',
|
||||
gap: '10px',
|
||||
headerWidth: 1000,
|
||||
headerGap: '12px',
|
||||
headerBorder: '1px',
|
||||
headerPadding: '12px',
|
||||
@@ -334,7 +334,7 @@ test('keeps the shared main and chief shell geometry and interaction states', as
|
||||
padding: getComputedStyle(element).padding,
|
||||
headerWidth: element.querySelector<HTMLElement>('.game-shell__header')!.getBoundingClientRect().width,
|
||||
}));
|
||||
expect(chiefDesktop).toEqual({ width: 1200, padding: '24px', headerWidth: 1152 });
|
||||
expect(chiefDesktop).toEqual({ width: 1000, padding: '0px', headerWidth: 1000 });
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
const chiefMobile = await page.locator('.chief-page').evaluate((element) => ({
|
||||
@@ -342,5 +342,5 @@ test('keeps the shared main and chief shell geometry and interaction states', as
|
||||
padding: getComputedStyle(element).padding,
|
||||
headerWidth: element.querySelector<HTMLElement>('.game-shell__header')!.getBoundingClientRect().width,
|
||||
}));
|
||||
expect(chiefMobile).toEqual({ width: 500, padding: '16px', headerWidth: 468 });
|
||||
expect(chiefMobile).toEqual({ width: 500, padding: '0px', headerWidth: 500 });
|
||||
});
|
||||
|
||||
@@ -102,7 +102,8 @@ for (const viewport of [
|
||||
await expect(card.locator('li')).toHaveText('서버 정화 기밀문');
|
||||
await expect(card.getByRole('link', { name: '자료' })).toHaveAttribute('href', 'https://example.com');
|
||||
await expect(card.getByRole('link', { name: '자료' })).toHaveAttribute('rel', 'noopener noreferrer nofollow');
|
||||
await expect(card.locator('script, svg, math, [onerror], [onclick], [style]')).toHaveCount(0);
|
||||
await expect(card.locator('.letter-text script, .letter-text svg, .letter-text math')).toHaveCount(0);
|
||||
await expect(card.locator('.letter-text [onerror], .letter-text [onclick], .letter-text [style]')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => (globalThis as Record<string, unknown>).__diplomacyXss)).toBeUndefined();
|
||||
|
||||
const geometry = await card.evaluate((element) => {
|
||||
@@ -112,6 +113,9 @@ for (const viewport of [
|
||||
const style = getComputedStyle(text);
|
||||
return {
|
||||
card: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
labelWidth: element.querySelector<HTMLElement>('.row-label')?.getBoundingClientRect().width,
|
||||
headerFontSize: getComputedStyle(element.querySelector('h3')!).fontSize,
|
||||
text: {
|
||||
x: textRect.x,
|
||||
y: textRect.y,
|
||||
@@ -122,8 +126,11 @@ for (const viewport of [
|
||||
},
|
||||
};
|
||||
});
|
||||
expect(geometry.card.width).toBeGreaterThan(0);
|
||||
expect(geometry.text.width).toBeGreaterThan(0);
|
||||
expect(geometry.card.width).toBe(1000);
|
||||
expect(geometry.documentWidth).toBe(viewport.name === 'mobile' ? 1000 : viewport.width);
|
||||
expect(geometry.labelWidth).toBe(200);
|
||||
expect(geometry.headerFontSize).toBe('28px');
|
||||
expect(geometry.text.width).toBe(800);
|
||||
|
||||
if (artifactRoot) {
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
@@ -134,10 +141,10 @@ for (const viewport of [
|
||||
);
|
||||
}
|
||||
|
||||
const refresh = page.getByRole('button', { name: '수동 갱신' });
|
||||
await refresh.focus();
|
||||
await expect(refresh).toBeFocused();
|
||||
await refresh.hover();
|
||||
const send = page.getByRole('button', { name: '전송' });
|
||||
await send.focus();
|
||||
await expect(send).toBeFocused();
|
||||
await send.hover();
|
||||
await screenshot(page, `diplomacy-html-${basePath.slice(1)}-${viewport.name}.png`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ const waitForMain = async (page: Page) => {
|
||||
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible();
|
||||
await expect(page.locator('.main-global-menu').first()).toBeVisible();
|
||||
await expect(page.locator('.main-nation-menu')).toBeVisible();
|
||||
await expect(page.locator('[data-navigation-id="npc-list"]')).toHaveCount(4);
|
||||
await expect(page.locator('[data-navigation-id="npc-list"]')).toHaveCount(3);
|
||||
};
|
||||
|
||||
const gridColumnCount = async (page: Page, selector: string) =>
|
||||
@@ -331,7 +331,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
||||
});
|
||||
|
||||
test('the 939/940 boundary and 500px bottom bar match the ref responsive contract', async ({ page }) => {
|
||||
test('the 939/940 boundary switches to the Ref-style 500px single document', async ({ page }) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 5,
|
||||
permission: 2,
|
||||
@@ -353,72 +353,33 @@ test('the 939/940 boundary and 500px bottom bar match the ref responsive contrac
|
||||
await expect(page.locator('.layout-mobile')).toBeVisible();
|
||||
expect(await gridColumnCount(page, '.main-global-menu')).toBe(4);
|
||||
expect(await gridColumnCount(page, '.main-nation-menu')).toBe(5);
|
||||
await expect(page.locator('.main-mobile-bottom')).toBeVisible();
|
||||
await expect(page.locator('.main-mobile-bottom')).toHaveCount(0);
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
const bottomGeometry = await page.locator('.main-mobile-bottom').evaluate((element) => {
|
||||
const documentGeometry = await page.locator('.main-page').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.x,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
bottom: innerHeight - rect.bottom,
|
||||
buttons: [...element.children].map((child) => child.getBoundingClientRect().width),
|
||||
position: getComputedStyle(element).position,
|
||||
zIndex: getComputedStyle(element).zIndex,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
expect(bottomGeometry).toEqual({
|
||||
expect(documentGeometry).toMatchObject({
|
||||
x: 0,
|
||||
width: 500,
|
||||
height: 45,
|
||||
bottom: 0,
|
||||
buttons: [125, 125, 125, 125],
|
||||
position: 'fixed',
|
||||
zIndex: '99',
|
||||
scrollWidth: 500,
|
||||
});
|
||||
|
||||
const externalTrigger = page.locator('[data-bottom-menu="global"]');
|
||||
await externalTrigger.focus();
|
||||
await externalTrigger.press('Enter');
|
||||
await expect(externalTrigger).toHaveAttribute('aria-expanded', 'true');
|
||||
const popupStyle = await page.locator('#mobile-global-menu').evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
columns: style.columnCount,
|
||||
overflowY: style.overflowY,
|
||||
maxHeight: style.maxHeight,
|
||||
bottom: style.bottom,
|
||||
};
|
||||
});
|
||||
expect(popupStyle.columns).toBe('3');
|
||||
expect(popupStyle.overflowY).toBe('auto');
|
||||
expect(popupStyle.maxHeight).toBe('850px');
|
||||
expect(popupStyle.bottom).toBe('47px');
|
||||
const globalPopup = page.locator('#mobile-global-menu');
|
||||
await expect(globalPopup.getByText('게시판', { exact: true })).toHaveCount(1);
|
||||
await expect(globalPopup.getByText('공식 오픈 톡', { exact: true })).toHaveCount(1);
|
||||
await expect(globalPopup.getByText('게임정보', { exact: true })).toHaveCount(1);
|
||||
await expect(globalPopup.getByText('기타 정보', { exact: true })).toHaveCount(1);
|
||||
await persistArtifact(page, `${basePath.slice(1)}-mobile-global-open-500`);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(externalTrigger).toBeFocused();
|
||||
|
||||
await page.locator('[data-bottom-menu="nation"]').click();
|
||||
const nationPopup = page.locator('#mobile-nation-menu');
|
||||
await expect(nationPopup.getByText('금/쌀 경매장', { exact: true })).toHaveCount(1);
|
||||
await expect(nationPopup.getByText('유니크 경매장', { exact: true })).toHaveCount(1);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.locator('[data-bottom-menu="quick"]').click();
|
||||
const quickPopup = page.locator('#mobile-quick-menu');
|
||||
for (const heading of ['국가 정보', '동향 정보', '메시지']) {
|
||||
await expect(quickPopup.locator('.bottom-heading').getByText(heading, { exact: true })).toHaveCount(1);
|
||||
expect(documentGeometry.height).toBeGreaterThan(900);
|
||||
for (const selector of [
|
||||
'[data-main-target="commands"]',
|
||||
'[data-main-target="general"]',
|
||||
'[data-main-target="map"]',
|
||||
'[data-main-target="world-history"]',
|
||||
'.mobile-message-panel',
|
||||
]) {
|
||||
await expect(page.locator(selector)).toBeVisible();
|
||||
}
|
||||
await expect(quickPopup.locator('.bottom-heading')).toHaveCount(3);
|
||||
await expect(quickPopup.locator('.bottom-divider')).toHaveCount(3);
|
||||
await persistArtifact(page, `${basePath.slice(1)}-mobile-quick-open-500`);
|
||||
await page.keyboard.press('Escape');
|
||||
await persistArtifact(page, `${basePath.slice(1)}-mobile-500`);
|
||||
});
|
||||
|
||||
@@ -459,9 +420,7 @@ test('nation menu presentation follows the server-derived permission matrix', as
|
||||
);
|
||||
});
|
||||
|
||||
test('mobile quick navigation changes tabs before scrolling, refreshes once, and preserves tokens on lobby return', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('mobile single document refreshes once and preserves tokens on lobby return', async ({ page }) => {
|
||||
const state: NavigationFixture = {
|
||||
officerLevel: 5,
|
||||
permission: 2,
|
||||
@@ -475,35 +434,26 @@ test('mobile quick navigation changes tabs before scrolling, refreshes once, and
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await waitForMain(page);
|
||||
|
||||
const cases = [
|
||||
['policy', 'map', '[data-main-target="policy"]'],
|
||||
['commands', 'commands', '[data-main-target="commands"]'],
|
||||
['nation', 'status', '[data-main-target="nation"]'],
|
||||
['general', 'status', '[data-main-target="general"]'],
|
||||
['city', 'status', '[data-main-target="city"]'],
|
||||
['map', 'map', '[data-main-target="map"]'],
|
||||
['global-records', 'world', '[data-main-target="global-records"]'],
|
||||
['general-records', 'world', '[data-main-target="general-records"]'],
|
||||
['world-history', 'world', '[data-main-target="world-history"]'],
|
||||
['public-message', 'messages', '[data-message-type="public"]'],
|
||||
['national-message', 'messages', '[data-message-type="national"]'],
|
||||
['private-message', 'messages', '[data-message-type="private"]'],
|
||||
['diplomacy-message', 'messages', '[data-message-type="diplomacy"]'],
|
||||
] as const;
|
||||
|
||||
for (const [id, tab, selector] of cases) {
|
||||
await page.locator('[data-bottom-menu="quick"]').click();
|
||||
await page.locator(`[data-quick-id="${id}"]`).click();
|
||||
await expect(page.locator('.mobile-tabs button.active')).toHaveText(
|
||||
{ map: '지도', commands: '명령', status: '상태', world: '동향', messages: '메시지' }[tab]
|
||||
);
|
||||
for (const selector of [
|
||||
'[data-main-target="policy"]',
|
||||
'[data-main-target="commands"]',
|
||||
'[data-main-target="nation"]',
|
||||
'[data-main-target="general"]',
|
||||
'[data-main-target="city"]',
|
||||
'[data-main-target="map"]',
|
||||
'[data-main-target="global-records"]',
|
||||
'[data-main-target="general-records"]',
|
||||
'[data-main-target="world-history"]',
|
||||
'[data-message-type="public"]',
|
||||
'[data-message-type="national"]',
|
||||
'[data-message-type="private"]',
|
||||
'[data-message-type="diplomacy"]',
|
||||
]) {
|
||||
await expect(page.locator(selector)).toBeVisible();
|
||||
const targetTop = await page.locator(selector).evaluate((element) => element.getBoundingClientRect().top);
|
||||
expect(targetTop).toBeLessThan(855);
|
||||
}
|
||||
|
||||
const callsBeforeRefresh = state.generalMeCalls;
|
||||
await page.locator('[data-bottom-menu="refresh"]').click();
|
||||
await page.getByRole('button', { name: '갱 신' }).click();
|
||||
await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeRefresh);
|
||||
|
||||
await page.evaluate(() => {
|
||||
@@ -512,8 +462,7 @@ test('mobile quick navigation changes tabs before scrolling, refreshes once, and
|
||||
await page.route('**/gateway/', async (route) => {
|
||||
await route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><title>gateway</title>' });
|
||||
});
|
||||
await page.locator('[data-bottom-menu="quick"]').click();
|
||||
await Promise.all([page.waitForURL('**/gateway/'), page.getByRole('menuitem', { name: '로비로' }).click()]);
|
||||
await Promise.all([page.waitForURL('**/gateway/'), page.getByRole('button', { name: '로비로' }).click()]);
|
||||
expect(
|
||||
await page.evaluate(() => ({
|
||||
session: localStorage.getItem('sammo-session-token'),
|
||||
|
||||
@@ -60,12 +60,14 @@ const handleClick = () => {
|
||||
|
||||
<style scoped>
|
||||
.chief-card {
|
||||
border: 1px solid rgba(201, 164, 90, 0.3);
|
||||
background: rgba(12, 12, 12, 0.7);
|
||||
padding: 10px;
|
||||
min-width: 0;
|
||||
border: 1px solid #666;
|
||||
background-color: #071638;
|
||||
background-image: var(--sammo-texture-blue);
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.chief-card.clickable {
|
||||
@@ -84,7 +86,7 @@ const handleClick = () => {
|
||||
}
|
||||
|
||||
.chief-card.compact {
|
||||
padding: 8px;
|
||||
padding: 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -92,13 +94,18 @@ const handleClick = () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
gap: 2px;
|
||||
padding: 1px 4px;
|
||||
background-color: #143b28;
|
||||
background-image: var(--sammo-texture-green);
|
||||
}
|
||||
|
||||
.chief-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.chief-level {
|
||||
@@ -107,35 +114,58 @@ const handleClick = () => {
|
||||
}
|
||||
|
||||
.chief-name {
|
||||
font-size: 0.95rem;
|
||||
overflow: hidden;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chief-me {
|
||||
font-size: 0.65rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
border-radius: 0;
|
||||
background: rgba(201, 164, 90, 0.2);
|
||||
color: rgba(232, 221, 196, 0.8);
|
||||
}
|
||||
|
||||
.chief-rows {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.chief-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 56px 1fr;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid rgba(201, 164, 90, 0.15);
|
||||
grid-template-columns: 24px 48px minmax(0, 1fr);
|
||||
min-height: 22px;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
padding: 0 3px;
|
||||
border: 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.chief-card.compact .chief-row {
|
||||
grid-template-columns: 28px 48px 1fr;
|
||||
font-size: 0.7rem;
|
||||
grid-template-columns: 18px 36px minmax(0, 1fr);
|
||||
box-sizing: border-box;
|
||||
height: 13px;
|
||||
min-height: 0;
|
||||
padding: 0 2px;
|
||||
font-size: 0.55rem;
|
||||
line-height: 11px;
|
||||
}
|
||||
|
||||
.chief-card.compact .chief-header {
|
||||
box-sizing: border-box;
|
||||
height: 20px;
|
||||
min-height: 0;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.chief-card.compact .chief-level,
|
||||
.chief-card.compact .chief-name {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.chief-row.rest {
|
||||
|
||||
@@ -308,6 +308,10 @@ const chiefViews = computed(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
const overviewChiefViews = computed(() =>
|
||||
chiefViews.value.filter((chief) => chief.officerLevel !== selectedChief.value?.officerLevel)
|
||||
);
|
||||
|
||||
const selectedChiefRows = computed(() => {
|
||||
if (!selectedChief.value) {
|
||||
return [] as TurnRow[];
|
||||
@@ -409,18 +413,6 @@ const shiftTurns = async (amount: number) => {
|
||||
</section>
|
||||
|
||||
<section v-else-if="data && isMobile" class="layout-mobile">
|
||||
<PanelCard title="선택 사령부" subtitle="터치하여 사령턴 확인">
|
||||
<ChiefTurnCard
|
||||
v-if="selectedChief"
|
||||
:officer-level-text="formatOfficerLevelText(selectedChief.officerLevel, data.nation.level)"
|
||||
:name="selectedChief.name"
|
||||
:npc-state="selectedChief.npcState"
|
||||
:rows="selectedChiefRows"
|
||||
:is-me="selectedChief.officerLevel === data.me.officerLevel"
|
||||
/>
|
||||
<div v-else class="muted">선택된 사령이 없습니다.</div>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard v-if="isEditingAllowed" title="사령부 편집" subtitle="선택 명령을 배치하세요">
|
||||
<CommandSelectForm
|
||||
:command-table="chiefCommandTable"
|
||||
@@ -470,7 +462,7 @@ const shiftTurns = async (amount: number) => {
|
||||
<PanelCard title="전체 사령부" subtitle="8자리 전체 보기">
|
||||
<div class="chief-overview">
|
||||
<ChiefTurnCard
|
||||
v-for="chief in chiefViews"
|
||||
v-for="chief in overviewChiefViews"
|
||||
:key="chief.officerLevel"
|
||||
:officer-level-text="chief.officerLevelText"
|
||||
:name="chief.name"
|
||||
@@ -560,14 +552,14 @@ const shiftTurns = async (amount: number) => {
|
||||
<style scoped>
|
||||
.layout-desktop {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2.2fr) minmax(280px, 1fr);
|
||||
gap: 16px;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(240px, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.chief-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.chief-side {
|
||||
@@ -579,12 +571,13 @@ const shiftTurns = async (amount: number) => {
|
||||
.layout-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.chief-overview {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(4, 125px);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.command-selected {
|
||||
@@ -681,11 +674,55 @@ const shiftTurns = async (amount: number) => {
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.chief-page {
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
width: 500px;
|
||||
min-width: 500px;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.chief-overview {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(4, 125px);
|
||||
}
|
||||
|
||||
.turn-list {
|
||||
gap: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.turn-item {
|
||||
min-height: 24px;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.turn-info {
|
||||
flex: 1;
|
||||
flex-wrap: nowrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.turn-buttons {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.turn-buttons button {
|
||||
min-height: 20px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024.01px) {
|
||||
.chief-page {
|
||||
box-sizing: border-box;
|
||||
width: 1000px;
|
||||
max-width: 1000px;
|
||||
min-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,8 @@ import Link from '@tiptap/extension-link';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { resolveGeneralIconUrl } from '../utils/generalIcon';
|
||||
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||
|
||||
type DiplomacyResponse = Awaited<ReturnType<typeof trpc.diplomacy.getLetters.query>>;
|
||||
type DiplomacyLetter = DiplomacyResponse['letters'][number];
|
||||
@@ -191,13 +193,44 @@ const destroyLetter = async (letterId: number) => {
|
||||
|
||||
const prevOptions = computed(() => data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? []);
|
||||
|
||||
const formatDate = (value: string) => new Date(value).toLocaleString('ko-KR');
|
||||
const formatDate = (value: string) => formatSeoulDateTime(value);
|
||||
|
||||
const stateLabelMap: Record<DiplomacyLetter['state'], string> = {
|
||||
PROPOSED: '제안',
|
||||
ACTIVATED: '승인',
|
||||
CANCELLED: '종료',
|
||||
REPLACED: '대체',
|
||||
PROPOSED: '제안됨',
|
||||
ACTIVATED: '승인됨',
|
||||
CANCELLED: '거부됨',
|
||||
REPLACED: '대체됨',
|
||||
};
|
||||
|
||||
const stateOptionLabelMap: Record<string, string> = {
|
||||
try_destroy_src: '송신측의 파기 요청',
|
||||
try_destroy_dest: '수신측의 파기 요청',
|
||||
};
|
||||
|
||||
const targetNation = (letter: DiplomacyLetter) =>
|
||||
letter.src.nationId === data.value?.myNationId ? letter.dest : letter.src;
|
||||
|
||||
const isBrightColor = (color: string): boolean => {
|
||||
const normalized = color.trim().replace(/^#/u, '');
|
||||
if (!/^[0-9a-f]{6}$/iu.test(normalized)) return false;
|
||||
const red = Number.parseInt(normalized.slice(0, 2), 16);
|
||||
const green = Number.parseInt(normalized.slice(2, 4), 16);
|
||||
const blue = Number.parseInt(normalized.slice(4, 6), 16);
|
||||
return red * 0.299 + green * 0.587 + blue * 0.114 > 170;
|
||||
};
|
||||
|
||||
const nationStyle = (color: string) => ({
|
||||
backgroundColor: color || '#315f86',
|
||||
color: isBrightColor(color) ? '#000' : '#fff',
|
||||
});
|
||||
|
||||
const signerIcon = (signer: DiplomacyLetter['src'] | DiplomacyLetter['dest']): string | null => {
|
||||
if (signer.generalIcon) return signer.generalIcon;
|
||||
if (!signer.generalPicture) return null;
|
||||
return resolveGeneralIconUrl(
|
||||
{ picture: signer.generalPicture, imageServer: signer.generalImageServer },
|
||||
{ legacyBaseUrl: '/image/general' }
|
||||
);
|
||||
};
|
||||
|
||||
const toggleHistory = (letterId: number) => {
|
||||
@@ -233,43 +266,41 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<div class="diplomacy-view">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>외교부</h1>
|
||||
<p class="subtitle">외교 문서를 작성하고 버전 기록을 확인합니다.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="ghost" @click="loadLetters">수동 갱신</button>
|
||||
</div>
|
||||
<span>외 교 부</span>
|
||||
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
|
||||
</header>
|
||||
|
||||
<p v-if="errorMessage" class="error-text">{{ errorMessage }}</p>
|
||||
|
||||
<section class="panel" v-if="editable">
|
||||
<section v-if="editable" id="new-letter" class="panel new-letter">
|
||||
<div class="panel-header">
|
||||
<h2>새 외교 문서 작성</h2>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
이전 문서
|
||||
<label class="document-row select-row">
|
||||
<span class="row-label">이전 문서</span>
|
||||
<span class="row-content">
|
||||
<select v-model.number="selectedPrevId" @change="applyPrevLetter">
|
||||
<option :value="null">없음</option>
|
||||
<option :value="null">-새 문서-</option>
|
||||
<option v-for="letter in prevOptions" :key="letter.id" :value="letter.id">
|
||||
#{{ letter.id }} {{ letter.src.nationName }} ↔ {{ letter.dest.nationName }}
|
||||
#{{ letter.id }} <{{ targetNation(letter).nationName }}>
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
대상 국가
|
||||
</span>
|
||||
</label>
|
||||
<label class="document-row select-row">
|
||||
<span class="row-label">대상 국가</span>
|
||||
<span class="row-content">
|
||||
<select v-model.number="selectedDestNationId">
|
||||
<option v-for="nation in data?.nations ?? []" :key="nation.id" :value="nation.id">
|
||||
{{ nation.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="editor-group">
|
||||
<div class="editor-label">내용(국가 내 공개)</div>
|
||||
<div class="editor-toolbar">
|
||||
</span>
|
||||
</label>
|
||||
<div class="document-row editor-row">
|
||||
<div class="row-label">내용(국가 내 공개)</div>
|
||||
<div class="row-content editor-content">
|
||||
<div class="editor-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
@click="briefEditor?.chain().focus().toggleBold().run()"
|
||||
@@ -306,12 +337,14 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
이미지 업로드
|
||||
</button>
|
||||
</div>
|
||||
<EditorContent v-if="briefEditor" :editor="briefEditor" />
|
||||
</div>
|
||||
<EditorContent v-if="briefEditor" :editor="briefEditor" />
|
||||
</div>
|
||||
<div class="editor-group">
|
||||
<div class="editor-label">내용(외교권자 전용)</div>
|
||||
<div class="editor-toolbar">
|
||||
<div class="document-row editor-row">
|
||||
<div class="row-label">내용(외교권자 전용)</div>
|
||||
<div class="row-content editor-content">
|
||||
<div class="editor-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
@click="detailEditor?.chain().focus().toggleBold().run()"
|
||||
@@ -348,11 +381,15 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
이미지 업로드
|
||||
</button>
|
||||
</div>
|
||||
<EditorContent v-if="detailEditor" :editor="detailEditor" />
|
||||
</div>
|
||||
<EditorContent v-if="detailEditor" :editor="detailEditor" />
|
||||
</div>
|
||||
<div class="submit-row">
|
||||
<button type="button" class="primary" @click="sendLetter">전송</button>
|
||||
<div class="document-row action-row">
|
||||
<div class="row-label">동작</div>
|
||||
<div class="row-content">
|
||||
<button type="button" @click="sendLetter">전송</button>
|
||||
</div>
|
||||
</div>
|
||||
<input ref="fileInputRef" type="file" accept="image/*" class="hidden" @change="onSelectImage" />
|
||||
</section>
|
||||
@@ -363,27 +400,42 @@ onBeforeUnmount(() => {
|
||||
|
||||
<section class="letter-list">
|
||||
<article v-for="letter in data?.letters ?? []" :key="letter.id" class="letter-card">
|
||||
<header class="letter-header">
|
||||
<h3>#{{ letter.id }} {{ letter.src.nationName }} ↔ {{ letter.dest.nationName }}</h3>
|
||||
<div class="letter-meta">
|
||||
<span class="state-badge" :data-state="letter.state">{{ stateLabelMap[letter.state] }}</span>
|
||||
<span>{{ formatDate(letter.date) }}</span>
|
||||
</div>
|
||||
<header class="letter-header" :style="nationStyle(targetNation(letter).nationColor)">
|
||||
<h3>{{ targetNation(letter).nationName }}국과의 외교 문서</h3>
|
||||
<time>{{ formatDate(letter.date) }}</time>
|
||||
</header>
|
||||
<div class="letter-body">
|
||||
<div class="letter-section">
|
||||
<h4>내용(국가 내 공개)</h4>
|
||||
<div class="letter-text" v-html="letter.brief" />
|
||||
<div class="document-row compact-row">
|
||||
<div class="row-label">문서 번호</div>
|
||||
<div class="row-content">#{{ letter.id }}</div>
|
||||
</div>
|
||||
<div class="letter-section">
|
||||
<h4>내용(외교권자 전용)</h4>
|
||||
<div class="letter-text" v-html="letter.detail" />
|
||||
<div class="document-row compact-row">
|
||||
<div class="row-label">이전 문서</div>
|
||||
<div class="row-content">
|
||||
<button v-if="letter.prevId" type="button" class="text-button" @click="toggleHistory(letter.id)">
|
||||
#{{ letter.prevId }}
|
||||
</button>
|
||||
<span v-else>신규</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="letter.prevId" class="letter-history">
|
||||
<button type="button" class="ghost" @click="toggleHistory(letter.id)">
|
||||
이전 문서 {{ historyOpen[letter.id] ? '접기' : '보기' }}
|
||||
</button>
|
||||
<div v-if="historyOpen[letter.id]" class="history-panel">
|
||||
<div class="document-row compact-row">
|
||||
<div class="row-label">상태</div>
|
||||
<div class="row-content">
|
||||
{{ stateLabelMap[letter.state] }}
|
||||
<span v-if="letter.stateOpt">({{ stateOptionLabelMap[letter.stateOpt] ?? letter.stateOpt }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="document-row text-row">
|
||||
<div class="row-label">내용(국가 내 공개)</div>
|
||||
<div class="row-content letter-text" v-html="letter.brief" />
|
||||
</div>
|
||||
<div class="document-row text-row">
|
||||
<div class="row-label">내용(외교권자 전용)</div>
|
||||
<div class="row-content letter-text" v-html="letter.detail" />
|
||||
</div>
|
||||
<div v-if="letter.prevId && historyOpen[letter.id]" class="document-row history-row">
|
||||
<div class="row-label">이전 문서 내용</div>
|
||||
<div class="row-content history-panel">
|
||||
<template v-if="getPrevLetter(letter)">
|
||||
<p>
|
||||
#{{ getPrevLetter(letter)?.id }} {{ getPrevLetter(letter)?.src.nationName }} ↔
|
||||
@@ -394,234 +446,284 @@ onBeforeUnmount(() => {
|
||||
<p v-else class="hint">이전 문서를 찾을 수 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="document-row signer-row">
|
||||
<div class="row-label">서명인</div>
|
||||
<div class="row-content signer-plate">
|
||||
<div class="signer-card">
|
||||
<div class="signer-image">
|
||||
<img v-if="signerIcon(letter.src)" :src="signerIcon(letter.src)!" width="64" height="64" alt="" />
|
||||
</div>
|
||||
<div :style="nationStyle(letter.src.nationColor)">{{ letter.src.nationName }}</div>
|
||||
<div :style="nationStyle(letter.src.nationColor)">{{ letter.src.generalName ?? ' ' }}</div>
|
||||
</div>
|
||||
<div class="signer-card">
|
||||
<div class="signer-image">
|
||||
<img v-if="signerIcon(letter.dest)" :src="signerIcon(letter.dest)!" width="64" height="64" alt="" />
|
||||
</div>
|
||||
<div :style="nationStyle(letter.dest.nationColor)">{{ letter.dest.nationName }}</div>
|
||||
<div :style="nationStyle(letter.dest.nationColor)">{{ letter.dest.generalName ?? ' ' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="letter-actions">
|
||||
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, true)">
|
||||
승인
|
||||
</button>
|
||||
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, false, '거부')">
|
||||
거부
|
||||
</button>
|
||||
<button v-if="canRollback(letter)" type="button" @click="rollbackLetter(letter.id)">회수</button>
|
||||
<button v-if="canDestroy(letter)" type="button" @click="destroyLetter(letter.id)">파기</button>
|
||||
<button
|
||||
v-if="canRenew(letter)"
|
||||
type="button"
|
||||
@click="
|
||||
selectedPrevId = letter.id;
|
||||
applyPrevLetter();
|
||||
"
|
||||
>
|
||||
추가 문서 작성
|
||||
</button>
|
||||
<footer class="document-row letter-actions">
|
||||
<div class="row-label">동작</div>
|
||||
<div class="row-content">
|
||||
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, true)">승인</button>
|
||||
<button v-if="canRespond(letter)" type="button" @click="respondLetter(letter.id, false, '거부')">거부</button>
|
||||
<button v-if="canRollback(letter)" type="button" @click="rollbackLetter(letter.id)">회수</button>
|
||||
<button v-if="canDestroy(letter)" type="button" @click="destroyLetter(letter.id)">파기</button>
|
||||
<button
|
||||
v-if="canRenew(letter)"
|
||||
type="button"
|
||||
@click="
|
||||
selectedPrevId = letter.id;
|
||||
applyPrevLetter();
|
||||
"
|
||||
>
|
||||
추가 문서 작성
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div v-if="loading" class="loading">불러오는 중...</div>
|
||||
<footer class="page-footer">
|
||||
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.diplomacy-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
background: #0f1118;
|
||||
color: #e6e8ef;
|
||||
width: 1000px;
|
||||
min-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
background-color: #111;
|
||||
background-image: var(--sammo-texture-walnut);
|
||||
color: #fff;
|
||||
min-height: 100vh;
|
||||
overflow-x: clip;
|
||||
font-family: var(--sammo-font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
min-height: 54px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
align-content: flex-start;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #9aa3b8;
|
||||
margin: 4px 0 0;
|
||||
.page-header > span {
|
||||
flex-basis: 100%;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2b2f3f;
|
||||
background: #141826;
|
||||
color: #c7d0e0;
|
||||
.legacy-button {
|
||||
min-height: 34px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid #2d5d7f;
|
||||
border-radius: 4px;
|
||||
background: #315f86;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #181b26;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid #23283a;
|
||||
width: 1000px;
|
||||
margin: 10px auto;
|
||||
border: 1px solid #666;
|
||||
background-image: var(--sammo-texture-walnut);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
margin-bottom: 12px;
|
||||
height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
.panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.document-row {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
margin-bottom: 12px;
|
||||
grid-template-columns: 200px 800px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.form-grid select {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #2b2f3f;
|
||||
background: #0f1118;
|
||||
color: #f5f6fa;
|
||||
.row-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #14241b;
|
||||
background-image: var(--sammo-texture-green);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.editor-group {
|
||||
margin-bottom: 16px;
|
||||
.row-content {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.editor-label {
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
.select-row .row-content,
|
||||
.action-row .row-content,
|
||||
.compact-row .row-content,
|
||||
.letter-actions .row-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.select-row select {
|
||||
width: 300px;
|
||||
min-height: 34px;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 4px;
|
||||
background: #505050;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
position: relative;
|
||||
min-height: 54px;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 2px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms linear;
|
||||
}
|
||||
|
||||
.editor-toolbar button {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #2b2f3f;
|
||||
background: #1e2232;
|
||||
color: #d8dff0;
|
||||
.editor-content:focus-within .editor-toolbar {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.editor-toolbar button,
|
||||
.diplomacy-view button {
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 0;
|
||||
background: #666;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor-toolbar button.active {
|
||||
background: #3b425c;
|
||||
background: #315f86;
|
||||
}
|
||||
|
||||
.letter-editor {
|
||||
min-height: 160px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2b2f3f;
|
||||
background: #0f1118;
|
||||
color: #f5f6fa;
|
||||
.editor-content :deep(.letter-editor) {
|
||||
min-height: 54px;
|
||||
padding: 2px 4px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.letter-editor :deep(img) {
|
||||
.editor-content :deep(.letter-editor img),
|
||||
.letter-text :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.submit-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.primary {
|
||||
padding: 8px 18px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.letter-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.letter-card {
|
||||
background: #161a24;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid #202638;
|
||||
width: 1000px;
|
||||
margin: 10px auto;
|
||||
border: 1px solid #666;
|
||||
background-image: var(--sammo-texture-walnut);
|
||||
}
|
||||
|
||||
.letter-header {
|
||||
position: relative;
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.letter-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
color: #9aa3b8;
|
||||
font-size: 12px;
|
||||
.letter-header h3 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.state-badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
background: #2b3348;
|
||||
color: #e6e8ef;
|
||||
}
|
||||
|
||||
.state-badge[data-state='PROPOSED'] {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.state-badge[data-state='ACTIVATED'] {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.state-badge[data-state='REPLACED'] {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.state-badge[data-state='CANCELLED'] {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.letter-section {
|
||||
margin-top: 12px;
|
||||
.letter-header time {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 3px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.letter-text {
|
||||
padding: 8px 0;
|
||||
line-height: 1.6;
|
||||
min-height: 18px;
|
||||
padding: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.letter-history {
|
||||
margin-top: 10px;
|
||||
.letter-text :deep(p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.history-panel {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: #11131a;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.letter-actions {
|
||||
margin-top: 12px;
|
||||
.text-button {
|
||||
padding: 0;
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
color: #9cf !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.signer-plate {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.signer-card {
|
||||
width: 110px;
|
||||
border: 1px solid #888;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.signer-image {
|
||||
height: 64px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.signer-card > div + div {
|
||||
min-height: 18px;
|
||||
border-top: 1px solid #888;
|
||||
}
|
||||
|
||||
.letter-actions .row-content {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
min-height: 74px;
|
||||
border: 1px solid #666;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
@@ -634,9 +736,17 @@ onBeforeUnmount(() => {
|
||||
|
||||
.error-text {
|
||||
color: #f87171;
|
||||
border: 1px solid #a33;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: #9aa3b8;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.diplomacy-view {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
@@ -15,8 +15,6 @@ import RecordPanel from '../components/main/RecordPanel.vue';
|
||||
import MainFrontStatus from '../components/main/MainFrontStatus.vue';
|
||||
import MainGlobalMenu from '../components/main/MainGlobalMenu.vue';
|
||||
import MainNationMenu from '../components/main/MainNationMenu.vue';
|
||||
import MainMobileBottomBar from '../components/main/MainMobileBottomBar.vue';
|
||||
import type { QuickNavigationItem } from '../components/main/mainNavigation';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { useMainDashboardStore } from '../stores/mainDashboard';
|
||||
@@ -26,17 +24,6 @@ const session = useSessionStore();
|
||||
const dashboard = useMainDashboardStore();
|
||||
const isMobile = useMediaQuery('(max-width: 939.98px)');
|
||||
|
||||
const mobileTabs = [
|
||||
{ key: 'map', label: '지도' },
|
||||
{ key: 'commands', label: '명령' },
|
||||
{ key: 'status', label: '상태' },
|
||||
{ key: 'world', label: '동향' },
|
||||
{ key: 'messages', label: '메시지' },
|
||||
] as const;
|
||||
|
||||
type MobileTabKey = (typeof mobileTabs)[number]['key'];
|
||||
|
||||
const mobileTab = ref<MobileTabKey>('map');
|
||||
const tournamentStage = ref(0);
|
||||
const npcMode = ref(0);
|
||||
|
||||
@@ -123,13 +110,6 @@ const moveLobby = () => {
|
||||
window.location.replace(import.meta.env.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/');
|
||||
};
|
||||
|
||||
const quickNavigate = async (item: QuickNavigationItem) => {
|
||||
mobileTab.value = item.tab;
|
||||
await nextTick();
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
||||
document.querySelector<HTMLElement>(item.selector)?.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [session.isReady, session.hasGeneral],
|
||||
([ready, hasGeneral]) => {
|
||||
@@ -186,27 +166,7 @@ watch(
|
||||
</aside>
|
||||
|
||||
<section v-if="isMobile" class="layout-mobile">
|
||||
<div class="mobile-tabs">
|
||||
<button
|
||||
v-for="tab in mobileTabs"
|
||||
:key="tab.key"
|
||||
:class="{ active: mobileTab === tab.key }"
|
||||
@click="mobileTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="mobileTab === 'map'" class="mobile-panel">
|
||||
<PanelCard title="지도" data-main-target="map">
|
||||
<MapViewer :map-data="worldMap" :map-layout="mapLayout" :loading="loading" />
|
||||
</PanelCard>
|
||||
<PanelCard title="선택 도시">
|
||||
<SelectedCityPanel :city="selectedCity" :loading="loading" />
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
<div v-if="mobileTab === 'commands'" class="mobile-panel">
|
||||
<div class="mobile-panel">
|
||||
<PanelCard title="명령 목록" subtitle="예턴/명령 배치 영역" data-main-target="commands">
|
||||
<CommandListPanel
|
||||
:command-table="commandTable"
|
||||
@@ -223,7 +183,7 @@ watch(
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
<div v-if="mobileTab === 'status'" class="mobile-panel">
|
||||
<div class="mobile-panel">
|
||||
<PanelCard title="장수 스탯" data-main-target="general">
|
||||
<GeneralBasicCard :general="general" :loading="loading" />
|
||||
</PanelCard>
|
||||
@@ -235,7 +195,16 @@ watch(
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
<div v-if="mobileTab === 'world'" class="mobile-panel record-zone-mobile">
|
||||
<div class="mobile-panel">
|
||||
<PanelCard title="지도" data-main-target="map">
|
||||
<MapViewer :map-data="worldMap" :map-layout="mapLayout" :loading="loading" />
|
||||
</PanelCard>
|
||||
<PanelCard title="선택 도시">
|
||||
<SelectedCityPanel :city="selectedCity" :loading="loading" />
|
||||
</PanelCard>
|
||||
</div>
|
||||
|
||||
<div class="mobile-panel record-zone-mobile">
|
||||
<RecordPanel title="장수 동향" data-main-target="global-records">
|
||||
<SkeletonLines v-if="loading" :lines="4" />
|
||||
<div v-else-if="recordsError" class="record-error" role="alert">{{ recordsError }}</div>
|
||||
@@ -287,7 +256,7 @@ watch(
|
||||
:vote-active="voteActive"
|
||||
/>
|
||||
|
||||
<div v-if="mobileTab === 'messages'" class="mobile-panel">
|
||||
<div class="mobile-panel">
|
||||
<MessagePanel
|
||||
class="mobile-message-panel"
|
||||
:messages="messages"
|
||||
@@ -425,15 +394,6 @@ watch(
|
||||
:vote-active="voteActive"
|
||||
/>
|
||||
|
||||
<MainMobileBottomBar
|
||||
:access="nationAccess"
|
||||
:tournament-stage="tournamentStage"
|
||||
:nation-color="nationColor"
|
||||
:npc-mode="npcMode"
|
||||
@refresh="loadMainData"
|
||||
@lobby="moveLobby"
|
||||
@quick="quickNavigate"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -445,6 +405,17 @@ button {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.main-page {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 500px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
gap: 10px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.toggle.active {
|
||||
background: rgba(201, 164, 90, 0.2);
|
||||
}
|
||||
@@ -518,8 +489,8 @@ button {
|
||||
|
||||
.layout-desktop {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 1.4fr) minmax(320px, 1fr);
|
||||
gap: 16px;
|
||||
grid-template-columns: minmax(0, 2.35fr) minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stack {
|
||||
@@ -541,8 +512,7 @@ button {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
width: calc(100% + 48px);
|
||||
margin-left: -24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.world-history-panel {
|
||||
@@ -567,15 +537,13 @@ button {
|
||||
}
|
||||
|
||||
.record-zone-mobile {
|
||||
width: 100vw;
|
||||
margin-left: -24px;
|
||||
width: 500px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.mobile-message-panel {
|
||||
width: 100vw;
|
||||
width: 500px;
|
||||
min-width: 0;
|
||||
margin-left: -24px;
|
||||
}
|
||||
|
||||
.layout-mobile {
|
||||
@@ -584,23 +552,6 @@ button {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mobile-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mobile-tabs button {
|
||||
padding: 6px 4px;
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-tabs button.active {
|
||||
background: rgba(201, 164, 90, 0.2);
|
||||
}
|
||||
|
||||
.mobile-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -617,16 +568,12 @@ button {
|
||||
|
||||
@media (max-width: 939.98px) {
|
||||
.main-page {
|
||||
padding-bottom: 61px;
|
||||
}
|
||||
|
||||
.desktop-action-controls {
|
||||
display: none;
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
.survey-notice {
|
||||
z-index: 90;
|
||||
bottom: 61px;
|
||||
bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user