448 lines
14 KiB
Vue
448 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
import type { MessageType } from '@sammo-ts/logic';
|
|
import { DEFAULT_GENERAL_ICON_URL, resolveMessageGeneralIconUrl, useDefaultGeneralIcon } from '../../utils/generalIcon';
|
|
|
|
interface MessageTarget {
|
|
generalId: number;
|
|
generalName: string;
|
|
nationId: number;
|
|
nationName: string;
|
|
color: string;
|
|
icon: string;
|
|
}
|
|
|
|
interface MessageEntry {
|
|
id: number;
|
|
text: string;
|
|
time: string;
|
|
msgType: MessageType;
|
|
src: MessageTarget;
|
|
dest: MessageTarget | null;
|
|
option?: Record<string, unknown> | null;
|
|
}
|
|
|
|
const props = defineProps<{
|
|
message: MessageEntry;
|
|
generalId: number;
|
|
generalName: string;
|
|
nationId: number;
|
|
permission: number;
|
|
canRespondDiplomacy: boolean;
|
|
replyableGeneralIds: number[];
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
(event: 'set-target', type: MessageType, target: MessageTarget): void;
|
|
(event: 'delete', messageId: number): void;
|
|
(event: 'respond', messageId: number, response: boolean): void;
|
|
}>();
|
|
|
|
const now = ref(Date.now());
|
|
let deleteTimer: number | null = null;
|
|
|
|
const destination = computed<MessageTarget>(
|
|
() =>
|
|
props.message.dest ?? {
|
|
generalId: 0,
|
|
generalName: '',
|
|
nationId: 0,
|
|
nationName: '재야',
|
|
color: '#000000',
|
|
icon: DEFAULT_GENERAL_ICON_URL,
|
|
}
|
|
);
|
|
|
|
const invalid = computed(() => props.message.option?.invalid === true);
|
|
const hasAction = computed(() => typeof props.message.option?.action === 'string');
|
|
const nationDirection = computed(() => {
|
|
if (props.message.src.nationId === destination.value.nationId) {
|
|
return 'local';
|
|
}
|
|
return props.message.src.nationId === props.nationId ? 'src' : 'dest';
|
|
});
|
|
|
|
const parseMessageTime = (): number => {
|
|
const normalized = props.message.time.includes('T')
|
|
? props.message.time
|
|
: `${props.message.time.replace(' ', 'T')}Z`;
|
|
return Date.parse(normalized);
|
|
};
|
|
|
|
const deletable = computed(() => {
|
|
if (invalid.value || hasAction.value || props.message.src.generalId !== props.generalId) {
|
|
return false;
|
|
}
|
|
if (props.message.option?.deletable === false) {
|
|
return false;
|
|
}
|
|
const sentAt = parseMessageTime();
|
|
return Number.isFinite(sentAt) && sentAt + 5 * 60 * 1000 > now.value;
|
|
});
|
|
|
|
const scheduleDeleteExpiry = () => {
|
|
const sentAt = parseMessageTime();
|
|
if (!Number.isFinite(sentAt)) {
|
|
return;
|
|
}
|
|
const delay = sentAt + 5 * 60 * 1000 - Date.now();
|
|
if (delay <= 0) {
|
|
now.value = Date.now();
|
|
return;
|
|
}
|
|
deleteTimer = window.setTimeout(() => {
|
|
now.value = Date.now();
|
|
}, delay);
|
|
};
|
|
|
|
const isBright = (color: string): boolean => {
|
|
const match = /^#([0-9a-f]{6})$/i.exec(color);
|
|
if (!match) {
|
|
return false;
|
|
}
|
|
const value = Number.parseInt(match[1]!, 16);
|
|
const red = (value >> 16) & 0xff;
|
|
const green = (value >> 8) & 0xff;
|
|
const blue = value & 0xff;
|
|
return red * 0.299 + green * 0.587 + blue * 0.114 > 160;
|
|
};
|
|
|
|
const iconUrl = computed(() => resolveMessageGeneralIconUrl(props.message.src.icon));
|
|
const canReplyToGeneral = (target: MessageTarget): boolean => props.replyableGeneralIds.includes(target.generalId);
|
|
|
|
const targetClass = (target: MessageTarget) => ({
|
|
'msg-target': true,
|
|
'msg-bright': isBright(target.color),
|
|
'msg-dark': !isBright(target.color),
|
|
});
|
|
|
|
const setTarget = (target: MessageTarget) => {
|
|
emit('set-target', props.message.msgType, target);
|
|
};
|
|
|
|
const requestDelete = () => {
|
|
if (!window.confirm('삭제하시겠습니까?')) {
|
|
return;
|
|
}
|
|
emit('delete', props.message.id);
|
|
};
|
|
|
|
const respond = (response: boolean) => {
|
|
if (!window.confirm(response ? '수락하시겠습니까?' : '거절하시겠습니까?')) {
|
|
return;
|
|
}
|
|
emit('respond', props.message.id, response);
|
|
};
|
|
|
|
onMounted(scheduleDeleteExpiry);
|
|
onBeforeUnmount(() => {
|
|
if (deleteTimer !== null) {
|
|
window.clearTimeout(deleteTimer);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<article
|
|
:id="`msg_${message.id}`"
|
|
:class="['msg-plate', `msg-plate-${message.msgType}`, `msg-plate-${nationDirection}`]"
|
|
:data-id="message.id"
|
|
>
|
|
<div class="msg-icon">
|
|
<img
|
|
class="general-icon"
|
|
width="64"
|
|
height="64"
|
|
:src="iconUrl"
|
|
:alt="message.src.generalName"
|
|
@error="useDefaultGeneralIcon"
|
|
/>
|
|
</div>
|
|
<div class="msg-body">
|
|
<div class="msg-header">
|
|
<button v-if="deletable" class="delete-message" type="button" @click="requestDelete">❌</button>
|
|
|
|
<template v-if="message.msgType === 'private'">
|
|
<template v-if="message.src.generalId === generalId">
|
|
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }"
|
|
>나</span
|
|
>
|
|
<span class="msg-from-to">▶</span>
|
|
<button
|
|
v-if="canReplyToGeneral(destination)"
|
|
:class="targetClass(destination)"
|
|
:style="{ backgroundColor: destination.color }"
|
|
type="button"
|
|
@click="setTarget(destination)"
|
|
>
|
|
{{ destination.generalName }}:{{ destination.nationName }} | ↩
|
|
</button>
|
|
<span v-else :class="targetClass(destination)" :style="{ backgroundColor: destination.color }">
|
|
{{ destination.generalName }}:{{ destination.nationName }}
|
|
</span>
|
|
</template>
|
|
<template v-else>
|
|
<button
|
|
v-if="canReplyToGeneral(message.src)"
|
|
:class="targetClass(message.src)"
|
|
:style="{ backgroundColor: message.src.color }"
|
|
type="button"
|
|
@click="setTarget(message.src)"
|
|
>
|
|
{{ message.src.generalName }}:{{ message.src.nationName }} | ↩
|
|
</button>
|
|
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
|
|
{{ message.src.generalName }}:{{ message.src.nationName }}
|
|
</span>
|
|
<span class="msg-from-to">▶</span>
|
|
<span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }"
|
|
>나</span
|
|
>
|
|
</template>
|
|
</template>
|
|
|
|
<template v-else-if="message.msgType === 'national' && message.src.nationId === destination.nationId">
|
|
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
|
|
{{ message.src.generalName }}
|
|
</span>
|
|
</template>
|
|
|
|
<template
|
|
v-else-if="(message.msgType === 'national' || message.msgType === 'diplomacy') && permission >= 4"
|
|
>
|
|
<template v-if="message.src.nationId === nationId">
|
|
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
|
|
{{ message.src.generalName }}
|
|
</span>
|
|
<span class="msg-from-to">▶</span>
|
|
<button
|
|
:class="targetClass(destination)"
|
|
:style="{ backgroundColor: destination.color }"
|
|
type="button"
|
|
@click="setTarget(destination)"
|
|
>
|
|
{{ destination.nationName }} | ↩
|
|
</button>
|
|
</template>
|
|
<button
|
|
v-else
|
|
:class="targetClass(message.src)"
|
|
:style="{ backgroundColor: message.src.color }"
|
|
type="button"
|
|
@click="setTarget(message.src)"
|
|
>
|
|
{{ message.src.generalName }}:{{ message.src.nationName }} | ↩
|
|
</button>
|
|
</template>
|
|
|
|
<template v-else-if="message.msgType === 'national' || message.msgType === 'diplomacy'">
|
|
<template v-if="message.src.nationId === nationId">
|
|
<span :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
|
|
{{ message.src.generalName }}
|
|
</span>
|
|
<span class="msg-from-to">▶</span>
|
|
<span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }">
|
|
{{ destination.nationName }}
|
|
</span>
|
|
</template>
|
|
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
|
|
{{ message.src.generalName }}:{{ message.src.nationName }}
|
|
</span>
|
|
</template>
|
|
|
|
<button
|
|
v-else-if="message.src.generalId !== generalId && canReplyToGeneral(message.src)"
|
|
:class="targetClass(message.src)"
|
|
:style="{ backgroundColor: message.src.color }"
|
|
type="button"
|
|
@click="setTarget(message.src)"
|
|
>
|
|
{{ message.src.generalName }}:{{ message.src.nationName }} | ↩
|
|
</button>
|
|
<span
|
|
v-else-if="message.src.generalId !== generalId"
|
|
:class="targetClass(message.src)"
|
|
:style="{ backgroundColor: message.src.color }"
|
|
>
|
|
{{ message.src.generalName }}:{{ message.src.nationName }}
|
|
</span>
|
|
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
|
|
{{ message.src.generalName }}
|
|
</span>
|
|
|
|
<span class="msg-time"><{{ message.time }}></span>
|
|
</div>
|
|
|
|
<div :class="['msg-content', invalid ? 'msg-invalid' : 'msg-valid']">
|
|
{{ invalid ? '삭제된 메시지입니다' : message.text }}
|
|
</div>
|
|
|
|
<div v-if="hasAction" class="message-response">
|
|
<button
|
|
class="prompt-yes"
|
|
type="button"
|
|
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
|
|
@click="respond(true)"
|
|
>
|
|
수락
|
|
</button>
|
|
<button
|
|
class="prompt-no"
|
|
type="button"
|
|
:disabled="message.msgType === 'diplomacy' && !canRespondDiplomacy"
|
|
@click="respond(false)"
|
|
>
|
|
거절
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.msg-plate {
|
|
display: grid;
|
|
grid-template-columns: 64px minmax(0, 1fr);
|
|
width: 100%;
|
|
min-height: 64px;
|
|
outline: 1px solid gray;
|
|
color: #fff;
|
|
font-size: 12.5px;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.msg-plate-private {
|
|
background-color: #5d1e1a;
|
|
}
|
|
|
|
.msg-plate-private.msg-plate-dest {
|
|
background-color: #5d461a;
|
|
}
|
|
|
|
.msg-plate-public {
|
|
background-color: #141c65;
|
|
}
|
|
|
|
.msg-plate-national,
|
|
.msg-plate-diplomacy {
|
|
background-color: #00582c;
|
|
}
|
|
|
|
.msg-plate-national.msg-plate-dest,
|
|
.msg-plate-diplomacy.msg-plate-dest {
|
|
background-color: #704615;
|
|
}
|
|
|
|
.msg-plate-national.msg-plate-src,
|
|
.msg-plate-diplomacy.msg-plate-src {
|
|
background-color: #70153b;
|
|
}
|
|
|
|
.msg-icon {
|
|
width: 64px;
|
|
height: 64px;
|
|
border-right: 1px solid gray;
|
|
}
|
|
|
|
.general-icon {
|
|
display: block;
|
|
width: 64px;
|
|
max-width: none;
|
|
height: 64px;
|
|
object-fit: fill;
|
|
}
|
|
|
|
.msg-body {
|
|
min-width: 0;
|
|
padding-left: 0;
|
|
}
|
|
|
|
.msg-header {
|
|
position: relative;
|
|
margin-bottom: 3px;
|
|
color: #fff;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.msg-target {
|
|
display: inline-block;
|
|
margin: 2px 2px 0;
|
|
border: 0;
|
|
border-radius: 3px;
|
|
padding: 2px 3px;
|
|
box-shadow: 2px 2px #000;
|
|
font: inherit;
|
|
font-weight: inherit;
|
|
}
|
|
|
|
button.msg-target {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.msg-bright {
|
|
color: #000;
|
|
}
|
|
|
|
.msg-dark {
|
|
color: #fff;
|
|
}
|
|
|
|
.msg-from-to {
|
|
display: inline-block;
|
|
}
|
|
|
|
.msg-time {
|
|
font-size: 0.75em;
|
|
font-weight: 400;
|
|
}
|
|
|
|
.delete-message {
|
|
position: absolute;
|
|
z-index: 1;
|
|
top: 0;
|
|
right: 0;
|
|
margin: 2px 2px 0;
|
|
border: 1px solid #ffc107;
|
|
border-radius: 3px;
|
|
background: transparent;
|
|
padding: 2px 4px;
|
|
color: #ffc107;
|
|
font-size: 8px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.msg-content {
|
|
overflow: hidden;
|
|
margin-right: 5px;
|
|
margin-left: 10px;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.msg-invalid {
|
|
color: rgba(255, 255, 255, 0.5);
|
|
}
|
|
|
|
.message-response {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0;
|
|
margin-top: 5px;
|
|
margin-right: 5px;
|
|
}
|
|
|
|
.message-response button {
|
|
min-width: 42px;
|
|
border: 1px outset buttonborder;
|
|
background: buttonface;
|
|
padding: 1px 6px;
|
|
color: buttontext;
|
|
font-size: 12.5px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.message-response button:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.65;
|
|
}
|
|
</style>
|