Browse Source

Search form, misc details

master
Nuša Pukšič 7 months ago committed by buttercat1791
parent
commit
48ef093740
  1. 1
      src/app.css
  2. 11
      src/lib/a/README.md
  3. 191
      src/lib/a/cards/AEventPreview.svelte
  4. 71
      src/lib/a/forms/ASearchForm.svelte
  5. 5
      src/lib/a/index.ts
  6. 0
      src/lib/a/nav/ANavDropdown.svelte
  7. 10
      src/lib/a/nav/ANavbar.svelte
  8. 9
      src/lib/a/primitives/AAlert.svelte
  9. 27
      src/lib/a/primitives/AThemeToggleMini.svelte
  10. 24
      src/theme-tokens.css

1
src/app.css

@ -693,6 +693,7 @@
input[type="tel"], input[type="tel"],
input[type="url"], input[type="url"],
textarea { textarea {
@apply bg-primary-50 dark:bg-primary-1000 text-gray-900 dark:text-gray-100 border-s-4 border-primary-200 rounded shadow-none;
@apply focus:border-primary-600 dark:focus:border-primary-400; @apply focus:border-primary-600 dark:focus:border-primary-400;
} }

11
src/lib/a/README.md

@ -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.

191
src/lib/a/cards/AEventPreview.svelte

@ -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>

71
src/lib/a/forms/ASearchForm.svelte

@ -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>

5
src/lib/a/index.ts

@ -7,6 +7,7 @@ export { default as ANostrUser } from './primitives/ANostrUser.svelte';
export { default as ANostrBadge } from './primitives/ANostrBadge.svelte'; export { default as ANostrBadge } from './primitives/ANostrBadge.svelte';
export { default as ANostrBadgeRow } from './primitives/ANostrBadgeRow.svelte'; export { default as ANostrBadgeRow } from './primitives/ANostrBadgeRow.svelte';
export { default as AThemeToggleMini } from './primitives/AThemeToggleMini.svelte'; export { default as AThemeToggleMini } from './primitives/AThemeToggleMini.svelte';
export { default as AAlert } from './primitives/AAlert.svelte';
export { default as AReaderPage } from './reader/AReaderPage.svelte'; export { default as AReaderPage } from './reader/AReaderPage.svelte';
export { default as AReaderToolbar } from './reader/AReaderToolbar.svelte'; export { default as AReaderToolbar } from './reader/AReaderToolbar.svelte';
@ -17,3 +18,7 @@ export { default as ATocNode } from './reader/ATocNode.svelte';
export { default as ANavbar } from './nav/ANavbar.svelte'; export { default as ANavbar } from './nav/ANavbar.svelte';
export { default as AFooter } from './nav/AFooter.svelte'; export { default as AFooter } from './nav/AFooter.svelte';
export { default as ASearchForm } from './forms/ASearchForm.svelte';
export { default as AEventPreview } from './cards/AEventPreview.svelte';

0
src/lib/a/nav/ANavDropdown.svelte

10
src/lib/a/nav/ANavbar.svelte

@ -7,16 +7,15 @@
NavHamburger, NavHamburger,
NavBrand, NavBrand,
Dropdown, Dropdown,
DropdownItem, DropdownItem
DropdownDivider
} from "flowbite-svelte"; } from "flowbite-svelte";
import { siteNav, userMenu } from "$lib/nav/site-nav.js"; import { siteNav } from "$lib/nav/site-nav.js";
import { logoutUser, userStore } from "$lib/stores/userStore"; import { logoutUser, userStore } from "$lib/stores/userStore";
import Profile from "$components/util/Profile.svelte"; import Profile from "$components/util/Profile.svelte";
import { shortenBech32 } from "$lib/nostr/format.ts";
import type { NavItem } from "$lib/a/nav/nav-types.ts"; import type { NavItem } from "$lib/a/nav/nav-types.ts";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { ChevronDownOutline } from "flowbite-svelte-icons"; import { ChevronDownOutline } from "flowbite-svelte-icons";
import { AThemeToggleMini } from "$lib/a";
let { let {
currentPath = "", currentPath = "",
@ -47,7 +46,7 @@
} }
</script> </script>
<Navbar class="flex flex-row" navContainerClass="w-full flex-row justify-between items-center"> <Navbar class="fixed start-0 top-0 z-50 flex flex-row bg-primary-50 dark:bg-primary-800 !p-0" navContainerClass="w-full flex-row justify-between items-center !p-0">
<NavBrand href="/"> <NavBrand href="/">
<h1>Alexandria</h1> <h1>Alexandria</h1>
</NavBrand> </NavBrand>
@ -78,5 +77,6 @@
<NavLi> <NavLi>
<DarkMode class="btn-leather p-0" /> <DarkMode class="btn-leather p-0" />
</NavLi> </NavLi>
<AThemeToggleMini />
</NavUl> </NavUl>
</Navbar> </Navbar>

9
src/lib/a/primitives/AAlert.svelte

@ -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>

27
src/lib/a/primitives/AThemeToggleMini.svelte

@ -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>

24
src/theme-tokens.css

@ -38,3 +38,27 @@
--brand-primary-400: #7ccdfc; --brand-primary-400: #7ccdfc;
--brand-primary-500: #38bdf8; --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;
}

Loading…
Cancel
Save