101 changed files with 9258 additions and 4181 deletions
@ -1,6 +1,5 @@
@@ -1,6 +1,5 @@
|
||||
import tailwindcss from "tailwindcss"; |
||||
import autoprefixer from "autoprefixer"; |
||||
|
||||
export default { |
||||
plugins: [tailwindcss(), autoprefixer()], |
||||
plugins: { |
||||
"@tailwindcss/postcss": {}, |
||||
}, |
||||
}; |
||||
|
||||
@ -0,0 +1,290 @@
@@ -0,0 +1,290 @@
|
||||
<script lang="ts"> |
||||
/** |
||||
* @fileoverview AEventPreview Component - Alexandria |
||||
* |
||||
* A card component for displaying nostr event previews with configurable display options. |
||||
* Shows event metadata, content, author information, and action buttons. |
||||
* |
||||
* @component |
||||
* @category Cards |
||||
* |
||||
* @prop {NDKEvent} event - The nostr event to display (required) |
||||
* @prop {string} [label=""] - Optional label/category for the event |
||||
* @prop {boolean} [community=false] - Whether this is a community event |
||||
* @prop {number} [truncateContentAt=200] - Character limit for content truncation |
||||
* @prop {boolean} [showKind=true] - Whether to show event kind |
||||
* @prop {boolean} [showSummary=true] - Whether to show event summary |
||||
* @prop {boolean} [showDeferralNaddr=true] - Whether to show deferral naddr |
||||
* @prop {boolean} [showPublicationLink=true] - Whether to show publication link |
||||
* @prop {boolean} [showContent=true] - Whether to show event content |
||||
* @prop {Array<{label: string, onClick: (ev: NDKEvent) => void, variant?: string}>} [actions] - Action buttons |
||||
* @prop {(ev: NDKEvent) => void} [onSelect] - Callback when event is selected |
||||
* @prop {(naddr: string, ev: NDKEvent) => void} [onDeferralClick] - Callback for deferral clicks |
||||
* |
||||
* @example |
||||
* ```svelte |
||||
* <AEventPreview |
||||
* {event} |
||||
* label="Article" |
||||
* showContent={true} |
||||
* actions={[{label: "View", onClick: handleView}]} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @example Basic event preview |
||||
* ```svelte |
||||
* <AEventPreview {event} /> |
||||
* ``` |
||||
* |
||||
* @example Community event with actions |
||||
* ```svelte |
||||
* <AEventPreview |
||||
* {event} |
||||
* community={true} |
||||
* actions={[ |
||||
* {label: "Reply", onClick: handleReply}, |
||||
* {label: "Share", onClick: handleShare, variant: "light"} |
||||
* ]} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @example Minimal preview without content |
||||
* ```svelte |
||||
* <AEventPreview |
||||
* {event} |
||||
* showContent={false} |
||||
* showSummary={false} |
||||
* truncateContentAt={100} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @features |
||||
* - Responsive card layout with author badges |
||||
* - Content truncation with "show more" functionality |
||||
* - Publication links and metadata display |
||||
* - Configurable action buttons |
||||
* - Community event highlighting |
||||
* - Event kind and summary display |
||||
* |
||||
* @accessibility |
||||
* - Semantic card structure |
||||
* - Keyboard accessible action buttons |
||||
* - Screen reader friendly metadata |
||||
* - Proper heading hierarchy |
||||
*/ |
||||
|
||||
import { Card, Button } from "flowbite-svelte"; |
||||
import ViewPublicationLink from "$lib/components/util/ViewPublicationLink.svelte"; |
||||
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; |
||||
import { toNpub, getMatchingTags } from "$lib/utils/nostrUtils"; |
||||
import type { NDKEvent } from "$lib/utils/nostrUtils"; |
||||
import { preventDefault } from "svelte/legacy"; |
||||
|
||||
let { |
||||
event, |
||||
label = "", |
||||
community = false, |
||||
truncateContentAt = 200, |
||||
showKind = true, |
||||
showSummary = true, |
||||
showDeferralNaddr = true, |
||||
showPublicationLink = true, |
||||
showContent = true, |
||||
actions, |
||||
onSelect, |
||||
onDeferralClick, |
||||
}: { |
||||
event: NDKEvent; |
||||
label?: string; |
||||
community?: boolean; |
||||
truncateContentAt?: number; |
||||
showKind?: boolean; |
||||
showSummary?: boolean; |
||||
showDeferralNaddr?: boolean; |
||||
showPublicationLink?: boolean; |
||||
showContent?: boolean; |
||||
actions?: { |
||||
label: string; |
||||
onClick: (ev: NDKEvent) => void; |
||||
variant?: "primary" | "light" | "alternative"; |
||||
}[]; |
||||
onSelect?: (ev: NDKEvent) => void; |
||||
onDeferralClick?: (naddr: string, ev: NDKEvent) => void; |
||||
} = $props(); |
||||
|
||||
type ProfileData = { |
||||
name?: string; |
||||
display_name?: string; |
||||
about?: string; |
||||
picture?: string; |
||||
banner?: string; |
||||
website?: string; |
||||
lud16?: string; |
||||
nip05?: string; |
||||
}; |
||||
|
||||
function parseProfileContent(ev: NDKEvent): ProfileData | null { |
||||
if (ev.kind !== 0 || !ev.content) { |
||||
return null; |
||||
} |
||||
try { |
||||
return JSON.parse(ev.content) as ProfileData; |
||||
} catch { |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
function getSummary(ev: NDKEvent): string | undefined { |
||||
return getMatchingTags(ev, "summary")[0]?.[1]; |
||||
} |
||||
|
||||
function getDeferralNaddr(ev: NDKEvent): string | undefined { |
||||
return getMatchingTags(ev, "deferral")[0]?.[1]; |
||||
} |
||||
|
||||
const profileData = parseProfileContent(event); |
||||
const summary = showSummary ? getSummary(event) : undefined; |
||||
const deferralNaddr = showDeferralNaddr ? getDeferralNaddr(event) : undefined; |
||||
|
||||
function clippedContent(content: string): string { |
||||
if (!showContent) { |
||||
return ""; |
||||
} |
||||
if (!truncateContentAt || content.length <= truncateContentAt) { |
||||
return content; |
||||
} |
||||
return content.slice(0, truncateContentAt) + "..."; |
||||
} |
||||
|
||||
function handleSelect(): void { |
||||
onSelect?.(event); |
||||
} |
||||
|
||||
function handleKeydown(e: KeyboardEvent): void { |
||||
if (e.key === "Enter" || e.key === " ") { |
||||
e.preventDefault(); |
||||
handleSelect(); |
||||
} |
||||
} |
||||
|
||||
function handleDeferralClick(e: MouseEvent): void { |
||||
e.stopPropagation(); |
||||
if (deferralNaddr) { |
||||
onDeferralClick?.(deferralNaddr, event); |
||||
} |
||||
} |
||||
|
||||
const displayName: string | undefined = |
||||
profileData?.display_name || profileData?.name; |
||||
const avatarFallback: string = (displayName || event.pubkey || "?") |
||||
.slice(0, 1) |
||||
.toUpperCase(); |
||||
const createdDate: string = event.created_at |
||||
? new Date(event.created_at * 1000).toLocaleDateString() |
||||
: "Unknown date"; |
||||
|
||||
const computedActions = |
||||
actions && actions.length > 0 |
||||
? actions |
||||
: [ |
||||
{ |
||||
label: "Open", |
||||
onClick: (ev: NDKEvent) => onSelect?.(ev), |
||||
variant: "light" as const, |
||||
}, |
||||
]; |
||||
</script> |
||||
|
||||
<Card |
||||
class="event-preview-card" |
||||
role="group" |
||||
tabindex="0" |
||||
aria-label="Event preview" |
||||
onclick={handleSelect} |
||||
onkeydown={handleKeydown} |
||||
size="xl" |
||||
> |
||||
<!-- Header --> |
||||
<div class="card-header"> |
||||
<!-- Meta --> |
||||
<div class="flex flex-row w-full gap-3 items-center min-w-0"> |
||||
{#if label} |
||||
<span class="event-label"> |
||||
{label} |
||||
</span> |
||||
{/if} |
||||
{#if showKind} |
||||
<span class="event-kind-badge"> |
||||
Kind {event.kind} |
||||
</span> |
||||
{/if} |
||||
{#if community} |
||||
<span |
||||
class="community-badge" |
||||
title="Has posted to the community" |
||||
> |
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"> |
||||
<path |
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" |
||||
/> |
||||
</svg> |
||||
Community |
||||
</span> |
||||
{/if} |
||||
<span class="text-xs ml-auto mb-4"> |
||||
{createdDate} |
||||
</span> |
||||
</div> |
||||
|
||||
<div class="flex flex-row"> |
||||
{@render userBadge(toNpub(event.pubkey) as string, displayName)} |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- Body --> |
||||
<div class="card-body"> |
||||
{#if event.kind === 0 && profileData?.about} |
||||
<div class="card-about"> |
||||
{clippedContent(profileData.about)} |
||||
</div> |
||||
{:else} |
||||
{#if summary} |
||||
<div class="card-summary"> |
||||
{summary} |
||||
</div> |
||||
{/if} |
||||
{#if deferralNaddr} |
||||
<div class="text-xs text-primary-800 dark:text-primary-300"> |
||||
Read |
||||
<span |
||||
class="deferral-link" |
||||
role="button" |
||||
tabindex="0" |
||||
onclick={handleDeferralClick} |
||||
onkeydown={(e) => { |
||||
if (e.key === "Enter" || e.key === " ") { |
||||
e.preventDefault(); |
||||
handleDeferralClick(e as unknown as MouseEvent); |
||||
} |
||||
}} |
||||
> |
||||
{deferralNaddr} |
||||
</span> |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if showContent && event.content} |
||||
<div class="card-content"> |
||||
{clippedContent(event.content)} |
||||
</div> |
||||
{/if} |
||||
{/if} |
||||
</div> |
||||
|
||||
<!-- Footer / Actions --> |
||||
{#if showPublicationLink && event.kind !== 0} |
||||
<div class="card-footer"> |
||||
<ViewPublicationLink {event} /> |
||||
</div> |
||||
{/if} |
||||
</Card> |
||||
@ -0,0 +1,449 @@
@@ -0,0 +1,449 @@
|
||||
<script lang="ts"> |
||||
/** |
||||
* @fileoverview AProfilePreview Component - Alexandria |
||||
* |
||||
* A comprehensive profile card component for displaying nostr user profiles. |
||||
* Shows avatar, banner, name, bio, NIP-05 verification, lightning address, and user status indicators. |
||||
* |
||||
* @component |
||||
* @category Cards |
||||
* |
||||
* @prop {NDKEvent} event - The nostr event (kind 0 profile) to display (required) |
||||
* @prop {UserLite} [user] - User object containing npub identifier |
||||
* @prop {Profile} profile - User profile metadata (required) |
||||
* @prop {boolean} [loading=false] - Whether the profile is currently loading |
||||
* @prop {string} [error=null] - Error message if profile loading failed |
||||
* @prop {boolean} [isOwn=false] - Whether this is the current user's own profile |
||||
* @prop {Record<string, boolean>} [communityStatusMap=false] - Map of pubkey to community membership status |
||||
* |
||||
* @example |
||||
* ```svelte |
||||
* <AProfilePreview |
||||
* {event} |
||||
* user={{npub}} |
||||
* {profile} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @example Own profile with actions |
||||
* ```svelte |
||||
* <AProfilePreview |
||||
* {event} |
||||
* {profile} |
||||
* isOwn={true} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @example Loading state |
||||
* ```svelte |
||||
* <AProfilePreview |
||||
* {event} |
||||
* {profile} |
||||
* loading={true} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @example With error handling |
||||
* ```svelte |
||||
* <AProfilePreview |
||||
* {event} |
||||
* {profile} |
||||
* error={errorMessage} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @features |
||||
* - Banner image with fallback color generation |
||||
* - Avatar display with proper sizing |
||||
* - NIP-05 verification badge display |
||||
* - Community membership indicator (star icon) |
||||
* - User list membership indicator (heart icon) |
||||
* - Lightning address (lud16) with QR code modal |
||||
* - Multiple identifier formats (npub, nprofile, nevent) |
||||
* - Copy to clipboard functionality for identifiers |
||||
* - Website link display |
||||
* - Bio/about text with markup rendering |
||||
* - Own profile actions (notifications, my notes) |
||||
* - Loading and error states |
||||
* |
||||
* @accessibility |
||||
* - Semantic profile structure with proper headings |
||||
* - Keyboard accessible action buttons and dropdowns |
||||
* - Screen reader friendly verification status badges |
||||
* - Proper modal focus management for QR code |
||||
* - Alt text for images |
||||
* - ARIA labels for status indicators |
||||
*/ |
||||
|
||||
import { |
||||
Card, |
||||
Heading, |
||||
P, |
||||
Button, |
||||
Modal, |
||||
Avatar, |
||||
Dropdown, |
||||
DropdownItem, |
||||
} from "flowbite-svelte"; |
||||
import { ChevronDownOutline } from "flowbite-svelte-icons"; |
||||
import AAlert from "$lib/a/primitives/AAlert.svelte"; |
||||
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte"; |
||||
import { goto } from "$app/navigation"; |
||||
import LazyImage from "$lib/components/util/LazyImage.svelte"; |
||||
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; |
||||
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte"; |
||||
import QrCode from "$lib/components/util/QrCode.svelte"; |
||||
import { generateDarkPastelColor } from "$lib/utils/image_utils"; |
||||
import { |
||||
lnurlpWellKnownUrl, |
||||
checkCommunity, |
||||
} from "$lib/utils/search_utility"; |
||||
import { bech32 } from "bech32"; |
||||
import { getNdkContext, activeInboxRelays } from "$lib/ndk"; |
||||
import { toNpub } from "$lib/utils/nostrUtils"; |
||||
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils"; |
||||
import { |
||||
isPubkeyInUserLists, |
||||
fetchCurrentUserLists, |
||||
} from "$lib/utils/user_lists"; |
||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
|
||||
type UserLite = { npub?: string | null }; |
||||
type Profile = { |
||||
name?: string; |
||||
display_name?: string; |
||||
displayName?: string; |
||||
about?: string; |
||||
picture?: string; |
||||
banner?: string; |
||||
website?: string; |
||||
lud16?: string; |
||||
nip05?: string; |
||||
// Optional flags that might come via cached profile data |
||||
isInUserLists?: boolean; |
||||
} | null; |
||||
|
||||
const props = $props<{ |
||||
user?: UserLite; |
||||
profile: Profile; |
||||
loading?: boolean; |
||||
error?: string | null; |
||||
isOwn?: boolean; |
||||
event: NDKEvent; |
||||
communityStatusMap?: Record<string, boolean>; |
||||
}>(); |
||||
|
||||
const ndk = getNdkContext(); |
||||
|
||||
let lnModalOpen = $state(false); |
||||
let lnurl = $state<string | null>(null); |
||||
let communityStatus = $state<boolean | null>(null); |
||||
let isInUserLists = $state<boolean | null>(null); |
||||
|
||||
function displayName() { |
||||
const p = props.profile; |
||||
const u = props.user; |
||||
return ( |
||||
p?.display_name || |
||||
p?.displayName || |
||||
p?.name || |
||||
(u?.npub ? u.npub.slice(0, 10) + "…" : "") |
||||
); |
||||
} |
||||
|
||||
function shortNpub() { |
||||
const npub = props.user?.npub; |
||||
if (!npub) return ""; |
||||
return npub.slice(0, 12) + "…" + npub.slice(-8); |
||||
} |
||||
|
||||
function hideOnError(e: Event) { |
||||
const img = e.currentTarget as HTMLImageElement | null; |
||||
if (img) { |
||||
img.style.display = "none"; |
||||
const next = img.nextElementSibling as HTMLElement | null; |
||||
if (next) next.classList.remove("hidden"); |
||||
} |
||||
} |
||||
|
||||
function getIdentifiers( |
||||
event: NDKEvent, |
||||
profile: any, |
||||
): { label: string; value: string; link?: string }[] { |
||||
const ids: { label: string; value: string; link?: string }[] = []; |
||||
if (event.kind === 0) { |
||||
const npub = toNpub(event.pubkey); |
||||
if (npub) |
||||
ids.push({ label: "npub", value: npub, link: `/events?id=${npub}` }); |
||||
ids.push({ |
||||
label: "nprofile", |
||||
value: nprofileEncode(event.pubkey, $activeInboxRelays), |
||||
link: `/events?id=${nprofileEncode(event.pubkey, $activeInboxRelays)}`, |
||||
}); |
||||
ids.push({ |
||||
label: "nevent", |
||||
value: neventEncode(event, $activeInboxRelays), |
||||
link: `/events?id=${neventEncode(event, $activeInboxRelays)}`, |
||||
}); |
||||
ids.push({ label: "pubkey", value: event.pubkey }); |
||||
} else { |
||||
ids.push({ |
||||
label: "nevent", |
||||
value: neventEncode(event, $activeInboxRelays), |
||||
link: `/events?id=${neventEncode(event, $activeInboxRelays)}`, |
||||
}); |
||||
try { |
||||
const naddr = naddrEncode(event, $activeInboxRelays); |
||||
ids.push({ label: "naddr", value: naddr, link: `/events?id=${naddr}` }); |
||||
} catch {} |
||||
ids.push({ |
||||
label: "id", |
||||
value: event.id, |
||||
link: `/events?id=${event.id}`, |
||||
}); |
||||
} |
||||
return ids; |
||||
} |
||||
|
||||
function navigateToIdentifier(link: string) { |
||||
goto(link); |
||||
} |
||||
|
||||
// Compute LNURL on mount if lud16 exists |
||||
$effect(() => { |
||||
const p = props.profile; |
||||
if (p?.lud16) { |
||||
try { |
||||
const [name, domain] = p.lud16.split("@"); |
||||
const url = lnurlpWellKnownUrl(domain, name); |
||||
const words = bech32.toWords(new TextEncoder().encode(url)); |
||||
lnurl = bech32.encode("lnurl", words); |
||||
} catch { |
||||
lnurl = null; |
||||
} |
||||
} else { |
||||
lnurl = null; |
||||
} |
||||
}); |
||||
|
||||
// Compute community/list status when event changes |
||||
$effect(() => { |
||||
const ev = props.event; |
||||
if (!ev?.pubkey) { |
||||
communityStatus = null; |
||||
isInUserLists = null; |
||||
return; |
||||
} |
||||
|
||||
// isInUserLists: prefer prop.profile hint, else cached profileData, else fetch |
||||
if (props.profile && typeof props.profile.isInUserLists === "boolean") { |
||||
isInUserLists = props.profile.isInUserLists; |
||||
} else { |
||||
const cachedProfileData = (ev as any).profileData; |
||||
if ( |
||||
cachedProfileData && |
||||
typeof cachedProfileData.isInUserLists === "boolean" |
||||
) { |
||||
isInUserLists = cachedProfileData.isInUserLists; |
||||
} else { |
||||
fetchCurrentUserLists() |
||||
.then((lists) => { |
||||
isInUserLists = isPubkeyInUserLists(ev.pubkey, lists); |
||||
}) |
||||
.catch(() => { |
||||
isInUserLists = false; |
||||
}); |
||||
} |
||||
} |
||||
|
||||
// community status: prefer map if provided, else check |
||||
if ( |
||||
props.communityStatusMap && |
||||
props.communityStatusMap[ev.pubkey] !== undefined |
||||
) { |
||||
communityStatus = props.communityStatusMap[ev.pubkey]; |
||||
} else { |
||||
checkCommunity(ev.pubkey) |
||||
.then((status) => { |
||||
communityStatus = status; |
||||
}) |
||||
.catch(() => { |
||||
communityStatus = false; |
||||
}); |
||||
} |
||||
}); |
||||
</script> |
||||
|
||||
<Card |
||||
size="xl" |
||||
class="main-leather p-0 overflow-hidden rounded-lg border border-primary-200 dark:border-primary-700" |
||||
> |
||||
{#if props.profile?.banner} |
||||
<div class="card-image-container"> |
||||
<LazyImage |
||||
src={props.profile.banner} |
||||
alt="Profile banner" |
||||
eventId={props.event.id} |
||||
className="card-banner" |
||||
/> |
||||
</div> |
||||
{:else} |
||||
<div |
||||
class="w-full h-60" |
||||
style={`background-color: ${generateDarkPastelColor(props.event.id)};`} |
||||
></div> |
||||
{/if} |
||||
|
||||
<div class={`p-6 flex flex-col relative`}> |
||||
<Avatar |
||||
size="xl" |
||||
src={props.profile?.picture ?? null} |
||||
alt="Avatar" |
||||
class="card-avatar-container" |
||||
/> |
||||
|
||||
<div class="flex flex-col gap-3"> |
||||
<Heading tag="h1" class="h-leather mb-2">{displayName()}</Heading> |
||||
{#if props.user?.npub} |
||||
<CopyToClipboard displayText={shortNpub()} copyText={props.user.npub} /> |
||||
{/if} |
||||
|
||||
{#if props.event} |
||||
<div class="flex items-center gap-2 min-w-0"> |
||||
{#if props.profile?.nip05} |
||||
<span class="profile-nip05-badge">{props.profile.nip05}</span> |
||||
{/if} |
||||
{#if communityStatus === true} |
||||
<div |
||||
class="community-status-indicator" |
||||
title="Has posted to the community" |
||||
> |
||||
<svg |
||||
class="community-status-icon" |
||||
fill="currentColor" |
||||
viewBox="0 0 24 24" |
||||
><path |
||||
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" |
||||
/></svg |
||||
> |
||||
</div> |
||||
{/if} |
||||
{#if isInUserLists === true} |
||||
<div |
||||
class="user-list-indicator" |
||||
title="In your lists (follows, etc.)" |
||||
> |
||||
<svg |
||||
class="user-list-icon" |
||||
fill="currentColor" |
||||
viewBox="0 0 24 24" |
||||
><path |
||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" |
||||
/></svg |
||||
> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
{#if props.profile?.about} |
||||
<div class="prose dark:prose-invert card-prose"> |
||||
{@render basicMarkup(props.profile.about, ndk)} |
||||
</div> |
||||
{/if} |
||||
|
||||
<div class="flex flex-wrap gap-4 text-sm"> |
||||
{#if props.profile?.website} |
||||
<a |
||||
href={props.profile.website} |
||||
rel="noopener" |
||||
class="text-primary-600 dark:text-primary-400 hover:underline break-all" |
||||
target="_blank">{props.profile.website}</a |
||||
> |
||||
{/if} |
||||
</div> |
||||
|
||||
<div class="flex flex-row flex-wrap justify-end gap-4 text-sm"> |
||||
{#if props.profile?.lud16} |
||||
<Button |
||||
color="alternative" |
||||
size="xs" |
||||
onclick={() => (lnModalOpen = true)}>⚡ {props.profile.lud16}</Button |
||||
> |
||||
{/if} |
||||
<Button size="xs" color="alternative" |
||||
>Identifiers <ChevronDownOutline class="ms-2 h-6 w-6" /></Button |
||||
> |
||||
<Dropdown simple> |
||||
{#each getIdentifiers(props.event, props.profile) as identifier} |
||||
<DropdownItem |
||||
><CopyToClipboard |
||||
displayText={identifier.label} |
||||
copyText={identifier.value} |
||||
/></DropdownItem |
||||
> |
||||
{/each} |
||||
</Dropdown> |
||||
|
||||
{#if props.isOwn} |
||||
<Button |
||||
class="!mb-0" |
||||
size="xs" |
||||
onclick={() => goto("/profile/notifications")}>Notifications</Button |
||||
> |
||||
<Button |
||||
class="!mb-0" |
||||
size="xs" |
||||
onclick={() => goto("/profile/my-notes")}>My notes</Button |
||||
> |
||||
{/if} |
||||
</div> |
||||
|
||||
{#if props.loading} |
||||
<AAlert color="primary">Loading profile…</AAlert> |
||||
{/if} |
||||
{#if props.error} |
||||
<AAlert color="red">Error loading profile: {props.error}</AAlert> |
||||
{/if} |
||||
</div> |
||||
</Card> |
||||
|
||||
{#if lnModalOpen} |
||||
<Modal |
||||
class="modal-leather" |
||||
title="Lightning Address" |
||||
bind:open={lnModalOpen} |
||||
outsideclose |
||||
size="sm" |
||||
> |
||||
{#if props.profile?.lud16} |
||||
<div> |
||||
<div class="flex flex-col items-center"> |
||||
{@render userBadge( |
||||
props.user?.npub ?? toNpub(props.event.pubkey), |
||||
props.profile?.displayName || |
||||
props.profile?.display_name || |
||||
props.profile?.name || |
||||
props.event?.pubkey || |
||||
"", |
||||
ndk, |
||||
)} |
||||
<P class="break-all">{props.profile.lud16}</P> |
||||
</div> |
||||
<div class="flex flex-col items-center mt-3 space-y-4"> |
||||
<P>Scan the QR code or copy the address</P> |
||||
{#if lnurl} |
||||
<P class="break-all overflow-wrap-anywhere"> |
||||
<CopyToClipboard icon={false} displayText={lnurl} |
||||
></CopyToClipboard> |
||||
</P> |
||||
<QrCode value={lnurl} /> |
||||
{:else} |
||||
<P>Couldn't generate address.</P> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
</Modal> |
||||
{/if} |
||||
@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
|
||||
<script lang="ts"> |
||||
/** |
||||
* @fileoverview ACommentForm Component - Alexandria |
||||
* |
||||
* A form component for creating and editing comments with markup support and preview functionality. |
||||
* Integrates with ATextareaWithPreview to provide rich text editing capabilities. |
||||
* |
||||
* @component |
||||
* @category Forms |
||||
* |
||||
* @prop {string} [content=""] - The comment content text (bindable) |
||||
* @prop {any} [extensions] - Additional extensions for markup processing |
||||
* @prop {boolean} [isSubmitting=false] - Whether form is currently submitting |
||||
* @prop {(content: string) => Promise<void>} [onSubmit] - Callback when form is submitted |
||||
* |
||||
* @example |
||||
* ```svelte |
||||
* <ACommentForm |
||||
* bind:content={commentText} |
||||
* {isSubmitting} |
||||
* onSubmit={handleCommentSubmit} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @example Basic comment form |
||||
* ```svelte |
||||
* <ACommentForm bind:content={comment} onSubmit={postComment} /> |
||||
* ``` |
||||
* |
||||
* @example Comment form with custom extensions |
||||
* ```svelte |
||||
* <ACommentForm |
||||
* bind:content={replyText} |
||||
* extensions={customMarkupExtensions} |
||||
* isSubmitting={posting} |
||||
* onSubmit={handleReply} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @features |
||||
* - Rich text editing with markdown-like syntax |
||||
* - Live preview of formatted content |
||||
* - Clear form functionality |
||||
* - Remove formatting option |
||||
* - Submit handling with loading states |
||||
* - Integration with user authentication |
||||
* |
||||
* @accessibility |
||||
* - Proper form labels and structure |
||||
* - Keyboard accessible controls |
||||
* - Screen reader friendly |
||||
* - Clear form validation feedback |
||||
*/ |
||||
|
||||
import { Button, Label } from "flowbite-svelte"; |
||||
import { userStore } from "$lib/stores/userStore.ts"; |
||||
import { parseBasicMarkup } from "$lib/utils/markup/basicMarkupParser.ts"; |
||||
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte"; |
||||
import { getNdkContext } from "$lib/ndk.ts"; |
||||
import { ATextareaWithPreview } from "$lib/a/index.ts"; |
||||
|
||||
const ndk = getNdkContext(); |
||||
|
||||
let { |
||||
// make content bindable |
||||
content = $bindable(""), |
||||
extensions, |
||||
isSubmitting = false, |
||||
onSubmit = () => {}, |
||||
} = $props<{ |
||||
content?: string; |
||||
extensions?: any; |
||||
isSubmitting?: boolean; |
||||
onSubmit?: (content: string) => Promise<void>; |
||||
}>(); |
||||
|
||||
function clearForm() { |
||||
content = ""; |
||||
} |
||||
|
||||
function removeFormatting() { |
||||
content = content |
||||
.replace(/\*\*(.*?)\*\*/g, "$1") |
||||
.replace(/_(.*?)_/g, "$1") |
||||
.replace(/~~(.*?)~~/g, "$1") |
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") |
||||
.replace(/!\[(.*?)\]\(.*?\)/g, "$1") |
||||
.replace(/^>\s*/gm, "") |
||||
.replace(/^[-*]\s*/gm, "") |
||||
.replace(/^\d+\.\s*/gm, "") |
||||
.replace(/#(\w+)/g, "$1"); |
||||
} |
||||
|
||||
async function handleSubmit(e: SubmitEvent) { |
||||
e.preventDefault(); |
||||
await onSubmit(content.trim()); |
||||
} |
||||
</script> |
||||
|
||||
<form novalidate onsubmit={handleSubmit}> |
||||
<Label for="editor" class="sr-only">Comment</Label> |
||||
|
||||
<ATextareaWithPreview |
||||
id="editor" |
||||
label="" |
||||
rows={10} |
||||
bind:value={content} |
||||
placeholder="Write a comment" |
||||
parser={parseBasicMarkup} |
||||
previewSnippet={basicMarkup} |
||||
previewArg={ndk} |
||||
{extensions} |
||||
/> |
||||
|
||||
<div class="flex flex-row justify-between mt-2"> |
||||
<div class="flex flex-row flex-wrap gap-3 !m-0"> |
||||
<Button |
||||
size="xs" |
||||
color="alternative" |
||||
onclick={removeFormatting} |
||||
class="!m-0">Remove Formatting</Button |
||||
> |
||||
<Button size="xs" color="alternative" class="!m-0" onclick={clearForm} |
||||
>Clear</Button |
||||
> |
||||
</div> |
||||
<Button |
||||
disabled={isSubmitting || !content.trim() || !$userStore.signedIn} |
||||
type="submit" |
||||
> |
||||
{#if !$userStore.signedIn} |
||||
Not Signed In |
||||
{:else if isSubmitting} |
||||
Publishing... |
||||
{:else} |
||||
Post Comment |
||||
{/if} |
||||
</Button> |
||||
</div> |
||||
</form> |
||||
@ -0,0 +1,170 @@
@@ -0,0 +1,170 @@
|
||||
<script lang="ts"> |
||||
/** |
||||
* @fileoverview AMarkupForm Component - Alexandria |
||||
* |
||||
* A comprehensive form component for creating content with subject/title and rich markup body. |
||||
* Provides advanced markup editing with preview, confirmation dialogs, and form management. |
||||
* |
||||
* @component |
||||
* @category Forms |
||||
* |
||||
* @prop {string} [subject=""] - The content title/subject (bindable) |
||||
* @prop {string} [content=""] - The main content body (bindable) |
||||
* @prop {boolean} [isSubmitting=false] - Whether form is currently submitting |
||||
* @prop {number} [clearSignal=0] - Signal to clear form (increment to trigger clear) |
||||
* @prop {(subject: string, content: string) => Promise<void>} [onSubmit] - Submit callback |
||||
* |
||||
* @example |
||||
* ```svelte |
||||
* <AMarkupForm |
||||
* bind:subject={title} |
||||
* bind:content={body} |
||||
* {isSubmitting} |
||||
* onSubmit={handlePublish} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @example Basic markup form |
||||
* ```svelte |
||||
* <AMarkupForm |
||||
* bind:subject={articleTitle} |
||||
* bind:content={articleContent} |
||||
* onSubmit={publishArticle} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @example Form with clear signal control |
||||
* ```svelte |
||||
* <AMarkupForm |
||||
* bind:subject={title} |
||||
* bind:content={body} |
||||
* clearSignal={resetCounter} |
||||
* isSubmitting={publishing} |
||||
* onSubmit={handleSubmit} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @features |
||||
* - Subject/title input field |
||||
* - Advanced markup editor with preview |
||||
* - Clear form functionality with confirmation dialog |
||||
* - Form validation and submission states |
||||
* - Integration with advanced markup parser |
||||
* - Responsive layout with proper spacing |
||||
* |
||||
* @accessibility |
||||
* - Proper form labels and structure |
||||
* - Keyboard accessible controls |
||||
* - Screen reader friendly |
||||
* - Modal dialogs with focus management |
||||
* - Clear form validation feedback |
||||
*/ |
||||
|
||||
import { Label, Input, Button, Modal } from "flowbite-svelte"; |
||||
import { parseAdvancedmarkup } from "$lib/utils/markup/advancedMarkupParser"; |
||||
import { ATextareaWithPreview } from "$lib/a/index.ts"; |
||||
import { getNdkContext } from "$lib/ndk.ts"; |
||||
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte"; |
||||
|
||||
let { |
||||
subject = $bindable(""), |
||||
content = $bindable(""), |
||||
isSubmitting = false, |
||||
clearSignal = 0, |
||||
onSubmit = async (_subject: string, _content: string) => {}, |
||||
} = $props<{ |
||||
subject?: string; |
||||
content?: string; |
||||
isSubmitting?: boolean; |
||||
clearSignal?: number; |
||||
onSubmit?: (subject: string, content: string) => Promise<void> | void; |
||||
}>(); |
||||
|
||||
// Local UI state |
||||
let showConfirmDialog = $state(false); |
||||
|
||||
// Track last clear signal to avoid clearing on mount if default matches |
||||
let _lastClearSignal = $state<number | null>(null); |
||||
$effect(() => { |
||||
if (clearSignal !== _lastClearSignal) { |
||||
if (_lastClearSignal !== null) { |
||||
subject = ""; |
||||
content = ""; |
||||
} |
||||
_lastClearSignal = clearSignal; |
||||
} |
||||
}); |
||||
|
||||
function clearForm() { |
||||
subject = ""; |
||||
content = ""; |
||||
} |
||||
|
||||
function handleSubmit(e: Event) { |
||||
e.preventDefault(); |
||||
showConfirmDialog = true; |
||||
} |
||||
|
||||
async function confirmSubmit() { |
||||
showConfirmDialog = false; |
||||
await onSubmit(subject.trim(), content.trim()); |
||||
} |
||||
|
||||
function cancelSubmit() { |
||||
showConfirmDialog = false; |
||||
} |
||||
|
||||
let ndk = getNdkContext(); |
||||
</script> |
||||
|
||||
<form class="space-y-4" onsubmit={handleSubmit} autocomplete="off"> |
||||
<div> |
||||
<Label for="subject" class="mb-2">Subject</Label> |
||||
<Input |
||||
id="subject" |
||||
class="w-full" |
||||
placeholder="Issue subject" |
||||
bind:value={subject} |
||||
required |
||||
autofocus |
||||
/> |
||||
</div> |
||||
|
||||
<div class="relative"> |
||||
<ATextareaWithPreview |
||||
id="content" |
||||
label="Description" |
||||
bind:value={content} |
||||
placeholder="Describe your issue. Use the Eye toggle to preview rendered markup." |
||||
parser={parseAdvancedmarkup} |
||||
previewSnippet={basicMarkup} |
||||
previewArg={ndk} |
||||
/> |
||||
</div> |
||||
|
||||
<div class="flex justify-end space-x-4"> |
||||
<Button type="button" color="alternative" onclick={clearForm} |
||||
>Clear Form</Button |
||||
> |
||||
<Button type="submit" tabindex={0} disabled={isSubmitting}> |
||||
{#if isSubmitting} |
||||
Submitting... |
||||
{:else} |
||||
Submit Issue |
||||
{/if} |
||||
</Button> |
||||
</div> |
||||
</form> |
||||
|
||||
<!-- Confirmation Dialog --> |
||||
<Modal bind:open={showConfirmDialog} size="sm" autoclose={false} class="w-full"> |
||||
<div class="text-center"> |
||||
<h3 class="mb-5 text-lg font-normal text-gray-700 dark:text-gray-300"> |
||||
Would you like to submit the issue? |
||||
</h3> |
||||
<div class="flex justify-center gap-4"> |
||||
<Button color="alternative" onclick={cancelSubmit}>Cancel</Button> |
||||
<Button color="primary" onclick={confirmSubmit}>Submit</Button> |
||||
</div> |
||||
</div> |
||||
</Modal> |
||||
@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
|
||||
<script lang="ts"> |
||||
/** |
||||
* @fileoverview ASearchForm Component - Alexandria |
||||
* |
||||
* A search form component with loading states, keyboard handling, and flexible callback system. |
||||
* Provides a standardized search interface with clear functionality and user feedback. |
||||
* |
||||
* @component |
||||
* @category Forms |
||||
* |
||||
* @prop {string} searchQuery - The current search query text (bindable) |
||||
* @prop {boolean} searching - Whether a search is currently in progress |
||||
* @prop {boolean} loading - Whether data is being loaded |
||||
* @prop {boolean} isUserEditing - Whether user is actively editing the query (bindable) |
||||
* @prop {string} [placeholder] - Placeholder text for the search input |
||||
* @prop {(args: {clearInput: boolean, queryOverride?: string}) => void} [search] - Search callback |
||||
* @prop {() => void} [clear] - Clear callback |
||||
* |
||||
* @example |
||||
* ```svelte |
||||
* <ASearchForm |
||||
* bind:searchQuery={query} |
||||
* {searching} |
||||
* {loading} |
||||
* bind:isUserEditing={editing} |
||||
* search={handleSearch} |
||||
* clear={handleClear} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @example Basic search form |
||||
* ```svelte |
||||
* <ASearchForm |
||||
* bind:searchQuery={searchTerm} |
||||
* searching={isSearching} |
||||
* search={performSearch} |
||||
* clear={clearResults} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @example Custom placeholder and editing tracking |
||||
* ```svelte |
||||
* <ASearchForm |
||||
* bind:searchQuery={query} |
||||
* bind:isUserEditing={userTyping} |
||||
* searching={searching} |
||||
* placeholder="Search events, users, topics..." |
||||
* search={handleEventSearch} |
||||
* clear={resetSearch} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @features |
||||
* - Enter key triggers search |
||||
* - Loading spinner during operations |
||||
* - Clear button functionality |
||||
* - User editing state tracking |
||||
* - Flexible callback system |
||||
* - Accessible search interface |
||||
* |
||||
* @accessibility |
||||
* - Keyboard accessible (Enter to search) |
||||
* - Screen reader friendly with proper labels |
||||
* - Loading states clearly communicated |
||||
* - Focus management |
||||
*/ |
||||
|
||||
import { Button, Search, Spinner } from "flowbite-svelte"; |
||||
|
||||
// AI-NOTE: 2025-08-16 - This component centralizes search form behavior. |
||||
// Parent supplies callbacks `search` and `clear`. Two-way bindings use $bindable. |
||||
let { |
||||
searchQuery = $bindable(""), |
||||
searching = false, |
||||
loading = false, |
||||
isUserEditing = $bindable(false), |
||||
placeholder = "Enter event ID, nevent, naddr, d:tag-name, t:topic, or n:username...", |
||||
search, |
||||
clear, |
||||
}: { |
||||
searchQuery: string; |
||||
searching: boolean; |
||||
loading: boolean; |
||||
isUserEditing: boolean; |
||||
placeholder?: string; |
||||
search?: (args: { clearInput: boolean; queryOverride?: string }) => void; |
||||
clear?: () => void; |
||||
} = $props(); |
||||
|
||||
function handleKeydown(e: KeyboardEvent): void { |
||||
if (e.key === "Enter") { |
||||
search?.({ clearInput: true }); |
||||
} |
||||
} |
||||
|
||||
function triggerSearch(): void { |
||||
search?.({ clearInput: true }); |
||||
} |
||||
|
||||
function handleInput(): void { |
||||
isUserEditing = true; |
||||
} |
||||
|
||||
function handleBlur(): void { |
||||
isUserEditing = false; |
||||
} |
||||
|
||||
function handleClear(): void { |
||||
clear?.(); |
||||
} |
||||
</script> |
||||
|
||||
<form id="search-form" class="flex gap-2"> |
||||
<Search |
||||
id="search-input" |
||||
class="justify-center" |
||||
bind:value={searchQuery} |
||||
onkeydown={handleKeydown} |
||||
oninput={handleInput} |
||||
onblur={handleBlur} |
||||
{placeholder} |
||||
/> |
||||
<Button onclick={triggerSearch} disabled={loading}> |
||||
{#if searching} |
||||
<Spinner class="mr-2 text-gray-600 dark:text-gray-300" size="5" /> |
||||
{/if} |
||||
{searching ? "Searching..." : "Search"} |
||||
</Button> |
||||
<Button |
||||
onclick={handleClear} |
||||
color="alternative" |
||||
type="button" |
||||
disabled={loading} |
||||
> |
||||
Clear |
||||
</Button> |
||||
</form> |
||||
@ -0,0 +1,288 @@
@@ -0,0 +1,288 @@
|
||||
<script lang="ts"> |
||||
/** |
||||
* @fileoverview ATextareaWithPreview Component - Alexandria |
||||
* |
||||
* A rich text editor with toolbar and live preview functionality for markup content. |
||||
* Provides formatting tools, preview toggle, and extensible parsing system. |
||||
* |
||||
* @component |
||||
* @category Forms |
||||
* |
||||
* @prop {string} value - The textarea content (bindable) |
||||
* @prop {string} [id="editor"] - HTML id for the textarea element |
||||
* @prop {string} [label=""] - Label text for the textarea |
||||
* @prop {number} [rows=10] - Number of textarea rows |
||||
* @prop {string} [placeholder=""] - Placeholder text |
||||
* @prop {(text: string, extensions?: any) => Promise<string>} parser - Async markup parser function |
||||
* @prop {snippet} previewSnippet - Svelte snippet for rendering preview content |
||||
* @prop {any} [previewArg] - Additional argument passed to preview snippet |
||||
* @prop {any} [extensions] - Extensions passed to the parser |
||||
* |
||||
* @example |
||||
* ```svelte |
||||
* <ATextareaWithPreview |
||||
* bind:value={content} |
||||
* {parser} |
||||
* {previewSnippet} |
||||
* previewArg={ndk} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @example Basic markup editor |
||||
* ```svelte |
||||
* <ATextareaWithPreview |
||||
* bind:value={content} |
||||
* parser={parseBasicMarkup} |
||||
* previewSnippet={basicMarkup} |
||||
* placeholder="Write your content..." |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @example Advanced editor with extensions |
||||
* ```svelte |
||||
* <ATextareaWithPreview |
||||
* bind:value={articleContent} |
||||
* parser={parseAdvancedMarkup} |
||||
* previewSnippet={advancedMarkup} |
||||
* previewArg={ndkInstance} |
||||
* extensions={customExtensions} |
||||
* rows={15} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @features |
||||
* - Rich formatting toolbar (bold, italic, strikethrough, etc.) |
||||
* - Live preview toggle with eye icon |
||||
* - Support for links, images, quotes, lists |
||||
* - Hashtag and mention insertion |
||||
* - Extensible parser system |
||||
* - Keyboard shortcuts for formatting |
||||
* |
||||
* @accessibility |
||||
* - Proper form labels and ARIA attributes |
||||
* - Keyboard accessible toolbar buttons |
||||
* - Screen reader friendly with descriptive labels |
||||
* - Focus management between edit and preview modes |
||||
*/ |
||||
|
||||
import { |
||||
Textarea, |
||||
Toolbar, |
||||
ToolbarGroup, |
||||
ToolbarButton, |
||||
Label, |
||||
Button, |
||||
} from "flowbite-svelte"; |
||||
import { |
||||
Bold, |
||||
Italic, |
||||
Strikethrough, |
||||
Quote, |
||||
Link2, |
||||
Image, |
||||
Hash, |
||||
List, |
||||
ListOrdered, |
||||
Eye, |
||||
PencilLine, |
||||
} from "@lucide/svelte"; |
||||
|
||||
// Reusable editor with toolbar (from ACommentForm) and toolbar-only Preview |
||||
let { |
||||
value = $bindable(""), |
||||
id = "editor", |
||||
label = "", |
||||
rows = 10, |
||||
placeholder = "", |
||||
// async parser that returns HTML string |
||||
parser = async (s: string) => s, |
||||
// snippet renderer and optional args |
||||
previewSnippet, |
||||
previewArg: previewArg = undefined, |
||||
// extra toolbar extensions (snippet returning toolbar buttons) |
||||
extensions, |
||||
} = $props<{ |
||||
value?: string; |
||||
id?: string; |
||||
label?: string; |
||||
rows?: number; |
||||
placeholder?: string; |
||||
parser?: (s: string) => Promise<string> | string; |
||||
previewSnippet?: any; // Svelte snippet |
||||
previewArg?: any; |
||||
extensions?: any; |
||||
}>(); |
||||
|
||||
let preview = $state(""); |
||||
let activeTab = $state<"write" | "preview">("write"); |
||||
let wrapper: HTMLElement | null = null; |
||||
let isExpanded = $state(false); |
||||
|
||||
const markupButtons = [ |
||||
{ label: "Bold", icon: Bold, action: () => insertMarkup("**", "**") }, |
||||
{ label: "Italic", icon: Italic, action: () => insertMarkup("_", "_") }, |
||||
{ |
||||
label: "Strike", |
||||
icon: Strikethrough, |
||||
action: () => insertMarkup("~~", "~~"), |
||||
}, |
||||
{ label: "Link", icon: Link2, action: () => insertMarkup("[", "](url)") }, |
||||
{ label: "Image", icon: Image, action: () => insertMarkup("") }, |
||||
{ label: "Quote", icon: Quote, action: () => insertMarkup("> ", "") }, |
||||
{ label: "List", icon: List, action: () => insertMarkup("* ", "") }, |
||||
{ |
||||
label: "Numbered List", |
||||
icon: ListOrdered, |
||||
action: () => insertMarkup("1. ", ""), |
||||
}, |
||||
{ label: "Hashtag", icon: Hash, action: () => insertMarkup("#", "") }, |
||||
]; |
||||
|
||||
function insertMarkup(prefix: string, suffix: string) { |
||||
const textarea = wrapper?.querySelector( |
||||
"textarea", |
||||
) as HTMLTextAreaElement | null; |
||||
if (!textarea) return; |
||||
|
||||
const start = textarea.selectionStart; |
||||
const end = textarea.selectionEnd; |
||||
const selectedText = value.substring(start, end); |
||||
|
||||
value = |
||||
value.substring(0, start) + |
||||
prefix + |
||||
selectedText + |
||||
suffix + |
||||
value.substring(end); |
||||
|
||||
// Set cursor position after the inserted markup |
||||
setTimeout(() => { |
||||
textarea.focus(); |
||||
textarea.selectionStart = textarea.selectionEnd = |
||||
start + prefix.length + selectedText.length + suffix.length; |
||||
}, 0); |
||||
} |
||||
|
||||
function togglePreview() { |
||||
activeTab = activeTab === "write" ? "preview" : "write"; |
||||
} |
||||
|
||||
function toggleSize() { |
||||
isExpanded = !isExpanded; |
||||
} |
||||
|
||||
$effect(() => { |
||||
if (activeTab !== "preview") return; |
||||
const src = value.trim(); |
||||
if (!src) { |
||||
preview = ""; |
||||
return; |
||||
} |
||||
Promise.resolve(parser(src)).then((html) => { |
||||
preview = html || ""; |
||||
}); |
||||
}); |
||||
</script> |
||||
|
||||
{#if label} |
||||
<Label for={id} class="mb-2">{label}</Label> |
||||
{/if} |
||||
|
||||
<div bind:this={wrapper} class="rounded-lg"> |
||||
<div class="min-h-[180px] relative"> |
||||
{#if activeTab === "write"} |
||||
<div class="inset-0"> |
||||
<Textarea |
||||
{id} |
||||
rows={isExpanded ? 30 : rows} |
||||
bind:value |
||||
classes={{ |
||||
wrapper: "!m-0 p-0 h-full", |
||||
inner: "!m-0 !bg-transparent !dark:bg-transparent", |
||||
header: "!m-0 !bg-transparent !dark:bg-transparent", |
||||
footer: "!m-0 !bg-transparent", |
||||
addon: "!m-0 top-3 hidden md:flex", |
||||
div: "!m-0 !bg-transparent !dark:bg-transparent !border-0 !rounded-none !shadow-none !focus:ring-0", |
||||
}} |
||||
{placeholder} |
||||
> |
||||
{#snippet header()} |
||||
<Toolbar |
||||
embedded |
||||
class="flex-row !m-0 !dark:bg-transparent !bg-transparent" |
||||
> |
||||
<ToolbarGroup class="flex-row flex-wrap !m-0"> |
||||
{#each markupButtons as button} |
||||
{@const TheIcon = button.icon} |
||||
<ToolbarButton |
||||
title={button.label} |
||||
color="dark" |
||||
size="md" |
||||
onclick={button.action} |
||||
> |
||||
<TheIcon size={24} /> |
||||
</ToolbarButton> |
||||
{/each} |
||||
{#if extensions} |
||||
{@render extensions()} |
||||
{/if} |
||||
<ToolbarButton |
||||
title="Toggle preview" |
||||
color="dark" |
||||
size="md" |
||||
onclick={togglePreview} |
||||
> |
||||
<Eye size={24} /> |
||||
</ToolbarButton> |
||||
</ToolbarGroup> |
||||
</Toolbar> |
||||
{/snippet} |
||||
</Textarea> |
||||
<Button |
||||
type="button" |
||||
size="xs" |
||||
class="absolute bottom-2 right-2 z-10 opacity-60 hover:opacity-100" |
||||
color="light" |
||||
onclick={toggleSize} |
||||
> |
||||
{isExpanded ? "⌃" : "⌄"} |
||||
</Button> |
||||
</div> |
||||
{:else} |
||||
<div |
||||
class="absolute rounded-lg inset-0 flex flex-col bg-white dark:bg-gray-900" |
||||
> |
||||
<div class="py-2 px-3 border-gray-200 dark:border-gray-700 border-b"> |
||||
<Toolbar |
||||
embedded |
||||
class="flex-row !m-0 !dark:bg-transparent !bg-transparent" |
||||
> |
||||
<ToolbarGroup class="flex-row flex-wrap !m-0"> |
||||
<ToolbarButton |
||||
title="Back to editor" |
||||
color="dark" |
||||
size="md" |
||||
onclick={togglePreview} |
||||
> |
||||
<PencilLine size={24} /> |
||||
</ToolbarButton> |
||||
</ToolbarGroup> |
||||
</Toolbar> |
||||
</div> |
||||
<div |
||||
class="flex-1 overflow-auto px-4 py-2 max-w-none bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 prose-content markup-content rounded-b-lg" |
||||
> |
||||
{#if preview} |
||||
{#if previewSnippet} |
||||
{@render previewSnippet(preview, previewArg)} |
||||
{:else} |
||||
{@html preview} |
||||
{/if} |
||||
{:else} |
||||
<p class="text-xs text-gray-500">Nothing to preview</p> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
|
||||
// Alexandria Component Library - Main Export File
|
||||
|
||||
// Primitive Components
|
||||
export { default as AAlert } from "./primitives/AAlert.svelte"; |
||||
export { default as ADetails } from "./primitives/ADetails.svelte"; |
||||
export { default as AInput } from "./primitives/AInput.svelte"; |
||||
export { default as ANostrBadge } from "./primitives/ANostrBadge.svelte"; |
||||
export { default as ANostrBadgeRow } from "./primitives/ANostrBadgeRow.svelte"; |
||||
export { default as ANostrUser } from "./primitives/ANostrUser.svelte"; |
||||
export { default as APagination } from "./primitives/APagination.svelte"; |
||||
export { default as AThemeToggleMini } from "./primitives/AThemeToggleMini.svelte"; |
||||
|
||||
// Navigation Components
|
||||
export { default as ANavbar } from "./nav/ANavbar.svelte"; |
||||
export { default as AFooter } from "./nav/AFooter.svelte"; |
||||
|
||||
// Form Components
|
||||
export { default as ACommentForm } from "./forms/ACommentForm.svelte"; |
||||
export { default as AMarkupForm } from "./forms/AMarkupForm.svelte"; |
||||
export { default as ASearchForm } from "./forms/ASearchForm.svelte"; |
||||
export { default as ATextareaWithPreview } from "./forms/ATextareaWithPreview.svelte"; |
||||
|
||||
// Card Components
|
||||
export { default as AEventPreview } from "./cards/AEventPreview.svelte"; |
||||
export { default as AProfilePreview } from "./cards/AProfilePreview.svelte"; |
||||
|
||||
// Reader Components
|
||||
export { default as ATechBlock } from "./reader/ATechBlock.svelte"; |
||||
export { default as ATechToggle } from "./reader/ATechToggle.svelte"; |
||||
@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
|
||||
<script> |
||||
/** |
||||
* @fileoverview AFooter Component - Alexandria |
||||
* |
||||
* A standardized footer component with copyright information and navigation links. |
||||
* Uses Flowbite's Footer components with Alexandria-specific styling and content. |
||||
* This component has no props - it renders a fixed footer structure. |
||||
* |
||||
* @component |
||||
* @category Navigation |
||||
* |
||||
* @example |
||||
* ```svelte |
||||
* <AFooter /> |
||||
* ``` |
||||
* |
||||
* @example Place at bottom of layout |
||||
* ```svelte |
||||
* <main> |
||||
* <!-- page content --> |
||||
* </main> |
||||
* <AFooter /> |
||||
* ``` |
||||
* |
||||
* @features |
||||
* - Copyright notice with GitCitadel attribution |
||||
* - Navigation links to About and Contact pages |
||||
* - Responsive layout that adapts to screen size |
||||
* - Consistent styling with Alexandria theme |
||||
* - Links to creator's nostr profile |
||||
* |
||||
* @accessibility |
||||
* - Semantic footer structure |
||||
* - Keyboard accessible navigation links |
||||
* - Screen reader friendly with proper link text |
||||
* - Responsive design for various screen sizes |
||||
* |
||||
* @integration |
||||
* - Typically placed at the bottom of page layouts |
||||
* - Uses Flowbite Footer components for consistency |
||||
* - Matches Alexandria's overall design system |
||||
*/ |
||||
|
||||
import { |
||||
Footer, |
||||
FooterCopyright, |
||||
FooterLink, |
||||
FooterLinkGroup, |
||||
} from "flowbite-svelte"; |
||||
</script> |
||||
|
||||
<Footer class="m-2"> |
||||
<FooterCopyright |
||||
href="/events?id=npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" |
||||
by="GitCitadel" |
||||
year={2025} |
||||
/> |
||||
<FooterLinkGroup |
||||
class="mt-3 flex flex-wrap items-center text-sm text-gray-500 sm:mt-0 dark:text-gray-400" |
||||
> |
||||
<FooterLink href="/about">About</FooterLink> |
||||
<FooterLink href="/contact">Contact</FooterLink> |
||||
</FooterLinkGroup> |
||||
</Footer> |
||||
@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
|
||||
<script lang="ts"> |
||||
/** |
||||
* @fileoverview ANavbar Component - Alexandria |
||||
* |
||||
* The main navigation bar component with responsive menu, user profile, and theme controls. |
||||
* Provides primary navigation for the Alexandria application with mega menu functionality. |
||||
* This component has no props - it renders a fixed navigation structure. |
||||
* |
||||
* @component |
||||
* @category Navigation |
||||
* |
||||
* @example |
||||
* ```svelte |
||||
* <ANavbar /> |
||||
* ``` |
||||
* |
||||
* @example Place at top of main layout |
||||
* ```svelte |
||||
* <ANavbar /> |
||||
* <main> |
||||
* <!-- page content --> |
||||
* </main> |
||||
* ``` |
||||
* |
||||
* @features |
||||
* - Responsive hamburger menu for mobile devices |
||||
* - Mega menu with categorized navigation items |
||||
* - User profile integration with sign-in/out functionality |
||||
* - Dark mode toggle |
||||
* - Brand logo and home link |
||||
* - Organized menu sections (Browse, Create, Learn, etc.) |
||||
* - Helpful descriptions for each navigation item |
||||
* |
||||
* @navigation |
||||
* - Browse: Publications, Events, Visualize |
||||
* - Create: Compose notes, Publish events |
||||
* - Learn: Getting Started, Relay Status |
||||
* - Profile: User-specific actions and settings |
||||
* |
||||
* @accessibility |
||||
* - Semantic navigation structure with proper ARIA attributes |
||||
* - Keyboard accessible menu items and dropdowns |
||||
* - Screen reader friendly with descriptive labels |
||||
* - Focus management for mobile menu |
||||
* - Proper heading hierarchy |
||||
* |
||||
* @integration |
||||
* - Uses Flowbite Navbar components for consistency |
||||
* - Integrates with Alexandria's theme system |
||||
* - Connects to user authentication state |
||||
* - Responsive design adapts to all screen sizes |
||||
*/ |
||||
|
||||
import { |
||||
DarkMode, |
||||
Navbar, |
||||
NavLi, |
||||
NavUl, |
||||
NavHamburger, |
||||
NavBrand, |
||||
MegaMenu, |
||||
P, |
||||
Heading |
||||
} from "flowbite-svelte"; |
||||
import Profile from "$components/util/Profile.svelte"; |
||||
|
||||
import { ChevronDownOutline } from "flowbite-svelte-icons"; |
||||
|
||||
let menu2 = [ |
||||
{ name: "Publications", href: "/", help: "Browse publications" }, |
||||
{ name: "Events", href: "/events", help: "Search and engage with events" }, |
||||
{ |
||||
name: "Visualize", |
||||
href: "/visualize", |
||||
help: "Visualize connections between publications and authors", |
||||
}, |
||||
|
||||
{ |
||||
name: "Compose notes", |
||||
href: "/new/compose", |
||||
help: "Create individual notes (30041 events)", |
||||
}, |
||||
{ |
||||
name: "Publish events", |
||||
href: "/events/compose", |
||||
help: "Create any kind", |
||||
}, |
||||
|
||||
{ |
||||
name: "Getting Started", |
||||
href: "/start", |
||||
help: "A quick overview and tutorial", |
||||
}, |
||||
{ |
||||
name: "Relay Status", |
||||
href: "/about/relay-stats", |
||||
help: "Relay status and monitoring", |
||||
}, |
||||
{ name: "About", href: "/about", help: "About the project" }, |
||||
{ |
||||
name: "Contact", |
||||
href: "/contact", |
||||
help: "Contact us or submit a bug report", |
||||
}, |
||||
]; |
||||
</script> |
||||
|
||||
<Navbar |
||||
id="navi" |
||||
class="fixed start-0 top-0 z-50 flex flex-row bg-primary-0 dark:bg-primary-1000" |
||||
navContainerClass="flex-row items-center !p-0" |
||||
> |
||||
<NavBrand href="/" > |
||||
<div class="flex flex-col"> |
||||
<Heading class="text-2xl font-bold mb-0"> |
||||
Alexandria |
||||
</Heading> |
||||
<P class="text-xs font-semibold tracking-wide max-sm:max-w-[11rem] mb-0"> |
||||
READ THE ORIGINAL. MAKE CONNECTIONS. CULTIVATE KNOWLEDGE. |
||||
</P> |
||||
</div> |
||||
</NavBrand> |
||||
<div class="flex md:order-2"> |
||||
<Profile /> |
||||
<NavHamburger /> |
||||
</div> |
||||
<NavUl class="order-1 ml-auto items-center" classes={{ ul: "items-center" }}> |
||||
<NavLi class="cursor-pointer"> |
||||
Explore<ChevronDownOutline |
||||
class="text-primary-800 ms-2 inline h-6 w-6 dark:text-white" |
||||
/> |
||||
</NavLi> |
||||
<MegaMenu items={menu2}> |
||||
{#snippet children({ item })} |
||||
<a |
||||
href={item.href} |
||||
class="block h-full rounded-lg p-3 hover:bg-gray-50 dark:hover:bg-gray-700" |
||||
> |
||||
<div class="font-semibold dark:text-white">{item.name}</div> |
||||
<span class="text-sm font-light text-gray-500 dark:text-gray-400" |
||||
>{item.help}</span |
||||
> |
||||
</a> |
||||
{/snippet} |
||||
</MegaMenu> |
||||
<DarkMode /> |
||||
</NavUl> |
||||
</Navbar> |
||||
@ -0,0 +1,370 @@
@@ -0,0 +1,370 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "fs"; |
||||
import path from "path"; |
||||
import { fileURLToPath } from "url"; |
||||
|
||||
const __filename = fileURLToPath(import.meta.url); |
||||
const __dirname = path.dirname(__filename); |
||||
|
||||
/** |
||||
* @typedef {Object} PropDefinition |
||||
* @property {string} name |
||||
* @property {string[]} type |
||||
* @property {string | null | undefined} default |
||||
* @property {string} description |
||||
* @property {boolean} required |
||||
*/ |
||||
|
||||
/** |
||||
* @typedef {Object} ExampleDefinition |
||||
* @property {string} name |
||||
* @property {string} code |
||||
*/ |
||||
|
||||
/** |
||||
* @typedef {Object} ComponentDefinition |
||||
* @property {string} name |
||||
* @property {string} description |
||||
* @property {string} category |
||||
* @property {PropDefinition[]} props |
||||
* @property {string[]} events |
||||
* @property {string[]} slots |
||||
* @property {ExampleDefinition[]} examples |
||||
* @property {string[]} features |
||||
* @property {string[]} accessibility |
||||
* @property {string} since |
||||
*/ |
||||
|
||||
/** |
||||
* Parse TSDoc comments from Svelte component files |
||||
*/ |
||||
class ComponentParser { |
||||
constructor() { |
||||
/** @type {ComponentDefinition[]} */ |
||||
this.components = []; |
||||
} |
||||
|
||||
/** |
||||
* Extract TSDoc block from script content |
||||
* @param {string} content |
||||
* @returns {string | null} |
||||
*/ |
||||
extractTSDoc(content) { |
||||
const scriptMatch = content.match(/<script[^>]*>([\s\S]*?)<\/script>/); |
||||
if (!scriptMatch) return null; |
||||
|
||||
const scriptContent = scriptMatch[1]; |
||||
const tsDocMatch = scriptContent.match(/\/\*\*\s*([\s\S]*?)\*\//); |
||||
if (!tsDocMatch) return null; |
||||
|
||||
return tsDocMatch[1]; |
||||
} |
||||
|
||||
/** |
||||
* Parse TSDoc content into structured data |
||||
* @param {string} tsDocContent |
||||
* @returns {ComponentDefinition} |
||||
*/ |
||||
parseTSDoc(tsDocContent) { |
||||
const lines = tsDocContent |
||||
.split("\n") |
||||
.map((line) => line.replace(/^\s*\*\s?/, "").trim()); |
||||
|
||||
/** @type {ComponentDefinition} */ |
||||
const component = { |
||||
name: "", |
||||
description: "", |
||||
category: "", |
||||
props: [], |
||||
events: [], |
||||
slots: [], |
||||
examples: [], |
||||
features: [], |
||||
accessibility: [], |
||||
since: "1.0.0", // Default version
|
||||
}; |
||||
|
||||
let currentSection = "description"; |
||||
let currentExample = ""; |
||||
let inCodeBlock = false; |
||||
|
||||
for (let i = 0; i < lines.length; i++) { |
||||
const line = lines[i]; |
||||
|
||||
// Skip empty lines
|
||||
if (!line) continue; |
||||
|
||||
// Handle @tags
|
||||
if (line.startsWith("@fileoverview")) { |
||||
const nameMatch = line.match(/@fileoverview\s+(\w+)\s+Component/); |
||||
if (nameMatch) { |
||||
component.name = nameMatch[1]; |
||||
} |
||||
const descMatch = lines |
||||
.slice(i + 1) |
||||
.find((l) => l && !l.startsWith("@")) |
||||
?.trim(); |
||||
if (descMatch) { |
||||
component.description = descMatch; |
||||
} |
||||
continue; |
||||
} |
||||
|
||||
if (line.startsWith("@category")) { |
||||
component.category = line.replace("@category", "").trim(); |
||||
continue; |
||||
} |
||||
|
||||
if (line.startsWith("@prop")) { |
||||
const prop = this.parseProp(line); |
||||
if (prop) component.props.push(prop); |
||||
continue; |
||||
} |
||||
|
||||
if (line.startsWith("@example")) { |
||||
currentSection = "example"; |
||||
currentExample = line.replace("@example", "").trim(); |
||||
if (currentExample) { |
||||
currentExample += "\n"; |
||||
} |
||||
continue; |
||||
} |
||||
|
||||
if (line.startsWith("@features")) { |
||||
currentSection = "features"; |
||||
continue; |
||||
} |
||||
|
||||
if (line.startsWith("@accessibility")) { |
||||
currentSection = "accessibility"; |
||||
continue; |
||||
} |
||||
|
||||
if (line.startsWith("@since")) { |
||||
component.since = line.replace("@since", "").trim(); |
||||
continue; |
||||
} |
||||
|
||||
// Handle content based on current section
|
||||
if (currentSection === "example") { |
||||
if (line === "```svelte" || line === "```") { |
||||
inCodeBlock = !inCodeBlock; |
||||
if (!inCodeBlock && currentExample.trim()) { |
||||
component.examples.push({ |
||||
name: currentExample.split("\n")[0] || "Example", |
||||
code: currentExample.trim(), |
||||
}); |
||||
currentExample = ""; |
||||
} |
||||
continue; |
||||
} |
||||
if (inCodeBlock) { |
||||
currentExample += line + "\n"; |
||||
} else if (line.startsWith("@")) { |
||||
// New section started
|
||||
i--; // Reprocess this line
|
||||
currentSection = "description"; |
||||
} else if (line && !line.startsWith("```")) { |
||||
currentExample = line + "\n"; |
||||
} |
||||
continue; |
||||
} |
||||
|
||||
if (currentSection === "features" && line.startsWith("-")) { |
||||
component.features.push(line.substring(1).trim()); |
||||
continue; |
||||
} |
||||
|
||||
if (currentSection === "accessibility" && line.startsWith("-")) { |
||||
component.accessibility.push(line.substring(1).trim()); |
||||
} |
||||
} |
||||
|
||||
return component; |
||||
} |
||||
|
||||
/** |
||||
* Parse a @prop line into structured prop data |
||||
* @param {string} propLine |
||||
* @returns {PropDefinition | null} |
||||
*/ |
||||
parseProp(propLine) { |
||||
// First, extract the type by finding balanced braces
|
||||
const propMatch = propLine.match(/@prop\s+\{/); |
||||
if (!propMatch || propMatch.index === undefined) return null; |
||||
|
||||
// Find the closing brace for the type
|
||||
let braceCount = 1; |
||||
let typeEndIndex = propMatch.index + propMatch[0].length; |
||||
const lineAfterType = propLine.substring(typeEndIndex); |
||||
|
||||
for (let i = 0; i < lineAfterType.length; i++) { |
||||
if (lineAfterType[i] === "{") braceCount++; |
||||
if (lineAfterType[i] === "}") braceCount--; |
||||
if (braceCount === 0) { |
||||
typeEndIndex += i; |
||||
break; |
||||
} |
||||
} |
||||
|
||||
const typeStr = propLine |
||||
.substring(propMatch.index + propMatch[0].length, typeEndIndex) |
||||
.trim(); |
||||
const restOfLine = propLine.substring(typeEndIndex + 1).trim(); |
||||
|
||||
// Parse the rest: [name=default] or name - description
|
||||
const restMatch = restOfLine.match( |
||||
/(\[?)([^[\]\s=-]+)(?:=([^\]]*))?]?\s*-?\s*(.*)/, |
||||
); |
||||
|
||||
if (!restMatch) return null; |
||||
|
||||
const [, isOptional, name, defaultValue, description] = restMatch; |
||||
|
||||
// Parse type - handle union types like "xs" | "s" | "m" | "l"
|
||||
let type = [typeStr.trim()]; |
||||
if (typeStr.includes("|") && !typeStr.includes("<")) { |
||||
type = typeStr.split("|").map((t) => t.trim().replace(/"/g, "")); |
||||
} else if (typeStr.includes('"') && !typeStr.includes("<")) { |
||||
// Handle quoted literal types
|
||||
const literals = typeStr.match(/"[^"]+"/g); |
||||
if (literals) { |
||||
type = literals.map((l) => l.replace(/"/g, "")); |
||||
} |
||||
} |
||||
|
||||
return { |
||||
name: name.trim(), |
||||
type: type, |
||||
default: defaultValue |
||||
? defaultValue.trim() |
||||
: isOptional |
||||
? undefined |
||||
: null, |
||||
description: description.trim(), |
||||
required: !isOptional, |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Process a single Svelte file |
||||
* @param {string} filePath |
||||
* @returns {ComponentDefinition | null} |
||||
*/ |
||||
processFile(filePath) { |
||||
try { |
||||
const content = fs.readFileSync(filePath, "utf-8"); |
||||
const tsDocContent = this.extractTSDoc(content); |
||||
|
||||
if (!tsDocContent) { |
||||
console.warn(`No TSDoc found in ${filePath}`); |
||||
return null; |
||||
} |
||||
|
||||
const component = this.parseTSDoc(tsDocContent); |
||||
|
||||
// If no name was extracted, use filename
|
||||
if (!component.name) { |
||||
component.name = path.basename(filePath, ".svelte"); |
||||
} |
||||
|
||||
return component; |
||||
} catch (error) { |
||||
const errorMessage = error instanceof Error |
||||
? error.message |
||||
: String(error); |
||||
console.error(`Error processing ${filePath}:`, errorMessage); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Process all Svelte files in a directory recursively |
||||
* @param {string} dirPath |
||||
*/ |
||||
processDirectory(dirPath) { |
||||
const items = fs.readdirSync(dirPath); |
||||
|
||||
for (const item of items) { |
||||
const itemPath = path.join(dirPath, item); |
||||
const stat = fs.statSync(itemPath); |
||||
|
||||
if (stat.isDirectory()) { |
||||
this.processDirectory(itemPath); |
||||
} else if (item.endsWith(".svelte")) { |
||||
const component = this.processFile(itemPath); |
||||
if (component) { |
||||
this.components.push(component); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Generate the final JSON output |
||||
*/ |
||||
generateOutput() { |
||||
// Sort components by category and name
|
||||
this.components.sort((a, b) => { |
||||
if (a.category !== b.category) { |
||||
return a.category.localeCompare(b.category); |
||||
} |
||||
return a.name.localeCompare(b.name); |
||||
}); |
||||
|
||||
return { |
||||
library: "Alexandria Component Library", |
||||
version: "1.0.0", |
||||
generated: new Date().toISOString(), |
||||
totalComponents: this.components.length, |
||||
categories: [...new Set(this.components.map((c) => c.category))].sort(), |
||||
components: this.components, |
||||
}; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Main execution |
||||
*/ |
||||
function main() { |
||||
const parser = new ComponentParser(); |
||||
const aFolderPath = __dirname; |
||||
|
||||
console.log("Parsing Alexandria components..."); |
||||
console.log(`Source directory: ${aFolderPath}`); |
||||
|
||||
if (!fs.existsSync(aFolderPath)) { |
||||
console.error(`Directory not found: ${aFolderPath}`); |
||||
process.exit(1); |
||||
} |
||||
|
||||
// Process all components
|
||||
parser.processDirectory(aFolderPath); |
||||
|
||||
// Generate output
|
||||
const output = parser.generateOutput(); |
||||
|
||||
// Write to file in the same directory (/a folder)
|
||||
const outputPath = path.join(__dirname, "alexandria-components.json"); |
||||
fs.writeFileSync(outputPath, JSON.stringify(output, null, 2)); |
||||
|
||||
console.log(`\n✅ Successfully parsed ${output.totalComponents} components`); |
||||
console.log(`📁 Categories: ${output.categories.join(", ")}`); |
||||
console.log(`💾 Output saved to: ${outputPath}`); |
||||
|
||||
// Print summary
|
||||
console.log("\n📊 Component Summary:"); |
||||
/** @type {Record<string, number>} */ |
||||
const categoryCounts = {}; |
||||
output.components.forEach((c) => { |
||||
categoryCounts[c.category] = (categoryCounts[c.category] || 0) + 1; |
||||
}); |
||||
|
||||
Object.entries(categoryCounts).forEach(([category, count]) => { |
||||
console.log(` ${category}: ${count} components`); |
||||
}); |
||||
} |
||||
|
||||
// Run the script
|
||||
main(); |
||||
@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
|
||||
<script lang="ts"> |
||||
/** |
||||
* @fileoverview AAlert Component - Alexandria |
||||
* |
||||
* A themed alert component based on Flowbite's Alert with consistent styling. |
||||
* Provides notifications, warnings, and informational messages with optional dismissal. |
||||
* |
||||
* @component |
||||
* @category Primitives |
||||
* |
||||
* @prop {string} [color] - Alert color theme (success, warning, error, info, etc.) |
||||
* @prop {boolean} [dismissable] - Whether alert can be dismissed by user |
||||
* @prop {snippet} children - Main alert content (required) |
||||
* @prop {snippet} [title] - Optional title section |
||||
* @prop {string} [classes] - Additional CSS classes to apply |
||||
* |
||||
* @example |
||||
* ```svelte |
||||
* <AAlert color="success" dismissable={true}> |
||||
* {#snippet title()}Success!{/snippet} |
||||
* {#snippet children()}Your changes have been saved.{/snippet} |
||||
* </AAlert> |
||||
* ``` |
||||
* |
||||
* @example Simple alert |
||||
* ```svelte |
||||
* <AAlert color="info"> |
||||
* {#snippet children()}This is an informational message.{/snippet} |
||||
* </AAlert> |
||||
* ``` |
||||
* |
||||
* @example Alert with title and custom classes |
||||
* ```svelte |
||||
* <AAlert color="warning" classes="mt-4" dismissable={true}> |
||||
* {#snippet title()}Warning{/snippet} |
||||
* {#snippet children()}Please check your input.{/snippet} |
||||
* </AAlert> |
||||
* ``` |
||||
* |
||||
* @features |
||||
* - Consistent "leather" theme styling |
||||
* - Built on Flowbite Alert component |
||||
* - Support for custom colors and dismissal |
||||
* - Flexible content with title and body sections |
||||
* |
||||
* @accessibility |
||||
* - Inherits Flowbite's accessibility features |
||||
* - Proper ARIA attributes for alerts |
||||
* - Keyboard accessible dismiss button when dismissable |
||||
*/ |
||||
|
||||
import { Alert } from "flowbite-svelte"; |
||||
|
||||
let { color, dismissable, children, title, classes } = $props<{ |
||||
color?: string; |
||||
dismissable?: boolean; |
||||
children?: any; |
||||
title?: any; |
||||
classes?: string; |
||||
}>(); |
||||
</script> |
||||
|
||||
<Alert {color} {dismissable} class="alert-leather mb-4 {classes}"> |
||||
{#if title} |
||||
<div class="flex"> |
||||
<span class="text-lg font-medium">{@render title()}</span> |
||||
</div> |
||||
{/if} |
||||
{@render children()} |
||||
</Alert> |
||||
@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
|
||||
<script lang="ts"> |
||||
/** |
||||
* @fileoverview ADetails Component - Alexandria |
||||
* |
||||
* A collapsible details/summary element with enhanced styling and tech-aware functionality. |
||||
* Integrates with the techStore to automatically hide technical details based on user preference. |
||||
* |
||||
* @component |
||||
* @category Primitives |
||||
* |
||||
* @prop {string} [summary=""] - The summary text shown in the collapsible header |
||||
* @prop {boolean} [tech=false] - Whether this contains technical content (affects visibility) |
||||
* @prop {boolean} [defaultOpen=false] - Whether details should be open by default |
||||
* @prop {boolean} [forceHide=false] - Force hide content even when tech mode is on |
||||
* @prop {string} [class=""] - Additional CSS classes to apply |
||||
* @prop {snippet} children - The content to show/hide in the details body (required, via default slot) |
||||
* |
||||
* @example |
||||
* ```svelte |
||||
* <ADetails summary="Event Details" tech={true}> |
||||
* <p>Technical event information here...</p> |
||||
* </ADetails> |
||||
* ``` |
||||
* |
||||
* @example Regular details block |
||||
* ```svelte |
||||
* <ADetails summary="More Information"> |
||||
* <p>Additional content here...</p> |
||||
* </ADetails> |
||||
* ``` |
||||
* |
||||
* @example Technical details with custom styling |
||||
* ```svelte |
||||
* <ADetails summary="Raw Event Data" tech={true} class="border-red-200"> |
||||
* <pre>{JSON.stringify(event, null, 2)}</pre> |
||||
* </ADetails> |
||||
* ``` |
||||
* |
||||
* @features |
||||
* - Respects global techStore setting for tech content |
||||
* - Animated chevron icon indicates open/closed state |
||||
* - "Technical" badge for tech-related details |
||||
* - Consistent themed styling with hover effects |
||||
* - Auto-closes tech details when techStore is disabled |
||||
* |
||||
* @accessibility |
||||
* - Uses semantic HTML details/summary elements |
||||
* - Keyboard accessible (Enter/Space to toggle) |
||||
* - Screen reader friendly with proper labeling |
||||
* - Clear visual indicators for state changes |
||||
*/ |
||||
|
||||
import { showTech } from "$lib/stores/techStore"; |
||||
let { |
||||
summary = "", |
||||
tech = false, |
||||
defaultOpen = false, |
||||
forceHide = false, |
||||
class: className = "", |
||||
} = $props(); |
||||
let open = $derived(defaultOpen); |
||||
$effect(() => { |
||||
if (tech && !$showTech) open = false; |
||||
}); |
||||
function onToggle(e: Event) { |
||||
const el = e.currentTarget as HTMLDetailsElement; |
||||
open = el.open; |
||||
} |
||||
</script> |
||||
|
||||
<details |
||||
{open} |
||||
ontoggle={onToggle} |
||||
class={`group rounded-lg border border-muted/20 bg-surface ${className}`} |
||||
data-kind={tech ? "tech" : "general"} |
||||
> |
||||
<summary |
||||
class="flex items-center gap-2 cursor-pointer list-none px-3 py-2 rounded-lg select-none hover:bg-primary/10" |
||||
> |
||||
<svg |
||||
class={`h-4 w-4 transition-transform ${open ? "rotate-90" : ""}`} |
||||
viewBox="0 0 24 24" |
||||
fill="none" |
||||
stroke="currentColor" |
||||
stroke-width="2" |
||||
stroke-linecap="round" |
||||
stroke-linejoin="round"><path d="M9 18l6-6-6-6" /></svg |
||||
> |
||||
<span class="font-medium">{summary}</span> |
||||
{#if tech}<span |
||||
class="ml-2 text-[10px] uppercase tracking-wide rounded px-1.5 py-0.5 border border-primary/30 text-primary bg-primary/5" |
||||
>Technical</span |
||||
>{/if} |
||||
<span class="ml-auto text-xs opacity-60 group-open:opacity-50" |
||||
>{open ? "Hide" : "Show"}</span |
||||
> |
||||
</summary> |
||||
{#if !(tech && !$showTech && forceHide)}<div |
||||
class="px-3 pb-3 pt-1 text-[0.95rem] leading-6" |
||||
> |
||||
<slot /> |
||||
</div>{/if} |
||||
</details> |
||||
@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
|
||||
<script lang="ts"> |
||||
/** |
||||
* @fileoverview AInput Component - Alexandria |
||||
* |
||||
* A styled input field with consistent theming and focus states. |
||||
* Provides a standardized text input with Alexandria's design system. |
||||
* |
||||
* @component |
||||
* @category Primitives |
||||
* |
||||
* @prop {string} value - The input value (bindable) |
||||
* @prop {string} [class=""] - Additional CSS classes to apply |
||||
* @prop {string} [placeholder=""] - Placeholder text for the input |
||||
* |
||||
* @example |
||||
* ```svelte |
||||
* <AInput bind:value={searchQuery} placeholder="Enter search term..." /> |
||||
* ``` |
||||
* |
||||
* @example Basic input |
||||
* ```svelte |
||||
* <AInput bind:value={name} placeholder="Your name" /> |
||||
* ``` |
||||
* |
||||
* @example Custom styled input |
||||
* ```svelte |
||||
* <AInput |
||||
* bind:value={email} |
||||
* placeholder="Email address" |
||||
* class="max-w-md border-blue-300" |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @features |
||||
* - Consistent themed styling with focus rings |
||||
* - Full width by default with customizable classes |
||||
* - Smooth focus transitions and hover effects |
||||
* - Integrates with Alexandria's color system |
||||
* |
||||
* @accessibility |
||||
* - Proper focus management with visible focus rings |
||||
* - Keyboard accessible |
||||
* - Supports all standard input attributes |
||||
* - Screen reader compatible |
||||
*/ |
||||
|
||||
let { value = $bindable(""), class: className = "", placeholder = "" } = $props<{ |
||||
value?: string; |
||||
class?: string; |
||||
placeholder?: string; |
||||
}>(); |
||||
</script> |
||||
|
||||
<input |
||||
class={`w-full h-10 px-3 rounded-md border border-muted/30 bg-surface text-text placeholder:opacity-60 focus:outline-none focus:ring-2 focus:ring-primary/40 ${className}`} |
||||
bind:value |
||||
{placeholder} |
||||
/> |
||||
@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
|
||||
<script lang="ts"> |
||||
/** |
||||
* @fileoverview ANostrBadge Component - Alexandria |
||||
* |
||||
* Displays a nostr badge (NIP-58) with image or fallback text representation. |
||||
* Shows badge thumbnails with proper sizing and accessibility features. |
||||
* |
||||
* @component |
||||
* @category Primitives |
||||
* |
||||
* @prop {DisplayBadge} badge - Badge object containing title, thumbUrl, etc. (required) |
||||
* @prop {"xs" | "s" | "m" | "l"} [size="s"] - Badge size (xs: 16px, s: 24px, m: 32px, l: 48px) |
||||
* |
||||
* @example |
||||
* ```svelte |
||||
* <ANostrBadge {badge} size="m" /> |
||||
* ``` |
||||
* |
||||
* @example Badge with image |
||||
* ```svelte |
||||
* <ANostrBadge badge={{title: "Developer", thumbUrl: "/badge.png"}} size="l" /> |
||||
* ``` |
||||
* |
||||
* @example Badge without image (shows first letter) |
||||
* ```svelte |
||||
* <ANostrBadge badge={{title: "Contributor"}} size="s" /> |
||||
* ``` |
||||
* |
||||
* @example In a list of badges |
||||
* ```svelte |
||||
* {#each userBadges as badge} |
||||
* <ANostrBadge {badge} size="xs" /> |
||||
* {/each} |
||||
* ``` |
||||
* |
||||
* @typedef {Object} DisplayBadge |
||||
* @property {string} title - Badge title |
||||
* @property {string} [thumbUrl] - Optional thumbnail URL |
||||
* |
||||
* @features |
||||
* - Displays badge thumbnail image when available |
||||
* - Fallback to first letter of title when no image |
||||
* - Multiple size options for different contexts |
||||
* - Lazy loading for performance |
||||
* - Proper aspect ratio and object-fit |
||||
* |
||||
* @accessibility |
||||
* - Alt text for badge images |
||||
* - Title attribute for hover information |
||||
* - Proper semantic structure |
||||
* - Loading and decoding optimizations |
||||
*/ |
||||
|
||||
import type { DisplayBadge } from "$lib/nostr/nip58"; |
||||
|
||||
let { badge, size = "s" }: { badge: DisplayBadge; size?: "xs" | "s" | "m" | "l" } = $props(); |
||||
const px = { xs: 16, s: 24, m: 32, l: 48 }[size]; |
||||
</script> |
||||
|
||||
<span class="inline-flex items-center" title={badge.title}> |
||||
{#if badge.thumbUrl} |
||||
<img |
||||
src={badge.thumbUrl} |
||||
alt={badge.title} |
||||
width={px} |
||||
height={px} |
||||
loading="lazy" |
||||
decoding="async" |
||||
class="rounded-md border border-muted/20 object-cover" |
||||
/> |
||||
{:else} |
||||
<span |
||||
class="grid place-items-center rounded-md border border-muted/20 bg-surface text-xs" |
||||
style={`width:${px}px;height:${px}px`} |
||||
> |
||||
{badge.title.slice(0, 1)} |
||||
</span> |
||||
{/if} |
||||
</span> |
||||
@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
|
||||
<script lang="ts"> |
||||
/** |
||||
* @fileoverview ANostrBadgeRow Component - Alexandria |
||||
* |
||||
* Displays a horizontal row of nostr badges with optional limit and overflow indicator. |
||||
* Uses ANostrBadge components to render individual badges in a flex layout. |
||||
* |
||||
* @component |
||||
* @category Primitives |
||||
* |
||||
* @prop {DisplayBadge[]} [badges=[]] - Array of badge objects to display |
||||
* @prop {"xs" | "s" | "m" | "l"} [size="s"] - Size for all badges in the row |
||||
* @prop {number} [limit=6] - Maximum number of badges to show before truncating |
||||
* |
||||
* @example |
||||
* ```svelte |
||||
* <ANostrBadgeRow badges={userBadges} size="m" limit={4} /> |
||||
* ``` |
||||
* |
||||
* @example Show all badges |
||||
* ```svelte |
||||
* <ANostrBadgeRow badges={allBadges} limit={999} /> |
||||
* ``` |
||||
* |
||||
* @example Limited display with small badges |
||||
* ```svelte |
||||
* <ANostrBadgeRow badges={userBadges} size="xs" limit={3} /> |
||||
* ``` |
||||
* |
||||
* @example Profile header with medium badges |
||||
* ```svelte |
||||
* <ANostrBadgeRow badges={profileBadges} size="m" limit={5} /> |
||||
* ``` |
||||
* |
||||
* @features |
||||
* - Responsive flex layout with wrapping |
||||
* - Configurable display limit with overflow counter |
||||
* - Consistent spacing between badges |
||||
* - Shows "+N" indicator when badges exceed limit |
||||
* - Uses badge.def.id as key for efficient rendering |
||||
* |
||||
* @accessibility |
||||
* - Inherits accessibility from ANostrBadge components |
||||
* - Clear visual hierarchy with proper spacing |
||||
* - Overflow indicator provides context about hidden badges |
||||
*/ |
||||
|
||||
import type { DisplayBadge } from "$lib/nostr/nip58"; |
||||
import ANostrBadge from "./ANostrBadge.svelte"; |
||||
|
||||
let { badges = [], size = "s", limit = 6 }: { badges?: DisplayBadge[]; size?: "xs" | "s" | "m" | "l"; limit?: number } = $props(); |
||||
const shown = () => badges.slice(0, limit); |
||||
</script> |
||||
|
||||
<div class="flex flex-wrap gap-1.5 items-center"> |
||||
{#each shown() as b (b.def.id)} |
||||
<ANostrBadge badge={b} {size} /> |
||||
{/each} |
||||
{#if badges.length > limit} |
||||
<span |
||||
class="text-[10px] px-1.5 py-0.5 rounded-md border border-muted/30 bg-surface/70" |
||||
> |
||||
+{badges.length - limit} |
||||
</span> |
||||
{/if} |
||||
</div> |
||||
@ -0,0 +1,218 @@
@@ -0,0 +1,218 @@
|
||||
<script lang="ts"> |
||||
/** |
||||
* @fileoverview ANostrUser Component - Alexandria |
||||
* |
||||
* Displays a nostr user with avatar, display name, npub, NIP-05 verification, and badges. |
||||
* Provides a comprehensive user representation with configurable display options. |
||||
* |
||||
* @component |
||||
* @category Primitives |
||||
* |
||||
* @prop {string} npub - The user's npub (required) |
||||
* @prop {string} [pubkey] - The user's public key (for NIP-05 verification) |
||||
* @prop {NostrProfile} [profile] - User profile metadata |
||||
* @prop {"sm" | "md" | "lg"} [size="md"] - Component size variant |
||||
* @prop {boolean} [showNpub=true] - Whether to show the shortened npub |
||||
* @prop {boolean} [showBadges=true] - Whether to display user badges |
||||
* @prop {boolean} [verifyNip05=true] - Whether to verify NIP-05 identifier |
||||
* @prop {boolean} [nip05Verified] - Override NIP-05 verification status |
||||
* @prop {DisplayBadge[] | null} [nativeBadges] - User's badges to display |
||||
* @prop {number} [badgeLimit=6] - Maximum badges to show |
||||
* @prop {string} [href] - Optional link URL (makes component clickable) |
||||
* @prop {string} [class=""] - Additional CSS classes |
||||
* |
||||
* @example |
||||
* ```svelte |
||||
* <ANostrUser |
||||
* {npub} |
||||
* {pubkey} |
||||
* {profile} |
||||
* size="lg" |
||||
* showBadges={true} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @example Basic user display |
||||
* ```svelte |
||||
* <ANostrUser {npub} {profile} /> |
||||
* ``` |
||||
* |
||||
* @example Large user card with all features |
||||
* ```svelte |
||||
* <ANostrUser |
||||
* {npub} |
||||
* {pubkey} |
||||
* {profile} |
||||
* size="lg" |
||||
* nativeBadges={userBadges} |
||||
* href="/profile/{npub}" |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @example Compact user mention |
||||
* ```svelte |
||||
* <ANostrUser |
||||
* {npub} |
||||
* size="sm" |
||||
* showNpub={false} |
||||
* showBadges={false} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @features |
||||
* - Avatar display with fallback |
||||
* - Display name from profile or npub |
||||
* - NIP-05 verification with visual indicator |
||||
* - Badge integration via ANostrBadgeRow |
||||
* - Configurable sizing and display options |
||||
* - Optional linking capability |
||||
* - Loading states for verification |
||||
* |
||||
* @accessibility |
||||
* - Semantic user representation |
||||
* - Alt text for avatars |
||||
* - Screen reader friendly verification status |
||||
* - Keyboard accessible when linked |
||||
* - Proper focus management |
||||
*/ |
||||
|
||||
import type { NostrProfile } from "$lib/nostr/types"; |
||||
import type { DisplayBadge } from "$lib/nostr/nip58"; |
||||
import ANostrBadgeRow from "./ANostrBadgeRow.svelte"; |
||||
import { shortenBech32, displayNameFrom } from "$lib/nostr/format"; |
||||
import { verifyNip05 } from "$lib/nostr/nip05"; |
||||
import { onMount } from "svelte"; |
||||
|
||||
let { |
||||
npub, // required |
||||
pubkey = undefined as string | undefined, |
||||
profile = undefined as NostrProfile | undefined, |
||||
size = "md" as "sm" | "md" | "lg", |
||||
showNpub = true, |
||||
showBadges = true, |
||||
verifyNip05: doVerify = true, |
||||
nip05Verified = undefined as boolean | undefined, |
||||
nativeBadges = null as DisplayBadge[] | null, |
||||
badgeLimit = 6, |
||||
href = undefined as string | undefined, |
||||
class: className = "", |
||||
} = $props(); |
||||
|
||||
// Derived view-model |
||||
let displayName = displayNameFrom(npub, profile); |
||||
let shortNpub = shortenBech32(npub, true); |
||||
let avatarUrl = profile?.picture ?? ""; |
||||
let nip05 = profile?.nip05 ?? ""; |
||||
|
||||
// NIP-05 verify |
||||
let computedVerified = $state(false); |
||||
let loadingVerify = $state(false); |
||||
|
||||
onMount(async () => { |
||||
if (nip05Verified !== undefined) { |
||||
computedVerified = nip05Verified; |
||||
return; |
||||
} |
||||
if (!doVerify || !nip05 || !pubkey) return; |
||||
loadingVerify = true; |
||||
computedVerified = await verifyNip05(nip05, pubkey); |
||||
loadingVerify = false; |
||||
}); |
||||
|
||||
// Sizing map |
||||
const sizes = { |
||||
sm: { |
||||
avatar: "h-6 w-6", |
||||
gap: "gap-2", |
||||
name: "text-sm", |
||||
meta: "text-[11px]", |
||||
}, |
||||
md: { |
||||
avatar: "h-8 w-8", |
||||
gap: "gap-2.5", |
||||
name: "text-base", |
||||
meta: "text-xs", |
||||
}, |
||||
lg: { avatar: "h-10 w-10", gap: "gap-3", name: "text-lg", meta: "text-sm" }, |
||||
}[size]; |
||||
</script> |
||||
|
||||
{#if href} |
||||
<a {href} class={`inline-flex items-center ${sizes.gap} ${className}`}> |
||||
<Content /> |
||||
</a> |
||||
{:else} |
||||
<div class={`inline-flex items-center ${sizes.gap} ${className}`}> |
||||
<Content /> |
||||
</div> |
||||
{/if} |
||||
|
||||
<!-- component content as a fragment (no extra <script> blocks, no JSX) --> |
||||
{#snippet Content()} |
||||
<span |
||||
class={`shrink-0 rounded-full overflow-hidden bg-muted/20 border border-muted/30 ${sizes.avatar}`} |
||||
> |
||||
{#if avatarUrl} |
||||
<img src={avatarUrl} alt="" class="h-full w-full object-cover" /> |
||||
{:else} |
||||
<span class="h-full w-full grid place-items-center text-xs opacity-70"> |
||||
{displayName.slice(0, 1).toUpperCase()} |
||||
</span> |
||||
{/if} |
||||
</span> |
||||
|
||||
<span class="min-w-0"> |
||||
<span class={`flex items-center gap-1 font-medium ${sizes.name}`}> |
||||
<span class="truncate">{displayName}</span> |
||||
{#if nip05 && (computedVerified || loadingVerify)} |
||||
<span |
||||
class="inline-flex items-center" |
||||
title={computedVerified ? `NIP-05 verified: ${nip05}` : "Verifying…"} |
||||
> |
||||
{#if computedVerified} |
||||
<!-- Verified check --> |
||||
<svg |
||||
class="h-4 w-4 text-primary" |
||||
viewBox="0 0 24 24" |
||||
fill="none" |
||||
stroke="currentColor" |
||||
stroke-width="2" |
||||
stroke-linecap="round" |
||||
stroke-linejoin="round" |
||||
> |
||||
<path d="M20 6L9 17l-5-5" /> |
||||
</svg> |
||||
{:else} |
||||
<!-- Loading ring --> |
||||
<svg |
||||
class="h-4 w-4 animate-pulse opacity-70" |
||||
viewBox="0 0 24 24" |
||||
fill="none" |
||||
stroke="currentColor" |
||||
stroke-width="2" |
||||
> |
||||
<circle cx="12" cy="12" r="10" /> |
||||
</svg> |
||||
{/if} |
||||
</span> |
||||
{/if} |
||||
</span> |
||||
|
||||
<span class={`flex items-center gap-2 text-muted/80 ${sizes.meta}`}> |
||||
{#if nip05}<span class="truncate" title={nip05}>{nip05}</span>{/if} |
||||
{#if showNpub}<span class="truncate opacity-80" title={npub} |
||||
>{shortNpub}</span |
||||
>{/if} |
||||
</span> |
||||
|
||||
{#if showBadges} |
||||
<span class="mt-1 block"> |
||||
<slot name="badges"> |
||||
{#if nativeBadges} |
||||
<ANostrBadgeRow badges={nativeBadges} limit={badgeLimit} size="s" /> |
||||
{/if} |
||||
</slot> |
||||
</span> |
||||
{/if} |
||||
</span> |
||||
{/snippet} |
||||
@ -0,0 +1,131 @@
@@ -0,0 +1,131 @@
|
||||
<script lang="ts"> |
||||
/** |
||||
* @fileoverview APagination Component - Alexandria |
||||
* |
||||
* A pagination component for navigating through multiple pages of content. |
||||
* Provides previous/next navigation with page information and item counts. |
||||
* |
||||
* @component |
||||
* @category Primitives |
||||
* |
||||
* @prop {number} currentPage - Current page number (1-based, bindable) |
||||
* @prop {number} totalPages - Total number of pages available |
||||
* @prop {boolean} hasNextPage - Whether there is a next page available |
||||
* @prop {boolean} hasPreviousPage - Whether there is a previous page available |
||||
* @prop {number} [totalItems=0] - Total number of items across all pages |
||||
* @prop {string} [itemsLabel="items"] - Label for items (e.g., "posts", "events") |
||||
* @prop {string} [className=""] - Additional CSS classes to apply |
||||
* |
||||
* @example |
||||
* ```svelte |
||||
* <APagination |
||||
* bind:currentPage={page} |
||||
* totalPages={10} |
||||
* hasNextPage={page < 10} |
||||
* hasPreviousPage={page > 1} |
||||
* totalItems={100} |
||||
* itemsLabel="events" |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @example Basic pagination |
||||
* ```svelte |
||||
* <APagination |
||||
* bind:currentPage={currentPage} |
||||
* totalPages={Math.ceil(totalEvents / pageSize)} |
||||
* hasNextPage={currentPage < totalPages} |
||||
* hasPreviousPage={currentPage > 1} |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @example With custom item labels and styling |
||||
* ```svelte |
||||
* <APagination |
||||
* bind:currentPage={page} |
||||
* totalPages={pageCount} |
||||
* hasNextPage={hasNext} |
||||
* hasPreviousPage={hasPrev} |
||||
* totalItems={eventCount} |
||||
* itemsLabel="nostr events" |
||||
* className="border-2 border-primary" |
||||
* /> |
||||
* ``` |
||||
* |
||||
* @features |
||||
* - Bindable current page for reactive updates |
||||
* - Previous/Next button navigation |
||||
* - Page information display with item counts |
||||
* - Disabled state for unavailable navigation |
||||
* - Only renders when totalPages > 1 |
||||
* |
||||
* @accessibility |
||||
* - Keyboard accessible buttons |
||||
* - Disabled buttons have proper cursor and opacity |
||||
* - Clear page information for screen readers |
||||
* - Semantic button elements |
||||
*/ |
||||
|
||||
type Props = { |
||||
currentPage: number; |
||||
totalPages: number; |
||||
hasNextPage: boolean; |
||||
hasPreviousPage: boolean; |
||||
totalItems?: number; |
||||
itemsLabel?: string; |
||||
className?: string; |
||||
}; |
||||
|
||||
let { |
||||
currentPage = $bindable<number>(1), |
||||
totalPages = 1, |
||||
hasNextPage = false, |
||||
hasPreviousPage = false, |
||||
totalItems = 0, |
||||
itemsLabel = "items", |
||||
className = "", |
||||
} = $props<{ |
||||
currentPage: number; |
||||
totalPages: number; |
||||
hasNextPage: boolean; |
||||
hasPreviousPage: boolean; |
||||
totalItems?: number; |
||||
itemsLabel?: string; |
||||
className?: string; |
||||
}>(); |
||||
|
||||
function next() { |
||||
if (hasNextPage) currentPage = currentPage + 1; |
||||
} |
||||
function previous() { |
||||
if (hasPreviousPage) currentPage = currentPage - 1; |
||||
} |
||||
</script> |
||||
|
||||
{#if totalPages > 1} |
||||
<div |
||||
class={`mt-4 flex flex-row items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg ${className}`} |
||||
> |
||||
<div class="text-sm !mb-0 text-gray-600 dark:text-gray-400"> |
||||
Page {currentPage} of {totalPages} ({totalItems} total {itemsLabel}) |
||||
</div> |
||||
<div class="flex flex-row items-center gap-2"> |
||||
<button |
||||
class="px-3 py-1 !mb-0 text-sm bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed" |
||||
onclick={previous} |
||||
disabled={!hasPreviousPage} |
||||
> |
||||
Previous |
||||
</button> |
||||
<span class="!mb-0 text-sm text-gray-600 dark:text-gray-400"> |
||||
{currentPage} / {totalPages} |
||||
</span> |
||||
<button |
||||
class="px-3 py-1 !mb-0 text-sm bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed" |
||||
onclick={next} |
||||
disabled={!hasNextPage} |
||||
> |
||||
Next |
||||
</button> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
|
||||
<script lang="ts"> |
||||
/** |
||||
* @fileoverview AThemeToggleMini Component - Alexandria |
||||
* |
||||
* A compact theme selector dropdown that allows users to switch between available themes. |
||||
* Integrates with the themeStore to persist and apply theme changes across the app. |
||||
* This component has no props - it manages its own state internally. |
||||
* |
||||
* @component |
||||
* @category Primitives |
||||
* |
||||
* @example |
||||
* ```svelte |
||||
* <AThemeToggleMini /> |
||||
* ``` |
||||
* |
||||
* @example Place in navigation or settings area |
||||
* ```svelte |
||||
* <div class="flex items-center gap-4"> |
||||
* <span>Appearance:</span> |
||||
* <AThemeToggleMini /> |
||||
* </div> |
||||
* ``` |
||||
* |
||||
* @features |
||||
* - Dropdown with radio buttons for theme selection |
||||
* - Automatic persistence via themeStore |
||||
* - Shows current theme in button label |
||||
* - Available themes: Light, Ocean, Forest |
||||
* - Instant theme application on selection |
||||
* |
||||
* @accessibility |
||||
* - Keyboard accessible dropdown navigation |
||||
* - Radio buttons for clear selection state |
||||
* - Screen reader friendly with proper labels |
||||
* - Focus management within dropdown |
||||
* |
||||
* @integration |
||||
* - Works with Alexandria's theme system |
||||
* - Automatically applies CSS custom properties |
||||
* - Persists selection in localStorage |
||||
* - Updates all themed components instantly |
||||
*/ |
||||
|
||||
import { ChevronDownOutline } from "flowbite-svelte-icons"; |
||||
import { Button, Dropdown, DropdownGroup, Radio } from "flowbite-svelte"; |
||||
import { onMount } from "svelte"; |
||||
import { setTheme, theme as themeStore } from "$lib/stores/themeStore"; |
||||
|
||||
let theme = $state<string>("light"); |
||||
|
||||
onMount(() => { |
||||
return themeStore.subscribe((v) => (theme = String(v))); |
||||
}); |
||||
|
||||
// Persist and apply whenever the selection changes |
||||
$effect(() => { |
||||
setTheme(theme); |
||||
}); |
||||
</script> |
||||
|
||||
<Button> |
||||
Theme {theme}<ChevronDownOutline class="ms-2 inline h-6 w-6" /> |
||||
</Button> |
||||
<Dropdown simple class="w-44"> |
||||
<DropdownGroup class="space-y-3 p-3"> |
||||
<li> |
||||
<Radio name="group1" bind:group={theme} value="light">Light</Radio> |
||||
</li> |
||||
<li> |
||||
<Radio name="group1" bind:group={theme} value="ocean">Ocean</Radio> |
||||
</li> |
||||
<li> |
||||
<Radio name="group1" bind:group={theme} value="forrest">Forrest</Radio> |
||||
</li> |
||||
</DropdownGroup> |
||||
</Dropdown> |
||||
@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
|
||||
<script lang="ts"> |
||||
/** |
||||
* @fileoverview ATechBlock Component - Alexandria |
||||
* |
||||
* A collapsible container for technical details that can be shown/hidden based on user preference. |
||||
* Works with the ATechToggle component and techStore to manage visibility of developer information. |
||||
* |
||||
* @component |
||||
* @category Reader |
||||
* |
||||
* @prop {string} [title="Technical details"] - The title shown when block is hidden |
||||
* @prop {string} [className=""] - Additional CSS classes to apply |
||||
* @prop {snippet} content - The technical content to show/hide (required) |
||||
* |
||||
* @example |
||||
* ```svelte |
||||
* <ATechBlock title="Raw Event Data"> |
||||
* {#snippet content()} |
||||
* <pre>{JSON.stringify(event, null, 2)}</pre> |
||||
* {/snippet} |
||||
* </ATechBlock> |
||||
* ``` |
||||
* |
||||
* @example Custom title and styling |
||||
* ```svelte |
||||
* <ATechBlock title="Event JSON" className="border-red-200"> |
||||
* {#snippet content()} |
||||
* <code>{eventData}</code> |
||||
* {/snippet} |
||||
* </ATechBlock> |
||||
* ``` |
||||
* |
||||
* @features |
||||
* - Respects global showTech setting from techStore |
||||
* - Individual reveal button when globally hidden |
||||
* - Accessible with proper ARIA attributes |
||||
* - Useful for hiding nostr event data, debug info, etc. |
||||
* |
||||
* @accessibility |
||||
* - Keyboard accessible reveal button |
||||
* - Screen reader friendly with descriptive labels |
||||
* - Proper semantic HTML structure |
||||
*/ |
||||
|
||||
import { showTech } from "$lib/stores/techStore.ts"; |
||||
let revealed = $state(false); |
||||
let { title = "Technical details", className = "", content } = $props(); |
||||
|
||||
let hidden = $derived(!$showTech && !revealed); |
||||
</script> |
||||
|
||||
{#if hidden} |
||||
<div |
||||
class="rounded-md border border-dashed border-muted/40 bg-surface/60 px-3 py-2 my-6 flex items-center gap-3 {className}" |
||||
> |
||||
<span class="text-xs opacity-70">{title} hidden</span> |
||||
<button |
||||
class="ml-auto text-sm underline hover:no-underline" |
||||
onclick={() => (revealed = true)}>Reveal this block</button |
||||
> |
||||
</div> |
||||
{:else} |
||||
{@render content()} |
||||
{/if} |
||||
@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
|
||||
<script lang="ts"> |
||||
/** |
||||
* @fileoverview ATechToggle Component - Alexandria |
||||
* |
||||
* A toggle switch that controls the visibility of technical details throughout the app. |
||||
* Works in conjunction with ATechBlock components to show/hide nostr-specific developer information. |
||||
* |
||||
* @component |
||||
* @category Reader |
||||
* |
||||
* @example |
||||
* ```svelte |
||||
* <ATechToggle /> |
||||
* ``` |
||||
* |
||||
* @features |
||||
* - Persists setting in localStorage via techStore |
||||
* - Accessible with ARIA labels and keyboard navigation |
||||
* - Automatically updates all ATechBlock components when toggled |
||||
* - Useful for nostr developers who want to see raw event data and technical details |
||||
* |
||||
* @accessibility |
||||
* - Uses proper ARIA labeling |
||||
* - Keyboard accessible (Space/Enter to toggle) |
||||
* - Screen reader friendly with descriptive label |
||||
*/ |
||||
|
||||
import { showTech } from "$lib/stores/techStore.ts"; |
||||
import { Toggle, P } from "flowbite-svelte"; |
||||
let label = "Show technical details"; |
||||
</script> |
||||
|
||||
<div class="inline-flex items-center gap-2 select-none my-3"> |
||||
<Toggle bind:checked={$showTech} aria-label={label} /> |
||||
<P class="text-sm">{label}</P> |
||||
</div> |
||||
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
export type NostrEvent = { |
||||
id: string; |
||||
kind: number; |
||||
pubkey: string; |
||||
created_at: number; |
||||
tags: string[][]; |
||||
content: string; |
||||
}; |
||||
export type AddressPointer = string; |
||||
@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
|
||||
export function shortenBech32( |
||||
id: string, |
||||
keepPrefix = true, |
||||
head = 8, |
||||
tail = 6, |
||||
) { |
||||
if (!id) return ""; |
||||
const i = id.indexOf("1"); |
||||
const prefix = i > 0 ? id.slice(0, i) : ""; |
||||
const data = i > 0 ? id.slice(i + 1) : id; |
||||
const short = data.length > head + tail |
||||
? `${"${"}data.slice(0,head)}…${"${"}data.slice(-tail)}` |
||||
: data; |
||||
return keepPrefix && prefix ? `${"${"}prefix}1${"${"}short}` : short; |
||||
} |
||||
export function displayNameFrom( |
||||
npub: string, |
||||
p?: { name?: string; display_name?: string; nip05?: string }, |
||||
) { |
||||
return (p?.display_name?.trim() || p?.name?.trim() || |
||||
(p?.nip05 && p.nip05.split("@")[0]) || shortenBech32(npub, true)); |
||||
} |
||||
@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
|
||||
export async function verifyNip05( |
||||
nip05: string, |
||||
pubkeyHex: string, |
||||
): Promise<boolean> { |
||||
try { |
||||
if (!nip05 || !pubkeyHex) return false; |
||||
const [name, domain] = nip05.toLowerCase().split("@"); |
||||
if (!name || !domain) return false; |
||||
const url = |
||||
`https://${"${"}domain}/.well-known/nostr.json?name=${"${"}encodeURIComponent(name)}`; |
||||
const res = await fetch(url, { headers: { Accept: "application/json" } }); |
||||
if (!res.ok) return false; |
||||
const json = await res.json(); |
||||
const found = json?.names?.[name]; |
||||
return typeof found === "string" && |
||||
found.toLowerCase() === pubkeyHex.toLowerCase(); |
||||
} catch { |
||||
return false; |
||||
} |
||||
} |
||||
@ -0,0 +1,147 @@
@@ -0,0 +1,147 @@
|
||||
import type { AddressPointer, NostrEvent } from "./event"; |
||||
export type BadgeDefinition = { |
||||
kind: 30009; |
||||
id: string; |
||||
pubkey: string; |
||||
d: string; |
||||
a: AddressPointer; |
||||
name?: string; |
||||
description?: string; |
||||
image?: { url: string; size?: string } | null; |
||||
thumbs: { url: string; size?: string }[]; |
||||
}; |
||||
export type BadgeAward = { |
||||
kind: 8; |
||||
id: string; |
||||
pubkey: string; |
||||
a: AddressPointer; |
||||
recipients: { pubkey: string; relay?: string }[]; |
||||
}; |
||||
export type ProfileBadges = { |
||||
kind: 30008; |
||||
id: string; |
||||
pubkey: string; |
||||
pairs: { a: AddressPointer; awardId: string; relay?: string }[]; |
||||
}; |
||||
export const isKind = (e: NostrEvent, k: number) => e.kind === k; |
||||
const val = (tags: string[][], name: string) => |
||||
tags.find((t) => t[0] === name)?.[1]; |
||||
const vals = (tags: string[][], name: string) => |
||||
tags.filter((t) => t[0] === name).map((t) => t.slice(1)); |
||||
export function parseBadgeDefinition(e: NostrEvent): BadgeDefinition | null { |
||||
if (e.kind !== 30009) return null; |
||||
const d = val(e.tags, "d"); |
||||
if (!d) return null; |
||||
const a: AddressPointer = `30009:${"${"}e.pubkey}:${"${"}d}`; |
||||
const name = val(e.tags, "name") || undefined; |
||||
const description = val(e.tags, "description") || undefined; |
||||
const imageTag = vals(e.tags, "image")[0]; |
||||
const image = imageTag ? { url: imageTag[0], size: imageTag[1] } : null; |
||||
const thumbs = vals(e.tags, "thumb").map(([url, size]) => ({ url, size })); |
||||
return { |
||||
kind: 30009, |
||||
id: e.id, |
||||
pubkey: e.pubkey, |
||||
d, |
||||
a, |
||||
name, |
||||
description, |
||||
image, |
||||
thumbs, |
||||
}; |
||||
} |
||||
export function parseBadgeAward(e: NostrEvent): BadgeAward | null { |
||||
if (e.kind !== 8) return null; |
||||
const atag = vals(e.tags, "a")[0]; |
||||
if (!atag) return null; |
||||
const a: AddressPointer = atag[0]; |
||||
const recipients = vals(e.tags, "p").map(([pubkey, relay]) => ({ |
||||
pubkey, |
||||
relay, |
||||
})); |
||||
return { kind: 8, id: e.id, pubkey: e.pubkey, a, recipients }; |
||||
} |
||||
export function parseProfileBadges(e: NostrEvent): ProfileBadges | null { |
||||
if (e.kind !== 30008) return null; |
||||
const d = val(e.tags, "d"); |
||||
if (d !== "profile_badges") return null; |
||||
const pairs: { a: AddressPointer; awardId: string; relay?: string }[] = []; |
||||
for (let i = 0; i < e.tags.length; i++) { |
||||
const t = e.tags[i]; |
||||
if (t[0] === "a") { |
||||
const a = t[1]; |
||||
const nxt = e.tags[i + 1]; |
||||
if (nxt && nxt[0] === "e") { |
||||
pairs.push({ a, awardId: nxt[1], relay: nxt[2] }); |
||||
i++; |
||||
} |
||||
} |
||||
} |
||||
return { kind: 30008, id: e.id, pubkey: e.pubkey, pairs }; |
||||
} |
||||
export type DisplayBadge = { |
||||
def: BadgeDefinition; |
||||
award: BadgeAward | null; |
||||
issuer: string; |
||||
thumbUrl: string | null; |
||||
title: string; |
||||
}; |
||||
export function pickThumb( |
||||
def: BadgeDefinition, |
||||
prefer: ("16" | "32" | "64" | "256" | "512")[] = ["32", "64", "256"], |
||||
): string | null { |
||||
for (const p of prefer) { |
||||
const t = def.thumbs.find((t) => (t.size || "").startsWith(p + "x")); |
||||
if (t) return t.url; |
||||
} |
||||
return def.image?.url || null; |
||||
} |
||||
export function buildDisplayBadgesForUser( |
||||
userPubkey: string, |
||||
defs: BadgeDefinition[], |
||||
awards: BadgeAward[], |
||||
profileBadges?: ProfileBadges | null, |
||||
opts: { issuerWhitelist?: Set<string>; max?: number } = {}, |
||||
): DisplayBadge[] { |
||||
const byA = new Map<string, BadgeDefinition>(defs.map((d) => [d.a, d])); |
||||
const byAwardId = new Map<string, BadgeAward>(awards.map((a) => [a.id, a])); |
||||
const isWhitelisted = (issuer: string) => |
||||
!opts.issuerWhitelist || opts.issuerWhitelist.has(issuer); |
||||
let out: DisplayBadge[] = []; |
||||
if (profileBadges && profileBadges.pubkey === userPubkey) { |
||||
for (const { a, awardId } of profileBadges.pairs) { |
||||
const def = byA.get(a); |
||||
if (!def) { |
||||
continue; |
||||
} |
||||
const award = byAwardId.get(awardId) || null; |
||||
if ( |
||||
award && |
||||
(award.a !== a || |
||||
!award.recipients.find((r) => r.pubkey === userPubkey)) |
||||
) continue; |
||||
if (!isWhitelisted(def.pubkey)) continue; |
||||
out.push({ |
||||
def, |
||||
award, |
||||
issuer: def.pubkey, |
||||
thumbUrl: pickThumb(def), |
||||
title: def.name || def.d, |
||||
}); |
||||
} |
||||
} else {for (const aw of awards) { |
||||
if (!aw.recipients.find((r) => r.pubkey === userPubkey)) continue; |
||||
const def = byA.get(aw.a); |
||||
if (!def) continue; |
||||
if (!isWhitelisted(def.pubkey)) continue; |
||||
out.push({ |
||||
def, |
||||
award: aw, |
||||
issuer: def.pubkey, |
||||
thumbUrl: pickThumb(def), |
||||
title: def.name || def.d, |
||||
}); |
||||
}} |
||||
if (opts.max && out.length > opts.max) out = out.slice(0, opts.max); |
||||
return out; |
||||
} |
||||
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
export type NostrProfile = { |
||||
name?: string; |
||||
display_name?: string; |
||||
picture?: string; |
||||
about?: string; |
||||
nip05?: string; |
||||
lud16?: string; |
||||
badges?: Array<{ label: string; color?: string }>; |
||||
}; |
||||
@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
|
||||
/** |
||||
* Tech Store - Alexandria |
||||
* |
||||
* This store manages the "show technical details" user setting for the Alexandria app. |
||||
* |
||||
* Use case: |
||||
* - Used with the ATechBlock component to hide nostr-specific developer details (e.g., raw event blocks) behind a collapsed section. |
||||
* - Users can toggle visibility of these technical details via the ATechToggle component. |
||||
* - If a user is a nostr developer, they can set their profile to always show technical details by default. |
||||
* - The setting is persisted in localStorage and reflected in the DOM via a data attribute for styling purposes. |
||||
* |
||||
* Example usage: |
||||
* <ATechBlock content={...} /> |
||||
* <ATechToggle /> |
||||
* |
||||
* This enables a cleaner UI for non-developers, while providing easy access to advanced information for developers. |
||||
*/ |
||||
|
||||
import { writable } from "svelte/store"; |
||||
const KEY = "alexandria/showTech"; |
||||
|
||||
// Default false unless explicitly set to 'true' in localStorage
|
||||
const initial = typeof localStorage !== "undefined" |
||||
? localStorage.getItem(KEY) === "true" |
||||
: false; |
||||
|
||||
export const showTech = writable<boolean>(initial); |
||||
|
||||
showTech.subscribe((v) => { |
||||
if (typeof document !== "undefined") { |
||||
document.documentElement.dataset.tech = v ? "on" : "off"; |
||||
localStorage.setItem(KEY, String(v)); |
||||
} |
||||
}); |
||||
@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
|
||||
import { writable } from "svelte/store"; |
||||
|
||||
const KEY = "alexandria/theme"; |
||||
|
||||
const initial = |
||||
(typeof localStorage !== "undefined" && localStorage.getItem(KEY)) || |
||||
"light"; |
||||
|
||||
export const theme = writable(initial); |
||||
|
||||
theme.subscribe((v) => { |
||||
if (typeof document !== "undefined") { |
||||
document.documentElement.dataset.theme = String(v); |
||||
localStorage.setItem(KEY, String(v)); |
||||
} |
||||
}); |
||||
|
||||
export const setTheme = (t: string) => theme.set(t); |
||||
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"; |
||||
import { twMerge } from "tailwind-merge"; |
||||
export { cva, twMerge, type VariantProps }; |
||||
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
<script lang="ts"> |
||||
import RelayStatus from "$lib/components/RelayStatus.svelte"; |
||||
</script> |
||||
|
||||
<div class="w-full flex flex-col justify-center"> |
||||
<RelayStatus /> |
||||
</div> |
||||
@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
|
||||
<script lang="ts"> |
||||
import { Heading, P, List, Li } from "flowbite-svelte"; |
||||
import EventInput from "$components/EventInput.svelte"; |
||||
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk.ts"; |
||||
import { userStore } from "$lib/stores/userStore.ts"; |
||||
import { AAlert } from "$lib/a"; |
||||
|
||||
// AI-NOTE: 2025-01-24 - Reactive effect to log relay configuration when stores change - non-blocking approach |
||||
$effect.pre(() => { |
||||
const inboxRelays = $activeInboxRelays; |
||||
const outboxRelays = $activeOutboxRelays; |
||||
|
||||
// Only log if we have relays (not empty arrays) |
||||
if (inboxRelays.length > 0 || outboxRelays.length > 0) { |
||||
// Defer logging to avoid blocking the reactive system |
||||
requestAnimationFrame(() => { |
||||
console.log('🔌 Compose Page - Relay Configuration Updated:'); |
||||
console.log('📥 Inbox Relays:', inboxRelays); |
||||
console.log('📤 Outbox Relays:', outboxRelays); |
||||
console.log(`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`); |
||||
}); |
||||
} |
||||
}); |
||||
</script> |
||||
|
||||
<div class="w-full flex justify-center"> |
||||
<div class="flex flex-col w-full max-w-4xl my-6 px-4 mx-auto"> |
||||
<div class="main-leather flex flex-col space-y-6"> |
||||
<Heading tag="h1" class="h-leather mb-2">Compose Event</Heading> |
||||
|
||||
<P class="my-3"> |
||||
Use this page to compose and publish various types of events to the Nostr network. |
||||
You can create notes, articles, and other event types depending on your needs. |
||||
</P> |
||||
|
||||
<P class="mb-4"> |
||||
Create and publish new Nostr events to the network. This form |
||||
supports various event kinds including: |
||||
</P> |
||||
|
||||
<List |
||||
class="mb-6 list-disc list-inside space-y-1" |
||||
> |
||||
<Li> |
||||
<strong>Kind 30040:</strong> Publication indexes that organize AsciiDoc |
||||
content into structured publications |
||||
</Li> |
||||
<Li> |
||||
<strong>Kind 30041:</strong> Individual section content for publications |
||||
</Li> |
||||
<Li> |
||||
<strong>Other kinds:</strong> Standard Nostr events with custom tags |
||||
and content |
||||
</Li> |
||||
</List> |
||||
|
||||
{#if $userStore.signedIn} |
||||
<EventInput /> |
||||
{:else} |
||||
<AAlert color="blue"> |
||||
{#snippet title()}Sign In Required{/snippet} |
||||
Please sign in to compose and publish events to the Nostr network. |
||||
</AAlert> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
|
||||
<script lang="ts"> |
||||
import { AAlert, AProfilePreview, AThemeToggleMini } from "$lib/a"; |
||||
import CommentBox from "$lib/components/CommentBox.svelte"; |
||||
import CommentViewer from "$lib/components/CommentViewer.svelte"; |
||||
import { userStore } from "$lib/stores/userStore"; |
||||
import { getUserMetadata } from "$lib/utils/nostrUtils"; |
||||
import type { NDKEvent } from "$lib/utils/nostrUtils"; |
||||
import { getNdkContext } from "$lib/ndk.ts"; |
||||
import { Heading, P } from "flowbite-svelte"; |
||||
import ATechToggle from "$lib/a/reader/ATechToggle.svelte"; |
||||
|
||||
// State |
||||
let user = $state($userStore); |
||||
let loading = $state(false); |
||||
let error = $state<string | null>(null); |
||||
let profileEvent = $state<NDKEvent | null>(null); |
||||
let profile = $state<{ |
||||
name?: string; |
||||
display_name?: string; |
||||
about?: string; |
||||
picture?: string; |
||||
banner?: string; |
||||
website?: string; |
||||
lud16?: string; |
||||
nip05?: string; |
||||
} | null>(null); |
||||
let lastFetchedPubkey: string | null = null; |
||||
let userRelayPreference = $state(false); // required by CommentBox |
||||
|
||||
userStore.subscribe(v => user = v); |
||||
|
||||
async function fetchProfileEvent(pubkey: string) { |
||||
if (!pubkey || pubkey === lastFetchedPubkey) return; |
||||
loading = true; |
||||
error = null; |
||||
try { |
||||
const ndk = getNdkContext(); |
||||
if (!ndk) { |
||||
throw new Error('NDK not initialized'); |
||||
} |
||||
// Fetch kind 0 event for this author |
||||
const evt = await ndk.fetchEvent({ kinds: [0], authors: [pubkey] }); |
||||
profileEvent = evt || null; |
||||
if (evt?.content) { |
||||
try { profile = JSON.parse(evt.content); } catch { profile = null; } |
||||
} else { |
||||
profile = null; |
||||
} |
||||
// Fallback: ensure we have metadata via helper (will cache) |
||||
if (!profile && user.npub) { |
||||
const meta = await getUserMetadata(user.npub, ndk, true); |
||||
profile = { |
||||
name: meta.name, |
||||
display_name: meta.displayName, |
||||
about: meta.about, |
||||
picture: meta.picture, |
||||
banner: meta.banner, |
||||
website: meta.website, |
||||
lud16: meta.lud16, |
||||
nip05: meta.nip05, |
||||
}; |
||||
} |
||||
lastFetchedPubkey = pubkey; |
||||
} catch (e: any) { |
||||
console.error('[profile/+page] Failed to fetch profile event', e); |
||||
error = e?.message || 'Failed to load profile'; |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
|
||||
// Reactive: when user login changes fetch profile event |
||||
$effect(() => { |
||||
if (user?.pubkey) fetchProfileEvent(user.pubkey); |
||||
}); |
||||
</script> |
||||
|
||||
{#if !user || !user.signedIn} |
||||
<div class="w-full max-w-3xl mx-auto mt-10 px-4"> |
||||
<AAlert color="blue">Please log in to view your profile.</AAlert> |
||||
</div> |
||||
{:else} |
||||
<div class="w-full flex justify-center"> |
||||
<div class="flex flex-col w-full max-w-5xl my-6 px-4 mx-auto gap-6"> |
||||
{#if profileEvent} |
||||
<AProfilePreview event={profileEvent} user={user} profile={profile} loading={loading} error={error} isOwn={!!user?.signedIn && (!profileEvent?.pubkey || profileEvent.pubkey === user.pubkey)} /> |
||||
{/if} |
||||
<div class="mt-6"> |
||||
<Heading tag="h3" class="h-leather mb-4"> |
||||
Settings |
||||
</Heading> |
||||
<!-- Theme and tech settings --> |
||||
<ul> |
||||
<li> |
||||
<ATechToggle /> |
||||
</li> |
||||
<li> |
||||
<AThemeToggleMini /> |
||||
</li> |
||||
</ul> |
||||
</div> |
||||
|
||||
{#if profileEvent} |
||||
<div class="main-leather flex flex-col space-y-6"> |
||||
<CommentViewer event={profileEvent} /> |
||||
<CommentBox event={profileEvent} {userRelayPreference} /> |
||||
</div> |
||||
{:else if !loading} |
||||
<AAlert color="gray">No profile event (kind 0) found for this user.</AAlert> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
|
||||
<script lang="ts"> |
||||
import Notifications from "$lib/components/Notifications.svelte"; |
||||
import { AAlert } from "$lib/a"; |
||||
import { userStore } from "$lib/stores/userStore"; |
||||
</script> |
||||
|
||||
<div class="flex flex-col w-full max-w-3xl mx-auto px-2 items-center gap-4"> |
||||
{#if $userStore?.signedIn} |
||||
<Notifications /> |
||||
{:else} |
||||
<AAlert color="blue">Please log in to view your notifications.</AAlert> |
||||
{/if} |
||||
</div> |
||||
@ -0,0 +1,252 @@
@@ -0,0 +1,252 @@
|
||||
@layer components { |
||||
/* ======================================== |
||||
Base Card Styles |
||||
======================================== */ |
||||
|
||||
/* Main card leather theme */ |
||||
.card-leather { |
||||
@apply shadow-none text-primary-1000 border-s-4 bg-highlight |
||||
border-primary-200 has-[:hover]:border-primary-700; |
||||
@apply dark:bg-primary-1000 dark:border-primary-800 |
||||
dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500; |
||||
} |
||||
|
||||
.card-leather h1, |
||||
.card-leather h2, |
||||
.card-leather h3, |
||||
.card-leather h4, |
||||
.card-leather h5, |
||||
.card-leather h6 { |
||||
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100 |
||||
dark:hover:text-primary-400; |
||||
} |
||||
|
||||
.card-leather .font-thin { |
||||
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100 |
||||
dark:hover:text-primary-400; |
||||
} |
||||
|
||||
/* Main card leather (used in profile previews) */ |
||||
.main-leather { |
||||
@apply bg-primary-50 dark:bg-primary-1000 text-gray-900 dark:text-gray-100; |
||||
} |
||||
|
||||
/* ======================================== |
||||
Responsive Card Styles |
||||
======================================== */ |
||||
|
||||
.responsive-card { |
||||
@apply w-full min-w-0 overflow-hidden; |
||||
} |
||||
|
||||
.responsive-card-content { |
||||
@apply break-words overflow-hidden; |
||||
} |
||||
|
||||
/* ======================================== |
||||
Article Box Styles (Blog & Publication Cards) |
||||
======================================== */ |
||||
|
||||
.ArticleBox { |
||||
@apply shadow-none text-primary-1000 border-s-4 bg-highlight |
||||
border-primary-200 has-[:hover]:border-primary-700; |
||||
@apply dark:bg-primary-1000 dark:border-primary-800 |
||||
dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500; |
||||
} |
||||
|
||||
.ArticleBox h1, |
||||
.ArticleBox h2, |
||||
.ArticleBox h3, |
||||
.ArticleBox h4, |
||||
.ArticleBox h5, |
||||
.ArticleBox h6 { |
||||
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100 |
||||
dark:hover:text-primary-400; |
||||
} |
||||
|
||||
.ArticleBox .font-thin { |
||||
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100 |
||||
dark:hover:text-primary-400; |
||||
} |
||||
|
||||
/* Article box image transitions */ |
||||
.ArticleBox.grid .ArticleBoxImage { |
||||
@apply max-h-0; |
||||
transition: max-height 0.5s ease; |
||||
} |
||||
|
||||
.ArticleBox.grid.active .ArticleBoxImage { |
||||
@apply max-h-40; |
||||
} |
||||
|
||||
/* ======================================== |
||||
Event Preview Card Styles |
||||
======================================== */ |
||||
|
||||
/* Event preview card hover state */ |
||||
.event-preview-card { |
||||
@apply hover:bg-highlight dark:bg-primary-900/70 bg-primary-50 |
||||
dark:hover:bg-primary-800 border-primary-400 border-s-4 transition-colors |
||||
cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-500 |
||||
shadow-none; |
||||
} |
||||
|
||||
/* Event metadata badges */ |
||||
.event-kind-badge { |
||||
@apply text-[10px] px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700 |
||||
text-gray-700 dark:text-gray-300; |
||||
} |
||||
|
||||
.event-label { |
||||
@apply text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400; |
||||
} |
||||
|
||||
/* Community badge */ |
||||
.community-badge { |
||||
@apply inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded |
||||
bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300; |
||||
} |
||||
|
||||
/* ======================================== |
||||
Profile Card Styles |
||||
======================================== */ |
||||
|
||||
/* Profile verification badge (NIP-05) */ |
||||
.profile-nip05-badge { |
||||
@apply px-2 py-0.5 !mb-0 rounded bg-green-100 dark:bg-green-900 |
||||
text-green-700 dark:text-green-300 text-xs; |
||||
} |
||||
|
||||
/* Community status indicator */ |
||||
.community-status-indicator { |
||||
@apply flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full |
||||
flex items-center justify-center; |
||||
} |
||||
|
||||
.community-status-icon { |
||||
@apply w-3 h-3 text-yellow-600 dark:text-yellow-400; |
||||
} |
||||
|
||||
/* User list status indicator (heart) */ |
||||
.user-list-indicator { |
||||
@apply flex-shrink-0 w-4 h-4 bg-red-100 dark:bg-red-900 rounded-full flex |
||||
items-center justify-center; |
||||
} |
||||
|
||||
.user-list-icon { |
||||
@apply w-3 h-3 text-red-600 dark:text-red-400; |
||||
} |
||||
|
||||
/* ======================================== |
||||
Card Content Styles |
||||
======================================== */ |
||||
|
||||
/* Card content sections */ |
||||
.card-header { |
||||
@apply flex items-start w-full p-4; |
||||
} |
||||
|
||||
.card-body { |
||||
@apply px-4 pb-3 flex flex-col gap-2; |
||||
} |
||||
|
||||
.card-footer { |
||||
@apply px-4 pt-2 pb-3 border-t border-primary-200 dark:border-primary-700 |
||||
flex items-center gap-2 flex-wrap; |
||||
} |
||||
|
||||
/* Card content text styles */ |
||||
.card-summary { |
||||
@apply text-sm text-primary-900 dark:text-primary-200 line-clamp-2; |
||||
} |
||||
|
||||
.card-content { |
||||
@apply text-sm text-gray-800 dark:text-gray-200 line-clamp-3 break-words |
||||
mb-4; |
||||
} |
||||
|
||||
.card-about { |
||||
@apply text-sm text-gray-700 dark:text-gray-300 line-clamp-3; |
||||
} |
||||
|
||||
/* Deferral link styling */ |
||||
.deferral-link { |
||||
@apply underline text-primary-700 dark:text-primary-400 |
||||
hover:text-primary-600 dark:hover:text-primary-400 break-all |
||||
cursor-pointer; |
||||
} |
||||
|
||||
/* ======================================== |
||||
Tags and Badges |
||||
======================================== */ |
||||
|
||||
.tags span { |
||||
@apply bg-primary-50 text-primary-800 text-sm font-medium me-2 px-2.5 py-0.5 |
||||
rounded-sm dark:bg-primary-900 dark:text-primary-200; |
||||
} |
||||
|
||||
/* ======================================== |
||||
Card Image Styles |
||||
======================================== */ |
||||
|
||||
.card-image-container { |
||||
@apply w-full bg-primary-200 dark:bg-primary-800 relative; |
||||
} |
||||
|
||||
.card-banner { |
||||
@apply w-full h-60 object-cover; |
||||
} |
||||
|
||||
.card-avatar-container { |
||||
@apply absolute w-fit top-[-56px]; |
||||
} |
||||
|
||||
/* ======================================== |
||||
Utility Classes for Cards |
||||
======================================== */ |
||||
|
||||
/* Prose styling within cards - extends prose class when applied */ |
||||
.card-prose { |
||||
@apply max-w-none text-gray-900 dark:text-gray-100 break-words min-w-0; |
||||
overflow-wrap: anywhere; |
||||
} |
||||
|
||||
/* Card metadata grid */ |
||||
.card-metadata-grid { |
||||
@apply grid grid-cols-1 gap-y-2; |
||||
} |
||||
|
||||
.card-metadata-label { |
||||
@apply font-semibold min-w-[120px] flex-shrink-0; |
||||
} |
||||
|
||||
.card-metadata-value { |
||||
@apply min-w-0 break-words; |
||||
} |
||||
|
||||
/* ======================================== |
||||
Interactive Card States |
||||
======================================== */ |
||||
|
||||
/* Clickable card states */ |
||||
.card-clickable { |
||||
@apply cursor-pointer transition-colors focus:outline-none focus:ring-2 |
||||
focus:ring-primary-500; |
||||
} |
||||
|
||||
.card-clickable:hover { |
||||
@apply bg-primary-100 dark:bg-primary-800; |
||||
} |
||||
|
||||
/* ======================================== |
||||
Skeleton Loader for Cards |
||||
======================================== */ |
||||
|
||||
.skeleton-leather div { |
||||
@apply bg-primary-100 dark:bg-primary-800; |
||||
} |
||||
|
||||
.skeleton-leather { |
||||
@apply h-48; |
||||
} |
||||
} |
||||
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
@layer components { |
||||
/* ======================================== |
||||
Base Form Styles |
||||
======================================== */ |
||||
} |
||||
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
@layer components { |
||||
.alert-leather { |
||||
@apply border border-s-4; |
||||
} |
||||
} |
||||
@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
|
||||
/* Default theme (your current palette) */ |
||||
:root { |
||||
--brand-primary-0: #efe6dc; |
||||
--brand-primary-50: #decdb9; |
||||
--brand-primary-100: #d6c1a8; |
||||
--brand-primary-200: #c6a885; |
||||
--brand-primary-300: #b58f62; |
||||
--brand-primary-400: #ad8351; |
||||
--brand-primary-500: #c6a885; |
||||
--brand-primary-600: #795c39; |
||||
--brand-primary-700: #564a3e; |
||||
--brand-primary-800: #3c352c; |
||||
--brand-primary-900: #2a241c; |
||||
--brand-primary-950: #1d1812; |
||||
--brand-primary-1000: #15110d; |
||||
} |
||||
|
||||
/* Example alternative theme: ocean */ |
||||
:root[data-theme="ocean"] { |
||||
--brand-primary-0: #ecf8ff; |
||||
--brand-primary-50: #e6f3ff; |
||||
--brand-primary-100: #d9ecff; |
||||
--brand-primary-200: #b9ddff; |
||||
--brand-primary-300: #90cbff; |
||||
--brand-primary-400: #61b6fb; |
||||
--brand-primary-500: #0ea5e9; /* sky-500-ish */ |
||||
--brand-primary-600: #0284c7; |
||||
--brand-primary-700: #0369a1; |
||||
--brand-primary-800: #075985; |
||||
--brand-primary-900: #0c4a6e; |
||||
--brand-primary-950: #082f49; |
||||
--brand-primary-1000: #062233; |
||||
} |
||||
|
||||
/* (Optional) per-theme dark tweaks — applied when <html class="dark"> is set */ |
||||
:root.dark[data-theme="ocean"] { |
||||
/* nudge the mid tones brighter for contrast */ |
||||
--brand-primary-400: #7ccdfc; |
||||
--brand-primary-500: #38bdf8; |
||||
} |
||||
|
||||
/* Example alternative theme: forrest */ |
||||
:root[data-theme="forrest"] { |
||||
--brand-primary-0: #eaf7ea; |
||||
--brand-primary-50: #d6eed6; |
||||
--brand-primary-100: #bfe3bf; |
||||
--brand-primary-200: #9fd49f; |
||||
--brand-primary-300: #7fc57f; |
||||
--brand-primary-400: #5fa65f; |
||||
--brand-primary-500: #3f863f; /* forest green */ |
||||
--brand-primary-600: #2e6b2e; |
||||
--brand-primary-700: #205120; |
||||
--brand-primary-800: #153a15; |
||||
--brand-primary-900: #0c230c; |
||||
--brand-primary-950: #071507; |
||||
--brand-primary-1000: #041004; |
||||
} |
||||
|
||||
/* (Optional) per-theme dark tweaks — applied when <html class="dark"> is set */ |
||||
:root.dark[data-theme="forrest"] { |
||||
/* nudge the mid tones brighter for contrast */ |
||||
--brand-primary-400: #7fc97f; |
||||
--brand-primary-500: #4caf50; |
||||
} |
||||
@ -1,123 +0,0 @@
@@ -1,123 +0,0 @@
|
||||
import flowbite from "flowbite/plugin"; |
||||
import plugin from "tailwindcss/plugin"; |
||||
import typography from "@tailwindcss/typography"; |
||||
|
||||
/** @type {import('tailwindcss').Config}*/ |
||||
const config = { |
||||
content: [ |
||||
"./src/**/*.{html,js,svelte,ts}", |
||||
"./node_modules/flowbite/**/*.js", |
||||
"./node_modules/flowbite-svelte/**/*.{html,js,svelte,ts}", |
||||
], |
||||
|
||||
theme: { |
||||
extend: { |
||||
colors: { |
||||
highlight: "#f9f6f1", |
||||
primary: { |
||||
0: "#efe6dc", |
||||
50: "#decdb9", |
||||
100: "#d6c1a8", |
||||
200: "#c6a885", |
||||
300: "#b58f62", |
||||
400: "#ad8351", |
||||
500: "#c6a885", |
||||
600: "#795c39", |
||||
700: "#564a3e", |
||||
800: "#3c352c", |
||||
900: "#2a241c", |
||||
950: "#1d1812", |
||||
1000: "#15110d", |
||||
}, |
||||
success: { |
||||
50: "#e3f2e7", |
||||
100: "#c7e6cf", |
||||
200: "#a2d4ae", |
||||
300: "#7dbf8e", |
||||
400: "#5ea571", |
||||
500: "#4e8e5f", |
||||
600: "#3e744c", |
||||
700: "#305b3b", |
||||
800: "#22412a", |
||||
900: "#15281b", |
||||
}, |
||||
info: { |
||||
50: "#e7eff6", |
||||
100: "#c5d9ea", |
||||
200: "#9fbfdb", |
||||
300: "#7aa5cc", |
||||
400: "#5e90be", |
||||
500: "#4779a5", |
||||
600: "#365d80", |
||||
700: "#27445d", |
||||
800: "#192b3a", |
||||
900: "#0d161f", |
||||
}, |
||||
warning: { |
||||
50: "#fef4e6", |
||||
100: "#fde4bf", |
||||
200: "#fcd18e", |
||||
300: "#fbbc5c", |
||||
400: "#f9aa33", |
||||
500: "#f7971b", |
||||
600: "#c97a14", |
||||
700: "#9a5c0e", |
||||
800: "#6c3e08", |
||||
900: "#3e2404", |
||||
}, |
||||
danger: { |
||||
50: "#fbeaea", |
||||
100: "#f5cccc", |
||||
200: "#eba5a5", |
||||
300: "#e17e7e", |
||||
400: "#d96060", |
||||
500: "#c94848", |
||||
600: "#a53939", |
||||
700: "#7c2b2b", |
||||
800: "#521c1c", |
||||
900: "#2b0e0e", |
||||
}, |
||||
}, |
||||
listStyleType: { |
||||
"upper-alpha": "upper-alpha", // Uppercase letters |
||||
"lower-alpha": "lower-alpha", // Lowercase letters |
||||
}, |
||||
flexGrow: { |
||||
1: "1", |
||||
2: "2", |
||||
3: "3", |
||||
}, |
||||
hueRotate: { |
||||
20: "20deg", |
||||
}, |
||||
}, |
||||
}, |
||||
|
||||
plugins: [ |
||||
flowbite(), |
||||
typography, |
||||
plugin(function ({ addUtilities, matchUtilities }) { |
||||
addUtilities({ |
||||
".content-visibility-auto": { |
||||
"content-visibility": "auto", |
||||
}, |
||||
".contain-size": { |
||||
contain: "size", |
||||
}, |
||||
}); |
||||
|
||||
matchUtilities({ |
||||
"contain-intrinsic-w-*": (value) => ({ |
||||
width: value, |
||||
}), |
||||
"contain-intrinsic-h-*": (value) => ({ |
||||
height: value, |
||||
}), |
||||
}); |
||||
}), |
||||
], |
||||
|
||||
darkMode: "class", |
||||
}; |
||||
|
||||
module.exports = config; |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue