diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4c453e7..510c318 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -81,7 +81,7 @@ export default function RootLayout({ sameAs: [ 'https://protocol.ai', 'https://bsky.app/profile/plrd.org', - 'https://github.com/protocol/plrd', + siteConfig.githubUrl, ], } diff --git a/src/components/FeedbackButton.tsx b/src/components/FeedbackButton.tsx new file mode 100644 index 0000000..dec5296 --- /dev/null +++ b/src/components/FeedbackButton.tsx @@ -0,0 +1,163 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import { usePathname } from 'next/navigation' +import { siteConfig } from '@/lib/site-config' + +type SelectionContext = { + text: string + heading?: string +} + +const MAX_SELECTED_TEXT = 1400 +const MAX_TITLE_TEXT = 110 + +function normalizeWhitespace(value: string) { + return value.replace(/\s+/g, ' ').trim() +} + +function truncate(value: string, maxLength: number) { + if (value.length <= maxLength) return value + return `${value.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…` +} + +function getElementForNode(node: Node | null) { + if (!node) return null + return node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement +} + +function findNearestHeading(element: Element | null) { + if (!element) return '' + + const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')) + + for (const heading of headings.reverse()) { + if (heading === element || heading.contains(element)) { + return normalizeWhitespace(heading.textContent || '') + } + + if (heading.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING) { + return normalizeWhitespace(heading.textContent || '') + } + } + + return '' +} + +function getHashHeading() { + if (!window.location.hash) return '' + + const id = decodeURIComponent(window.location.hash.slice(1)) + const target = document.getElementById(id) + return normalizeWhitespace(target?.textContent || id.replace(/[-_]/g, ' ')) +} + +function getSelectionContext(): SelectionContext { + const selection = window.getSelection() + const text = normalizeWhitespace(selection?.toString() || '') + + if (!selection || selection.rangeCount === 0 || !text) { + return { text: '' } + } + + const range = selection.getRangeAt(0) + const element = getElementForNode(range.startContainer) + const heading = findNearestHeading(element) || getHashHeading() + + return { + text: truncate(text, MAX_SELECTED_TEXT), + heading: heading ? truncate(heading, 160) : undefined, + } +} + +function buildIssueUrl(selection: SelectionContext) { + const url = new URL(window.location.href) + const path = `${url.pathname}${url.search}${url.hash}` || '/' + const pageTitle = normalizeWhitespace(document.title.replace(/\s*\|\s*PL R&D\s*$/, '')) || 'PL R&D' + const section = selection.heading || getHashHeading() + const pageTag = `page:${path}` + const contentTag = selection.text + ? `selection:${truncate(selection.text, 72)}` + : section + ? `section:${truncate(section, 72)}` + : 'content:general' + + const title = truncate(`Feedback on ${path === '/' ? 'home page' : path}`, MAX_TITLE_TEXT) + const referencedContent = selection.text + ? [ + section ? `Section/heading: ${section}` : undefined, + '', + `> ${selection.text}`, + ].filter(Boolean).join('\n') + : '_No text was selected. To tag exact content next time, highlight text before clicking “Give feedback.”_' + + const body = [ + '## Feedback', + '', + '', + '## Page context', + `- Page title: ${pageTitle}`, + `- URL: ${url.toString()}`, + `- Tags: \`${pageTag}\` \`${contentTag}\``, + '', + '## Referenced content', + referencedContent, + ].join('\n') + + const params = new URLSearchParams({ title, body }) + return `${siteConfig.githubUrl}/issues/new?${params.toString()}` +} + +export default function FeedbackButton() { + const pathname = usePathname() + const [hasSelection, setHasSelection] = useState(false) + const latestSelection = useRef({ text: '' }) + + const updateSelection = useCallback(() => { + const nextSelection = getSelectionContext() + latestSelection.current = nextSelection + setHasSelection(Boolean(nextSelection.text)) + }, []) + + useEffect(() => { + latestSelection.current = { text: '' } + setHasSelection(false) + }, [pathname]) + + useEffect(() => { + document.addEventListener('selectionchange', updateSelection) + window.addEventListener('hashchange', updateSelection) + + return () => { + document.removeEventListener('selectionchange', updateSelection) + window.removeEventListener('hashchange', updateSelection) + } + }, [updateSelection]) + + function openFeedbackIssue() { + const currentSelection = getSelectionContext() + const selection = currentSelection.text ? currentSelection : latestSelection.current + const issueUrl = buildIssueUrl(selection) + window.open(issueUrl, '_blank', 'noopener,noreferrer') + } + + return ( + + ) +} diff --git a/src/components/SiteFooter.tsx b/src/components/SiteFooter.tsx index cb75450..f8c3189 100644 --- a/src/components/SiteFooter.tsx +++ b/src/components/SiteFooter.tsx @@ -1,6 +1,7 @@ import Link from 'next/link' import CookieSettingsLink from './CookieSettingsLink' import { COOKIE_CONSENT_ENABLED } from '@/lib/cookie-consent' +import { siteConfig } from '@/lib/site-config' export default function SiteFooter() { return ( @@ -58,7 +59,7 @@ export default function SiteFooter() {
Connect
    -
  • GitHub
  • +
  • GitHub
  • Bluesky
  • X / Twitter
  • RSS
  • @@ -85,7 +86,7 @@ export default function SiteFooter() { © Protocol Labs · Content licensed CC-BY 4.0 · A research initiative of Protocol Labs

    - GitHub + GitHub Bluesky X RSS diff --git a/src/components/SiteShell.tsx b/src/components/SiteShell.tsx index 93a427a..714be87 100644 --- a/src/components/SiteShell.tsx +++ b/src/components/SiteShell.tsx @@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation' import SiteHeader from './SiteHeader' import SiteFooter from './SiteFooter' import OffCanvasNav from './OffCanvasNav' +import FeedbackButton from './FeedbackButton' // Routes that render full-screen (no footer, no bottom padding) const FULLSCREEN_PATTERNS = [ @@ -41,6 +42,7 @@ export default function SiteShell({ children }: { children: React.ReactNode }) {
    {!isFullscreen && } + ) } diff --git a/src/lib/site-config.ts b/src/lib/site-config.ts index bfc97e5..d0b4a46 100644 --- a/src/lib/site-config.ts +++ b/src/lib/site-config.ts @@ -7,6 +7,7 @@ export const siteConfig = { twitterUser: '@protocollabs_rd', avatar: '/images/pl_research_card.png', logo: '/images/pl_research_logo.svg', + githubUrl: 'https://github.com/protocol/plrd.org', copyright: '© Protocol Labs, Inc. Except as noted, content licensed CC-BY 3.0.', }