688 lines
18 KiB
Vue
688 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||
import axios from 'axios';
|
||
import { api } from '@/lib/api';
|
||
import type {
|
||
ConversationAccessDto,
|
||
ConversationPeerDto,
|
||
ConversationRoomDto,
|
||
} from '@mento/shared';
|
||
import { formatClockFa, toFaDigits } from '@/lib/jalali';
|
||
import { useOrgChat } from '@/lib/useOrgChat';
|
||
import UiIcon from '@/components/UiIcon.vue';
|
||
import UserAvatar from '@/components/UserAvatar.vue';
|
||
|
||
const access = ref<ConversationAccessDto | null>(null);
|
||
const rooms = ref<ConversationRoomDto[]>([]);
|
||
const peers = ref<ConversationPeerDto[]>([]);
|
||
const loading = ref(true);
|
||
const error = ref('');
|
||
const activeRoom = ref<ConversationRoomDto | null>(null);
|
||
const draft = ref('');
|
||
const showNewDm = ref(false);
|
||
const showNewGroup = ref(false);
|
||
const groupName = ref('');
|
||
const groupMemberIds = ref<string[]>([]);
|
||
const dmPeerId = ref('');
|
||
|
||
const { messages, loadingMessages, status, loadMessages, sendText, sendRead } = useOrgChat();
|
||
const msgStreamEl = ref<HTMLElement | null>(null);
|
||
|
||
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
|
||
|
||
async function scrollToBottom() {
|
||
await nextTick();
|
||
const el = msgStreamEl.value;
|
||
if (!el) return;
|
||
el.scrollTop = el.scrollHeight;
|
||
}
|
||
|
||
const sortedRooms = computed(() =>
|
||
[...rooms.value].sort((a, b) => {
|
||
const ta = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0;
|
||
const tb = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0;
|
||
return tb - ta;
|
||
}),
|
||
);
|
||
|
||
const chatUserId = ref('');
|
||
|
||
function roomTitle(room: ConversationRoomDto) {
|
||
if (room.kind === 'group') return room.name;
|
||
const peer = peers.value.find((p) => p.id === room.peerUserId);
|
||
return peer?.fullName ?? room.name;
|
||
}
|
||
|
||
function formatListTime(iso: string | null) {
|
||
if (!iso) return '';
|
||
try {
|
||
return formatClockFa(new Date(iso));
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
async function bootstrap() {
|
||
loading.value = true;
|
||
error.value = '';
|
||
try {
|
||
const accessRes = await api.get<ConversationAccessDto>('/conversations/access');
|
||
access.value = accessRes.data;
|
||
if (!accessRes.data.canUse) {
|
||
error.value = 'به گفتگو دسترسی ندارید.';
|
||
return;
|
||
}
|
||
|
||
const [roomsOutcome, peersOutcome, sessionOutcome] = await Promise.allSettled([
|
||
api.get<ConversationRoomDto[]>('/conversations/rooms'),
|
||
api.get<ConversationPeerDto[]>('/conversations/peers'),
|
||
api.get<{ chatUserId: string }>('/conversations/session'),
|
||
]);
|
||
|
||
if (peersOutcome.status === 'fulfilled') {
|
||
peers.value = peersOutcome.value.data;
|
||
}
|
||
if (sessionOutcome.status === 'fulfilled') {
|
||
chatUserId.value = sessionOutcome.value.data.chatUserId;
|
||
}
|
||
if (roomsOutcome.status === 'fulfilled') {
|
||
rooms.value = roomsOutcome.value.data;
|
||
} else {
|
||
const e = roomsOutcome.reason;
|
||
if (axios.isAxiosError(e)) {
|
||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||
error.value = Array.isArray(msg)
|
||
? msg.join('، ')
|
||
: msg ||
|
||
(e.response?.status === 503 || e.response?.status === 502
|
||
? 'اتصال به سرویس چت برقرار نشد. Host و API Key را در سوپرادمین بررسی کنید.'
|
||
: 'بارگذاری لیست گفتگو ناموفق بود.');
|
||
} else {
|
||
error.value = 'بارگذاری لیست گفتگو ناموفق بود.';
|
||
}
|
||
}
|
||
} catch (e) {
|
||
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
||
error.value = 'ماژول گفتگو برای این محیط فعال نیست.';
|
||
} else if (axios.isAxiosError(e)) {
|
||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||
error.value = Array.isArray(msg)
|
||
? msg.join('، ')
|
||
: msg || 'بارگذاری گفتگو ناموفق بود.';
|
||
} else {
|
||
error.value = 'بارگذاری گفتگو ناموفق بود.';
|
||
}
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function openRoom(room: ConversationRoomDto) {
|
||
activeRoom.value = room;
|
||
await loadMessages(room.externalRoomId);
|
||
sendRead(room.externalRoomId);
|
||
}
|
||
|
||
async function submitMessage() {
|
||
if (!activeRoom.value || !draft.value.trim()) return;
|
||
try {
|
||
await sendText(activeRoom.value.externalRoomId, draft.value);
|
||
draft.value = '';
|
||
} catch {
|
||
error.value = 'ارسال پیام ناموفق بود.';
|
||
}
|
||
}
|
||
|
||
async function startDm() {
|
||
if (!dmPeerId.value) return;
|
||
try {
|
||
const { data } = await api.post<ConversationRoomDto>('/conversations/rooms/dm', {
|
||
peerUserId: dmPeerId.value,
|
||
});
|
||
rooms.value = [data, ...rooms.value.filter((r) => r.externalRoomId !== data.externalRoomId)];
|
||
showNewDm.value = false;
|
||
dmPeerId.value = '';
|
||
await openRoom(data);
|
||
} catch {
|
||
error.value = 'شروع گفتگوی جدید ناموفق بود.';
|
||
}
|
||
}
|
||
|
||
async function startGroup() {
|
||
if (!groupName.value.trim() || !groupMemberIds.value.length) return;
|
||
try {
|
||
const { data } = await api.post<ConversationRoomDto>('/conversations/rooms/group', {
|
||
name: groupName.value.trim(),
|
||
memberUserIds: groupMemberIds.value,
|
||
});
|
||
rooms.value = [data, ...rooms.value];
|
||
showNewGroup.value = false;
|
||
groupName.value = '';
|
||
groupMemberIds.value = [];
|
||
await openRoom(data);
|
||
} catch (e) {
|
||
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
||
error.value = 'اجازه ساخت گروه را ندارید.';
|
||
} else {
|
||
error.value = 'ساخت گروه ناموفق بود.';
|
||
}
|
||
}
|
||
}
|
||
|
||
function toggleGroupMember(id: string) {
|
||
if (groupMemberIds.value.includes(id)) {
|
||
groupMemberIds.value = groupMemberIds.value.filter((x) => x !== id);
|
||
} else {
|
||
groupMemberIds.value = [...groupMemberIds.value, id];
|
||
}
|
||
}
|
||
|
||
watch(activeRoom, () => {
|
||
draft.value = '';
|
||
});
|
||
|
||
watch(
|
||
() => messages.value.length,
|
||
() => {
|
||
void scrollToBottom();
|
||
},
|
||
);
|
||
|
||
watch(loadingMessages, (v) => {
|
||
if (!v) void scrollToBottom();
|
||
});
|
||
|
||
onMounted(() => {
|
||
void bootstrap();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="conv-page">
|
||
<div class="conv-aurora" aria-hidden="true" />
|
||
|
||
<header class="conv-head">
|
||
<div>
|
||
<p class="eyebrow">درون سازمان</p>
|
||
<h1>گفتگو</h1>
|
||
<p class="sub">فضای زنده برای هماهنگی تیم — بدون پیامرسان بیرونی.</p>
|
||
</div>
|
||
<div class="head-actions">
|
||
<span class="ws-pill" :data-state="status">{{ status === 'open' ? 'آنلاین' : '…' }}</span>
|
||
</div>
|
||
</header>
|
||
|
||
<p v-if="error" class="err-banner">{{ error }}</p>
|
||
<p v-else-if="loading" class="muted center">در حال آمادهسازی…</p>
|
||
|
||
<div v-else class="conv-shell">
|
||
<aside class="conv-rail">
|
||
<div class="rail-actions">
|
||
<button type="button" class="fab" @click="showNewDm = true">+ گفتگو</button>
|
||
<button
|
||
v-if="canCreateGroup"
|
||
type="button"
|
||
class="fab ghost"
|
||
@click="showNewGroup = true"
|
||
>
|
||
گروه
|
||
</button>
|
||
</div>
|
||
<ul class="room-list">
|
||
<li v-for="room in sortedRooms" :key="room.externalRoomId">
|
||
<button
|
||
type="button"
|
||
class="room-chip"
|
||
:class="{ active: activeRoom?.externalRoomId === room.externalRoomId }"
|
||
@click="openRoom(room)"
|
||
>
|
||
<span class="avatar-ring">
|
||
<UserAvatar :name="roomTitle(room)" :size="40" />
|
||
</span>
|
||
<span class="room-meta">
|
||
<strong>{{ roomTitle(room) }}</strong>
|
||
<small>{{ room.lastMessagePreview || 'بدون پیام' }}</small>
|
||
</span>
|
||
<span v-if="room.unreadCount" class="unread">{{ toFaDigits(room.unreadCount) }}</span>
|
||
<time>{{ formatListTime(room.lastMessageAt) }}</time>
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
</aside>
|
||
|
||
<section class="conv-stage">
|
||
<div v-if="!activeRoom" class="empty-stage">
|
||
<div class="empty-glow" />
|
||
<UiIcon name="chat" :size="48" />
|
||
<h2>یک مکالمه را انتخاب کنید</h2>
|
||
<p>یا گفتگوی تازه با همکاران خود بسازید.</p>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<div class="stage-top">
|
||
<h2>{{ roomTitle(activeRoom) }}</h2>
|
||
<span class="kind-tag">{{ activeRoom.kind === 'group' ? 'گروه' : 'خصوصی' }}</span>
|
||
</div>
|
||
|
||
<div v-if="loadingMessages" class="muted center pad">در حال دریافت پیامها…</div>
|
||
<ul v-else ref="msgStreamEl" class="msg-stream">
|
||
<li
|
||
v-for="msg in messages"
|
||
:key="msg.id"
|
||
class="msg-row"
|
||
:class="{ mine: msg.senderId === chatUserId || msg.optimistic }"
|
||
>
|
||
<div class="bubble">
|
||
<p>{{ msg.content }}</p>
|
||
<time>{{ formatListTime(msg.createdAt) }}</time>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
|
||
<form class="composer" @submit.prevent="submitMessage">
|
||
<input v-model="draft" type="text" placeholder="پیام خود را بنویسید…" autocomplete="off" />
|
||
<button type="submit" :disabled="!draft.trim()">
|
||
<UiIcon name="send" :size="18" />
|
||
</button>
|
||
</form>
|
||
</template>
|
||
</section>
|
||
</div>
|
||
|
||
<dialog v-if="showNewDm" class="modal" open @click.self="showNewDm = false">
|
||
<div class="modal-card">
|
||
<h3>گفتگوی جدید</h3>
|
||
<select v-model="dmPeerId">
|
||
<option value="">همکار را انتخاب کنید</option>
|
||
<option v-for="p in peers" :key="p.id" :value="p.id">{{ p.fullName }}</option>
|
||
</select>
|
||
<div class="modal-actions">
|
||
<button type="button" @click="showNewDm = false">انصراف</button>
|
||
<button type="button" class="primary" @click="startDm">شروع</button>
|
||
</div>
|
||
</div>
|
||
</dialog>
|
||
|
||
<dialog v-if="showNewGroup" class="modal" open @click.self="showNewGroup = false">
|
||
<div class="modal-card wide">
|
||
<h3>گروه جدید</h3>
|
||
<input v-model="groupName" type="text" placeholder="نام گروه" />
|
||
<p class="hint">اعضا</p>
|
||
<div class="member-grid">
|
||
<label v-for="p in peers" :key="p.id" class="member-check">
|
||
<input
|
||
type="checkbox"
|
||
:checked="groupMemberIds.includes(p.id)"
|
||
@change="toggleGroupMember(p.id)"
|
||
/>
|
||
{{ p.fullName }}
|
||
</label>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button type="button" @click="showNewGroup = false">انصراف</button>
|
||
<button type="button" class="primary" @click="startGroup">ساخت گروه</button>
|
||
</div>
|
||
</div>
|
||
</dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.conv-page {
|
||
position: relative;
|
||
min-height: calc(100vh - 4rem);
|
||
padding: 1rem 1.25rem 1.5rem;
|
||
overflow: hidden;
|
||
}
|
||
.conv-aurora {
|
||
position: absolute;
|
||
inset: -20% -10% auto;
|
||
height: 320px;
|
||
background:
|
||
radial-gradient(ellipse 60% 80% at 85% 0%, rgba(244, 114, 182, 0.22), transparent 60%),
|
||
radial-gradient(ellipse 50% 70% at 10% 10%, rgba(45, 212, 191, 0.2), transparent 55%);
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
}
|
||
.conv-head {
|
||
position: relative;
|
||
z-index: 1;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 1rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
.eyebrow {
|
||
font-size: 0.75rem;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--muted);
|
||
margin: 0 0 0.25rem;
|
||
}
|
||
.conv-head h1 {
|
||
margin: 0;
|
||
font-size: 1.75rem;
|
||
font-weight: 800;
|
||
background: linear-gradient(120deg, #0f766e, #7c3aed 55%, #db2777);
|
||
-webkit-background-clip: text;
|
||
background-clip: text;
|
||
color: transparent;
|
||
}
|
||
.sub {
|
||
margin: 0.35rem 0 0;
|
||
color: var(--muted);
|
||
font-size: 0.92rem;
|
||
}
|
||
.head-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
.ws-pill {
|
||
font-size: 0.75rem;
|
||
padding: 0.25rem 0.65rem;
|
||
border-radius: 999px;
|
||
background: rgba(15, 118, 110, 0.12);
|
||
color: #0f766e;
|
||
}
|
||
.ws-pill[data-state='open'] {
|
||
box-shadow: 0 0 0 1px rgba(45, 212, 191, 0.35);
|
||
}
|
||
.gear {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 2.25rem;
|
||
height: 2.25rem;
|
||
border-radius: 12px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--line);
|
||
color: var(--text);
|
||
}
|
||
.err-banner {
|
||
background: #fef2f2;
|
||
color: #b91c1c;
|
||
padding: 0.65rem 0.85rem;
|
||
border-radius: 12px;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
.conv-shell {
|
||
position: relative;
|
||
z-index: 1;
|
||
display: grid;
|
||
grid-template-columns: minmax(260px, 320px) 1fr;
|
||
gap: 0.75rem;
|
||
min-height: 520px;
|
||
}
|
||
@media (max-width: 900px) {
|
||
.conv-shell {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
.conv-rail {
|
||
background: rgba(255, 255, 255, 0.72);
|
||
backdrop-filter: blur(12px);
|
||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||
border-radius: 20px;
|
||
padding: 0.75rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
}
|
||
.rail-actions {
|
||
display: flex;
|
||
gap: 0.4rem;
|
||
}
|
||
.fab {
|
||
flex: 1;
|
||
border: none;
|
||
border-radius: 14px;
|
||
padding: 0.55rem 0.65rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
background: linear-gradient(135deg, #0d9488, #6366f1);
|
||
color: #fff;
|
||
}
|
||
.fab.ghost {
|
||
background: transparent;
|
||
border: 1px dashed rgba(99, 102, 241, 0.45);
|
||
color: #4338ca;
|
||
}
|
||
.room-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
overflow: auto;
|
||
max-height: calc(100vh - 14rem);
|
||
}
|
||
.room-chip {
|
||
width: 100%;
|
||
display: grid;
|
||
grid-template-columns: auto 1fr auto;
|
||
grid-template-rows: auto auto;
|
||
gap: 0.15rem 0.65rem;
|
||
align-items: center;
|
||
text-align: right;
|
||
border: none;
|
||
background: transparent;
|
||
padding: 0.55rem 0.45rem;
|
||
border-radius: 14px;
|
||
cursor: pointer;
|
||
transition: background 0.15s ease;
|
||
}
|
||
.room-chip:hover,
|
||
.room-chip.active {
|
||
background: rgba(99, 102, 241, 0.08);
|
||
}
|
||
.avatar-ring {
|
||
grid-row: span 2;
|
||
border-radius: 999px;
|
||
padding: 2px;
|
||
background: linear-gradient(135deg, #2dd4bf, #a78bfa);
|
||
}
|
||
.room-meta strong {
|
||
display: block;
|
||
font-size: 0.92rem;
|
||
}
|
||
.room-meta small {
|
||
color: var(--muted);
|
||
font-size: 0.78rem;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
max-width: 160px;
|
||
display: block;
|
||
}
|
||
.unread {
|
||
grid-column: 3;
|
||
grid-row: 1;
|
||
background: #db2777;
|
||
color: #fff;
|
||
font-size: 0.7rem;
|
||
min-width: 1.25rem;
|
||
height: 1.25rem;
|
||
border-radius: 999px;
|
||
display: grid;
|
||
place-items: center;
|
||
}
|
||
.room-chip time {
|
||
grid-column: 3;
|
||
grid-row: 2;
|
||
font-size: 0.68rem;
|
||
color: var(--muted);
|
||
}
|
||
.conv-stage {
|
||
background: rgba(255, 255, 255, 0.85);
|
||
backdrop-filter: blur(14px);
|
||
border-radius: 22px;
|
||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 480px;
|
||
overflow: hidden;
|
||
}
|
||
.stage-top {
|
||
padding: 1rem 1.15rem;
|
||
border-bottom: 1px solid var(--line);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
.stage-top h2 {
|
||
margin: 0;
|
||
font-size: 1.1rem;
|
||
}
|
||
.kind-tag {
|
||
font-size: 0.72rem;
|
||
padding: 0.15rem 0.5rem;
|
||
border-radius: 999px;
|
||
background: rgba(124, 58, 237, 0.1);
|
||
color: #6d28d9;
|
||
}
|
||
.msg-stream {
|
||
flex: 1;
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 1rem 1.15rem;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.65rem;
|
||
}
|
||
.msg-row {
|
||
display: flex;
|
||
justify-content: flex-start;
|
||
}
|
||
.msg-row.mine {
|
||
justify-content: flex-end;
|
||
}
|
||
.bubble {
|
||
max-width: min(420px, 85%);
|
||
padding: 0.65rem 0.85rem;
|
||
border-radius: 18px 18px 18px 4px;
|
||
background: #f1f5f9;
|
||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||
}
|
||
.msg-row.mine .bubble {
|
||
border-radius: 18px 18px 4px 18px;
|
||
background: linear-gradient(135deg, rgba(13, 148, 136, 0.15), rgba(99, 102, 241, 0.18));
|
||
}
|
||
.bubble p {
|
||
margin: 0 0 0.25rem;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
.bubble time {
|
||
font-size: 0.68rem;
|
||
color: var(--muted);
|
||
}
|
||
.composer {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
padding: 0.75rem 1rem 1rem;
|
||
border-top: 1px solid var(--line);
|
||
}
|
||
.composer input {
|
||
flex: 1;
|
||
border: 1px solid var(--line);
|
||
border-radius: 14px;
|
||
padding: 0.65rem 0.85rem;
|
||
font: inherit;
|
||
}
|
||
.composer button {
|
||
border: none;
|
||
border-radius: 14px;
|
||
width: 2.75rem;
|
||
background: #0d9488;
|
||
color: #fff;
|
||
cursor: pointer;
|
||
display: grid;
|
||
place-items: center;
|
||
}
|
||
.empty-stage {
|
||
flex: 1;
|
||
display: grid;
|
||
place-content: center;
|
||
text-align: center;
|
||
gap: 0.35rem;
|
||
color: var(--muted);
|
||
position: relative;
|
||
}
|
||
.empty-glow {
|
||
position: absolute;
|
||
width: 200px;
|
||
height: 200px;
|
||
border-radius: 50%;
|
||
background: radial-gradient(circle, rgba(167, 139, 250, 0.25), transparent 70%);
|
||
filter: blur(8px);
|
||
}
|
||
.modal {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 40;
|
||
border: none;
|
||
background: rgba(15, 23, 42, 0.45);
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 1rem;
|
||
}
|
||
.modal-card {
|
||
background: var(--surface);
|
||
border-radius: 18px;
|
||
padding: 1.25rem;
|
||
width: min(400px, 100%);
|
||
display: grid;
|
||
gap: 0.75rem;
|
||
}
|
||
.modal-card.wide {
|
||
width: min(480px, 100%);
|
||
}
|
||
.modal-card select,
|
||
.modal-card input {
|
||
width: 100%;
|
||
padding: 0.55rem 0.65rem;
|
||
border-radius: 10px;
|
||
border: 1px solid var(--line);
|
||
font: inherit;
|
||
}
|
||
.member-grid {
|
||
display: grid;
|
||
gap: 0.35rem;
|
||
max-height: 200px;
|
||
overflow: auto;
|
||
}
|
||
.member-check {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
font-size: 0.9rem;
|
||
}
|
||
.modal-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 0.5rem;
|
||
}
|
||
.modal-actions .primary {
|
||
background: #6366f1;
|
||
color: #fff;
|
||
border: none;
|
||
padding: 0.45rem 0.85rem;
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
}
|
||
.muted {
|
||
color: var(--muted);
|
||
}
|
||
.center {
|
||
text-align: center;
|
||
}
|
||
.pad {
|
||
padding: 2rem;
|
||
}
|
||
.hint {
|
||
margin: 0;
|
||
font-size: 0.85rem;
|
||
color: var(--muted);
|
||
}
|
||
</style>
|