feat: suggestions
This commit is contained in:
parent
5ea35f6f8d
commit
53fa0f38ad
12
src/App.css
12
src/App.css
@ -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 {
|
||||
|
||||
69
src/App.tsx
69
src/App.tsx
@ -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">
|
||||
|
||||
571
src/components/NavSearch.css
Normal file
571
src/components/NavSearch.css
Normal 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;
|
||||
}
|
||||
}
|
||||
816
src/components/NavSearch.tsx
Normal file
816
src/components/NavSearch.tsx
Normal 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;
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "キャンセル",
|
||||
|
||||
@ -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": "취소",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Отмена",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "取消",
|
||||
|
||||
@ -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 }}>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user