diff --git a/src/components/account/SettingsShell.tsx b/src/components/account/SettingsShell.tsx index d19030b..55f4080 100644 --- a/src/components/account/SettingsShell.tsx +++ b/src/components/account/SettingsShell.tsx @@ -5,15 +5,17 @@ import { CloudOff } from 'lucide-react'; import { useAtprotoSession } from '@/components/AtprotoSessionProvider'; import AccountTab from './tabs/AccountTab'; import GeneralTab from './tabs/GeneralTab'; +import SectionsTab from './tabs/SectionsTab'; import WaypointsTab from './tabs/WaypointsTab'; import CustomTab from './tabs/CustomTab'; import AboutTab from './tabs/AboutTab'; -type TabId = 'account' | 'general' | 'waypoints' | 'custom' | 'about'; +type TabId = 'account' | 'general' | 'sections' | 'waypoints' | 'custom' | 'about'; const TABS: { id: TabId; label: string }[] = [ { id: 'account', label: 'Account' }, { id: 'general', label: 'General' }, + { id: 'sections', label: 'Sections' }, { id: 'waypoints', label: 'Waypoints' }, { id: 'custom', label: 'Custom' }, { id: 'about', label: 'About' }, @@ -22,6 +24,7 @@ const TABS: { id: TabId; label: string }[] = [ const HASH_TO_TAB: Record = { account: 'account', general: 'general', + sections: 'sections', waypoints: 'waypoints', custom: 'custom', about: 'about', @@ -88,6 +91,7 @@ export default function SettingsShell() {
{tab === 'account' && } {tab === 'general' && } + {tab === 'sections' && } {tab === 'waypoints' && } {tab === 'custom' && } {tab === 'about' && } diff --git a/src/components/account/tabs/GeneralTab.tsx b/src/components/account/tabs/GeneralTab.tsx index f0255df..09fbf37 100644 --- a/src/components/account/tabs/GeneralTab.tsx +++ b/src/components/account/tabs/GeneralTab.tsx @@ -63,81 +63,22 @@ export default function GeneralTab() { } function AppearanceCard() { - const { prefs, update } = usePreferences(); - return (

Appearance

- Switch between dark and light themes, tune the text size, dial in - accessibility options, and choose how much of each explorer repo - page you see. The page chrome and accent palette flip together; - explorer panels, embeds, and the home strip all follow. + Switch between dark and light themes, tune the text size, and dial + in accessibility options. The page chrome and accent palette flip + together; explorer panels, embeds, and the home strip all follow. + Choose which explorer sections appear — and in what order — under + the Sections tab.

- - update((p) => ({ ...p, hideRelationshipBar: next }))} - /> - - update((p) => ({ ...p, hideRepoGlance: next }))} - /> - - - update((p) => ({ ...p, repoGlanceCollapsedByDefault: next })) - } - /> - - update((p) => ({ ...p, minimalProfile: next }))} - /> - - update((p) => ({ ...p, hideRichPreview: next }))} - /> - - update((p) => ({ ...p, hideRichJsonPreview: next }))} - /> - - update((p) => ({ ...p, showRawRecordJson: next }))} - />
); } diff --git a/src/components/account/tabs/SectionsTab.tsx b/src/components/account/tabs/SectionsTab.tsx new file mode 100644 index 0000000..350d3ac --- /dev/null +++ b/src/components/account/tabs/SectionsTab.tsx @@ -0,0 +1,245 @@ +'use client'; + +/** + * Explore-page layout manager for /account#sections. Lets the user show / + * hide and drag-to-reorder the sections on both explorer page types (record + * pages and repo/profile pages). On each page at least one "data view" must + * stay visible (field table / raw JSON on record pages; profile / identity on + * repo pages) — the toggle of the last visible one is disabled. Reuses the + * same @dnd-kit scaffolding as the waypoint groups manager. + */ + +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + SortableContext, + arrayMove, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { GripVertical } from 'lucide-react'; +import { usePreferences } from '@/components/PreferencesProvider'; +import Toggle from '../Toggle'; +import { + countVisibleGuaranteed, + isGuaranteedDataView, + sectionMetaFor, + type ExplorePage, +} from '@/utils/exploreSections'; +import { resetSections, setSectionHidden, setSections } from '@/utils/preferences'; + +export default function SectionsTab() { + const { prefs, update } = usePreferences(); + + return ( + <> +
+
+

Record pages

+

+ Choose which sections appear on a record / Bluesky-post explorer + page, and drag to reorder them. The rich JSON preview and raw JSON + are the two data views — at least one always stays on so the + record is never blank. +

+
+ +
+ +
+
+

Repo & profile pages

+

+ Choose which sections appear on a repo / profile explorer page, + and drag to reorder. The breadcrumb and the collections tabs stay + fixed. +

+
+ + + update((p) => ({ ...p, repoGlanceCollapsedByDefault: next })) + } + /> +
+ + ); +} + +function SectionList({ page }: { page: ExplorePage }) { + const { prefs, update } = usePreferences(); + const sections = page === 'record' ? prefs.recordSections : prefs.repoSections; + const meta = sectionMetaFor(page); + const visibleGuaranteed = countVisibleGuaranteed(sections, page); + const visibleCount = sections.filter((s) => !s.hidden).length; + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + function onDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id) return; + const ids = sections.map((s) => s.id); + const oldIndex = ids.indexOf(String(active.id)); + const newIndex = ids.indexOf(String(over.id)); + if (oldIndex < 0 || newIndex < 0) return; + update((prev) => setSections(prev, page, arrayMove(sections, oldIndex, newIndex))); + } + + return ( +
+
+

+ {visibleCount} of {sections.length} shown +

+ +
+ + + s.id)} + strategy={verticalListSortingStrategy} + > +
+ {sections.map((s) => { + const m = meta.find((mm) => mm.id === s.id); + if (!m) return null; + const isLastGuaranteed = + isGuaranteedDataView(page, s.id) && !s.hidden && visibleGuaranteed <= 1; + return ( +
+
+
+
+ ); +} + +function SectionRow({ + id, + label, + description, + kind, + hidden, + toggleDisabled, + onToggle, +}: { + id: string; + label: string; + description: string; + kind: 'record-data' | 'helper'; + hidden: boolean; + toggleDisabled: boolean; + onToggle: (nextHidden: boolean) => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id }); + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 2 : undefined, + }; + const switchId = `section-toggle-${id}`; + return ( +
+ + + + + + onToggle(!e.target.checked)} + aria-label={`Show ${label}`} + /> + +
+ ); +} diff --git a/src/components/explore/RecordExplorer.tsx b/src/components/explore/RecordExplorer.tsx index 38f4483..048b5d2 100644 --- a/src/components/explore/RecordExplorer.tsx +++ b/src/components/explore/RecordExplorer.tsx @@ -23,6 +23,12 @@ import LinkButton from './LinkButton'; import NotFoundPanel from '@/components/NotFoundPanel'; import { useAtprotoSession } from '@/components/AtprotoSessionProvider'; import { usePreferences } from '@/components/PreferencesProvider'; +import { + DEFAULT_RECORD_SECTIONS, + sectionHidden, + type RecordSectionId, +} from '@/utils/exploreSections'; +import { setSectionHidden, toggleRecordDataView } from '@/utils/preferences'; type Props = { repo: string; @@ -107,43 +113,30 @@ export default function RecordExplorer({ repo, collection, rkey }: Props) { const repoSeg = encodeRepo(identity.handle || identity.did); const canEdit = Boolean(agent && signedInDid && signedInDid === identity.did); const isPost = collection === 'app.bsky.feed.post'; - - // The record body is up to three independently-toggleable sections, each - // with its own switch directly beneath it: - // 1. rich preview card — the post / margin card (generic records have none) - // 2. rich JSON preview — the structured RecordPreview field table - // 3. raw JSON — the linkified record JSON - // Visibility is gated on prefs having settled so first paint matches the - // defaults (card + field table shown, raw hidden) and doesn't flicker. - const settled = !prefsLoading; const hasRichCard = recordHasRichCard(collection); - const hideCard = settled && prefs.hideRichPreview; - const hideJsonPreview = settled && prefs.hideRichJsonPreview; - const showRawJson = settled && prefs.showRawRecordJson; - // The field table and the raw JSON are the record's two data views; at least - // one must stay visible so the body is never empty. Hiding one forces the - // other on. The card (a rendering, not the data itself) is freely hideable. - const toggleCard = () => - updatePrefs((p) => ({ ...p, hideRichPreview: !p.hideRichPreview })); - const toggleJsonPreview = () => - updatePrefs((p) => - p.hideRichJsonPreview - ? { ...p, hideRichJsonPreview: false } - : { ...p, hideRichJsonPreview: true, showRawRecordJson: true }, - ); - const toggleRawJson = () => - updatePrefs((p) => - p.showRawRecordJson - ? { ...p, showRawRecordJson: false, hideRichJsonPreview: false } - : { ...p, showRawRecordJson: true }, - ); + // The record page renders the user's chosen sections in their chosen order + // (configurable in Settings → Sections). Until prefs settle we use the + // defaults so first paint matches SSR and doesn't flicker. + const settled = !prefsLoading; + const recordSections = settled ? prefs.recordSections : DEFAULT_RECORD_SECTIONS; + const cardHidden = sectionHidden(recordSections, 'richPreview'); + const structuredHiddenRaw = sectionHidden(recordSections, 'structuredJson'); + const rawHidden = sectionHidden(recordSections, 'rawJson'); + // The field table and raw JSON are the two data views; at least one must + // stay visible. The setters maintain this, but guard the render too. + const structuredHidden = structuredHiddenRaw && rawHidden ? false : structuredHiddenRaw; // Universal link uses the canonical `/profile/` path; bare-form // `///` still works as a fallback route but // shareable copies should point at the canonical one. const aturiUniversalPath = `/profile/${identity.handle || identity.did}/${collection}/${encodeURIComponent(decodedRkey)}`; const universalLinkFull = `https://aturi.to${aturiUniversalPath}`; + const pdsRecordUrl = getRecordUrl(identity.pds, { + repo: identity.did, + collection, + rkey: decodedRkey, + }); // Edit affordance is a standalone chip rendered beneath the view sections — // the sections are independently toggleable, so the button can't live inside @@ -170,24 +163,56 @@ export default function RecordExplorer({ repo, collection, rkey }: Props) { ) : null; - return ( -
- - - + const breadcrumb = ( + + + + ); + const copyRowNode = ( + + ); + const signInNode = !session ? ( +
+

+ Sign in with your handle to edit your own records. +

+ +
+ ) : null; + const editChipNode = editButton ? ( +
+ {editButton} +
+ ) : null; - {/* Record body. In read mode this is up to three stacked sections — - rich preview card, structured field table, raw JSON — each with its - own toggle directly beneath it. In edit mode the whole body becomes - the editor, so the user's eye stays put and the page doesn't grow a - separate editor section. */} - {recordError ? ( + // Load error: error panel + the helper sections that still make sense. + if (recordError) { + return ( +
+ {breadcrumb} - ) : editing && canEdit && agent ? ( + + + + {copyRowNode} + {editChipNode && {editChipNode}} + {signInNode && {signInNode}} +
+ ); + } + + // Edit mode: the editor takes the whole body; keep the copy row handy. + if (editing && canEdit && agent) { + return ( +
+ {breadcrumb} setEditing(false)} /> - ) : !record ? ( -

Loading record…

- ) : ( - <> - {/* Section 1 — rich preview card (posts, margin lexicons). Generic - records skip it; their rich view is the field table below. */} - {hasRichCard && ( -
- {!hideCard && ( - - - - )} - -
- )} + {copyRowNode} +
+ ); + } - {/* Section 2 — rich JSON preview (structured field table). */} -
- {!hideJsonPreview && ( - - - - )} - -
+ if (!record) { + return ( +
+ {breadcrumb} +

Loading record…

+
+ ); + } - {/* Section 3 — raw JSON. */} -
- {showRawJson && ( - - - - )} -