👁️
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

basic primer

+365 -6
+120
src/components/deck/PrimerSection.tsx
··· 1 + import { useState } from "react"; 2 + import { RichTextEditor } from "@/components/richtext/RichTextEditor"; 3 + import { type ParseResult, RichText } from "@/lib/richtext"; 4 + 5 + interface PrimerSectionProps { 6 + markdown: string; 7 + setMarkdown: (value: string) => void; 8 + parsed: ParseResult; 9 + isDirty?: boolean; 10 + isPending?: boolean; 11 + isSaving?: boolean; 12 + readOnly?: boolean; 13 + } 14 + 15 + const COLLAPSED_LINES = 4; 16 + const LINE_HEIGHT = 1.5; 17 + 18 + export function PrimerSection({ 19 + markdown, 20 + setMarkdown, 21 + parsed, 22 + isDirty, 23 + isPending, 24 + isSaving, 25 + readOnly = false, 26 + }: PrimerSectionProps) { 27 + const [isEditing, setIsEditing] = useState(false); 28 + const [isExpanded, setIsExpanded] = useState(false); 29 + 30 + const hasContent = parsed.text.trim().length > 0; 31 + const lineCount = parsed.text.split("\n").length; 32 + const needsTruncation = lineCount > COLLAPSED_LINES; 33 + 34 + if (isEditing && !readOnly) { 35 + return ( 36 + <div className="space-y-3"> 37 + <div className="flex justify-end"> 38 + <button 39 + type="button" 40 + onClick={() => setIsEditing(false)} 41 + className="text-sm text-blue-600 dark:text-blue-400 hover:underline" 42 + > 43 + Done 44 + </button> 45 + </div> 46 + <RichTextEditor 47 + markdown={markdown} 48 + setMarkdown={setMarkdown} 49 + parsed={parsed} 50 + isDirty={isDirty} 51 + isPending={isPending} 52 + isSaving={isSaving} 53 + placeholder="Write about your deck's strategy, key combos, card choices..." 54 + /> 55 + </div> 56 + ); 57 + } 58 + 59 + if (!hasContent && readOnly) { 60 + return null; 61 + } 62 + 63 + if (!hasContent) { 64 + return ( 65 + <button 66 + type="button" 67 + onClick={() => setIsEditing(true)} 68 + className="text-sm text-gray-400 dark:text-gray-500 hover:text-blue-600 dark:hover:text-blue-400 italic" 69 + > 70 + Add a description... 71 + </button> 72 + ); 73 + } 74 + 75 + return ( 76 + <div className="relative"> 77 + <div 78 + className={ 79 + !isExpanded && needsTruncation ? "overflow-hidden" : undefined 80 + } 81 + style={ 82 + !isExpanded && needsTruncation 83 + ? { maxHeight: `${COLLAPSED_LINES * LINE_HEIGHT}em` } 84 + : undefined 85 + } 86 + > 87 + <RichText 88 + text={parsed.text} 89 + facets={parsed.facets} 90 + className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap" 91 + /> 92 + </div> 93 + 94 + {needsTruncation && !isExpanded && ( 95 + <div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-white dark:from-slate-900 to-transparent" /> 96 + )} 97 + 98 + <div className="flex items-center gap-3 mt-1"> 99 + {needsTruncation && ( 100 + <button 101 + type="button" 102 + onClick={() => setIsExpanded(!isExpanded)} 103 + className="text-sm text-blue-600 dark:text-blue-400 hover:underline" 104 + > 105 + {isExpanded ? "Show less" : "Show more"} 106 + </button> 107 + )} 108 + {!readOnly && ( 109 + <button 110 + type="button" 111 + onClick={() => setIsEditing(true)} 112 + className="text-sm text-blue-600 dark:text-blue-400 hover:underline" 113 + > 114 + Edit 115 + </button> 116 + )} 117 + </div> 118 + </div> 119 + ); 120 + }
+100
src/components/richtext/RichTextEditor.tsx
··· 1 + import type { ParseResult } from "@/lib/richtext"; 2 + import { RichText } from "@/lib/richtext"; 3 + 4 + type SaveState = "saved" | "dirty" | "saving"; 5 + 6 + function getSaveState({ 7 + isDirty, 8 + isPending, 9 + isSaving, 10 + }: { 11 + isDirty?: boolean; 12 + isPending?: boolean; 13 + isSaving?: boolean; 14 + }): SaveState { 15 + if (isSaving) return "saving"; 16 + // isPending (debounce buffering) or isDirty both show as "dirty" 17 + if (isPending || isDirty) return "dirty"; 18 + return "saved"; 19 + } 20 + 21 + function SaveIndicator({ state }: { state: SaveState }) { 22 + const colors: Record<SaveState, string> = { 23 + saving: "text-blue-400 dark:text-blue-500", 24 + dirty: "text-amber-500 dark:text-amber-400", 25 + saved: "text-green-500 dark:text-green-400", 26 + }; 27 + 28 + const labels: Record<SaveState, string> = { 29 + saving: "Saving", 30 + dirty: "Unsaved", 31 + saved: "Saved", 32 + }; 33 + 34 + return ( 35 + <svg 36 + className={`w-3 h-3 ${colors[state]}`} 37 + viewBox="0 0 24 24" 38 + fill="currentColor" 39 + role="img" 40 + aria-label={labels[state]} 41 + > 42 + <circle cx="12" cy="12" r="6" /> 43 + </svg> 44 + ); 45 + } 46 + 47 + export interface RichTextEditorProps { 48 + markdown: string; 49 + setMarkdown: (value: string) => void; 50 + parsed: ParseResult; 51 + isDirty?: boolean; 52 + isPending?: boolean; 53 + isSaving?: boolean; 54 + placeholder?: string; 55 + className?: string; 56 + } 57 + 58 + export function RichTextEditor({ 59 + markdown, 60 + setMarkdown, 61 + parsed, 62 + isDirty, 63 + isPending, 64 + isSaving, 65 + placeholder = "Write something...", 66 + className, 67 + }: RichTextEditorProps) { 68 + const saveState = getSaveState({ isDirty, isPending, isSaving }); 69 + 70 + return ( 71 + <div className={className}> 72 + <div className="grid grid-cols-2 gap-4"> 73 + <div className="relative"> 74 + <textarea 75 + value={markdown} 76 + onChange={(e) => setMarkdown(e.target.value)} 77 + placeholder={placeholder} 78 + className="w-full h-64 p-3 pr-8 border border-gray-300 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 resize-y font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400" 79 + /> 80 + <div className="absolute top-2 right-2"> 81 + <SaveIndicator state={saveState} /> 82 + </div> 83 + </div> 84 + <div className="p-3 border border-gray-300 dark:border-slate-700 rounded-lg bg-gray-50 dark:bg-slate-800/50 min-h-64 overflow-auto"> 85 + {parsed.text ? ( 86 + <RichText 87 + text={parsed.text} 88 + facets={parsed.facets} 89 + className="prose dark:prose-invert prose-sm max-w-none whitespace-pre-wrap" 90 + /> 91 + ) : ( 92 + <span className="text-gray-400 dark:text-gray-500"> 93 + {placeholder} 94 + </span> 95 + )} 96 + </div> 97 + </div> 98 + </div> 99 + ); 100 + }
+41 -5
src/lib/richtext/types.ts
··· 1 + import type { Main as LexiconRichText } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 2 + import type { 3 + ByteSlice as LexiconByteSlice, 4 + Link as LexiconLink, 5 + Mention as LexiconMention, 6 + } from "@/lib/lexicons/types/com/deckbelcher/richtext/facet"; 7 + 8 + // ByteSlice matches lexicon exactly 1 9 export interface ByteSlice { 2 10 byteStart: number; 3 11 byteEnd: number; 4 12 } 5 13 14 + // Feature types with required $type for our internal use 6 15 export interface BoldFeature { 7 16 $type: "com.deckbelcher.richtext.facet#bold"; 8 17 } ··· 21 30 22 31 export interface LinkFeature { 23 32 $type: "com.deckbelcher.richtext.facet#link"; 24 - uri: string; 33 + uri: LexiconLink["uri"]; 25 34 } 26 35 27 36 export interface MentionFeature { 28 37 $type: "com.deckbelcher.richtext.facet#mention"; 29 - did: string; 38 + did: LexiconMention["did"]; 39 + } 40 + 41 + export interface TagFeature { 42 + $type: "com.deckbelcher.richtext.facet#tag"; 43 + tag: string; 30 44 } 31 45 32 46 export type FormatFeature = ··· 35 49 | CodeFeature 36 50 | CodeBlockFeature 37 51 | LinkFeature 38 - | MentionFeature; 52 + | MentionFeature 53 + | TagFeature; 39 54 40 55 export interface Facet { 41 56 index: ByteSlice; ··· 47 62 facets: Facet[]; 48 63 } 49 64 65 + // Type alias for lexicon compatibility 66 + export type RichText = LexiconRichText; 67 + export type { LexiconByteSlice }; 68 + 50 69 export function isBold(feature: FormatFeature): feature is BoldFeature { 51 70 return feature.$type === "com.deckbelcher.richtext.facet#bold"; 52 71 } ··· 71 90 72 91 export function isMention(feature: FormatFeature): feature is MentionFeature { 73 92 return feature.$type === "com.deckbelcher.richtext.facet#mention"; 93 + } 94 + 95 + export function isTag(feature: FormatFeature): feature is TagFeature { 96 + return feature.$type === "com.deckbelcher.richtext.facet#tag"; 74 97 } 75 98 76 99 export const BOLD: BoldFeature = { ··· 90 113 }; 91 114 92 115 export function link(uri: string): LinkFeature { 93 - return { $type: "com.deckbelcher.richtext.facet#link", uri }; 116 + return { 117 + $type: "com.deckbelcher.richtext.facet#link", 118 + uri: uri as LinkFeature["uri"], 119 + }; 94 120 } 95 121 96 122 export function mention(did: string): MentionFeature { 97 - return { $type: "com.deckbelcher.richtext.facet#mention", did }; 123 + return { 124 + $type: "com.deckbelcher.richtext.facet#mention", 125 + did: did as MentionFeature["did"], 126 + }; 127 + } 128 + 129 + export function tag(value: string): TagFeature { 130 + return { 131 + $type: "com.deckbelcher.richtext.facet#tag", 132 + tag: value, 133 + }; 98 134 }
+78
src/lib/useRichText.ts
··· 1 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 2 + import { type ParseResult, parseMarkdown } from "./richtext"; 3 + import { useDebounce } from "./useDebounce"; 4 + 5 + export interface UseRichTextOptions { 6 + initialValue?: string; 7 + onSave?: (parsed: ParseResult) => void | Promise<void>; 8 + debounceMs?: number; 9 + } 10 + 11 + export interface UseRichTextResult { 12 + markdown: string; 13 + setMarkdown: (value: string) => void; 14 + parsed: ParseResult; 15 + isDirty: boolean; 16 + isPending: boolean; // debounce pending (keystrokes buffered) 17 + save: () => void; 18 + } 19 + 20 + export function useRichText({ 21 + initialValue = "", 22 + onSave, 23 + debounceMs = 1500, 24 + }: UseRichTextOptions = {}): UseRichTextResult { 25 + const [markdown, setMarkdownRaw] = useState(initialValue); 26 + const savedValueRef = useRef(initialValue); 27 + const onSaveRef = useRef(onSave); 28 + const prevDebouncedRef = useRef(initialValue); 29 + 30 + onSaveRef.current = onSave; 31 + 32 + const { 33 + value: debouncedMarkdown, 34 + flush, 35 + isPending, 36 + } = useDebounce(markdown, debounceMs); 37 + 38 + const parsed = useMemo(() => parseMarkdown(markdown), [markdown]); 39 + 40 + const isDirty = markdown !== savedValueRef.current; 41 + 42 + // Call onSave when debounced value changes (not on every render) 43 + if (debouncedMarkdown !== prevDebouncedRef.current) { 44 + prevDebouncedRef.current = debouncedMarkdown; 45 + if (debouncedMarkdown !== savedValueRef.current && onSaveRef.current) { 46 + onSaveRef.current(parseMarkdown(debouncedMarkdown)); 47 + savedValueRef.current = debouncedMarkdown; 48 + } 49 + } 50 + 51 + const setMarkdown = useCallback((value: string) => { 52 + setMarkdownRaw(value); 53 + }, []); 54 + 55 + const save = useCallback(() => { 56 + const current = flush(); 57 + if (current !== savedValueRef.current && onSaveRef.current) { 58 + onSaveRef.current(parseMarkdown(current)); 59 + savedValueRef.current = current; 60 + } 61 + }, [flush]); 62 + 63 + // Sync with external initialValue changes (e.g., deck reload) 64 + useEffect(() => { 65 + setMarkdownRaw(initialValue); 66 + savedValueRef.current = initialValue; 67 + prevDebouncedRef.current = initialValue; 68 + }, [initialValue]); 69 + 70 + return { 71 + markdown, 72 + setMarkdown, 73 + parsed, 74 + isDirty, 75 + isPending, 76 + save, 77 + }; 78 + }
+26 -1
src/routes/profile/$did/deck/$rkey/index.tsx
··· 16 16 import { DragDropProvider } from "@/components/deck/DragDropProvider"; 17 17 import type { DragData } from "@/components/deck/DraggableCard"; 18 18 import { GoldfishView } from "@/components/deck/GoldfishView"; 19 + import { PrimerSection } from "@/components/deck/PrimerSection"; 19 20 import { StatsCardList } from "@/components/deck/stats/StatsCardList"; 20 21 import { TrashDropZone } from "@/components/deck/TrashDropZone"; 21 22 import { ViewControls } from "@/components/deck/ViewControls"; ··· 35 36 } from "@/lib/deck-types"; 36 37 import { formatDisplayName } from "@/lib/format-utils"; 37 38 import { getCardByIdQueryOptions } from "@/lib/queries"; 39 + import { serializeToMarkdown } from "@/lib/richtext"; 38 40 import type { ScryfallId } from "@/lib/scryfall-types"; 39 41 import { getImageUri } from "@/lib/scryfall-utils"; 40 42 import { getSelectedCards, type StatsSelection } from "@/lib/stats-selection"; 41 43 import { useAuth } from "@/lib/useAuth"; 42 44 import { useDeckStats } from "@/lib/useDeckStats"; 43 45 import { usePersistedState } from "@/lib/usePersistedState"; 46 + import { useRichText } from "@/lib/useRichText"; 44 47 45 48 export const Route = createFileRoute("/profile/$did/deck/$rkey/")({ 46 49 component: DeckEditorPage, ··· 153 156 154 157 // Check if current user is the owner 155 158 const isOwner = session?.info.sub === did; 159 + 160 + // Primer editor state 161 + const primerInitialValue = useMemo( 162 + () => 163 + serializeToMarkdown(deck.primer?.text ?? "", deck.primer?.facets ?? []), 164 + [deck.primer], 165 + ); 166 + const primer = useRichText({ 167 + initialValue: primerInitialValue, 168 + onSave: (parsed) => { 169 + if (!isOwner) return; 170 + mutation.mutate({ ...deck, primer: parsed }); 171 + }, 172 + debounceMs: 1500, 173 + }); 156 174 157 175 // Helper to update deck via mutation 158 176 const updateDeck = async (updater: (prev: Deck) => Deck) => { ··· 420 438 updateDeck={updateDeck} 421 439 highlightedCards={highlightedCards} 422 440 handleCardsChanged={handleCardsChanged} 441 + primer={primer} 442 + isSaving={mutation.isPending} 423 443 /> 424 444 </DragDropProvider> 425 445 ); ··· 458 478 updateDeck: (updater: (prev: Deck) => Deck) => Promise<void>; 459 479 highlightedCards: Set<ScryfallId>; 460 480 handleCardsChanged: (changedIds: Set<ScryfallId>) => void; 481 + primer: ReturnType<typeof useRichText>; 482 + isSaving: boolean; 461 483 } 462 484 463 485 function DeckEditorInner({ ··· 493 515 updateDeck, 494 516 highlightedCards, 495 517 handleCardsChanged, 518 + primer, 519 + isSaving, 496 520 }: DeckEditorInnerProps) { 497 521 // Track drag state globally (must be inside DndContext) 498 522 useDndMonitor({ ··· 516 540 return ( 517 541 <div className="min-h-screen bg-white dark:bg-slate-900"> 518 542 {/* Deck name and format */} 519 - <div className="max-w-7xl 2xl:max-w-[96rem] mx-auto px-6 pt-8 pb-4"> 543 + <div className="max-w-7xl 2xl:max-w-[96rem] mx-auto px-6 pt-8 pb-4 space-y-4"> 520 544 <DeckHeader 521 545 name={deck.name} 522 546 format={deck.format} ··· 524 548 onFormatChange={handleFormatChange} 525 549 readOnly={!isOwner} 526 550 /> 551 + <PrimerSection {...primer} isSaving={isSaving} readOnly={!isOwner} /> 527 552 </div> 528 553 529 554 {/* Sticky header with search */}