feat(activities): attach compressed photos to activity notes

Photos on an activity are shrunk with the form-builder compressor and stored only when they fit under 200KB.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-27 21:30:45 +03:30
co-authored by Cursor
parent 9812e4e5ba
commit 1d3588a69d
10 changed files with 526 additions and 11 deletions
+213 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
@@ -15,6 +15,7 @@ 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 UiIcon from '@/components/UiIcon.vue';
import { brand } from '@mento/shared';
import UserAvatar from '@/components/UserAvatar.vue';
@@ -33,6 +34,7 @@ import { typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import type {
ActivityBoardDto,
ActivityDto,
ActivityImageDto,
ActivityFieldValue,
ActivityFieldValues,
ActivityPriority,
@@ -92,6 +94,16 @@ const keepTypeId = ref('');
const priority = ref<ActivityPriority>('medium');
const description = ref('');
const tagsRaw = ref('');
const PHOTO_TOO_LARGE = 'حجم عکس باید زیر 200 کیلوبایت باشد';
const PHOTO_LIMIT = 8;
type PendingPhoto = { localId: string; name: string; dataUrl: string; file: File };
type SavedPhoto = ActivityImageDto & { previewUrl: string };
const pendingPhotos = ref<PendingPhoto[]>([]);
const savedPhotos = ref<SavedPhoto[]>([]);
const photoBusy = ref(false);
const photoError = ref('');
const createdActivityId = ref('');
const photoInput = ref<HTMLInputElement | null>(null);
const dueDate = ref('');
const dueTime = ref('10:30');
const durationMinutes = ref(30);
@@ -370,6 +382,8 @@ function resetForm() {
priority.value = 'medium';
description.value = '';
tagsRaw.value = '';
clearPhotos();
createdActivityId.value = '';
defaultDue();
durationMinutes.value = 30;
personId.value = '';
@@ -461,6 +475,7 @@ async function load() {
: [];
refreshPickedRoles();
isDone.value = data.status === 'done';
await loadSavedPhotos(data);
} else {
resetForm();
const incomingPerson =
@@ -581,6 +596,99 @@ function toggleRole(roleId: string, userIds: string[]) {
refreshPickedRoles();
}
function clearPhotos() {
pendingPhotos.value = [];
for (const photo of savedPhotos.value) {
if (photo.previewUrl.startsWith('blob:')) URL.revokeObjectURL(photo.previewUrl);
}
savedPhotos.value = [];
photoError.value = '';
photoBusy.value = false;
}
async function loadSavedPhotos(activity: ActivityDto) {
clearPhotos();
const images = activity.images ?? [];
const loaded: SavedPhoto[] = [];
for (const image of images) {
try {
const { data } = await api.get<Blob>(`/activities/${activity.id}/images/${image.id}`, {
responseType: 'blob',
});
loaded.push({ ...image, previewUrl: URL.createObjectURL(data) });
} catch {
loaded.push({ ...image, previewUrl: '' });
}
}
savedPhotos.value = loaded;
}
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 (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 : 'فشرده‌سازی عکس ناموفق بود.';
} 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 removeSavedPhoto(photo: SavedPhoto) {
const activityId = id.value || createdActivityId.value;
if (!activityId) return;
await api.delete(`/activities/${activityId}/images/${photo.id}`);
if (photo.previewUrl.startsWith('blob:')) URL.revokeObjectURL(photo.previewUrl);
savedPhotos.value = savedPhotos.value.filter((item) => item.id !== photo.id);
}
async function uploadPendingPhotos(activityId: string) {
const queue = [...pendingPhotos.value];
for (const photo of queue) {
const form = new FormData();
form.append('file', photo.file, photo.name);
const { data } = await api.post<ActivityImageDto>(`/activities/${activityId}/images`, form);
pendingPhotos.value = pendingPhotos.value.filter((item) => item.localId !== photo.localId);
savedPhotos.value = [...savedPhotos.value, { ...data, previewUrl: photo.dataUrl }];
}
}
onUnmounted(() => {
for (const photo of savedPhotos.value) {
if (photo.previewUrl.startsWith('blob:')) URL.revokeObjectURL(photo.previewUrl);
}
});
function validate() {
if (!type.value) return 'نوع فعالیت را انتخاب کنید.';
if (title.value.trim().length < 2) return 'عنوان فعالیت را بنویسید.';
@@ -590,7 +698,14 @@ function validate() {
if (missing) return `فیلد «${missing.label}» را تکمیل کنید.`;
if (!dueDate.value || !dueTime.value) return 'تاریخ و ساعت را مشخص کنید.';
if (!assigneeIds.value.length) return 'حداقل یک مسئول فعالیت را انتخاب کنید.';
if (needsNote.value && !description.value.trim()) return 'توضیح این فعالیت را بنویسید.';
if (
needsNote.value &&
!description.value.trim() &&
!pendingPhotos.value.length &&
!savedPhotos.value.length
) {
return 'توضیح این فعالیت را بنویسید.';
}
if (reminderAt.value && reminderChanged.value) {
const when = parseLocalInput(reminderAt.value);
if (when && when.getTime() < Date.now() - 60_000) {
@@ -613,22 +728,24 @@ async function submit(next: 'detail' | 'new' = 'detail') {
saving.value = true;
error.value = '';
try {
let savedId = id.value;
if (isNew.value) {
let savedId = id.value || createdActivityId.value;
if (!savedId) {
const { data } = await api.post<ActivityDto>('/activities', {
...payload(),
...(props.initialStageId ? { stageId: props.initialStageId } : {}),
});
savedId = data.id;
createdActivityId.value = data.id;
if (isDone.value) {
await api.patch(`/activities/${savedId}/complete`);
}
} else {
await api.patch(`/activities/${id.value}`, {
await api.patch(`/activities/${savedId}`, {
...payload(),
status: isDone.value ? 'done' : 'open',
});
}
await uploadPendingPhotos(savedId);
if (props.embedded) {
emit('saved');
return;
@@ -1062,6 +1179,32 @@ 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 ? 'در حال کم‌حجم کردن عکس…' : 'افزودن عکس' }}
</button>
<input
ref="photoInput"
class="photo-file"
type="file"
accept="image/*"
multiple
@change="onPhotosPicked"
/>
<small class="hint">عکس خودکار کم‌حجم می‌شود تا زیر ۲۰۰ کیلوبایت بماند.</small>
</div>
<p v-if="photoError" class="error">{{ photoError }}</p>
<div v-if="pendingPhotos.length || savedPhotos.length" class="photo-grid">
<figure v-for="photo in savedPhotos" :key="photo.id">
<img v-if="photo.previewUrl" :src="photo.previewUrl" :alt="photo.fileName" />
<button type="button" aria-label="حذف عکس" @click="removeSavedPhoto(photo)">×</button>
</figure>
<figure v-for="photo in pendingPhotos" :key="photo.localId">
<img :src="photo.dataUrl" :alt="photo.name" />
<button type="button" aria-label="حذف عکس" @click="removePendingPhoto(photo.localId)">×</button>
</figure>
</div>
<label for="activity-tags" class="tags-label">هشتگ / برچسب</label>
<UiInput
id="activity-tags"
@@ -1900,6 +2043,71 @@ watch([id, isNew], () => void load(), { immediate: true });
resize: vertical;
}
.photo-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
}
.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.7;
cursor: progress;
}
.photo-file {
display: none;
}
.photo-grid {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.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;
}
.muted,
.error {
margin: 0;