bump version to 1.5.3 and add tag search feature support

This commit is contained in:
daniel31x13
2026-04-17 16:18:10 -04:00
parent ee30c18aa3
commit 47b0e39fa7
4 changed files with 102 additions and 52 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
"name": "Linkwarden",
"description": "The browser extension for Linkwarden.",
"homepage_url": "https://linkwarden.app/",
"version": "1.5.2",
"version": "1.5.3",
"action": {
"default_popup": "index.html",
"default_icon": {
+16 -3
View File
@@ -25,7 +25,7 @@ import { AxiosError } from 'axios';
import { toast } from '../../hooks/use-toast.ts';
import { Toaster } from './ui/Toaster.tsx';
import { getCollections } from '../lib/actions/collections.ts';
import { getTags } from '../lib/actions/tags.ts';
import { getShouldUseTagSearch, getTags } from '../lib/actions/tags.ts';
import { ExternalLink, X } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from './ui/Popover.tsx';
import { CaretSortIcon } from '@radix-ui/react-icons';
@@ -44,6 +44,7 @@ const BookmarkForm = () => {
const [openCollections, setOpenCollections] = useState<boolean>(false);
const [uploadImage, setUploadImage] = useState<boolean>(false);
const [state, setState] = useState<'capturing' | 'uploading' | null>(null);
const [tagSearch, setTagSearch] = useState<string>('');
const [isConfigured, setIsConfigured] = useState(false);
const [isDuplicate, setIsDuplicate] = useState(false);
@@ -195,6 +196,16 @@ const BookmarkForm = () => {
enabled: isConfigured,
});
const { data: shouldUseTagSearch = false } = useQuery({
queryKey: ['tag-search-support', config?.baseUrl, config?.apiKey],
queryFn: async () =>
await getShouldUseTagSearch(
config?.baseUrl as string,
config?.apiKey as string
),
enabled: isConfigured && openOptions,
});
const effectiveTagSearch = shouldUseTagSearch ? tagSearch : '';
const {
isLoading: loadingTags,
data: tagsData,
@@ -203,12 +214,13 @@ const BookmarkForm = () => {
isFetchingNextPage,
fetchNextPage,
} = useInfiniteQuery(
['tags', config?.baseUrl, config?.apiKey],
['tags', config?.baseUrl, config?.apiKey, effectiveTagSearch],
async ({ pageParam = 0 }) => {
return await getTags(
config?.baseUrl as string,
config?.apiKey as string,
pageParam
pageParam,
effectiveTagSearch
);
},
{
@@ -456,6 +468,7 @@ const BookmarkForm = () => {
tags={tags}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onSearchChange={setTagSearch}
onReachEnd={() => {
if (!hasNextPage || isFetchingNextPage) return;
void fetchNextPage();
+47 -35
View File
@@ -1,4 +1,4 @@
import { FC, UIEvent, useState } from 'react';
import { FC, UIEvent, useMemo, useState } from 'react';
import { Button } from './ui/Button.tsx';
import { Popover, PopoverContent, PopoverTrigger } from './ui/Popover.tsx';
import { Check, ChevronsUpDown } from 'lucide-react';
@@ -20,6 +20,7 @@ interface TagInputProps {
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
onReachEnd?: () => void;
onSearchChange?: (value: string) => void;
}
export const TagInput: FC<TagInputProps> = ({
@@ -29,9 +30,21 @@ export const TagInput: FC<TagInputProps> = ({
hasNextPage,
isFetchingNextPage,
onReachEnd,
onSearchChange,
}) => {
const [open, setOpen] = useState<boolean>(false);
const [inputValue, setInputValue] = useState<string>('');
const filteredTags = useMemo(() => {
if (!Array.isArray(tags)) return [];
const normalizedInputValue = inputValue.trim().toLowerCase();
if (!normalizedInputValue) return tags;
return tags.filter((tag) =>
tag.name.toLowerCase().includes(normalizedInputValue)
);
}, [inputValue, tags]);
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
if (!hasNextPage || isFetchingNextPage || !onReachEnd) return;
@@ -49,6 +62,7 @@ export const TagInput: FC<TagInputProps> = ({
if (inputValue) {
const newTags = [...value, { name: inputValue }];
setInputValue('');
onSearchChange?.('');
onChange(newTags);
}
}
@@ -76,12 +90,15 @@ export const TagInput: FC<TagInputProps> = ({
</PopoverTrigger>
<div className="min-w-full inset-x-0">
<PopoverContent className="min-w-full p-0">
<Command className="flex-grow min-w-full">
<Command className="flex-grow min-w-full" shouldFilter={false}>
<CommandInput
className="min-w-[280px]"
placeholder="Search tag or add tag (Enter)"
value={inputValue}
onValueChange={setInputValue}
onValueChange={(value) => {
setInputValue(value);
onSearchChange?.(value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAddTag();
@@ -95,39 +112,34 @@ export const TagInput: FC<TagInputProps> = ({
<CommandEmpty>No tag found.</CommandEmpty>
{Array.isArray(tags) && (
<CommandGroup className="w-full">
{tags
.filter((tag) =>
tag.name
.toLowerCase()
.includes(inputValue.trim().toLowerCase())
)
.map((tag: { name: string }) => (
<CommandItem
className="w-full"
key={tag.name}
onSelect={() => {
if (Array.isArray(value)) {
if (value.some((v) => v.name === tag.name)) {
handleRemoveTag(tag.name);
} else {
onChange([...value, tag]);
}
{filteredTags.map((tag: { name: string }) => (
<CommandItem
className="w-full"
key={tag.name}
value={tag.name}
onSelect={() => {
if (Array.isArray(value)) {
if (value.some((v) => v.name === tag.name)) {
handleRemoveTag(tag.name);
} else {
onChange([...value, tag]);
}
setOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
Array.isArray(value) &&
value.some((v) => v.name === tag.name)
? 'opacity-100'
: 'opacity-0'
)}
/>
{tag.name}
</CommandItem>
))}
}
setOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
Array.isArray(value) &&
value.some((v) => v.name === tag.name)
? 'opacity-100'
: 'opacity-0'
)}
/>
{tag.name}
</CommandItem>
))}
{isFetchingNextPage ? (
<CommandItem
className="w-full"
+38 -13
View File
@@ -31,8 +31,15 @@ type PaginatedTagsResponse = {
};
const MIN_TAG_PAGINATION_VERSION = '2.14.0';
const MIN_TAG_SEARCH_VERSION = '2.14.1';
const TAG_SORT_NAME_ASC = 2;
const paginationSupportCache = new Map<string, boolean>();
const tagFeatureSupportCache = new Map<
string,
{
shouldUsePagination: boolean;
shouldUseSearch: boolean;
}
>();
export type TagsPage = {
tags: ResponseTags[];
@@ -124,44 +131,62 @@ const getInstanceVersion = async (baseUrl: string, apiKey: string) => {
}
};
const getPaginationSupportCacheKey = (baseUrl: string, apiKey: string) =>
const getTagFeatureSupportCacheKey = (baseUrl: string, apiKey: string) =>
`${baseUrl}::${apiKey}`;
const getShouldUsePagination = async (baseUrl: string, apiKey: string) => {
const cacheKey = getPaginationSupportCacheKey(baseUrl, apiKey);
const cachedValue = paginationSupportCache.get(cacheKey);
const getTagFeatures = async (baseUrl: string, apiKey: string) => {
const cacheKey = getTagFeatureSupportCacheKey(baseUrl, apiKey);
const cachedValue = tagFeatureSupportCache.get(cacheKey);
if (cachedValue !== undefined) return cachedValue;
const instanceVersion = await getInstanceVersion(baseUrl, apiKey);
const shouldUsePagination = isAtLeastInstanceVersion(
instanceVersion,
MIN_TAG_PAGINATION_VERSION
);
const nextValue = {
shouldUsePagination: isAtLeastInstanceVersion(
instanceVersion,
MIN_TAG_PAGINATION_VERSION
),
shouldUseSearch: isAtLeastInstanceVersion(
instanceVersion,
MIN_TAG_SEARCH_VERSION
),
};
paginationSupportCache.set(cacheKey, shouldUsePagination);
tagFeatureSupportCache.set(cacheKey, nextValue);
return shouldUsePagination;
return nextValue;
};
export const getShouldUseTagSearch = async (baseUrl: string, apiKey: string) =>
(await getTagFeatures(baseUrl, apiKey)).shouldUseSearch;
export async function getTags(
baseUrl: string,
apiKey: string,
cursor = 0
cursor = 0,
search = ''
): Promise<TagsPage> {
const shouldUsePagination = await getShouldUsePagination(baseUrl, apiKey);
const { shouldUsePagination, shouldUseSearch } = await getTagFeatures(
baseUrl,
apiKey
);
const headers = {
Authorization: `Bearer ${apiKey}`,
};
const searchParams = new URLSearchParams();
const normalizedSearch = search.trim();
searchParams.set('sort', String(TAG_SORT_NAME_ASC));
if (shouldUsePagination) {
searchParams.set('cursor', String(cursor));
}
if (shouldUseSearch && normalizedSearch) {
searchParams.set('search', normalizedSearch);
}
const initialResponse = await axios.get<
LegacyTagsResponse | PaginatedTagsResponse
>(`${baseUrl}/api/v1/tags?${searchParams.toString()}`, {