mirror of
https://github.com/linkwarden/browser-extension.git
synced 2026-06-23 04:10:26 +00:00
bump version to 1.5.2 and enhance tag fetching with pagination 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.1",
|
||||
"version": "1.5.2",
|
||||
"action": {
|
||||
"default_popup": "index.html",
|
||||
"default_icon": {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user