feat(activities): attach voice and files on activity notes
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ onMounted(() => {
|
||||
</li>
|
||||
</ul>
|
||||
<p class="muted">
|
||||
عکس فعالیت، پیوست یادداشت، لوگو، کاور فرم، تصویر پروفایل، پیوست مالی و فایل گفتگو حساب میشود.
|
||||
عکس و صوت فعالیت، پیوست یادداشت، لوگو، کاور فرم، تصویر پروفایل، پیوست مالی و فایل گفتگو حساب میشود.
|
||||
متن مشتری، فعالیت و فاکتور حساب نمیشود. فایلهای قدیمی گفتگو که قبلاً روی سرور خارجی رفتهاند داخل این عدد نیستند.
|
||||
</p>
|
||||
<label class="quota">
|
||||
|
||||
Reference in New Issue
Block a user