Add link search

This commit is contained in:
2026-06-20 22:08:52 +10:00
parent a3ba6dd395
commit b6212d8b3e
9 changed files with 780 additions and 14 deletions
+343
View File
@@ -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<Link, 'id' | 'scope'>) {
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<SearchState>(INITIAL_STATE);
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLInputElement>) {
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 (
<div className="link-search" ref={containerRef}>
<div className="link-search__input-wrapper">
<SearchIcon
className="link-search__icon"
aria-hidden="true"
size={18}
strokeWidth={2.2}
/>
<input
ref={inputRef}
type="search"
className="link-search__input"
placeholder={placeholder}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
enterKeyHint="go"
aria-label="Search links by alias"
aria-expanded={showDropdown}
aria-autocomplete="list"
aria-controls="link-search-results"
role="combobox"
/>
{hasInput ? (
<button
type="button"
className="link-search__clear"
onClick={clearSearch}
aria-label="Clear search"
tabIndex={-1}
>
<XIcon aria-hidden="true" size={16} strokeWidth={2.5} />
</button>
) : null}
</div>
{showDropdown ? (
<div
id="link-search-results"
className="link-search__dropdown"
role="listbox"
>
{showLoading ? (
<div className="link-search__status">
<span className="link-search__spinner" aria-hidden="true" />
<span>Searching</span>
</div>
) : null}
{showError ? (
<div className="link-search__status link-search__status--error" role="alert">
{state.error}
</div>
) : null}
{showNoResults ? (
<div className="link-search__status">
No links matching <code>{trimmedQuery}</code>
</div>
) : null}
{showResults
? results.map((link, index) => {
const exact = isExactMatch(link, trimmedQuery);
const isActive = index === activeIndex;
const shortLinkHref = `/links/${encodeURIComponent(link.id)}/go`;
const safeUrl =
link.linkType === 'redirect' ? safeLinkTargetUrl(link.targetUrl) : null;
return (
<div
key={link.id}
role="option"
aria-selected={isActive}
className={`link-search__result${isActive ? ' is-active' : ''}${
exact ? ' is-exact' : ''
}`}
onClick={() => handleSelect(link)}
onMouseEnter={() => setActiveIndex(index)}
>
<div className="link-search__result-main">
<a
href={shortLinkHref}
target="_blank"
rel="noreferrer noopener"
className="alias-pill link-search__result-alias"
onClick={(e) => e.stopPropagation()}
>
/{link.alias}
</a>
{exact ? (
<span className="link-search__exact-badge">Exact match</span>
) : null}
</div>
{link.linkType === 'redirect' && link.targetUrl ? (
<p className="link-search__result-url">
{safeUrl ? (
<span className="truncate" title={link.targetUrl}>
{link.targetUrl}
</span>
) : (
<span className="muted">Invalid target</span>
)}
</p>
) : link.linkType === 'custom' ? (
<p className="link-search__result-url">
<span className="muted">Custom page</span>
</p>
) : null}
<div className="link-search__result-meta">
<span className="link-search__clicks">
{link.clickCount.toLocaleString()} click{link.clickCount !== 1 ? 's' : ''}
</span>
</div>
</div>
);
})
: null}
</div>
) : null}
</div>
);
}
+1 -1
View File
@@ -109,7 +109,7 @@ export default function LinkTable({
onDelete,
actions = true,
}: LinkTableProps) {
const [sortField, setSortField] = useState<SortField>('updatedAt');
const [sortField, setSortField] = useState<SortField>('clickCount');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const [viewStyle, setViewStyle] = useState<ViewStyle>(() => readViewStyleFromHash());
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
+8 -4
View File
@@ -104,8 +104,10 @@ function jsonBody(input: LinkInput): string {
// ---- Private links (owner-scoped) ----
export function listPrivateLinks(): Promise<LinkListResponse> {
return request<LinkListResponse>('/api/links/private');
export function listPrivateLinks(query?: string): Promise<LinkListResponse> {
const trimmed = query?.trim();
const search = trimmed ? `?q=${encodeURIComponent(trimmed)}` : '';
return request<LinkListResponse>(`/api/links/private${search}`);
}
export function getPrivateLink(id: string): Promise<LinkMutationResponse> {
@@ -132,8 +134,10 @@ export function deletePrivateLink(id: string): Promise<DeleteResponse> {
// ---- Public directory (read-only for normal users) ----
export function listPublicLinks(): Promise<LinkListResponse> {
return request<LinkListResponse>('/api/links/public');
export function listPublicLinks(query?: string): Promise<LinkListResponse> {
const trimmed = query?.trim();
const search = trimmed ? `?q=${encodeURIComponent(trimmed)}` : '';
return request<LinkListResponse>(`/api/links/public${search}`);
}
export function getPublicLink(id: string): Promise<LinkMutationResponse> {
+3
View File
@@ -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() {
</div>
) : null}
<LinkSearchBar scope="private" placeholder="Search my links by alias…" />
<LinkTable
links={links}
loading={loading}
+3
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import LinkSearchBar from '../components/LinkSearchBar';
import LinkTable from '../components/LinkTable';
import { type Link, listPublicLinks } from '../lib/api';
@@ -33,6 +34,8 @@ export default function PublicLinksPage() {
</div>
</header>
<LinkSearchBar scope="public" placeholder="Search public links by alias…" />
<LinkTable
links={links}
loading={loading}
+205
View File
@@ -633,6 +633,190 @@ button.link-action--confirm:hover { background: var(--accent-soft); }
Tables
═══════════════════════════════════════════ */
/* Search bar above table/cards */
.link-search {
position: relative;
margin-bottom: 1rem;
width: 100%;
}
.link-search__input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.link-search__icon {
position: absolute;
left: 0.85rem;
color: var(--muted);
pointer-events: none;
flex-shrink: 0;
}
.link-search__input {
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
color: var(--text);
font: inherit;
font-size: 0.9375rem;
padding: 0.625rem 2.5rem 0.625rem 2.5rem;
width: 100%;
transition: border-color 0.15s, box-shadow 0.15s;
-webkit-appearance: none;
appearance: none;
}
.link-search__input::-webkit-search-cancel-button,
.link-search__input::-webkit-search-decoration {
-webkit-appearance: none;
appearance: none;
}
.link-search__input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
outline: none;
}
.link-search__input::placeholder {
color: var(--muted);
}
.link-search__clear {
position: absolute;
right: 0.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 999px;
color: var(--muted);
cursor: pointer;
padding: 0.3rem;
line-height: 1;
transition: background 0.1s, color 0.1s;
}
.link-search__clear:hover {
background: var(--background);
color: var(--text);
}
.link-search__dropdown {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
right: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: 0 12px 36px rgba(15, 23, 42, 0.14), 0 2px 8px rgba(15, 23, 42, 0.06);
max-height: min(60vh, 26rem);
overflow-y: auto;
z-index: 30;
padding: 0.3rem;
animation: link-search-fade-in 0.12s ease-out;
}
@keyframes link-search-fade-in {
from { opacity: 0; transform: translateY(-0.25rem); }
to { opacity: 1; transform: translateY(0); }
}
.link-search__status {
align-items: center;
color: var(--muted);
display: flex;
font-size: 0.875rem;
gap: 0.5rem;
padding: 0.875rem 0.75rem;
}
.link-search__status code {
font-size: 0.8125em;
}
.link-search__status--error {
color: var(--danger);
}
.link-search__spinner {
border: 2px solid var(--border);
border-radius: 50%;
border-top-color: var(--accent);
width: 14px;
height: 14px;
animation: link-search-spin 0.7s linear infinite;
}
@keyframes link-search-spin {
to { transform: rotate(360deg); }
}
.link-search__result {
border-radius: var(--radius-xs);
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.625rem 0.75rem;
transition: background 0.1s;
}
.link-search__result.is-active,
.link-search__result:hover {
background: var(--accent-soft);
}
.link-search__result.is-exact {
border-left: 3px solid var(--accent);
padding-left: calc(0.75rem - 3px);
}
.link-search__result-main {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.link-search__result-alias {
font-size: 0.875em;
}
.link-search__exact-badge {
background: var(--accent);
border-radius: 999px;
color: white;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.03em;
padding: 0.15rem 0.5rem;
text-transform: uppercase;
white-space: nowrap;
}
.link-search__result-url {
color: var(--muted);
font-size: 0.78rem;
margin: 0;
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link-search__result-url .truncate {
display: inline-block;
max-width: 100%;
}
.link-search__result-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.72rem;
}
.link-search__clicks {
color: var(--accent-text);
font-weight: 600;
}
/* Toolbar above table/cards */
.link-table-toolbar {
display: flex;
@@ -1809,4 +1993,25 @@ button.link-action--confirm:hover { background: var(--accent-soft); }
.detail-actions__notice {
align-self: flex-start;
}
/* Search bar — mobile-friendly sizing and dropdown */
.link-search__input {
font-size: 1rem;
padding: 0.7rem 2.5rem 0.7rem 2.5rem;
min-height: 44px;
}
.link-search__icon {
left: 0.8rem;
}
.link-search__dropdown {
max-height: 50vh;
border-radius: var(--radius-sm);
}
.link-search__result {
padding: 0.7rem 0.75rem;
min-height: 44px;
}
.link-search__result-url .truncate {
max-width: 100%;
}
}
+40
View File
@@ -160,6 +160,46 @@ describe('api client', () => {
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: [] } }));
+139 -5
View File
@@ -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<T>(): Promise<AllResult<T>> {
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']);
});
});
+38 -4
View File
@@ -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<Respon
if (pathname === '/api/links/public') {
if (request.method === 'GET') {
return await listPublicLinks(env);
return await listPublicLinks(request, env);
}
return methodNotAllowed();
}
@@ -260,6 +270,16 @@ export async function handleLinksApi(request: Request, env: Env): Promise<Respon
async function listPrivateLinks(request: Request, env: Env): Promise<Response> {
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<LinkRow>();
return json({ links: (result.results ?? []).map(toLinkJson) });
}
const result = await env.DB.prepare(PRIVATE_LINK_LIST_QUERY).bind(user.id).all<LinkRow>();
return json({ links: (result.results ?? []).map(toLinkJson) });
}
@@ -273,7 +293,17 @@ async function getPrivateLink(request: Request, env: Env, id: string): Promise<R
return json({ link: toLinkJson(link) });
}
async function listPublicLinks(env: Env): Promise<Response> {
async function listPublicLinks(request: Request, env: Env): Promise<Response> {
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<LinkRow>();
return json({ links: (result.results ?? []).map(toLinkJson) });
}
const result = await env.DB.prepare(PUBLIC_LINK_LIST_QUERY).all<LinkRow>();
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) {