feat(activities): edit detail in place and tighten the board peek
Let people change activity fields and their own notes with photos from the detail page, open full detail from the card title, and toggle the side peek with a chevron. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,13 +10,15 @@ import {
|
||||
Flex as AFlex,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Image as AImage,
|
||||
Spin as ASpin,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import type { ActivityDto } from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { parseActivityThread } from '@/lib/activity-thread';
|
||||
import { bindThreadMedia, parseActivityThread } from '@/lib/activity-thread';
|
||||
import { noteChips } from '@/lib/lead-form-notes';
|
||||
import {
|
||||
formatClockFa,
|
||||
formatJalaliDay,
|
||||
@@ -40,7 +42,7 @@ const emit = defineEmits<{
|
||||
full: [id: string];
|
||||
}>();
|
||||
|
||||
const { Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
|
||||
const { Text: ATypographyText, Paragraph: ATypographyParagraph, Link: ATypographyLink } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
const auth = useAuthStore();
|
||||
|
||||
@@ -145,7 +147,7 @@ function onPointerDown(event: PointerEvent) {
|
||||
if (!(target instanceof Node)) return;
|
||||
if (panelEl.value?.contains(target)) return;
|
||||
if (commentOpen.value) return;
|
||||
if (target instanceof Element && target.closest('.ant-select-dropdown, #jalali-picker, .layer')) return;
|
||||
if (target instanceof Element && target.closest('.ant-select-dropdown, #jalali-picker, .layer, .ant-image-preview-root, .ant-image-preview-wrap')) return;
|
||||
const card = cardEl(props.activityId);
|
||||
if (card?.contains(target)) return;
|
||||
emit('close');
|
||||
@@ -170,6 +172,7 @@ onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
window.clearTimeout(hideTimer);
|
||||
clearNoteMedia();
|
||||
});
|
||||
|
||||
const activity = ref<ActivityDto | null>(null);
|
||||
@@ -180,6 +183,20 @@ const commentOpen = ref(false);
|
||||
const postponeKey = ref('h1');
|
||||
const customDue = ref('');
|
||||
|
||||
type NoteMedia = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
kind: 'image' | 'audio' | 'file';
|
||||
createdAt: string;
|
||||
};
|
||||
const noteMedia = ref<NoteMedia[]>([]);
|
||||
|
||||
function clearNoteMedia() {
|
||||
for (const item of noteMedia.value) URL.revokeObjectURL(item.url);
|
||||
noteMedia.value = [];
|
||||
}
|
||||
|
||||
const isClosed = computed(
|
||||
() => activity.value?.status === 'done' || activity.value?.status === 'canceled',
|
||||
);
|
||||
@@ -205,6 +222,14 @@ const createdLabel = computed(() => {
|
||||
|
||||
const thread = computed(() => parseActivityThread(activity.value?.description));
|
||||
const plainNote = computed(() => (thread.value ? '' : activity.value?.description?.trim() || ''));
|
||||
const threadBlocks = computed(() => {
|
||||
const entries = thread.value;
|
||||
if (!entries) return null;
|
||||
return bindThreadMedia(entries, noteMedia.value);
|
||||
});
|
||||
const looseImages = computed(() =>
|
||||
(threadBlocks.value?.loose ?? noteMedia.value).filter((item) => item.kind === 'image'),
|
||||
);
|
||||
|
||||
const noteScrollStyle = computed(() => ({
|
||||
maxHeight: '220px',
|
||||
@@ -279,6 +304,41 @@ function apiMessage(e: unknown, fallback: string) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function loadNoteMedia(row: ActivityDto) {
|
||||
clearNoteMedia();
|
||||
const next: NoteMedia[] = [];
|
||||
for (const image of row.images ?? []) {
|
||||
const mime = image.mimeType.toLowerCase();
|
||||
const kind = mime.startsWith('audio/') ? 'audio' : mime.startsWith('image/') ? 'image' : 'file';
|
||||
try {
|
||||
const { data } = await api.get<Blob>(`/activities/${row.id}/images/${image.id}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = URL.createObjectURL(data);
|
||||
if (props.activityId !== row.id) {
|
||||
URL.revokeObjectURL(url);
|
||||
continue;
|
||||
}
|
||||
next.push({
|
||||
id: image.id,
|
||||
name: image.fileName,
|
||||
url,
|
||||
kind,
|
||||
createdAt: image.createdAt,
|
||||
});
|
||||
} catch {
|
||||
/* فایل در دسترس نیست */
|
||||
}
|
||||
}
|
||||
if (props.activityId !== row.id) {
|
||||
for (const item of next) URL.revokeObjectURL(item.url);
|
||||
return;
|
||||
}
|
||||
noteMedia.value = next;
|
||||
await nextTick();
|
||||
measure();
|
||||
}
|
||||
|
||||
async function load(id: string) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -287,6 +347,7 @@ async function load(id: string) {
|
||||
if (props.activityId !== id) return;
|
||||
activity.value = data;
|
||||
if (!customDue.value) customDue.value = formatLocalDateTimeInput(addMinutes(60));
|
||||
void loadNoteMedia(data);
|
||||
} catch (e) {
|
||||
if (props.activityId === id) {
|
||||
activity.value = null;
|
||||
@@ -309,6 +370,7 @@ watch(
|
||||
activity.value = null;
|
||||
error.value = '';
|
||||
resetDrafts();
|
||||
clearNoteMedia();
|
||||
placed.value = false;
|
||||
window.clearTimeout(hideTimer);
|
||||
hideTimer = window.setTimeout(() => {
|
||||
@@ -320,6 +382,7 @@ watch(
|
||||
if (prev && prev !== id) {
|
||||
resetDrafts();
|
||||
activity.value = null;
|
||||
clearNoteMedia();
|
||||
}
|
||||
if (prev && prev !== id && rendered.value) placed.value = false;
|
||||
rendered.value = true;
|
||||
@@ -466,10 +529,10 @@ function openFull() {
|
||||
<template #icon><PlusOutlined /></template>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<div :style="noteScrollStyle">
|
||||
<a-flex v-if="thread?.length" vertical :gap="8">
|
||||
<div class="note-photos" :style="noteScrollStyle">
|
||||
<a-flex v-if="threadBlocks" vertical :gap="8">
|
||||
<div
|
||||
v-for="(entry, index) in thread"
|
||||
v-for="(entry, index) in threadBlocks.blocks"
|
||||
:key="`${entry.dateLabel}-${entry.timeLabel}-${index}`"
|
||||
:style="noteBubbleStyle"
|
||||
>
|
||||
@@ -482,14 +545,83 @@ function openFull() {
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-typography-paragraph v-if="entry.body" :style="{ margin: entry.dateLabel ? '4px 0 0' : 0, whiteSpace: 'pre-wrap' }">
|
||||
{{ entry.body }}
|
||||
<template v-for="(chip, chipIndex) in noteChips(entry.body)" :key="chipIndex">
|
||||
<a-image
|
||||
v-if="chip.kind === 'image'"
|
||||
:src="chip.src"
|
||||
:alt="chip.name"
|
||||
:width="72"
|
||||
:height="72"
|
||||
:preview="{ zIndex: 1300 }"
|
||||
:style="{ display: 'block', marginTop: '8px' }"
|
||||
/>
|
||||
<a-typography-link
|
||||
v-else-if="chip.kind === 'link'"
|
||||
:href="chip.href"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ chip.label }}
|
||||
</a-typography-link>
|
||||
<span v-else>{{ chip.text }}</span>
|
||||
</template>
|
||||
</a-typography-paragraph>
|
||||
<a-flex
|
||||
v-if="entry.media.some((item) => item.kind === 'image')"
|
||||
wrap="wrap"
|
||||
:gap="8"
|
||||
:style="{ marginTop: '8px' }"
|
||||
>
|
||||
<a-image
|
||||
v-for="image in entry.media.filter((item) => item.kind === 'image')"
|
||||
:key="image.id"
|
||||
:src="image.url"
|
||||
:alt="image.name"
|
||||
:width="72"
|
||||
:height="72"
|
||||
:preview="{ zIndex: 1300 }"
|
||||
/>
|
||||
</a-flex>
|
||||
</div>
|
||||
<div v-for="image in threadBlocks.loose.filter((item) => item.kind === 'image')" :key="image.id" :style="noteBubbleStyle">
|
||||
<a-image :src="image.url" :alt="image.name" :width="72"
|
||||
:height="72" :preview="{ zIndex: 1300 }" />
|
||||
</div>
|
||||
</a-flex>
|
||||
<div v-else-if="plainNote" :style="noteBubbleStyle">
|
||||
<a-typography-paragraph :style="{ margin: 0, whiteSpace: 'pre-wrap' }">
|
||||
{{ plainNote }}
|
||||
<div v-else-if="plainNote || looseImages.length" :style="noteBubbleStyle">
|
||||
<a-typography-paragraph v-if="plainNote" :style="{ margin: 0, whiteSpace: 'pre-wrap' }">
|
||||
<template v-for="(chip, chipIndex) in noteChips(plainNote)" :key="chipIndex">
|
||||
<a-image
|
||||
v-if="chip.kind === 'image'"
|
||||
:src="chip.src"
|
||||
:alt="chip.name"
|
||||
:width="72"
|
||||
:height="72"
|
||||
:preview="{ zIndex: 1300 }"
|
||||
:style="{ display: 'block', marginTop: '8px' }"
|
||||
/>
|
||||
<a-typography-link
|
||||
v-else-if="chip.kind === 'link'"
|
||||
:href="chip.href"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ chip.label }}
|
||||
</a-typography-link>
|
||||
<span v-else>{{ chip.text }}</span>
|
||||
</template>
|
||||
</a-typography-paragraph>
|
||||
<a-flex v-if="looseImages.length" wrap="wrap" :gap="8" :style="{ marginTop: plainNote ? '8px' : 0 }">
|
||||
<a-image
|
||||
v-for="image in looseImages"
|
||||
:key="image.id"
|
||||
:src="image.url"
|
||||
:alt="image.name"
|
||||
:width="72"
|
||||
:height="72"
|
||||
:preview="{ zIndex: 1300 }"
|
||||
/>
|
||||
</a-flex>
|
||||
</div>
|
||||
<a-typography-text v-else type="secondary">یادداشتی ثبت نشده.</a-typography-text>
|
||||
</div>
|
||||
@@ -536,3 +668,15 @@ function openFull() {
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.note-photos :deep(.ant-image) {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
.note-photos :deep(.ant-image-img) {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user