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
+55 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import { api } from '@/lib/api';
import {
@@ -35,6 +35,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 moreOpen = ref(false);
const commentOpen = ref(false);
const postponeOpen = ref(false);
@@ -130,6 +131,7 @@ async function load() {
try {
const { data } = await api.get<ActivityDto>(`/activities/${id.value}`);
activity.value = data;
await loadActivityImages(data);
assignUserIds.value = data.assignees?.length
? data.assignees.map((u) => u.id)
: data.assignee?.id
@@ -143,6 +145,29 @@ async function load() {
}
}
function clearImageUrls() {
for (const image of imageUrls.value) URL.revokeObjectURL(image.url);
imageUrls.value = [];
}
async function loadActivityImages(row: ActivityDto) {
clearImageUrls();
const next: Array<{ id: string; name: string; url: string }> = [];
for (const image of row.images ?? []) {
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) });
} catch {
/* عکس در دسترس نیست */
}
}
imageUrls.value = next;
}
onUnmounted(clearImageUrls);
async function loadUsers() {
if (!canReassign.value) return;
try {
@@ -446,9 +471,15 @@ onMounted(() => {
</strong>
<UiIcon name="chevron" :size="16" :class="{ open: noteOpen }" />
</button>
<p v-show="noteOpen" :class="{ empty: !activity.description?.trim() }">
{{ activity.description?.trim() || 'یادداشتی ثبت نشده.' }}
</p>
<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">
<img :src="image.url" :alt="image.name" />
</a>
</div>
</div>
</article>
<p v-if="error" class="error">{{ error }}</p>
@@ -800,6 +831,26 @@ dd {
color: #94a3b8;
}
.note-photos {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.note-photos a {
width: 5.5rem;
height: 5.5rem;
}
.note-photos img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.6rem;
border: 1px solid #e2e8f0;
}
.dock {
position: fixed;
inset-inline: 0.85rem;