diff --git a/src/components/PostPreview.tsx b/src/components/PostPreview.tsx index 954c162..629ab8d 100644 --- a/src/components/PostPreview.tsx +++ b/src/components/PostPreview.tsx @@ -11,7 +11,7 @@ import { BskyPost } from '@/utils/recordFetcher'; import { getEmbedImages, type EmbedDisplayImage } from '@/utils/postEmbeds'; import { formatCount } from '@/utils/ufos/format'; import { sanitizeFacetLink, sanitizeDid, sanitizeHashtag, sanitizeUrl, sanitizeHandle } from '@/utils/sanitize'; -import { User, MessageSquare, Repeat2, Heart, Quote, Play, CornerDownRight, Telescope } from 'lucide-react'; +import { User, MessageSquare, Repeat2, Heart, Quote, Play, CornerDownRight, Telescope, Globe } from 'lucide-react'; import { explorePathFromAtUri } from '@/utils/atproto/urls'; type PostPreviewProps = { @@ -1296,6 +1296,39 @@ export default function PostPreview({ post, parent, hideExplorerCtas }: PostPrev ); })()} + + {/* Inverse of the universal link page's "View record data in the + Explorer →" CTA: inside the explorer, a quiet link back out to the + shareable universal link page for this post. Mirrors the explorer + profile card's "View the universal link page →". */} + {hideExplorerCtas && (() => { + const postUrl = buildPostUrl(post.uri, author); + if (!postUrl) return null; + return ( +
+ + + View the universal link page → + +
+ ); + })()} ); diff --git a/src/components/account/tabs/GeneralTab.tsx b/src/components/account/tabs/GeneralTab.tsx index 3386796..4c37a2b 100644 --- a/src/components/account/tabs/GeneralTab.tsx +++ b/src/components/account/tabs/GeneralTab.tsx @@ -114,6 +114,14 @@ function AppearanceCard() { checked={prefs.minimalProfile} onChange={(next) => update((p) => ({ ...p, minimalProfile: next }))} /> + + update((p) => ({ ...p, minimalPostPreview: next }))} + /> ); } diff --git a/src/components/explore/LinkButton.tsx b/src/components/explore/LinkButton.tsx new file mode 100644 index 0000000..697a4fe --- /dev/null +++ b/src/components/explore/LinkButton.tsx @@ -0,0 +1,79 @@ +'use client'; + +import Link from 'next/link'; +import { ExternalLink } from 'lucide-react'; + +type Props = { + href: string; + label: string; + /** External target (opens in a new tab) vs internal Next navigation. */ + external?: boolean; + title?: string; +}; + +/** + * An outbound-link button styled to match CopyButton (variant="subtle", + * compact) so the explorer's copy row reads as one consistent set of + * pill buttons. The external-link icon signals "navigates away", paired + * with the copy buttons' copy icon — which is why the row no longer needs + * a leading "Copy" label to explain itself. + */ +export default function LinkButton({ href, label, external, title }: Props) { + const style: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: '0.4rem', + padding: '0.25rem 0.5rem', + background: 'transparent', + border: '1px solid var(--border-subtle)', + color: 'var(--text-secondary)', + fontFamily: 'var(--font-mono)', + fontSize: '0.75rem', + textDecoration: 'none', + cursor: 'pointer', + transition: 'color 0.2s ease, border-color 0.2s ease', + }; + const onMouseEnter = (e: React.MouseEvent) => { + e.currentTarget.style.color = 'var(--text-primary)'; + e.currentTarget.style.borderColor = 'var(--border-medium)'; + }; + const onMouseLeave = (e: React.MouseEvent) => { + e.currentTarget.style.color = 'var(--text-secondary)'; + e.currentTarget.style.borderColor = 'var(--border-subtle)'; + }; + + const inner = ( + <> + + {label} + + ); + + if (external) { + return ( + + {inner} + + ); + } + + return ( + + {inner} + + ); +} diff --git a/src/components/explore/RecordExplorer.tsx b/src/components/explore/RecordExplorer.tsx index 526530b..32fde54 100644 --- a/src/components/explore/RecordExplorer.tsx +++ b/src/components/explore/RecordExplorer.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { ExternalLink, FilePenLine, SearchX, TriangleAlert } from 'lucide-react'; +import { FilePenLine, SearchX, TriangleAlert } from 'lucide-react'; import { getRecord, getRecordUrl, type AtRecord } from '@/utils/atproto/pdsClient'; import { resolveIdentifier, type IdentityBundle } from '@/utils/atproto/identity'; import { encodeRepo } from '@/utils/atproto/urls'; @@ -18,9 +18,10 @@ import BacklinksTab from './tabs/BacklinksTab'; import LexiconUsageCard from './lexicons/LexiconUsageCard'; import RecordEditor from './RecordEditor'; import SignInPanel from './SignInPanel'; -import UniversalLinkChip from './UniversalLinkChip'; +import LinkButton from './LinkButton'; import NotFoundPanel from '@/components/NotFoundPanel'; import { useAtprotoSession } from '@/components/AtprotoSessionProvider'; +import { usePreferences } from '@/components/PreferencesProvider'; type Props = { repo: string; @@ -36,6 +37,7 @@ export default function RecordExplorer({ repo, collection, rkey }: Props) { const [recordError, setRecordError] = useState(null); const [editing, setEditing] = useState(false); const { agent, did: signedInDid, session } = useAtprotoSession(); + const { prefs, update: updatePrefs, loading: prefsLoading } = usePreferences(); const decodedRkey = useMemo(() => { try { @@ -103,6 +105,12 @@ export default function RecordExplorer({ repo, collection, rkey }: Props) { const atUri = `at://${identity.did}/${collection}/${decodedRkey}`; const repoSeg = encodeRepo(identity.handle || identity.did); const canEdit = Boolean(agent && signedInDid && signedInDid === identity.did); + // "Minimal post view" preference — collapse the rich Bluesky post card to + // just the structured record. Only applies to posts, and only once prefs + // have settled (defaults render the full preview, matching first paint). + const isPost = collection === 'app.bsky.feed.post'; + const showPostViewSwitch = !prefsLoading && isPost; + const minimalPost = showPostViewSwitch && prefs.minimalPostPreview; // Universal link uses the canonical `/profile/` path; bare-form // `///` still works as a fallback route but // shareable copies should point at the canonical one. @@ -193,9 +201,21 @@ export default function RecordExplorer({ repo, collection, rkey }: Props) { rkey={decodedRkey} record={record} footerActions={editsInPreviewFooter ? editButton : null} + minimalPost={minimalPost} /> )} + {/* Inline switch between the rich post card and the minimal + record-only view — mirrors the repo page's profile switch so + users can flip without round-tripping through settings. */} + {!editing && showPostViewSwitch && ( + + updatePrefs((p) => ({ ...p, minimalPostPreview: !p.minimalPostPreview })) + } + /> + )} )} @@ -296,6 +316,47 @@ export default function RecordExplorer({ repo, collection, rkey }: Props) { ); } +/** + * Quiet text toggle between the rich post card and the minimal + * record-only view, mirroring the repo page's ProfileViewSwitch. Flips the + * persisted `minimalPostPreview` pref so the choice sticks and syncs. + */ +function PostViewSwitch({ + minimal, + onToggle, +}: { + minimal: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + /** * Single-row of compact copy buttons for the values a record-page * visitor might want to grab. Replaces the older RecordMeta + per-cell @@ -338,12 +399,6 @@ function CopyRow({ background: 'var(--bg-secondary)', }} > - - Copy - @@ -351,28 +406,13 @@ function CopyRow({ {recordJson && ( )} - - + - - View on PDS - + /> ); } diff --git a/src/components/explore/RichRecordPreview.tsx b/src/components/explore/RichRecordPreview.tsx index 6b29ed8..07512a4 100644 --- a/src/components/explore/RichRecordPreview.tsx +++ b/src/components/explore/RichRecordPreview.tsx @@ -34,6 +34,12 @@ type Props = { * and the explorer renders the action above them instead. */ footerActions?: import('react').ReactNode; + /** + * When true (posts only), collapse the rich Bluesky post card and show + * just the record's structured data — the user's "minimal post view" + * preference, mirroring the minimal profile view on repo pages. + */ + minimalPost?: boolean; }; /** @@ -65,6 +71,7 @@ export default function RichRecordPreview({ rkey, record, footerActions, + minimalPost = false, }: Props) { const marginType = getMarginLexiconType(collection); const isPost = collection === 'app.bsky.feed.post'; @@ -72,7 +79,8 @@ export default function RichRecordPreview({ const [postThread, setPostThread] = useState(null); useEffect(() => { - if (!isPost) { + // Minimal view doesn't render the post card, so skip the AppView fetch. + if (!isPost || minimalPost) { setPostThread(null); return undefined; } @@ -84,7 +92,7 @@ export default function RichRecordPreview({ return () => { cancelled = true; }; - }, [isPost, did, collection, rkey]); + }, [isPost, minimalPost, did, collection, rkey]); const recordForLegacy: GenericRecord | null = record ? { @@ -101,6 +109,19 @@ export default function RichRecordPreview({ // (text, facets, langs, reply refs, embed) is only reachable in the // collapsed "Raw record JSON" disclosure at the bottom of the page. if (isPost) { + // Minimal view: drop the rich post card, keep just the structured + // record data (the same RecordPreview shown beneath the full preview). + if (minimalPost) { + return recordForLegacy ? ( + + ) : null; + } const post = postThread?.thread[0]?.value.post; if (!post) return null; return ( diff --git a/src/components/explore/UniversalLinkChip.tsx b/src/components/explore/UniversalLinkChip.tsx deleted file mode 100644 index 6a70fae..0000000 --- a/src/components/explore/UniversalLinkChip.tsx +++ /dev/null @@ -1,58 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { Globe } from 'lucide-react'; - -type Props = { - /** - * Internal path to the universal link page, e.g. - * `/profile///` or `/profile/`. - */ - href: string; - /** Override the link text. Defaults to "Universal link page". */ - label?: string; -}; - -/** - * The inverse of the "View full record in Explorer →" CTA on universal link - * pages: a navigation link from inside the explorer back out to the - * shareable, client-agnostic universal link page for the same record/profile. - * - * Styled to match the "View on PDS" anchor in the explorer's copy row so the - * two outbound affordances read as a pair. - */ -export default function UniversalLinkChip({ - href, - label = 'Universal link page', -}: Props) { - return ( - { - e.currentTarget.style.color = 'var(--text-primary)'; - e.currentTarget.style.borderColor = 'var(--text-accent)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.color = 'var(--text-secondary)'; - e.currentTarget.style.borderColor = 'var(--border-medium)'; - }} - > - - {label} - - ); -} diff --git a/src/utils/preferences.ts b/src/utils/preferences.ts index ab0d26d..81707e9 100644 --- a/src/utils/preferences.ts +++ b/src/utils/preferences.ts @@ -125,6 +125,12 @@ export type Preferences = { * offers an inline switch to flip back to the full profile. */ minimalProfile: boolean; + /** + * Show a minimal post on explorer record pages — just the record's + * structured data — instead of the rich Bluesky post card. The page also + * offers an inline switch to flip back to the full preview. + */ + minimalPostPreview: boolean; /** * ISO timestamp of last local change. Used to break ties when local and * PDS prefs both exist on sign-in. @@ -148,6 +154,7 @@ export const DEFAULT_PREFERENCES: Preferences = { hideRepoGlance: false, repoGlanceCollapsedByDefault: false, minimalProfile: false, + minimalPostPreview: false, updatedAt: new Date(0).toISOString(), }; @@ -246,6 +253,8 @@ export function mergeWithDefaults(input: Partial | null | undefined : false; const minimalProfile = typeof input.minimalProfile === 'boolean' ? input.minimalProfile : false; + const minimalPostPreview = + typeof input.minimalPostPreview === 'boolean' ? input.minimalPostPreview : false; return { waypointGroups, hiddenWaypoints, @@ -259,6 +268,7 @@ export function mergeWithDefaults(input: Partial | null | undefined hideRepoGlance, repoGlanceCollapsedByDefault, minimalProfile, + minimalPostPreview, updatedAt: typeof input.updatedAt === 'string' ? input.updatedAt : new Date(0).toISOString(), }; @@ -327,6 +337,7 @@ export function preferencesAreEqual(a: Preferences, b: Preferences): boolean { a.hideRepoGlance === b.hideRepoGlance && a.repoGlanceCollapsedByDefault === b.repoGlanceCollapsedByDefault && a.minimalProfile === b.minimalProfile && + a.minimalPostPreview === b.minimalPostPreview && JSON.stringify(a.waypointGroups) === JSON.stringify(b.waypointGroups) && JSON.stringify(a.customWaypoints) === JSON.stringify(b.customWaypoints) && JSON.stringify(a.pinnedLexicons) === JSON.stringify(b.pinnedLexicons) &&