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 <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
|||||||
formatClockFa,
|
formatClockFa,
|
||||||
formatJalaliDay,
|
formatJalaliDay,
|
||||||
} from '@/lib/jalali';
|
} from '@/lib/jalali';
|
||||||
|
import { FormFileTooLargeError, prepareFormFieldFile } from '@/lib/form-file-compress';
|
||||||
import { appendSpokenText } from '@/lib/spoken-text';
|
import { appendSpokenText } from '@/lib/spoken-text';
|
||||||
import UiIcon from '@/components/UiIcon.vue';
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
|
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
|
||||||
@@ -14,11 +15,11 @@ import type { ActivityDto, ActivityType, OrganizationDto, OrgUserDto } from '@me
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
activity: ActivityDto;
|
activity: ActivityDto;
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
|
save: (payload: { text: string; photoOnly: boolean }) => Promise<void>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: [];
|
close: [];
|
||||||
submit: [text: string];
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
|
const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
|
||||||
@@ -28,8 +29,18 @@ const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
|
|||||||
note: 'chat',
|
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 comment = ref('');
|
||||||
const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
const pendingPhotos = ref<PendingPhoto[]>([]);
|
||||||
|
const uploadedCount = ref(0);
|
||||||
|
const photoBusy = ref(false);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const photoError = ref('');
|
||||||
|
const photoInput = ref<HTMLInputElement | null>(null);
|
||||||
const orgName = ref('سازمان');
|
const orgName = ref('سازمان');
|
||||||
const users = ref<OrgUserDto[]>([]);
|
const users = ref<OrgUserDto[]>([]);
|
||||||
const mentionOpen = ref(false);
|
const mentionOpen = ref(false);
|
||||||
@@ -61,8 +72,17 @@ const nowLabel = computed(
|
|||||||
() => `${formatJalaliDay(now.value)} ساعت ${formatClockFa(now.value)}`,
|
() => `${formatJalaliDay(now.value)} ساعت ${formatClockFa(now.value)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const photoSlotsUsed = computed(
|
||||||
|
() => (props.activity.images?.length ?? 0) + uploadedCount.value + pendingPhotos.value.length,
|
||||||
|
);
|
||||||
|
|
||||||
const canSubmit = computed(
|
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) {
|
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();
|
const text = comment.value.trim();
|
||||||
if (!text || props.saving || voiceBusy.value) return;
|
if (!canSubmit.value) return;
|
||||||
emit('submit', text);
|
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() {
|
function close() {
|
||||||
@@ -215,6 +308,35 @@ onBeforeUnmount(() => {
|
|||||||
rows="6"
|
rows="6"
|
||||||
placeholder="متن خود را بنویسید..."
|
placeholder="متن خود را بنویسید..."
|
||||||
/>
|
/>
|
||||||
|
<div class="photo-row">
|
||||||
|
<button
|
||||||
|
class="photo-add"
|
||||||
|
type="button"
|
||||||
|
:disabled="photoBusy || uploading || recording || photoSlotsUsed >= PHOTO_LIMIT"
|
||||||
|
@click="photoInput?.click()"
|
||||||
|
>
|
||||||
|
<UiIcon name="camera" :size="16" />
|
||||||
|
{{ photoBusy ? 'در حال کمحجم کردن عکس…' : 'افزودن عکس' }}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref="photoInput"
|
||||||
|
class="photo-file"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
@change="onPhotosPicked"
|
||||||
|
/>
|
||||||
|
<small>عکس خودکار کمحجم میشود تا زیر ۲۰۰ کیلوبایت بماند.</small>
|
||||||
|
</div>
|
||||||
|
<p v-if="photoError" class="photo-err">{{ photoError }}</p>
|
||||||
|
<div v-if="pendingPhotos.length" class="photo-grid">
|
||||||
|
<figure v-for="photo in pendingPhotos" :key="photo.localId">
|
||||||
|
<img :src="photo.dataUrl" :alt="photo.name" />
|
||||||
|
<button type="button" aria-label="حذف عکس" :disabled="uploading" @click="removePendingPhoto(photo.localId)">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
<div class="tools">
|
<div class="tools">
|
||||||
<div class="tool-wrap">
|
<div class="tool-wrap">
|
||||||
<button
|
<button
|
||||||
@@ -259,9 +381,9 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="act primary" type="button" :disabled="!canSubmit" @click="submit">
|
<button class="act primary" type="button" :disabled="!canSubmit" @click="submit">
|
||||||
{{ recording ? 'در حال ضبط…' : 'ثبت نظر' }}
|
{{ recording ? 'در حال ضبط…' : uploading || saving ? 'در حال ثبت…' : 'ثبت نظر' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="act ghost" type="button" :disabled="saving || voiceBusy" @click="close">انصراف</button>
|
<button class="act ghost" type="button" :disabled="saving || voiceBusy || uploading" @click="close">انصراف</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -457,6 +579,85 @@ onBeforeUnmount(() => {
|
|||||||
color: #94a3b8;
|
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 {
|
.tools {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -242,8 +242,8 @@ async function postponeTo(iso: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitComment(text: string) {
|
async function submitComment(payload: { text: string; photoOnly: boolean }) {
|
||||||
const body = text.trim();
|
const body = payload.text.trim() || (payload.photoOnly ? 'عکس پیوست شد.' : '');
|
||||||
if (!id.value || !activity.value || !body) return;
|
if (!id.value || !activity.value || !body) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
@@ -257,8 +257,9 @@ async function submitComment(text: string) {
|
|||||||
await api.patch(`/activities/${id.value}`, { description: next });
|
await api.patch(`/activities/${id.value}`, { description: next });
|
||||||
commentOpen.value = false;
|
commentOpen.value = false;
|
||||||
await load();
|
await load();
|
||||||
} catch {
|
} catch (e) {
|
||||||
error.value = 'ثبت نظر ناموفق بود.';
|
error.value = 'ثبت نظر ناموفق بود.';
|
||||||
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
@@ -488,8 +489,8 @@ onMounted(() => {
|
|||||||
v-if="commentOpen"
|
v-if="commentOpen"
|
||||||
:activity="activity"
|
:activity="activity"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:save="submitComment"
|
||||||
@close="commentOpen = false"
|
@close="commentOpen = false"
|
||||||
@submit="submitComment"
|
|
||||||
/>
|
/>
|
||||||
<ActivityPostponeSheet
|
<ActivityPostponeSheet
|
||||||
v-if="postponeOpen"
|
v-if="postponeOpen"
|
||||||
|
|||||||
Reference in New Issue
Block a user