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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user