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:
2026-11-10 21:31:21 +03:30
co-authored by Cursor
parent fdf1fbe4aa
commit f13a1249af
7 changed files with 1163 additions and 300 deletions
+98 -8
View File
@@ -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 {