Convert video to file and change the appearance of mobile chat

This commit is contained in:
2026-09-29 20:18:17 +03:30
parent b0f05b6a27
commit 70bcd15847
3 changed files with 358 additions and 88 deletions
@@ -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;
+74 -10
View File
@@ -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) {
+280 -76
View File
@@ -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>