10 changed files with 338 additions and 11 deletions
@ -0,0 +1,11 @@ |
|||||||
|
# Component Library |
||||||
|
|
||||||
|
This folder contains a component library. |
||||||
|
The idea is to have project-scoped reusable components that centralize theming and style rules, |
||||||
|
so that main pages and layouts focus on the functionalities. |
||||||
|
|
||||||
|
All components are based on Flowbite Svelte components, |
||||||
|
which are built on top of Tailwind CSS. |
||||||
|
|
||||||
|
Keeping all the styles in one place allows us to easily change the look and feel of the application by switching themes. |
||||||
|
|
||||||
@ -0,0 +1,191 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { Card } 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"; |
||||||
|
|
||||||
|
// AI-NOTE: 2025-08-16 - AEventPreview centralizes display logic for search result cards |
||||||
|
// Used for primary search results (profiles or events). Extend cautiously for other contexts. |
||||||
|
let { |
||||||
|
event, |
||||||
|
index, |
||||||
|
label = "", |
||||||
|
community = false, |
||||||
|
truncateContentAt = 200, |
||||||
|
showKind = true, |
||||||
|
showSummary = true, |
||||||
|
showDeferralNaddr = true, |
||||||
|
showPublicationLink = true, |
||||||
|
onSelect |
||||||
|
}: { |
||||||
|
event: NDKEvent; |
||||||
|
index?: number; |
||||||
|
label?: string; |
||||||
|
community?: boolean|string; |
||||||
|
truncateContentAt?: number; |
||||||
|
showKind?: boolean; |
||||||
|
showSummary?: boolean; |
||||||
|
showDeferralNaddr?: boolean; |
||||||
|
showPublicationLink?: boolean; |
||||||
|
onSelect?: (ev: NDKEvent) => void; |
||||||
|
} = $props(); |
||||||
|
|
||||||
|
// Parse kind 0 profile JSON |
||||||
|
function parseProfileContent(ev: NDKEvent): { |
||||||
|
name?: string; |
||||||
|
display_name?: string; |
||||||
|
about?: string; |
||||||
|
picture?: string; |
||||||
|
} | null { |
||||||
|
if (ev.kind !== 0 || !ev.content) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
try { |
||||||
|
return JSON.parse(ev.content); |
||||||
|
} 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 handleSelect(): void { |
||||||
|
onSelect?.(event); |
||||||
|
} |
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent): void { |
||||||
|
if (e.key === "Enter" || e.key === " ") { |
||||||
|
e.preventDefault(); |
||||||
|
handleSelect(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function clippedContent(content: string): string { |
||||||
|
if (!truncateContentAt || content.length <= truncateContentAt) { |
||||||
|
return content; |
||||||
|
} |
||||||
|
return content.slice(0, truncateContentAt) + "..."; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<Card |
||||||
|
class="card" |
||||||
|
role="button" |
||||||
|
tabindex="0" |
||||||
|
onclick={handleSelect} |
||||||
|
onkeydown={handleKeydown} |
||||||
|
> |
||||||
|
<div class="flex flex-col gap-1 p-4"> |
||||||
|
<div class="flex items-center gap-2 mb-1"> |
||||||
|
{#if label} |
||||||
|
<span class="font-medium text-gray-800 dark:text-gray-100">{label}</span> |
||||||
|
{/if} |
||||||
|
{#if showKind} |
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400">Kind: {event.kind}</span> |
||||||
|
{/if} |
||||||
|
{#if community} |
||||||
|
<div |
||||||
|
class="flex-shrink-0 w-4 h-4 bg-yellow-100 dark:bg-yellow-900 rounded-full flex items-center justify-center" |
||||||
|
title="Has posted to the community" |
||||||
|
> |
||||||
|
<svg |
||||||
|
class="w-3 h-3 text-yellow-600 dark:text-yellow-400" |
||||||
|
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> |
||||||
|
{:else} |
||||||
|
<div class="flex-shrink-0 w-4 h-4"></div> |
||||||
|
{/if} |
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400"> |
||||||
|
{@render userBadge( |
||||||
|
toNpub(event.pubkey) as string, |
||||||
|
profileData?.display_name || profileData?.name |
||||||
|
)} |
||||||
|
</span> |
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 ml-auto"> |
||||||
|
{event.created_at |
||||||
|
? new Date(event.created_at * 1000).toLocaleDateString() |
||||||
|
: "Unknown date"} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if event.kind === 0 && profileData} |
||||||
|
<div class="flex items-center gap-3 mb-2"> |
||||||
|
{#if profileData.picture} |
||||||
|
<img |
||||||
|
src={profileData.picture} |
||||||
|
alt="Profile" |
||||||
|
class="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-gray-600" |
||||||
|
onerror={(e) => { |
||||||
|
(e.target as HTMLImageElement).style.display = "none"; |
||||||
|
}} |
||||||
|
/> |
||||||
|
{:else} |
||||||
|
<div |
||||||
|
class="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600" |
||||||
|
> |
||||||
|
<span class="text-lg font-medium text-gray-600 dark:text-gray-300"> |
||||||
|
{(profileData.display_name || profileData.name || event.pubkey.slice(0, 1)).toUpperCase()} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
<div class="flex flex-col min-w-0 flex-1"> |
||||||
|
{#if profileData.display_name || profileData.name} |
||||||
|
<span class="font-medium text-gray-900 dark:text-gray-100 truncate"> |
||||||
|
{profileData.display_name || profileData.name} |
||||||
|
</span> |
||||||
|
{/if} |
||||||
|
{#if profileData.about} |
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2"> |
||||||
|
{profileData.about} |
||||||
|
</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
{#if summary} |
||||||
|
<div class="text-sm text-primary-900 dark:text-primary-200 mb-1 line-clamp-2"> |
||||||
|
{summary} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if deferralNaddr} |
||||||
|
<div class="text-xs text-primary-800 dark:text-primary-300 mb-1"> |
||||||
|
Read |
||||||
|
<span |
||||||
|
class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all cursor-pointer" |
||||||
|
onclick={(e) => { |
||||||
|
e.stopPropagation(); |
||||||
|
// Parent should intercept navigation by listening onSelect and inspecting event tags if needed |
||||||
|
}} |
||||||
|
> |
||||||
|
{deferralNaddr} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if showPublicationLink} |
||||||
|
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1"> |
||||||
|
<ViewPublicationLink event={event} /> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if event.content} |
||||||
|
<div class="text-sm text-gray-800 dark:text-gray-200 mt-1 line-clamp-2 break-words"> |
||||||
|
{clippedContent(event.content)} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</Card> |
||||||
@ -0,0 +1,71 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
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,9 @@ |
|||||||
|
<script> |
||||||
|
import { Alert } from "flowbite-svelte"; |
||||||
|
|
||||||
|
let { color, children } = $props(); |
||||||
|
</script> |
||||||
|
|
||||||
|
<Alert {color} class="alert-leather mb-4"> |
||||||
|
{@render children()} |
||||||
|
</Alert> |
||||||
@ -1,10 +1,25 @@ |
|||||||
<script> |
<script lang="ts"> |
||||||
let theme = $state('ocean'); // e.g. 'ocean' or '' for default |
import { ChevronDownOutline } from "flowbite-svelte-icons"; |
||||||
|
import { Dropdown, DropdownGroup, NavLi, Radio } from "flowbite-svelte"; |
||||||
|
|
||||||
|
let theme = $state('papyrus'); // e.g. 'ocean' or '' for default |
||||||
const apply = () => document.documentElement.setAttribute('data-theme', theme); |
const apply = () => document.documentElement.setAttribute('data-theme', theme); |
||||||
$effect(apply); |
$effect(apply); |
||||||
</script> |
</script> |
||||||
|
|
||||||
<select bind:value={theme} onchange={apply}> |
<NavLi> |
||||||
<option value="">Default</option> |
Theme {theme}<ChevronDownOutline class="text-primary-800 ms-2 inline h-6 w-6 dark:text-white" /> |
||||||
<option value="ocean">Ocean</option> |
</NavLi> |
||||||
</select> |
<Dropdown simple class="w-44"> |
||||||
|
<DropdownGroup class="space-y-3 p-3"> |
||||||
|
<li> |
||||||
|
<Radio name="group1" bind:group={theme} value="papyrus">Papyrus</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> |
||||||
|
|||||||
Loading…
Reference in new issue