Browse Source
Merges 38 commits from master including: - New Alexandria component library (src/lib/a/) - UI updates and styling improvements - Route reorganization (my-notes → profile/my-notes) - Tailwind 4 migration - New nostr utilities and stores Conflict Resolutions: - package.json: Merged dependencies (kept CodeMirror + added Lucide/CVA) - package-lock.json: Regenerated after dependency merge - asciidoc_metadata.ts: Kept feature branch's deeper header processing logic - ZettelEditor.svelte: Kept feature branch's CodeMirror implementation - compose/+page.svelte: Kept feature branch (removed orphaned publish button) - my-notes/+page.svelte: Accepted master's location and .ts extensions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>master
100 changed files with 8833 additions and 4932 deletions
@ -1,6 +1,5 @@ |
|||||||
import tailwindcss from "tailwindcss"; |
|
||||||
import autoprefixer from "autoprefixer"; |
|
||||||
|
|
||||||
export default { |
export default { |
||||||
plugins: [tailwindcss(), autoprefixer()], |
plugins: { |
||||||
|
"@tailwindcss/postcss": {}, |
||||||
|
}, |
||||||
}; |
}; |
||||||
|
|||||||
@ -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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
// 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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
#!/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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
export type NostrEvent = { |
||||||
|
id: string; |
||||||
|
kind: number; |
||||||
|
pubkey: string; |
||||||
|
created_at: number; |
||||||
|
tags: string[][]; |
||||||
|
content: string; |
||||||
|
}; |
||||||
|
export type AddressPointer = string; |
||||||
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
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 @@ |
|||||||
|
import { cva, type VariantProps } from "class-variance-authority"; |
||||||
|
import { twMerge } from "tailwind-merge"; |
||||||
|
export { cva, twMerge, type VariantProps }; |
||||||
@ -1,8 +1,10 @@ |
|||||||
import type { LayoutLoad } from "./$types"; |
import type { LayoutLoad } from "./$types"; |
||||||
import { initNdk } from "$lib/ndk"; |
import { initNdk } from "$lib/ndk"; |
||||||
|
|
||||||
|
export const ssr = false; |
||||||
|
|
||||||
export const load: LayoutLoad = () => { |
export const load: LayoutLoad = () => { |
||||||
return { |
return { |
||||||
ndk: initNdk(), |
ndk: initNdk(), |
||||||
}; |
}; |
||||||
} |
}; |
||||||
|
|||||||
@ -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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
@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 @@ |
|||||||
|
@layer components { |
||||||
|
/* ======================================== |
||||||
|
Base Form Styles |
||||||
|
======================================== */ |
||||||
|
} |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
@layer components { |
||||||
|
.alert-leather { |
||||||
|
@apply border border-s-4; |
||||||
|
} |
||||||
|
} |
||||||
@ -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 @@ |
|||||||
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; |
|
||||||
Loading…
Reference in new issue