bump version to 1.5.2 and enhance tag fetching with pagination support

This commit is contained in:
daniel31x13
2026-03-19 01:16:46 -04:00
parent 24132a53b6
commit ee30c18aa3
4 changed files with 273 additions and 60 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.1",
"version": "1.5.2",
"action": {
"default_popup": "index.html",
"default_icon": {
+35 -14
View File
@@ -17,8 +17,8 @@ import { Button } from './ui/Button.tsx';
import { TagInput } from './TagInput.tsx';
import { Textarea } from './ui/Textarea.tsx';
import { getCurrentTabInfo, updateBadge } from '../lib/utils.ts';
import { useEffect, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
import { getConfig, isConfigured as getIsConfigured } from '../lib/config.ts';
import { checkLinkExists, postLink } from '../lib/actions/links.ts';
import { AxiosError } from 'axios';
@@ -197,22 +197,33 @@ const BookmarkForm = () => {
const {
isLoading: loadingTags,
data: tags,
data: tagsData,
error: tagsError,
} = useQuery({
queryKey: ['tags'],
queryFn: async () => {
const response = await getTags(
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useInfiniteQuery(
['tags', config?.baseUrl, config?.apiKey],
async ({ pageParam = 0 }) => {
return await getTags(
config?.baseUrl as string,
config?.apiKey as string
config?.apiKey as string,
pageParam
);
return response.data.response.sort((a, b) => {
return a.name.localeCompare(b.name);
});
},
enabled: isConfigured,
});
{
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
enabled: isConfigured && openOptions,
}
);
const tags = useMemo(() => {
return (
tagsData?.pages
.flatMap((page) => page.tags)
.sort((a, b) => a.name.localeCompare(b.name)) ?? []
);
}, [tagsData]);
return (
<div>
@@ -427,18 +438,28 @@ const BookmarkForm = () => {
onChange={field.onChange}
value={[{ name: 'Loading tags...' }]}
tags={[{ id: 1, name: 'Loading tags...' }]}
hasNextPage={false}
isFetchingNextPage={false}
/>
) : tagsError ? (
<TagInput
onChange={field.onChange}
value={[{ name: 'Not found' }]}
tags={[{ id: 1, name: 'Not found' }]}
hasNextPage={false}
isFetchingNextPage={false}
/>
) : (
<TagInput
onChange={field.onChange}
value={field.value ?? []}
tags={tags}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onReachEnd={() => {
if (!hasNextPage || isFetchingNextPage) return;
void fetchNextPage();
}}
/>
)}
<FormMessage />
+74 -38
View File
@@ -1,4 +1,4 @@
import { FC, useState } from 'react';
import { FC, UIEvent, useState } from 'react';
import { Button } from './ui/Button.tsx';
import { Popover, PopoverContent, PopoverTrigger } from './ui/Popover.tsx';
import { Check, ChevronsUpDown } from 'lucide-react';
@@ -8,19 +8,41 @@ import {
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from './ui/Command.tsx';
import { cn } from '../lib/utils.ts';
import { ResponseTags } from '../lib/actions/tags.ts';
interface TagInputProps {
onChange: (tags: { name: string }[]) => void;
value: { name: string; id?: number }[];
tags: { id: number; name: string }[] | undefined;
tags: Pick<ResponseTags, 'id' | 'name'>[] | undefined;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
onReachEnd?: () => void;
}
export const TagInput: FC<TagInputProps> = ({ value, onChange, tags }) => {
export const TagInput: FC<TagInputProps> = ({
value,
onChange,
tags,
hasNextPage,
isFetchingNextPage,
onReachEnd,
}) => {
const [open, setOpen] = useState<boolean>(false);
const [inputValue, setInputValue] = useState<string>('');
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
if (!hasNextPage || isFetchingNextPage || !onReachEnd) return;
const target = event.currentTarget;
const reachedBottom =
target.scrollTop + target.clientHeight >= target.scrollHeight - 16;
if (reachedBottom) onReachEnd();
};
function handleAddTag() {
if (inputValue && value.some((tagObj) => tagObj.name === inputValue))
return;
@@ -53,7 +75,7 @@ export const TagInput: FC<TagInputProps> = ({ value, onChange, tags }) => {
</Button>
</PopoverTrigger>
<div className="min-w-full inset-x-0">
<PopoverContent className="min-w-full p-0 overflow-y-auto max-h-[200px]">
<PopoverContent className="min-w-full p-0">
<Command className="flex-grow min-w-full">
<CommandInput
className="min-w-[280px]"
@@ -66,44 +88,58 @@ export const TagInput: FC<TagInputProps> = ({ value, onChange, tags }) => {
}
}}
/>
<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 }) => (
<CommandList
className="max-h-[200px]"
onScroll={handleListScroll}
>
<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]);
}
}
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"
key={tag.name}
onSelect={() => {
if (Array.isArray(value)) {
if (value.some((v) => v.name === tag.name)) {
handleRemoveTag(tag.name);
} else {
onChange([...value, tag]);
}
}
setOpen(false);
}}
value="Loading more tags..."
disabled
>
<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}
Loading more tags...
</CommandItem>
))}
</CommandGroup>
)}
) : null}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</div>
+163 -7
View File
@@ -1,6 +1,6 @@
import axios from 'axios';
interface ResponseTags {
export interface ResponseTags {
id: number;
name: string;
ownerId: number;
@@ -11,11 +11,167 @@ interface ResponseTags {
};
}
export async function getTags(baseUrl: string, apiKey: string) {
const url = `${baseUrl}/api/v1/tags`;
return await axios.get<{ response: ResponseTags[] }>(url, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
type ConfigResponse = {
response: {
INSTANCE_VERSION?: string | null;
};
};
type LegacyTagsResponse = {
response:
| ResponseTags[]
| { tags: ResponseTags[]; nextCursor?: number | null };
};
type PaginatedTagsResponse = {
data: {
tags: ResponseTags[];
nextCursor?: number | null;
};
};
const MIN_TAG_PAGINATION_VERSION = '2.14.0';
const TAG_SORT_NAME_ASC = 2;
const paginationSupportCache = new Map<string, boolean>();
export type TagsPage = {
tags: ResponseTags[];
nextCursor: number | null;
};
const normalizeVersion = (version?: string | null) => {
if (!version) return null;
return version
.replace(/^v/i, '')
.split('-')[0]
.split('.')
.map((part) => Number(part.replace(/\D/g, '')) || 0);
};
const isAtLeastInstanceVersion = (
version?: string | null,
minimumVersion?: string | null
) => {
const normalizedVersion = normalizeVersion(version);
const normalizedMinimumVersion = normalizeVersion(minimumVersion);
if (!normalizedVersion || !normalizedMinimumVersion) return false;
const length = Math.max(
normalizedVersion.length,
normalizedMinimumVersion.length
);
for (let index = 0; index < length; index++) {
const left = normalizedVersion[index] ?? 0;
const right = normalizedMinimumVersion[index] ?? 0;
if (left > right) return true;
if (left < right) return false;
}
return true;
};
const extractTagsPayload = (
data: LegacyTagsResponse | PaginatedTagsResponse
): { tags: ResponseTags[]; nextCursor: number | null } => {
if (Array.isArray((data as LegacyTagsResponse).response)) {
return {
tags: (data as LegacyTagsResponse).response as ResponseTags[],
nextCursor: null,
};
}
if (
(data as LegacyTagsResponse).response &&
!Array.isArray((data as LegacyTagsResponse).response)
) {
const response = (data as LegacyTagsResponse).response as {
tags: ResponseTags[];
nextCursor?: number | null;
};
return {
tags: response.tags,
nextCursor: response.nextCursor ?? null,
};
}
const response = (data as PaginatedTagsResponse).data;
return {
tags: response.tags,
nextCursor: response.nextCursor ?? null,
};
};
const getInstanceVersion = async (baseUrl: string, apiKey: string) => {
try {
const response = await axios.get<ConfigResponse>(
`${baseUrl}/api/v1/config`,
{
headers: {
Authorization: `Bearer ${apiKey}`,
},
}
);
return response.data.response.INSTANCE_VERSION ?? null;
} catch (_error) {
return null;
}
};
const getPaginationSupportCacheKey = (baseUrl: string, apiKey: string) =>
`${baseUrl}::${apiKey}`;
const getShouldUsePagination = async (baseUrl: string, apiKey: string) => {
const cacheKey = getPaginationSupportCacheKey(baseUrl, apiKey);
const cachedValue = paginationSupportCache.get(cacheKey);
if (cachedValue !== undefined) return cachedValue;
const instanceVersion = await getInstanceVersion(baseUrl, apiKey);
const shouldUsePagination = isAtLeastInstanceVersion(
instanceVersion,
MIN_TAG_PAGINATION_VERSION
);
paginationSupportCache.set(cacheKey, shouldUsePagination);
return shouldUsePagination;
};
export async function getTags(
baseUrl: string,
apiKey: string,
cursor = 0
): Promise<TagsPage> {
const shouldUsePagination = await getShouldUsePagination(baseUrl, apiKey);
const headers = {
Authorization: `Bearer ${apiKey}`,
};
const searchParams = new URLSearchParams();
searchParams.set('sort', String(TAG_SORT_NAME_ASC));
if (shouldUsePagination) {
searchParams.set('cursor', String(cursor));
}
const initialResponse = await axios.get<
LegacyTagsResponse | PaginatedTagsResponse
>(`${baseUrl}/api/v1/tags?${searchParams.toString()}`, {
headers,
});
const payload = extractTagsPayload(initialResponse.data);
return {
tags: payload.tags,
nextCursor: shouldUsePagination ? payload.nextCursor : null,
};
}