Browse Source

add citation helper

imwald
Silberengel 2 weeks ago
parent
commit
62b24a53e8
  1. 65
      src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx
  2. 161
      src/components/AdvancedEventLab/AdvancedLabCitationPickerDialog.tsx
  3. 2
      src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx
  4. 15
      src/i18n/locales/de.ts
  5. 15
      src/i18n/locales/en.ts
  6. 45
      src/services/mention-event-search.service.ts

65
src/components/AdvancedEventLab/AdvancedEventLabMarkupToolbar.tsx

@ -1,4 +1,8 @@ @@ -1,4 +1,8 @@
import type { AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice'
import {
AdvancedLabCitationPickerDialog,
type LabCitationDisplayType
} from '@/components/AdvancedEventLab/AdvancedLabCitationPickerDialog'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@ -14,6 +18,7 @@ import { ScrollArea } from '@/components/ui/scroll-area' @@ -14,6 +18,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import type { EditorView } from '@codemirror/view'
import {
Anchor,
BookMarked,
Braces,
ChevronDown,
Code2,
@ -34,7 +39,7 @@ import { @@ -34,7 +39,7 @@ import {
Volume2
} from 'lucide-react'
import type { MutableRefObject } from 'react'
import { useMemo, useState } from 'react'
import { Fragment, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
labInsertRaw,
@ -94,6 +99,16 @@ const CODE_LANGUAGES = [ @@ -94,6 +99,16 @@ const CODE_LANGUAGES = [
'protobuf'
] as const
const LAB_CITATION_MENU_ITEMS: { type: LabCitationDisplayType; labelKey: string }[] = [
{ type: 'inline', labelKey: 'Advanced lab citation type inline' },
{ type: 'quote', labelKey: 'Advanced lab citation type quote' },
{ type: 'end', labelKey: 'Advanced lab citation type end' },
{ type: 'foot', labelKey: 'Advanced lab citation type foot' },
{ type: 'foot-end', labelKey: 'Advanced lab citation type footEnd' },
{ type: 'prompt-inline', labelKey: 'Advanced lab citation type promptInline' },
{ type: 'prompt-end', labelKey: 'Advanced lab citation type promptEnd' }
]
export type AdvancedEventLabMarkupToolbarProps = {
markupMode: 'markdown' | 'asciidoc'
viewRef: MutableRefObject<EditorView | null>
@ -108,6 +123,13 @@ export function AdvancedEventLabMarkupToolbar({ @@ -108,6 +123,13 @@ export function AdvancedEventLabMarkupToolbar({
const { t } = useTranslation()
const [codeFilter, setCodeFilter] = useState('')
const [langFilter, setLangFilter] = useState('')
const [citationPickerOpen, setCitationPickerOpen] = useState(false)
const [citationDisplayType, setCitationDisplayType] = useState<LabCitationDisplayType>('inline')
const openCitationPicker = (displayType: LabCitationDisplayType) => {
setCitationDisplayType(displayType)
setCitationPickerOpen(true)
}
const filteredLangs = useMemo(() => {
const q = codeFilter.trim().toLowerCase()
@ -121,6 +143,37 @@ export function AdvancedEventLabMarkupToolbar({ @@ -121,6 +143,37 @@ export function AdvancedEventLabMarkupToolbar({
fn(v)
}
const citationPicker = (
<AdvancedLabCitationPickerDialog
open={citationPickerOpen}
onOpenChange={setCitationPickerOpen}
displayType={citationDisplayType}
onInsertMacro={(macro) =>
run((v) => labInsertRawWithOptionalBlockLeadNl(v, sliceRef, `${macro}\n`))
}
/>
)
const citationDropdown = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1 text-xs shrink-0">
<BookMarked className="h-3.5 w-3.5" />
{t('Advanced lab tb citations')}
<ChevronDown className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="z-[280] w-[min(20rem,92vw)] max-h-80 overflow-y-auto">
<DropdownMenuLabel>{t('Advanced lab tb citationsHint')}</DropdownMenuLabel>
{LAB_CITATION_MENU_ITEMS.map(({ type, labelKey }) => (
<DropdownMenuItem key={type} onClick={() => openCitationPicker(type)}>
{t(labelKey)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
/** Contiguous document header per https://docs.asciidoctor.org/asciidoc/latest/document/header/ (no blank lines until after the last header line). */
const adocInsertFullHeader = (titleLine: string) => {
run((v) =>
@ -152,6 +205,7 @@ export function AdvancedEventLabMarkupToolbar({ @@ -152,6 +205,7 @@ export function AdvancedEventLabMarkupToolbar({
if (markupMode === 'markdown') {
return (
<Fragment>
<div className="flex flex-wrap items-center gap-1.5 min-w-0 overflow-x-auto border-b bg-muted/30 px-2 py-2">
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide shrink-0 mr-1">
{t('Advanced lab tb markup tools')}
@ -314,6 +368,8 @@ export function AdvancedEventLabMarkupToolbar({ @@ -314,6 +368,8 @@ export function AdvancedEventLabMarkupToolbar({
</DropdownMenuContent>
</DropdownMenu>
{citationDropdown}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1 text-xs shrink-0">
@ -586,11 +642,14 @@ export function AdvancedEventLabMarkupToolbar({ @@ -586,11 +642,14 @@ export function AdvancedEventLabMarkupToolbar({
</DropdownMenuContent>
</DropdownMenu>
</div>
{citationPicker}
</Fragment>
)
}
/* AsciiDoc */
return (
<Fragment>
<div className="flex flex-wrap items-center gap-1.5 min-w-0 overflow-x-auto border-b bg-muted/30 px-2 py-2">
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide shrink-0 mr-1">
{t('Advanced lab tb markup tools')}
@ -713,6 +772,8 @@ export function AdvancedEventLabMarkupToolbar({ @@ -713,6 +772,8 @@ export function AdvancedEventLabMarkupToolbar({
</DropdownMenuContent>
</DropdownMenu>
{citationDropdown}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1 text-xs shrink-0">
@ -1126,5 +1187,7 @@ export function AdvancedEventLabMarkupToolbar({ @@ -1126,5 +1187,7 @@ export function AdvancedEventLabMarkupToolbar({
<Minus className="h-3.5 w-3.5" />
</Button>
</div>
{citationPicker}
</Fragment>
)
}

161
src/components/AdvancedEventLab/AdvancedLabCitationPickerDialog.tsx

@ -0,0 +1,161 @@ @@ -0,0 +1,161 @@
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { SimpleUsername } from '@/components/Username'
import { getNoteBech32Id } from '@/lib/event'
import { CITATION_PICKER_KINDS, searchCitationEventsForPicker } from '@/services/mention-event-search.service'
import client from '@/services/client.service'
import { Search } from 'lucide-react'
import { nip19, type Event as NostrEvent } from 'nostr-tools'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
/** Matches {@link MarkdownArticle} / AsciiDoc citation token `[[citation::type::…]]`. */
export type LabCitationDisplayType =
| 'end'
| 'foot'
| 'foot-end'
| 'inline'
| 'quote'
| 'prompt-end'
| 'prompt-inline'
export function buildCitationWikiMacro(displayType: LabCitationDisplayType, nostrLink: string): string {
let id = nostrLink.trim()
if (id.toLowerCase().startsWith('nostr:')) id = id.slice(6)
return `[[citation::${displayType}::${id}]]`
}
function isCitationKind(kind: number): boolean {
return (CITATION_PICKER_KINDS as readonly number[]).includes(kind)
}
export function AdvancedLabCitationPickerDialog({
open,
onOpenChange,
displayType,
onInsertMacro
}: {
open: boolean
onOpenChange: (open: boolean) => void
displayType: LabCitationDisplayType
onInsertMacro: (macro: string) => void
}) {
const { t } = useTranslation()
const [query, setQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
const [events, setEvents] = useState<NostrEvent[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!open) return
setQuery('')
setDebouncedQuery('')
setEvents([])
}, [open])
useEffect(() => {
if (!open) return
const timer = setTimeout(() => setDebouncedQuery(query.trim()), 300)
return () => clearTimeout(timer)
}, [open, query])
useEffect(() => {
if (!open || !debouncedQuery) {
setEvents([])
setLoading(false)
return
}
let cancelled = false
setLoading(true)
void searchCitationEventsForPicker(debouncedQuery, 20)
.then((list) => {
if (cancelled) return
setEvents(list.filter((ev) => isCitationKind(ev.kind)).slice(0, 15))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [open, debouncedQuery])
const handleSelect = useCallback(
(event: NostrEvent) => {
if (!isCitationKind(event.kind)) return
client.addEventToCache(event)
try {
const bech32 = getNoteBech32Id(event)
const link = `nostr:${bech32}`
onInsertMacro(buildCitationWikiMacro(displayType, link))
onOpenChange(false)
} catch {
onInsertMacro(buildCitationWikiMacro(displayType, `nostr:${nip19.noteEncode(event.id)}`))
onOpenChange(false)
}
},
[displayType, onInsertMacro, onOpenChange]
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-lg max-h-[80vh] flex flex-col gap-4 z-[280]"
overlayClassName="z-[275]"
>
<DialogHeader>
<DialogTitle>{t('Advanced lab citation dialog title')}</DialogTitle>
<p className="text-sm text-muted-foreground">{t('Advanced lab citation dialog hint')}</p>
</DialogHeader>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('Advanced lab citation search placeholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
autoFocus
/>
</div>
<div className="min-h-[200px] max-h-[50vh] border rounded-md overflow-y-auto overflow-x-hidden">
<div className="p-2 space-y-1">
{loading && (
<div className="space-y-2 p-2" role="status" aria-busy="true" aria-live="polite">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full rounded-md" />
))}
</div>
)}
{!loading && debouncedQuery && events.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-6">{t('Advanced lab citation none')}</p>
)}
{!loading &&
events.map((ev) => (
<Button
key={ev.id}
variant="ghost"
className="w-full justify-start text-left h-auto py-2 px-3 font-normal"
onClick={() => handleSelect(ev)}
>
<div className="flex flex-col gap-0.5 min-w-0 w-full">
<span className="text-xs text-muted-foreground">
{t('Advanced lab citation kindLabel', { kind: ev.kind })}
</span>
<SimpleUsername userId={ev.pubkey} className="text-xs text-muted-foreground truncate" />
<span className="text-sm line-clamp-2 break-words">{ev.content || t('(empty)')}</span>
</div>
</Button>
))}
</div>
</div>
</DialogContent>
</Dialog>
)
}

2
src/components/PostEditor/PostTextarea/Mention/NeventNaddrPickerDialog.tsx

@ -65,7 +65,7 @@ function NeventNaddrPickerDialog({ @@ -65,7 +65,7 @@ function NeventNaddrPickerDialog({
}
let cancelled = false
setLoading(true)
searchEventsForPicker(debouncedQuery, 20, mode)
searchEventsForPicker(debouncedQuery, 20, mode, undefined)
.then((list) => {
if (cancelled) return
setEvents(list.slice(0, 15) as NEvent[])

15
src/i18n/locales/de.ts

@ -1014,6 +1014,21 @@ export default { @@ -1014,6 +1014,21 @@ export default {
'Advanced lab tb image': 'Bild ![alt](URL)',
'Advanced lab tb imageTitled': 'Bild mit Titel ![alt](URL "Titel")',
'Advanced lab tb hardBreak': 'Harter Zeilenumbruch (zwei Leerzeichen + Zeilenumbruch)',
'Advanced lab tb citations': 'Zitate (NIP-32)',
'Advanced lab tb citationsHint': 'Kinds 30–33. Fügt `[[citation::typ::nevent…]]` ein (Markdown & AsciiDoc).',
'Advanced lab citation dialog title': 'Zitation einfügen',
'Advanced lab citation dialog hint':
'NIP-32-Zitations-Events suchen (intern, extern, Hardcopy, Prompt) und auswählen.',
'Advanced lab citation search placeholder': 'Kinds 30–33 durchsuchen…',
'Advanced lab citation none': 'Keine Zitations-Events gefunden',
'Advanced lab citation kindLabel': 'Kind {{kind}}',
'Advanced lab citation type inline': 'Inline-Zitation',
'Advanced lab citation type quote': 'Zitat (Block)',
'Advanced lab citation type end': 'Endnoten-Zitation',
'Advanced lab citation type foot': 'Fußnotenanker',
'Advanced lab citation type footEnd': 'Fußnote + Ende',
'Advanced lab citation type promptInline': 'Prompt (inline)',
'Advanced lab citation type promptEnd': 'Prompt (Ende)',
'Advanced lab tb lists': 'Listen',
'Advanced lab tb bulletList': 'Aufzählung (-)',
'Advanced lab tb bulletListStar': 'Aufzählung (*)',

15
src/i18n/locales/en.ts

@ -1015,6 +1015,21 @@ export default { @@ -1015,6 +1015,21 @@ export default {
'Advanced lab tb image': 'Image ![alt](url)',
'Advanced lab tb imageTitled': 'Image with title ![alt](url "title")',
'Advanced lab tb hardBreak': 'Hard line break (two spaces + newline)',
'Advanced lab tb citations': 'Citations (NIP-32)',
'Advanced lab tb citationsHint': 'Kinds 30–33. Inserts `[[citation::type::nevent…]]` (Markdown & AsciiDoc).',
'Advanced lab citation dialog title': 'Insert citation',
'Advanced lab citation dialog hint':
'Search for NIP-32 citation events (internal, external, hardcopy, prompt), then pick one.',
'Advanced lab citation search placeholder': 'Search kinds 30–33…',
'Advanced lab citation none': 'No citation events found',
'Advanced lab citation kindLabel': 'Kind {{kind}}',
'Advanced lab citation type inline': 'Inline citation',
'Advanced lab citation type quote': 'Block quote citation',
'Advanced lab citation type end': 'Endnote citation',
'Advanced lab citation type foot': 'Footnote call',
'Advanced lab citation type footEnd': 'Footnote + end',
'Advanced lab citation type promptInline': 'Prompt (inline)',
'Advanced lab citation type promptEnd': 'Prompt (end)',
'Advanced lab tb lists': 'Lists',
'Advanced lab tb bulletList': 'Bullet list (-)',
'Advanced lab tb bulletListStar': 'Bullet list (*)',

45
src/services/mention-event-search.service.ts

@ -17,20 +17,28 @@ export const MENTION_NPUB_DROPDOWN_LIMIT = 50 @@ -17,20 +17,28 @@ export const MENTION_NPUB_DROPDOWN_LIMIT = 50
/** Kinds for nevent search: notes, threads, long-form, etc. */
export const NEVENT_KINDS = [
kinds.ShortTextNote,
ExtendedKind.PICTURE,
ExtendedKind.VIDEO,
ExtendedKind.SHORT_VIDEO,
ExtendedKind.PICTURE,
ExtendedKind.VIDEO,
ExtendedKind.SHORT_VIDEO,
ExtendedKind.POLL,
ExtendedKind.ZAP_POLL,
ExtendedKind.COMMENT,
ExtendedKind.VOICE,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.PUBLIC_MESSAGE,
ExtendedKind.COMMENT,
ExtendedKind.VOICE,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.PUBLIC_MESSAGE,
ExtendedKind.DISCUSSION,
ExtendedKind.CITATION_INTERNAL,
ExtendedKind.CITATION_EXTERNAL,
ExtendedKind.CITATION_HARDCOPY,
ExtendedKind.CITATION_PROMPT,
ExtendedKind.CITATION_INTERNAL,
ExtendedKind.CITATION_EXTERNAL,
ExtendedKind.CITATION_HARDCOPY,
ExtendedKind.CITATION_PROMPT
] as const
/** NIP-32 citation events only (Advanced lab citation picker, etc.). */
export const CITATION_PICKER_KINDS = [
ExtendedKind.CITATION_INTERNAL,
ExtendedKind.CITATION_EXTERNAL,
ExtendedKind.CITATION_HARDCOPY,
ExtendedKind.CITATION_PROMPT
] as const
/** Kinds for naddr search: calendar, publications, wiki, etc. */
@ -49,16 +57,19 @@ export type PickerSearchMode = 'nevent' | 'naddr' @@ -49,16 +57,19 @@ export type PickerSearchMode = 'nevent' | 'naddr'
/**
* Search for events: session cache IndexedDB relays. Merges and dedupes by event id, up to limit.
* @param mode - 'nevent' uses NEVENT_KINDS (1,11,20,21,22,9802), 'naddr' uses NADDR_KINDS (30023,30817,30818,30040).
* @param kindFilter - When set, only these kinds are searched (overrides `mode` for the kinds list).
*/
export async function searchEventsForPicker(
query: string,
limit: number = DEFAULT_NOTES_LIMIT,
mode: PickerSearchMode = 'nevent'
mode: PickerSearchMode = 'nevent',
kindFilter?: readonly number[]
): Promise<NEvent[]> {
const q = query.trim()
if (!q) return []
const kindsList = mode === 'nevent' ? [...NEVENT_KINDS] : [...NADDR_KINDS]
const kindsList =
kindFilter && kindFilter.length > 0 ? [...kindFilter] : mode === 'nevent' ? [...NEVENT_KINDS] : [...NADDR_KINDS]
const seen = new Set<string>()
const out: NEvent[] = []
@ -85,6 +96,14 @@ export async function searchEventsForPicker( @@ -85,6 +96,14 @@ export async function searchEventsForPicker(
return out.slice(0, limit)
}
/** Search only NIP-32 citation events (kinds 30–33). */
export async function searchCitationEventsForPicker(
query: string,
limit: number = DEFAULT_NOTES_LIMIT
): Promise<NEvent[]> {
return searchEventsForPicker(query, limit, 'nevent', CITATION_PICKER_KINDS)
}
/**
* @deprecated Use searchEventsForPicker(query, limit, 'nevent') instead.
*/

Loading…
Cancel
Save