diff --git a/src/icons/optimized/persons.svg b/src/icons/optimized/persons.svg new file mode 100644 index 00000000000..f088835a679 --- /dev/null +++ b/src/icons/optimized/persons.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/svg/persons.svg b/src/icons/svg/persons.svg new file mode 100644 index 00000000000..29dce2d315e --- /dev/null +++ b/src/icons/svg/persons.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/lib/components/ui/icon/sprite/sprite.svelte b/src/lib/components/ui/icon/sprite/sprite.svelte index 269fd845784..005e7398340 100644 --- a/src/lib/components/ui/icon/sprite/sprite.svelte +++ b/src/lib/components/ui/icon/sprite/sprite.svelte @@ -105,14 +105,6 @@ fill-rule="evenodd" > - - - + + + + + + = { + '@context': 'https://schema.org', + '@type': 'ProfilePage', + url: canonicalUrl, + mainEntity: { + '@type': 'Person', + name: author.display_name, + alternateName: author.username, + ...(author.avatar ? { image: author.avatar } : {}), + ...(author.bio ? { description: author.bio } : {}), + interactionStatistic: [ + { + '@type': 'InteractionCounter', + interactionType: 'https://schema.org/WriteAction', + userInteractionCount: author.thread_count + author.reply_count + } + ] + } + }; + + return escapeJsonLtForHtmlScript(JSON.stringify(schema)); +} diff --git a/src/routes/docs/references/[version]/[platform]/[service]/+page.svelte b/src/routes/docs/references/[version]/[platform]/[service]/+page.svelte index ab95bc4b809..79970555114 100644 --- a/src/routes/docs/references/[version]/[platform]/[service]/+page.svelte +++ b/src/routes/docs/references/[version]/[platform]/[service]/+page.svelte @@ -175,6 +175,10 @@ }); } + function formatGroup(group: string) { + return group.replace(/([a-z])([A-Z])/g, '$1 $2'); + } + function groupMethodsByGroup(methods: SDKMethod[]) { return methods.reduce>((acc, method) => { const groupKey = method.group || ''; @@ -387,7 +391,7 @@
  • {#if group !== ''}
    - {group} + {formatGroup(group)}
    {/if}
      diff --git a/src/routes/threads/ThreadCard.svelte b/src/routes/threads/ThreadCard.svelte index 7b98ae3fd7a..5605117d40c 100644 --- a/src/routes/threads/ThreadCard.svelte +++ b/src/routes/threads/ThreadCard.svelte @@ -7,6 +7,17 @@ export let query: string; $: highlightTerms = query?.split(' ') ?? []; + $: isResolved = thread.is_resolved || /\[(solved|resolved|closed|fixed)\]/i.test(thread.title); + + function timeAgo(dateStr: string): string { + const diff = (Date.now() - new Date(dateStr).getTime()) / 1000; + const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); + if (diff < 3600) return formatter.format(-Math.floor(diff / 60), 'minute'); + if (diff < 86400) return formatter.format(-Math.floor(diff / 3600), 'hour'); + if (diff < 2592000) return formatter.format(-Math.floor(diff / 86400), 'day'); + if (diff < 31536000) return formatter.format(-Math.floor(diff / 2592000), 'month'); + return formatter.format(-Math.floor(diff / 31536000), 'year'); + } {#key highlightTerms} @@ -30,6 +41,14 @@
        + {#if isResolved} +
      • +
        + + Resolved +
        +
      • + {/if} {#each thread.tags ?? [] as tag, index (tag + index)}
      • {tag}
        @@ -37,12 +56,17 @@ {/each}
      -
      - - {thread.message_count} +
      +
      + + {thread.message_count} +
      + {#if thread.last_activity} + {timeAgo(thread.last_activity)} + {/if}
      @@ -54,6 +78,11 @@ overflow: hidden; } + .tag-resolved { + color: #22c55e; + border-color: rgba(34, 197, 94, 0.3); + } + .thread { position: relative; max-width: 100%; diff --git a/src/routes/threads/[id]/+page.svelte b/src/routes/threads/[id]/+page.svelte index af2affe3f8b..9bb82f25d2a 100644 --- a/src/routes/threads/[id]/+page.svelte +++ b/src/routes/threads/[id]/+page.svelte @@ -13,6 +13,9 @@ let { data } = $props(); const title = $derived(data.title + ' - Threads' + TITLE_SUFFIX); + const isResolved = $derived( + data.is_resolved || /\[(solved|resolved|closed|fixed)\]/i.test(data.title) + ); const description = DEFAULT_DESCRIPTION; const discordLink = $derived( `https://discord.com/channels/564160730845151244/${data.discord_id}` @@ -55,6 +58,52 @@ {data.vote_count} + {#if isResolved} +
    • + + Resolved +
    • + {/if} + {#if data.participant_count} +
    • + + {data.participant_count} +
    • + {/if} {#each data.tags ?? [] as tag}
    • {tag} diff --git a/src/routes/threads/[id]/MessageCard.svelte b/src/routes/threads/[id]/MessageCard.svelte index 508953bbf6a..94c96187c77 100644 --- a/src/routes/threads/[id]/MessageCard.svelte +++ b/src/routes/threads/[id]/MessageCard.svelte @@ -29,7 +29,14 @@
      - {message.author} + {#if message.author_id} + {message.author} + {:else} + {message.author} + {/if}
    • {formatTimestamp(message.timestamp)} @@ -45,6 +52,12 @@ }} /> + {#if message.reaction_count} +
      + + {message.reaction_count} +
      + {/if} @@ -71,6 +84,15 @@ gap: 0.5rem; } + .reactions { + display: flex; + align-items: center; + gap: 0.25rem; + margin-block-start: 0.75rem; + opacity: 0.7; + font-size: 0.875rem; + } + .author-img { --p-size: 1.5rem; // 24px diff --git a/src/routes/threads/authors/[id]/+page.server.ts b/src/routes/threads/authors/[id]/+page.server.ts new file mode 100644 index 00000000000..977fbc13024 --- /dev/null +++ b/src/routes/threads/authors/[id]/+page.server.ts @@ -0,0 +1,28 @@ +import { error } from '@sveltejs/kit'; +import { DEFAULT_HOST } from '$lib/utils/metadata'; +import { getAuthor, getAuthorThreads } from '../../helpers.js'; +import type { DiscordThread } from '../../types.js'; + +export const load = async ({ params }) => { + let author; + try { + author = await getAuthor(params.id); + } catch { + error(404, 'Author not found'); + } + + let threads: DiscordThread[] = []; + let total = 0; + try { + ({ threads, total } = await getAuthorThreads(params.id)); + } catch (e) { + console.error('Failed to fetch author threads:', e); + } + + return { + author, + threads, + total, + canonicalUrl: `${DEFAULT_HOST}/threads/authors/${params.id}` + }; +}; diff --git a/src/routes/threads/authors/[id]/+page.svelte b/src/routes/threads/authors/[id]/+page.svelte new file mode 100644 index 00000000000..22b07915f44 --- /dev/null +++ b/src/routes/threads/authors/[id]/+page.svelte @@ -0,0 +1,159 @@ + + + + {title} + + + + + + + {@html getInlinedScriptTag(structuredDataJsonLd)} + + +
      +
      +
      + + + Back + +
      + {#if data.author.avatar} + {data.author.display_name} + {/if} +
      +

      + {data.author.display_name} +

      +

      @{data.author.username}

      + {#if data.author.roles?.length} +
        + {#each data.author.roles as role} +
      • {role}
      • + {/each} +
      + {/if} +
        +
      • + {data.author.thread_count} + Threads +
      • +
      • + {data.author.reply_count} + Replies +
      • +
      +
      +
      +
      + +
      +
      +

      Threads

      + {#if data.total > data.threads.length} + Showing {data.threads.length} of {data.total} + {/if} +
      +
      + {#each data.threads as thread (thread.$id)} + + {:else} +

      No threads yet.

      + {/each} +
      +
      +
      + + +
      + + +
      +
      + + diff --git a/src/routes/threads/helpers.ts b/src/routes/threads/helpers.ts index 0ba6f6f342f..8fb00ca06b7 100644 --- a/src/routes/threads/helpers.ts +++ b/src/routes/threads/helpers.ts @@ -5,7 +5,24 @@ import { } from '$env/static/public'; import { databases } from '$lib/appwrite'; import { Query } from '@appwrite.io/console'; -import type { DiscordMessage, DiscordThread } from './types'; +import type { DiscordAuthor, DiscordMessage, DiscordThread } from './types'; + +export async function getAuthor(discordId: string) { + return (await databases.getDocument( + PUBLIC_APPWRITE_DB_MAIN_ID, + 'authors', + discordId + )) as unknown as DiscordAuthor; +} + +export async function getAuthorThreads(authorId: string) { + const data = await databases.listDocuments( + PUBLIC_APPWRITE_DB_MAIN_ID, + PUBLIC_APPWRITE_COL_THREADS_ID, + [Query.equal('author_id', authorId), Query.orderDesc('$createdAt'), Query.limit(25)] + ); + return { threads: data.documents as unknown as DiscordThread[], total: data.total }; +} type Ranked = { data: T; diff --git a/src/routes/threads/types.ts b/src/routes/threads/types.ts index 6714c048a79..0c4c7e01828 100644 --- a/src/routes/threads/types.ts +++ b/src/routes/threads/types.ts @@ -11,16 +11,19 @@ export type MockThread = { export interface DiscordMessage extends Pick { threadId: string; author: string; + author_id?: string; author_avatar: string; message: string; role?: string; - /* `UTC` timestamp */ timestamp: string; + reaction_count?: number; + is_edited?: boolean; } export interface DiscordThread extends Models.Document { discord_id: string; author: string; + author_id?: string; tags?: string[]; author_avatar: string; seo_description?: string; @@ -30,6 +33,21 @@ export interface DiscordThread extends Models.Document { tldr: string; vote_count: number; message_count: number; + participant_count?: number; + last_activity?: string; + is_resolved?: boolean; +} + +export interface DiscordAuthor extends Models.Document { + discord_id: string; + username: string; + display_name: string; + avatar?: string; + roles?: string[]; + joined_at?: string; + thread_count: number; + reply_count: number; + bio?: string; } export type MockMessage = {