mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-09 05:15:52 +10:00
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
} from 'react';
|
|
import { ArrowRight, Plus, Search, X } from 'lucide-react';
|
|
import { type Link, listPublicLinks } from '../lib/api';
|
|
import { safeLinkTargetUrl } from '../lib/url';
|
|
import { useCurrentUser } from '../lib/auth';
|
|
import { useTranslation, Trans } from 'react-i18next';
|
|
|
|
const DEBOUNCE_MS = 220;
|
|
const MAX_RESULTS = 8;
|
|
const MIN_QUERY_LENGTH = 1;
|
|
|
|
const SUGGESTIONS = ['canva', 'piano', 'claude'] as const;
|
|
|
|
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();
|
|
}
|
|
|
|
export default function LandingHero() {
|
|
const { t } = useTranslation();
|
|
const { user } = useCurrentUser();
|
|
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 = 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(-1);
|
|
} catch (err) {
|
|
if (requestId !== requestIdRef.current) return;
|
|
setState({
|
|
links: [],
|
|
loading: false,
|
|
error: err instanceof Error ? err.message : t('landing.searchFail'),
|
|
query,
|
|
});
|
|
setOpen(true);
|
|
setActiveIndex(-1);
|
|
}
|
|
}, [t]);
|
|
|
|
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);
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
document.addEventListener('touchstart', handleClickOutside);
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
document.removeEventListener('touchstart', handleClickOutside);
|
|
};
|
|
}, []);
|
|
|
|
const results = useMemo(() => state.links, [state.links]);
|
|
const exactMatch = useMemo(
|
|
() => results.find((link) => isExactMatch(link, trimmedQuery)) ?? null,
|
|
[results, trimmedQuery],
|
|
);
|
|
|
|
const showDropdown = open && trimmedQuery.length >= MIN_QUERY_LENGTH;
|
|
const showLoading = showDropdown && state.loading;
|
|
const showError = showDropdown && !!state.error;
|
|
const showNoResults = showDropdown && !state.loading && !state.error && results.length === 0;
|
|
const showResults = showDropdown && !state.loading && !state.error && results.length > 0;
|
|
|
|
function goToShortlink(alias: string) {
|
|
window.location.href = `/${alias}`;
|
|
}
|
|
|
|
function handleCreate() {
|
|
// 未登录时 #/my-links 自动渲染登录页;登录后路由停留在 my-links(即"我的链接/创建链接"页)
|
|
// 携带 create 提示参数,供未来扩展自动展开创建表单
|
|
const sep = window.location.hash.includes('?') ? '&' : '?';
|
|
window.location.hash = `#/my-links${sep}create=1`;
|
|
}
|
|
|
|
function handlePrimaryAction() {
|
|
if (exactMatch) {
|
|
goToShortlink(exactMatch.alias);
|
|
} else if (user) {
|
|
// 已登录但当前在公开着陆页 → 直接进入我的链接页
|
|
window.location.hash = '#/my-links';
|
|
} else if (trimmedQuery) {
|
|
handleCreate();
|
|
}
|
|
}
|
|
|
|
function handleSelect(link: Link) {
|
|
goToShortlink(link.alias);
|
|
}
|
|
|
|
function handleKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
if (showDropdown && activeIndex >= 0 && activeIndex < results.length) {
|
|
handleSelect(results[activeIndex]);
|
|
} else {
|
|
handlePrimaryAction();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!showDropdown || results.length === 0) return;
|
|
|
|
switch (event.key) {
|
|
case 'ArrowDown':
|
|
event.preventDefault();
|
|
setOpen(true);
|
|
setActiveIndex((prev) => (prev + 1) % results.length);
|
|
break;
|
|
case 'ArrowUp':
|
|
event.preventDefault();
|
|
setActiveIndex((prev) => (prev <= 0 ? results.length - 1 : prev - 1));
|
|
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();
|
|
}
|
|
|
|
function pickSuggestion(alias: string) {
|
|
setInputValue(alias);
|
|
inputRef.current?.focus();
|
|
}
|
|
|
|
const hasQuery = trimmedQuery.length > 0;
|
|
const canCreate = hasQuery && !exactMatch && !state.loading;
|
|
const primaryLabel = exactMatch ? t('landing.goLabel') : canCreate ? t('landing.createPersonalLink') : t('landing.goLabel');
|
|
const primaryDisabled = !exactMatch && !canCreate;
|
|
const primaryVariant = exactMatch ? 'is-go' : canCreate ? 'is-create' : 'is-idle';
|
|
|
|
return (
|
|
<section className="landing-hero" ref={containerRef}>
|
|
<div className="hero-aurora" aria-hidden="true" />
|
|
<div className="hero-grid" aria-hidden="true" />
|
|
|
|
<div className="hero-content">
|
|
<div className="hero-eyebrow">
|
|
<span className="hero-eyebrow__dot" aria-hidden="true" />
|
|
<span>{t('landing.eyebrow')}</span>
|
|
</div>
|
|
|
|
<h1 className="hero-title">
|
|
<span className="hero-brand">Heygo</span>
|
|
<span className="hero-suffix">{t('landing.brandSuffix')}</span>
|
|
</h1>
|
|
|
|
<p className="hero-description">
|
|
<Trans i18nKey="landing.description" components={{ 1: <br /> }} />
|
|
</p>
|
|
|
|
<div className="hero-features" aria-hidden="true">
|
|
<div className="feature-pill"><span className="feature-dot" />{t('landing.featurePublic')}</div>
|
|
<div className="feature-pill"><span className="feature-dot" />{t('landing.featurePrivate')}</div>
|
|
<div className="feature-pill"><span className="feature-dot" />{t('landing.featureShared')}</div>
|
|
<div className="feature-pill"><span className="feature-dot" />{t('landing.featureCrossPlatform')}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`hero-search${showDropdown ? ' is-open' : ''}`}>
|
|
<div className="hero-search__shell">
|
|
<div className="hero-search__prefix">
|
|
<Search className="hero-search__prefix-icon" size={16} strokeWidth={2.4} aria-hidden="true" />
|
|
<span className="hero-search__prefix-text">{t('landing.searchPrefix')}</span>
|
|
</div>
|
|
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
className="hero-search__field"
|
|
placeholder={t('landing.searchPlaceholder')}
|
|
value={inputValue}
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
onFocus={() => {
|
|
if (results.length > 0 && trimmedQuery.length >= MIN_QUERY_LENGTH) setOpen(true);
|
|
}}
|
|
autoComplete="off"
|
|
autoCorrect="off"
|
|
autoCapitalize="off"
|
|
spellCheck={false}
|
|
enterKeyHint="go"
|
|
aria-label={t('landing.searchLabel')}
|
|
aria-expanded={showDropdown}
|
|
aria-autocomplete="list"
|
|
aria-controls="hero-search-results"
|
|
role="combobox"
|
|
/>
|
|
|
|
{hasQuery ? (
|
|
<button
|
|
type="button"
|
|
className="hero-search__clear"
|
|
onClick={clearSearch}
|
|
aria-label={t('landing.clearLabel')}
|
|
tabIndex={-1}
|
|
>
|
|
<X size={16} strokeWidth={2.5} aria-hidden="true" />
|
|
</button>
|
|
) : null}
|
|
|
|
<button
|
|
type="button"
|
|
className={`hero-search__action ${primaryVariant}`}
|
|
onClick={handlePrimaryAction}
|
|
disabled={primaryDisabled}
|
|
aria-label={exactMatch ? t('landing.goToAlias', { alias: exactMatch.alias }) : primaryLabel}
|
|
>
|
|
{exactMatch ? (
|
|
<>
|
|
<span className="hero-search__action-label">{primaryLabel}</span>
|
|
<ArrowRight size={18} strokeWidth={2.5} aria-hidden="true" />
|
|
</>
|
|
) : canCreate ? (
|
|
<>
|
|
<Plus size={16} strokeWidth={2.6} aria-hidden="true" />
|
|
<span className="hero-search__action-label">{primaryLabel}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="hero-search__action-label">{primaryLabel}</span>
|
|
<ArrowRight size={18} strokeWidth={2.5} aria-hidden="true" />
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{showDropdown ? (
|
|
<div id="hero-search-results" className="hero-search__dropdown" role="listbox">
|
|
{showLoading ? (
|
|
<div className="hero-search__status">
|
|
<span className="hero-search__spinner" aria-hidden="true" />
|
|
<span>{t('landing.searching')}</span>
|
|
</div>
|
|
) : null}
|
|
|
|
{showError ? (
|
|
<div className="hero-search__status hero-search__status--error" role="alert">
|
|
{state.error}
|
|
</div>
|
|
) : null}
|
|
|
|
{showNoResults ? (
|
|
<div className="hero-search__empty">
|
|
<div className="hero-search__empty-title">
|
|
<Trans
|
|
i18nKey="landing.noResultsTitle"
|
|
values={{ query: trimmedQuery }}
|
|
components={{ 1: <code /> }}
|
|
/>
|
|
</div>
|
|
<div className="hero-search__empty-hint">
|
|
{t('landing.noResultsHint')}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="hero-search__empty-cta"
|
|
onClick={handleCreate}
|
|
>
|
|
<Plus size={15} strokeWidth={2.6} aria-hidden="true" />
|
|
{t('landing.createPersonalLink')}
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
|
|
{showResults
|
|
? results.map((link, index) => {
|
|
const exact = isExactMatch(link, trimmedQuery);
|
|
const isActive = index === activeIndex;
|
|
const safeUrl =
|
|
link.linkType === 'redirect' ? safeLinkTargetUrl(link.targetUrl) : null;
|
|
return (
|
|
<div
|
|
key={link.id}
|
|
role="option"
|
|
aria-selected={isActive}
|
|
className={`hero-search__result${isActive ? ' is-active' : ''}${
|
|
exact ? ' is-exact' : ''
|
|
}`}
|
|
onClick={() => handleSelect(link)}
|
|
onMouseEnter={() => setActiveIndex(index)}
|
|
>
|
|
<div className="hero-search__result-main">
|
|
<span className="hero-search__result-alias">
|
|
<span className="hero-search__result-slash">/</span>
|
|
{link.alias}
|
|
</span>
|
|
{exact ? (
|
|
<span className="hero-search__exact-badge">{t('landing.exactMatch')}</span>
|
|
) : null}
|
|
</div>
|
|
{link.linkType === 'redirect' && link.targetUrl ? (
|
|
<p className="hero-search__result-url">
|
|
{safeUrl ? (
|
|
<span className="truncate" title={link.targetUrl}>
|
|
{link.targetUrl}
|
|
</span>
|
|
) : (
|
|
<span className="muted">{t('landing.invalidLink')}</span>
|
|
)}
|
|
</p>
|
|
) : link.linkType === 'custom' ? (
|
|
<p className="hero-search__result-url">
|
|
<span className="muted">{t('landing.customPage')}</span>
|
|
</p>
|
|
) : null}
|
|
<div className="hero-search__result-meta">
|
|
<span className="hero-search__clicks">
|
|
{t('landing.clicksCount', { count: link.clickCount.toLocaleString() })}
|
|
</span>
|
|
{link.description ? (
|
|
<span className="hero-search__result-desc">{link.description}</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
: null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="hero-suggestions">
|
|
<span className="hero-suggestions__label">{t('landing.suggestionsLabel')}</span>
|
|
{SUGGESTIONS.map((alias) => (
|
|
<button
|
|
key={alias}
|
|
type="button"
|
|
className="hero-suggestion-chip"
|
|
onClick={() => pickSuggestion(alias)}
|
|
>
|
|
{alias}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
} |