Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# [1.23.0](https://github.com/rodrigogs/whats-reader/compare/v1.22.2...v1.23.0) (2026-01-02)


### Features

* UI componentization with reusable components ([47f0496](https://github.com/rodrigogs/whats-reader/commit/47f04961283f85198f36fed3b9b75fd06f4e6d41))

## [1.22.2](https://github.com/rodrigogs/whats-reader/compare/v1.22.1...v1.22.2) (2026-01-02)


Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "whats-reader",
"productName": "WhatsApp Backup Reader",
"version": "1.22.2",
"version": "1.23.0",
"description": "A desktop app to read and visualize WhatsApp chat exports",
"license": "AGPL-3.0",
"author": {
Expand Down
53 changes: 53 additions & 0 deletions src/lib/components/Collapsible.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from './Icon.svelte';

interface Props {
/**
* The title text displayed in the summary
*/
title: string;
/**
* Optional icon to display before the title
* @default 'chevron-right'
*/
icon?: 'chevron-right' | 'chevron-down';
/**
* Whether the collapsible is open by default
* @default false
*/
open?: boolean;
/**
* Additional CSS classes to apply to the details element
*/
class?: string;
/**
* Content to display when expanded
*/
children: Snippet;
}

let {
title,
icon = 'chevron-right',
open = false,
class: className = '',
children,
}: Props = $props();
</script>

<details class="group {className}" {open}>
<summary
class="flex items-center justify-center gap-2 cursor-pointer text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors select-none"
>
<Icon
name={icon}
size="sm"
class="transition-transform group-open:rotate-90"
/>
{title}
</summary>
<div class="mt-3">
{@render children()}
</div>
</details>
115 changes: 115 additions & 0 deletions src/lib/components/Dropdown.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { browser } from '$app/environment';
import { floating } from '$lib/actions/floating';

interface Props {
/**
* Reference element for positioning the dropdown
*/
anchor: HTMLElement | null;
/**
* Whether the dropdown is open
*/
open: boolean;
/**
* Callback when the dropdown should close
*/
onClose: () => void;
/**
* Dropdown placement relative to anchor
* @default 'bottom-end'
*/
placement?: 'bottom-end' | 'bottom-start' | 'top-end' | 'top-start';
/**
* Fallback placements if primary doesn't fit
*/
fallbackPlacements?: Array<
'bottom-start' | 'top-end' | 'top-start' | 'left-start' | 'right-start'
>;
/**
* Distance from anchor element in pixels
* @default 8
*/
offsetDistance?: number;
/**
* Width of the dropdown
* @default 'w-64'
*/
width?: string;
/**
* Additional CSS classes
*/
class?: string;
/**
* Accessible label for the close backdrop button
* @default 'Close dropdown'
*/
closeAriaLabel?: string;
/**
* Dropdown content
*/
children: Snippet;
}

let {
anchor,
open,
onClose,
placement = 'bottom-end',
fallbackPlacements = [
'bottom-start',
'top-end',
'top-start',
'left-start',
'right-start',
],
offsetDistance = 8,
width = 'w-64',
class: className = '',
closeAriaLabel = 'Close dropdown',
children,
}: Props = $props();

// Handle ESC key to close dropdown
$effect(() => {
if (!browser || !open) return;

const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};

window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
});
</script>

{#if open && anchor}
<!-- Backdrop to close dropdown -->
<button
type="button"
class="fixed inset-0 z-40 cursor-default"
onclick={onClose}
aria-label={closeAriaLabel}
></button>

<!-- Dropdown menu -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
role="menu"
tabindex="-1"
class="fixed {width} bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50 overflow-hidden {className}"
use:floating={{
reference: anchor,
placement,
fallbackPlacements,
offsetDistance,
enableSizeConstraint: true,
}}
onclick={(e) => e.stopPropagation()}
>
{@render children()}
</div>
{/if}
20 changes: 20 additions & 0 deletions src/lib/components/DropdownHeader.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<script lang="ts">
interface Props {
/**
* Header text
*/
title: string;
/**
* Additional CSS classes
*/
class?: string;
}

let { title, class: className = '' }: Props = $props();
</script>

<div
class="px-3 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider border-b border-gray-100 dark:border-gray-700 {className}"
>
{title}
</div>
29 changes: 29 additions & 0 deletions src/lib/components/DropdownList.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script lang="ts">
import type { Snippet } from 'svelte';

interface Props {
/**
* Maximum height for scrollable content
* @default 'max-h-48'
*/
maxHeight?: string;
/**
* Additional CSS classes
*/
class?: string;
/**
* List items
*/
children: Snippet;
}

let {
maxHeight = 'max-h-48',
class: className = '',
children,
}: Props = $props();
</script>

<div class="{maxHeight} overflow-y-auto py-1 {className}">
{@render children()}
</div>
50 changes: 50 additions & 0 deletions src/lib/components/DropdownSearch.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<script lang="ts">
import Icon from './Icon.svelte';

interface Props {
/**
* Search input value (use bind:value)
*/
value: string;
/**
* Placeholder text
*/
placeholder: string;
/**
* Callback when value changes
*/
onInput?: (value: string) => void;
/**
* Reference to the input element
*/
ref?: HTMLInputElement | null;
/**
* Additional CSS classes
*/
class?: string;
}

let {
value = $bindable(''),
placeholder,
onInput,
ref = $bindable(null),
class: className = '',
}: Props = $props();
</script>

<div class="p-2 border-b border-gray-100 dark:border-gray-700 {className}">
<div class="relative">
<div class="absolute left-2.5 top-1/2 -translate-y-1/2">
<Icon name="search" size="sm" class="text-gray-400" />
</div>
<input
bind:this={ref}
bind:value
type="text"
{placeholder}
oninput={() => onInput?.(value)}
class="w-full pl-8 pr-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 border-0 rounded-md focus:ring-2 focus:ring-[var(--color-whatsapp-teal)] focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400"
/>
</div>
</div>
62 changes: 62 additions & 0 deletions src/lib/components/FeatureItem.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { IconName } from './Icon.svelte';
import Icon from './Icon.svelte';

interface Props {
/**
* Badge content - either a number or an icon name (optional for icon variant)
*/
badge?: string | number;
/**
* Icon to display in the badge (required for icon variant)
*/
icon?: IconName;
/**
* Badge style variant
* @default 'numbered' - Circle with number
* 'icon' - Circle with icon
*/
variant?: 'numbered' | 'icon';
/**
* Additional CSS classes for the container
*/
class?: string;
/**
* Content to display next to the badge
*/
children: Snippet;
}

let {
badge,
icon,
variant = badge !== undefined ? 'numbered' : 'icon',
class: className = '',
children,
}: Props = $props();

// Resolve the effective variant to avoid empty badges when icon is missing
const effectiveVariant = $derived(
variant === 'icon' && !icon && badge !== undefined ? 'numbered' : variant,
);
</script>

<div
class="flex items-center gap-3 px-3 py-2 bg-gray-50 dark:bg-gray-800/50 rounded-lg {className}"
>
{#if (effectiveVariant === 'numbered' && badge !== undefined) || (effectiveVariant === 'icon' && icon)}
<span
class="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-600 text-white text-xs flex items-center justify-center {effectiveVariant === 'numbered' ? 'font-bold' : ''}"
>
{#if effectiveVariant === 'numbered' && badge !== undefined}
{badge}
{:else if effectiveVariant === 'icon' && icon}
<Icon name={icon} size="xs" stroke-width="2" />
{/if}
</span>
{/if}
<span class="text-xs text-gray-600 dark:text-gray-400">
{@render children()}
</span>
</div>
2 changes: 1 addition & 1 deletion src/lib/components/Icon.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { HTMLAttributes } from 'svelte/elements';
/**
* Supported icon names for the Icon component
*/
type IconName =
export type IconName =
// Navigation
| 'menu'
| 'close'
Expand Down
Loading