Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 65 additions & 44 deletions web/src/components/workshop/cards/PostCard.tsx
Original file line number Diff line number Diff line change
@@ -1,62 +1,83 @@
import React from 'react';
import { LuCalendar, LuExternalLink, LuUser } from 'react-icons/lu';
import { Link } from '@tanstack/react-router';
import { FC } from 'react';

import { DiscourseInstanceIcon } from '@/components/DiscourseInstanceIcon';
import { mapDiscourseInstanceUrl } from '@/util/discourse';
import { formatRelativeTime, getPlainText, truncateText } from '@/util/format';

import type { Post } from '../types';
import type { Post, SearchEntity } from '../types';

interface PostCardProps {
post: Post;
showDetails?: boolean;
entity: SearchEntity;
}

export const PostCard: React.FC<PostCardProps> = ({ post, showDetails = true }) => {
const plainText = getPlainText(post.cooked);
export const PostCard: FC<PostCardProps> = ({ post, entity, showDetails = true }) => {
const plainText = getPlainText(entity.cooked ?? '');

return (
<div className="border border-primary/20 rounded-lg p-4 bg-secondary/50 hover:bg-secondary/70 transition-colors space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<LuUser size={14} className="text-primary/60" />
<span className="font-medium text-primary text-sm">@{post.username}</span>
{post.name && <span className="text-primary/60 text-xs">({post.name})</span>}
</div>
<div className="flex items-center gap-2">
{showDetails && (
<span className="text-xs text-primary/60">#{post.post_number}</span>
)}
<a
href={`#/topic/${post.topic_id}/${post.post_number}`}
className="text-primary/60 hover:text-primary transition-colors"
title="View post"
>
<LuExternalLink size={14} />
</a>
</div>
</div>

<div className="text-sm text-primary/80 leading-relaxed">
{truncateText(plainText, showDetails ? 300 : 150)}
</div>
// copied from TopicPost
const extra = post.extra as Record<string, unknown>;
const displayName =
(extra?.['display_username'] as string) ||
(extra?.['name'] as string) ||
(extra?.['username'] as string);
const avatar = extra?.['avatar_template'] as string;
const username = extra?.['username'] as string;

{showDetails && (
<div className="flex items-center gap-4 text-xs text-primary/60">
<div className="flex items-center gap-1">
<LuCalendar size={12} />
<span>{formatRelativeTime(post.created_at)}</span>
</div>
{post.like_count && post.like_count > 0 && (
return (
<Link
to="/t/$discourseId/$topicId"
params={{
discourseId: post.discourse_id ?? 'magicians',
topicId: post.topic_id.toString(),
}}
className="block"
title="View post"
>
<div className="border border-primary/20 rounded-lg p-4 bg-secondary/50 hover:bg-secondary/70 transition-colors space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{avatar && (
<img
src={
mapDiscourseInstanceUrl(post.discourse_id) +
avatar.replace('{size}', '40')
}
alt={username}
className="w-7 h-7 rounded-sm"
/>
)}
<div className="flex items-center gap-1">
<span>❤️ {post.like_count}</span>
<span className="font-bold text-primary text-sm">@{displayName}</span>

{username && username?.toLowerCase() !== displayName.toLowerCase() && (
<span className="text-primary/60 text-xs">({username})</span>
)}
</div>
)}
{post.reply_count && post.reply_count > 0 && (
<div className="flex items-center gap-1">
<span>💬 {post.reply_count}</span>
</div>
{post.discourse_id && (
<div>
<DiscourseInstanceIcon discourse_id={post.discourse_id} />
</div>
)}
</div>
)}
</div>

{showDetails && (
<>
{plainText && (
<div className="text-sm text-primary/80 leading-relaxed">
{truncateText(plainText, showDetails ? 300 : 150)}
</div>
)}
{post.created_at && (
<div className="text-xs text-primary/60 text-right">
{formatRelativeTime(post.created_at)}
</div>
)}
</>
)}
</div>
</Link>
);
};
117 changes: 64 additions & 53 deletions web/src/components/workshop/cards/TopicCard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import React from 'react';
import { LuCalendar, LuExternalLink, LuMessageSquare, LuUsers } from 'react-icons/lu';
import { Link } from '@tanstack/react-router';
import { FC } from 'react';
import { LuEye, LuMessageSquare } from 'react-icons/lu';

import { formatRelativeTime } from '@/util/format';
import { CategoryTag } from '@/components/CategoryTag';
import { DiscourseInstanceIcon } from '@/components/DiscourseInstanceIcon';
import { decodeCategory } from '@/util/category';
import { formatCompact, formatRelativeTime } from '@/util/format';

import type { TopicSummary } from '../types';

Expand All @@ -10,58 +14,65 @@ interface TopicCardProps {
showDetails?: boolean;
}

export const TopicCard: React.FC<TopicCardProps> = ({ topic, showDetails = true }) => (
<div className="border border-primary/20 rounded-lg p-4 bg-secondary/50 hover:bg-secondary/70 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-primary text-sm leading-tight mb-2 line-clamp-2">
{topic.title}
</h3>
{showDetails && (
<div className="flex items-center gap-4 text-xs text-primary/60">
<div className="flex items-center gap-1">
<LuMessageSquare size={12} />
<span>{topic.posts_count} posts</span>
export const TopicCard: FC<TopicCardProps> = ({ topic, showDetails = true }) => {
const extra = (topic.extra || {}) as Record<string, unknown>;

const tags = [
...decodeCategory(extra?.['category_id'] as number),
...(extra?.['tags'] as string[]),
];

return (
<Link
to="/t/$discourseId/$topicId"
params={{
discourseId: topic.discourse_id ?? 'magicians',
topicId: topic.topic_id.toString(),
}}
className="block"
title="View topic"
>
<div className="border border-primary/20 rounded-lg p-4 bg-secondary/50 hover:bg-secondary/70 transition-colors space-y-3">
<div className="flex items-start gap-4 justify-between">
{topic.title && (
<h3 className="font-semibold text-primary text-sm leading-tight mb-2 line-clamp-2">
{topic.title}
</h3>
)}
{topic.discourse_id && (
<div>
<DiscourseInstanceIcon discourse_id={topic.discourse_id} />
</div>
<div className="flex items-center gap-1">
<LuCalendar size={12} />
<span>{formatRelativeTime(topic.created_at)}</span>
)}
</div>

{tags && (
<span className="flex gap-2 whitespace-nowrap overflow-x-hidden">
{tags?.map((tag) => <CategoryTag key={tag} tag={tag} />)}
</span>
)}

{showDetails && (
<div className="flex items-center justify-between gap-4 text-xs text-primary/60">
<div className="flex gap-2">
{topic.view_count && (
<span className="flex items-center gap-1">
<LuEye size={12} />
{formatCompact(topic.view_count)}
</span>
)}
{topic.post_count && (
<span className="flex items-center gap-1">
<LuMessageSquare size={12} />
{topic.post_count}
</span>
)}
</div>
{topic.views && (
<div className="flex items-center gap-1">
<span>{topic.views} views</span>
</div>
)}

{topic.created_at && <span>{formatRelativeTime(topic.created_at)}</span>}
</div>
)}
</div>
<a
href={`#/topic/${topic.id}`}
className="text-primary/60 hover:text-primary transition-colors flex-shrink-0"
title="View topic"
>
<LuExternalLink size={16} />
</a>
</div>
{topic.participants && topic.participants.length > 0 && (
<div className="mt-3 pt-3 border-t border-primary/10">
<div className="flex items-center gap-2 text-xs text-primary/60">
<LuUsers size={12} />
<span>Participants:</span>
<div className="flex items-center gap-1">
{topic.participants.slice(0, 3).map((participant) => (
<span key={participant.id} className="text-primary">
@{participant.username}
</span>
))}
{topic.participants.length > 3 && (
<span className="text-primary/50">
+{topic.participants.length - 3} more
</span>
)}
</div>
</div>
</div>
)}
</div>
);
</Link>
);
};
99 changes: 48 additions & 51 deletions web/src/components/workshop/components/SearchResults.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import classNames from 'classnames';
import React, { useState } from 'react';
import React, { FC, useState } from 'react';
import { LuChevronDown, LuChevronLeft, LuHash, LuMessageSquare, LuSearch } from 'react-icons/lu';
import { match } from 'ts-pattern';
import { LoadingIcon } from 'yet-another-react-lightbox';

import { Post, usePosts, useTopic } from '@/api';

import { PostCard } from '../cards/PostCard';
import { TopicCard } from '../cards/TopicCard';
import type { Post, SearchEntity, SearchResult, TopicSummary } from '../types';
import { SearchEntity, SearchResult, TopicSummary } from '../types';

interface SearchResultsProps {
data: SearchResult | SearchEntity[];
Expand All @@ -26,59 +30,52 @@ const getResultsMessage = (toolName: string, topicCount: number, postCount: numb
}
};

// Transform search entity to topic/post format
const transformSearchEntity = (entity: SearchEntity): TopicSummary | Post | null => {
if (entity.entity_type === 'topic') {
return {
id: entity.topic_id || 0,
title: entity.title || 'Untitled Topic',
posts_count: 0,
created_at: '',
last_posted_at: '',
views: 0,
like_count: 0,
};
}
// basic implementation of hooks, change to ts pattern ?
const Topics: FC<{ entity: SearchEntity }> = ({ entity }) => {
const query = useTopic(entity.discourse_id ?? 'magicians', (entity.topic_id ?? 0).toString());

return match(query)
.with({ status: 'pending' }, () => <LoadingIcon />)
.with({ status: 'error' }, ({ error }) => (
<p className="text-red-500">Error: Topic not found {error.message}</p>
))
.with(
{ status: 'success' },
({ data: topic }) => topic && <TopicCard topic={topic as TopicSummary} />
)
.exhaustive();
};

if (entity.entity_type === 'post') {
return {
id: entity.post_id || 0,
topic_id: entity.topic_id || 0,
post_number: entity.post_number || 0,
cooked: entity.cooked || '',
created_at: '',
username: entity.username || 'Unknown User',
};
}
const Posts: FC<{ entity: SearchEntity }> = ({ entity }) => {
const query = usePosts(
entity.discourse_id ?? 'magicians',
(entity.topic_id ?? 1).toString(),
1
);

return null;
return match(query)
.with({ status: 'pending' }, () => <LoadingIcon />)
.with({ status: 'error' }, ({ error }) => (
<p className="text-red-500">Error: Post not found {error.message}</p>
))
.with({ status: 'success' }, ({ data: postData }) => {
const post = postData?.posts.find((p) => p.post_number === entity.post_number);

if (post) return <PostCard post={post as Post} entity={entity} />;
})
.exhaustive();
};

export const SearchResults: React.FC<SearchResultsProps> = ({ data, toolName }) => {
let topics: TopicSummary[] = [];
let posts: Post[] = [];

export const SearchResults: FC<SearchResultsProps> = ({ data, toolName }) => {
// Individual expansion states for each section
const [isTopicsExpanded, setIsTopicsExpanded] = useState(false);
const [isPostsExpanded, setIsPostsExpanded] = useState(false);

// Handle different data formats
if (Array.isArray(data)) {
// SearchEntity[] format
data.forEach((entity) => {
const transformed = transformSearchEntity(entity);

if (transformed && entity.entity_type === 'topic') {
topics.push(transformed as TopicSummary);
} else if (transformed && entity.entity_type === 'post') {
posts.push(transformed as Post);
}
});
} else {
// SearchResult format
topics = data.topics || [];
posts = data.posts || [];
}
// Filter topic & post search result entities
const entities: SearchEntity[] = Array.isArray(data) ? data : [];

const topics = entities.filter((entity) => entity.entity_type === 'topic');
const posts = entities.filter((entity) => entity.entity_type === 'post');

const topicCount = topics.length;
const postCount = posts.length;
Expand Down Expand Up @@ -132,7 +129,7 @@ export const SearchResults: React.FC<SearchResultsProps> = ({ data, toolName })
)}
>
{topicsToShow.map((topic) => (
<TopicCard key={topic.id} topic={topic} />
<Topics key={topic.topic_id} entity={topic} />
))}
</div>
)}
Expand Down Expand Up @@ -167,9 +164,9 @@ export const SearchResults: React.FC<SearchResultsProps> = ({ data, toolName })
hasManyPosts ? 'max-h-80 overflow-y-auto' : ''
)}
>
{postsToShow.map((post) => (
<PostCard key={post.id} post={post} />
))}
{postsToShow.map(
(post) => post && <Posts key={post.post_id} entity={post} />
)}
</div>
)}
</div>
Expand Down
Loading