diff --git a/apps/api/src/conversations/conversations.service.ts b/apps/api/src/conversations/conversations.service.ts index 9804f7a..6befa00 100644 --- a/apps/api/src/conversations/conversations.service.ts +++ b/apps/api/src/conversations/conversations.service.ts @@ -134,8 +134,12 @@ export class ConversationsService { chatUserId, wsUrl: `wss://${host}/ws?${params.toString()}`, messagesBaseUrl: `https://${host}`, - uploadUrl: creds.uploadUrl, - downloadUrl: creds.downloadUrl, + uploadUrl: + creds.uploadUrl.trim() || + `https://${host}/upload`, + downloadUrl: + creds.downloadUrl.trim() || + `https://${host}/files`, }; } @@ -433,9 +437,21 @@ export class ConversationsService { const kind = tracked?.kind === 'group' ? 'group' : tracked?.kind === 'dm' ? 'dm' : 'group'; const lastMsg = (row.last_message ?? row.lastMessage) as Record | undefined; - const preview = lastMsg + const lastType = String(lastMsg?.type_content ?? lastMsg?.type ?? 'text') + .toLowerCase() + .trim(); + let preview = lastMsg ? String(lastMsg.content ?? lastMsg.text ?? lastMsg.body ?? '').trim() : ''; + if (lastType === 'image' || lastType === 'photo' || lastType === 'img') { + preview = 'تصویر'; + } else if (lastType === 'voice' || lastType === 'audio') { + preview = 'وویس'; + } else if (lastType === 'video') { + preview = 'ویدیو'; + } else if (lastType === 'file' || lastType === 'document' || lastType === 'attachment') { + preview = 'فایل'; + } const lastAtRaw = lastMsg?.created_at ?? lastMsg?.createdAt ?? row.updated_at ?? row.updatedAt ?? null; const unread = Number(row.unread_count ?? row.unreadCount ?? 0); diff --git a/apps/web/src/lib/useOrgChat.ts b/apps/web/src/lib/useOrgChat.ts index 12fb22e..be23f6d 100644 --- a/apps/web/src/lib/useOrgChat.ts +++ b/apps/web/src/lib/useOrgChat.ts @@ -4,17 +4,24 @@ import { api } from '@/lib/api'; export type ChatDelivery = 'sending' | 'sent' | 'read'; +export type ChatMediaType = 'text' | 'image' | 'voice' | 'video' | 'file'; + export type ChatMessage = { id: string; roomId: string; senderId: string; content: string; - type: 'text' | 'image' | 'voice'; + type: ChatMediaType; createdAt: string; reply: string | null; optimistic?: boolean; delivery?: ChatDelivery; readAt?: string | null; + /** Absolute URL for media display/download */ + mediaUrl?: string; + fileName?: string; + uploadProgress?: number; + localPreviewUrl?: string; }; export type RoomLiveEvent = { @@ -25,6 +32,9 @@ export type RoomLiveEvent = { }; const PAGE_SIZE = 50; +const IMAGE_MAX_EDGE = 1600; +const IMAGE_WEBP_QUALITY = 0.82; +const MAX_UPLOAD_BYTES = 40 * 1024 * 1024; let socket: WebSocket | null = null; let sessionCache: ConversationSessionDto | null = null; @@ -70,6 +80,129 @@ function sortAsc(items: ChatMessage[]): ChatMessage[] { }); } +function normalizeMediaType(raw: string): ChatMediaType { + const t = raw.toLowerCase().trim(); + if (t === 'image' || t === 'photo' || t === 'img') return 'image'; + if (t === 'voice' || t === 'audio') return 'voice'; + if (t === 'video') return 'video'; + if (t === 'file' || t === 'document' || t === 'attachment') return 'file'; + return 'text'; +} + +export function previewLabelForMessage(msg: Pick) { + if (msg.type === 'image') return 'تصویر'; + if (msg.type === 'voice') return 'وویس'; + if (msg.type === 'video') return 'ویدیو'; + if (msg.type === 'file') return msg.fileName?.trim() || 'فایل'; + return msg.content || 'پیام'; +} + +function buildChatFileUrl(value: string, downloadUrl: string): string { + const raw = String(value || '').trim(); + if (!raw) return ''; + if (/^(https?:\/\/|blob:|data:)/i.test(raw)) return raw; + if (!downloadUrl) return raw; + + const cleanRaw = raw.replace(/^\/+/, ''); + try { + const baseUrl = new URL(downloadUrl); + const basePath = baseUrl.pathname.replace(/^\/+|\/+$/g, ''); + const baseParts = basePath.split('/').filter(Boolean); + const rawParts = cleanRaw.split('/').filter(Boolean); + + if (basePath && cleanRaw.startsWith(`${basePath}/`)) { + return `${baseUrl.origin}/${cleanRaw}`; + } + + const lastBasePart = baseParts[baseParts.length - 1]; + if (lastBasePart && rawParts[0] === lastBasePart) { + const parentPath = baseParts.slice(0, -1).join('/'); + return `${baseUrl.origin}/${[parentPath, cleanRaw].filter(Boolean).join('/')}`; + } + + return `${downloadUrl.replace(/\/+$/, '')}/${cleanRaw}`; + } catch { + return raw; + } +} + +function extractUploadedPath(result: Record): string { + const nested = (result.data && typeof result.data === 'object' + ? (result.data as Record) + : null); + const file = (result.file && typeof result.file === 'object' + ? (result.file as Record) + : null); + const value = + result.path || + result.url || + result.file_url || + result.fileUrl || + nested?.path || + nested?.url || + nested?.file_url || + nested?.fileUrl || + file?.path || + file?.url; + return String(value || '').trim(); +} + +function classifyFile(file: File): Exclude { + const mime = (file.type || '').toLowerCase(); + if (mime.startsWith('image/')) return 'image'; + if (mime.startsWith('audio/')) return 'voice'; + if (mime.startsWith('video/')) return 'video'; + return 'file'; +} + +/** WS only officially documents image|voice; map video/file to closest supported + file. */ +function wsMediaType(type: ChatMediaType): 'image' | 'voice' | 'video' | 'file' { + if (type === 'image') return 'image'; + if (type === 'voice') return 'voice'; + if (type === 'video') return 'video'; + return 'file'; +} + +async function compressImageIfNeeded(file: File): Promise { + if (!file.type.startsWith('image/')) return file; + if (file.type === 'image/gif' || file.type === 'image/svg+xml') return file; + if (file.size < 350_000) return file; + + try { + const bitmap = await createImageBitmap(file); + const ratio = Math.min( + IMAGE_MAX_EDGE / bitmap.width, + IMAGE_MAX_EDGE / bitmap.height, + 1, + ); + const width = Math.round(bitmap.width * ratio); + const height = Math.round(bitmap.height * ratio); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) { + bitmap.close(); + return file; + } + ctx.drawImage(bitmap, 0, 0, width, height); + bitmap.close(); + + const blob = await new Promise((resolve) => { + canvas.toBlob((b) => resolve(b), 'image/webp', IMAGE_WEBP_QUALITY); + }); + if (!blob || blob.size >= file.size) return file; + + const base = file.name.replace(/\.[^/.]+$/, '') || 'image'; + return new File([blob], `${base}.webp`, { + type: 'image/webp', + lastModified: Date.now(), + }); + } catch { + return file; + } +} + function normalizeIncoming( raw: Record, roomFallback: string, @@ -78,10 +211,17 @@ function normalizeIncoming( raw.message && typeof raw.message === 'object' ? (raw.message as Record) : raw; + const type = normalizeMediaType(String(base.type_content ?? base.type ?? 'text')); const content = String(base.content ?? base.text ?? base.body ?? '').trim(); if (!content && !base.attachments) return null; + const id = String(base.id ?? base.message_id ?? '').trim(); const readAt = base.read_at ?? base.readAt ?? null; + const downloadUrl = sessionCache?.downloadUrl ?? ''; + const isMedia = type !== 'text'; + const mediaUrl = isMedia ? buildChatFileUrl(content, downloadUrl) : undefined; + const fileName = String(base.file_name ?? base.fileName ?? base.name ?? '').trim() || undefined; + return { id: id || `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, roomId: String(base.room_id ?? raw.room_id ?? roomFallback), @@ -89,13 +229,15 @@ function normalizeIncoming( base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '', ), content, - type: (String(base.type_content ?? base.type ?? 'text') as ChatMessage['type']) || 'text', + type, createdAt: String( base.created_at ?? base.createdAt ?? raw.created_at ?? new Date().toISOString(), ), reply: base.reply ? String(base.reply) : null, readAt: readAt ? String(readAt) : null, delivery: readAt ? 'read' : 'sent', + mediaUrl, + fileName, }; } @@ -108,6 +250,8 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[ ...prev, ...incoming, optimistic: false, + localPreviewUrl: incoming.localPreviewUrl ?? prev.localPreviewUrl, + mediaUrl: incoming.mediaUrl || prev.mediaUrl, delivery: incoming.delivery === 'read' || prev.delivery === 'read' ? 'read' @@ -122,7 +266,10 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[ m.optimistic && m.roomId === incoming.roomId && m.senderId === incoming.senderId && - m.content === incoming.content, + (m.content === incoming.content || + (m.type === incoming.type && + m.type !== 'text' && + Boolean(m.localPreviewUrl))), ); if (optimisticIdx >= 0) { const prev = list[optimisticIdx]; @@ -131,8 +278,18 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[ ...prev, ...incoming, optimistic: false, + localPreviewUrl: undefined, + uploadProgress: undefined, + mediaUrl: incoming.mediaUrl || prev.mediaUrl, delivery: incoming.readAt ? 'read' : 'sent', }; + if (prev.localPreviewUrl?.startsWith('blob:')) { + try { + URL.revokeObjectURL(prev.localPreviewUrl); + } catch { + // ignore + } + } return sortAsc(next); } @@ -145,7 +302,6 @@ function applySeenPayload(payload: Record) { const myId = sessionCache?.chatUserId ?? ''; const readerId = String(payload.reader_id ?? ''); - // وقتی طرف مقابل خوانده، پیام‌های من تیک دوتایی می‌خورند if (!readerId || readerId === myId) return; const ids = new Set( @@ -154,7 +310,6 @@ function applySeenPayload(payload: Record) { : [], ); if (!ids.size) { - // اگر id لیست نبود، همه پیام‌های خودم در این room را read کن messages.value = messages.value.map((m) => m.roomId === roomId && m.senderId === myId ? { ...m, delivery: 'read' as const, readAt: String(payload.seen_at ?? new Date().toISOString()) } @@ -237,7 +392,7 @@ function handleIncomingMessage(msg: ChatMessage) { emitRoomLive({ roomId: msg.roomId, - preview: msg.content, + preview: previewLabelForMessage(msg), at: msg.createdAt, fromSelf, }); @@ -322,6 +477,56 @@ function sendRead(roomId: string) { socket.send(JSON.stringify({ type: 'read', room_id: roomId })); } +function uploadChatFile( + uploadUrl: string, + file: File, + onProgress?: (percent: number) => void, +): Promise { + return new Promise((resolve, reject) => { + if (!uploadUrl.trim()) { + reject(new Error('آدرس آپلود گفتگو تنظیم نشده است')); + return; + } + const formData = new FormData(); + formData.append('file', file); + const xhr = new XMLHttpRequest(); + xhr.open('POST', uploadUrl); + xhr.upload.onprogress = (event) => { + if (!event.lengthComputable) return; + onProgress?.(Math.min(99, Math.round((event.loaded / event.total) * 100))); + }; + xhr.onload = () => { + if (xhr.status < 200 || xhr.status >= 300) { + reject(new Error(`آپلود ناموفق بود (${xhr.status})`)); + return; + } + try { + const contentType = xhr.getResponseHeader('content-type') || ''; + if (contentType.includes('application/json')) { + const result = JSON.parse(xhr.responseText) as Record; + const path = extractUploadedPath(result); + if (!path) { + reject(new Error('پاسخ آپلود فاقد مسیر فایل است')); + return; + } + resolve(path); + return; + } + const path = xhr.responseText.trim(); + if (!path) { + reject(new Error('پاسخ آپلود خالی است')); + return; + } + resolve(path); + } catch (e) { + reject(e instanceof Error ? e : new Error('پاسخ آپلود نامعتبر است')); + } + }; + xhr.onerror = () => reject(new Error('خطای شبکه هنگام آپلود')); + xhr.send(formData); + }); +} + export async function refreshUnreadTotal() { try { const { data } = await api.get('/conversations/rooms'); @@ -416,6 +621,153 @@ export function useOrgChat() { ); } + async function sendMediaPath( + roomId: string, + path: string, + type: Exclude, + opts?: { + reply?: string | null; + fileName?: string; + localPreviewUrl?: string; + tempId?: string; + }, + ) { + const filePath = path.trim(); + if (!filePath) return; + await connectSocket(); + if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open'); + const session = await ensureSession(); + const id = opts?.tempId || crypto.randomUUID(); + const reply = opts?.reply ?? null; + const wsType = wsMediaType(type); + const mediaUrl = buildChatFileUrl(filePath, session.downloadUrl); + + const payload: Record = { + id, + type: wsType, + room_id: roomId, + content: filePath, + }; + if (reply) payload.reply = reply; + + const existingIdx = messages.value.findIndex((m) => m.id === id); + if (existingIdx >= 0) { + const next = [...messages.value]; + next[existingIdx] = { + ...next[existingIdx], + content: filePath, + type, + mediaUrl, + fileName: opts?.fileName, + uploadProgress: 100, + optimistic: false, + delivery: 'sent', + }; + messages.value = next; + } else { + const optimistic: ChatMessage = { + id, + roomId, + senderId: session.chatUserId, + content: filePath, + type, + createdAt: new Date().toISOString(), + reply, + optimistic: true, + delivery: 'sending', + mediaUrl, + fileName: opts?.fileName, + localPreviewUrl: opts?.localPreviewUrl, + }; + messages.value = mergeMessages(optimistic, messages.value); + } + + emitRoomLive({ + roomId, + preview: previewLabelForMessage({ type, content: filePath, fileName: opts?.fileName }), + at: new Date().toISOString(), + fromSelf: true, + }); + + socket.send(JSON.stringify(payload)); + messages.value = messages.value.map((m) => + m.id === id + ? { ...m, optimistic: false, delivery: 'sent' as const, uploadProgress: undefined } + : m, + ); + } + + async function sendFile( + roomId: string, + file: File, + reply: string | null = null, + ) { + if (!file) return; + if (file.size > MAX_UPLOAD_BYTES) { + throw new Error('حجم فایل بیش از حد مجاز است (۴۰ مگابایت)'); + } + + const session = await ensureSession(); + if (!session.uploadUrl?.trim()) { + throw new Error( + 'آدرس آپلود گفتگو خالی است. در سوپرادمین → محیط‌ها → تنظیمات گفتگو پر کنید.', + ); + } + + await connectSocket(); + const type = classifyFile(file); + const id = crypto.randomUUID(); + const localPreviewUrl = URL.createObjectURL(file); + + const optimistic: ChatMessage = { + id, + roomId, + senderId: session.chatUserId, + content: '', + type, + createdAt: new Date().toISOString(), + reply, + optimistic: true, + delivery: 'sending', + fileName: file.name, + localPreviewUrl, + uploadProgress: 0, + }; + messages.value = mergeMessages(optimistic, messages.value); + + const setProgress = (percent: number) => { + messages.value = messages.value.map((m) => + m.id === id ? { ...m, uploadProgress: percent } : m, + ); + }; + + try { + setProgress(5); + const fileForUpload = type === 'image' ? await compressImageIfNeeded(file) : file; + setProgress(12); + const uploadedPath = await uploadChatFile(session.uploadUrl, fileForUpload, (p) => { + setProgress(12 + Math.round(p * 0.86)); + }); + setProgress(99); + await sendMediaPath(roomId, uploadedPath, type, { + reply, + fileName: file.name, + localPreviewUrl, + tempId: id, + }); + } catch (e) { + messages.value = messages.value.filter((m) => m.id !== id); + if (localPreviewUrl.startsWith('blob:')) { + try { + URL.revokeObjectURL(localPreviewUrl); + } catch { + // ignore + } + } + throw e; + } + } + function clearActiveRoom() { activeRoomId.value = ''; messages.value = []; @@ -436,6 +788,7 @@ export function useOrgChat() { loadingMessages, loadMessages, sendText, + sendFile, sendRead, ensureConnected, setConversationsPageOpen, diff --git a/apps/web/src/views/ConversationsView.vue b/apps/web/src/views/ConversationsView.vue index c8acd32..1b95d4a 100644 --- a/apps/web/src/views/ConversationsView.vue +++ b/apps/web/src/views/ConversationsView.vue @@ -12,11 +12,20 @@ import { onRoomLiveEvent, useOrgChat, type ChatDelivery, + type ChatMessage, type RoomLiveEvent, } from '@/lib/useOrgChat'; import UiIcon from '@/components/UiIcon.vue'; import UserAvatar from '@/components/UserAvatar.vue'; +type PendingAttach = { + id: string; + file: File; + kind: 'image' | 'voice' | 'video' | 'file'; + previewUrl: string; + name: string; +}; + const access = ref(null); const rooms = ref([]); const peers = ref([]); @@ -30,6 +39,12 @@ const groupName = ref(''); const groupMemberIds = ref([]); const dmPeerId = ref(''); const chatUserId = ref(''); +const pendingAttaches = ref([]); +const sending = ref(false); +const recording = ref(false); +const recordSecs = ref(0); +const lightboxUrl = ref(''); +const fileInputEl = ref(null); const { messages, @@ -37,6 +52,7 @@ const { status, loadMessages, sendText, + sendFile, sendRead, ensureConnected, setConversationsPageOpen, @@ -45,6 +61,10 @@ const { } = useOrgChat(); const msgStreamEl = ref(null); let stopRoomLive: (() => boolean) | undefined; +let mediaRecorder: MediaRecorder | null = null; +let recordChunks: Blob[] = []; +let recordTimer: ReturnType | null = null; +let recordStream: MediaStream | null = null; const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false); @@ -84,6 +104,129 @@ function deliveryLabel(delivery?: ChatDelivery) { return '…'; } +function classifyPending(file: File): PendingAttach['kind'] { + const mime = (file.type || '').toLowerCase(); + if (mime.startsWith('image/')) return 'image'; + if (mime.startsWith('audio/')) return 'voice'; + if (mime.startsWith('video/')) return 'video'; + return 'file'; +} + +function clearPendingAttaches() { + for (const item of pendingAttaches.value) { + if (item.previewUrl.startsWith('blob:')) { + try { + URL.revokeObjectURL(item.previewUrl); + } catch { + // ignore + } + } + } + pendingAttaches.value = []; +} + +function removePending(id: string) { + const item = pendingAttaches.value.find((a) => a.id === id); + if (item?.previewUrl.startsWith('blob:')) { + try { + URL.revokeObjectURL(item.previewUrl); + } catch { + // ignore + } + } + pendingAttaches.value = pendingAttaches.value.filter((a) => a.id !== id); +} + +function addFiles(files: FileList | File[]) { + const list = Array.from(files); + for (const file of list) { + pendingAttaches.value = [ + ...pendingAttaches.value, + { + id: crypto.randomUUID(), + file, + kind: classifyPending(file), + previewUrl: URL.createObjectURL(file), + name: file.name, + }, + ]; + } +} + +function onFilePicked(event: Event) { + const input = event.target as HTMLInputElement; + if (input.files?.length) addFiles(input.files); + input.value = ''; +} + +function mediaSrc(msg: ChatMessage) { + return msg.localPreviewUrl || msg.mediaUrl || ''; +} + +function stopRecordTimer() { + if (recordTimer) { + clearInterval(recordTimer); + recordTimer = null; + } +} + +function stopMicTracks() { + recordStream?.getTracks().forEach((t) => t.stop()); + recordStream = null; +} + +async function toggleVoiceRecord() { + if (recording.value) { + mediaRecorder?.stop(); + return; + } + try { + recordStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + recordChunks = []; + const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') + ? 'audio/webm;codecs=opus' + : MediaRecorder.isTypeSupported('audio/webm') + ? 'audio/webm' + : ''; + mediaRecorder = mime + ? new MediaRecorder(recordStream, { mimeType: mime }) + : new MediaRecorder(recordStream); + mediaRecorder.ondataavailable = (e) => { + if (e.data.size) recordChunks.push(e.data); + }; + mediaRecorder.onstop = () => { + stopRecordTimer(); + recording.value = false; + const type = mediaRecorder?.mimeType || 'audio/webm'; + stopMicTracks(); + mediaRecorder = null; + if (!recordChunks.length) return; + const blob = new Blob(recordChunks, { type }); + recordChunks = []; + const ext = type.includes('ogg') ? 'ogg' : type.includes('mp4') ? 'm4a' : 'webm'; + const file = new File([blob], `voice-${Date.now()}.${ext}`, { type }); + addFiles([file]); + recordSecs.value = 0; + }; + mediaRecorder.start(); + recording.value = true; + recordSecs.value = 0; + recordTimer = setInterval(() => { + recordSecs.value += 1; + }, 1000); + } catch { + error.value = 'دسترسی به میکروفون ممکن نشد.'; + stopMicTracks(); + recording.value = false; + } +} + +function recordLabel() { + const m = Math.floor(recordSecs.value / 60); + const s = recordSecs.value % 60; + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; +} + function applyRoomLive(event: RoomLiveEvent) { const idx = rooms.value.findIndex((r) => r.externalRoomId === event.roomId); if (idx < 0) { @@ -186,12 +329,39 @@ async function openRoom(room: ConversationRoomDto) { } async function submitMessage() { - if (!activeRoom.value || !draft.value.trim()) return; + if (!activeRoom.value) return; + if (sending.value || recording.value) return; + const text = draft.value.trim(); + const attaches = [...pendingAttaches.value]; + if (!text && !attaches.length) return; + + sending.value = true; + error.value = ''; + const roomId = activeRoom.value.externalRoomId; + draft.value = ''; + pendingAttaches.value = []; + try { - await sendText(activeRoom.value.externalRoomId, draft.value); - draft.value = ''; - } catch { - error.value = 'ارسال پیام ناموفق بود.'; + if (text) { + await sendText(roomId, text); + } + for (const item of attaches) { + try { + await sendFile(roomId, item.file); + } finally { + if (item.previewUrl.startsWith('blob:')) { + try { + URL.revokeObjectURL(item.previewUrl); + } catch { + // ignore + } + } + } + } + } catch (e) { + error.value = e instanceof Error ? e.message : 'ارسال پیام ناموفق بود.'; + } finally { + sending.value = false; } } @@ -241,6 +411,10 @@ function toggleGroupMember(id: string) { watch(activeRoom, () => { draft.value = ''; + clearPendingAttaches(); + if (recording.value) { + mediaRecorder?.stop(); + } }); watch( @@ -264,6 +438,10 @@ onUnmounted(() => { setConversationsPageOpen(false); stopRoomLive?.(); clearActiveRoom(); + clearPendingAttaches(); + if (recording.value) mediaRecorder?.stop(); + stopRecordTimer(); + stopMicTracks(); }); @@ -342,8 +520,48 @@ onUnmounted(() => { class="msg-row" :class="{ mine: msg.senderId === chatUserId || msg.optimistic }" > -
-

{{ msg.content }}

+
+ + + + +

{{ msg.content }}

{
- - +
+
+ +
+
+
+ + در حال ضبط {{ toFaDigits(recordLabel()) }} + +
+
+ + + + + +
@@ -408,6 +682,13 @@ onUnmounted(() => {
+ + + + +
@@ -656,11 +937,76 @@ onUnmounted(() => { white-space: pre-wrap; word-break: break-word; } +.bubble.media { + padding: 0.45rem; +} +.media-image { + position: relative; + display: block; + border: none; + padding: 0; + background: transparent; + cursor: zoom-in; + border-radius: 12px; + overflow: hidden; + max-width: min(280px, 72vw); +} +.media-image img { + display: block; + width: 100%; + max-height: 320px; + object-fit: cover; +} +.upload-veil { + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: rgba(15, 23, 42, 0.45); + color: #fff; + font-size: 0.9rem; + font-weight: 700; +} +.media-audio, +.media-video { + display: block; + width: min(280px, 72vw); + max-width: 100%; +} +.media-video { + border-radius: 12px; + background: #0f172a; +} +.media-file { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.45rem 0.65rem; + border-radius: 12px; + background: rgba(15, 23, 42, 0.06); + color: inherit; + text-decoration: none; + font-size: 0.88rem; + max-width: min(280px, 72vw); +} +.media-file span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.upload-line { + display: block; + margin-top: 0.35rem; + font-size: 0.72rem; + color: var(--muted); +} .bubble-meta { display: flex; align-items: center; justify-content: flex-end; gap: 0.35rem; + margin-top: 0.25rem; + padding-inline: 0.25rem; } .bubble time { font-size: 0.68rem; @@ -684,27 +1030,177 @@ onUnmounted(() => { } .composer { display: flex; + flex-direction: column; gap: 0.5rem; padding: 0.75rem 1rem 1rem; border-top: 1px solid var(--line); } -.composer input { +.composer-row { + display: flex; + gap: 0.4rem; + align-items: center; +} +.composer-row input[type='text'] { flex: 1; border: 1px solid var(--line); border-radius: 14px; padding: 0.65rem 0.85rem; font: inherit; } -.composer button { +.composer-row > button[type='submit'] { border: none; border-radius: 14px; width: 2.75rem; + height: 2.75rem; background: #0d9488; color: #fff; cursor: pointer; display: grid; place-items: center; } +.composer-row > button[type='submit']:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.tool-btn { + border: 1px solid var(--line); + background: var(--surface); + color: var(--text); + border-radius: 12px; + width: 2.5rem; + height: 2.5rem; + display: grid; + place-items: center; + cursor: pointer; +} +.tool-btn.on { + border-color: #ef4444; + color: #ef4444; + background: rgba(239, 68, 68, 0.08); +} +.tool-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.attach-preview { + display: flex; + gap: 0.45rem; + overflow-x: auto; + padding-bottom: 0.15rem; +} +.attach-chip { + position: relative; + flex: 0 0 auto; + border-radius: 12px; + border: 1px solid var(--line); + overflow: hidden; + background: #f8fafc; +} +.attach-chip.image, +.attach-chip.video { + width: 72px; + height: 72px; +} +.attach-chip img, +.attach-chip video { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.attach-chip-label { + display: flex; + align-items: center; + gap: 0.35rem; + max-width: 160px; + padding: 0.55rem 0.7rem; + font-size: 0.78rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.attach-remove { + position: absolute; + top: 4px; + left: 4px; + border: none; + border-radius: 999px; + width: 1.35rem; + height: 1.35rem; + background: rgba(15, 23, 42, 0.7); + color: #fff; + display: grid; + place-items: center; + cursor: pointer; +} +.rec-bar { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: #b91c1c; +} +.rec-dot { + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; + background: #ef4444; + animation: pulse 1s infinite; +} +.rec-stop { + margin-inline-start: auto; + border: 1px solid #fecaca; + background: #fff; + color: #b91c1c; + border-radius: 10px; + padding: 0.25rem 0.65rem; + cursor: pointer; +} +@keyframes pulse { + 50% { + opacity: 0.35; + } +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} +.lightbox { + position: fixed; + inset: 0; + z-index: 60; + border: none; + background: rgba(15, 23, 42, 0.82); + display: grid; + place-items: center; + padding: 1.5rem; +} +.lightbox img { + max-width: min(960px, 100%); + max-height: 90vh; + border-radius: 12px; + object-fit: contain; +} +.lightbox-close { + position: absolute; + top: 1rem; + left: 1rem; + border: none; + border-radius: 999px; + width: 2.5rem; + height: 2.5rem; + background: rgba(255, 255, 255, 0.15); + color: #fff; + display: grid; + place-items: center; + cursor: pointer; +} .empty-stage { flex: 1; display: grid; diff --git a/apps/web/src/views/adm/AdmOrganizationsView.vue b/apps/web/src/views/adm/AdmOrganizationsView.vue index 9e36ef8..32108e1 100644 --- a/apps/web/src/views/adm/AdmOrganizationsView.vue +++ b/apps/web/src/views/adm/AdmOrganizationsView.vue @@ -563,11 +563,19 @@ onMounted(() => {