Convert video to file and change the appearance of mobile chat
This commit is contained in:
@@ -446,11 +446,13 @@ export class ConversationsService {
|
|||||||
if (lastType === 'image' || lastType === 'photo' || lastType === 'img') {
|
if (lastType === 'image' || lastType === 'photo' || lastType === 'img') {
|
||||||
preview = 'تصویر';
|
preview = 'تصویر';
|
||||||
} else if (lastType === 'voice' || lastType === 'audio') {
|
} else if (lastType === 'voice' || lastType === 'audio') {
|
||||||
preview = 'وویس';
|
preview = 'پیام صوتی';
|
||||||
} else if (lastType === 'video') {
|
} else if (lastType === 'video') {
|
||||||
preview = 'ویدیو';
|
preview = 'ویدیو';
|
||||||
} else if (lastType === 'file' || lastType === 'document' || lastType === 'attachment') {
|
} else if (lastType === 'file' || lastType === 'document' || lastType === 'attachment') {
|
||||||
preview = 'فایل';
|
preview = /\.(mp4|webm|mov|m4v|mkv|avi|3gp)(?:\?|$)/i.test(preview)
|
||||||
|
? 'ویدیو'
|
||||||
|
: 'فایل';
|
||||||
}
|
}
|
||||||
const lastAtRaw =
|
const lastAtRaw =
|
||||||
lastMsg?.created_at ?? lastMsg?.createdAt ?? row.updated_at ?? row.updatedAt ?? null;
|
lastMsg?.created_at ?? lastMsg?.createdAt ?? row.updated_at ?? row.updatedAt ?? null;
|
||||||
|
|||||||
@@ -89,11 +89,40 @@ function normalizeMediaType(raw: string): ChatMediaType {
|
|||||||
return 'text';
|
return 'text';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VIDEO_EXT_RE = /\.(mp4|webm|mov|m4v|mkv|avi|3gp|ogv)(?:\?|$)/i;
|
||||||
|
const AUDIO_EXT_RE = /\.(webm|ogg|mp3|m4a|wav|aac|opus|oga)(?:\?|$)/i;
|
||||||
|
const IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp|bmp|svg|heic|heif)(?:\?|$)/i;
|
||||||
|
|
||||||
|
function looksLikeVideo(pathOrName: string): boolean {
|
||||||
|
return VIDEO_EXT_RE.test(pathOrName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferMediaType(
|
||||||
|
declared: ChatMediaType,
|
||||||
|
content: string,
|
||||||
|
fileName?: string,
|
||||||
|
): ChatMediaType {
|
||||||
|
const probe = `${fileName || ''} ${content}`;
|
||||||
|
if (declared === 'video' || looksLikeVideo(probe)) return 'video';
|
||||||
|
if (declared === 'image') return 'image';
|
||||||
|
if (declared === 'voice') return 'voice';
|
||||||
|
if (declared === 'file' || declared === 'text') {
|
||||||
|
if (looksLikeVideo(probe)) return 'video';
|
||||||
|
if (IMAGE_EXT_RE.test(probe)) return 'image';
|
||||||
|
if (AUDIO_EXT_RE.test(probe)) return 'voice';
|
||||||
|
if (declared === 'file') return 'file';
|
||||||
|
}
|
||||||
|
return declared;
|
||||||
|
}
|
||||||
|
|
||||||
export function previewLabelForMessage(msg: Pick<ChatMessage, 'type' | 'content' | 'fileName'>) {
|
export function previewLabelForMessage(msg: Pick<ChatMessage, 'type' | 'content' | 'fileName'>) {
|
||||||
if (msg.type === 'image') return 'تصویر';
|
if (msg.type === 'image') return 'تصویر';
|
||||||
if (msg.type === 'voice') return 'وویس';
|
if (msg.type === 'voice') return 'پیام صوتی';
|
||||||
if (msg.type === 'video') return 'ویدیو';
|
if (msg.type === 'video') return 'ویدیو';
|
||||||
if (msg.type === 'file') return msg.fileName?.trim() || 'فایل';
|
if (msg.type === 'file') {
|
||||||
|
if (looksLikeVideo(`${msg.fileName || ''} ${msg.content}`)) return 'ویدیو';
|
||||||
|
return msg.fileName?.trim() || 'فایل';
|
||||||
|
}
|
||||||
return msg.content || 'پیام';
|
return msg.content || 'پیام';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,12 +184,12 @@ function classifyFile(file: File): Exclude<ChatMediaType, 'text'> {
|
|||||||
return 'file';
|
return 'file';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** WS only officially documents image|voice; map video/file to closest supported + file. */
|
/** Chat backend (Hamkar-compatible) accepts text|image|voice|file — not video. */
|
||||||
function wsMediaType(type: ChatMediaType): 'image' | 'voice' | 'video' | 'file' {
|
function wsMediaType(type: ChatMediaType): 'image' | 'voice' | 'file' | 'text' {
|
||||||
if (type === 'image') return 'image';
|
if (type === 'image') return 'image';
|
||||||
if (type === 'voice') return 'voice';
|
if (type === 'voice') return 'voice';
|
||||||
if (type === 'video') return 'video';
|
if (type === 'video' || type === 'file') return 'file';
|
||||||
return 'file';
|
return 'text';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function compressImageIfNeeded(file: File): Promise<File> {
|
async function compressImageIfNeeded(file: File): Promise<File> {
|
||||||
@@ -211,16 +240,20 @@ function normalizeIncoming(
|
|||||||
raw.message && typeof raw.message === 'object'
|
raw.message && typeof raw.message === 'object'
|
||||||
? (raw.message as Record<string, unknown>)
|
? (raw.message as Record<string, unknown>)
|
||||||
: raw;
|
: raw;
|
||||||
const type = normalizeMediaType(String(base.type_content ?? base.type ?? 'text'));
|
const declared = normalizeMediaType(String(base.type_content ?? base.type ?? 'text'));
|
||||||
const content = String(base.content ?? base.text ?? base.body ?? '').trim();
|
const content = String(base.content ?? base.text ?? base.body ?? '').trim();
|
||||||
if (!content && !base.attachments) return null;
|
if (!content && !base.attachments) return null;
|
||||||
|
|
||||||
const id = String(base.id ?? base.message_id ?? '').trim();
|
const id = String(base.id ?? base.message_id ?? '').trim();
|
||||||
const readAt = base.read_at ?? base.readAt ?? null;
|
const readAt = base.read_at ?? base.readAt ?? null;
|
||||||
const downloadUrl = sessionCache?.downloadUrl ?? '';
|
const downloadUrl = sessionCache?.downloadUrl ?? '';
|
||||||
|
const fileName =
|
||||||
|
String(base.file_name ?? base.fileName ?? base.name ?? '').trim() ||
|
||||||
|
content.split('/').pop() ||
|
||||||
|
undefined;
|
||||||
|
const type = inferMediaType(declared, content, fileName);
|
||||||
const isMedia = type !== 'text';
|
const isMedia = type !== 'text';
|
||||||
const mediaUrl = isMedia ? buildChatFileUrl(content, downloadUrl) : undefined;
|
const mediaUrl = isMedia ? buildChatFileUrl(content, downloadUrl) : undefined;
|
||||||
const fileName = String(base.file_name ?? base.fileName ?? base.name ?? '').trim() || undefined;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: id || `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
id: id || `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
@@ -250,8 +283,13 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
|
|||||||
...prev,
|
...prev,
|
||||||
...incoming,
|
...incoming,
|
||||||
optimistic: false,
|
optimistic: false,
|
||||||
|
type:
|
||||||
|
prev.type === 'video' && (incoming.type === 'file' || incoming.type === 'text')
|
||||||
|
? 'video'
|
||||||
|
: inferMediaType(incoming.type, incoming.content, incoming.fileName || prev.fileName),
|
||||||
localPreviewUrl: incoming.localPreviewUrl ?? prev.localPreviewUrl,
|
localPreviewUrl: incoming.localPreviewUrl ?? prev.localPreviewUrl,
|
||||||
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
|
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
|
||||||
|
fileName: incoming.fileName || prev.fileName,
|
||||||
delivery:
|
delivery:
|
||||||
incoming.delivery === 'read' || prev.delivery === 'read'
|
incoming.delivery === 'read' || prev.delivery === 'read'
|
||||||
? 'read'
|
? 'read'
|
||||||
@@ -278,9 +316,14 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
|
|||||||
...prev,
|
...prev,
|
||||||
...incoming,
|
...incoming,
|
||||||
optimistic: false,
|
optimistic: false,
|
||||||
localPreviewUrl: undefined,
|
type:
|
||||||
|
prev.type === 'video' && (incoming.type === 'file' || incoming.type === 'text')
|
||||||
|
? 'video'
|
||||||
|
: inferMediaType(incoming.type, incoming.content, incoming.fileName || prev.fileName),
|
||||||
|
localPreviewUrl: prev.localPreviewUrl,
|
||||||
uploadProgress: undefined,
|
uploadProgress: undefined,
|
||||||
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
|
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
|
||||||
|
fileName: incoming.fileName || prev.fileName,
|
||||||
delivery: incoming.readAt ? 'read' : 'sent',
|
delivery: incoming.readAt ? 'read' : 'sent',
|
||||||
};
|
};
|
||||||
if (prev.localPreviewUrl?.startsWith('blob:')) {
|
if (prev.localPreviewUrl?.startsWith('blob:')) {
|
||||||
@@ -477,6 +520,26 @@ function sendRead(roomId: string) {
|
|||||||
socket.send(JSON.stringify({ type: 'read', room_id: roomId }));
|
socket.send(JSON.stringify({ type: 'read', room_id: roomId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatUploadFailure(status: number, bodyText: string): string {
|
||||||
|
const raw = bodyText.trim();
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
const err = String(json.error ?? json.message ?? '').trim();
|
||||||
|
const details = String(json.details ?? '').trim();
|
||||||
|
if (err && details) return `آپلود ناموفق بود: ${err} (${details})`;
|
||||||
|
if (err) return `آپلود ناموفق بود: ${err}`;
|
||||||
|
} catch {
|
||||||
|
const snippet = raw.slice(0, 160);
|
||||||
|
if (snippet) return `آپلود ناموفق بود (${status}): ${snippet}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (status === 500) {
|
||||||
|
return 'آپلود ناموفق بود (۵۰۰). سرویس ذخیرهسازی فایل (S3) روی آدرس آپلود در دسترس نیست — تنظیمات upload/download در سوپرادمین را بررسی کنید.';
|
||||||
|
}
|
||||||
|
return `آپلود ناموفق بود (${status})`;
|
||||||
|
}
|
||||||
|
|
||||||
function uploadChatFile(
|
function uploadChatFile(
|
||||||
uploadUrl: string,
|
uploadUrl: string,
|
||||||
file: File,
|
file: File,
|
||||||
@@ -497,7 +560,7 @@ function uploadChatFile(
|
|||||||
};
|
};
|
||||||
xhr.onload = () => {
|
xhr.onload = () => {
|
||||||
if (xhr.status < 200 || xhr.status >= 300) {
|
if (xhr.status < 200 || xhr.status >= 300) {
|
||||||
reject(new Error(`آپلود ناموفق بود (${xhr.status})`));
|
reject(new Error(formatUploadFailure(xhr.status, xhr.responseText || '')));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -649,6 +712,7 @@ export function useOrgChat() {
|
|||||||
content: filePath,
|
content: filePath,
|
||||||
};
|
};
|
||||||
if (reply) payload.reply = reply;
|
if (reply) payload.reply = reply;
|
||||||
|
if (opts?.fileName) payload.file_name = opts.fileName;
|
||||||
|
|
||||||
const existingIdx = messages.value.findIndex((m) => m.id === id);
|
const existingIdx = messages.value.findIndex((m) => m.id === id);
|
||||||
if (existingIdx >= 0) {
|
if (existingIdx >= 0) {
|
||||||
|
|||||||
@@ -104,6 +104,22 @@ function deliveryLabel(delivery?: ChatDelivery) {
|
|||||||
return '…';
|
return '…';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function roomPreviewText(room: ConversationRoomDto) {
|
||||||
|
const raw = (room.lastMessagePreview || '').trim();
|
||||||
|
if (!raw) return 'بدون پیام';
|
||||||
|
if (raw === 'وویس') return 'پیام صوتی';
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMobileChat() {
|
||||||
|
clearActiveRoom();
|
||||||
|
activeRoom.value = null;
|
||||||
|
clearPendingAttaches();
|
||||||
|
if (recording.value) mediaRecorder?.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
const mobileChatOpen = computed(() => Boolean(activeRoom.value));
|
||||||
|
|
||||||
function classifyPending(file: File): PendingAttach['kind'] {
|
function classifyPending(file: File): PendingAttach['kind'] {
|
||||||
const mime = (file.type || '').toLowerCase();
|
const mime = (file.type || '').toLowerCase();
|
||||||
if (mime.startsWith('image/')) return 'image';
|
if (mime.startsWith('image/')) return 'image';
|
||||||
@@ -446,25 +462,30 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="conv-page">
|
<div class="conv-page" :class="{ 'chat-open': mobileChatOpen }">
|
||||||
<div class="conv-aurora" aria-hidden="true" />
|
<div class="conv-aurora" aria-hidden="true" />
|
||||||
|
|
||||||
<header class="conv-head">
|
<header class="conv-head desktop-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">درون سازمان</p>
|
<p class="eyebrow">درون سازمان</p>
|
||||||
<h1>گفتگو</h1>
|
<h1>گفتگو</h1>
|
||||||
<p class="sub">فضای زنده برای هماهنگی تیم — بدون پیامرسان بیرونی.</p>
|
<p class="sub">فضای زنده برای هماهنگی تیم — بدون پیامرسان بیرونی.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<span class="ws-pill" :data-state="status">{{ status === 'open' ? 'آنلاین' : '…' }}</span>
|
<span class="ws-pill" :data-state="status">{{ status === 'open' ? 'آنلاین' : '…' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<header class="mobile-list-head" :hidden="mobileChatOpen">
|
||||||
|
<h1>گفتگو</h1>
|
||||||
|
<span class="ws-pill" :data-state="status">{{ status === 'open' ? 'آنلاین' : '…' }}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
<p v-if="error" class="err-banner">{{ error }}</p>
|
<p v-if="error" class="err-banner">{{ error }}</p>
|
||||||
<p v-else-if="loading" class="muted center">در حال آمادهسازی…</p>
|
<p v-else-if="loading" class="muted center">در حال آمادهسازی…</p>
|
||||||
|
|
||||||
<div v-else class="conv-shell">
|
<div v-else class="conv-shell">
|
||||||
<aside class="conv-rail">
|
<aside class="conv-rail" :hidden="mobileChatOpen">
|
||||||
<div class="rail-actions">
|
<div class="rail-actions">
|
||||||
<button type="button" class="fab" @click="showNewDm = true">+ گفتگو</button>
|
<button type="button" class="fab" @click="showNewDm = true">+ گفتگو</button>
|
||||||
<button
|
<button
|
||||||
@@ -489,7 +510,7 @@ onUnmounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
<span class="room-meta">
|
<span class="room-meta">
|
||||||
<strong>{{ roomTitle(room) }}</strong>
|
<strong>{{ roomTitle(room) }}</strong>
|
||||||
<small>{{ room.lastMessagePreview || 'بدون پیام' }}</small>
|
<small>{{ roomPreviewText(room) }}</small>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="room.unreadCount" class="unread">{{ toFaDigits(room.unreadCount) }}</span>
|
<span v-if="room.unreadCount" class="unread">{{ toFaDigits(room.unreadCount) }}</span>
|
||||||
<time>{{ formatListTime(room.lastMessageAt) }}</time>
|
<time>{{ formatListTime(room.lastMessageAt) }}</time>
|
||||||
@@ -498,8 +519,11 @@ onUnmounted(() => {
|
|||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="conv-stage">
|
<section
|
||||||
<div v-if="!activeRoom" class="empty-stage">
|
class="conv-stage"
|
||||||
|
:class="{ empty: !activeRoom, open: mobileChatOpen }"
|
||||||
|
>
|
||||||
|
<div v-if="!activeRoom" class="empty-stage desktop-empty">
|
||||||
<div class="empty-glow" />
|
<div class="empty-glow" />
|
||||||
<UiIcon name="chat" :size="48" />
|
<UiIcon name="chat" :size="48" />
|
||||||
<h2>یک مکالمه را انتخاب کنید</h2>
|
<h2>یک مکالمه را انتخاب کنید</h2>
|
||||||
@@ -508,8 +532,16 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="stage-top">
|
<div class="stage-top">
|
||||||
<h2>{{ roomTitle(activeRoom) }}</h2>
|
<button type="button" class="back-btn" aria-label="بازگشت" @click="closeMobileChat">
|
||||||
<span class="kind-tag">{{ activeRoom.kind === 'group' ? 'گروه' : 'خصوصی' }}</span>
|
<UiIcon name="arrow-right" :size="20" />
|
||||||
|
</button>
|
||||||
|
<span class="stage-avatar">
|
||||||
|
<UserAvatar :name="roomTitle(activeRoom)" :size="36" />
|
||||||
|
</span>
|
||||||
|
<div class="stage-titles">
|
||||||
|
<h2>{{ roomTitle(activeRoom) }}</h2>
|
||||||
|
<span class="kind-tag">{{ activeRoom.kind === 'group' ? 'گروه' : 'خصوصی' }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loadingMessages" class="muted center pad">در حال دریافت پیامها…</div>
|
<div v-if="loadingMessages" class="muted center pad">در حال دریافت پیامها…</div>
|
||||||
@@ -541,7 +573,24 @@ onUnmounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="msg.type === 'video'">
|
<template v-else-if="msg.type === 'video'">
|
||||||
<video class="media-video" :src="mediaSrc(msg)" controls preload="metadata" />
|
<video
|
||||||
|
v-if="mediaSrc(msg)"
|
||||||
|
class="media-video"
|
||||||
|
:src="mediaSrc(msg)"
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
v-else
|
||||||
|
class="media-file"
|
||||||
|
:href="msg.content"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<UiIcon name="document" :size="18" />
|
||||||
|
<span>{{ msg.fileName || 'ویدیو' }}</span>
|
||||||
|
</a>
|
||||||
<span v-if="msg.uploadProgress != null && msg.uploadProgress < 100" class="upload-line">
|
<span v-if="msg.uploadProgress != null && msg.uploadProgress < 100" class="upload-line">
|
||||||
آپلود {{ toFaDigits(msg.uploadProgress) }}٪
|
آپلود {{ toFaDigits(msg.uploadProgress) }}٪
|
||||||
</span>
|
</span>
|
||||||
@@ -622,7 +671,7 @@ onUnmounted(() => {
|
|||||||
type="button"
|
type="button"
|
||||||
class="tool-btn"
|
class="tool-btn"
|
||||||
:class="{ on: recording }"
|
:class="{ on: recording }"
|
||||||
title="ضبط وویس"
|
title="ضبط پیام صوتی"
|
||||||
:disabled="sending"
|
:disabled="sending"
|
||||||
@click="toggleVoiceRecord"
|
@click="toggleVoiceRecord"
|
||||||
>
|
>
|
||||||
@@ -631,12 +680,14 @@ onUnmounted(() => {
|
|||||||
<input
|
<input
|
||||||
v-model="draft"
|
v-model="draft"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="پیام خود را بنویسید…"
|
class="composer-input"
|
||||||
|
placeholder="پیام…"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:disabled="sending || recording"
|
:disabled="sending || recording"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
class="send-btn"
|
||||||
:disabled="sending || recording || (!draft.trim() && !pendingAttaches.length)"
|
:disabled="sending || recording || (!draft.trim() && !pendingAttaches.length)"
|
||||||
>
|
>
|
||||||
<UiIcon name="send" :size="18" />
|
<UiIcon name="send" :size="18" />
|
||||||
@@ -718,6 +769,9 @@ onUnmounted(() => {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
.mobile-list-head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
@@ -754,16 +808,6 @@ onUnmounted(() => {
|
|||||||
.ws-pill[data-state='open'] {
|
.ws-pill[data-state='open'] {
|
||||||
box-shadow: 0 0 0 1px rgba(45, 212, 191, 0.35);
|
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 {
|
.err-banner {
|
||||||
background: #fef2f2;
|
background: #fef2f2;
|
||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
@@ -778,11 +822,7 @@ onUnmounted(() => {
|
|||||||
grid-template-columns: minmax(260px, 320px) 1fr;
|
grid-template-columns: minmax(260px, 320px) 1fr;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
min-height: 520px;
|
min-height: 520px;
|
||||||
}
|
height: min(72vh, 720px);
|
||||||
@media (max-width: 900px) {
|
|
||||||
.conv-shell {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.conv-rail {
|
.conv-rail {
|
||||||
background: rgba(255, 255, 255, 0.72);
|
background: rgba(255, 255, 255, 0.72);
|
||||||
@@ -793,6 +833,7 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.rail-actions {
|
.rail-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -818,12 +859,13 @@ onUnmounted(() => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
max-height: calc(100vh - 14rem);
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.room-chip {
|
.room-chip {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
grid-template-rows: auto auto;
|
grid-template-rows: auto auto;
|
||||||
gap: 0.15rem 0.65rem;
|
gap: 0.15rem 0.65rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -845,6 +887,9 @@ onUnmounted(() => {
|
|||||||
padding: 2px;
|
padding: 2px;
|
||||||
background: linear-gradient(135deg, #2dd4bf, #a78bfa);
|
background: linear-gradient(135deg, #2dd4bf, #a78bfa);
|
||||||
}
|
}
|
||||||
|
.room-meta {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.room-meta strong {
|
.room-meta strong {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
@@ -855,7 +900,7 @@ onUnmounted(() => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
max-width: 160px;
|
max-width: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.unread {
|
.unread {
|
||||||
@@ -883,26 +928,55 @@ onUnmounted(() => {
|
|||||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 480px;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.stage-top {
|
.stage-top {
|
||||||
padding: 1rem 1.15rem;
|
padding: 0.75rem 1rem;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.55rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.back-btn {
|
||||||
|
display: none;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.stage-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px;
|
||||||
|
background: linear-gradient(135deg, #2dd4bf, #a78bfa);
|
||||||
|
}
|
||||||
|
.stage-titles {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
}
|
}
|
||||||
.stage-top h2 {
|
.stage-top h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1.05rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.kind-tag {
|
.kind-tag {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.1rem 0.45rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(124, 58, 237, 0.1);
|
background: rgba(124, 58, 237, 0.1);
|
||||||
color: #6d28d9;
|
color: #6d28d9;
|
||||||
|
width: fit-content;
|
||||||
}
|
}
|
||||||
.msg-stream {
|
.msg-stream {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -913,6 +987,10 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
|
min-height: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 12% 8%, rgba(45, 212, 191, 0.07), transparent 40%),
|
||||||
|
radial-gradient(circle at 88% 92%, rgba(167, 139, 250, 0.08), transparent 42%);
|
||||||
}
|
}
|
||||||
.msg-row {
|
.msg-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1031,57 +1109,58 @@ onUnmounted(() => {
|
|||||||
.composer {
|
.composer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.45rem;
|
||||||
padding: 0.75rem 1rem 1rem;
|
padding: 0.65rem 0.75rem calc(0.65rem + env(safe-area-inset-bottom, 0px));
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.composer-row {
|
.composer-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.composer-row input[type='text'] {
|
.composer-input {
|
||||||
flex: 1;
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 0.65rem 0.85rem;
|
padding: 0.65rem 0.85rem;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
.composer-row > button[type='submit'] {
|
.tool-btn,
|
||||||
border: none;
|
.send-btn {
|
||||||
border-radius: 14px;
|
flex: 0 0 auto;
|
||||||
width: 2.75rem;
|
border-radius: 12px;
|
||||||
height: 2.75rem;
|
width: 2.6rem;
|
||||||
background: #0d9488;
|
height: 2.6rem;
|
||||||
color: #fff;
|
min-width: 2.6rem;
|
||||||
cursor: pointer;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
cursor: pointer;
|
||||||
.composer-row > button[type='submit']:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
.tool-btn {
|
.tool-btn {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: var(--surface);
|
background: var(--surface, #fff);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-radius: 12px;
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
.tool-btn.on {
|
.tool-btn.on {
|
||||||
border-color: #ef4444;
|
border-color: #ef4444;
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
background: rgba(239, 68, 68, 0.08);
|
background: rgba(239, 68, 68, 0.08);
|
||||||
}
|
}
|
||||||
.tool-btn:disabled {
|
.tool-btn:disabled,
|
||||||
|
.send-btn:disabled {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
.send-btn {
|
||||||
|
border: none;
|
||||||
|
background: #0d9488;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
.attach-preview {
|
.attach-preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
@@ -1171,6 +1250,23 @@ onUnmounted(() => {
|
|||||||
clip: rect(0, 0, 0, 0);
|
clip: rect(0, 0, 0, 0);
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
.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);
|
||||||
|
}
|
||||||
.lightbox {
|
.lightbox {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1201,23 +1297,6 @@ onUnmounted(() => {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.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 {
|
.modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1286,4 +1365,129 @@ onUnmounted(() => {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.conv-page {
|
||||||
|
margin: -1rem -0.85rem calc(-1.25rem - var(--mobile-tabbar-h, 3.65rem));
|
||||||
|
padding: 0;
|
||||||
|
min-height: calc(100dvh - 3.5rem - var(--mobile-tabbar-h, 3.65rem));
|
||||||
|
height: calc(100dvh - 3.5rem - var(--mobile-tabbar-h, 3.65rem));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.conv-page.chat-open {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.conv-aurora,
|
||||||
|
.desktop-head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.mobile-list-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mobile-list-head[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.mobile-list-head h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
.err-banner {
|
||||||
|
margin: 0.5rem 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.conv-shell {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
height: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.conv-rail {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #fff;
|
||||||
|
backdrop-filter: none;
|
||||||
|
padding: 0.5rem 0.35rem 0.75rem;
|
||||||
|
}
|
||||||
|
.conv-rail[hidden],
|
||||||
|
.conv-page.chat-open .conv-rail {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.rail-actions {
|
||||||
|
padding: 0 0.5rem 0.35rem;
|
||||||
|
}
|
||||||
|
.room-list {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
.room-chip {
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0.7rem 0.75rem;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
.room-chip.active,
|
||||||
|
.room-chip:hover {
|
||||||
|
background: rgba(13, 148, 136, 0.06);
|
||||||
|
}
|
||||||
|
.conv-stage {
|
||||||
|
display: none;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #fff;
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
.conv-stage.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.desktop-empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.back-btn {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
.stage-top {
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
background: #fff;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.msg-stream {
|
||||||
|
padding: 0.75rem 0.7rem 0.5rem;
|
||||||
|
}
|
||||||
|
.bubble {
|
||||||
|
max-width: min(300px, 82%);
|
||||||
|
}
|
||||||
|
.composer {
|
||||||
|
padding: 0.5rem 0.55rem calc(0.45rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
box-shadow: 0 -6px 18px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
.tool-btn,
|
||||||
|
.send-btn {
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
min-width: 2.75rem;
|
||||||
|
}
|
||||||
|
.composer-input {
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user