mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-08 22:47:02 +02:00
feat: add Educational Resource (kind 30142) with AMB metadata support (#260)
* feat: add Educational Resource (kind 30142) with AMB metadata support - Add feed and detail renderers for AMB Educational Resource events - Add amb-helpers library with cached helper functions and tests - Handle broken thumbnail images with BookOpen placeholder fallback - Surface primary resource URL prominently in both renderers (bookmark-style) - Register kind 30142 with GraduationCap icon in kind registry - Link NIP-AMB badge to community NIP event (kind 30817) * fix: address PR #260 review comments for Educational Resource renderers - Rename Kind30142*Renderer to EducationalResource*Renderer (human-friendly names) - Localize language names with Intl.DisplayNames via shared locale-utils - Use ExternalLink component for license and reference URLs - Localize ISO dates with formatISODate, fixing UTC timezone shift bug - Remove # prefix from keyword labels in both feed and detail renderers - Remove image/thumbnail from feed renderer - Extract getBrowserLanguage to shared locale-utils, reuse in amb-helpers * fix: mock getBrowserLanguage in tests for Node < 21 compat Tests were directly mutating navigator.language which doesn't exist as a global in Node < 21, causing ReferenceError in CI.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { getNIPInfo } from "../lib/nip-icons";
|
||||
import { useAddWindow } from "@/core/state";
|
||||
import { isNipDeprecated } from "@/constants/nips";
|
||||
import { getCommunityNipForNipId } from "@/constants/kinds";
|
||||
|
||||
export interface NIPBadgeProps {
|
||||
nipNumber: string;
|
||||
@@ -26,7 +27,19 @@ export function NIPBadge({
|
||||
nipInfo?.description || `Nostr Implementation Possibility ${nipNumber}`;
|
||||
const isDeprecated = isNipDeprecated(nipNumber);
|
||||
|
||||
const communityNip = getCommunityNipForNipId(nipNumber);
|
||||
|
||||
const openNIP = () => {
|
||||
if (communityNip) {
|
||||
const pointer = {
|
||||
kind: 30817,
|
||||
pubkey: communityNip.pubkey,
|
||||
identifier: communityNip.identifier,
|
||||
relays: communityNip.relayHints,
|
||||
};
|
||||
addWindow("open", { pointer }, undefined, communityNip.title);
|
||||
return;
|
||||
}
|
||||
const paddedNum = nipNumber.toString().padStart(2, "0");
|
||||
addWindow(
|
||||
"nip",
|
||||
@@ -41,7 +54,13 @@ export function NIPBadge({
|
||||
className={`flex items-center gap-2 border bg-card px-2.5 py-1.5 text-sm hover:underline hover:decoration-dotted cursor-crosshair ${
|
||||
isDeprecated ? "opacity-50" : ""
|
||||
} ${className}`}
|
||||
title={isDeprecated ? `${description} (DEPRECATED)` : description}
|
||||
title={
|
||||
isDeprecated
|
||||
? `${description} (DEPRECATED)`
|
||||
: communityNip
|
||||
? `${description} (Community NIP)`
|
||||
: description
|
||||
}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{`${showNIPPrefix ? "NIP-" : ""}${nipNumber}`}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { Copy, CopyCheck } from "lucide-react";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
import { UserName } from "../UserName";
|
||||
import { MarkdownContent } from "../MarkdownContent";
|
||||
import { KindBadge } from "@/components/KindBadge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import { formatTimestamp } from "@/hooks/useLocale";
|
||||
import { getTagValues } from "@/lib/nostr-utils";
|
||||
import { toast } from "sonner";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
|
||||
@@ -24,6 +26,12 @@ export function CommunityNIPDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
return getTagValue(event, "r");
|
||||
}, [event]);
|
||||
|
||||
const kinds = useMemo(() => {
|
||||
return getTagValues(event, "k")
|
||||
.map(Number)
|
||||
.filter((n) => !isNaN(n));
|
||||
}, [event]);
|
||||
|
||||
// Format created date using locale utility
|
||||
const createdDate = formatTimestamp(event.created_at, "long");
|
||||
|
||||
@@ -65,6 +73,19 @@ export function CommunityNIPDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
|
||||
{/* NIP Content - Markdown */}
|
||||
<MarkdownContent content={event.content} canonicalUrl={canonicalUrl} />
|
||||
|
||||
{kinds.length > 0 && (
|
||||
<div className="mt-6 pt-4 border-t border-border">
|
||||
<h3 className="text-sm font-bold mb-3">
|
||||
Event Kinds Defined in {title}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{kinds.map((kind) => (
|
||||
<KindBadge key={kind} kind={kind} variant="full" clickable />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
306
src/components/nostr/kinds/EducationalResourceDetailRenderer.tsx
Normal file
306
src/components/nostr/kinds/EducationalResourceDetailRenderer.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import { useMemo } from "react";
|
||||
import { NostrEvent } from "@/types/nostr";
|
||||
import { useAddWindow } from "@/core/state";
|
||||
import {
|
||||
getAmbName,
|
||||
getAmbDescription,
|
||||
getAmbImage,
|
||||
getAmbLanguage,
|
||||
getAmbTypes,
|
||||
getAmbKeywords,
|
||||
getAmbCreators,
|
||||
getAmbLearningResourceType,
|
||||
getAmbEducationalLevel,
|
||||
getAmbAudience,
|
||||
getAmbSubjects,
|
||||
getAmbLicenseId,
|
||||
getAmbIsAccessibleForFree,
|
||||
getAmbExternalUrls,
|
||||
getAmbRelatedResources,
|
||||
getAmbDateCreated,
|
||||
getAmbDatePublished,
|
||||
} from "@/lib/amb-helpers";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { UserName } from "@/components/nostr/UserName";
|
||||
import { MediaEmbed } from "../MediaEmbed";
|
||||
import { ExternalLink } from "@/components/ExternalLink";
|
||||
import { formatLanguageName, formatISODate } from "@/lib/locale-utils";
|
||||
|
||||
interface EducationalResourceDetailRendererProps {
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 30142 - Educational Resource (AMB)
|
||||
* Full metadata view with all AMB properties
|
||||
*/
|
||||
export function EducationalResourceDetailRenderer({
|
||||
event,
|
||||
}: EducationalResourceDetailRendererProps) {
|
||||
const addWindow = useAddWindow();
|
||||
|
||||
const name = getAmbName(event);
|
||||
const description = getAmbDescription(event);
|
||||
const image = getAmbImage(event);
|
||||
const language = getAmbLanguage(event);
|
||||
const types = getAmbTypes(event);
|
||||
const keywords = getAmbKeywords(event);
|
||||
const creators = getAmbCreators(event);
|
||||
const learningResourceType = getAmbLearningResourceType(event);
|
||||
const educationalLevel = getAmbEducationalLevel(event);
|
||||
const audience = getAmbAudience(event);
|
||||
const subjects = getAmbSubjects(event);
|
||||
const licenseId = getAmbLicenseId(event);
|
||||
const isAccessibleForFree = getAmbIsAccessibleForFree(event);
|
||||
const externalUrls = getAmbExternalUrls(event);
|
||||
const relatedResources = getAmbRelatedResources(event);
|
||||
const dateCreated = getAmbDateCreated(event);
|
||||
const datePublished = getAmbDatePublished(event);
|
||||
|
||||
const licenseLabel = useMemo(() => {
|
||||
if (!licenseId) return undefined;
|
||||
// Extract short CC label from URI
|
||||
const ccMatch = licenseId.match(
|
||||
/creativecommons\.org\/licenses?\/([\w-]+)/,
|
||||
);
|
||||
if (ccMatch) return `CC ${ccMatch[1].toUpperCase()}`;
|
||||
return licenseId;
|
||||
}, [licenseId]);
|
||||
|
||||
const handleRelatedClick = (address: string) => {
|
||||
try {
|
||||
const [kindStr, pubkey, ...identifierParts] = address.split(":");
|
||||
const pointer = {
|
||||
kind: parseInt(kindStr),
|
||||
pubkey,
|
||||
identifier: identifierParts.join(":"),
|
||||
};
|
||||
addWindow("open", { pointer });
|
||||
} catch {
|
||||
// ignore malformed address
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-bold">{name || "Untitled Resource"}</h1>
|
||||
{types.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{types.map((type) => (
|
||||
<Label key={type} size="md">
|
||||
{type}
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{externalUrls[0] && (
|
||||
<ExternalLink href={externalUrls[0]} size="sm">
|
||||
{formatReferenceLabel(externalUrls[0])}
|
||||
</ExternalLink>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
{image && <MediaEmbed url={image} preset="preview" enableZoom />}
|
||||
|
||||
{/* Description */}
|
||||
{description && <p className="text-sm">{description}</p>}
|
||||
|
||||
{/* Metadata Grid */}
|
||||
<div className="grid grid-cols-2 gap-3 py-2 text-sm">
|
||||
{language && (
|
||||
<MetadataField label="Language">
|
||||
{formatLanguageName(language)}
|
||||
</MetadataField>
|
||||
)}
|
||||
|
||||
{educationalLevel && (
|
||||
<MetadataField label="Educational Level">
|
||||
{educationalLevel.label || educationalLevel.id}
|
||||
</MetadataField>
|
||||
)}
|
||||
|
||||
{learningResourceType && (
|
||||
<MetadataField label="Resource Type">
|
||||
{learningResourceType.label || learningResourceType.id}
|
||||
</MetadataField>
|
||||
)}
|
||||
|
||||
{audience && (
|
||||
<MetadataField label="Audience">
|
||||
{audience.label || audience.id}
|
||||
</MetadataField>
|
||||
)}
|
||||
|
||||
{licenseId && (
|
||||
<MetadataField label="License">
|
||||
{licenseId.startsWith("http") ? (
|
||||
<ExternalLink href={licenseId} variant="default" size="sm">
|
||||
{licenseLabel}
|
||||
</ExternalLink>
|
||||
) : (
|
||||
<span>{licenseLabel}</span>
|
||||
)}
|
||||
</MetadataField>
|
||||
)}
|
||||
|
||||
{isAccessibleForFree !== undefined && (
|
||||
<MetadataField label="Free Access">
|
||||
{isAccessibleForFree ? "Yes" : "No"}
|
||||
</MetadataField>
|
||||
)}
|
||||
|
||||
{dateCreated && (
|
||||
<MetadataField label="Created">
|
||||
{formatISODate(dateCreated)}
|
||||
</MetadataField>
|
||||
)}
|
||||
|
||||
{datePublished && (
|
||||
<MetadataField label="Published">
|
||||
{formatISODate(datePublished)}
|
||||
</MetadataField>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Creators */}
|
||||
{creators.length > 0 && (
|
||||
<Section title="Creators">
|
||||
<div className="flex flex-col gap-2">
|
||||
{creators.map((creator, i) => (
|
||||
<div
|
||||
key={creator.pubkey || creator.name || i}
|
||||
className="text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{creator.pubkey ? (
|
||||
<UserName
|
||||
pubkey={creator.pubkey}
|
||||
relayHints={
|
||||
creator.relayHint ? [creator.relayHint] : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="font-medium">
|
||||
{creator.name || "Unknown"}
|
||||
</span>
|
||||
)}
|
||||
{creator.type && (
|
||||
<span className="text-muted-foreground">
|
||||
({creator.type})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{creator.affiliationName && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
{creator.affiliationName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Subjects */}
|
||||
{subjects.length > 0 && (
|
||||
<Section title="Subjects">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{subjects.map((subject, i) => (
|
||||
<Label key={subject.id || i} size="md">
|
||||
{subject.label || subject.id}
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Keywords */}
|
||||
{keywords.length > 0 && (
|
||||
<Section title="Keywords">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{keywords.map((kw) => (
|
||||
<Label key={kw}>{kw}</Label>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* External References */}
|
||||
{externalUrls.length > 0 && (
|
||||
<Section title="References">
|
||||
<div className="flex flex-col gap-1">
|
||||
{externalUrls.map((url) => (
|
||||
<ExternalLink key={url} href={url} variant="default" size="sm">
|
||||
{formatReferenceLabel(url)}
|
||||
</ExternalLink>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Related Resources */}
|
||||
{relatedResources.length > 0 && (
|
||||
<Section title="Related Resources">
|
||||
<div className="flex flex-col gap-1">
|
||||
{relatedResources.map((res, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<button
|
||||
onClick={() => handleRelatedClick(res.address)}
|
||||
className="text-primary hover:underline cursor-crosshair truncate"
|
||||
>
|
||||
{res.address.split(":").slice(2).join(":") || res.address}
|
||||
</button>
|
||||
{res.relationship && <Label>{res.relationship}</Label>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataField({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-muted-foreground">{label}</h3>
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Format a reference URL for display (detect DOI, ISBN) */
|
||||
function formatReferenceLabel(url: string): string {
|
||||
if (url.includes("doi.org/")) {
|
||||
const doi = url.split("doi.org/")[1];
|
||||
return `DOI: ${doi}`;
|
||||
}
|
||||
if (url.startsWith("urn:isbn:")) {
|
||||
return `ISBN: ${url.replace("urn:isbn:", "")}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
117
src/components/nostr/kinds/EducationalResourceRenderer.tsx
Normal file
117
src/components/nostr/kinds/EducationalResourceRenderer.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
BaseEventContainer,
|
||||
BaseEventProps,
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import {
|
||||
getAmbName,
|
||||
getAmbDescription,
|
||||
getAmbLanguage,
|
||||
getAmbTypes,
|
||||
getAmbKeywords,
|
||||
getAmbEducationalLevel,
|
||||
getAmbLearningResourceType,
|
||||
getAmbCreators,
|
||||
getAmbExternalUrls,
|
||||
} from "@/lib/amb-helpers";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { UserName } from "@/components/nostr/UserName";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { formatLanguageName } from "@/lib/locale-utils";
|
||||
|
||||
/**
|
||||
* Feed renderer for Kind 30142 - Educational Resource (AMB)
|
||||
* Compact card showing title, types, language, description, keywords, and creators
|
||||
*/
|
||||
export function EducationalResourceRenderer({ event }: BaseEventProps) {
|
||||
const name = getAmbName(event);
|
||||
const description = getAmbDescription(event);
|
||||
const language = getAmbLanguage(event);
|
||||
const types = getAmbTypes(event);
|
||||
const keywords = getAmbKeywords(event);
|
||||
const educationalLevel = getAmbEducationalLevel(event);
|
||||
const learningResourceType = getAmbLearningResourceType(event);
|
||||
const creators = getAmbCreators(event);
|
||||
const externalUrls = getAmbExternalUrls(event);
|
||||
const primaryUrl = externalUrls[0];
|
||||
const displayUrl = primaryUrl?.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2 min-w-0">
|
||||
{/* Title */}
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
className="text-lg font-semibold text-foreground"
|
||||
>
|
||||
{name || "Untitled Resource"}
|
||||
</ClickableEventTitle>
|
||||
|
||||
{/* Primary URL */}
|
||||
{primaryUrl && (
|
||||
<a
|
||||
href={primaryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-muted-foreground hover:underline hover:decoration-dotted"
|
||||
>
|
||||
<ExternalLink className="size-4 flex-shrink-0" />
|
||||
<span className="text-sm truncate">{displayUrl}</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Badges row: types, language, educational level, resource type */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{types.map((type) => (
|
||||
<Label key={type}>{type}</Label>
|
||||
))}
|
||||
{language && <Label>{formatLanguageName(language)}</Label>}
|
||||
{educationalLevel?.label && <Label>{educationalLevel.label}</Label>}
|
||||
{learningResourceType?.label && (
|
||||
<Label>{learningResourceType.label}</Label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Keywords */}
|
||||
{keywords.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{keywords.map((kw) => (
|
||||
<Label key={kw} className="text-primary/80">
|
||||
{kw}
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Creators */}
|
||||
{creators.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>by</span>
|
||||
{creators.map((creator, i) => (
|
||||
<span key={creator.pubkey || creator.name || i}>
|
||||
{i > 0 && ", "}
|
||||
{creator.pubkey ? (
|
||||
<UserName
|
||||
pubkey={creator.pubkey}
|
||||
relayHints={
|
||||
creator.relayHint ? [creator.relayHint] : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span>{creator.name || "Unknown"}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
@@ -170,6 +170,8 @@ import {
|
||||
MusicTrackDetailRenderer,
|
||||
} from "./MusicTrackRenderer";
|
||||
import { PlaylistRenderer, PlaylistDetailRenderer } from "./PlaylistRenderer";
|
||||
import { EducationalResourceRenderer } from "./EducationalResourceRenderer";
|
||||
import { EducationalResourceDetailRenderer } from "./EducationalResourceDetailRenderer";
|
||||
|
||||
/**
|
||||
* Registry of kind-specific renderers
|
||||
@@ -245,6 +247,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
30023: Kind30023Renderer, // Long-form Article
|
||||
30030: EmojiSetRenderer, // Emoji Sets (NIP-30)
|
||||
30063: ZapstoreReleaseRenderer, // Zapstore App Release
|
||||
30142: EducationalResourceRenderer, // Educational Resource (AMB)
|
||||
30166: RelayDiscoveryRenderer, // Relay Discovery (NIP-66)
|
||||
30267: ZapstoreAppSetRenderer, // Zapstore App Collection
|
||||
30311: LiveActivityRenderer, // Live Streaming Event (NIP-53)
|
||||
@@ -358,6 +361,7 @@ const detailRenderers: Record<
|
||||
30023: Kind30023DetailRenderer, // Long-form Article Detail
|
||||
30030: EmojiSetDetailRenderer, // Emoji Sets Detail (NIP-30)
|
||||
30063: ZapstoreReleaseDetailRenderer, // Zapstore App Release Detail
|
||||
30142: EducationalResourceDetailRenderer, // Educational Resource Detail (AMB)
|
||||
30166: RelayDiscoveryDetailRenderer, // Relay Discovery Detail (NIP-66)
|
||||
30267: ZapstoreAppSetDetailRenderer, // Zapstore App Collection Detail
|
||||
30311: LiveActivityDetailRenderer, // Live Streaming Event Detail (NIP-53)
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
GitBranch,
|
||||
GitMerge,
|
||||
GitPullRequest,
|
||||
GraduationCap,
|
||||
BookHeart,
|
||||
HardDrive,
|
||||
Hash,
|
||||
@@ -74,12 +75,20 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface CommunityNip {
|
||||
title: string;
|
||||
identifier: string;
|
||||
pubkey: string;
|
||||
relayHints?: string[];
|
||||
}
|
||||
|
||||
export interface EventKind {
|
||||
kind: number | string;
|
||||
name: string;
|
||||
description: string;
|
||||
nip: string;
|
||||
icon: LucideIcon;
|
||||
communityNip?: CommunityNip;
|
||||
}
|
||||
|
||||
export const SPELL_KIND = 777;
|
||||
@@ -1211,6 +1220,24 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
nip: "78",
|
||||
icon: Settings,
|
||||
},
|
||||
30142: {
|
||||
kind: 30142,
|
||||
name: "Educational Resource",
|
||||
description: "AMB Educational Resource Metadata",
|
||||
nip: "AMB",
|
||||
icon: GraduationCap,
|
||||
communityNip: {
|
||||
title: "NIP-AMB",
|
||||
identifier: "edufeed-amb",
|
||||
pubkey:
|
||||
"bdc21f93b1e2cb75608cecd7a0a00a779779d9367dc9798bd9f213f06c95bc48",
|
||||
relayHints: [
|
||||
"wss://relay.nostr.band",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.damus.io",
|
||||
],
|
||||
},
|
||||
},
|
||||
30166: {
|
||||
kind: 30166,
|
||||
name: "Relay Discovery",
|
||||
@@ -1525,3 +1552,14 @@ export function getKindName(kind: number): string {
|
||||
export function getKindIcon(kind: number): LucideIcon {
|
||||
return EVENT_KINDS[kind]?.icon || MessageSquare;
|
||||
}
|
||||
|
||||
export function getCommunityNipForNipId(
|
||||
nipId: string,
|
||||
): CommunityNip | undefined {
|
||||
for (const entry of Object.values(EVENT_KINDS)) {
|
||||
if (entry.nip === nipId && entry.communityNip) {
|
||||
return entry.communityNip;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
363
src/lib/amb-helpers.test.ts
Normal file
363
src/lib/amb-helpers.test.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import {
|
||||
getAmbCreators,
|
||||
getAmbRelatedResources,
|
||||
getAmbIsAccessibleForFree,
|
||||
getAmbDescription,
|
||||
} from "./amb-helpers";
|
||||
|
||||
// Mock locale-utils so we can control getBrowserLanguage without relying on navigator
|
||||
vi.mock("@/lib/locale-utils", () => ({
|
||||
getBrowserLanguage: vi.fn(() => "en"),
|
||||
}));
|
||||
|
||||
import { getBrowserLanguage } from "@/lib/locale-utils";
|
||||
const mockGetBrowserLanguage = vi.mocked(getBrowserLanguage);
|
||||
|
||||
// Helper to build a minimal event with specific tags
|
||||
function makeEvent(tags: string[][], content = ""): NostrEvent {
|
||||
return {
|
||||
id: "test",
|
||||
pubkey: "test",
|
||||
created_at: 0,
|
||||
kind: 30142,
|
||||
tags,
|
||||
content,
|
||||
sig: "test",
|
||||
};
|
||||
}
|
||||
|
||||
describe("amb-helpers", () => {
|
||||
beforeEach(() => {
|
||||
mockGetBrowserLanguage.mockReturnValue("en");
|
||||
});
|
||||
|
||||
describe("findPrefLabel (via getAmbLearningResourceType)", () => {
|
||||
it("should return browser language label when available", async () => {
|
||||
mockGetBrowserLanguage.mockReturnValue("de");
|
||||
const { getAmbLearningResourceType } = await import("./amb-helpers.ts");
|
||||
|
||||
const event = makeEvent([
|
||||
["learningResourceType:id", "https://example.org/type/1"],
|
||||
["learningResourceType:prefLabel:en", "Course"],
|
||||
["learningResourceType:prefLabel:de", "Kurs"],
|
||||
]);
|
||||
|
||||
const result = getAmbLearningResourceType(event);
|
||||
expect(result?.label).toBe("Kurs");
|
||||
});
|
||||
|
||||
it("should fall back to English when browser language not available", async () => {
|
||||
mockGetBrowserLanguage.mockReturnValue("fr");
|
||||
const { getAmbEducationalLevel } = await import("./amb-helpers.ts");
|
||||
|
||||
const event = makeEvent([
|
||||
["educationalLevel:id", "https://example.org/level/1"],
|
||||
["educationalLevel:prefLabel:de", "Grundschule"],
|
||||
["educationalLevel:prefLabel:en", "Primary School"],
|
||||
]);
|
||||
|
||||
const result = getAmbEducationalLevel(event);
|
||||
expect(result?.label).toBe("Primary School");
|
||||
});
|
||||
|
||||
it("should fall back to first available when neither browser lang nor en exists", async () => {
|
||||
mockGetBrowserLanguage.mockReturnValue("ja");
|
||||
const { getAmbAudience } = await import("./amb-helpers.ts");
|
||||
|
||||
const event = makeEvent([
|
||||
["audience:id", "https://example.org/audience/1"],
|
||||
["audience:prefLabel:de", "Lernende"],
|
||||
["audience:prefLabel:es", "Estudiantes"],
|
||||
]);
|
||||
|
||||
const result = getAmbAudience(event);
|
||||
expect(result?.label).toBe("Lernende");
|
||||
});
|
||||
|
||||
it("should return undefined when no labels exist", async () => {
|
||||
mockGetBrowserLanguage.mockReturnValue("en");
|
||||
const { getAmbLearningResourceType } = await import("./amb-helpers.ts");
|
||||
|
||||
const event = makeEvent([
|
||||
["learningResourceType:id", "https://example.org/type/1"],
|
||||
]);
|
||||
|
||||
const result = getAmbLearningResourceType(event);
|
||||
expect(result?.label).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAmbSubjects", () => {
|
||||
it("should pair ids with labels from preferred language only", async () => {
|
||||
mockGetBrowserLanguage.mockReturnValue("de");
|
||||
const { getAmbSubjects } = await import("./amb-helpers.ts");
|
||||
|
||||
const event = makeEvent([
|
||||
["about:id", "https://example.org/subject/math"],
|
||||
["about:id", "https://example.org/subject/physics"],
|
||||
["about:prefLabel:de", "Mathematik"],
|
||||
["about:prefLabel:de", "Physik"],
|
||||
["about:prefLabel:en", "Mathematics"],
|
||||
["about:prefLabel:en", "Physics"],
|
||||
]);
|
||||
|
||||
const subjects = getAmbSubjects(event);
|
||||
expect(subjects).toHaveLength(2);
|
||||
expect(subjects[0]).toEqual({
|
||||
id: "https://example.org/subject/math",
|
||||
label: "Mathematik",
|
||||
});
|
||||
expect(subjects[1]).toEqual({
|
||||
id: "https://example.org/subject/physics",
|
||||
label: "Physik",
|
||||
});
|
||||
});
|
||||
|
||||
it("should fall back to English labels", async () => {
|
||||
mockGetBrowserLanguage.mockReturnValue("ja");
|
||||
const { getAmbSubjects } = await import("./amb-helpers.ts");
|
||||
|
||||
const event = makeEvent([
|
||||
["about:id", "https://example.org/subject/math"],
|
||||
["about:prefLabel:de", "Mathematik"],
|
||||
["about:prefLabel:en", "Mathematics"],
|
||||
]);
|
||||
|
||||
const subjects = getAmbSubjects(event);
|
||||
expect(subjects).toHaveLength(1);
|
||||
expect(subjects[0].label).toBe("Mathematics");
|
||||
});
|
||||
|
||||
it("should handle single language correctly", async () => {
|
||||
mockGetBrowserLanguage.mockReturnValue("en");
|
||||
const { getAmbSubjects } = await import("./amb-helpers.ts");
|
||||
|
||||
const event = makeEvent([
|
||||
["about:id", "https://example.org/subject/art"],
|
||||
["about:prefLabel:de", "Kunst"],
|
||||
]);
|
||||
|
||||
const subjects = getAmbSubjects(event);
|
||||
expect(subjects).toHaveLength(1);
|
||||
expect(subjects[0].label).toBe("Kunst");
|
||||
});
|
||||
|
||||
it("should handle no labels gracefully", async () => {
|
||||
mockGetBrowserLanguage.mockReturnValue("en");
|
||||
const { getAmbSubjects } = await import("./amb-helpers.ts");
|
||||
|
||||
const event = makeEvent([
|
||||
["about:id", "https://example.org/subject/math"],
|
||||
["about:id", "https://example.org/subject/physics"],
|
||||
]);
|
||||
|
||||
const subjects = getAmbSubjects(event);
|
||||
expect(subjects).toHaveLength(2);
|
||||
expect(subjects[0]).toEqual({
|
||||
id: "https://example.org/subject/math",
|
||||
label: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle more labels than ids", async () => {
|
||||
mockGetBrowserLanguage.mockReturnValue("en");
|
||||
const { getAmbSubjects } = await import("./amb-helpers.ts");
|
||||
|
||||
const event = makeEvent([
|
||||
["about:id", "https://example.org/subject/math"],
|
||||
["about:prefLabel:en", "Mathematics"],
|
||||
["about:prefLabel:en", "Physics"],
|
||||
]);
|
||||
|
||||
const subjects = getAmbSubjects(event);
|
||||
expect(subjects).toHaveLength(2);
|
||||
expect(subjects[1]).toEqual({
|
||||
id: undefined,
|
||||
label: "Physics",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAmbCreators", () => {
|
||||
it("should extract a p-tag only creator", () => {
|
||||
const event = makeEvent([
|
||||
["p", "abc123", "wss://relay.example.com", "creator"],
|
||||
]);
|
||||
|
||||
const creators = getAmbCreators(event);
|
||||
expect(creators).toEqual([
|
||||
{ pubkey: "abc123", relayHint: "wss://relay.example.com" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract a flattened-tag only creator", () => {
|
||||
const event = makeEvent([
|
||||
["creator:name", "Alice"],
|
||||
["creator:type", "Person"],
|
||||
["creator:affiliation:name", "MIT"],
|
||||
["creator:id", "https://orcid.org/0000-0001"],
|
||||
]);
|
||||
|
||||
const creators = getAmbCreators(event);
|
||||
expect(creators).toEqual([
|
||||
{
|
||||
name: "Alice",
|
||||
type: "Person",
|
||||
affiliationName: "MIT",
|
||||
id: "https://orcid.org/0000-0001",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should merge flattened tags into the first p-tag creator", () => {
|
||||
const event = makeEvent([
|
||||
["p", "abc123", "wss://relay.example.com", "creator"],
|
||||
["creator:name", "Alice"],
|
||||
["creator:type", "Person"],
|
||||
]);
|
||||
|
||||
const creators = getAmbCreators(event);
|
||||
expect(creators).toHaveLength(1);
|
||||
expect(creators[0]).toEqual({
|
||||
pubkey: "abc123",
|
||||
relayHint: "wss://relay.example.com",
|
||||
name: "Alice",
|
||||
type: "Person",
|
||||
affiliationName: undefined,
|
||||
id: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should only merge into the first p-tag creator", () => {
|
||||
const event = makeEvent([
|
||||
["p", "abc123", "", "creator"],
|
||||
["p", "def456", "wss://relay2.example.com", "creator"],
|
||||
["creator:name", "Alice"],
|
||||
]);
|
||||
|
||||
const creators = getAmbCreators(event);
|
||||
expect(creators).toHaveLength(2);
|
||||
// First creator gets the merged name
|
||||
expect(creators[0].pubkey).toBe("abc123");
|
||||
expect(creators[0].name).toBe("Alice");
|
||||
// Second creator has no name
|
||||
expect(creators[1].pubkey).toBe("def456");
|
||||
expect(creators[1].name).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should ignore p tags without creator role", () => {
|
||||
const event = makeEvent([
|
||||
["p", "abc123", "", "mention"],
|
||||
["p", "def456"],
|
||||
]);
|
||||
|
||||
const creators = getAmbCreators(event);
|
||||
expect(creators).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle p-tag with empty relay hint", () => {
|
||||
const event = makeEvent([["p", "abc123", "", "creator"]]);
|
||||
|
||||
const creators = getAmbCreators(event);
|
||||
expect(creators).toEqual([{ pubkey: "abc123", relayHint: undefined }]);
|
||||
});
|
||||
|
||||
it("should return empty array for event with no creator tags", () => {
|
||||
const event = makeEvent([
|
||||
["t", "education"],
|
||||
["type", "LearningResource"],
|
||||
]);
|
||||
|
||||
const creators = getAmbCreators(event);
|
||||
expect(creators).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAmbRelatedResources", () => {
|
||||
it("should extract a related resource with all fields", () => {
|
||||
const event = makeEvent([
|
||||
["a", "30142:pubkey123:d-tag", "wss://relay.example.com", "isPartOf"],
|
||||
]);
|
||||
|
||||
const resources = getAmbRelatedResources(event);
|
||||
expect(resources).toEqual([
|
||||
{
|
||||
address: "30142:pubkey123:d-tag",
|
||||
relayHint: "wss://relay.example.com",
|
||||
relationship: "isPartOf",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should exclude non-30142 a tags", () => {
|
||||
const event = makeEvent([
|
||||
["a", "30142:pubkey123:d-tag", "", "isPartOf"],
|
||||
["a", "30023:pubkey456:d-tag", "wss://relay.example.com"],
|
||||
["a", "10002:pubkey789:"],
|
||||
]);
|
||||
|
||||
const resources = getAmbRelatedResources(event);
|
||||
expect(resources).toHaveLength(1);
|
||||
expect(resources[0].address).toBe("30142:pubkey123:d-tag");
|
||||
});
|
||||
|
||||
it("should handle missing relay hint and relationship", () => {
|
||||
const event = makeEvent([["a", "30142:pubkey123:d-tag"]]);
|
||||
|
||||
const resources = getAmbRelatedResources(event);
|
||||
expect(resources).toEqual([
|
||||
{
|
||||
address: "30142:pubkey123:d-tag",
|
||||
relayHint: undefined,
|
||||
relationship: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return empty array when no a tags exist", () => {
|
||||
const event = makeEvent([["t", "education"]]);
|
||||
|
||||
const resources = getAmbRelatedResources(event);
|
||||
expect(resources).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAmbIsAccessibleForFree", () => {
|
||||
it("should return true for 'true'", () => {
|
||||
const event = makeEvent([["isAccessibleForFree", "true"]]);
|
||||
expect(getAmbIsAccessibleForFree(event)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for 'false'", () => {
|
||||
const event = makeEvent([["isAccessibleForFree", "false"]]);
|
||||
expect(getAmbIsAccessibleForFree(event)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return undefined when tag is missing", () => {
|
||||
const event = makeEvent([["t", "education"]]);
|
||||
expect(getAmbIsAccessibleForFree(event)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAmbDescription", () => {
|
||||
it("should use event.content when present", () => {
|
||||
const event = makeEvent(
|
||||
[["description", "tag description"]],
|
||||
"content description",
|
||||
);
|
||||
expect(getAmbDescription(event)).toBe("content description");
|
||||
});
|
||||
|
||||
it("should fall back to description tag when content is empty", () => {
|
||||
const event = makeEvent([["description", "tag description"]], "");
|
||||
expect(getAmbDescription(event)).toBe("tag description");
|
||||
});
|
||||
|
||||
it("should return undefined when both content and tag are missing", () => {
|
||||
const event = makeEvent([["t", "education"]], "");
|
||||
expect(getAmbDescription(event)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
280
src/lib/amb-helpers.ts
Normal file
280
src/lib/amb-helpers.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { getTagValue, getOrComputeCachedValue } from "applesauce-core/helpers";
|
||||
import { getBrowserLanguage } from "@/lib/locale-utils";
|
||||
|
||||
/**
|
||||
* AMB (Allgemeines Metadatenprofil für Bildungsressourcen) Helpers
|
||||
* Extract metadata from kind 30142 educational resource events
|
||||
*
|
||||
* Uses flattened tag convention: nested JSON-LD properties are
|
||||
* represented as colon-delimited tag names (e.g., "creator:name").
|
||||
*
|
||||
* All cached helpers use getOrComputeCachedValue to avoid
|
||||
* recomputation — no useMemo needed in components.
|
||||
*/
|
||||
|
||||
// Cache symbols
|
||||
const TypesSymbol = Symbol("ambTypes");
|
||||
const KeywordsSymbol = Symbol("ambKeywords");
|
||||
const CreatorsSymbol = Symbol("ambCreators");
|
||||
const LearningResourceTypeSymbol = Symbol("ambLearningResourceType");
|
||||
const EducationalLevelSymbol = Symbol("ambEducationalLevel");
|
||||
const SubjectsSymbol = Symbol("ambSubjects");
|
||||
const ExternalUrlsSymbol = Symbol("ambExternalUrls");
|
||||
const RelatedResourcesSymbol = Symbol("ambRelatedResources");
|
||||
const AudienceSymbol = Symbol("ambAudience");
|
||||
|
||||
// ============================================================================
|
||||
// Simple helpers (direct tag reads)
|
||||
// ============================================================================
|
||||
|
||||
export function getAmbName(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "name");
|
||||
}
|
||||
|
||||
export function getAmbImage(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "image");
|
||||
}
|
||||
|
||||
export function getAmbDescription(event: NostrEvent): string | undefined {
|
||||
return event.content || getTagValue(event, "description");
|
||||
}
|
||||
|
||||
export function getAmbLanguage(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "inLanguage");
|
||||
}
|
||||
|
||||
export function getAmbLicenseId(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "license:id");
|
||||
}
|
||||
|
||||
export function getAmbIsAccessibleForFree(
|
||||
event: NostrEvent,
|
||||
): boolean | undefined {
|
||||
const val = getTagValue(event, "isAccessibleForFree");
|
||||
if (val === "true") return true;
|
||||
if (val === "false") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getAmbDateCreated(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "dateCreated");
|
||||
}
|
||||
|
||||
export function getAmbDatePublished(event: NostrEvent): string | undefined {
|
||||
return getTagValue(event, "datePublished");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cached helpers (iterate tags)
|
||||
// ============================================================================
|
||||
|
||||
/** All `type` tag values (e.g., ["LearningResource", "Course"]) */
|
||||
export function getAmbTypes(event: NostrEvent): string[] {
|
||||
return getOrComputeCachedValue(event, TypesSymbol, () =>
|
||||
event.tags.filter((t) => t[0] === "type" && t[1]).map((t) => t[1]),
|
||||
);
|
||||
}
|
||||
|
||||
/** All `t` tag values (keywords) */
|
||||
export function getAmbKeywords(event: NostrEvent): string[] {
|
||||
return getOrComputeCachedValue(event, KeywordsSymbol, () =>
|
||||
event.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1]),
|
||||
);
|
||||
}
|
||||
|
||||
export interface AmbCreator {
|
||||
pubkey?: string;
|
||||
relayHint?: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
affiliationName?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract creators from both `p` tags with "creator" role
|
||||
* and `creator:*` flattened tags.
|
||||
*/
|
||||
export function getAmbCreators(event: NostrEvent): AmbCreator[] {
|
||||
return getOrComputeCachedValue(event, CreatorsSymbol, () => {
|
||||
const creators: AmbCreator[] = [];
|
||||
|
||||
// Nostr-native creators from p tags with "creator" role
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === "p" && tag[3] === "creator" && tag[1]) {
|
||||
creators.push({
|
||||
pubkey: tag[1],
|
||||
relayHint: tag[2] || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// External creators from flattened tags
|
||||
const creatorName = getTagValue(event, "creator:name");
|
||||
const creatorType = getTagValue(event, "creator:type");
|
||||
const creatorAffiliation = getTagValue(event, "creator:affiliation:name");
|
||||
const creatorId = getTagValue(event, "creator:id");
|
||||
|
||||
if (creatorName || creatorType || creatorAffiliation || creatorId) {
|
||||
// Merge with the first p-tag creator if it exists, otherwise create new
|
||||
const existingNostr = creators.find((c) => c.pubkey);
|
||||
if (existingNostr) {
|
||||
existingNostr.name = creatorName;
|
||||
existingNostr.type = creatorType;
|
||||
existingNostr.affiliationName = creatorAffiliation;
|
||||
existingNostr.id = creatorId;
|
||||
} else {
|
||||
creators.push({
|
||||
name: creatorName,
|
||||
type: creatorType,
|
||||
affiliationName: creatorAffiliation,
|
||||
id: creatorId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return creators;
|
||||
});
|
||||
}
|
||||
|
||||
export interface AmbConceptRef {
|
||||
id?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** learningResourceType from flattened tags */
|
||||
export function getAmbLearningResourceType(
|
||||
event: NostrEvent,
|
||||
): AmbConceptRef | undefined {
|
||||
return getOrComputeCachedValue(event, LearningResourceTypeSymbol, () => {
|
||||
const id = getTagValue(event, "learningResourceType:id");
|
||||
const label = findPrefLabel(event, "learningResourceType");
|
||||
if (!id && !label) return undefined;
|
||||
return { id, label };
|
||||
});
|
||||
}
|
||||
|
||||
/** educationalLevel from flattened tags */
|
||||
export function getAmbEducationalLevel(
|
||||
event: NostrEvent,
|
||||
): AmbConceptRef | undefined {
|
||||
return getOrComputeCachedValue(event, EducationalLevelSymbol, () => {
|
||||
const id = getTagValue(event, "educationalLevel:id");
|
||||
const label = findPrefLabel(event, "educationalLevel");
|
||||
if (!id && !label) return undefined;
|
||||
return { id, label };
|
||||
});
|
||||
}
|
||||
|
||||
/** audience from flattened tags */
|
||||
export function getAmbAudience(event: NostrEvent): AmbConceptRef | undefined {
|
||||
return getOrComputeCachedValue(event, AudienceSymbol, () => {
|
||||
const id = getTagValue(event, "audience:id");
|
||||
const label = findPrefLabel(event, "audience");
|
||||
if (!id && !label) return undefined;
|
||||
return { id, label };
|
||||
});
|
||||
}
|
||||
|
||||
/** All about:* subjects as concept references */
|
||||
export function getAmbSubjects(event: NostrEvent): AmbConceptRef[] {
|
||||
return getOrComputeCachedValue(event, SubjectsSymbol, () => {
|
||||
const ids: string[] = [];
|
||||
// Group labels by language: Map<lang, labels[]>
|
||||
const labelsByLang = new Map<string, string[]>();
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === "about:id" && tag[1]) {
|
||||
ids.push(tag[1]);
|
||||
}
|
||||
if (tag[0]?.startsWith("about:prefLabel:") && tag[1]) {
|
||||
const lang = tag[0].slice("about:prefLabel:".length);
|
||||
let arr = labelsByLang.get(lang);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
labelsByLang.set(lang, arr);
|
||||
}
|
||||
arr.push(tag[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Pick best language set: browser lang > "en" > first available
|
||||
const browserLang = getBrowserLanguage();
|
||||
const labels =
|
||||
labelsByLang.get(browserLang) ??
|
||||
labelsByLang.get("en") ??
|
||||
labelsByLang.values().next().value ??
|
||||
[];
|
||||
|
||||
// Pair ids with labels positionally
|
||||
const count = Math.max(ids.length, labels.length);
|
||||
const subjects: AmbConceptRef[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
subjects.push({
|
||||
id: ids[i],
|
||||
label: labels[i],
|
||||
});
|
||||
}
|
||||
|
||||
return subjects;
|
||||
});
|
||||
}
|
||||
|
||||
/** All `r` tag values (external URLs) */
|
||||
export function getAmbExternalUrls(event: NostrEvent): string[] {
|
||||
return getOrComputeCachedValue(event, ExternalUrlsSymbol, () =>
|
||||
event.tags.filter((t) => t[0] === "r" && t[1]).map((t) => t[1]),
|
||||
);
|
||||
}
|
||||
|
||||
export interface AmbRelatedResource {
|
||||
address: string;
|
||||
relayHint?: string;
|
||||
relationship?: string;
|
||||
}
|
||||
|
||||
/** `a` tags pointing to other 30142 events with relationship type */
|
||||
export function getAmbRelatedResources(
|
||||
event: NostrEvent,
|
||||
): AmbRelatedResource[] {
|
||||
return getOrComputeCachedValue(event, RelatedResourcesSymbol, () =>
|
||||
event.tags
|
||||
.filter((t) => t[0] === "a" && t[1]?.startsWith("30142:"))
|
||||
.map((t) => ({
|
||||
address: t[1],
|
||||
relayHint: t[2] || undefined,
|
||||
relationship: t[3] || undefined,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Internal utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Find the best prefLabel for a given prefix using locale fallback:
|
||||
* browser language > "en" > first available
|
||||
*/
|
||||
function findPrefLabel(event: NostrEvent, prefix: string): string | undefined {
|
||||
const labelPrefix = `${prefix}:prefLabel:`;
|
||||
const browserLang = getBrowserLanguage();
|
||||
|
||||
let browserMatch: string | undefined;
|
||||
let enMatch: string | undefined;
|
||||
let firstMatch: string | undefined;
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0]?.startsWith(labelPrefix) && tag[1]) {
|
||||
const lang = tag[0].slice(labelPrefix.length);
|
||||
if (!firstMatch) firstMatch = tag[1];
|
||||
if (lang === browserLang && !browserMatch) browserMatch = tag[1];
|
||||
if (lang === "en" && !enMatch) enMatch = tag[1];
|
||||
// Early exit if we found the best match
|
||||
if (browserMatch) break;
|
||||
}
|
||||
}
|
||||
|
||||
return browserMatch ?? enMatch ?? firstMatch;
|
||||
}
|
||||
53
src/lib/locale-utils.ts
Normal file
53
src/lib/locale-utils.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Shared locale utilities (non-React)
|
||||
* For the React hook version, see src/hooks/useLocale.ts
|
||||
*/
|
||||
|
||||
/** Get base language code from browser (e.g., "de", "en") */
|
||||
export function getBrowserLanguage(): string {
|
||||
const lang = navigator?.language || navigator?.languages?.[0] || "en";
|
||||
return lang.split("-")[0].toLowerCase();
|
||||
}
|
||||
|
||||
/** Get the full browser locale string (e.g., "en-US", "de-DE") */
|
||||
export function getBrowserLocale(): string {
|
||||
return navigator?.language || navigator?.languages?.[0] || "en-US";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a language code as a human-friendly name using Intl.DisplayNames.
|
||||
* Falls back to the raw code if the language is not recognized.
|
||||
* Example: "de" → "German", "en" → "English"
|
||||
*/
|
||||
export function formatLanguageName(languageCode: string): string {
|
||||
try {
|
||||
const displayNames = new Intl.DisplayNames([getBrowserLocale()], {
|
||||
type: "language",
|
||||
});
|
||||
return displayNames.of(languageCode) ?? languageCode;
|
||||
} catch {
|
||||
return languageCode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO date string (e.g., "2024-01-15") in a locale-aware way.
|
||||
* Returns a human-readable long date like "January 15, 2024".
|
||||
*/
|
||||
export function formatISODate(isoDate: string): string {
|
||||
try {
|
||||
const parts = isoDate.split("-");
|
||||
if (parts.length !== 3) return isoDate;
|
||||
const [year, month, day] = parts.map(Number);
|
||||
if (isNaN(year) || isNaN(month) || isNaN(day)) return isoDate;
|
||||
const date = new Date(year, month - 1, day); // local time, no UTC shift
|
||||
if (isNaN(date.getTime())) return isoDate;
|
||||
return date.toLocaleDateString(getBrowserLocale(), {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return isoDate;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user