30 changed files with 1086 additions and 171 deletions
@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
|
||||
--- |
||||
description: Do not use Framer Motion or Motion for React animations |
||||
alwaysApply: true |
||||
--- |
||||
|
||||
# No Framer Motion |
||||
|
||||
This project does **not** use Framer Motion or the `motion` package. |
||||
|
||||
- Never add `framer-motion`, `motion`, or `motion/react` to `package.json`. |
||||
- Never import from `framer-motion`, `motion`, or `motion/react`. |
||||
- Never use `motion.div`, `motion.span`, `motion.button`, or other `motion.*` components. |
||||
- Never use `AnimatePresence`, `useAnimation`, `useMotionValue`, or related APIs. |
||||
|
||||
Use plain HTML elements (`motion`-free elements) with **Tailwind** and **CSS** instead: `transition-*`, `animate-*`, `motion-reduce:*`, etc. |
||||
|
||||
If you need enter/exit effects, prefer CSS transitions, conditional render, or existing UI patterns in the codebase — not animation libraries. |
||||
@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
|
||||
import { ExtendedKind } from '@/constants' |
||||
import { LEGACY_PROFILE_BADGES_D_TAG } from '@/lib/nip58-profile-badges' |
||||
import { shouldOfferProfileBadgesMigration } from '@/lib/nip58-profile-badges-list' |
||||
import type { Event } from 'nostr-tools' |
||||
import { describe, expect, it } from 'vitest' |
||||
|
||||
function listEvent( |
||||
kind: number, |
||||
created_at: number, |
||||
pairs: Array<[string, string]>, |
||||
d?: string |
||||
): Event { |
||||
const tags: string[][] = [] |
||||
if (d !== undefined) tags.push(['d', d]) |
||||
for (const [a, e] of pairs) { |
||||
tags.push(['a', a]) |
||||
tags.push(['e', e]) |
||||
} |
||||
return { |
||||
id: 'id', |
||||
pubkey: 'aa'.repeat(32), |
||||
created_at, |
||||
kind, |
||||
tags, |
||||
content: '', |
||||
sig: 'sig' |
||||
} |
||||
} |
||||
|
||||
describe('shouldOfferProfileBadgesMigration', () => { |
||||
const legacy = listEvent( |
||||
ExtendedKind.PROFILE_BADGES, |
||||
100, |
||||
[['30009:aa:bravery', 'bb'.repeat(32)]], |
||||
LEGACY_PROFILE_BADGES_D_TAG |
||||
) |
||||
|
||||
it('offers when only legacy list has entries', () => { |
||||
expect(shouldOfferProfileBadgesMigration(null, legacy)).toBe(true) |
||||
}) |
||||
|
||||
it('offers when kind 10008 is empty but legacy has entries', () => { |
||||
const empty = listEvent(ExtendedKind.PROFILE_BADGES_LIST, 200, []) |
||||
expect(shouldOfferProfileBadgesMigration(empty, legacy)).toBe(true) |
||||
}) |
||||
|
||||
it('offers when legacy is newer than current', () => { |
||||
const current = listEvent(ExtendedKind.PROFILE_BADGES_LIST, 50, [ |
||||
['30009:aa:other', 'cc'.repeat(32)] |
||||
]) |
||||
expect(shouldOfferProfileBadgesMigration(current, legacy)).toBe(true) |
||||
}) |
||||
|
||||
it('does not offer when current is up to date', () => { |
||||
const current = listEvent(ExtendedKind.PROFILE_BADGES_LIST, 200, [ |
||||
['30009:aa:bravery', 'bb'.repeat(32)] |
||||
]) |
||||
expect(shouldOfferProfileBadgesMigration(current, legacy)).toBe(false) |
||||
}) |
||||
|
||||
it('does not offer without legacy entries', () => { |
||||
const emptyLegacy = listEvent( |
||||
ExtendedKind.PROFILE_BADGES, |
||||
100, |
||||
[], |
||||
LEGACY_PROFILE_BADGES_D_TAG |
||||
) |
||||
expect(shouldOfferProfileBadgesMigration(null, emptyLegacy)).toBe(false) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
|
||||
import { |
||||
ExtendedKind, |
||||
METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, |
||||
METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS |
||||
} from '@/constants' |
||||
import { |
||||
isNip58ProfileBadgesListEvent, |
||||
LEGACY_PROFILE_BADGES_D_TAG, |
||||
parseProfileBadgeEntries, |
||||
type ProfileBadgeEntry |
||||
} from '@/lib/nip58-profile-badges' |
||||
import { normalizeHexPubkey } from '@/lib/pubkey' |
||||
import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' |
||||
import { normalizeAnyRelayUrl } from '@/lib/url' |
||||
import client, { replaceableEventService } from '@/services/client.service' |
||||
import type { Event } from 'nostr-tools' |
||||
|
||||
export function profileBadgeEntriesToTags(entries: ProfileBadgeEntry[]): string[][] { |
||||
const tags: string[][] = [] |
||||
for (const entry of entries) { |
||||
tags.push(['a', entry.definitionCoordinate]) |
||||
tags.push(['e', entry.awardEventId]) |
||||
} |
||||
return tags |
||||
} |
||||
|
||||
export function profileBadgeListTagsAfterRemovingEntry( |
||||
tags: string[][], |
||||
entry: ProfileBadgeEntry |
||||
): string[][] | null { |
||||
const parsed = parseProfileBadgeEntries({ kind: ExtendedKind.PROFILE_BADGES_LIST, tags } as Event) |
||||
const next = parsed.filter( |
||||
(row) => |
||||
!( |
||||
row.definitionCoordinate === entry.definitionCoordinate && |
||||
row.awardEventId === entry.awardEventId |
||||
) |
||||
) |
||||
if (next.length === parsed.length) return null |
||||
return profileBadgeEntriesToTags(next) |
||||
} |
||||
|
||||
export async function fetchProfileBadgesListEvent( |
||||
pubkeyHex: string, |
||||
relayUrls: string[] |
||||
): Promise<Event | undefined> { |
||||
const pk = normalizeHexPubkey(pubkeyHex) |
||||
let cached: Event | undefined |
||||
try { |
||||
cached = |
||||
(await replaceableEventService.fetchReplaceableEvent(pk, ExtendedKind.PROFILE_BADGES_LIST)) ?? |
||||
undefined |
||||
} catch { |
||||
cached = undefined |
||||
} |
||||
const fromRelays = relayUrls.length |
||||
? await fetchLatestReplaceableListEvent(pk, ExtendedKind.PROFILE_BADGES_LIST, relayUrls) |
||||
: undefined |
||||
if (!cached) return fromRelays |
||||
if (!fromRelays) return cached |
||||
return fromRelays.created_at >= cached.created_at ? fromRelays : cached |
||||
} |
||||
|
||||
/** Deprecated NIP-58 profile badges (kind 30008, d=profile_badges). */ |
||||
export async function fetchLegacyProfileBadgesListEvent( |
||||
pubkeyHex: string, |
||||
relayUrls: string[] |
||||
): Promise<Event | undefined> { |
||||
const pk = normalizeHexPubkey(pubkeyHex) |
||||
let cached: Event | undefined |
||||
try { |
||||
cached = |
||||
(await replaceableEventService.fetchReplaceableEvent( |
||||
pk, |
||||
ExtendedKind.PROFILE_BADGES, |
||||
LEGACY_PROFILE_BADGES_D_TAG |
||||
)) ?? undefined |
||||
} catch { |
||||
cached = undefined |
||||
} |
||||
|
||||
const allUrls = [...new Set(relayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean))] |
||||
if (!allUrls.length) return cached |
||||
|
||||
const rows = await client.fetchEvents( |
||||
allUrls, |
||||
{ |
||||
authors: [pk], |
||||
kinds: [ExtendedKind.PROFILE_BADGES], |
||||
'#d': [LEGACY_PROFILE_BADGES_D_TAG], |
||||
limit: 20 |
||||
}, |
||||
{ |
||||
replaceableRace: true, |
||||
eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, |
||||
globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS |
||||
} |
||||
) |
||||
|
||||
const legacyRows = rows.filter(isNip58ProfileBadgesListEvent) |
||||
if (!legacyRows.length) return cached |
||||
const newest = legacyRows.reduce((best, e) => (e.created_at > best.created_at ? e : best)) |
||||
if (!cached) return newest |
||||
return newest.created_at >= cached.created_at ? newest : cached |
||||
} |
||||
|
||||
export function shouldOfferProfileBadgesMigration( |
||||
currentList: Event | null | undefined, |
||||
legacyList: Event | null | undefined |
||||
): boolean { |
||||
if (!legacyList || !isNip58ProfileBadgesListEvent(legacyList)) return false |
||||
const legacyEntries = parseProfileBadgeEntries(legacyList) |
||||
if (legacyEntries.length === 0) return false |
||||
if (!currentList || currentList.kind !== ExtendedKind.PROFILE_BADGES_LIST) return true |
||||
const currentEntries = parseProfileBadgeEntries(currentList) |
||||
if (currentEntries.length === 0) return true |
||||
return legacyList.created_at > currentList.created_at |
||||
} |
||||
@ -0,0 +1,403 @@
@@ -0,0 +1,403 @@
|
||||
import JsonViewDialog from '@/components/JsonViewDialog' |
||||
import { RefreshButton } from '@/components/RefreshButton' |
||||
import { |
||||
AlertDialog, |
||||
AlertDialogAction, |
||||
AlertDialogCancel, |
||||
AlertDialogContent, |
||||
AlertDialogDescription, |
||||
AlertDialogFooter, |
||||
AlertDialogHeader, |
||||
AlertDialogTitle |
||||
} from '@/components/ui/alert-dialog' |
||||
import { Button } from '@/components/ui/button' |
||||
import { |
||||
DropdownMenu, |
||||
DropdownMenuContent, |
||||
DropdownMenuItem, |
||||
DropdownMenuTrigger |
||||
} from '@/components/ui/dropdown-menu' |
||||
import { Input } from '@/components/ui/input' |
||||
import { Label } from '@/components/ui/label' |
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' |
||||
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' |
||||
import { ExtendedKind } from '@/constants' |
||||
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' |
||||
import { createReplaceablePersonalListDraftEvent } from '@/lib/draft-event' |
||||
import { extractBadgeDefinitionMedia } from '@/lib/badge-definition-media' |
||||
import { parseAddressableCoordinate, parseProfileBadgeEntries } from '@/lib/nip58-profile-badges' |
||||
import { |
||||
fetchLegacyProfileBadgesListEvent, |
||||
fetchProfileBadgesListEvent, |
||||
profileBadgeEntriesToTags, |
||||
shouldOfferProfileBadgesMigration |
||||
} from '@/lib/nip58-profile-badges-list' |
||||
import type { ProfileBadgeEntry } from '@/lib/nip58-profile-badges' |
||||
import { showPublishingError } from '@/lib/publishing-feedback' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { replaceableEventService } from '@/services/client.service' |
||||
import indexedDb from '@/services/indexed-db.service' |
||||
import dayjs from 'dayjs' |
||||
import { Award, Code, Eraser, MoreVertical, Trash2 } from 'lucide-react' |
||||
import type { Event } from 'nostr-tools' |
||||
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { toast } from 'sonner' |
||||
import NotFoundPage from '../NotFoundPage' |
||||
|
||||
function BadgeEntryRow({ |
||||
entry, |
||||
onRemove |
||||
}: { |
||||
entry: ProfileBadgeEntry |
||||
onRemove: () => void |
||||
}) { |
||||
const [label, setLabel] = useState(entry.definitionCoordinate) |
||||
const [imageUrl, setImageUrl] = useState<string | undefined>() |
||||
|
||||
useEffect(() => { |
||||
let cancelled = false |
||||
const parsed = parseAddressableCoordinate(entry.definitionCoordinate) |
||||
if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) { |
||||
setLabel(entry.definitionCoordinate) |
||||
return |
||||
} |
||||
void replaceableEventService |
||||
.fetchReplaceableEvent(parsed.pubkey, parsed.kind, parsed.d) |
||||
.then((def) => { |
||||
if (cancelled || !def) return |
||||
const name = def.tags.find((t) => t[0] === 'name')?.[1]?.trim() || parsed.d |
||||
setLabel(name) |
||||
setImageUrl(extractBadgeDefinitionMedia(def).thumb ?? extractBadgeDefinitionMedia(def).image) |
||||
}) |
||||
.catch(() => {}) |
||||
return () => { |
||||
cancelled = true |
||||
} |
||||
}, [entry.definitionCoordinate]) |
||||
|
||||
return ( |
||||
<div className="flex items-center gap-3 rounded-lg border border-border px-3 py-2"> |
||||
{imageUrl ? ( |
||||
<img src={imageUrl} alt="" className="h-10 w-10 shrink-0 rounded-md object-cover" /> |
||||
) : ( |
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted"> |
||||
<Award className="h-5 w-5 text-muted-foreground" aria-hidden /> |
||||
</div> |
||||
)} |
||||
<div className="min-w-0 flex-1 space-y-0.5"> |
||||
<div className="truncate text-sm font-medium">{label}</div> |
||||
<div className="truncate font-mono text-xs text-muted-foreground">{entry.definitionCoordinate}</div> |
||||
<div className="truncate font-mono text-xs text-muted-foreground">e: {entry.awardEventId}</div> |
||||
</div> |
||||
<Button type="button" variant="ghost" size="icon" onClick={onRemove} aria-label="Remove"> |
||||
<Trash2 className="h-4 w-4" /> |
||||
</Button> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
const ProfileBadgesListPage = forwardRef( |
||||
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { |
||||
const { t } = useTranslation() |
||||
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() |
||||
const { profile, pubkey, publish, checkLogin } = useNostr() |
||||
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
||||
const [listEvent, setListEvent] = useState<Event | null>(null) |
||||
const [legacyListEvent, setLegacyListEvent] = useState<Event | null>(null) |
||||
const [entries, setEntries] = useState<ProfileBadgeEntry[]>([]) |
||||
const [newDefinitionA, setNewDefinitionA] = useState('') |
||||
const [newAwardE, setNewAwardE] = useState('') |
||||
const [publishing, setPublishing] = useState(false) |
||||
const [migrating, setMigrating] = useState(false) |
||||
const [jsonOpen, setJsonOpen] = useState(false) |
||||
const [jsonPayload, setJsonPayload] = useState<unknown>(null) |
||||
const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false) |
||||
const [cleaning, setCleaning] = useState(false) |
||||
|
||||
const showMigrate = useMemo( |
||||
() => shouldOfferProfileBadgesMigration(listEvent, legacyListEvent), |
||||
[listEvent, legacyListEvent] |
||||
) |
||||
|
||||
const loadLists = useCallback(async () => { |
||||
if (!pubkey) { |
||||
setListEvent(null) |
||||
setLegacyListEvent(null) |
||||
setEntries([]) |
||||
return |
||||
} |
||||
const relays = await buildAccountListRelayUrlsForMerge({ |
||||
accountPubkey: pubkey, |
||||
favoriteRelays: favoriteRelays ?? [], |
||||
blockedRelays |
||||
}) |
||||
const [current, legacy] = await Promise.all([ |
||||
fetchProfileBadgesListEvent(pubkey, relays), |
||||
fetchLegacyProfileBadgesListEvent(pubkey, relays) |
||||
]) |
||||
setListEvent(current ?? null) |
||||
setLegacyListEvent(legacy ?? null) |
||||
const active = current ?? null |
||||
setEntries(parseProfileBadgeEntries(active ?? undefined)) |
||||
if (current) { |
||||
try { |
||||
await indexedDb.putReplaceableEvent(current) |
||||
} catch { |
||||
/* ignore */ |
||||
} |
||||
} |
||||
}, [pubkey, favoriteRelays, blockedRelays]) |
||||
|
||||
useEffect(() => { |
||||
void loadLists() |
||||
}, [loadLists]) |
||||
|
||||
useEffect(() => { |
||||
if (!hideTitlebar) { |
||||
registerPrimaryPanelRefresh(null) |
||||
return |
||||
} |
||||
registerPrimaryPanelRefresh(() => { |
||||
void loadLists() |
||||
}) |
||||
return () => registerPrimaryPanelRefresh(null) |
||||
}, [hideTitlebar, registerPrimaryPanelRefresh, loadLists]) |
||||
|
||||
const publishEntries = useCallback( |
||||
async (nextEntries: ProfileBadgeEntry[], successMessage: string) => { |
||||
if (!pubkey) return |
||||
setPublishing(true) |
||||
try { |
||||
if (dayjs().unix() === listEvent?.created_at) { |
||||
await new Promise((resolve) => setTimeout(resolve, 1000)) |
||||
} |
||||
const relays = await buildAccountListRelayUrlsForMerge({ |
||||
accountPubkey: pubkey, |
||||
favoriteRelays: favoriteRelays ?? [], |
||||
blockedRelays |
||||
}) |
||||
const draft = createReplaceablePersonalListDraftEvent( |
||||
ExtendedKind.PROFILE_BADGES_LIST, |
||||
profileBadgeEntriesToTags(nextEntries), |
||||
'' |
||||
) |
||||
const published = await publish(draft, { specifiedRelayUrls: relays }) |
||||
await indexedDb.putReplaceableEvent(published) |
||||
setListEvent(published) |
||||
setEntries(nextEntries) |
||||
toast.success(successMessage) |
||||
} catch (e) { |
||||
showPublishingError(e instanceof Error ? e : new Error(String(e))) |
||||
} finally { |
||||
setPublishing(false) |
||||
} |
||||
}, |
||||
[pubkey, listEvent?.created_at, favoriteRelays, blockedRelays, publish, t] |
||||
) |
||||
|
||||
const handleSave = useCallback(() => { |
||||
checkLogin(() => void publishEntries(entries, t('Profile badges list updated'))) |
||||
}, [checkLogin, publishEntries, entries, t]) |
||||
|
||||
const handleAddEntry = useCallback(() => { |
||||
const a = newDefinitionA.trim() |
||||
const e = newAwardE.trim() |
||||
if (!a || !e) { |
||||
toast.error(t('Profile badges need both definition (a) and award (e)')) |
||||
return |
||||
} |
||||
if (!/^[0-9a-f]{64}$/i.test(e)) { |
||||
toast.error(t('Award must be a 64-character hex event id')) |
||||
return |
||||
} |
||||
const next: ProfileBadgeEntry[] = [...entries, { definitionCoordinate: a, awardEventId: e.toLowerCase() }] |
||||
setEntries(next) |
||||
setNewDefinitionA('') |
||||
setNewAwardE('') |
||||
}, [newDefinitionA, newAwardE, entries, t]) |
||||
|
||||
const handleRemoveEntry = useCallback((entry: ProfileBadgeEntry) => { |
||||
setEntries((prev) => |
||||
prev.filter( |
||||
(row) => |
||||
!( |
||||
row.definitionCoordinate === entry.definitionCoordinate && |
||||
row.awardEventId === entry.awardEventId |
||||
) |
||||
) |
||||
) |
||||
}, []) |
||||
|
||||
const handleMigrate = useCallback(() => { |
||||
if (!legacyListEvent || migrating) return |
||||
checkLogin(async () => { |
||||
setMigrating(true) |
||||
try { |
||||
const migrated = parseProfileBadgeEntries(legacyListEvent) |
||||
if (migrated.length === 0) { |
||||
toast.error(t('No badges found in deprecated list')) |
||||
return |
||||
} |
||||
await publishEntries(migrated, t('Migrated profile badges to kind 10008')) |
||||
} finally { |
||||
setMigrating(false) |
||||
} |
||||
}) |
||||
}, [legacyListEvent, migrating, checkLogin, publishEntries, t]) |
||||
|
||||
const handleCleanList = useCallback(async () => { |
||||
if (!pubkey || cleaning) return |
||||
setCleaning(true) |
||||
try { |
||||
await publishEntries([], t('List cleaned')) |
||||
} finally { |
||||
setCleaning(false) |
||||
setCleanConfirmOpen(false) |
||||
} |
||||
}, [pubkey, cleaning, publishEntries, t]) |
||||
|
||||
const openJson = useCallback(() => { |
||||
setJsonPayload({ |
||||
profileBadgesListKind10008: listEvent ?? null, |
||||
deprecatedKind30008ProfileBadges: legacyListEvent ?? null, |
||||
derivedEntries: entries, |
||||
note: 'NIP-58 profile badges: consecutive `a` (badge definition) and `e` (badge award) tag pairs on kind 10008.' |
||||
}) |
||||
setJsonOpen(true) |
||||
}, [listEvent, legacyListEvent, entries]) |
||||
|
||||
if (!profile || !pubkey) { |
||||
return <NotFoundPage /> |
||||
} |
||||
|
||||
return ( |
||||
<SecondaryPageLayout |
||||
ref={ref} |
||||
index={index} |
||||
title={hideTitlebar ? undefined : t('Profile badges list')} |
||||
hideBackButton={hideTitlebar} |
||||
controls={ |
||||
hideTitlebar ? undefined : ( |
||||
<div className="flex items-center gap-0"> |
||||
<RefreshButton onClick={() => void loadLists()} /> |
||||
<DropdownMenu> |
||||
<DropdownMenuTrigger asChild> |
||||
<Button variant="ghost" size="icon" aria-label={t('More options')}> |
||||
<MoreVertical className="size-4" /> |
||||
</Button> |
||||
</DropdownMenuTrigger> |
||||
<DropdownMenuContent align="end"> |
||||
<DropdownMenuItem onClick={() => openJson()}> |
||||
<Code className="mr-2 size-4" /> |
||||
{t('View JSON')} |
||||
</DropdownMenuItem> |
||||
<DropdownMenuItem |
||||
className="text-destructive focus:text-destructive" |
||||
onClick={() => setCleanConfirmOpen(true)} |
||||
> |
||||
<Eraser className="mr-2 size-4" /> |
||||
{t('Clean list')} |
||||
</DropdownMenuItem> |
||||
</DropdownMenuContent> |
||||
</DropdownMenu> |
||||
</div> |
||||
) |
||||
} |
||||
displayScrollToTopButton |
||||
> |
||||
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} /> |
||||
<AlertDialog open={cleanConfirmOpen} onOpenChange={setCleanConfirmOpen}> |
||||
<AlertDialogContent> |
||||
<AlertDialogHeader> |
||||
<AlertDialogTitle>{t('Clean this list?')}</AlertDialogTitle> |
||||
<AlertDialogDescription>{t('Clean list confirm')}</AlertDialogDescription> |
||||
</AlertDialogHeader> |
||||
<AlertDialogFooter> |
||||
<AlertDialogCancel disabled={cleaning}>{t('Cancel')}</AlertDialogCancel> |
||||
<AlertDialogAction |
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" |
||||
disabled={cleaning} |
||||
onClick={(e) => { |
||||
e.preventDefault() |
||||
void handleCleanList() |
||||
}} |
||||
> |
||||
{cleaning ? t('loading...') : t('Clean list')} |
||||
</AlertDialogAction> |
||||
</AlertDialogFooter> |
||||
</AlertDialogContent> |
||||
</AlertDialog> |
||||
|
||||
<div className="space-y-4 px-4 pt-2 pb-8"> |
||||
<p className="text-sm text-muted-foreground">{t('Profile badges list intro')}</p> |
||||
|
||||
{showMigrate && ( |
||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 space-y-2"> |
||||
<p className="text-sm">{t('Profile badges migrate hint')}</p> |
||||
<Button |
||||
type="button" |
||||
variant="secondary" |
||||
size="sm" |
||||
disabled={migrating || publishing} |
||||
onClick={() => handleMigrate()} |
||||
> |
||||
{migrating ? t('loading...') : t('Migrate from kind 30008')} |
||||
</Button> |
||||
</div> |
||||
)} |
||||
|
||||
{entries.length === 0 ? ( |
||||
<p className="text-center text-sm text-muted-foreground py-4"> |
||||
{t('No profile badges on your list')} |
||||
</p> |
||||
) : ( |
||||
<div className="space-y-2"> |
||||
{entries.map((entry) => ( |
||||
<BadgeEntryRow |
||||
key={`${entry.definitionCoordinate}:${entry.awardEventId}`} |
||||
entry={entry} |
||||
onRemove={() => handleRemoveEntry(entry)} |
||||
/> |
||||
))} |
||||
</div> |
||||
)} |
||||
|
||||
<div className="rounded-lg border border-border p-4 space-y-3"> |
||||
<Label className="text-sm font-medium">{t('Add badge')}</Label> |
||||
<div className="space-y-2"> |
||||
<Input |
||||
value={newDefinitionA} |
||||
onChange={(e) => setNewDefinitionA(e.target.value)} |
||||
placeholder={t('Badge definition (a tag), e.g. 30009:pubkey:bravery')} |
||||
className="font-mono text-sm" |
||||
/> |
||||
<Input |
||||
value={newAwardE} |
||||
onChange={(e) => setNewAwardE(e.target.value)} |
||||
placeholder={t('Badge award event id (e tag)')} |
||||
className="font-mono text-sm" |
||||
/> |
||||
</div> |
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddEntry}> |
||||
{t('Add to list')} |
||||
</Button> |
||||
</div> |
||||
|
||||
<Button |
||||
type="button" |
||||
className="w-full" |
||||
disabled={publishing || migrating} |
||||
onClick={handleSave} |
||||
> |
||||
{publishing ? t('Publishing...') : t('Publish profile badges list')} |
||||
</Button> |
||||
</div> |
||||
</SecondaryPageLayout> |
||||
) |
||||
} |
||||
) |
||||
|
||||
ProfileBadgesListPage.displayName = 'ProfileBadgesListPage' |
||||
export default ProfileBadgesListPage |
||||
Loading…
Reference in new issue