feat: suggestions

This commit is contained in:
Alexander Medvedev 2026-08-28 22:25:59 +02:00
parent 5ea35f6f8d
commit 53fa0f38ad
23 changed files with 1764 additions and 262 deletions

View File

@ -53,7 +53,6 @@ h3 {
.page-title,
.auth-title {
font-size: clamp(2.5rem, 5vw, 4rem);
text-transform: uppercase;
}
.page-title span {
@ -1100,8 +1099,6 @@ h3 {
.filter-title {
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 1px;
margin: 0;
}
@ -1650,8 +1647,6 @@ h3 {
color: var(--primary, #FF7518);
margin-bottom: 0.25rem;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.early-access-text {
@ -1713,7 +1708,6 @@ h3 {
.plugin-title {
font-size: 3.5rem;
text-transform: uppercase;
overflow-wrap: break-word;
word-break: break-word;
}
@ -2007,8 +2001,6 @@ h3 {
display: flex;
align-items: center;
gap: 0.45rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.plugin-recent-update-badge {
@ -2529,8 +2521,6 @@ h3 {
.review-form-title {
font-size: 1rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 1.5rem 0;
color: var(--text);
}
@ -3052,8 +3042,6 @@ h3 {
font-weight: 800;
color: var(--text);
margin: 0 0 0.4rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.library-empty-sub {

View File

@ -1,7 +1,7 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Link, NavLink, useNavigate, Outlet, Navigate, useLocation } from 'react-router-dom';
import { BrowserRouter as Router, Routes, Route, Link, NavLink, Outlet, Navigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Search, Store, X } from 'lucide-react';
import { Store } from 'lucide-react';
import Home from './pages/Home';
import PluginDetail from './pages/PluginDetail';
import LoginPage from './pages/auth/LoginPage';
@ -26,6 +26,7 @@ import LegalNoticePage from './pages/legal/LegalNoticePage';
import AdminPanel from './pages/admin/AdminPanel';
import { CookieBanner } from './components/CookieBanner';
import { openCookiePreferencesModal } from './utils/consent';
import { NavSearch } from './components/NavSearch';
import './App.css';
import api from './api';
@ -272,44 +273,7 @@ const DashboardLayout = () => {
// --- Components ---
const Navbar = ({ user }: any) => {
const { t } = useTranslation();
const location = useLocation();
const [searchQuery, setSearchQuery] = useState(() => {
if (typeof window !== 'undefined' && window.location.pathname === '/search') {
return new URLSearchParams(window.location.search).get('q') || '';
}
return '';
});
const [isMenuOpen, setIsMenuOpen] = useState(false);
const navigate = useNavigate();
// Sync searchQuery with URL 'q' parameter when navigating
useEffect(() => {
if (location.pathname === '/search') {
const urlQuery = new URLSearchParams(location.search).get('q') || '';
setSearchQuery(urlQuery);
} else {
setSearchQuery('');
}
}, [location.pathname, location.search]);
const handleSearch = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
const query = searchQuery.trim();
if (query) {
navigate(`/search?q=${encodeURIComponent(query)}`);
} else {
navigate('/search');
}
setIsMenuOpen(false);
}
};
const handleClear = () => {
setSearchQuery('');
if (location.pathname === '/search') {
navigate('/search');
}
};
const toggleMenu = () => setIsMenuOpen(!isMenuOpen);
@ -341,32 +305,9 @@ const Navbar = ({ user }: any) => {
{user && (user.role === 'admin' || user.role === 'moderator') && <NavLink to="/staff" onClick={() => setIsMenuOpen(false)}>Staff</NavLink>}
</div>
{/* ── Center Search Bar ── */}
{/* ── Center Search Bar with Suggestions ── */}
<div className="nav-search-center">
<div className="nav-search-input-wrap">
<Search size={16} className="nav-search-icon" />
<input
type="search"
name="q"
placeholder={t('nav.search_placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearch}
autoComplete="off"
autoCorrect="off"
spellCheck={false}
/>
{searchQuery && (
<button
type="button"
className="nav-search-clear"
onClick={handleClear}
aria-label="Clear search"
>
<X size={14} />
</button>
)}
</div>
<NavSearch onNavigate={() => setIsMenuOpen(false)} />
</div>
<div className="nav-actions">

View File

@ -0,0 +1,571 @@
.nav-search-container {
position: relative;
width: 100%;
max-width: 440px;
}
.nav-search-input-wrap {
position: relative;
width: 100%;
display: flex;
align-items: center;
}
.nav-search-icon {
position: absolute;
left: 14px;
color: var(--text-muted, #94a3b8);
pointer-events: none;
z-index: 2;
transition: color 0.2s ease;
}
.nav-search-input-wrap input {
width: 100%;
padding: 8px 60px 8px 38px;
border-radius: 20px;
border: 1px solid var(--border, rgba(255, 255, 255, 0.12));
background: rgba(255, 255, 255, 0.05);
color: var(--text, #f8fafc);
font-size: 0.875rem;
outline: none;
transition: all 0.2s ease;
}
.nav-search-input-wrap input::placeholder {
color: var(--text-muted, #94a3b8);
}
.nav-search-input-wrap input:focus,
.nav-search-input-wrap.dropdown-active input {
border-color: var(--primary, #f97316);
background: rgba(0, 0, 0, 0.55);
box-shadow: 0 0 12px rgba(249, 115, 22, 0.25);
}
.nav-search-input-wrap:focus-within .nav-search-icon {
color: var(--primary, #f97316);
}
.nav-search-end-adornments {
position: absolute;
right: 10px;
display: flex;
align-items: center;
gap: 6px;
z-index: 2;
}
.nav-search-spinner {
animation: nav-search-spin 0.8s linear infinite;
color: var(--primary, #f97316);
}
@keyframes nav-search-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.nav-search-clear {
background: none;
border: none;
color: var(--text-muted, #94a3b8);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 50%;
transition: color 0.15s, background-color 0.15s;
}
.nav-search-clear:hover {
color: var(--text, #f8fafc);
background: rgba(255, 255, 255, 0.1);
}
/* ── Suggestions Dropdown ── */
.nav-search-dropdown {
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
width: 100%;
min-width: 380px;
background: #121316;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.65), 0 0 0 1px rgba(249, 115, 22, 0.1);
z-index: 1000;
max-height: 480px;
display: flex;
flex-direction: column;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
overflow: hidden;
animation: navSearchFadeIn 0.15s ease-out;
}
@keyframes navSearchFadeIn {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.nav-search-results-list {
overflow-y: auto;
flex: 1;
padding: 6px;
max-height: 380px;
}
.nav-search-results-list::-webkit-scrollbar {
width: 6px;
}
.nav-search-results-list::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 3px;
}
.nav-search-results-list::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
/* ── Sections ── */
.nav-search-section {
margin-bottom: 8px;
}
.nav-search-section:last-child {
margin-bottom: 0;
}
.nav-search-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px 4px;
}
.nav-search-section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
font-weight: 600;
color: var(--text-muted, #94a3b8);
}
.nav-search-clear-recent-btn {
background: none;
border: none;
color: var(--text-muted, #94a3b8);
font-size: 0.72rem;
font-weight: 500;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: all 0.15s;
}
.nav-search-clear-recent-btn:hover {
color: var(--primary, #f97316);
background: rgba(249, 115, 22, 0.1);
}
/* ── Generic Item ── */
.nav-search-item {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
user-select: none;
}
.nav-search-item:hover,
.nav-search-item.selected {
background: rgba(255, 255, 255, 0.07);
}
.nav-search-item.selected {
background: rgba(249, 115, 22, 0.12);
outline: 1px solid rgba(249, 115, 22, 0.35);
}
.nav-search-item-icon.muted {
color: var(--text-muted, #94a3b8);
}
.nav-search-item-text {
flex: 1;
font-size: 0.875rem;
color: var(--text, #f8fafc);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-search-item-title {
flex: 1;
font-size: 0.875rem;
font-weight: 500;
color: var(--text, #f8fafc);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-search-item-badge {
font-size: 0.72rem;
color: var(--text-muted, #94a3b8);
background: rgba(255, 255, 255, 0.06);
padding: 2px 7px;
border-radius: 12px;
white-space: nowrap;
}
.nav-search-item-remove-btn {
background: none;
border: none;
color: var(--text-muted, #94a3b8);
cursor: pointer;
padding: 3px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
transition: opacity 0.15s, color 0.15s, background-color 0.15s;
}
.nav-search-item-remove-btn:hover {
opacity: 1;
color: #ef4444;
background: rgba(239, 68, 68, 0.12);
}
/* ── Plugin Item ── */
.nav-search-plugin-item {
gap: 12px;
}
.nav-search-plugin-thumb {
width: 34px;
height: 34px;
border-radius: 7px;
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.nav-search-plugin-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.nav-search-thumb-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.875rem;
color: var(--primary, #f97316);
background: rgba(249, 115, 22, 0.12);
}
.nav-search-plugin-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-search-plugin-title-row {
display: flex;
align-items: center;
gap: 6px;
}
.nav-search-plugin-name {
font-size: 0.875rem;
font-weight: 600;
color: var(--text, #f8fafc);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-search-plugin-meta {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.75rem;
color: var(--text-muted, #94a3b8);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-search-meta-dot {
opacity: 0.4;
}
.nav-search-plugin-cat {
opacity: 0.85;
}
.nav-search-pill.preorder {
font-size: 0.65rem;
font-weight: 600;
color: #38bdf8;
background: rgba(56, 189, 248, 0.12);
border: 1px solid rgba(56, 189, 248, 0.25);
padding: 1px 5px;
border-radius: 4px;
white-space: nowrap;
}
.nav-search-plugin-pricing {
flex-shrink: 0;
display: flex;
align-items: center;
}
.nav-search-price-badge {
font-size: 0.75rem;
font-weight: 700;
padding: 2px 7px;
border-radius: 6px;
white-space: nowrap;
}
.nav-search-price-badge.free {
color: #22c55e;
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.25);
}
.nav-search-price-badge.paid {
color: var(--text, #f8fafc);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.nav-search-sale-wrap {
display: flex;
align-items: center;
gap: 5px;
}
.nav-search-sale-discount {
font-size: 0.68rem;
font-weight: 700;
color: #ef4444;
background: rgba(239, 68, 68, 0.15);
padding: 2px 5px;
border-radius: 4px;
}
/* ── Category Item ── */
.nav-search-icon-circle {
width: 28px;
height: 28px;
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.nav-search-icon-circle.category {
background: rgba(249, 115, 22, 0.12);
color: var(--primary, #f97316);
}
/* ── Author Item ── */
.nav-search-author-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
color: #fff;
flex-shrink: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.nav-search-author-info {
flex: 1;
min-width: 0;
}
/* ── Tags Grid ── */
.nav-search-tags-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 4px 6px;
}
.nav-search-tag-chip {
display: flex;
align-items: center;
gap: 4px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text, #f8fafc);
padding: 4px 10px;
border-radius: 14px;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.nav-search-tag-chip:hover,
.nav-search-tag-chip.selected {
background: rgba(249, 115, 22, 0.15);
border-color: rgba(249, 115, 22, 0.4);
color: var(--primary, #f97316);
}
.nav-search-tag-hash {
color: var(--primary, #f97316);
font-weight: 700;
}
/* ── Matching Text Highlight ── */
.nav-search-highlight {
background: transparent;
color: var(--primary, #f97316);
font-weight: 700;
text-decoration: underline;
text-underline-offset: 2px;
}
/* ── States ── */
.nav-search-loading-state,
.nav-search-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 16px;
text-align: center;
color: var(--text-muted, #94a3b8);
gap: 8px;
}
.nav-search-empty-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--text, #f8fafc);
margin: 0;
}
.nav-search-empty-hint {
font-size: 0.75rem;
color: var(--text-muted, #94a3b8);
margin: 0;
}
/* ── View All Results Footer ── */
.nav-search-view-all-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.35);
border-top: 1px solid rgba(255, 255, 255, 0.08);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.nav-search-view-all-footer:hover,
.nav-search-view-all-footer.selected {
background: rgba(249, 115, 22, 0.15);
border-top-color: rgba(249, 115, 22, 0.3);
}
.nav-search-view-all-left {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.825rem;
font-weight: 600;
color: var(--text, #f8fafc);
}
.nav-search-view-all-icon {
color: var(--primary, #f97316);
}
.nav-search-view-all-right {
display: flex;
align-items: center;
gap: 6px;
}
.nav-search-key-hint {
display: flex;
align-items: center;
gap: 3px;
font-size: 0.7rem;
color: var(--text-muted, #94a3b8);
background: rgba(255, 255, 255, 0.08);
padding: 2px 6px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.nav-search-view-all-arrow {
color: var(--text-muted, #94a3b8);
transition: transform 0.15s ease, color 0.15s ease;
}
.nav-search-view-all-footer:hover .nav-search-view-all-arrow,
.nav-search-view-all-footer.selected .nav-search-view-all-arrow {
transform: translateX(2px);
color: var(--primary, #f97316);
}
/* ── Mobile Responsiveness ── */
@media (max-width: 768px) {
.nav-search-container {
max-width: 100%;
}
.nav-search-dropdown {
position: fixed;
top: auto;
left: 12px;
right: 12px;
width: auto;
min-width: 0;
max-height: 60vh;
}
}

View File

@ -0,0 +1,816 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Search,
X,
Clock,
User,
Folder,
Tag,
Package,
ArrowRight,
CornerDownLeft,
Loader2,
} from 'lucide-react';
import api from '../api';
import { getPluginUrl } from '../utils/url';
import './NavSearch.css';
export interface SearchSuggestionPlugin {
id: number;
name: string;
dev_name: string;
category: string | null;
preview_path: string | null;
price_cents: number;
price: number;
type: 'free' | 'paid' | 'adwall';
sale_active: boolean;
sale_discount_percent: number;
downloads: number;
is_early_access: boolean;
is_preorder: boolean;
}
export interface SearchSuggestionAuthor {
id: number;
username: string;
plugin_count: number;
}
export interface SearchSuggestionCategory {
name: string;
plugin_count: number;
}
export interface SearchSuggestionsResponse {
plugins: SearchSuggestionPlugin[];
authors: SearchSuggestionAuthor[];
categories: SearchSuggestionCategory[];
tags: string[];
}
const RECENT_SEARCHES_KEY = 'pumpkin_market_recent_searches';
const MAX_RECENT_SEARCHES = 6;
const getRecentSearches = (): string[] => {
try {
const raw = localStorage.getItem(RECENT_SEARCHES_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.slice(0, MAX_RECENT_SEARCHES) : [];
} catch {
return [];
}
};
const saveRecentSearch = (term: string) => {
const clean = term.trim();
if (!clean) return;
try {
const existing = getRecentSearches();
const updated = [clean, ...existing.filter((s) => s.toLowerCase() !== clean.toLowerCase())].slice(
0,
MAX_RECENT_SEARCHES
);
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
} catch {
// Ignore localStorage errors
}
};
const removeRecentSearch = (term: string) => {
try {
const existing = getRecentSearches();
const updated = existing.filter((s) => s.toLowerCase() !== term.toLowerCase());
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
} catch {
// Ignore localStorage errors
}
};
const clearRecentSearches = () => {
try {
localStorage.removeItem(RECENT_SEARCHES_KEY);
} catch {
// Ignore localStorage errors
}
};
const clientSuggestionCache = new Map<string, SearchSuggestionsResponse>();
const MAX_CLIENT_CACHE = 100;
const getCachedSuggestions = (term: string): SearchSuggestionsResponse | undefined => {
return clientSuggestionCache.get(term.toLowerCase());
};
const setCachedSuggestions = (term: string, data: SearchSuggestionsResponse) => {
if (clientSuggestionCache.size >= MAX_CLIENT_CACHE) {
const firstKey = clientSuggestionCache.keys().next().value;
if (firstKey) clientSuggestionCache.delete(firstKey);
}
clientSuggestionCache.set(term.toLowerCase(), data);
};
const HighlightMatch: React.FC<{ text: string; query: string }> = ({ text, query }) => {
if (!query.trim()) return <>{text}</>;
const trimmed = query.trim();
const escaped = trimmed.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const parts = text.split(new RegExp(`(${escaped})`, 'gi'));
return (
<>
{parts.map((part, i) =>
part.toLowerCase() === trimmed.toLowerCase() ? (
<mark key={i} className="nav-search-highlight">
{part}
</mark>
) : (
part
)
)}
</>
);
};
const getAuthorAccentColor = (name: string) => {
const colors = [
'#f97316', '#3b82f6', '#10b981', '#8b5cf6',
'#ec4899', '#06b6d4', '#eab308', '#14b8a6',
];
let hash = 0;
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return colors[Math.abs(hash) % colors.length];
};
type FlattenedItem =
| { kind: 'recent'; query: string }
| { kind: 'plugin'; plugin: SearchSuggestionPlugin }
| { kind: 'author'; author: SearchSuggestionAuthor }
| { kind: 'category'; category: SearchSuggestionCategory }
| { kind: 'tag'; tag: string }
| { kind: 'view_all'; query: string };
interface NavSearchProps {
onNavigate?: () => void;
className?: string;
placeholder?: string;
autoFocus?: boolean;
}
export const NavSearch: React.FC<NavSearchProps> = ({
onNavigate,
className = '',
placeholder,
autoFocus = false,
}) => {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const [query, setQuery] = useState(() => {
if (typeof window !== 'undefined' && window.location.pathname === '/search') {
return new URLSearchParams(window.location.search).get('q') || '';
}
return '';
});
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [suggestions, setSuggestions] = useState<SearchSuggestionsResponse | null>(null);
const [recentSearches, setRecentSearches] = useState<string[]>([]);
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
// Sync query with URL on /search page
useEffect(() => {
if (location.pathname === '/search') {
const urlQ = new URLSearchParams(location.search).get('q') || '';
setQuery(urlQ);
}
}, [location.pathname, location.search]);
// Load recent searches on mount / open
const refreshRecentSearches = useCallback(() => {
setRecentSearches(getRecentSearches());
}, []);
useEffect(() => {
refreshRecentSearches();
}, [refreshRecentSearches]);
// Close dropdown when clicking outside
useEffect(() => {
const handleOutsideClick = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
setSelectedIndex(-1);
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
}, []);
// Fetch suggestions with instant in-memory client cache & debounced fallback
useEffect(() => {
const trimmed = query.trim();
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
if (!trimmed) {
setSuggestions(null);
setLoading(false);
setSelectedIndex(-1);
return;
}
// 1. Check client memory cache (0ms instant response on backspace / repeat)
const cached = getCachedSuggestions(trimmed);
if (cached) {
setSuggestions(cached);
setLoading(false);
setSelectedIndex(-1);
return;
}
setLoading(true);
const controller = new AbortController();
abortControllerRef.current = controller;
debounceTimerRef.current = setTimeout(async () => {
try {
const res = await api.get('/plugins/suggestions', {
params: { q: trimmed },
signal: controller.signal,
});
setCachedSuggestions(trimmed, res.data);
setSuggestions(res.data);
setSelectedIndex(-1);
} catch (err: any) {
if (err?.code !== 'ERR_CANCELED' && err?.name !== 'CanceledError') {
console.error('Failed to fetch search suggestions:', err);
}
} finally {
setLoading(false);
}
}, 160);
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
if (abortControllerRef.current) abortControllerRef.current.abort();
};
}, [query]);
// Compute flattened navigable items
const flattenedItems = useMemo<FlattenedItem[]>(() => {
const items: FlattenedItem[] = [];
const trimmed = query.trim();
if (!trimmed) {
// Show recent searches
recentSearches.forEach((s) => {
items.push({ kind: 'recent', query: s });
});
return items;
}
if (suggestions) {
// Plugins
suggestions.plugins.forEach((plugin) => {
items.push({ kind: 'plugin', plugin });
});
// Categories
suggestions.categories.forEach((category) => {
items.push({ kind: 'category', category });
});
// Authors
suggestions.authors.forEach((author) => {
items.push({ kind: 'author', author });
});
// Tags
suggestions.tags.forEach((tag) => {
items.push({ kind: 'tag', tag });
});
// View all results
items.push({ kind: 'view_all', query: trimmed });
}
return items;
}, [query, recentSearches, suggestions]);
const executeSearch = useCallback(
(searchTerm: string) => {
const clean = searchTerm.trim();
if (clean) {
saveRecentSearch(clean);
refreshRecentSearches();
navigate(`/search?q=${encodeURIComponent(clean)}`);
} else {
navigate('/search');
}
setIsOpen(false);
setSelectedIndex(-1);
inputRef.current?.blur();
onNavigate?.();
},
[navigate, onNavigate, refreshRecentSearches]
);
const handleSelectItem = useCallback(
(item: FlattenedItem) => {
switch (item.kind) {
case 'recent': {
setQuery(item.query);
executeSearch(item.query);
break;
}
case 'plugin': {
saveRecentSearch(item.plugin.name);
refreshRecentSearches();
navigate(getPluginUrl(item.plugin));
setIsOpen(false);
setSelectedIndex(-1);
inputRef.current?.blur();
onNavigate?.();
break;
}
case 'author': {
saveRecentSearch(item.author.username);
refreshRecentSearches();
navigate(`/profile/${encodeURIComponent(item.author.username)}`);
setIsOpen(false);
setSelectedIndex(-1);
inputRef.current?.blur();
onNavigate?.();
break;
}
case 'category': {
saveRecentSearch(item.category.name);
refreshRecentSearches();
navigate(`/search?category=${encodeURIComponent(item.category.name)}`);
setIsOpen(false);
setSelectedIndex(-1);
inputRef.current?.blur();
onNavigate?.();
break;
}
case 'tag': {
saveRecentSearch(item.tag);
refreshRecentSearches();
navigate(`/search?q=${encodeURIComponent(item.tag)}`);
setIsOpen(false);
setSelectedIndex(-1);
inputRef.current?.blur();
onNavigate?.();
break;
}
case 'view_all': {
executeSearch(item.query);
break;
}
}
},
[executeSearch, navigate, onNavigate, refreshRecentSearches]
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
refreshRecentSearches();
return;
}
if (flattenedItems.length === 0) return;
setSelectedIndex((prev) => (prev + 1 >= flattenedItems.length ? 0 : prev + 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (!isOpen) return;
if (flattenedItems.length === 0) return;
setSelectedIndex((prev) => (prev <= 0 ? flattenedItems.length - 1 : prev - 1));
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && selectedIndex < flattenedItems.length) {
handleSelectItem(flattenedItems[selectedIndex]);
} else {
executeSearch(query);
}
} else if (e.key === 'Escape') {
setIsOpen(false);
setSelectedIndex(-1);
inputRef.current?.blur();
}
};
const handleClear = () => {
setQuery('');
setSuggestions(null);
setSelectedIndex(-1);
inputRef.current?.focus();
if (location.pathname === '/search') {
navigate('/search');
}
};
const handleRemoveRecent = (e: React.MouseEvent, term: string) => {
e.stopPropagation();
removeRecentSearch(term);
refreshRecentSearches();
};
const handleClearAllRecent = (e: React.MouseEvent) => {
e.stopPropagation();
clearRecentSearches();
refreshRecentSearches();
};
const hasSuggestions =
suggestions &&
(suggestions.plugins.length > 0 ||
suggestions.authors.length > 0 ||
suggestions.categories.length > 0 ||
suggestions.tags.length > 0);
const showRecentDropdown = isOpen && !query.trim() && recentSearches.length > 0;
const showSuggestionsDropdown = isOpen && query.trim().length > 0;
return (
<div className={`nav-search-container ${className}`} ref={containerRef}>
<div className={`nav-search-input-wrap ${isOpen ? 'dropdown-active' : ''}`}>
<Search size={16} className="nav-search-icon" aria-hidden="true" />
<input
ref={inputRef}
type="search"
name="q"
placeholder={placeholder || t('nav.search_placeholder')}
value={query}
onChange={(e) => {
setQuery(e.target.value);
if (!isOpen) setIsOpen(true);
}}
onFocus={() => {
setIsOpen(true);
refreshRecentSearches();
}}
onKeyDown={handleKeyDown}
autoComplete="off"
autoCorrect="off"
spellCheck={false}
autoFocus={autoFocus}
aria-expanded={isOpen}
aria-autocomplete="list"
aria-controls="nav-search-suggestions"
role="combobox"
/>
<div className="nav-search-end-adornments">
{loading && (
<Loader2 size={15} className="nav-search-spinner" aria-label="Loading suggestions" />
)}
{query && (
<button
type="button"
className="nav-search-clear"
onClick={handleClear}
aria-label="Clear search input"
>
<X size={14} />
</button>
)}
</div>
</div>
{/* Suggestions & Recent Searches Dropdown */}
{(showRecentDropdown || showSuggestionsDropdown) && (
<div
id="nav-search-suggestions"
className="nav-search-dropdown animate-fade-in"
role="listbox"
>
{/* ── 1. Recent Searches View (Empty query) ── */}
{showRecentDropdown && (
<div className="nav-search-section">
<div className="nav-search-section-header">
<span className="nav-search-section-title">
<Clock size={13} />
{t('search.recent_title')}
</span>
<button
type="button"
className="nav-search-clear-recent-btn"
onClick={handleClearAllRecent}
>
{t('search.clear_recent')}
</button>
</div>
<div className="nav-search-recent-list">
{recentSearches.map((term, index) => {
const isSelected = selectedIndex === index;
return (
<div
key={`recent-${term}`}
className={`nav-search-item nav-search-recent-item ${
isSelected ? 'selected' : ''
}`}
onClick={() => handleSelectItem({ kind: 'recent', query: term })}
role="option"
aria-selected={isSelected}
>
<Clock size={14} className="nav-search-item-icon muted" />
<span className="nav-search-item-text">{term}</span>
<button
type="button"
className="nav-search-item-remove-btn"
onClick={(e) => handleRemoveRecent(e, term)}
aria-label={`Remove ${term} from recent searches`}
>
<X size={12} />
</button>
</div>
);
})}
</div>
</div>
)}
{/* ── 2. Live Search Suggestions View ── */}
{showSuggestionsDropdown && (
<>
{loading && !suggestions && (
<div className="nav-search-loading-state">
<Loader2 size={18} className="nav-search-spinner" />
<span>{t('common.loading')}</span>
</div>
)}
{suggestions && !hasSuggestions && (
<div className="nav-search-empty-state">
<p className="nav-search-empty-title">{t('search.no_suggestions')}</p>
<p className="nav-search-empty-hint">{t('search.press_enter')}</p>
</div>
)}
{suggestions && (
<div className="nav-search-results-list">
{/* Category matches */}
{suggestions.categories.length > 0 && (
<div className="nav-search-section">
<div className="nav-search-section-header">
<span className="nav-search-section-title">
<Folder size={13} />
{t('search.categories_title')}
</span>
</div>
{suggestions.categories.map((cat) => {
const itemIndex = flattenedItems.findIndex(
(it) => it.kind === 'category' && it.category.name === cat.name
);
const isSelected = selectedIndex === itemIndex;
return (
<div
key={`cat-${cat.name}`}
className={`nav-search-item nav-search-category-item ${
isSelected ? 'selected' : ''
}`}
onClick={() => handleSelectItem({ kind: 'category', category: cat })}
role="option"
aria-selected={isSelected}
>
<div className="nav-search-icon-circle category">
<Folder size={13} />
</div>
<span className="nav-search-item-title">
<HighlightMatch text={cat.name} query={query} />
</span>
<span className="nav-search-item-badge">
{cat.plugin_count === 1
? t('search.plugin_count')
: t('search.plugins_count', { count: cat.plugin_count })}
</span>
</div>
);
})}
</div>
)}
{/* Plugin matches */}
{suggestions.plugins.length > 0 && (
<div className="nav-search-section">
<div className="nav-search-section-header">
<span className="nav-search-section-title">
<Package size={13} />
{t('search.plugins_title')}
</span>
</div>
{suggestions.plugins.map((plugin) => {
const itemIndex = flattenedItems.findIndex(
(it) => it.kind === 'plugin' && it.plugin.id === plugin.id
);
const isSelected = selectedIndex === itemIndex;
const isFree = plugin.type === 'free';
const isSale = plugin.sale_active && plugin.sale_discount_percent > 0;
const price = plugin.price_cents / 100;
const discountedPrice = isSale
? (price * (1 - plugin.sale_discount_percent / 100)).toFixed(2)
: price.toFixed(2);
return (
<div
key={`plugin-${plugin.id}`}
className={`nav-search-item nav-search-plugin-item ${
isSelected ? 'selected' : ''
}`}
onClick={() => handleSelectItem({ kind: 'plugin', plugin })}
role="option"
aria-selected={isSelected}
>
<div className="nav-search-plugin-thumb">
{plugin.preview_path ? (
<img src={plugin.preview_path} alt={plugin.name} />
) : (
<div className="nav-search-thumb-fallback">
{plugin.name.charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="nav-search-plugin-info">
<div className="nav-search-plugin-title-row">
<span className="nav-search-plugin-name">
<HighlightMatch text={plugin.name} query={query} />
</span>
{plugin.is_preorder && (
<span className="nav-search-pill preorder">
{t('plugin.pre_order')}
</span>
)}
</div>
<div className="nav-search-plugin-meta">
<span className="nav-search-plugin-dev">
by <HighlightMatch text={plugin.dev_name} query={query} />
</span>
{plugin.category && (
<>
<span className="nav-search-meta-dot">·</span>
<span className="nav-search-plugin-cat">{plugin.category}</span>
</>
)}
</div>
</div>
<div className="nav-search-plugin-pricing">
{isFree ? (
<span className="nav-search-price-badge free">
{t('plugin.free')}
</span>
) : isSale ? (
<div className="nav-search-sale-wrap">
<span className="nav-search-sale-discount">
-{plugin.sale_discount_percent}%
</span>
<span className="nav-search-price-badge paid">
€{discountedPrice}
</span>
</div>
) : (
<span className="nav-search-price-badge paid">
€{price.toFixed(2)}
</span>
)}
</div>
</div>
);
})}
</div>
)}
{/* Author matches */}
{suggestions.authors.length > 0 && (
<div className="nav-search-section">
<div className="nav-search-section-header">
<span className="nav-search-section-title">
<User size={13} />
{t('search.authors_title')}
</span>
</div>
{suggestions.authors.map((author) => {
const itemIndex = flattenedItems.findIndex(
(it) => it.kind === 'author' && it.author.id === author.id
);
const isSelected = selectedIndex === itemIndex;
const accentColor = getAuthorAccentColor(author.username);
return (
<div
key={`author-${author.id}`}
className={`nav-search-item nav-search-author-item ${
isSelected ? 'selected' : ''
}`}
onClick={() => handleSelectItem({ kind: 'author', author })}
role="option"
aria-selected={isSelected}
>
<div
className="nav-search-author-avatar"
style={{ backgroundColor: accentColor }}
>
{author.username.charAt(0).toUpperCase()}
</div>
<div className="nav-search-author-info">
<span className="nav-search-item-title">
<HighlightMatch text={author.username} query={query} />
</span>
</div>
<span className="nav-search-item-badge">
{author.plugin_count === 1
? t('search.plugin_count')
: t('search.plugins_count', { count: author.plugin_count })}
</span>
</div>
);
})}
</div>
)}
{/* Tags matches */}
{suggestions.tags.length > 0 && (
<div className="nav-search-section">
<div className="nav-search-section-header">
<span className="nav-search-section-title">
<Tag size={13} />
{t('search.tags_title')}
</span>
</div>
<div className="nav-search-tags-grid">
{suggestions.tags.map((tag) => {
const itemIndex = flattenedItems.findIndex(
(it) => it.kind === 'tag' && it.tag === tag
);
const isSelected = selectedIndex === itemIndex;
return (
<button
key={`tag-${tag}`}
type="button"
className={`nav-search-tag-chip ${isSelected ? 'selected' : ''}`}
onClick={() => handleSelectItem({ kind: 'tag', tag })}
>
<span className="nav-search-tag-hash">#</span>
<span className="nav-search-tag-text">
<HighlightMatch text={tag} query={query} />
</span>
</button>
);
})}
</div>
</div>
)}
</div>
)}
{/* View All Results Footer */}
{query.trim() && (
<div
className={`nav-search-view-all-footer ${
selectedIndex === flattenedItems.length - 1 ? 'selected' : ''
}`}
onClick={() => handleSelectItem({ kind: 'view_all', query: query.trim() })}
role="option"
aria-selected={selectedIndex === flattenedItems.length - 1}
>
<div className="nav-search-view-all-left">
<Search size={14} className="nav-search-view-all-icon" />
<span>
{t('search.view_all_results', { query: query.trim() })}
</span>
</div>
<div className="nav-search-view-all-right">
<span className="nav-search-key-hint">
<CornerDownLeft size={11} /> Enter
</span>
<ArrowRight size={14} className="nav-search-view-all-arrow" />
</div>
</div>
)}
</>
)}
</div>
)}
</div>
);
};
export default NavSearch;

View File

@ -221,6 +221,19 @@
"customize": "Einstellungen anpassen",
"save": "Einstellungen speichern"
},
"search": {
"recent_title": "Letzte Suchen",
"clear_recent": "Löschen",
"no_suggestions": "Keine Vorschläge gefunden",
"press_enter": "Eingabetaste drücken zum Suchen",
"categories_title": "Kategorien",
"plugins_title": "Plugins",
"authors_title": "Entwickler",
"tags_title": "Tags",
"plugin_count": "1 Plugin",
"plugins_count": "{{count}} Plugins",
"view_all_results": "Alle Ergebnisse für „{{query}}“ anzeigen"
},
"common": {
"save": "Speichern",
"cancel": "Abbrechen",

View File

@ -221,6 +221,19 @@
"customize": "Customize Preferences",
"save": "Save Preferences"
},
"search": {
"recent_title": "Recent Searches",
"clear_recent": "Clear",
"no_suggestions": "No suggestions found",
"press_enter": "Press Enter to search",
"categories_title": "Categories",
"plugins_title": "Plugins",
"authors_title": "Developers",
"tags_title": "Tags",
"plugin_count": "1 plugin",
"plugins_count": "{{count}} plugins",
"view_all_results": "View all results for \"{{query}}\""
},
"common": {
"save": "Save",
"cancel": "Cancel",

View File

@ -221,6 +221,19 @@
"customize": "Personalizar",
"save": "Guardar preferencias"
},
"search": {
"recent_title": "Búsquedas recientes",
"clear_recent": "Borrar",
"no_suggestions": "No se encontraron sugerencias",
"press_enter": "Presiona Enter para buscar",
"categories_title": "Categorías",
"plugins_title": "Plugins",
"authors_title": "Desarrolladores",
"tags_title": "Etiquetas",
"plugin_count": "1 plugin",
"plugins_count": "{{count}} plugins",
"view_all_results": "Ver todos los resultados de «{{query}}»"
},
"common": {
"save": "Guardar",
"cancel": "Cancelar",

View File

@ -221,6 +221,19 @@
"customize": "Personnaliser",
"save": "Enregistrer les préférences"
},
"search": {
"recent_title": "Recherches récentes",
"clear_recent": "Effacer",
"no_suggestions": "Aucune suggestion trouvée",
"press_enter": "Appuyez sur Entrée pour rechercher",
"categories_title": "Catégories",
"plugins_title": "Plugins",
"authors_title": "Développeurs",
"tags_title": "Tags",
"plugin_count": "1 plugin",
"plugins_count": "{{count}} plugins",
"view_all_results": "Voir tous les résultats pour « {{query}} »"
},
"common": {
"save": "Enregistrer",
"cancel": "Annuler",

View File

@ -221,6 +221,19 @@
"customize": "Personalizza",
"save": "Salva Preferenze"
},
"search": {
"recent_title": "Ricerche recenti",
"clear_recent": "Cancella",
"no_suggestions": "Nessun suggerimento trovato",
"press_enter": "Premi Invio per cercare",
"categories_title": "Categorie",
"plugins_title": "Plugin",
"authors_title": "Sviluppatori",
"tags_title": "Tag",
"plugin_count": "1 plugin",
"plugins_count": "{{count}} plugin",
"view_all_results": "Mostra tutti i risultati per \"{{query}}\""
},
"common": {
"save": "Salva",
"cancel": "Annulla",

View File

@ -221,6 +221,19 @@
"customize": "設定を変更",
"save": "設定を保存"
},
"search": {
"recent_title": "最近の検索",
"clear_recent": "クリア",
"no_suggestions": "候補が見つかりません",
"press_enter": "Enterを押して検索",
"categories_title": "カテゴリー",
"plugins_title": "プラグイン",
"authors_title": "開発者",
"tags_title": "タグ",
"plugin_count": "1個のプラグイン",
"plugins_count": "{{count}}個のプラグイン",
"view_all_results": "「{{query}}」の全検索結果を表示"
},
"common": {
"save": "保存",
"cancel": "キャンセル",

View File

@ -221,6 +221,19 @@
"customize": "설정 변경",
"save": "설정 저장"
},
"search": {
"recent_title": "최근 검색",
"clear_recent": "지우기",
"no_suggestions": "추천 검색어가 없습니다",
"press_enter": "Enter를 눌러 검색",
"categories_title": "카테고리",
"plugins_title": "플러그인",
"authors_title": "개발자",
"tags_title": "태그",
"plugin_count": "플러그인 1개",
"plugins_count": "플러그인 {{count}}개",
"view_all_results": "\"{{query}}\" 전체 결과 보기"
},
"common": {
"save": "저장",
"cancel": "취소",

View File

@ -221,6 +221,19 @@
"customize": "Aanpassen",
"save": "Voorkeuren Opslaan"
},
"search": {
"recent_title": "Recente zoekopdrachten",
"clear_recent": "Wissen",
"no_suggestions": "Geen suggesties gevonden",
"press_enter": "Druk op Enter om te zoeken",
"categories_title": "Categorieën",
"plugins_title": "Plugins",
"authors_title": "Ontwikkelaars",
"tags_title": "Tags",
"plugin_count": "1 plugin",
"plugins_count": "{{count}} plugins",
"view_all_results": "Bekijk alle resultaten voor \"{{query}}\""
},
"common": {
"save": "Opslaan",
"cancel": "Annuleren",

View File

@ -221,6 +221,19 @@
"customize": "Dostosuj",
"save": "Zapisz Ustawienia"
},
"search": {
"recent_title": "Ostatnie wyszukiwania",
"clear_recent": "Wyczyść",
"no_suggestions": "Brak sugestii",
"press_enter": "Naciśnij Enter, aby wyszukać",
"categories_title": "Kategorie",
"plugins_title": "Wtyczki",
"authors_title": "Twórcy",
"tags_title": "Tagi",
"plugin_count": "1 wtyczka",
"plugins_count": "{{count}} wtyczek",
"view_all_results": "Zobacz wszystkie wyniki dla \"{{query}}\""
},
"common": {
"save": "Zapisz",
"cancel": "Anuluj",

View File

@ -221,6 +221,19 @@
"customize": "Personalizar",
"save": "Salvar Preferências"
},
"search": {
"recent_title": "Pesquisas recentes",
"clear_recent": "Limpar",
"no_suggestions": "Nenhuma sugestão encontrada",
"press_enter": "Pressione Enter para pesquisar",
"categories_title": "Categorias",
"plugins_title": "Plugins",
"authors_title": "Desenvolvedores",
"tags_title": "Tags",
"plugin_count": "1 plugin",
"plugins_count": "{{count}} plugins",
"view_all_results": "Ver todos os resultados de \"{{query}}\""
},
"common": {
"save": "Salvar",
"cancel": "Cancelar",

View File

@ -221,6 +221,19 @@
"customize": "Настроить",
"save": "Сохранить настройки"
},
"search": {
"recent_title": "Недавние поиски",
"clear_recent": "Очистить",
"no_suggestions": "Подсказок не найдено",
"press_enter": "Нажмите Enter для поиска",
"categories_title": "Категории",
"plugins_title": "Плагины",
"authors_title": "Разработчики",
"tags_title": "Теги",
"plugin_count": "1 плагин",
"plugins_count": "Плагинов: {{count}}",
"view_all_results": "Все результаты для «{{query}}»"
},
"common": {
"save": "Сохранить",
"cancel": "Отмена",

View File

@ -221,6 +221,19 @@
"customize": "Tercihleri Özelleştir",
"save": "Tercihleri Kaydet"
},
"search": {
"recent_title": "Son Aramalar",
"clear_recent": "Temizle",
"no_suggestions": "Öneri bulunamadı",
"press_enter": "Aramak için Enter'a basın",
"categories_title": "Kategoriler",
"plugins_title": "Eklentiler",
"authors_title": "Geliştiriciler",
"tags_title": "Etiketler",
"plugin_count": "1 eklenti",
"plugins_count": "{{count}} eklenti",
"view_all_results": "\"{{query}}\" için tüm sonuçları gör"
},
"common": {
"save": "Kaydet",
"cancel": "İptal",

View File

@ -221,6 +221,19 @@
"customize": "自定义设置",
"save": "保存偏好"
},
"search": {
"recent_title": "最近搜索",
"clear_recent": "清除",
"no_suggestions": "未找到搜索建议",
"press_enter": "按 Enter 搜索",
"categories_title": "分类",
"plugins_title": "插件",
"authors_title": "开发者",
"tags_title": "标签",
"plugin_count": "1 个插件",
"plugins_count": "{{count}} 个插件",
"view_all_results": "查看“{{query}}”的所有结果"
},
"common": {
"save": "保存",
"cancel": "取消",

View File

@ -919,7 +919,7 @@ const PluginDetail = () => {
wordBreak: 'break-word'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', fontWeight: 800, color: 'var(--primary)', textTransform: 'uppercase' }}>Developer Response</span>
<span style={{ fontSize: '0.75rem', fontWeight: 800, color: 'var(--primary)' }}>Developer Response</span>
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{formatDate(r.replied_at)}</span>
</div>
<ExpandableText
@ -1407,7 +1407,7 @@ const PluginDetail = () => {
alignItems: 'center',
background: 'linear-gradient(to right, rgba(255, 117, 24, 0.05), transparent)'
}}>
<h2 style={{ margin: 0, fontSize: '1.25rem', color: 'var(--primary)', textTransform: 'uppercase', letterSpacing: '1px', fontWeight: 800 }}>Update History</h2>
<h2 style={{ margin: 0, fontSize: '1.25rem', color: 'var(--primary)', fontWeight: 800 }}>Update History</h2>
</div>
<div style={{ padding: '24px', overflowY: 'auto', flex: 1 }}>

View File

@ -3,15 +3,16 @@
justify-content: center;
align-items: center;
min-height: 70vh;
background-color: #f9f9f9;
font-family: sans-serif;
background-color: transparent;
padding: 40px 20px;
}
.check-email-box {
background: #fff;
background: var(--surface, #141414);
border: 1px solid var(--border, #2A2A2A);
padding: 40px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
text-align: center;
max-width: 450px;
width: 100%;
@ -19,34 +20,35 @@
.check-email-icon {
margin-bottom: 20px;
color: #4CAF50; /* A nice green */
color: var(--success, #4ADE80);
}
.check-email-box h1 {
font-size: 24px;
margin-bottom: 15px;
color: #333;
color: var(--text, #EAEAEA);
font-weight: 700;
}
.check-email-box p {
font-size: 16px;
font-size: 15px;
line-height: 1.6;
color: #666;
color: var(--text-muted, #888888);
margin-bottom: 25px;
}
.check-email-box strong {
color: #333;
color: var(--text, #EAEAEA);
font-weight: 600;
}
.check-email-box hr {
border: none;
border-top: 1px solid #eee;
border-top: 1px solid var(--border, #2A2A2A);
margin: 25px 0;
}
.check-email-footer {
font-size: 14px;
color: #999;
color: var(--text-muted, #888888);
}

View File

@ -100,12 +100,10 @@ const ForgotPasswordPage = () => {
.auth-card {
width: 100%;
max-width: 440px;
background: #ffffff;
background: var(--surface, #141414);
border: 1px solid var(--border, #2A2A2A);
border-radius: 12px;
box-shadow:
0 0 0 1px rgba(60, 66, 87, 0.08),
0 2px 4px rgba(60, 66, 87, 0.04),
0 8px 24px rgba(60, 66, 87, 0.08);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
padding: 40px;
animation: cardIn 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@ -119,8 +117,8 @@ const ForgotPasswordPage = () => {
.card-title {
font-size: 22px;
font-weight: 600;
color: #0a2540;
font-weight: 700;
color: var(--text, #EAEAEA);
letter-spacing: -0.4px;
line-height: 1.3;
}
@ -128,7 +126,7 @@ const ForgotPasswordPage = () => {
.card-subtitle {
margin-top: 6px;
font-size: 14px;
color: #697386;
color: var(--text-muted, #888888);
line-height: 1.5;
}
@ -137,8 +135,8 @@ const ForgotPasswordPage = () => {
.field-label {
display: block;
font-size: 13px;
font-weight: 500;
color: #3c4257;
font-weight: 600;
color: var(--text, #EAEAEA);
margin-bottom: 6px;
letter-spacing: 0.01em;
}
@ -151,28 +149,38 @@ const ForgotPasswordPage = () => {
padding: 0 40px 0 14px;
font-size: 15px;
font-family: inherit;
color: #0a2540;
background: #ffffff;
border: 1.5px solid #e0e5ec;
color: var(--text, #EAEAEA);
background: rgba(255, 255, 255, 0.05);
border: 1.5px solid var(--border, #2A2A2A);
border-radius: 7px;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
-webkit-appearance: none;
}
.auth-input::placeholder { color: var(--text-muted, #888888); font-size: 14px; }
.auth-input:hover:not(:disabled) { border-color: rgba(255, 255, 255, 0.25); }
.auth-input:focus {
border-color: #ff6b00;
box-shadow: 0 0 0 3px rgba(255, 107, 0, 0.16);
border-color: var(--primary, #FF7518);
background: rgba(0, 0, 0, 0.4);
box-shadow: 0 0 0 3px rgba(255, 117, 24, 0.2);
}
.auth-input:disabled {
background: rgba(255, 255, 255, 0.02);
color: var(--text-muted, #666666);
cursor: not-allowed;
}
.auth-input.email-invalid {
border-color: #df1b41 !important;
box-shadow: 0 0 0 3px rgba(223, 27, 65, 0.12) !important;
border-color: #ef4444 !important;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2) !important;
}
.auth-input.email-valid {
border-color: #1a9e5f !important;
box-shadow: 0 0 0 3px rgba(26, 158, 95, 0.12) !important;
border-color: #22c55e !important;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2) !important;
}
.field-status-icon {
@ -194,47 +202,54 @@ const ForgotPasswordPage = () => {
font-weight: 500;
}
.field-message.invalid { color: #df1b41; }
.field-message.invalid { color: #ef4444; }
.error-banner {
background: #fff8f8;
border: 1.5px solid #fecdd3;
background: rgba(239, 68, 68, 0.12);
border: 1.5px solid rgba(239, 68, 68, 0.35);
border-radius: 7px;
padding: 12px 14px;
margin-bottom: 20px;
color: #df1b41;
color: #ef4444;
font-size: 13px;
}
.success-banner {
background: #f0fff4;
border: 1.5px solid #c6f6d5;
background: rgba(34, 197, 94, 0.12);
border: 1.5px solid rgba(34, 197, 94, 0.35);
border-radius: 7px;
padding: 12px 14px;
margin-bottom: 20px;
color: #2f855a;
color: #22c55e;
font-size: 13px;
}
.submit-btn {
width: 100%;
height: 46px;
background: #ff6b00;
background: var(--primary, #FF7518);
color: white;
border: none;
border-radius: 7px;
font-size: 15px;
font-weight: 500;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
transition: background 0.15s ease, box-shadow 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.submit-btn:hover:not(:disabled) { background: #e85d00; }
.submit-btn:disabled { background: #ffb380; cursor: not-allowed; }
.submit-btn:hover:not(:disabled) {
background: #e85d00;
box-shadow: 0 4px 14px rgba(255, 117, 24, 0.35);
}
.submit-btn:disabled {
background: rgba(255, 117, 24, 0.5);
cursor: not-allowed;
}
.spinner {
width: 16px; height: 16px;
@ -246,15 +261,18 @@ const ForgotPasswordPage = () => {
@keyframes spin { to { transform: rotate(360deg); } }
.divider { height: 1px; background: #e8ecf1; margin: 24px 0; }
.divider { height: 1px; background: var(--border, #2A2A2A); margin: 24px 0; }
.card-footer { text-align: center; font-size: 13.5px; color: #697386; }
.card-footer { text-align: center; font-size: 13.5px; color: var(--text-muted, #888888); }
.card-footer a {
color: #ff6b00;
color: var(--primary, #FF7518);
text-decoration: none;
font-weight: 500;
font-weight: 600;
transition: color 0.15s;
}
.card-footer a:hover { color: #ff944d; text-decoration: underline; }
`}</style>
<div className="auth-page">
@ -286,14 +304,14 @@ const ForgotPasswordPage = () => {
/>
{emailIsInvalid && (
<svg className="field-status-icon" viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="9" stroke="#df1b41" strokeWidth="1.5"/>
<path d="M7 7l6 6M13 7l-6 6" stroke="#df1b41" strokeWidth="1.5" strokeLinecap="round"/>
<circle cx="10" cy="10" r="9" stroke="#ef4444" strokeWidth="1.5"/>
<path d="M7 7l6 6M13 7l-6 6" stroke="#ef4444" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
)}
{emailIsValid && (
<svg className="field-status-icon" viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="9" stroke="#1a9e5f" strokeWidth="1.5"/>
<path d="M6 10l3 3 5-5" stroke="#1a9e5f" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<circle cx="10" cy="10" r="9" stroke="#22c55e" strokeWidth="1.5"/>
<path d="M6 10l3 3 5-5" stroke="#22c55e" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</div>
@ -309,7 +327,7 @@ const ForgotPasswordPage = () => {
onSuccess={(token) => setCaptchaToken(token)}
onExpire={() => setCaptchaToken(null)}
onError={() => setCaptchaToken(null)}
options={{ theme: 'light', appearance: 'interaction-only' } as any}
options={{ theme: 'dark', appearance: 'interaction-only' } as any}
/>
</div>

View File

@ -120,17 +120,6 @@ const LoginPage = () => {
return (
<>
<style>{`
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
}
.login-page {
display: flex;
flex-direction: column;
@ -142,12 +131,10 @@ const LoginPage = () => {
.login-card {
width: 100%;
max-width: 440px;
background: #ffffff;
background: var(--surface, #141414);
border: 1px solid var(--border, #2A2A2A);
border-radius: 12px;
box-shadow:
0 0 0 1px rgba(60, 66, 87, 0.08),
0 2px 4px rgba(60, 66, 87, 0.04),
0 8px 24px rgba(60, 66, 87, 0.08);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
padding: 40px;
animation: cardIn 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@ -161,8 +148,8 @@ const LoginPage = () => {
.card-title {
font-size: 22px;
font-weight: 600;
color: #0a2540;
font-weight: 700;
color: var(--text, #EAEAEA);
letter-spacing: -0.4px;
line-height: 1.3;
}
@ -170,7 +157,7 @@ const LoginPage = () => {
.card-subtitle {
margin-top: 6px;
font-size: 14px;
color: #697386;
color: var(--text-muted, #888888);
line-height: 1.5;
}
@ -179,8 +166,8 @@ const LoginPage = () => {
.field-label {
display: block;
font-size: 13px;
font-weight: 500;
color: #3c4257;
font-weight: 600;
color: var(--text, #EAEAEA);
margin-bottom: 6px;
letter-spacing: 0.01em;
}
@ -193,37 +180,38 @@ const LoginPage = () => {
padding: 0 40px 0 14px;
font-size: 15px;
font-family: inherit;
color: #0a2540;
background: #ffffff;
border: 1.5px solid #e0e5ec;
color: var(--text, #EAEAEA);
background: rgba(255, 255, 255, 0.05);
border: 1.5px solid var(--border, #2A2A2A);
border-radius: 7px;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
-webkit-appearance: none;
}
.login-input::placeholder { color: #a3acb9; font-size: 14px; }
.login-input:hover:not(:disabled) { border-color: #c1c9d5; }
.login-input::placeholder { color: var(--text-muted, #888888); font-size: 14px; }
.login-input:hover:not(:disabled) { border-color: rgba(255, 255, 255, 0.25); }
.login-input:focus {
border-color: #ff6b00;
box-shadow: 0 0 0 3px rgba(255, 107, 0, 0.16);
border-color: var(--primary, #FF7518);
background: rgba(0, 0, 0, 0.4);
box-shadow: 0 0 0 3px rgba(255, 117, 24, 0.2);
}
.login-input:disabled {
background: #f7fafc;
color: #a3acb9;
background: rgba(255, 255, 255, 0.02);
color: var(--text-muted, #666666);
cursor: not-allowed;
}
.login-input.email-invalid {
border-color: #df1b41 !important;
box-shadow: 0 0 0 3px rgba(223, 27, 65, 0.12) !important;
border-color: #ef4444 !important;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2) !important;
}
.login-input.email-valid {
border-color: #1a9e5f !important;
box-shadow: 0 0 0 3px rgba(26, 158, 95, 0.12) !important;
border-color: #22c55e !important;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2) !important;
}
/* no-icon variant (password field) */
@ -260,15 +248,15 @@ const LoginPage = () => {
to { opacity: 1; transform: translateY(0); }
}
.field-message.invalid { color: #df1b41; }
.field-message.valid { color: #1a9e5f; }
.field-message.invalid { color: #ef4444; }
.field-message.valid { color: #22c55e; }
.error-banner {
display: flex;
align-items: flex-start;
gap: 10px;
background: #fff8f8;
border: 1.5px solid #fecdd3;
background: rgba(239, 68, 68, 0.12);
border: 1.5px solid rgba(239, 68, 68, 0.35);
border-radius: 7px;
padding: 12px 14px;
margin-bottom: 20px;
@ -283,18 +271,18 @@ const LoginPage = () => {
80% { transform: translateX(3px); }
}
.error-icon { flex-shrink: 0; width: 16px; height: 16px; margin-top: 1px; color: #df1b41; }
.error-text { font-size: 13px; color: #df1b41; line-height: 1.5; }
.error-icon { flex-shrink: 0; width: 16px; height: 16px; margin-top: 1px; color: #ef4444; }
.error-text { font-size: 13px; color: #ef4444; line-height: 1.5; }
.submit-btn {
width: 100%;
height: 46px;
background: #ff6b00;
background: var(--primary, #FF7518);
color: white;
border: none;
border-radius: 7px;
font-size: 15px;
font-weight: 500;
font-weight: 600;
font-family: inherit;
letter-spacing: -0.1px;
cursor: pointer;
@ -308,14 +296,14 @@ const LoginPage = () => {
.submit-btn:hover:not(:disabled) {
background: #e85d00;
box-shadow: 0 4px 12px rgba(255, 107, 0, 0.35);
box-shadow: 0 4px 14px rgba(255, 117, 24, 0.35);
transform: translateY(-1px);
}
.submit-btn:active:not(:disabled) { transform: translateY(0); box-shadow: none; }
.submit-btn:disabled {
background: #ffb380;
background: rgba(255, 117, 24, 0.5);
cursor: not-allowed;
transform: none;
box-shadow: none;
@ -331,18 +319,18 @@ const LoginPage = () => {
@keyframes spin { to { transform: rotate(360deg); } }
.divider { height: 1px; background: #e8ecf1; margin: 24px 0; }
.divider { height: 1px; background: var(--border, #2A2A2A); margin: 24px 0; }
.card-footer { text-align: center; font-size: 13.5px; color: #697386; }
.card-footer { text-align: center; font-size: 13.5px; color: var(--text-muted, #888888); }
.card-footer a {
color: #ff6b00;
color: var(--primary, #FF7518);
text-decoration: none;
font-weight: 500;
font-weight: 600;
transition: color 0.15s;
}
.card-footer a:hover { color: #e85d00; text-decoration: underline; }
.card-footer a:hover { color: #ff944d; text-decoration: underline; }
`}</style>
<div className="login-page">
@ -369,7 +357,7 @@ const LoginPage = () => {
{requires2fa ? (
<div className="form-field">
<label className="field-label" htmlFor="totpCode">Authentication Code</label>
<p style={{ fontSize: '13px', color: '#697386', marginBottom: '12px' }}>Enter the 6-digit code from your authenticator app.</p>
<p style={{ fontSize: '13px', color: 'var(--text-muted, #888)', marginBottom: '12px' }}>Enter the 6-digit code from your authenticator app.</p>
<div className="field-input-wrapper">
<input
id="totpCode"
@ -410,14 +398,14 @@ const LoginPage = () => {
/>
{emailIsInvalid && (
<svg key="x" className="field-status-icon" viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="9" stroke="#df1b41" strokeWidth="1.5"/>
<path d="M7 7l6 6M13 7l-6 6" stroke="#df1b41" strokeWidth="1.5" strokeLinecap="round"/>
<circle cx="10" cy="10" r="9" stroke="#ef4444" strokeWidth="1.5"/>
<path d="M7 7l6 6M13 7l-6 6" stroke="#ef4444" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
)}
{emailIsValid && (
<svg key="check" className="field-status-icon" viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="9" stroke="#1a9e5f" strokeWidth="1.5"/>
<path d="M6 10l3 3 5-5" stroke="#1a9e5f" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<circle cx="10" cy="10" r="9" stroke="#22c55e" strokeWidth="1.5"/>
<path d="M6 10l3 3 5-5" stroke="#22c55e" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</div>
@ -434,7 +422,7 @@ const LoginPage = () => {
<div className="form-field">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
<label className="field-label" htmlFor="password" style={{ marginBottom: 0 }}>{t('auth.password')}</label>
<Link to="/forgot-password" style={{ fontSize: '13px', color: '#ff6b00', textDecoration: 'none', fontWeight: 500 }}>
<Link to="/forgot-password" style={{ fontSize: '13px', color: 'var(--primary, #FF7518)', textDecoration: 'none', fontWeight: 500 }}>
Forgot password?
</Link>
</div>
@ -463,7 +451,7 @@ const LoginPage = () => {
onSuccess={(token) => setCaptchaToken(token)}
onExpire={() => setCaptchaToken(null)}
onError={() => setCaptchaToken(null)}
options={{ theme: 'light', appearance: 'interaction-only' } as any}
options={{ theme: 'dark', appearance: 'interaction-only' } as any}
/>
</div>
)}

View File

@ -114,9 +114,6 @@ const RegisterPage = () => {
return (
<>
<style>{`
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; -webkit-font-smoothing: antialiased; }
.register-page {
display: flex; flex-direction: column; align-items: center;
justify-content: center; padding: 40px 20px; position: relative;
@ -135,36 +132,32 @@ const RegisterPage = () => {
@keyframes reqIn { from{opacity:0;transform:translateY(-6px)} to{opacity:1;transform:translateY(0)} }
@keyframes barFill { from{width:0} }
.register-logo { margin-bottom: 32px; display:flex; align-items:center; gap:10px; animation: fadeDown 0.4s cubic-bezier(0.22,1,0.36,1) both; }
.register-logo-mark { width:36px; height:36px; background:#ff6b00; border-radius:8px; display:flex; align-items:center; justify-content:center; }
.register-logo-mark svg { width:20px; height:20px; fill:white; }
.register-logo-text { font-size:20px; font-weight:600; color:#0a2540; letter-spacing:-0.3px; }
.register-card {
width:100%; max-width:440px; background:#ffffff; border-radius:12px;
box-shadow: 0 0 0 1px rgba(60,66,87,.08), 0 2px 4px rgba(60,66,87,.04), 0 8px 24px rgba(60,66,87,.08);
width:100%; max-width:440px; background:var(--surface, #141414); border-radius:12px;
border: 1px solid var(--border, #2A2A2A);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
padding:40px; animation: cardIn 0.35s cubic-bezier(0.22,1,0.36,1) both;
}
.card-header { margin-bottom:28px; }
.card-title { font-size:22px; font-weight:600; color:#0a2540; letter-spacing:-0.4px; line-height:1.3; }
.card-subtitle { margin-top:6px; font-size:14px; color:#697386; line-height:1.5; }
.card-title { font-size:22px; font-weight:700; color:var(--text, #EAEAEA); letter-spacing:-0.4px; line-height:1.3; }
.card-subtitle { margin-top:6px; font-size:14px; color:var(--text-muted, #888888); line-height:1.5; }
.form-field { margin-bottom:18px; }
.field-label { display:block; font-size:13px; font-weight:500; color:#3c4257; margin-bottom:6px; letter-spacing:0.01em; }
.field-label { display:block; font-size:13px; font-weight:600; color:var(--text, #EAEAEA); margin-bottom:6px; letter-spacing:0.01em; }
.field-input-wrapper { position:relative; }
.field-input {
width:100%; height:44px; padding:0 40px 0 14px; font-size:15px; font-family:inherit;
color:#0a2540; background:#ffffff; border:1.5px solid #e0e5ec; border-radius:7px;
outline:none; transition:border-color 0.15s ease, box-shadow 0.15s ease; -webkit-appearance:none;
color:var(--text, #EAEAEA); background:rgba(255, 255, 255, 0.05); border:1.5px solid var(--border, #2A2A2A); border-radius:7px;
outline:none; transition:border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; -webkit-appearance:none;
}
.field-input::placeholder { color:#a3acb9; font-size:14px; }
.field-input:hover:not(:disabled) { border-color:#c1c9d5; }
.field-input:focus { border-color:#ff6b00; box-shadow:0 0 0 3px rgba(255,107,0,.16); }
.field-input:disabled { background:#f7fafc; color:#a3acb9; cursor:not-allowed; }
.field-input.email-invalid { border-color:#df1b41 !important; box-shadow:0 0 0 3px rgba(223,27,65,.12) !important; }
.field-input.email-valid { border-color:#1a9e5f !important; box-shadow:0 0 0 3px rgba(26,158,95,.12) !important; }
.field-input::placeholder { color:var(--text-muted, #888888); font-size:14px; }
.field-input:hover:not(:disabled) { border-color:rgba(255, 255, 255, 0.25); }
.field-input:focus { border-color:var(--primary, #FF7518); background:rgba(0, 0, 0, 0.4); box-shadow:0 0 0 3px rgba(255,117,24,.2); }
.field-input:disabled { background:rgba(255, 255, 255, 0.02); color:var(--text-muted, #666666); cursor:not-allowed; }
.field-input.email-invalid { border-color:#ef4444 !important; box-shadow:0 0 0 3px rgba(239,68,68,.2) !important; }
.field-input.email-valid { border-color:#22c55e !important; box-shadow:0 0 0 3px rgba(34,197,94,.2) !important; }
/* password has extra right padding for the toggle button */
.field-input.password-input { padding-right: 44px; }
@ -176,21 +169,21 @@ const RegisterPage = () => {
}
/* toggle show/hide password */
.password-toggle {
.pw-toggle-btn {
position:absolute; right:12px; top:50%; transform:translateY(-50%);
background:none; border:none; cursor:pointer; padding:2px; color:#a3acb9;
background:none; border:none; cursor:pointer; padding:4px; color:var(--text-muted, #888888);
display:flex; align-items:center; justify-content:center;
transition:color 0.15s;
transition:color 0.15s; border-radius: 4px;
}
.password-toggle:hover { color:#697386; }
.pw-toggle-btn:hover { color:var(--text, #EAEAEA); }
.field-message {
display:flex; align-items:center; gap:5px; margin-top:5px;
font-size:12.5px; font-weight:500;
animation: msgSlide 0.18s cubic-bezier(0.22,1,0.36,1) both;
}
.field-message.invalid { color:#df1b41; }
.field-message.valid { color:#1a9e5f; }
.field-message.invalid { color:#ef4444; }
.field-message.valid { color:#22c55e; }
/* ── Strength meter ── */
.strength-meter { margin-top:10px; animation: reqIn 0.2s cubic-bezier(0.22,1,0.36,1) both; }
@ -199,7 +192,7 @@ const RegisterPage = () => {
display:flex; gap:4px; margin-bottom:6px;
}
.strength-bar {
flex:1; height:4px; border-radius:99px; background:#e0e5ec;
flex:1; height:4px; border-radius:99px; background:var(--border, #2A2A2A);
overflow:hidden; position:relative;
}
.strength-bar-fill {
@ -209,62 +202,63 @@ const RegisterPage = () => {
.strength-label-row {
display:flex; justify-content:space-between; align-items:center;
font-size:12px; color:#697386; margin-bottom:8px;
font-size:12px; color:var(--text-muted, #888888); margin-bottom:8px;
}
.strength-label-text { font-weight:500; transition:color 0.2s; }
.strength-label-text { font-weight:600; transition:color 0.2s; }
/* ── Requirements checklist ── */
.requirements-list { list-style:none; display:flex; flex-direction:column; gap:4px; }
.req-item {
display:flex; align-items:center; gap:6px;
font-size:12.5px; color:#697386;
font-size:12.5px; color:var(--text-muted, #888888);
transition: color 0.2s ease;
}
.req-item.met { color:#1a9e5f; }
.req-item.unmet-touched { color:#df1b41; }
.req-item.met { color:#22c55e; }
.req-item.unmet-touched { color:#ef4444; }
.req-icon {
width:14px; height:14px; flex-shrink:0;
border-radius:50%; display:flex; align-items:center; justify-content:center;
transition: background 0.2s, border-color 0.2s;
border:1.5px solid #c1c9d5;
border:1.5px solid var(--border, #2A2A2A);
}
.req-item.met .req-icon { background:#1a9e5f; border-color:#1a9e5f; }
.req-item.unmet-touched .req-icon { border-color:#df1b41; }
.req-item.met .req-icon { background:rgba(34, 197, 94, 0.2); border-color:#22c55e; }
.req-item.unmet-touched .req-icon { border-color:#ef4444; }
/* Error banner */
.error-banner {
display:flex; align-items:flex-start; gap:10px;
background:#fff8f8; border:1.5px solid #fecdd3; border-radius:7px;
background:rgba(239, 68, 68, 0.12); border:1.5px solid rgba(239, 68, 68, 0.35); border-radius:7px;
padding:12px 14px; margin-bottom:20px;
animation: shake 0.3s cubic-bezier(0.36,0.07,0.19,0.97);
}
.error-icon { flex-shrink:0; width:16px; height:16px; margin-top:1px; color:#df1b41; }
.error-text { font-size:13px; color:#df1b41; line-height:1.5; }
.error-icon { flex-shrink:0; width:16px; height:16px; margin-top:1px; color:#ef4444; }
.error-text { font-size:13px; color:#ef4444; line-height:1.5; }
.captcha-container { display:flex; justify-content:center; margin-bottom:20px; margin-top:4px; }
.submit-btn {
width:100%; height:46px; background:#ff6b00; color:white; border:none;
border-radius:7px; font-size:15px; font-weight:500; font-family:inherit;
width:100%; height:46px; background:var(--primary, #FF7518); color:white; border:none;
border-radius:7px; font-size:15px; font-weight:600; font-family:inherit;
letter-spacing:-0.1px; cursor:pointer;
transition:background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
display:flex; align-items:center; justify-content:center; gap:8px;
position:relative; overflow:hidden;
}
.submit-btn:hover:not(:disabled) { background:#e85d00; box-shadow:0 4px 12px rgba(255,107,0,.35); transform:translateY(-1px); }
.submit-btn:hover:not(:disabled) { background:#e85d00; box-shadow:0 4px 14px rgba(255,117,24,.35); transform:translateY(-1px); }
.submit-btn:active:not(:disabled) { transform:translateY(0); box-shadow:none; }
.submit-btn:disabled { background:#ffb380; cursor:not-allowed; transform:none; box-shadow:none; }
.submit-btn:disabled { background:rgba(255, 117, 24, 0.5); cursor:not-allowed; transform:none; box-shadow:none; }
.spinner { width:16px; height:16px; border:2px solid rgba(255,255,255,.35); border-top-color:white; border-radius:50%; animation:spin 0.7s linear infinite; }
.divider { height:1px; background:#e8ecf1; margin:24px 0; }
.card-footer { text-align:center; font-size:13.5px; color:#697386; }
.card-footer a { color:#ff6b00; text-decoration:none; font-weight:500; transition:color 0.15s; }
.card-footer a:hover { color:#e85d00; text-decoration:underline; }
.divider { height:1px; background:var(--border, #2A2A2A); margin:24px 0; }
.card-footer { text-align:center; font-size:13.5px; color:var(--text-muted, #888888); }
.card-footer a { color:var(--primary, #FF7518); text-decoration:none; font-weight:600; transition:color 0.15s; }
.card-footer a:hover { color:#ff944d; text-decoration:underline; }
.terms-note { text-align:center; font-size:12px; color:#a3acb9; margin-top:16px; line-height:1.6; }
.terms-note a { color:#697386; text-decoration:underline; text-underline-offset:2px; }
.terms-note { text-align:center; font-size:12px; color:var(--text-muted, #888888); margin-top:16px; line-height:1.6; }
.terms-note a { color:var(--text, #EAEAEA); text-decoration:underline; text-underline-offset:2px; }
.terms-note a:hover { color:var(--primary, #FF7518); }
`}</style>
<div className="register-page">
@ -441,7 +435,7 @@ const RegisterPage = () => {
onSuccess={(token) => setCaptchaToken(token)}
onExpire={() => setCaptchaToken(null)}
onError={() => setCaptchaToken(null)}
options={{ theme: 'light', appearance: 'interaction-only' } as any}
options={{ theme: 'dark', appearance: 'interaction-only' } as any}
/>
</div>

View File

@ -73,12 +73,10 @@ const ResetPasswordPage = () => {
.auth-card {
width: 100%;
max-width: 440px;
background: #ffffff;
background: var(--surface, #141414);
border: 1px solid var(--border, #2A2A2A);
border-radius: 12px;
box-shadow:
0 0 0 1px rgba(60, 66, 87, 0.08),
0 2px 4px rgba(60, 66, 87, 0.04),
0 8px 24px rgba(60, 66, 87, 0.08);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
padding: 40px;
animation: cardIn 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@ -92,8 +90,8 @@ const ResetPasswordPage = () => {
.card-title {
font-size: 22px;
font-weight: 600;
color: #0a2540;
font-weight: 700;
color: var(--text, #EAEAEA);
letter-spacing: -0.4px;
line-height: 1.3;
}
@ -101,7 +99,7 @@ const ResetPasswordPage = () => {
.card-subtitle {
margin-top: 6px;
font-size: 14px;
color: #697386;
color: var(--text-muted, #888888);
line-height: 1.5;
}
@ -110,8 +108,8 @@ const ResetPasswordPage = () => {
.field-label {
display: block;
font-size: 13px;
font-weight: 500;
color: #3c4257;
font-weight: 600;
color: var(--text, #EAEAEA);
margin-bottom: 6px;
letter-spacing: 0.01em;
}
@ -122,48 +120,65 @@ const ResetPasswordPage = () => {
padding: 0 14px;
font-size: 15px;
font-family: inherit;
color: #0a2540;
background: #ffffff;
border: 1.5px solid #e0e5ec;
color: var(--text, #EAEAEA);
background: rgba(255, 255, 255, 0.05);
border: 1.5px solid var(--border, #2A2A2A);
border-radius: 7px;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.auth-input::placeholder { color: var(--text-muted, #888888); }
.auth-input:hover:not(:disabled) { border-color: rgba(255, 255, 255, 0.25); }
.auth-input:focus {
border-color: #ff6b00;
box-shadow: 0 0 0 3px rgba(255, 107, 0, 0.16);
border-color: var(--primary, #FF7518);
background: rgba(0, 0, 0, 0.4);
box-shadow: 0 0 0 3px rgba(255, 117, 24, 0.2);
}
.auth-input:disabled {
background: rgba(255, 255, 255, 0.02);
color: var(--text-muted, #666666);
cursor: not-allowed;
}
.error-banner {
background: #fff8f8;
border: 1.5px solid #fecdd3;
background: rgba(239, 68, 68, 0.12);
border: 1.5px solid rgba(239, 68, 68, 0.35);
border-radius: 7px;
padding: 12px 14px;
margin-bottom: 20px;
color: #df1b41;
color: #ef4444;
font-size: 13px;
}
.submit-btn {
width: 100%;
height: 46px;
background: #ff6b00;
background: var(--primary, #FF7518);
color: white;
border: none;
border-radius: 7px;
font-size: 15px;
font-weight: 500;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
transition: background 0.15s ease, box-shadow 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.submit-btn:hover:not(:disabled) { background: #e85d00; }
.submit-btn:disabled { background: #ffb380; cursor: not-allowed; }
.submit-btn:hover:not(:disabled) {
background: #e85d00;
box-shadow: 0 4px 14px rgba(255, 117, 24, 0.35);
}
.submit-btn:disabled {
background: rgba(255, 117, 24, 0.5);
cursor: not-allowed;
}
.spinner {
width: 16px; height: 16px;