feat(activities): attach voice and files on activity notes

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-02 16:34:03 +03:30
co-authored by Cursor
parent 43437b0280
commit 06977d27ed
9 changed files with 1009 additions and 117 deletions
@@ -352,7 +352,7 @@ export class ActivitiesController {
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 2 * 1024 * 1024 },
limits: { fileSize: 10 * 1024 * 1024 },
}),
)
addImage(
+89 -11
View File
@@ -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<string, string> = {
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,
+4
View File
@@ -68,6 +68,7 @@ defineProps<{
| 'pulse'
| 'sliders'
| 'camera'
| 'clip'
| 'download'
| 'upload'
| 'arrow-right'
@@ -423,6 +424,9 @@ defineProps<{
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3Z" />
<circle cx="12" cy="13" r="3" />
</template>
<template v-else-if="name === 'clip'">
<path d="M21.4 11.6 12.7 20.3a5 5 0 0 1-7.1-7.1l8.5-8.4a3.5 3.5 0 0 1 5 5L10.6 18.2a2 2 0 1 1-2.8-2.8l7.1-7.1" />
</template>
</svg>
</template>
@@ -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<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
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<HTMLTextAreaElement | null>(null);
const pendingPhotos = ref<PendingPhoto[]>([]);
const pendingVoices = ref<PendingVoice[]>([]);
const pendingFiles = ref<PendingFile[]>([]);
const uploadedCount = ref(0);
const photoBusy = ref(false);
const uploading = ref(false);
const photoError = ref('');
const photoInput = ref<HTMLInputElement | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const voiceNoteOn = ref(false);
const voiceNoteSeconds = ref(0);
let voiceNote: VoiceRecorder | null = null;
const orgName = ref('سازمان');
const users = ref<OrgUserDto[]>([]);
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="متن خود را بنویسید..."
/>
<div class="photo-row">
<button
class="photo-add"
type="button"
:disabled="photoBusy || uploading || recording || photoSlotsUsed >= PHOTO_LIMIT"
@click="photoInput?.click()"
>
<UiIcon name="camera" :size="16" />
{{ photoBusy ? 'در حال کم‌حجم کردن عکس…' : 'افزودن عکس' }}
</button>
<input
ref="photoInput"
class="photo-file"
type="file"
accept="image/*"
multiple
@change="onPhotosPicked"
/>
<small>عکس خودکار کم‌حجم می‌شود تا زیر ۲۰۰ کیلوبایت بماند.</small>
</div>
<p v-if="photoError" class="photo-err">{{ photoError }}</p>
<div v-if="pendingPhotos.length" class="photo-grid">
<figure v-for="photo in pendingPhotos" :key="photo.localId">
@@ -337,13 +421,31 @@ onBeforeUnmount(() => {
</button>
</figure>
</div>
<div v-if="pendingVoices.length" class="voice-list">
<div v-for="voice in pendingVoices" :key="voice.localId" class="voice-item">
<audio :src="voice.url" controls preload="metadata" />
<button type="button" aria-label="حذف صوت" :disabled="uploading" @click="removePendingVoice(voice.localId)">
×
</button>
</div>
</div>
<ul v-if="pendingFiles.length" class="file-list">
<li v-for="file in pendingFiles" :key="file.localId">
<UiIcon name="document" :size="15" />
<span>{{ file.name }}</span>
<button type="button" aria-label="حذف فایل" :disabled="uploading" @click="removePendingFile(file.localId)">
×
</button>
</li>
</ul>
<div class="tools">
<div class="tool-cluster">
<div class="tool-wrap">
<button
class="tool"
type="button"
aria-label="منشن"
:disabled="recording"
:disabled="recording || voiceNoteOn"
@click="mentionOpen = !mentionOpen"
>
@
@@ -357,12 +459,36 @@ onBeforeUnmount(() => {
</li>
</ul>
</div>
<button
class="tool"
type="button"
aria-label="پیوست"
title="پیوست"
:disabled="photoBusy || uploading || recording || voiceNoteOn"
@click="fileInput?.click()"
>
<UiIcon name="clip" :size="16" />
</button>
<input ref="fileInput" class="photo-file" type="file" multiple @change="onFilesPicked" />
<button
class="tool"
type="button"
:class="{ 'voice-on': voiceNoteOn }"
:aria-label="voiceNoteOn ? 'توقف ضبط پیام صوتی' : 'ضبط پیام صوتی'"
:title="voiceNoteOn ? 'توقف ضبط پیام صوتی' : 'ضبط پیام صوتی'"
:disabled="photoBusy || uploading || recording"
@click="toggleVoiceNote"
>
<UiIcon name="mic" :size="16" />
</button>
<span v-if="voiceNoteOn" class="rec-time">{{ voiceClock(voiceNoteSeconds) }}</span>
</div>
<div class="tool-wrap">
<p v-if="voiceTip && !recording" class="tip">تایپ صوتی را امتحان کنید! 🎙️✨</p>
<VoiceToTextButton
variant="toolbar"
hide-error
:disabled="saving"
:disabled="saving || voiceNoteOn"
@transcript="onVoiceTranscript"
@recording="recording = $event"
@busy="onVoiceBusy"
@@ -665,6 +791,73 @@ onBeforeUnmount(() => {
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;
}
+69
View File
@@ -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<string, string> = {
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<PreparedAttachment> {
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: '' };
}
+166
View File
@@ -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<File>;
cancel: () => void;
};
export async function startVoiceRecorder(handlers: {
onTick: (seconds: number) => void;
onAutoStop: (file: File) => void;
}): Promise<VoiceRecorder> {
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<File>((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<File> {
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<Blob>((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<void>((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;
}
+55 -6
View File
@@ -57,7 +57,7 @@ const loading = ref(true);
const saving = ref(false);
const error = ref('');
const activity = ref<ActivityDto | null>(null);
const imageUrls = ref<Array<{ id: string; name: string; url: string }>>([]);
const imageUrls = ref<Array<{ id: string; name: string; url: string; kind: 'image' | 'audio' | 'file' }>>([]);
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<Blob>(`/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(() => {
<div v-show="noteOpen">
<p v-if="activity.description?.trim()">{{ activity.description.trim() }}</p>
<p v-else-if="!imageUrls.length" class="empty">یادداشتی ثبت نشده.</p>
<div v-if="imageUrls.length" class="note-photos">
<a v-for="image in imageUrls" :key="image.id" :href="image.url" target="_blank" rel="noopener">
<div v-if="imageUrls.some((image) => image.kind === 'image')" class="note-photos">
<a
v-for="image in imageUrls.filter((item) => item.kind === 'image')"
:key="image.id"
:href="image.url"
target="_blank"
rel="noopener"
>
<img :src="image.url" :alt="image.name" />
</a>
</div>
<div v-if="imageUrls.some((image) => image.kind === 'audio')" class="note-voices">
<audio
v-for="image in imageUrls.filter((item) => item.kind === 'audio')"
:key="image.id"
:src="image.url"
controls
preload="metadata"
/>
</div>
<ul v-if="imageUrls.some((image) => image.kind === 'file')" class="note-files">
<li v-for="image in imageUrls.filter((item) => item.kind === 'file')" :key="image.id">
<a :href="image.url" :download="image.name">{{ image.name }}</a>
</li>
</ul>
</div>
</article>
@@ -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;
+369 -36
View File
@@ -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<SavedPhoto[]>([]);
const photoBusy = ref(false);
const photoError = ref('');
const createdActivityId = ref('');
const photoInput = ref<HTMLInputElement | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const VOICE_LIMIT = 5;
type PendingVoice = { localId: string; name: string; url: string; file: File };
type SavedVoice = ActivityImageDto & { previewUrl: string };
const pendingVoices = ref<PendingVoice[]>([]);
const savedVoices = ref<SavedVoice[]>([]);
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<PendingFile[]>([]);
const savedFiles = ref<SavedFile[]>([]);
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<Blob>(`/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<ActivityImageDto>(`/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<ActivityImageDto>(`/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="جزئیات تماس، نتیجه جلسه یا یادداشت مرتبط را بنویسید…"
/>
<div class="photo-row">
<button type="button" class="photo-add" :disabled="photoBusy" @click="photoInput?.click()">
<UiIcon name="camera" :size="16" />
{{ photoBusy ? 'در حال کم‌حجم کردن عکس…' : 'افزودن عکس' }}
<div class="attach-row">
<button
type="button"
class="icon-tool"
aria-label="پیوست"
title="پیوست"
:disabled="photoBusy || recording"
@click="fileInput?.click()"
>
<UiIcon name="clip" :size="16" />
</button>
<button
type="button"
class="icon-tool"
:class="{ recording }"
:disabled="voiceBusy"
:aria-label="recording ? `توقف ضبط پیام صوتی ${formatVoiceClock(recordSeconds)}` : 'ضبط پیام صوتی'"
:title="recording ? `توقف ضبط ${formatVoiceClock(recordSeconds)}` : 'ضبط پیام صوتی'"
@click="toggleRecording"
>
<UiIcon name="mic" :size="16" />
</button>
<span v-if="recording" class="rec-time">{{ formatVoiceClock(recordSeconds) }}</span>
<input
ref="photoInput"
ref="fileInput"
class="photo-file"
type="file"
accept="image/*"
multiple
@change="onPhotosPicked"
@change="onFilesPicked"
/>
<small class="hint">عکس خودکار کم‌حجم می‌شود تا زیر ۲۰۰ کیلوبایت بماند.</small>
<small class="hint">
عکس زیر ۲۰۰ کیلوبایت و صوت مثل پیام‌رسان کم‌حجم می‌شود. فایل‌های دیگر تا ۱۰ مگابایت؛ همه در سقف حجم حساب می‌شوند.
</small>
</div>
<p v-if="photoError" class="error">{{ photoError }}</p>
<div v-if="pendingPhotos.length || savedPhotos.length" class="photo-grid">
@@ -1291,6 +1486,33 @@ watch([id, isNew], () => void load(), { immediate: true });
<button type="button" aria-label="حذف عکس" @click="removePendingPhoto(photo.localId)">×</button>
</figure>
</div>
<p v-if="voiceError" class="error">{{ voiceError }}</p>
<div v-if="pendingVoices.length || savedVoices.length" class="voice-list">
<div v-for="voice in savedVoices" :key="voice.id" class="voice-item">
<audio v-if="voice.previewUrl" :src="voice.previewUrl" controls preload="metadata" />
<span>{{ formatVoiceBytes(voice.sizeBytes) }}</span>
<button type="button" aria-label="حذف صوت" @click="removeSavedVoice(voice)">×</button>
</div>
<div v-for="voice in pendingVoices" :key="voice.localId" class="voice-item">
<audio :src="voice.url" controls preload="metadata" />
<span>{{ formatVoiceBytes(voice.file.size) }}</span>
<button type="button" aria-label="حذف صوت" @click="removePendingVoice(voice.localId)">×</button>
</div>
</div>
<ul v-if="pendingFiles.length || savedFiles.length" class="file-list">
<li v-for="file in savedFiles" :key="file.id">
<UiIcon name="document" :size="15" />
<span>{{ file.fileName }}</span>
<small>{{ formatVoiceBytes(file.sizeBytes) }}</small>
<button type="button" aria-label="حذف فایل" @click="removeSavedFile(file)">×</button>
</li>
<li v-for="file in pendingFiles" :key="file.localId">
<UiIcon name="document" :size="15" />
<span>{{ file.name }}</span>
<small>{{ formatVoiceBytes(file.file.size) }}</small>
<button type="button" aria-label="حذف فایل" @click="removePendingFile(file.localId)">×</button>
</li>
</ul>
<label for="activity-tags" class="tags-label">هشتگ / برچسب</label>
<UiInput
id="activity-tags"
@@ -2129,6 +2351,79 @@ watch([id, isNew], () => 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;
}
+1 -1
View File
@@ -144,7 +144,7 @@ onMounted(() => {
</li>
</ul>
<p class="muted">
عکس فعالیت، پیوست یادداشت، لوگو، کاور فرم، تصویر پروفایل، پیوست مالی و فایل گفتگو حساب می‌شود.
عکس و صوت فعالیت، پیوست یادداشت، لوگو، کاور فرم، تصویر پروفایل، پیوست مالی و فایل گفتگو حساب می‌شود.
متن مشتری، فعالیت و فاکتور حساب نمی‌شود. فایل‌های قدیمی گفتگو که قبلاً روی سرور خارجی رفته‌اند داخل این عدد نیستند.
</p>
<label class="quota">