diff --git a/src/components/LinkSearchBar.tsx b/src/components/LinkSearchBar.tsx new file mode 100644 index 0000000..26586c7 --- /dev/null +++ b/src/components/LinkSearchBar.tsx @@ -0,0 +1,343 @@ +import { SearchIcon, XIcon } from 'lucide-react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, +} from 'react'; +import { type Link, type LinkScope, listPrivateLinks, listPublicLinks } from '../lib/api'; +import { safeLinkTargetUrl } from '../lib/url'; + +const DEBOUNCE_MS = 220; +const MAX_RESULTS = 12; +const MIN_QUERY_LENGTH = 1; + +interface LinkSearchBarProps { + readonly scope: LinkScope; + readonly placeholder?: string; + readonly onSelect?: (link: Link) => void; +} + +interface SearchState { + readonly links: Link[]; + readonly loading: boolean; + readonly error: string | null; + readonly query: string; +} + +const INITIAL_STATE: SearchState = { + links: [], + loading: false, + error: null, + query: '', +}; + +function isExactMatch(link: Link, query: string): boolean { + return link.alias.toLowerCase() === query.trim().toLowerCase(); +} + +function navigateToLinkDetail(link: Pick) { + window.location.hash = `/links/${link.scope}/${encodeURIComponent(link.id)}`; +} + +export default function LinkSearchBar({ + scope, + placeholder = 'Search by alias…', + onSelect, +}: LinkSearchBarProps) { + const [inputValue, setInputValue] = useState(''); + const [state, setState] = useState(INITIAL_STATE); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + + const containerRef = useRef(null); + const inputRef = useRef(null); + const debounceRef = useRef | null>(null); + const requestIdRef = useRef(0); + + const trimmedQuery = inputValue.trim(); + + const fetchResults = useCallback( + async (query: string) => { + const requestId = ++requestIdRef.current; + setState((prev) => ({ ...prev, loading: true, error: null, query })); + try { + const result = + scope === 'private' + ? await listPrivateLinks(query) + : await listPublicLinks(query); + if (requestId !== requestIdRef.current) return; + const links = (result.links ?? []).slice(0, MAX_RESULTS); + setState({ links, loading: false, error: null, query }); + setOpen(true); + setActiveIndex(links.length > 0 ? 0 : -1); + } catch (err) { + if (requestId !== requestIdRef.current) return; + setState({ + links: [], + loading: false, + error: err instanceof Error ? err.message : 'Search failed', + query, + }); + setOpen(true); + setActiveIndex(-1); + } + }, + [scope], + ); + + useEffect(() => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + + if (trimmedQuery.length < MIN_QUERY_LENGTH) { + setState(INITIAL_STATE); + setOpen(false); + setActiveIndex(-1); + return; + } + + debounceRef.current = setTimeout(() => { + void fetchResults(trimmedQuery); + }, DEBOUNCE_MS); + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + }; + }, [trimmedQuery, fetchResults]); + + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + requestIdRef.current++; + }; + }, []); + + useEffect(() => { + function handleClickOutside(event: Event) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setOpen(false); + } + } + function handleFocus() { + if (state.links.length > 0 && trimmedQuery.length >= MIN_QUERY_LENGTH) { + setOpen(true); + } + } + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('touchstart', handleClickOutside); + inputRef.current?.addEventListener('focus', handleFocus); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('touchstart', handleClickOutside); + inputRef.current?.removeEventListener('focus', handleFocus); + }; + }, [state.links.length, trimmedQuery.length]); + + const showDropdown = open && trimmedQuery.length >= MIN_QUERY_LENGTH; + + const results = useMemo(() => state.links, [state.links]); + + function handleSelect(link: Link) { + setInputValue(''); + setState(INITIAL_STATE); + setOpen(false); + setActiveIndex(-1); + inputRef.current?.blur(); + if (onSelect) { + onSelect(link); + } else { + navigateToLinkDetail(link); + } + } + + function handleKeyDown(event: ReactKeyboardEvent) { + if (!showDropdown) { + if (event.key === 'ArrowDown' && results.length > 0) { + setOpen(true); + setActiveIndex(0); + event.preventDefault(); + } + return; + } + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + if (results.length > 0) { + setActiveIndex((prev) => (prev + 1) % results.length); + } + break; + case 'ArrowUp': + event.preventDefault(); + if (results.length > 0) { + setActiveIndex((prev) => (prev <= 0 ? results.length - 1 : prev - 1)); + } + break; + case 'Enter': + event.preventDefault(); + if (activeIndex >= 0 && activeIndex < results.length) { + handleSelect(results[activeIndex]); + } + break; + case 'Escape': + event.preventDefault(); + setOpen(false); + setActiveIndex(-1); + inputRef.current?.blur(); + break; + case 'Tab': + setOpen(false); + break; + } + } + + function clearSearch() { + setInputValue(''); + setState(INITIAL_STATE); + setOpen(false); + setActiveIndex(-1); + inputRef.current?.focus(); + } + + const hasInput = inputValue.length > 0; + const showNoResults = + showDropdown && !state.loading && !state.error && results.length === 0; + const showError = showDropdown && state.error != null; + const showLoading = showDropdown && state.loading; + const showResults = showDropdown && !state.loading && !state.error && results.length > 0; + + return ( +
+
+
+ + {showDropdown ? ( + + ) : null} +
+ ); +} diff --git a/src/components/LinkTable.tsx b/src/components/LinkTable.tsx index 832b9f1..acae1bc 100644 --- a/src/components/LinkTable.tsx +++ b/src/components/LinkTable.tsx @@ -109,7 +109,7 @@ export default function LinkTable({ onDelete, actions = true, }: LinkTableProps) { - const [sortField, setSortField] = useState('updatedAt'); + const [sortField, setSortField] = useState('clickCount'); const [sortDir, setSortDir] = useState('desc'); const [viewStyle, setViewStyle] = useState(() => readViewStyleFromHash()); const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); diff --git a/src/lib/api.ts b/src/lib/api.ts index 7841861..9e80170 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -104,8 +104,10 @@ function jsonBody(input: LinkInput): string { // ---- Private links (owner-scoped) ---- -export function listPrivateLinks(): Promise { - return request('/api/links/private'); +export function listPrivateLinks(query?: string): Promise { + const trimmed = query?.trim(); + const search = trimmed ? `?q=${encodeURIComponent(trimmed)}` : ''; + return request(`/api/links/private${search}`); } export function getPrivateLink(id: string): Promise { @@ -132,8 +134,10 @@ export function deletePrivateLink(id: string): Promise { // ---- Public directory (read-only for normal users) ---- -export function listPublicLinks(): Promise { - return request('/api/links/public'); +export function listPublicLinks(query?: string): Promise { + const trimmed = query?.trim(); + const search = trimmed ? `?q=${encodeURIComponent(trimmed)}` : ''; + return request(`/api/links/public${search}`); } export function getPublicLink(id: string): Promise { diff --git a/src/routes/PrivateLinksPage.tsx b/src/routes/PrivateLinksPage.tsx index 407b6dc..ced719a 100644 --- a/src/routes/PrivateLinksPage.tsx +++ b/src/routes/PrivateLinksPage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { InboxIcon, PlusIcon } from 'lucide-react'; import LinkForm from '../components/LinkForm'; +import LinkSearchBar from '../components/LinkSearchBar'; import LinkTable from '../components/LinkTable'; import MySubmissionsList from '../components/MySubmissionsList'; import PromotionDialog from '../components/PromotionDialog'; @@ -212,6 +213,8 @@ export default function PrivateLinksPage() { ) : null} + + + + { expect(calls[0].path).toBe('/api/links/public'); }); + it('listPublicLinks appends q query parameter when search is provided', async () => { + const { calls } = mockFetch(() => ({ body: { links: [] } })); + + await listPublicLinks('opencode'); + + expect(calls[0].path).toBe('/api/links/public?q=opencode'); + }); + + it('listPublicLinks encodes special characters in the search query', async () => { + const { calls } = mockFetch(() => ({ body: { links: [] } })); + + await listPublicLinks('go links'); + + expect(calls[0].path).toBe('/api/links/public?q=go%20links'); + }); + + it('listPublicLinks ignores blank/whitespace-only queries', async () => { + const { calls } = mockFetch(() => ({ body: { links: [] } })); + + await listPublicLinks(' '); + + expect(calls[0].path).toBe('/api/links/public'); + }); + + it('listPrivateLinks appends q query parameter when search is provided', async () => { + const { calls } = mockFetch(() => ({ body: { links: [] } })); + + await listPrivateLinks('docs'); + + expect(calls[0].path).toBe('/api/links/private?q=docs'); + }); + + it('listPrivateLinks ignores blank/whitespace-only queries', async () => { + const { calls } = mockFetch(() => ({ body: { links: [] } })); + + await listPrivateLinks(' '); + + expect(calls[0].path).toBe('/api/links/private'); + }); + it('public detail endpoints use the readable /api/links/public routes', async () => { const { calls } = mockFetch(() => ({ body: { link: {}, stats: [], history: [] } })); diff --git a/tests/api.links.test.ts b/tests/api.links.test.ts index d810d8f..e8b9399 100644 --- a/tests/api.links.test.ts +++ b/tests/api.links.test.ts @@ -66,15 +66,30 @@ class FakeD1Database { return this.sessions.find((candidate) => candidate.session_token_hash === hash) ?? null; } - listLinks(scope: LinkScope, ownerUserId?: string): LinkRow[] { + listLinks(scope: LinkScope, ownerUserId?: string, search?: string): LinkRow[] { + const term = search?.toLowerCase(); return this.links .filter((link) => { if (link.scope !== scope || link.status !== 'active') { return false; } - return scope === 'public' ? true : link.owner_user_id === ownerUserId; + if (scope === 'private' && link.owner_user_id !== ownerUserId) { + return false; + } + if (term && !link.alias.toLowerCase().includes(term)) { + return false; + } + return true; }) - .sort((a, b) => b.updated_at.localeCompare(a.updated_at)); + .sort((a, b) => { + if (term) { + const aExact = a.alias.toLowerCase() === term ? 0 : 1; + const bExact = b.alias.toLowerCase() === term ? 0 : 1; + if (aExact !== bExact) return aExact - bExact; + } + if (a.click_count !== b.click_count) return b.click_count - a.click_count; + return b.updated_at.localeCompare(a.updated_at); + }); } findDuplicate( @@ -191,13 +206,23 @@ class FakeD1PreparedStatement { } async all(): Promise> { + const isSearch = this.sql.includes(' LIKE '); + let searchTerm: string | undefined; + if (isSearch) { + if (this.sql.includes("scope='public'")) { + searchTerm = String(this.params[1]); + } else { + searchTerm = String(this.params[2]); + } + } + if (this.sql.includes("scope='public'")) { - return { results: this.db.listLinks('public').map(rowToDbResult) as T[], success: true, meta: {} }; + return { results: this.db.listLinks('public', undefined, searchTerm).map(rowToDbResult) as T[], success: true, meta: {} }; } if (this.sql.includes("scope='private'")) { return { - results: this.db.listLinks('private', String(this.params[0])).map(rowToDbResult) as T[], + results: this.db.listLinks('private', String(this.params[0]), searchTerm).map(rowToDbResult) as T[], success: true, meta: {}, }; @@ -779,3 +804,112 @@ describe('link CRUD API', () => { await expect(expectJson(missingTarget.response)).resolves.toHaveProperty('error'); }); }); + +describe('link list ordering and search', () => { + it('lists public links sorted by click_count desc then updated_at desc', async () => { + const { response } = await fetchWorker('/api/links/public', { + links: [ + link({ id: 'low', scope: 'public', owner_user_id: null, alias: 'low', click_count: 5, updated_at: '2026-06-20T00:00:03.000Z' }), + link({ id: 'high', scope: 'public', owner_user_id: null, alias: 'high', click_count: 100, updated_at: '2026-06-20T00:00:01.000Z' }), + link({ id: 'mid', scope: 'public', owner_user_id: null, alias: 'mid', click_count: 50, updated_at: '2026-06-20T00:00:02.000Z' }), + ], + }); + + expect(response.status).toBe(200); + const body = await expectJson(response); + expect(body.links.map((item: { id: string }) => item.id)).toEqual(['high', 'mid', 'low']); + }); + + it('lists private links sorted by click_count desc', async () => { + const session = await userSession('token-a', 'user_1'); + const { response } = await fetchWorker('/api/links/private', { + cookie: cookie('token-a'), + sessions: [session], + links: [ + link({ id: 'few', scope: 'private', owner_user_id: 'user_1', alias: 'few', click_count: 2 }), + link({ id: 'many', scope: 'private', owner_user_id: 'user_1', alias: 'many', click_count: 80 }), + ], + }); + + expect(response.status).toBe(200); + const body = await expectJson(response); + expect(body.links.map((item: { id: string }) => item.id)).toEqual(['many', 'few']); + }); + + it('searches public links by alias contains and pins exact match at top', async () => { + const { response } = await fetchWorker('/api/links/public?q=op', { + links: [ + link({ id: 'popular_contains', scope: 'public', owner_user_id: null, alias: 'opencode', click_count: 500 }), + link({ id: 'exact_op', scope: 'public', owner_user_id: null, alias: 'op', click_count: 10 }), + link({ id: 'other_contains', scope: 'public', owner_user_id: null, alias: 'open-shop', click_count: 200 }), + link({ id: 'unrelated', scope: 'public', owner_user_id: null, alias: 'docs', click_count: 999 }), + ], + }); + + expect(response.status).toBe(200); + const body = await expectJson(response); + const ids = body.links.map((item: { id: string }) => item.id); + expect(ids).toEqual(['exact_op', 'popular_contains', 'other_contains']); + expect(ids).not.toContain('unrelated'); + }); + + it('search is case-insensitive on the query parameter', async () => { + const { response } = await fetchWorker('/api/links/public?q=OPEN', { + links: [ + link({ id: 'exact', scope: 'public', owner_user_id: null, alias: 'open', click_count: 1 }), + link({ id: 'contains', scope: 'public', owner_user_id: null, alias: 'opencode', click_count: 100 }), + ], + }); + + expect(response.status).toBe(200); + const body = await expectJson(response); + const ids = body.links.map((item: { id: string }) => item.id); + expect(ids).toEqual(['exact', 'contains']); + }); + + it('searches private links by alias contains for the current user only', async () => { + const session = await userSession('token-a', 'user_1'); + const { response } = await fetchWorker('/api/links/private?q=doc', { + cookie: cookie('token-a'), + sessions: [session], + links: [ + link({ id: 'mine_exact', scope: 'private', owner_user_id: 'user_1', alias: 'doc', click_count: 3 }), + link({ id: 'mine_contains', scope: 'private', owner_user_id: 'user_1', alias: 'docs', click_count: 30 }), + link({ id: 'theirs', scope: 'private', owner_user_id: 'user_2', alias: 'docs', click_count: 999 }), + link({ id: 'unrelated', scope: 'private', owner_user_id: 'user_1', alias: 'blog', click_count: 50 }), + ], + }); + + expect(response.status).toBe(200); + const body = await expectJson(response); + const ids = body.links.map((item: { id: string }) => item.id); + expect(ids).toEqual(['mine_exact', 'mine_contains']); + expect(ids).not.toContain('theirs'); + expect(ids).not.toContain('unrelated'); + }); + + it('returns empty results for a query matching no aliases', async () => { + const { response } = await fetchWorker('/api/links/public?q=nonexistent', { + links: [ + link({ id: 'pub', scope: 'public', owner_user_id: null, alias: 'docs' }), + ], + }); + + expect(response.status).toBe(200); + const body = await expectJson(response); + expect(body.links).toEqual([]); + }); + + it('treats a blank query as no search (returns all links sorted by clicks)', async () => { + const { response } = await fetchWorker('/api/links/public?q=%20%20', { + links: [ + link({ id: 'few', scope: 'public', owner_user_id: null, alias: 'few', click_count: 1 }), + link({ id: 'many', scope: 'public', owner_user_id: null, alias: 'many', click_count: 99 }), + ], + }); + + expect(response.status).toBe(200); + const body = await expectJson(response); + expect(body.links.map((item: { id: string }) => item.id)).toEqual(['many', 'few']); + }); +}); diff --git a/worker/routes/api.links.ts b/worker/routes/api.links.ts index a1fefb2..c9aa7ca 100644 --- a/worker/routes/api.links.ts +++ b/worker/routes/api.links.ts @@ -50,12 +50,22 @@ const LINK_COLUMNS = `id, alias, scope, link_type, target_url, content_markdown, const PRIVATE_LINK_LIST_QUERY = `SELECT ${LINK_COLUMNS} FROM links WHERE scope='private' AND status='active' AND owner_user_id=? -ORDER BY updated_at DESC`; +ORDER BY click_count DESC, updated_at DESC`; + +const PRIVATE_LINK_SEARCH_QUERY = `SELECT ${LINK_COLUMNS} +FROM links +WHERE scope='private' AND status='active' AND owner_user_id=? AND alias LIKE ? ESCAPE '\\' +ORDER BY CASE WHEN alias = ? COLLATE NOCASE THEN 0 ELSE 1 END, click_count DESC, updated_at DESC`; const PUBLIC_LINK_LIST_QUERY = `SELECT ${LINK_COLUMNS} FROM links WHERE scope='public' AND status='active' -ORDER BY updated_at DESC`; +ORDER BY click_count DESC, updated_at DESC`; + +const PUBLIC_LINK_SEARCH_QUERY = `SELECT ${LINK_COLUMNS} +FROM links +WHERE scope='public' AND status='active' AND alias LIKE ? ESCAPE '\\' +ORDER BY CASE WHEN alias = ? COLLATE NOCASE THEN 0 ELSE 1 END, click_count DESC, updated_at DESC`; const LINK_BY_ID_PRIVATE_QUERY = `SELECT ${LINK_COLUMNS} FROM links @@ -177,7 +187,7 @@ export async function handleLinksApi(request: Request, env: Env): Promise { const user = await requireUser(request, env); + const url = new URL(request.url); + const query = url.searchParams.get('q') ?? ''; + const trimmed = query.trim(); + if (trimmed) { + const escaped = escapeLikePattern(trimmed.toLowerCase()); + const result = await env.DB.prepare(PRIVATE_LINK_SEARCH_QUERY) + .bind(user.id, `%${escaped}%`, trimmed.toLowerCase()) + .all(); + return json({ links: (result.results ?? []).map(toLinkJson) }); + } const result = await env.DB.prepare(PRIVATE_LINK_LIST_QUERY).bind(user.id).all(); return json({ links: (result.results ?? []).map(toLinkJson) }); } @@ -273,7 +293,17 @@ async function getPrivateLink(request: Request, env: Env, id: string): Promise { +async function listPublicLinks(request: Request, env: Env): Promise { + const url = new URL(request.url); + const query = url.searchParams.get('q') ?? ''; + const trimmed = query.trim(); + if (trimmed) { + const escaped = escapeLikePattern(trimmed.toLowerCase()); + const result = await env.DB.prepare(PUBLIC_LINK_SEARCH_QUERY) + .bind(`%${escaped}%`, trimmed.toLowerCase()) + .all(); + return json({ links: (result.results ?? []).map(toLinkJson) }); + } const result = await env.DB.prepare(PUBLIC_LINK_LIST_QUERY).all(); return json({ links: (result.results ?? []).map(toLinkJson) }); } @@ -555,6 +585,10 @@ function isHttpUrl(value: string): boolean { } } +function escapeLikePattern(pattern: string): string { + return pattern.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_'); +} + class RequestValidationError extends Error {} function toLinkJson(row: LinkRow) {