diff --git a/web/src/components/workshop/cards/PostCard.tsx b/web/src/components/workshop/cards/PostCard.tsx index 4a79345..d978dc9 100644 --- a/web/src/components/workshop/cards/PostCard.tsx +++ b/web/src/components/workshop/cards/PostCard.tsx @@ -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 = ({ post, showDetails = true }) => { - const plainText = getPlainText(post.cooked); +export const PostCard: FC = ({ post, entity, showDetails = true }) => { + const plainText = getPlainText(entity.cooked ?? ''); - return ( -
-
-
- - @{post.username} - {post.name && ({post.name})} -
-
- {showDetails && ( - #{post.post_number} - )} - - - -
-
- -
- {truncateText(plainText, showDetails ? 300 : 150)} -
+ // copied from TopicPost + const extra = post.extra as Record; + 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 && ( -
-
- - {formatRelativeTime(post.created_at)} -
- {post.like_count && post.like_count > 0 && ( + return ( + +
+
+
+ {avatar && ( + {username} + )}
- ❤️ {post.like_count} + @{displayName} + + {username && username?.toLowerCase() !== displayName.toLowerCase() && ( + ({username}) + )}
- )} - {post.reply_count && post.reply_count > 0 && ( -
- 💬 {post.reply_count} +
+ {post.discourse_id && ( +
+
)}
- )} -
+ + {showDetails && ( + <> + {plainText && ( +
+ {truncateText(plainText, showDetails ? 300 : 150)} +
+ )} + {post.created_at && ( +
+ {formatRelativeTime(post.created_at)} +
+ )} + + )} +
+ ); }; diff --git a/web/src/components/workshop/cards/TopicCard.tsx b/web/src/components/workshop/cards/TopicCard.tsx index d3050d8..b915869 100644 --- a/web/src/components/workshop/cards/TopicCard.tsx +++ b/web/src/components/workshop/cards/TopicCard.tsx @@ -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'; @@ -10,58 +14,65 @@ interface TopicCardProps { showDetails?: boolean; } -export const TopicCard: React.FC = ({ topic, showDetails = true }) => ( -
-
-
-

- {topic.title} -

- {showDetails && ( -
-
- - {topic.posts_count} posts +export const TopicCard: FC = ({ topic, showDetails = true }) => { + const extra = (topic.extra || {}) as Record; + + const tags = [ + ...decodeCategory(extra?.['category_id'] as number), + ...(extra?.['tags'] as string[]), + ]; + + return ( + +
+
+ {topic.title && ( +

+ {topic.title} +

+ )} + {topic.discourse_id && ( +
+
-
- - {formatRelativeTime(topic.created_at)} + )} +
+ + {tags && ( + + {tags?.map((tag) => )} + + )} + + {showDetails && ( +
+
+ {topic.view_count && ( + + + {formatCompact(topic.view_count)} + + )} + {topic.post_count && ( + + + {topic.post_count} + + )}
- {topic.views && ( -
- {topic.views} views -
- )} + + {topic.created_at && {formatRelativeTime(topic.created_at)}}
)}
- - - -
- {topic.participants && topic.participants.length > 0 && ( -
-
- - Participants: -
- {topic.participants.slice(0, 3).map((participant) => ( - - @{participant.username} - - ))} - {topic.participants.length > 3 && ( - - +{topic.participants.length - 3} more - - )} -
-
-
- )} -
-); + + ); +}; diff --git a/web/src/components/workshop/components/SearchResults.tsx b/web/src/components/workshop/components/SearchResults.tsx index e8a834e..77b12df 100644 --- a/web/src/components/workshop/components/SearchResults.tsx +++ b/web/src/components/workshop/components/SearchResults.tsx @@ -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[]; @@ -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' }, () => ) + .with({ status: 'error' }, ({ error }) => ( +

Error: Topic not found {error.message}

+ )) + .with( + { status: 'success' }, + ({ data: topic }) => topic && + ) + .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' }, () => ) + .with({ status: 'error' }, ({ error }) => ( +

Error: Post not found {error.message}

+ )) + .with({ status: 'success' }, ({ data: postData }) => { + const post = postData?.posts.find((p) => p.post_number === entity.post_number); + + if (post) return ; + }) + .exhaustive(); }; -export const SearchResults: React.FC = ({ data, toolName }) => { - let topics: TopicSummary[] = []; - let posts: Post[] = []; - +export const SearchResults: FC = ({ 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; @@ -132,7 +129,7 @@ export const SearchResults: React.FC = ({ data, toolName }) )} > {topicsToShow.map((topic) => ( - + ))}
)} @@ -167,9 +164,9 @@ export const SearchResults: React.FC = ({ data, toolName }) hasManyPosts ? 'max-h-80 overflow-y-auto' : '' )} > - {postsToShow.map((post) => ( - - ))} + {postsToShow.map( + (post) => post && + )}
)}
diff --git a/web/src/components/workshop/types.ts b/web/src/components/workshop/types.ts index 0e1d6eb..a6c5f97 100644 --- a/web/src/components/workshop/types.ts +++ b/web/src/components/workshop/types.ts @@ -1,27 +1,30 @@ // Type definitions for the forum API responses export interface TopicSummary { - id: number; + discourse_id: string; + topic_id: number; title: string; - posts_count: number; + post_count: number; created_at: string; - last_posted_at: string; - views: number; + last_post_at?: string; + view_count: number; like_count: number; + extra: string; participants?: Array<{ id: number; username: string; avatar_template?: string }>; } export interface Post { - id: number; + discourse_id: string; topic_id: number; + post_id: number; post_number: number; - raw?: string; - cooked: string; - created_at: string; - username: string; + created_at?: string; name?: string; avatar_template?: string; - like_count?: number; - reply_count?: number; + user_id?: number; + extra?: unknown; + updated_at?: string; + cooked?: string; + post_url?: string; } export interface SearchEntity { @@ -36,6 +39,7 @@ export interface SearchEntity { pm_issue: number | null; cooked: string | null; entity_id: string; + discourse_id: string; } export interface SearchResult {