From 06977d27ed177161dbb5161f31fcc0367d254854 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Mon, 2 Nov 2026 16:34:03 +0330 Subject: [PATCH] feat(activities): attach voice and files on activity notes Co-authored-by: Cursor --- .../src/activities/activities.controller.ts | 2 +- apps/api/src/activities/activities.service.ts | 100 ++++- apps/web/src/components/UiIcon.vue | 4 + .../activities/ActivityCommentSheet.vue | 317 +++++++++++--- apps/web/src/lib/activity-attachment.ts | 69 +++ apps/web/src/lib/voice-note.ts | 166 +++++++ apps/web/src/views/ActivityDetailView.vue | 61 ++- apps/web/src/views/ActivityFormView.vue | 405 ++++++++++++++++-- apps/web/src/views/adm/AdmBillingPanel.vue | 2 +- 9 files changed, 1009 insertions(+), 117 deletions(-) create mode 100644 apps/web/src/lib/activity-attachment.ts create mode 100644 apps/web/src/lib/voice-note.ts diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index d3f8930..ba6de59 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -352,7 +352,7 @@ export class ActivitiesController { @UseInterceptors( FileInterceptor('file', { storage: memoryStorage(), - limits: { fileSize: 2 * 1024 * 1024 }, + limits: { fileSize: 10 * 1024 * 1024 }, }), ) addImage( diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 7bfede1..a2ebd20 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -105,6 +105,46 @@ type ActivityRow = { const ACTIVITY_IMAGE_MAX_BYTES = 200 * 1024; const ACTIVITY_IMAGE_MAX_COUNT = 8; const ACTIVITY_IMAGE_MIME = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']); +const ACTIVITY_VOICE_MAX_BYTES = 2 * 1024 * 1024; +const ACTIVITY_VOICE_MAX_COUNT = 5; +const ACTIVITY_VOICE_MIME = new Set(['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'audio/mp3', 'audio/aac', 'audio/x-m4a', 'audio/m4a', 'audio/wav', 'audio/x-wav']); +const ACTIVITY_FILE_MAX_BYTES = 10 * 1024 * 1024; +const ACTIVITY_FILE_MAX_COUNT = 8; +const ACTIVITY_FILE_MIME = new Set([ + 'application/pdf', + 'text/plain', + 'text/csv', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/zip', + 'application/x-zip-compressed', +]); +const ACTIVITY_EXT_MIME: Record = { + pdf: 'application/pdf', + txt: 'text/plain', + csv: 'text/csv', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + zip: 'application/zip', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', + webm: 'audio/webm', + ogg: 'audio/ogg', + mp3: 'audio/mpeg', + m4a: 'audio/mp4', + wav: 'audio/wav', +}; const include = { stage: { select: { id: true, kind: true, boardId: true } }, @@ -840,19 +880,57 @@ export class ActivitiesService { if (!file?.buffer?.length) { throw new BadRequestException('فایلی ارسال نشده است'); } - const mimeType = (file.mimetype || '').toLowerCase(); - if (!ACTIVITY_IMAGE_MIME.has(mimeType)) { - throw new BadRequestException('فقط عکس می‌توانید اضافه کنید.'); + const namedExt = path.extname(file.originalname || '').slice(1).toLowerCase(); + let mimeType = (file.mimetype || '').toLowerCase().split(';')[0]?.trim() || ''; + if (!mimeType || mimeType === 'application/octet-stream') { + mimeType = ACTIVITY_EXT_MIME[namedExt] || mimeType; } - if (file.size > ACTIVITY_IMAGE_MAX_BYTES) { - throw new BadRequestException('حجم عکس باید زیر 200 کیلوبایت باشد'); + const isVoice = mimeType.startsWith('audio/') || ACTIVITY_VOICE_MIME.has(mimeType); + const isImage = ACTIVITY_IMAGE_MIME.has(mimeType); + const isFile = ACTIVITY_FILE_MIME.has(mimeType); + if (!isVoice && !isImage && !isFile) { + throw new BadRequestException('این نوع فایل مجاز نیست.'); } - const count = await this.prisma.activityImage.count({ where: { activityId } }); - if (count >= ACTIVITY_IMAGE_MAX_COUNT) { - throw new BadRequestException(`حداکثر ${ACTIVITY_IMAGE_MAX_COUNT} عکس برای هر فعالیت مجاز است`); + if (isVoice) { + if (file.size > ACTIVITY_VOICE_MAX_BYTES) { + throw new BadRequestException('حجم صوت باید زیر ۲ مگابایت باشد'); + } + const voiceCount = await this.prisma.activityImage.count({ + where: { activityId, mimeType: { startsWith: 'audio/' } }, + }); + if (voiceCount >= ACTIVITY_VOICE_MAX_COUNT) { + throw new BadRequestException(`حداکثر ${ACTIVITY_VOICE_MAX_COUNT} صوت برای هر فعالیت مجاز است`); + } + } else if (isImage) { + if (file.size > ACTIVITY_IMAGE_MAX_BYTES) { + throw new BadRequestException('حجم عکس باید زیر 200 کیلوبایت باشد'); + } + const count = await this.prisma.activityImage.count({ + where: { activityId, mimeType: { startsWith: 'image/' } }, + }); + if (count >= ACTIVITY_IMAGE_MAX_COUNT) { + throw new BadRequestException(`حداکثر ${ACTIVITY_IMAGE_MAX_COUNT} عکس برای هر فعالیت مجاز است`); + } + } else { + if (file.size > ACTIVITY_FILE_MAX_BYTES) { + throw new BadRequestException('حجم فایل باید زیر ۱۰ مگابایت باشد'); + } + const fileCount = await this.prisma.activityImage.count({ + where: { + activityId, + NOT: { + OR: [{ mimeType: { startsWith: 'image/' } }, { mimeType: { startsWith: 'audio/' } }], + }, + }, + }); + if (fileCount >= ACTIVITY_FILE_MAX_COUNT) { + throw new BadRequestException(`حداکثر ${ACTIVITY_FILE_MAX_COUNT} فایل برای هر فعالیت مجاز است`); + } } const fileName = sanitizeActivityImageName(file.originalname); - const ext = path.extname(fileName).slice(0, 12) || '.jpg'; + const ext = + path.extname(fileName).slice(0, 12) || + (mimeType === 'audio/ogg' ? '.ogg' : mimeType === 'audio/mp4' ? '.m4a' : isVoice ? '.webm' : '.jpg'); const imageId = randomUUID().replace(/-/g, '').slice(0, 24); const storageKey = path .join(organizationId, 'activities', activityId, `${imageId}${ext}`) @@ -896,7 +974,7 @@ export class ActivitiesService { const row = await this.prisma.activityImage.findFirst({ where: { id: imageId, activityId, organizationId }, }); - if (!row) throw new NotFoundException('عکس پیدا نشد'); + if (!row) throw new NotFoundException('فایل پیدا نشد'); await this.files.remove(row.storageKey); await this.prisma.activityImage.delete({ where: { id: row.id } }); await this.storage.addLocal(organizationId, -row.sizeBytes); @@ -912,7 +990,7 @@ export class ActivitiesService { const row = await this.prisma.activityImage.findFirst({ where: { id: imageId, activityId, organizationId }, }); - if (!row) throw new NotFoundException('عکس پیدا نشد'); + if (!row) throw new NotFoundException('فایل پیدا نشد'); return { stream: this.files.openReadStream(row.storageKey), fileName: row.fileName, diff --git a/apps/web/src/components/UiIcon.vue b/apps/web/src/components/UiIcon.vue index 3330e0f..1696720 100644 --- a/apps/web/src/components/UiIcon.vue +++ b/apps/web/src/components/UiIcon.vue @@ -68,6 +68,7 @@ defineProps<{ | 'pulse' | 'sliders' | 'camera' + | 'clip' | 'download' | 'upload' | 'arrow-right' @@ -423,6 +424,9 @@ defineProps<{ + diff --git a/apps/web/src/components/activities/ActivityCommentSheet.vue b/apps/web/src/components/activities/ActivityCommentSheet.vue index 4848662..4cb2cf9 100644 --- a/apps/web/src/components/activities/ActivityCommentSheet.vue +++ b/apps/web/src/components/activities/ActivityCommentSheet.vue @@ -6,7 +6,8 @@ import { formatClockFa, formatJalaliDay, } from '@/lib/jalali'; -import { FormFileTooLargeError, prepareFormFieldFile } from '@/lib/form-file-compress'; +import { prepareActivityAttachment } from '@/lib/activity-attachment'; +import { startVoiceRecorder, type VoiceRecorder } from '@/lib/voice-note'; import { appendSpokenText } from '@/lib/spoken-text'; import UiIcon from '@/components/UiIcon.vue'; import VoiceToTextButton from '@/components/VoiceToTextButton.vue'; @@ -29,18 +30,26 @@ const typeIcon: Record = { note: 'chat', }; -const PHOTO_TOO_LARGE = 'حجم عکس باید زیر 200 کیلوبایت باشد'; const PHOTO_LIMIT = 8; +const VOICE_LIMIT = 5; +const FILE_LIMIT = 8; type PendingPhoto = { localId: string; name: string; dataUrl: string; file: File }; +type PendingVoice = { localId: string; name: string; url: string; file: File }; +type PendingFile = { localId: string; name: string; file: File }; const comment = ref(''); const textareaRef = ref(null); const pendingPhotos = ref([]); +const pendingVoices = ref([]); +const pendingFiles = ref([]); const uploadedCount = ref(0); const photoBusy = ref(false); const uploading = ref(false); const photoError = ref(''); -const photoInput = ref(null); +const fileInput = ref(null); +const voiceNoteOn = ref(false); +const voiceNoteSeconds = ref(0); +let voiceNote: VoiceRecorder | null = null; const orgName = ref('سازمان'); const users = ref([]); const mentionOpen = ref(false); @@ -72,17 +81,29 @@ const nowLabel = computed( () => `${formatJalaliDay(now.value)} ساعت ${formatClockFa(now.value)}`, ); -const photoSlotsUsed = computed( - () => (props.activity.images?.length ?? 0) + uploadedCount.value + pendingPhotos.value.length, -); +function imageCount(kind: 'image' | 'audio' | 'file') { + return ( + props.activity.images?.filter((image) => { + const mime = image.mimeType.toLowerCase(); + if (kind === 'audio') return mime.startsWith('audio/'); + if (kind === 'image') return mime.startsWith('image/'); + return !mime.startsWith('audio/') && !mime.startsWith('image/'); + }).length ?? 0 + ); +} const canSubmit = computed( () => - (comment.value.trim().length > 0 || pendingPhotos.value.length > 0 || uploadedCount.value > 0) && + (comment.value.trim().length > 0 || + pendingPhotos.value.length > 0 || + pendingVoices.value.length > 0 || + pendingFiles.value.length > 0 || + uploadedCount.value > 0) && !props.saving && !voiceBusy.value && !photoBusy.value && - !uploading.value, + !uploading.value && + !voiceNoteOn.value, ); function onVoiceTranscript(text: string) { @@ -105,36 +126,45 @@ function apiMessage(error: unknown, fallback: string) { return fallback; } -async function onPhotosPicked(event: Event) { +async function onFilesPicked(event: Event) { const input = event.target as HTMLInputElement; const files = Array.from(input.files ?? []); input.value = ''; if (!files.length) return; photoError.value = ''; for (const file of files) { - if (photoSlotsUsed.value >= PHOTO_LIMIT) { - photoError.value = `حداکثر ${PHOTO_LIMIT} عکس برای هر فعالیت مجاز است`; - break; - } - if (!file.type.startsWith('image/')) { - photoError.value = 'فقط عکس می‌توانید اضافه کنید.'; - continue; - } photoBusy.value = true; try { - const prepared = await prepareFormFieldFile(file); - pendingPhotos.value = [ - ...pendingPhotos.value, - { - localId: crypto.randomUUID(), - name: prepared.name, - dataUrl: prepared.dataUrl, - file: prepared.file, - }, - ]; + const prepared = await prepareActivityAttachment(file); + if (prepared.kind === 'image') { + if (imageCount('image') + pendingPhotos.value.length >= PHOTO_LIMIT) { + photoError.value = `حداکثر ${PHOTO_LIMIT} عکس برای هر فعالیت.`; + continue; + } + pendingPhotos.value = [ + ...pendingPhotos.value, + { localId: crypto.randomUUID(), name: prepared.name, dataUrl: prepared.previewUrl, file: prepared.file }, + ]; + } else if (prepared.kind === 'audio') { + if (imageCount('audio') + pendingVoices.value.length >= VOICE_LIMIT) { + URL.revokeObjectURL(prepared.previewUrl); + photoError.value = `حداکثر ${VOICE_LIMIT} صوت برای هر فعالیت.`; + continue; + } + pendingVoices.value = [ + ...pendingVoices.value, + { localId: crypto.randomUUID(), name: prepared.name, url: prepared.previewUrl, file: prepared.file }, + ]; + } else if (imageCount('file') + pendingFiles.value.length >= FILE_LIMIT) { + photoError.value = `حداکثر ${FILE_LIMIT} فایل برای هر فعالیت.`; + } else { + pendingFiles.value = [ + ...pendingFiles.value, + { localId: crypto.randomUUID(), name: prepared.name, file: prepared.file }, + ]; + } } catch (error) { - photoError.value = - error instanceof FormFileTooLargeError ? PHOTO_TOO_LARGE : 'فشرده‌سازی عکس ناموفق بود.'; + photoError.value = error instanceof Error ? error.message : 'افزودن فایل ناموفق بود.'; } finally { photoBusy.value = false; } @@ -143,18 +173,86 @@ async function onPhotosPicked(event: Event) { function removePendingPhoto(localId: string) { pendingPhotos.value = pendingPhotos.value.filter((photo) => photo.localId !== localId); - if (photoError.value === PHOTO_TOO_LARGE) photoError.value = ''; +} + +function removePendingVoice(localId: string) { + const voice = pendingVoices.value.find((item) => item.localId === localId); + if (voice) URL.revokeObjectURL(voice.url); + pendingVoices.value = pendingVoices.value.filter((item) => item.localId !== localId); +} + +function removePendingFile(localId: string) { + pendingFiles.value = pendingFiles.value.filter((item) => item.localId !== localId); +} + +function voiceClock(total: number) { + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`; +} + +function addVoiceNote(file: File) { + if (imageCount('audio') + pendingVoices.value.length >= VOICE_LIMIT) { + photoError.value = `حداکثر ${VOICE_LIMIT} صوت برای هر فعالیت.`; + return; + } + pendingVoices.value = [ + ...pendingVoices.value, + { localId: crypto.randomUUID(), name: file.name, url: URL.createObjectURL(file), file }, + ]; +} + +async function toggleVoiceNote() { + photoError.value = ''; + if (voiceNote) { + const handle = voiceNote; + voiceNote = null; + voiceNoteOn.value = false; + try { + addVoiceNote(await handle.stop()); + } catch (error) { + photoError.value = error instanceof Error ? error.message : 'ضبط صوت ناموفق بود.'; + } + return; + } + try { + voiceNoteSeconds.value = 0; + voiceNote = await startVoiceRecorder({ + onTick: (seconds) => { + voiceNoteSeconds.value = seconds; + }, + onAutoStop: (file) => { + voiceNote = null; + voiceNoteOn.value = false; + addVoiceNote(file); + }, + }); + voiceNoteOn.value = true; + } catch (error) { + photoError.value = error instanceof Error ? error.message : 'ضبط صوت ناموفق بود.'; + } +} + +async function uploadQueue(items: Array<{ localId: string; name: string; file: File }>, drop: (localId: string) => void) { + for (const item of items) { + const form = new FormData(); + form.append('file', item.file, item.name); + await api.post(`/activities/${props.activity.id}/images`, form); + drop(item.localId); + uploadedCount.value += 1; + } } async function uploadPendingPhotos() { - const queue = [...pendingPhotos.value]; - for (const photo of queue) { - const form = new FormData(); - form.append('file', photo.file, photo.name); - await api.post(`/activities/${props.activity.id}/images`, form); - pendingPhotos.value = pendingPhotos.value.filter((item) => item.localId !== photo.localId); - uploadedCount.value += 1; - } + await uploadQueue([...pendingPhotos.value], (localId) => { + pendingPhotos.value = pendingPhotos.value.filter((item) => item.localId !== localId); + }); + await uploadQueue([...pendingVoices.value], (localId) => { + const voice = pendingVoices.value.find((item) => item.localId === localId); + if (voice) URL.revokeObjectURL(voice.url); + pendingVoices.value = pendingVoices.value.filter((item) => item.localId !== localId); + }); + await uploadQueue([...pendingFiles.value], (localId) => { + pendingFiles.value = pendingFiles.value.filter((item) => item.localId !== localId); + }); } async function submit() { @@ -162,14 +260,18 @@ async function submit() { if (!canSubmit.value) return; photoError.value = ''; uploading.value = true; - const hadPhoto = pendingPhotos.value.length > 0 || uploadedCount.value > 0; + const hadPhoto = + pendingPhotos.value.length > 0 || + pendingVoices.value.length > 0 || + pendingFiles.value.length > 0 || + uploadedCount.value > 0; try { await uploadPendingPhotos(); await props.save({ text, photoOnly: hadPhoto && !text }); } catch (error) { photoError.value = pendingPhotos.value.length > 0 - ? apiMessage(error, 'افزودن عکس ناموفق بود.') + ? apiMessage(error, 'افزودن فایل ناموفق بود.') : apiMessage(error, 'ثبت نظر ناموفق بود.'); } finally { uploading.value = false; @@ -243,6 +345,8 @@ onMounted(async () => { }); onBeforeUnmount(() => { + voiceNote?.cancel(); + for (const voice of pendingVoices.value) URL.revokeObjectURL(voice.url); document.body.style.overflow = ''; window.removeEventListener('keydown', onKeydown); clearInterval(clockTimer); @@ -308,26 +412,6 @@ onBeforeUnmount(() => { rows="6" placeholder="متن خود را بنویسید..." /> -
- - - عکس خودکار کم‌حجم می‌شود تا زیر ۲۰۰ کیلوبایت بماند. -

{{ photoError }}

@@ -337,13 +421,31 @@ onBeforeUnmount(() => {
+
+
+
+
+
    +
  • + + {{ file.name }} + +
  • +
+
+ + + + {{ voiceClock(voiceNoteSeconds) }} +

تایپ صوتی را امتحان کنید! 🎙️✨

{ padding: 0.35rem 0.5rem 0.5rem; } +.tool-cluster { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.tool.voice-on { + border-color: #fecaca; + background: #fef2f2; + color: #b91c1c; +} + +.rec-time { + color: #b91c1c; + font-size: 0.78rem; + font-weight: 700; +} + +.voice-list { + display: grid; + gap: 0.35rem; + padding: 0 0.5rem; +} + +.voice-item { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.voice-item audio { + flex: 1; + min-width: 0; + height: 2.1rem; +} + +.voice-item button, +.file-list button { + width: 1.4rem; + height: 1.4rem; + border: 0; + border-radius: 999px; + background: #e2e8f0; + cursor: pointer; +} + +.file-list { + list-style: none; + margin: 0; + padding: 0 0.5rem; + display: grid; + gap: 0.3rem; +} + +.file-list li { + display: flex; + align-items: center; + gap: 0.35rem; + min-width: 0; +} + +.file-list span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .tool-wrap { position: relative; } diff --git a/apps/web/src/lib/activity-attachment.ts b/apps/web/src/lib/activity-attachment.ts new file mode 100644 index 0000000..72ea03d --- /dev/null +++ b/apps/web/src/lib/activity-attachment.ts @@ -0,0 +1,69 @@ +import { FormFileTooLargeError, prepareFormFieldFile } from '@/lib/form-file-compress'; +import { compressVoiceFile } from '@/lib/voice-note'; + +export const ACTIVITY_FILE_MAX_BYTES = 10 * 1024 * 1024; + +const FILE_MIME_BY_EXT: Record = { + pdf: 'application/pdf', + txt: 'text/plain', + csv: 'text/csv', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + zip: 'application/zip', +}; + +const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp']); +const AUDIO_EXT = new Set(['webm', 'ogg', 'mp3', 'm4a', 'aac', 'wav', 'mpeg']); + +export type PreparedAttachment = { + kind: 'image' | 'audio' | 'file'; + name: string; + file: File; + previewUrl: string; +}; + +function extensionOf(name: string): string { + return name.split('.').pop()?.toLowerCase().replace(/[^a-z0-9]/g, '') ?? ''; +} + +function kindOf(file: File): 'image' | 'audio' | 'file' { + const type = file.type.toLowerCase().split(';')[0] ?? ''; + const ext = extensionOf(file.name); + if (type.startsWith('image/') || IMAGE_EXT.has(ext)) return 'image'; + if (type.startsWith('audio/') || AUDIO_EXT.has(ext)) return 'audio'; + return 'file'; +} + +export async function prepareActivityAttachment(file: File): Promise { + const kind = kindOf(file); + if (kind === 'image') { + try { + const prepared = await prepareFormFieldFile(file); + return { kind, name: prepared.name, file: prepared.file, previewUrl: prepared.dataUrl }; + } catch (error) { + if (error instanceof FormFileTooLargeError) { + throw new Error('حجم عکس باید زیر ۲۰۰ کیلوبایت باشد'); + } + throw new Error('فشرده‌سازی عکس ناموفق بود.'); + } + } + if (kind === 'audio') { + const compressed = await compressVoiceFile(file); + return { + kind, + name: compressed.name, + file: compressed, + previewUrl: URL.createObjectURL(compressed), + }; + } + const ext = extensionOf(file.name); + const mime = FILE_MIME_BY_EXT[ext]; + if (!mime) throw new Error('این نوع فایل مجاز نیست.'); + if (file.size > ACTIVITY_FILE_MAX_BYTES) throw new Error('حجم فایل باید زیر ۱۰ مگابایت باشد'); + const named = file.type === mime ? file : new File([file], file.name, { type: mime }); + return { kind: 'file', name: named.name, file: named, previewUrl: '' }; +} diff --git a/apps/web/src/lib/voice-note.ts b/apps/web/src/lib/voice-note.ts new file mode 100644 index 0000000..d5a38c1 --- /dev/null +++ b/apps/web/src/lib/voice-note.ts @@ -0,0 +1,166 @@ +/** پیام صوتی به سبک بله و تلگرام: Opus تک‌کاناله، حدود ۳۲ کیلوبیت بر ثانیه. */ +export const VOICE_BITRATE = 32_000; +export const VOICE_MAX_SECONDS = 5 * 60; +export const VOICE_MAX_BYTES = Math.floor(1.8 * 1024 * 1024); + +const MIME_CANDIDATES = [ + 'audio/webm;codecs=opus', + 'audio/ogg;codecs=opus', + 'audio/webm', + 'audio/mp4', +]; + +export function voiceRecorderMime(): string { + if (typeof MediaRecorder === 'undefined') return ''; + return MIME_CANDIDATES.find((mime) => MediaRecorder.isTypeSupported(mime)) ?? ''; +} + +function extensionFor(mime: string): string { + if (mime.includes('mp4')) return 'm4a'; + if (mime.includes('ogg')) return 'ogg'; + return 'webm'; +} + +function fileFromBlob(blob: Blob, mime: string): File { + const type = mime.split(';')[0] || blob.type || 'audio/webm'; + return new File([blob], `voice.${extensionFor(mime)}`, { type }); +} + +export type VoiceRecorder = { + stop: () => Promise; + cancel: () => void; +}; + +export async function startVoiceRecorder(handlers: { + onTick: (seconds: number) => void; + onAutoStop: (file: File) => void; +}): Promise { + const mime = voiceRecorderMime(); + if (!mime || !navigator.mediaDevices?.getUserMedia) { + throw new Error('این مرورگر ضبط صوت را پشتیبانی نمی‌کند.'); + } + let stream: MediaStream; + try { + stream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: { ideal: 1 }, + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + }); + } catch { + throw new Error('دسترسی به میکروفون داده نشد.'); + } + + const rec = new MediaRecorder(stream, { mimeType: mime, audioBitsPerSecond: VOICE_BITRATE }); + const chunks: Blob[] = []; + rec.ondataavailable = (event) => { + if (event.data.size) chunks.push(event.data); + }; + const started = Date.now(); + let settled = false; + let timer = 0; + let auto = 0; + + const stopped = new Promise((resolve, reject) => { + rec.onstop = () => { + const file = fileFromBlob(new Blob(chunks, { type: mime.split(';')[0] }), mime); + if (!file.size) { + reject(new Error('صدایی ضبط نشد.')); + return; + } + if (file.size > VOICE_MAX_BYTES) { + reject(new Error('حجم صوت بعد از فشرده‌سازی هنوز زیاد است.')); + return; + } + resolve(file); + }; + rec.onerror = () => reject(new Error('ضبط صوت ناموفق بود.')); + }); + + const finish = () => { + if (settled) return; + settled = true; + window.clearInterval(timer); + window.clearTimeout(auto); + stream.getTracks().forEach((track) => track.stop()); + if (rec.state !== 'inactive') rec.stop(); + }; + + timer = window.setInterval(() => { + handlers.onTick(Math.min(VOICE_MAX_SECONDS, Math.round((Date.now() - started) / 1000))); + }, 250); + auto = window.setTimeout(() => { + finish(); + void stopped.then(handlers.onAutoStop).catch(() => undefined); + }, VOICE_MAX_SECONDS * 1000); + rec.start(250); + + return { + stop: () => { + finish(); + return stopped; + }, + cancel: () => { + chunks.length = 0; + finish(); + void stopped.catch(() => undefined); + }, + }; +} + +export async function compressVoiceFile(file: File): Promise { + const mime = voiceRecorderMime(); + if (!mime) throw new Error('این مرورگر فشرده‌سازی صوت را پشتیبانی نمی‌کند.'); + const ctx = new AudioContext(); + let audio: AudioBuffer; + try { + audio = await ctx.decodeAudioData(await file.arrayBuffer()); + } catch { + await ctx.close(); + throw new Error('این فایل صوتی قابل خواندن نیست.'); + } + if (audio.duration > VOICE_MAX_SECONDS + 0.5) { + await ctx.close(); + throw new Error('صوت حداکثر ۵ دقیقه می‌تواند باشد.'); + } + + const mono = ctx.createBuffer(1, audio.length, audio.sampleRate); + const out = mono.getChannelData(0); + for (let i = 0; i < audio.length; i += 1) { + let sum = 0; + for (let channel = 0; channel < audio.numberOfChannels; channel += 1) { + sum += audio.getChannelData(channel)[i] ?? 0; + } + out[i] = sum / audio.numberOfChannels; + } + + const dest = ctx.createMediaStreamDestination(); + const source = ctx.createBufferSource(); + source.buffer = mono; + source.connect(dest); + const rec = new MediaRecorder(dest.stream, { mimeType: mime, audioBitsPerSecond: VOICE_BITRATE }); + const chunks: Blob[] = []; + rec.ondataavailable = (event) => { + if (event.data.size) chunks.push(event.data); + }; + const encoded = new Promise((resolve, reject) => { + rec.onstop = () => resolve(new Blob(chunks, { type: mime.split(';')[0] })); + rec.onerror = () => reject(new Error('فشرده‌سازی صوت ناموفق بود.')); + }); + rec.start(250); + source.start(); + await new Promise((resolve) => { + source.onended = () => resolve(); + }); + await new Promise((resolve) => window.setTimeout(resolve, 180)); + if (rec.state !== 'inactive') rec.stop(); + const blob = await encoded; + source.disconnect(); + await ctx.close(); + const result = fileFromBlob(blob, mime); + if (!result.size) throw new Error('فشرده‌سازی صوت ناموفق بود.'); + if (result.size > VOICE_MAX_BYTES) throw new Error('حجم صوت بعد از فشرده‌سازی هنوز زیاد است.'); + return result; +} diff --git a/apps/web/src/views/ActivityDetailView.vue b/apps/web/src/views/ActivityDetailView.vue index 7bf1fc7..70acd7f 100644 --- a/apps/web/src/views/ActivityDetailView.vue +++ b/apps/web/src/views/ActivityDetailView.vue @@ -57,7 +57,7 @@ const loading = ref(true); const saving = ref(false); const error = ref(''); const activity = ref(null); -const imageUrls = ref>([]); +const imageUrls = ref>([]); const moreOpen = ref(false); const commentOpen = ref(false); const postponeOpen = ref(false); @@ -174,15 +174,22 @@ function clearImageUrls() { async function loadActivityImages(row: ActivityDto) { clearImageUrls(); - const next: Array<{ id: string; name: string; url: string }> = []; + const next: Array<{ id: string; name: string; url: string; kind: 'image' | 'audio' | 'file' }> = []; for (const image of row.images ?? []) { + const mime = image.mimeType.toLowerCase(); + const kind = mime.startsWith('audio/') ? 'audio' : mime.startsWith('image/') ? 'image' : 'file'; try { const { data } = await api.get(`/activities/${row.id}/images/${image.id}`, { responseType: 'blob', }); - next.push({ id: image.id, name: image.fileName, url: URL.createObjectURL(data) }); + next.push({ + id: image.id, + name: image.fileName, + url: URL.createObjectURL(data), + kind, + }); } catch { - /* عکس در دسترس نیست */ + /* فایل در دسترس نیست */ } } imageUrls.value = next; @@ -560,11 +567,31 @@ onMounted(() => {

{{ activity.description.trim() }}

یادداشتی ثبت نشده.

-
- + +
+
+
@@ -967,6 +994,28 @@ dd { border: 1px solid #e2e8f0; } +.note-voices { + display: grid; + gap: 0.45rem; + margin-top: 0.75rem; +} + +.note-voices audio { + width: 100%; +} + +.note-files { + list-style: none; + margin: 0.75rem 0 0; + padding: 0; + display: grid; + gap: 0.35rem; +} + +.note-files a { + color: #0f766e; +} + .dock { position: fixed; inset-inline: 0.85rem; diff --git a/apps/web/src/views/ActivityFormView.vue b/apps/web/src/views/ActivityFormView.vue index 735f886..7f1bc09 100644 --- a/apps/web/src/views/ActivityFormView.vue +++ b/apps/web/src/views/ActivityFormView.vue @@ -15,7 +15,8 @@ import { import JalaliDateInput from '@/components/JalaliDateInput.vue'; import VoiceToTextButton from '@/components/VoiceToTextButton.vue'; import { appendSpokenText } from '@/lib/spoken-text'; -import { FormFileTooLargeError, prepareFormFieldFile } from '@/lib/form-file-compress'; +import { prepareActivityAttachment } from '@/lib/activity-attachment'; +import { startVoiceRecorder, type VoiceRecorder } from '@/lib/voice-note'; import UiIcon from '@/components/UiIcon.vue'; import { brand } from '@mento/shared'; import UserAvatar from '@/components/UserAvatar.vue'; @@ -105,7 +106,21 @@ const savedPhotos = ref([]); const photoBusy = ref(false); const photoError = ref(''); const createdActivityId = ref(''); -const photoInput = ref(null); +const fileInput = ref(null); +const VOICE_LIMIT = 5; +type PendingVoice = { localId: string; name: string; url: string; file: File }; +type SavedVoice = ActivityImageDto & { previewUrl: string }; +const pendingVoices = ref([]); +const savedVoices = ref([]); +const voiceBusy = ref(false); +const voiceError = ref(''); +const recording = ref(false); +const recordSeconds = ref(0); +type PendingFile = { localId: string; name: string; file: File }; +type SavedFile = ActivityImageDto; +const pendingFiles = ref([]); +const savedFiles = ref([]); +let voiceRecorder: VoiceRecorder | null = null; const dueDate = ref(''); const dueTime = ref('10:30'); const durationMinutes = ref(30); @@ -655,6 +670,23 @@ function toggleTeam(teamId: string, userIds: string[]) { toggleGroup('team', teamId, userIds); } +function clearVoices() { + voiceRecorder?.cancel(); + voiceRecorder = null; + recording.value = false; + recordSeconds.value = 0; + for (const voice of pendingVoices.value) URL.revokeObjectURL(voice.url); + pendingVoices.value = []; + for (const voice of savedVoices.value) { + if (voice.previewUrl.startsWith('blob:')) URL.revokeObjectURL(voice.previewUrl); + } + savedVoices.value = []; + pendingFiles.value = []; + savedFiles.value = []; + voiceError.value = ''; + voiceBusy.value = false; +} + function clearPhotos() { pendingPhotos.value = []; for (const photo of savedPhotos.value) { @@ -663,55 +695,82 @@ function clearPhotos() { savedPhotos.value = []; photoError.value = ''; photoBusy.value = false; + clearVoices(); } async function loadSavedPhotos(activity: ActivityDto) { clearPhotos(); const images = activity.images ?? []; - const loaded: SavedPhoto[] = []; + const loadedPhotos: SavedPhoto[] = []; + const loadedVoices: SavedVoice[] = []; + const loadedFiles: SavedFile[] = []; for (const image of images) { + const mime = image.mimeType.toLowerCase(); + if (!mime.startsWith('image/') && !mime.startsWith('audio/')) { + loadedFiles.push(image); + continue; + } + let previewUrl = ''; try { const { data } = await api.get(`/activities/${activity.id}/images/${image.id}`, { responseType: 'blob', }); - loaded.push({ ...image, previewUrl: URL.createObjectURL(data) }); + previewUrl = URL.createObjectURL(data); } catch { - loaded.push({ ...image, previewUrl: '' }); + previewUrl = ''; } + if (mime.startsWith('audio/')) loadedVoices.push({ ...image, previewUrl }); + else loadedPhotos.push({ ...image, previewUrl }); } - savedPhotos.value = loaded; + savedPhotos.value = loadedPhotos; + savedVoices.value = loadedVoices; + savedFiles.value = loadedFiles; } -async function onPhotosPicked(event: Event) { +const FILE_LIMIT = 8; + +async function onFilesPicked(event: Event) { const input = event.target as HTMLInputElement; const files = Array.from(input.files ?? []); input.value = ''; if (!files.length) return; photoError.value = ''; + voiceError.value = ''; for (const file of files) { - if (pendingPhotos.value.length + savedPhotos.value.length >= PHOTO_LIMIT) { - photoError.value = `حداکثر ${PHOTO_LIMIT} عکس برای هر فعالیت.`; - break; - } - if (!file.type.startsWith('image/')) { - photoError.value = 'فقط عکس می‌توانید اضافه کنید.'; - continue; - } photoBusy.value = true; try { - const prepared = await prepareFormFieldFile(file); - pendingPhotos.value = [ - ...pendingPhotos.value, - { - localId: crypto.randomUUID(), - name: prepared.name, - dataUrl: prepared.dataUrl, - file: prepared.file, - }, - ]; - } catch (e) { - photoError.value = - e instanceof FormFileTooLargeError ? PHOTO_TOO_LARGE : 'فشرده‌سازی عکس ناموفق بود.'; + const prepared = await prepareActivityAttachment(file); + if (prepared.kind === 'image') { + if (pendingPhotos.value.length + savedPhotos.value.length >= PHOTO_LIMIT) { + photoError.value = `حداکثر ${toFaDigits(PHOTO_LIMIT)} عکس برای هر فعالیت.`; + continue; + } + pendingPhotos.value = [ + ...pendingPhotos.value, + { localId: crypto.randomUUID(), name: prepared.name, dataUrl: prepared.previewUrl, file: prepared.file }, + ]; + } else if (prepared.kind === 'audio') { + if (voiceCount() >= VOICE_LIMIT) { + URL.revokeObjectURL(prepared.previewUrl); + voiceError.value = `حداکثر ${toFaDigits(VOICE_LIMIT)} صوت برای هر فعالیت.`; + continue; + } + pendingVoices.value = [ + ...pendingVoices.value, + { localId: crypto.randomUUID(), name: prepared.name, url: prepared.previewUrl, file: prepared.file }, + ]; + } else { + if (pendingFiles.value.length + savedFiles.value.length >= FILE_LIMIT) { + photoError.value = `حداکثر ${toFaDigits(FILE_LIMIT)} فایل برای هر فعالیت.`; + continue; + } + pendingFiles.value = [ + ...pendingFiles.value, + { localId: crypto.randomUUID(), name: prepared.name, file: prepared.file }, + ]; + } + } catch (error) { + photoError.value = error instanceof Error ? error.message : 'افزودن فایل ناموفق بود.'; } finally { photoBusy.value = false; } @@ -731,6 +790,116 @@ async function removeSavedPhoto(photo: SavedPhoto) { savedPhotos.value = savedPhotos.value.filter((item) => item.id !== photo.id); } +function formatVoiceClock(total: number) { + const minutes = Math.floor(total / 60); + const seconds = total % 60; + return toFaDigits(`${minutes}:${String(seconds).padStart(2, '0')}`); +} + +function formatVoiceBytes(bytes: number) { + if (bytes >= 1024 * 1024) return `${toFaDigits((bytes / (1024 * 1024)).toFixed(1))} مگابایت`; + if (bytes >= 1024) return `${toFaDigits(Math.round(bytes / 1024))} کیلوبایت`; + return `${toFaDigits(bytes)} بایت`; +} + +function voiceCount() { + return pendingVoices.value.length + savedVoices.value.length; +} + +function addPendingVoice(file: File) { + if (voiceCount() >= VOICE_LIMIT) { + voiceError.value = `حداکثر ${toFaDigits(VOICE_LIMIT)} صوت برای هر فعالیت.`; + return; + } + pendingVoices.value = [ + ...pendingVoices.value, + { localId: crypto.randomUUID(), name: file.name, url: URL.createObjectURL(file), file }, + ]; +} + +async function toggleRecording() { + voiceError.value = ''; + if (voiceRecorder) { + const handle = voiceRecorder; + voiceRecorder = null; + recording.value = false; + try { + addPendingVoice(await handle.stop()); + } catch (error) { + voiceError.value = error instanceof Error ? error.message : 'ضبط صوت ناموفق بود.'; + } + return; + } + if (voiceCount() >= VOICE_LIMIT) { + voiceError.value = `حداکثر ${toFaDigits(VOICE_LIMIT)} صوت برای هر فعالیت.`; + return; + } + try { + recordSeconds.value = 0; + voiceRecorder = await startVoiceRecorder({ + onTick: (seconds) => { + recordSeconds.value = seconds; + }, + onAutoStop: (file) => { + voiceRecorder = null; + recording.value = false; + addPendingVoice(file); + }, + }); + recording.value = true; + } catch (error) { + voiceError.value = error instanceof Error ? error.message : 'ضبط صوت ناموفق بود.'; + } +} + +function removePendingVoice(localId: string) { + const voice = pendingVoices.value.find((item) => item.localId === localId); + if (voice) URL.revokeObjectURL(voice.url); + pendingVoices.value = pendingVoices.value.filter((item) => item.localId !== localId); +} + +async function removeSavedVoice(voice: SavedVoice) { + const activityId = id.value || createdActivityId.value; + if (!activityId) return; + await api.delete(`/activities/${activityId}/images/${voice.id}`); + if (voice.previewUrl.startsWith('blob:')) URL.revokeObjectURL(voice.previewUrl); + savedVoices.value = savedVoices.value.filter((item) => item.id !== voice.id); +} + +function removePendingFile(localId: string) { + pendingFiles.value = pendingFiles.value.filter((item) => item.localId !== localId); +} + +async function removeSavedFile(file: SavedFile) { + const activityId = id.value || createdActivityId.value; + if (!activityId) return; + await api.delete(`/activities/${activityId}/images/${file.id}`); + savedFiles.value = savedFiles.value.filter((item) => item.id !== file.id); +} + +async function uploadPendingFiles(activityId: string) { + const queue = [...pendingFiles.value]; + for (const file of queue) { + const form = new FormData(); + form.append('file', file.file, file.name); + const { data } = await api.post(`/activities/${activityId}/images`, form); + pendingFiles.value = pendingFiles.value.filter((item) => item.localId !== file.localId); + savedFiles.value = [...savedFiles.value, data]; + } +} + +async function uploadPendingVoices(activityId: string) { + const queue = [...pendingVoices.value]; + for (const voice of queue) { + const form = new FormData(); + form.append('file', voice.file, voice.name); + const { data } = await api.post(`/activities/${activityId}/images`, form); + URL.revokeObjectURL(voice.url); + pendingVoices.value = pendingVoices.value.filter((item) => item.localId !== voice.localId); + savedVoices.value = [...savedVoices.value, { ...data, previewUrl: URL.createObjectURL(voice.file) }]; + } +} + async function uploadPendingPhotos(activityId: string) { const queue = [...pendingPhotos.value]; for (const photo of queue) { @@ -743,9 +912,14 @@ async function uploadPendingPhotos(activityId: string) { } onUnmounted(() => { + voiceRecorder?.cancel(); for (const photo of savedPhotos.value) { if (photo.previewUrl.startsWith('blob:')) URL.revokeObjectURL(photo.previewUrl); } + for (const voice of pendingVoices.value) URL.revokeObjectURL(voice.url); + for (const voice of savedVoices.value) { + if (voice.previewUrl.startsWith('blob:')) URL.revokeObjectURL(voice.previewUrl); + } }); function validate() { @@ -805,6 +979,8 @@ async function submit(next: 'detail' | 'new' = 'detail') { }); } await uploadPendingPhotos(savedId); + await uploadPendingVoices(savedId); + await uploadPendingFiles(savedId); if (props.embedded) { emit('saved'); return; @@ -1265,20 +1441,39 @@ watch([id, isNew], () => void load(), { immediate: true }); :rows="4" placeholder="جزئیات تماس، نتیجه جلسه یا یادداشت مرتبط را بنویسید…" /> -
- + + {{ formatVoiceClock(recordSeconds) }} - عکس خودکار کم‌حجم می‌شود تا زیر ۲۰۰ کیلوبایت بماند. + + عکس زیر ۲۰۰ کیلوبایت و صوت مثل پیام‌رسان کم‌حجم می‌شود. فایل‌های دیگر تا ۱۰ مگابایت؛ همه در سقف حجم حساب می‌شوند. +

{{ photoError }}

@@ -1291,6 +1486,33 @@ watch([id, isNew], () => void load(), { immediate: true });
+

{{ voiceError }}

+
+
+
+
+
+
+
    +
  • + + {{ file.fileName }} + {{ formatVoiceBytes(file.sizeBytes) }} + +
  • +
  • + + {{ file.name }} + {{ formatVoiceBytes(file.file.size) }} + +
  • +
void load(), { immediate: true }); resize: vertical; } +.attach-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem; +} + +.icon-tool { + width: 36px; + height: 36px; + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #fff; + color: #0f766e; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.icon-tool.recording { + border-color: #fecaca; + background: #fef2f2; + color: #b91c1c; +} + +.icon-tool:disabled { + opacity: 0.6; + cursor: progress; +} + +.rec-time { + color: #b91c1c; + font-size: 0.82rem; + font-weight: 700; +} + +.file-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.35rem; +} + +.file-list li { + display: flex; + align-items: center; + gap: 0.4rem; + min-width: 0; +} + +.file-list span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-list small { + color: var(--muted); + white-space: nowrap; +} + +.file-list button { + margin-inline-start: auto; + width: 1.4rem; + height: 1.4rem; + border: 0; + border-radius: 999px; + background: #e2e8f0; + cursor: pointer; +} + .photo-row { display: flex; flex-wrap: wrap; @@ -2155,6 +2450,44 @@ watch([id, isNew], () => void load(), { immediate: true }); cursor: progress; } +.photo-add.recording { + border-color: #fecaca; + background: #fef2f2; + color: #b91c1c; +} + +.voice-list { + display: grid; + gap: 0.45rem; +} + +.voice-item { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.voice-item audio { + flex: 1; + min-width: 0; + height: 2.25rem; +} + +.voice-item span { + color: var(--muted); + font-size: 0.78rem; + white-space: nowrap; +} + +.voice-item button { + width: 1.4rem; + height: 1.4rem; + border: 0; + border-radius: 999px; + background: #e2e8f0; + cursor: pointer; +} + .photo-file { display: none; } diff --git a/apps/web/src/views/adm/AdmBillingPanel.vue b/apps/web/src/views/adm/AdmBillingPanel.vue index d9ad3f2..db52c8d 100644 --- a/apps/web/src/views/adm/AdmBillingPanel.vue +++ b/apps/web/src/views/adm/AdmBillingPanel.vue @@ -144,7 +144,7 @@ onMounted(() => {

- عکس فعالیت، پیوست یادداشت، لوگو، کاور فرم، تصویر پروفایل، پیوست مالی و فایل گفتگو حساب می‌شود. + عکس و صوت فعالیت، پیوست یادداشت، لوگو، کاور فرم، تصویر پروفایل، پیوست مالی و فایل گفتگو حساب می‌شود. متن مشتری، فعالیت و فاکتور حساب نمی‌شود. فایل‌های قدیمی گفتگو که قبلاً روی سرور خارجی رفته‌اند داخل این عدد نیستند.