feat(activities): add photos when registering a follow-up comment

A later comment can attach photos with the same compressor, and they stay on the activity note.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-27 21:52:37 +03:30
co-authored by Cursor
parent 1d3588a69d
commit f15c5b9141
2 changed files with 213 additions and 11 deletions
@@ -6,6 +6,7 @@ import {
formatClockFa,
formatJalaliDay,
} from '@/lib/jalali';
import { FormFileTooLargeError, prepareFormFieldFile } from '@/lib/form-file-compress';
import { appendSpokenText } from '@/lib/spoken-text';
import UiIcon from '@/components/UiIcon.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
@@ -14,11 +15,11 @@ import type { ActivityDto, ActivityType, OrganizationDto, OrgUserDto } from '@me
const props = defineProps<{
activity: ActivityDto;
saving?: boolean;
save: (payload: { text: string; photoOnly: boolean }) => Promise<void>;
}>();
const emit = defineEmits<{
close: [];
submit: [text: string];
}>();
const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
@@ -28,8 +29,18 @@ const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
note: 'chat',
};
const PHOTO_TOO_LARGE = 'حجم عکس باید زیر 200 کیلوبایت باشد';
const PHOTO_LIMIT = 8;
type PendingPhoto = { localId: string; name: string; dataUrl: string; file: File };
const comment = ref('');
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const pendingPhotos = ref<PendingPhoto[]>([]);
const uploadedCount = ref(0);
const photoBusy = ref(false);
const uploading = ref(false);
const photoError = ref('');
const photoInput = ref<HTMLInputElement | null>(null);
const orgName = ref('سازمان');
const users = ref<OrgUserDto[]>([]);
const mentionOpen = ref(false);
@@ -61,8 +72,17 @@ const nowLabel = computed(
() => `${formatJalaliDay(now.value)} ساعت ${formatClockFa(now.value)}`,
);
const photoSlotsUsed = computed(
() => (props.activity.images?.length ?? 0) + uploadedCount.value + pendingPhotos.value.length,
);
const canSubmit = computed(
() => comment.value.trim().length > 0 && !props.saving && !voiceBusy.value,
() =>
(comment.value.trim().length > 0 || pendingPhotos.value.length > 0 || uploadedCount.value > 0) &&
!props.saving &&
!voiceBusy.value &&
!photoBusy.value &&
!uploading.value,
);
function onVoiceTranscript(text: string) {
@@ -77,10 +97,83 @@ function onVoiceBusy(active: boolean) {
}
}
function submit() {
function apiMessage(error: unknown, fallback: string) {
const raw = (error as { response?: { data?: { message?: string | string[] } } }).response?.data
?.message;
if (Array.isArray(raw)) return raw.join('، ');
if (typeof raw === 'string' && raw.trim()) return raw;
return fallback;
}
async function onPhotosPicked(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,
},
];
} catch (error) {
photoError.value =
error instanceof FormFileTooLargeError ? PHOTO_TOO_LARGE : 'فشرده‌سازی عکس ناموفق بود.';
} finally {
photoBusy.value = false;
}
}
}
function removePendingPhoto(localId: string) {
pendingPhotos.value = pendingPhotos.value.filter((photo) => photo.localId !== localId);
if (photoError.value === PHOTO_TOO_LARGE) photoError.value = '';
}
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;
}
}
async function submit() {
const text = comment.value.trim();
if (!text || props.saving || voiceBusy.value) return;
emit('submit', text);
if (!canSubmit.value) return;
photoError.value = '';
uploading.value = true;
const hadPhoto = pendingPhotos.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, 'ثبت نظر ناموفق بود.');
} finally {
uploading.value = false;
}
}
function close() {
@@ -215,6 +308,35 @@ 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">
<img :src="photo.dataUrl" :alt="photo.name" />
<button type="button" aria-label="حذف عکس" :disabled="uploading" @click="removePendingPhoto(photo.localId)">
×
</button>
</figure>
</div>
<div class="tools">
<div class="tool-wrap">
<button
@@ -259,9 +381,9 @@ onBeforeUnmount(() => {
<div class="actions">
<button class="act primary" type="button" :disabled="!canSubmit" @click="submit">
{{ recording ? 'در حال ضبط…' : 'ثبت نظر' }}
{{ recording ? 'در حال ضبط…' : uploading || saving ? 'در حال ثبت…' : 'ثبت نظر' }}
</button>
<button class="act ghost" type="button" :disabled="saving || voiceBusy" @click="close">انصراف</button>
<button class="act ghost" type="button" :disabled="saving || voiceBusy || uploading" @click="close">انصراف</button>
</div>
</div>
</section>
@@ -457,6 +579,85 @@ onBeforeUnmount(() => {
color: #94a3b8;
}
.photo-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
padding: 0 0.7rem;
}
.photo-row small {
color: #64748b;
font-size: 0.75rem;
}
.photo-add {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px dashed #99f6e4;
background: #f0fdfa;
color: #0f766e;
border-radius: var(--radius);
padding: 0.35rem 0.7rem;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.photo-add:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.photo-file {
display: none;
}
.photo-err {
margin: 0;
padding: 0 0.7rem;
color: #b91c1c;
font-size: 0.8rem;
}
.photo-grid {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
padding: 0 0.7rem;
}
.photo-grid figure {
position: relative;
margin: 0;
width: 4.5rem;
height: 4.5rem;
}
.photo-grid img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.6rem;
border: 1px solid #e2e8f0;
}
.photo-grid figure button {
position: absolute;
top: 0.15rem;
inset-inline-end: 0.15rem;
width: 1.2rem;
height: 1.2rem;
border: 0;
border-radius: 999px;
background: rgba(15, 23, 42, 0.72);
color: #fff;
cursor: pointer;
line-height: 1;
}
.tools {
display: flex;
justify-content: space-between;
+5 -4
View File
@@ -242,8 +242,8 @@ async function postponeTo(iso: string) {
}
}
async function submitComment(text: string) {
const body = text.trim();
async function submitComment(payload: { text: string; photoOnly: boolean }) {
const body = payload.text.trim() || (payload.photoOnly ? 'عکس پیوست شد.' : '');
if (!id.value || !activity.value || !body) return;
saving.value = true;
error.value = '';
@@ -257,8 +257,9 @@ async function submitComment(text: string) {
await api.patch(`/activities/${id.value}`, { description: next });
commentOpen.value = false;
await load();
} catch {
} catch (e) {
error.value = 'ثبت نظر ناموفق بود.';
throw e;
} finally {
saving.value = false;
}
@@ -488,8 +489,8 @@ onMounted(() => {
v-if="commentOpen"
:activity="activity"
:saving="saving"
:save="submitComment"
@close="commentOpen = false"
@submit="submitComment"
/>
<ActivityPostponeSheet
v-if="postponeOpen"