feat: mobile timeline — render links as tappable [link] in notes and time entries
- Replace stripHtml() with renderContent() that: 1. Extracts <a href="...">...</a> anchors before stripping HTML 2. Strips remaining HTML tags cleanly 3. Detects bare https?:// URLs in plain text 4. Renders each as a tappable <a>[link]</a> with text-primary underline - Collapsed note preview still shows plain text (no link clutter) - Expanded note/time-entry body shows inline [link] elements
This commit is contained in:
parent
c8be128c5c
commit
012c7bde50
1 changed files with 41 additions and 6 deletions
|
|
@ -53,8 +53,42 @@ function fmtHours(h: string | number) {
|
|||
if (n < 1) return `${Math.round(n * 60)}m`;
|
||||
return `${n.toFixed(1)}h`;
|
||||
}
|
||||
function stripHtml(s: string) {
|
||||
return s?.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() ?? '';
|
||||
function renderContent(s: string): React.ReactNode[] {
|
||||
if (!s) return [];
|
||||
|
||||
// Step 1: convert <a href="...">...</a> → placeholder §URL§
|
||||
// Collect links in order
|
||||
const links: string[] = [];
|
||||
const withPlaceholders = s.replace(/<a\b[^>]*\bhref=["']([^"']+)["'][^>]*>.*?<\/a>/gi, (_, href) => {
|
||||
links.push(href);
|
||||
return `\x00LINK${links.length - 1}\x00`;
|
||||
});
|
||||
|
||||
// Step 2: strip remaining HTML tags
|
||||
const plain = withPlaceholders.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Step 3: split on LINK placeholders and bare URLs
|
||||
const URL_RE = /\x00LINK(\d+)\x00|(https?:\/\/[^\s<>"']+)/g;
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let last = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
let key = 0;
|
||||
|
||||
while ((match = URL_RE.exec(plain)) !== null) {
|
||||
if (match.index > last) {
|
||||
nodes.push(plain.slice(last, match.index));
|
||||
}
|
||||
const href = match[1] !== undefined ? links[parseInt(match[1])] : match[2];
|
||||
nodes.push(
|
||||
<a key={key++} href={href} target="_blank" rel="noopener noreferrer"
|
||||
className="text-primary underline" onClick={e => e.stopPropagation()}>
|
||||
[link]
|
||||
</a>
|
||||
);
|
||||
last = match.index + match[0].length;
|
||||
}
|
||||
if (last < plain.length) nodes.push(plain.slice(last));
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defaultOpen?: boolean }) {
|
||||
|
|
@ -118,7 +152,7 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau
|
|||
<p className="text-xs font-medium text-foreground flex items-center gap-1.5">
|
||||
<User className="w-3 h-3 text-muted-foreground" />{te.resource_name}
|
||||
</p>
|
||||
{te.notes && <p className="text-sm text-foreground leading-relaxed">{stripHtml(te.notes)}</p>}
|
||||
{te.notes && <p className="text-sm text-foreground leading-relaxed">{renderContent(te.notes)}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -130,7 +164,8 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau
|
|||
const note = item.data;
|
||||
const isInternal = note.publish === 1;
|
||||
const isSystem = item.is_system;
|
||||
const body = stripHtml(note.description ?? '');
|
||||
const bodyNodes = renderContent(note.description ?? '');
|
||||
const bodyPlain = (note.description ?? '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const iconColor = isSystem ? 'text-muted-foreground' : isInternal ? 'text-slate-400' : 'text-amber-500';
|
||||
const iconBg = isSystem ? 'bg-muted' : isInternal ? 'bg-muted' : 'bg-amber-500/10';
|
||||
return (
|
||||
|
|
@ -149,7 +184,7 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau
|
|||
<p className={`text-sm font-medium line-clamp-1 ${isSystem ? 'text-muted-foreground' : ''}`}>
|
||||
{note.title || (isInternal ? 'Internal Note' : 'Customer Note')}
|
||||
</p>
|
||||
{!open && body && <p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{body}</p>}
|
||||
{!open && bodyPlain && <p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{bodyPlain}</p>}
|
||||
</div>
|
||||
{open ? <ChevronDown className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" /> : <ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" />}
|
||||
</div>
|
||||
|
|
@ -159,7 +194,7 @@ function TimelineCard({ item, defaultOpen = false }: { item: TimelineItem; defau
|
|||
<p className="text-xs text-muted-foreground flex items-center gap-1 mb-1.5">
|
||||
<User className="w-3 h-3" />{note.author || 'System'} · {isSystem ? 'System' : isInternal ? 'Internal' : 'Client-visible'}
|
||||
</p>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">{body}</p>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">{bodyNodes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue