From f15c5b9141f743d83d888e63d993bb13bd0bb34d Mon Sep 17 00:00:00 2001 From: Mahdi Date: Tue, 27 Oct 2026 21:52:37 +0330 Subject: [PATCH] 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 --- .../activities/ActivityCommentSheet.vue | 215 +++++++++++++++++- apps/web/src/views/ActivityDetailView.vue | 9 +- 2 files changed, 213 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/activities/ActivityCommentSheet.vue b/apps/web/src/components/activities/ActivityCommentSheet.vue index 05cb6fa..4848662 100644 --- a/apps/web/src/components/activities/ActivityCommentSheet.vue +++ b/apps/web/src/components/activities/ActivityCommentSheet.vue @@ -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; }>(); const emit = defineEmits<{ close: []; - submit: [text: string]; }>(); const typeIcon: Record = { @@ -28,8 +29,18 @@ const typeIcon: Record = { 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(null); +const pendingPhotos = ref([]); +const uploadedCount = ref(0); +const photoBusy = ref(false); +const uploading = ref(false); +const photoError = ref(''); +const photoInput = ref(null); const orgName = ref('سازمان'); const users = ref([]); 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="متن خود را بنویسید..." /> +
+ + + عکس خودکار کم‌حجم می‌شود تا زیر ۲۰۰ کیلوبایت بماند. +
+

{{ photoError }}

+
+
+ + +
+
- +
@@ -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; diff --git a/apps/web/src/views/ActivityDetailView.vue b/apps/web/src/views/ActivityDetailView.vue index 9c7fd94..05bbaa4 100644 --- a/apps/web/src/views/ActivityDetailView.vue +++ b/apps/web/src/views/ActivityDetailView.vue @@ -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" />