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
@@ -0,0 +1,32 @@
-- CreateTable
CREATE TABLE "ActivityImage" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"activityId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"sizeBytes" INTEGER NOT NULL,
"storageKey" TEXT NOT NULL,
"uploadedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ActivityImage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ActivityImage_activityId_createdAt_idx" ON "ActivityImage"("activityId", "createdAt");
-- CreateIndex
CREATE INDEX "ActivityImage_organizationId_idx" ON "ActivityImage"("organizationId");
-- CreateIndex
CREATE INDEX "ActivityImage_uploadedById_idx" ON "ActivityImage"("uploadedById");
-- AddForeignKey
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_activityId_fkey" FOREIGN KEY ("activityId") REFERENCES "Activity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+23
View File
@@ -174,6 +174,7 @@ model Organization {
products Product[]
notifications Notification[]
noteAttachments NoteAttachment[]
activityImages ActivityImage[]
leadForms LeadForm[]
quotes Quote[]
invoices Invoice[]
@@ -293,6 +294,7 @@ model User {
notes Note[] @relation("NoteAuthor")
noteRevisions NoteRevision[] @relation("NoteRevisionEditor")
noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader")
activityImages ActivityImage[] @relation("ActivityImageUploader")
notifications Notification[]
salesGoals SalesGoal[] @relation("SalesGoalUser")
commissionPercent Decimal @default(0) @db.Decimal(8, 2)
@@ -866,6 +868,7 @@ model Activity {
/// لید مرتبط (مثلاً فعالیت ساخته‌شده از ارسال فرم)
leadId String?
lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
images ActivityImage[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -884,6 +887,26 @@ model Activity {
@@index([dealId])
}
/// عکس فشرده‌شدهٔ یادداشت فعالیت (حداکثر ۲۰۰ کیلوبایت)
model ActivityImage {
id String @id
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
activityId String
activity Activity @relation(fields: [activityId], references: [id], onDelete: Cascade)
fileName String
mimeType String
sizeBytes Int
storageKey String
uploadedById String?
uploadedBy User? @relation("ActivityImageUploader", fields: [uploadedById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@index([activityId, createdAt])
@@index([organizationId])
@@index([uploadedById])
}
model Note {
id String @id @default(cuid())
organizationId String
@@ -309,6 +309,56 @@ export class ActivitiesController {
return this.activitiesService.get(user.organizationId, id, user);
}
@Post(':id/images')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 2 * 1024 * 1024 },
}),
)
addImage(
@CurrentUser() user: Actor,
@Param('id') id: string,
@UploadedFile() file: Express.Multer.File,
) {
return this.activitiesService.addImage(
user.organizationId,
user.userId,
id,
file,
user,
);
}
@Get(':id/images/:imageId')
async downloadImage(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Param('imageId') imageId: string,
): Promise<StreamableFile> {
const { stream, fileName, mimeType } = await this.activitiesService.openImage(
user.organizationId,
id,
imageId,
user,
);
const encoded = encodeURIComponent(fileName);
return new StreamableFile(stream, {
type: mimeType,
disposition: `inline; filename="${fileName.replace(/"/g, '')}"; filename*=UTF-8''${encoded}`,
});
}
@Delete(':id/images/:imageId')
async removeImage(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Param('imageId') imageId: string,
) {
await this.activitiesService.removeImage(user.organizationId, id, imageId, user);
return { ok: true };
}
@Post(':id/promote-from-prior')
promoteFromPrior(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.activitiesService.promoteFromPrior(user, id);
+2 -1
View File
@@ -7,9 +7,10 @@ import { ActivityTypesService } from './activity-types.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
import { StorageModule } from '../storage/storage.module';
@Module({
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule, StorageModule],
controllers: [ActivitiesController],
providers: [
ActivitiesService,
+137 -1
View File
@@ -8,6 +8,7 @@ import type {
ActivityDto,
ActivityFieldValues,
ActivityFilter,
ActivityImageDto,
ActivitySummaryDto,
ActivityStageCountsDto,
CreateActivityRequest,
@@ -15,6 +16,9 @@ import type {
UpdateActivityRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import * as path from 'node:path';
import { LocalFileStorage } from '../storage/local-file.storage';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
@@ -84,8 +88,19 @@ type ActivityRow = {
typeDef?: { id: string; name: string; icon: string; color: string } | null;
fields?: Prisma.JsonValue | null;
participantIds?: string[];
images?: Array<{
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
createdAt: Date;
}>;
};
const ACTIVITY_IMAGE_MAX_BYTES = 200 * 1024;
const ACTIVITY_IMAGE_MAX_COUNT = 8;
const ACTIVITY_IMAGE_MIME = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']);
const include = {
stage: { select: { id: true, kind: true, boardId: true } },
typeDef: { select: { id: true, name: true, icon: true, color: true } },
@@ -108,6 +123,7 @@ export class ActivitiesService {
private readonly stages: ActivityStagesService,
private readonly types: ActivityTypesService,
private readonly game: GamificationService,
private readonly files: LocalFileStorage,
) {}
private async ctx(organizationId: string): Promise<DtoContext> {
@@ -382,7 +398,19 @@ export class ActivitiesService {
): Promise<ActivityDto> {
const row = await this.prisma.activity.findFirst({
where: { id, organizationId, ...activityAccessWhere(actor) },
include,
include: {
...include,
images: {
select: {
id: true,
fileName: true,
mimeType: true,
sizeBytes: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' },
},
},
});
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
const dto = this.toDto(row, await this.ctx(organizationId));
@@ -701,7 +729,102 @@ export class ActivitiesService {
actor: ActivityActor,
): Promise<void> {
await this.ensureExists(organizationId, id, actor);
const images = await this.prisma.activityImage.findMany({
where: { activityId: id, organizationId },
select: { storageKey: true },
});
await this.prisma.activity.delete({ where: { id } });
for (const image of images) {
await this.files.remove(image.storageKey);
}
}
async addImage(
organizationId: string,
userId: string,
activityId: string,
file: Express.Multer.File,
actor: ActivityActor,
): Promise<ActivityImageDto> {
await this.ensureExists(organizationId, activityId, actor);
if (!file?.buffer?.length) {
throw new BadRequestException('فایلی ارسال نشده است');
}
const mimeType = (file.mimetype || '').toLowerCase();
if (!ACTIVITY_IMAGE_MIME.has(mimeType)) {
throw new BadRequestException('فقط عکس می‌توانید اضافه کنید.');
}
if (file.size > ACTIVITY_IMAGE_MAX_BYTES) {
throw new BadRequestException('حجم عکس باید زیر 200 کیلوبایت باشد');
}
const count = await this.prisma.activityImage.count({ where: { activityId } });
if (count >= ACTIVITY_IMAGE_MAX_COUNT) {
throw new BadRequestException(`حداکثر ${ACTIVITY_IMAGE_MAX_COUNT} عکس برای هر فعالیت مجاز است`);
}
const fileName = sanitizeActivityImageName(file.originalname);
const ext = path.extname(fileName).slice(0, 12) || '.jpg';
const imageId = randomUUID().replace(/-/g, '').slice(0, 24);
const storageKey = path
.join(organizationId, 'activities', activityId, `${imageId}${ext}`)
.replace(/\\/g, '/');
await this.files.save(storageKey, file.buffer);
try {
const row = await this.prisma.activityImage.create({
data: {
id: imageId,
organizationId,
activityId,
fileName,
mimeType: mimeType === 'image/jpg' ? 'image/jpeg' : mimeType,
sizeBytes: file.size,
storageKey,
uploadedById: userId,
},
});
return {
id: row.id,
fileName: row.fileName,
mimeType: row.mimeType,
sizeBytes: row.sizeBytes,
createdAt: row.createdAt.toISOString(),
};
} catch (err) {
await this.files.remove(storageKey);
throw err;
}
}
async removeImage(
organizationId: string,
activityId: string,
imageId: string,
actor: ActivityActor,
): Promise<void> {
await this.ensureExists(organizationId, activityId, actor);
const row = await this.prisma.activityImage.findFirst({
where: { id: imageId, activityId, organizationId },
});
if (!row) throw new NotFoundException('عکس پیدا نشد');
await this.files.remove(row.storageKey);
await this.prisma.activityImage.delete({ where: { id: row.id } });
}
async openImage(
organizationId: string,
activityId: string,
imageId: string,
actor: ActivityActor,
): Promise<{ stream: ReturnType<LocalFileStorage['openReadStream']>; fileName: string; mimeType: string }> {
await this.ensureExists(organizationId, activityId, actor);
const row = await this.prisma.activityImage.findFirst({
where: { id: imageId, activityId, organizationId },
});
if (!row) throw new NotFoundException('عکس پیدا نشد');
return {
stream: this.files.openReadStream(row.storageKey),
fileName: row.fileName,
mimeType: row.mimeType,
};
}
private buildWhere(
@@ -971,6 +1094,13 @@ export class ActivitiesService {
priority: row.priority,
title: row.title,
description: row.description,
images: row.images?.map((image) => ({
id: image.id,
fileName: image.fileName,
mimeType: image.mimeType,
sizeBytes: image.sizeBytes,
createdAt: image.createdAt.toISOString(),
})),
dueAt: row.dueAt?.toISOString() ?? null,
durationMinutes: row.durationMinutes,
reminderAt: row.reminderAt?.toISOString() ?? null,
@@ -1042,6 +1172,12 @@ function typesMatching(term: string): ActivityDto['type'][] {
);
}
function sanitizeActivityImageName(raw: string): string {
const base = path.basename(raw || 'photo.jpg').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
const trimmed = base.trim() || 'photo.jpg';
return trimmed.slice(0, 180);
}
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
+2
View File
@@ -162,6 +162,7 @@ describe('promote from prior', () => {
} as never,
{ system: async () => ({}), toSummary: (row: unknown) => row } as never,
{} as never,
{} as never,
);
const dto = await service.promoteFromPrior(actor, 'act1');
@@ -191,6 +192,7 @@ describe('promote from prior', () => {
} as never,
{ system: async () => ({}), toSummary: (row: unknown) => row } as never,
{} as never,
{} as never,
);
await assert.rejects(service.promoteFromPrior({ ...actor, priorLens: 'off' }, 'act1'), ForbiddenException);
+2
View File
@@ -97,6 +97,7 @@ export async function prepareFormFieldFile(file: File): Promise<{
mime: string;
size: number;
dataUrl: string;
file: File;
}> {
let prepared = file;
if (file.type.startsWith('image/')) {
@@ -115,6 +116,7 @@ export async function prepareFormFieldFile(file: File): Promise<{
mime: prepared.type || file.type || 'application/octet-stream',
size: prepared.size,
dataUrl,
file: prepared,
};
}
+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;
+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;
+10
View File
@@ -1819,6 +1819,14 @@ export interface DealSummaryDto {
title: string;
}
export interface ActivityImageDto {
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
createdAt: string;
}
export interface ActivityDto {
id: string;
type: ActivityType;
@@ -1826,6 +1834,8 @@ export interface ActivityDto {
priority: ActivityPriority;
title: string;
description: string | null;
/** عکس‌های یادداشت؛ فقط در جزئیات فعالیت پر می‌شود */
images?: ActivityImageDto[];
dueAt: string | null;
durationMinutes: number | null;
reminderAt: string | null;