mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-09 05:15:52 +10:00
Add link search
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user