feat(activities): attach voice and files on activity notes
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user