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
310 changes: 310 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-opener": "^2",
"dompurify": "^3.3.0",
"inaturalistjs": "github:inaturalist/inaturalistjs",
"lightningcss": "^1.30.2",
"lucide-svelte": "^0.546.0",
"maplibre-gl": "^5.9.0",
Expand Down
4 changes: 2 additions & 2 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ export default defineConfig({
name: 'integration-windows',
use: {
...devices['Desktop Edge'],
channel: 'msedge'
channel: 'msedge',
},
testIgnore: '**/performance.spec.ts',
retries: 2
retries: 2,
},
],

Expand Down
2 changes: 1 addition & 1 deletion scripts/bump-version.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node

import { readFileSync, writeFileSync } from 'fs';
import { execSync } from 'child_process';
import { readFileSync, writeFileSync } from 'fs';

const args = process.argv.slice(2);
const DRY_RUN = args.includes('--dry-run');
Expand Down
67 changes: 67 additions & 0 deletions src/lib/components/InatPlaceChooser.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<script lang="ts">
import { MapPin } from 'lucide-svelte';
import type { ComboboxItem, Place, SearchResult } from '$lib/types/inaturalist';
import InatSearchChooser from './InatSearchChooser.svelte';

interface Props {
selectedId?: number | null;
onChange?: () => void;
}

let { selectedId = $bindable(), onChange }: Props = $props();

const mapPlaceResult = (result: SearchResult): ComboboxItem => {
const place = (result.place || result.record) as Place;
return {
label: `${place.display_name} (${place.id})`,
value: place.id.toString(),
item: place,
};
};
</script>

<InatSearchChooser
bind:selectedId
{onChange}
source="places"
mapResultFn={mapPlaceResult}
placeholder="Place"
label="Place"
>
{#snippet thumbnail({ selectedItem })}
{#if selectedItem}
<div
class="aspect-square h-9 bg-surface-500 rounded flex items-center justify-center text-surface-contrast-500"
>
<MapPin size={16} />
</div>
{:else}
<div
class="aspect-square h-9 bg-surface-200-800 rounded flex items-center justify-center"
>
<MapPin size={16} />
</div>
{/if}
{/snippet}

{#snippet itemContent({ item })}
{@const place = item.item as Place}
<div class="flex w-full gap-2 items-center">
<div
class="aspect-square h-9 bg-surface-200-800 rounded flex items-center justify-center"
>
<MapPin size={16} />
</div>
<div class="flex-1">
<div class="line-clamp-1 text-ellipsis font-semibold">
{place?.display_name || item.label}
</div>
{#if place?.place_type}
<div class="line-clamp-1 text-ellipsis text-sm capitalize">
{place.place_type}
</div>
{/if}
</div>
</div>
{/snippet}
</InatSearchChooser>
187 changes: 187 additions & 0 deletions src/lib/components/InatSearchChooser.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
<script lang="ts">
import {
Combobox,
Portal,
useListCollection,
} from '@skeletonlabs/skeleton-svelte';
import { XIcon } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import { tick } from 'svelte';
import { useInatSearch } from '$lib/composables/useInatSearch.svelte.js';
import type {
ComboboxItem,
InatItem,
SearchResult,
SourceType,
} from '$lib/types/inaturalist';

interface Props {
selectedId?: number | string | null;
onChange?: () => void;
source: SourceType;
mapResultFn: (result: SearchResult) => ComboboxItem;
placeholder?: string;
label?: string;
thumbnail: Snippet<[{ selectedItem: InatItem | null }]>;
itemContent: Snippet<[{ item: ComboboxItem }]>;
}

let {
selectedId = $bindable(),
onChange,
source,
mapResultFn,
placeholder = 'Search...',
label,
thumbnail,
itemContent,
}: Props = $props();

const search = useInatSearch(source, mapResultFn);

let inputValue = $state('');
let isOpen = $state(false);

// Create collection for combobox
const collection = $derived(
useListCollection({
items: search.comboboxData,
itemToString: (item) => item.label,
itemToValue: (item) => item.value,
}),
);

// If we receive an ID but we don't yet have the actual object corresponding
// to that ID, we need to fetch it from the API
$effect(() => {
if (selectedId && !search.selectedItem) {
search.loadById(selectedId);
}
});

// Sync input value when selected item changes
$effect(() => {
if (search.selectedItem) {
const item = search.comboboxData.find(
(i) => i.value === search.selectedValue[0],
);
if (item) {
inputValue = item.label;
}
}
});

function handleInputValueChange(e: { inputValue: string }) {
inputValue = e.inputValue;
search.handleInputValueChange(e);
}

function handleValueChange(e: { value: string[] }) {
search.handleValueChange(e, (selected) => {
selectedId = selected?.id ?? null;
if (onChange) {
onChange();
}
});

if (e.value.length > 0) {
isOpen = false;
tick().then(() => {
// Clear suggestions after selection
});
}
}

function handleClear(e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
search.clearSelection();
selectedId = null;
inputValue = '';
if (onChange) {
onChange();
}
}

function handleOpenChange(e: { open: boolean }) {
isOpen = e.open;
}

// Control popover visibility
$effect(() => {
if (
inputValue.length < 2 ||
(search.comboboxData.length === 0 && !search.loading) ||
(search.comboboxData.length === 1 &&
search.comboboxData[0].label === inputValue)
) {
isOpen = false;
} else if (search.comboboxData.length > 0 && !search.selectedItem) {
isOpen = true;
}
});
</script>

<div class="w-full">
{#if label}
<label for={`search-chooser-${source}`} class="block text-sm font-medium mb-1">
{label}
</label>
{/if}
<div class="flex flex-row gap-2 items-center">
{@render thumbnail({ selectedItem: search.selectedItem })}
<div class="relative flex-1">
<Combobox
{collection}
value={search.selectedValue}
{inputValue}
open={isOpen}
onInputValueChange={handleInputValueChange}
onValueChange={handleValueChange}
onOpenChange={handleOpenChange}
positioning={{
// If the popover flips above the input, you might not see the top
// result
flip: false
}}
>
<Combobox.Control>
<Combobox.Input
class="input w-full pr-8"
autocapitalize="off"
autocorrect="off"
{placeholder}
id={`search-chooser-${source}`}
/>
{#if search.selectedItem}
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 hover:bg-gray-100 dark:hover:bg-gray-700 p-1 rounded"
onclick={handleClear}
aria-label="Clear selection"
>
<XIcon size={14} />
</button>
{/if}
</Combobox.Control>
<Portal>
<Combobox.Positioner>
<Combobox.Content>
{#if search.loading}
<div class="text-sm text-gray-500">Loading...</div>
{:else if search.comboboxData.length === 0 && inputValue.length >= 2}
<div class="text-sm text-gray-500">No matches found</div>
{:else}
{#each search.comboboxData as item (item.value)}
<Combobox.Item {item}>
{@render itemContent({ item })}
</Combobox.Item>
{/each}
{/if}
</Combobox.Content>
</Combobox.Positioner>
</Portal>
</Combobox>
</div>
</div>
</div>
74 changes: 74 additions & 0 deletions src/lib/components/InatTaxonChooser.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<script lang="ts">
import { Leaf } from 'lucide-svelte';
import type { ComboboxItem, SearchResult, Taxon } from '$lib/types/inaturalist';
import InatSearchChooser from './InatSearchChooser.svelte';
import InatTaxonName from './InatTaxonName.svelte';

interface Props {
selectedId?: number | null;
onChange?: () => void;
}

let { selectedId = $bindable(), onChange }: Props = $props();

const mapTaxonResult = (result: SearchResult): ComboboxItem => {
const taxon = (result.taxon || result.record) as Taxon;
return {
label: `${taxon.preferred_common_name || taxon.name} (${taxon.preferred_common_name ? taxon.name : taxon.iconic_taxon_name})`,
value: taxon.id.toString(),
item: taxon,
};
};
</script>

<InatSearchChooser
bind:selectedId
{onChange}
source="taxa"
mapResultFn={mapTaxonResult}
placeholder="Taxon"
label="Taxon"
>
{#snippet thumbnail({ selectedItem })}
{@const taxon = selectedItem as Taxon | null}
{#if taxon}
{#if taxon.default_photo}
<img
src={taxon.default_photo.medium_url}
alt={taxon.name}
class="aspect-square h-9 object-cover rounded"
/>
{:else}
<div
class="aspect-square h-9 bg-surface-500 rounded flex items-center justify-center text-surface-contrast-500"
>
<Leaf size={16} />
</div>
{/if}
{:else}
<div
class="aspect-square h-9 bg-surface-200-800 rounded flex items-center justify-center"
>
<Leaf size={16} />
</div>
{/if}
{/snippet}

{#snippet itemContent({ item })}
{@const taxon = item.item as Taxon}
<div class="flex w-full gap-2">
{#if taxon?.default_photo}
<img
src={taxon.default_photo.medium_url}
alt={taxon.name}
class="w-12 h-12 object-cover rounded"
/>
{:else}
<div class="w-12 h-12 bg-surface-200-800 rounded"></div>
{/if}
<div class="flex-1 items-start">
<InatTaxonName {taxon} numLines={2} />
</div>
</div>
{/snippet}
</InatSearchChooser>
Loading