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:
@@ -6,9 +6,13 @@ export type ActivityThreadEntry = {
|
||||
timeLabel: string;
|
||||
author: string;
|
||||
body: string;
|
||||
/** شناسهٔ فایلهای چسبیده به این یادداشت (عکس/صوت/فایل) */
|
||||
attachmentIds?: string[];
|
||||
};
|
||||
|
||||
const STAMP = /^\[(.+?) ساعت (\S+?)(?: — (.+))?\]$/;
|
||||
const ATTACHMENTS = /^\[\[attachments:([^\]]+)\]\]$/i;
|
||||
const COMMENT_MEDIA_WINDOW_MS = 3 * 60 * 1000;
|
||||
|
||||
/** یادداشت فعالیت را به نظرهای جدا با مهر تاریخ/ساعت/نویسنده تبدیل میکند. */
|
||||
export function parseActivityThread(raw: string | null | undefined): ActivityThreadEntry[] | null {
|
||||
@@ -21,19 +25,29 @@ export function parseActivityThread(raw: string | null | undefined): ActivityThr
|
||||
let sawStamp = false;
|
||||
|
||||
for (const line of text.split('\n')) {
|
||||
const match = line.trim().match(STAMP);
|
||||
if (match) {
|
||||
const trimmed = line.trim();
|
||||
const stamp = trimmed.match(STAMP);
|
||||
if (stamp) {
|
||||
sawStamp = true;
|
||||
if (current) entries.push(trimEntry(current));
|
||||
current = {
|
||||
at: stampToDate(match[1] ?? '', match[2] ?? ''),
|
||||
dateLabel: (match[1] ?? '').trim(),
|
||||
timeLabel: (match[2] ?? '').trim(),
|
||||
author: (match[3] ?? '').trim(),
|
||||
at: stampToDate(stamp[1] ?? '', stamp[2] ?? ''),
|
||||
dateLabel: (stamp[1] ?? '').trim(),
|
||||
timeLabel: (stamp[2] ?? '').trim(),
|
||||
author: (stamp[3] ?? '').trim(),
|
||||
body: '',
|
||||
attachmentIds: [],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
const attach = trimmed.match(ATTACHMENTS);
|
||||
if (attach && current) {
|
||||
current.attachmentIds = attach[1]!
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
continue;
|
||||
}
|
||||
if (current) current.body += `${current.body ? '\n' : ''}${line}`;
|
||||
else preamble.push(line);
|
||||
}
|
||||
@@ -42,13 +56,89 @@ export function parseActivityThread(raw: string | null | undefined): ActivityThr
|
||||
|
||||
const head = preamble.join('\n').trim();
|
||||
if (head) {
|
||||
entries.unshift({ at: null, dateLabel: '', timeLabel: '', author: '', body: head });
|
||||
entries.unshift({
|
||||
at: null,
|
||||
dateLabel: '',
|
||||
timeLabel: '',
|
||||
author: '',
|
||||
body: head,
|
||||
attachmentIds: [],
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** برگرداندن متن ذخیرهشونده از نظرهای جدا. */
|
||||
export function serializeActivityThread(entries: ActivityThreadEntry[]): string {
|
||||
return entries
|
||||
.map((entry) => {
|
||||
const ids = (entry.attachmentIds ?? []).filter(Boolean);
|
||||
const attachLine = ids.length ? `\n[[attachments:${ids.join(',')}]]` : '';
|
||||
if (!entry.dateLabel) return `${entry.body}${attachLine}`.trim();
|
||||
const stamp = `[${entry.dateLabel} ساعت ${entry.timeLabel}${entry.author ? ` — ${entry.author}` : ''}]`;
|
||||
return entry.body ? `${stamp}\n${entry.body}${attachLine}` : `${stamp}${attachLine}`;
|
||||
})
|
||||
.filter((chunk) => chunk.trim())
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
/** فایلها را به یادداشتها میچسباند: اول با شناسهٔ صریح، بعد با نزدیکی زمان. */
|
||||
export function bindThreadMedia<T extends { id: string; createdAt: string }>(
|
||||
entries: ActivityThreadEntry[],
|
||||
images: T[],
|
||||
windowMs = COMMENT_MEDIA_WINDOW_MS,
|
||||
): { blocks: Array<ActivityThreadEntry & { media: T[] }>; loose: T[] } {
|
||||
const blocks = entries.map((entry) => ({ ...entry, media: [] as T[] }));
|
||||
const byId = new Map(images.map((image) => [image.id, image]));
|
||||
const used = new Set<string>();
|
||||
|
||||
blocks.forEach((block) => {
|
||||
for (const id of block.attachmentIds ?? []) {
|
||||
const image = byId.get(id);
|
||||
if (!image || used.has(id)) continue;
|
||||
block.media.push(image);
|
||||
used.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
for (const image of images) {
|
||||
if (used.has(image.id)) continue;
|
||||
const at = new Date(image.createdAt).getTime();
|
||||
if (Number.isNaN(at)) continue;
|
||||
let best = -1;
|
||||
let bestDelta = Number.POSITIVE_INFINITY;
|
||||
blocks.forEach((block, index) => {
|
||||
if (!block.at) return;
|
||||
const delta = block.at.getTime() - at;
|
||||
if (delta >= -60_000 && delta < bestDelta) {
|
||||
best = index;
|
||||
bestDelta = delta;
|
||||
}
|
||||
});
|
||||
if (best >= 0 && bestDelta <= windowMs) {
|
||||
blocks[best]!.media.push(image);
|
||||
used.add(image.id);
|
||||
}
|
||||
}
|
||||
|
||||
const firstStamp = blocks.find((block) => block.at)?.at?.getTime() ?? Number.POSITIVE_INFINITY;
|
||||
const preamble = blocks.find((block) => !block.at);
|
||||
const loose: T[] = [];
|
||||
for (const image of images) {
|
||||
if (used.has(image.id)) continue;
|
||||
const at = new Date(image.createdAt).getTime();
|
||||
if (preamble && !Number.isNaN(at) && at < firstStamp) preamble.media.push(image);
|
||||
else loose.push(image);
|
||||
}
|
||||
return { blocks, loose };
|
||||
}
|
||||
|
||||
function trimEntry(entry: ActivityThreadEntry): ActivityThreadEntry {
|
||||
return { ...entry, body: entry.body.trim() };
|
||||
return {
|
||||
...entry,
|
||||
body: entry.body.trim(),
|
||||
attachmentIds: [...new Set((entry.attachmentIds ?? []).filter(Boolean))],
|
||||
};
|
||||
}
|
||||
|
||||
function latinDigits(value: string): string {
|
||||
|
||||
Reference in New Issue
Block a user