mirror of
https://github.com/linkwarden/browser-extension.git
synced 2026-06-23 04:10:26 +00:00
bump version to 1.5.3 and add tag search feature support
This commit is contained in:
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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()}`, {
|
||||
|
||||
Reference in New Issue
Block a user