The owner installed it, landed on the configuration form, and could not get off it. Not a bug in the form: there was nothing behind it. One panel was registered and it was the settings. That is the wrong shape for a plugin regardless of how much is finished. A screen whose only content is its own configuration reads as broken rather than unfinished, because the first thing anybody does after installing is look for the way forward. So: collections on the left, links on the right, and the settings behind the gear in the collections panel - reachable when something is wrong and out of the way when it is not. Selection is `?collection=` in the URL rather than a channel, so which collection you are looking at survives a reload and can be linked to. Search is Linkwarden's own `searchQueryString`, which searches names, descriptions, tags AND the archived page text - the thing a bookmark bar cannot do, and the reason this is worth more than a list of addresses. Doing it here would mean pulling every link to the browser and would still only see the fields we happened to fetch. Verified against the instance: 50 unfiltered, 8 for "arch", 0 for a string matching nothing. The panel holds no credential and could not send one. Every request goes through the authenticated door, which finds the caller's mapped account and puts their token on. `no Linkwarden account is mapped to you` is the state every user starts in, so it is said as an instruction rather than rendered as an outage.
139 lines
5.3 KiB
TypeScript
139 lines
5.3 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useSearchParams } from 'react-router';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Input } from '@/components/ui/input';
|
|
import { ExternalLink, FileText } from 'lucide-react';
|
|
import { ALL } from './CollectionsPanel';
|
|
|
|
/**
|
|
* Your saved links, as the calling Officer user.
|
|
*
|
|
* Every request goes through `/api/linkwarden/api/*` — the authenticated door, which finds the caller's
|
|
* mapped account and puts THEIR token on the request. This panel holds no credential and could not send
|
|
* one if it wanted to, which is the property the whole plugin exists for.
|
|
*
|
|
* ── Search is Linkwarden's, not ours ──
|
|
*
|
|
* `searchQueryString` searches names, descriptions, tags AND the archived page text, which is the thing
|
|
* a bookmark bar cannot do. Re-implementing it here would mean pulling every link to the browser to
|
|
* filter it, and would still only see the fields we happened to fetch. Verified against the live
|
|
* instance: 50 links unfiltered, 8 for "arch", 0 for a string that matches nothing.
|
|
*/
|
|
type Link = {
|
|
id: number;
|
|
name: string;
|
|
url: string;
|
|
description?: string;
|
|
textContent?: string;
|
|
collection?: { id: number; name: string };
|
|
tags?: { id: number; name: string }[];
|
|
createdAt?: string;
|
|
};
|
|
|
|
export const LinksPanel = () => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [params] = useSearchParams();
|
|
const collection = params.get('collection') ?? ALL;
|
|
|
|
const [query, setQuery] = useState('');
|
|
const [rows, setRows] = useState<Link[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const load = useCallback(async (collectionId: string, search: string) => {
|
|
const qs = new URLSearchParams();
|
|
if (collectionId !== ALL) qs.set('collectionId', collectionId);
|
|
if (search.trim()) qs.set('searchQueryString', search.trim());
|
|
|
|
try {
|
|
const body = await clientRef.current.get<{ response: Link[] }>(
|
|
`/linkwarden/api/v1/links${qs.toString() ? `?${qs}` : ''}`,
|
|
);
|
|
setRows(body.response ?? []);
|
|
setError(null);
|
|
} catch (failure) {
|
|
setError(failure instanceof Error ? failure.message : 'Could not read your links.');
|
|
setRows(null);
|
|
}
|
|
}, []);
|
|
|
|
/*
|
|
Debounced, because this types straight into a request.
|
|
|
|
250 ms is chosen against what it is: somebody typing a word, who wants the list to move while they
|
|
type rather than after they stop. Long enough that a five-letter word is one query instead of five.
|
|
*/
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => void load(collection, query), 250);
|
|
return () => clearTimeout(timer);
|
|
}, [collection, query, load]);
|
|
|
|
return (
|
|
<div className="flex size-full flex-col">
|
|
<header className="border-b p-2">
|
|
<Input
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="Search names, tags and the text of saved pages…"
|
|
className="h-8 text-xs"
|
|
/>
|
|
</header>
|
|
|
|
{error && (
|
|
<p className="px-3 py-2 text-xs text-destructive">
|
|
{error}
|
|
{/* The state every user starts in, said as an instruction rather than a failure. */}
|
|
{error.includes('no Linkwarden account') && ' — the owner maps accounts in Settings.'}
|
|
</p>
|
|
)}
|
|
|
|
{rows === null && !error ? (
|
|
<Centred>Loading…</Centred>
|
|
) : rows && rows.length === 0 ? (
|
|
<Centred>{query.trim() ? `Nothing matches “${query.trim()}”.` : 'Nothing saved here yet.'}</Centred>
|
|
) : (
|
|
<ul className="flex-1 divide-y overflow-auto">
|
|
{rows?.map((link) => (
|
|
<li key={link.id} className="group px-3 py-2 hover:bg-muted/40">
|
|
<div className="flex items-start gap-2">
|
|
<FileText className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
|
<div className="min-w-0 flex-1">
|
|
<a
|
|
href={link.url}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
className="flex items-center gap-1 text-xs hover:underline"
|
|
>
|
|
<span className="min-w-0 truncate">{link.name || link.url}</span>
|
|
<ExternalLink className="size-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-60" />
|
|
</a>
|
|
<p className="truncate text-[11px] text-muted-foreground">{link.url}</p>
|
|
|
|
{(link.collection || link.tags?.length) && (
|
|
<p className="mt-0.5 flex flex-wrap items-center gap-1 text-[10px] text-muted-foreground">
|
|
{link.collection && <span>{link.collection.name}</span>}
|
|
{link.tags?.map((tag) => (
|
|
<span key={tag.id} className="rounded bg-muted px-1 py-px">
|
|
{tag.name}
|
|
</span>
|
|
))}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Centred = ({ children }: { children: React.ReactNode }) => (
|
|
<div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-muted-foreground">
|
|
{children}
|
|
</div>
|
|
);
|