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,
|
.page-title,
|
||||||
.auth-title {
|
.auth-title {
|
||||||
font-size: clamp(2.5rem, 5vw, 4rem);
|
font-size: clamp(2.5rem, 5vw, 4rem);
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title span {
|
.page-title span {
|
||||||
@ -1100,8 +1099,6 @@ h3 {
|
|||||||
|
|
||||||
.filter-title {
|
.filter-title {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1650,8 +1647,6 @@ h3 {
|
|||||||
color: var(--primary, #FF7518);
|
color: var(--primary, #FF7518);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.early-access-text {
|
.early-access-text {
|
||||||
@ -1713,7 +1708,6 @@ h3 {
|
|||||||
|
|
||||||
.plugin-title {
|
.plugin-title {
|
||||||
font-size: 3.5rem;
|
font-size: 3.5rem;
|
||||||
text-transform: uppercase;
|
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@ -2007,8 +2001,6 @@ h3 {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-recent-update-badge {
|
.plugin-recent-update-badge {
|
||||||
@ -2529,8 +2521,6 @@ h3 {
|
|||||||
.review-form-title {
|
.review-form-title {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin: 0 0 1.5rem 0;
|
margin: 0 0 1.5rem 0;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
@ -3052,8 +3042,6 @@ h3 {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin: 0 0 0.4rem;
|
margin: 0 0 0.4rem;
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.library-empty-sub {
|
.library-empty-sub {
|
||||||
|
|||||||
69
src/App.tsx
69
src/App.tsx
@ -1,7 +1,7 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { Search, Store, X } from 'lucide-react';
|
import { Store } from 'lucide-react';
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
import PluginDetail from './pages/PluginDetail';
|
import PluginDetail from './pages/PluginDetail';
|
||||||
import LoginPage from './pages/auth/LoginPage';
|
import LoginPage from './pages/auth/LoginPage';
|
||||||
@ -26,6 +26,7 @@ import LegalNoticePage from './pages/legal/LegalNoticePage';
|
|||||||
import AdminPanel from './pages/admin/AdminPanel';
|
import AdminPanel from './pages/admin/AdminPanel';
|
||||||
import { CookieBanner } from './components/CookieBanner';
|
import { CookieBanner } from './components/CookieBanner';
|
||||||
import { openCookiePreferencesModal } from './utils/consent';
|
import { openCookiePreferencesModal } from './utils/consent';
|
||||||
|
import { NavSearch } from './components/NavSearch';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
import api from './api';
|
import api from './api';
|
||||||
@ -272,44 +273,7 @@ const DashboardLayout = () => {
|
|||||||
// --- Components ---
|
// --- Components ---
|
||||||
const Navbar = ({ user }: any) => {
|
const Navbar = ({ user }: any) => {
|
||||||
const { t } = useTranslation();
|
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 [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);
|
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>}
|
{user && (user.role === 'admin' || user.role === 'moderator') && <NavLink to="/staff" onClick={() => setIsMenuOpen(false)}>Staff</NavLink>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Center Search Bar ── */}
|
{/* ── Center Search Bar with Suggestions ── */}
|
||||||
<div className="nav-search-center">
|
<div className="nav-search-center">
|
||||||
<div className="nav-search-input-wrap">
|
<NavSearch onNavigate={() => setIsMenuOpen(false)} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="nav-actions">
|
<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",
|
"customize": "Einstellungen anpassen",
|
||||||
"save": "Einstellungen speichern"
|
"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": {
|
"common": {
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
|
|||||||
@ -221,6 +221,19 @@
|
|||||||
"customize": "Customize Preferences",
|
"customize": "Customize Preferences",
|
||||||
"save": "Save 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": {
|
"common": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|||||||
@ -221,6 +221,19 @@
|
|||||||
"customize": "Personalizar",
|
"customize": "Personalizar",
|
||||||
"save": "Guardar preferencias"
|
"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": {
|
"common": {
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
|
|||||||
@ -221,6 +221,19 @@
|
|||||||
"customize": "Personnaliser",
|
"customize": "Personnaliser",
|
||||||
"save": "Enregistrer les préférences"
|
"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": {
|
"common": {
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
|
|||||||
@ -221,6 +221,19 @@
|
|||||||
"customize": "Personalizza",
|
"customize": "Personalizza",
|
||||||
"save": "Salva Preferenze"
|
"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": {
|
"common": {
|
||||||
"save": "Salva",
|
"save": "Salva",
|
||||||
"cancel": "Annulla",
|
"cancel": "Annulla",
|
||||||
|
|||||||
@ -221,6 +221,19 @@
|
|||||||
"customize": "設定を変更",
|
"customize": "設定を変更",
|
||||||
"save": "設定を保存"
|
"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": {
|
"common": {
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
|
|||||||
@ -221,6 +221,19 @@
|
|||||||
"customize": "설정 변경",
|
"customize": "설정 변경",
|
||||||
"save": "설정 저장"
|
"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": {
|
"common": {
|
||||||
"save": "저장",
|
"save": "저장",
|
||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
|
|||||||
@ -221,6 +221,19 @@
|
|||||||
"customize": "Aanpassen",
|
"customize": "Aanpassen",
|
||||||
"save": "Voorkeuren Opslaan"
|
"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": {
|
"common": {
|
||||||
"save": "Opslaan",
|
"save": "Opslaan",
|
||||||
"cancel": "Annuleren",
|
"cancel": "Annuleren",
|
||||||
|
|||||||
@ -221,6 +221,19 @@
|
|||||||
"customize": "Dostosuj",
|
"customize": "Dostosuj",
|
||||||
"save": "Zapisz Ustawienia"
|
"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": {
|
"common": {
|
||||||
"save": "Zapisz",
|
"save": "Zapisz",
|
||||||
"cancel": "Anuluj",
|
"cancel": "Anuluj",
|
||||||
|
|||||||
@ -221,6 +221,19 @@
|
|||||||
"customize": "Personalizar",
|
"customize": "Personalizar",
|
||||||
"save": "Salvar Preferências"
|
"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": {
|
"common": {
|
||||||
"save": "Salvar",
|
"save": "Salvar",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
|
|||||||
@ -221,6 +221,19 @@
|
|||||||
"customize": "Настроить",
|
"customize": "Настроить",
|
||||||
"save": "Сохранить настройки"
|
"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": {
|
"common": {
|
||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
|
|||||||
@ -221,6 +221,19 @@
|
|||||||
"customize": "Tercihleri Özelleştir",
|
"customize": "Tercihleri Özelleştir",
|
||||||
"save": "Tercihleri Kaydet"
|
"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": {
|
"common": {
|
||||||
"save": "Kaydet",
|
"save": "Kaydet",
|
||||||
"cancel": "İptal",
|
"cancel": "İptal",
|
||||||
|
|||||||
@ -221,6 +221,19 @@
|
|||||||
"customize": "自定义设置",
|
"customize": "自定义设置",
|
||||||
"save": "保存偏好"
|
"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": {
|
"common": {
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
|
|||||||
@ -919,7 +919,7 @@ const PluginDetail = () => {
|
|||||||
wordBreak: 'break-word'
|
wordBreak: 'break-word'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '0.5rem' }}>
|
<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>
|
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{formatDate(r.replied_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<ExpandableText
|
<ExpandableText
|
||||||
@ -1407,7 +1407,7 @@ const PluginDetail = () => {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
background: 'linear-gradient(to right, rgba(255, 117, 24, 0.05), transparent)'
|
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>
|
||||||
|
|
||||||
<div style={{ padding: '24px', overflowY: 'auto', flex: 1 }}>
|
<div style={{ padding: '24px', overflowY: 'auto', flex: 1 }}>
|
||||||
|
|||||||
@ -3,15 +3,16 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 70vh;
|
min-height: 70vh;
|
||||||
background-color: #f9f9f9;
|
background-color: transparent;
|
||||||
font-family: sans-serif;
|
padding: 40px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-email-box {
|
.check-email-box {
|
||||||
background: #fff;
|
background: var(--surface, #141414);
|
||||||
|
border: 1px solid var(--border, #2A2A2A);
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
max-width: 450px;
|
max-width: 450px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -19,34 +20,35 @@
|
|||||||
|
|
||||||
.check-email-icon {
|
.check-email-icon {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
color: #4CAF50; /* A nice green */
|
color: var(--success, #4ADE80);
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-email-box h1 {
|
.check-email-box h1 {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
color: #333;
|
color: var(--text, #EAEAEA);
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-email-box p {
|
.check-email-box p {
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #666;
|
color: var(--text-muted, #888888);
|
||||||
margin-bottom: 25px;
|
margin-bottom: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-email-box strong {
|
.check-email-box strong {
|
||||||
color: #333;
|
color: var(--text, #EAEAEA);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-email-box hr {
|
.check-email-box hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid #eee;
|
border-top: 1px solid var(--border, #2A2A2A);
|
||||||
margin: 25px 0;
|
margin: 25px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-email-footer {
|
.check-email-footer {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #999;
|
color: var(--text-muted, #888888);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,12 +100,10 @@ const ForgotPasswordPage = () => {
|
|||||||
.auth-card {
|
.auth-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 440px;
|
max-width: 440px;
|
||||||
background: #ffffff;
|
background: var(--surface, #141414);
|
||||||
|
border: 1px solid var(--border, #2A2A2A);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow:
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
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);
|
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
animation: cardIn 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
|
animation: cardIn 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
}
|
}
|
||||||
@ -119,8 +117,8 @@ const ForgotPasswordPage = () => {
|
|||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #0a2540;
|
color: var(--text, #EAEAEA);
|
||||||
letter-spacing: -0.4px;
|
letter-spacing: -0.4px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
@ -128,7 +126,7 @@ const ForgotPasswordPage = () => {
|
|||||||
.card-subtitle {
|
.card-subtitle {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #697386;
|
color: var(--text-muted, #888888);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,8 +135,8 @@ const ForgotPasswordPage = () => {
|
|||||||
.field-label {
|
.field-label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: #3c4257;
|
color: var(--text, #EAEAEA);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
@ -151,28 +149,38 @@ const ForgotPasswordPage = () => {
|
|||||||
padding: 0 40px 0 14px;
|
padding: 0 40px 0 14px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: #0a2540;
|
color: var(--text, #EAEAEA);
|
||||||
background: #ffffff;
|
background: rgba(255, 255, 255, 0.05);
|
||||||
border: 1.5px solid #e0e5ec;
|
border: 1.5px solid var(--border, #2A2A2A);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
outline: none;
|
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;
|
-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 {
|
.auth-input:focus {
|
||||||
border-color: #ff6b00;
|
border-color: var(--primary, #FF7518);
|
||||||
box-shadow: 0 0 0 3px rgba(255, 107, 0, 0.16);
|
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 {
|
.auth-input.email-invalid {
|
||||||
border-color: #df1b41 !important;
|
border-color: #ef4444 !important;
|
||||||
box-shadow: 0 0 0 3px rgba(223, 27, 65, 0.12) !important;
|
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-input.email-valid {
|
.auth-input.email-valid {
|
||||||
border-color: #1a9e5f !important;
|
border-color: #22c55e !important;
|
||||||
box-shadow: 0 0 0 3px rgba(26, 158, 95, 0.12) !important;
|
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-status-icon {
|
.field-status-icon {
|
||||||
@ -194,47 +202,54 @@ const ForgotPasswordPage = () => {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-message.invalid { color: #df1b41; }
|
.field-message.invalid { color: #ef4444; }
|
||||||
|
|
||||||
.error-banner {
|
.error-banner {
|
||||||
background: #fff8f8;
|
background: rgba(239, 68, 68, 0.12);
|
||||||
border: 1.5px solid #fecdd3;
|
border: 1.5px solid rgba(239, 68, 68, 0.35);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
color: #df1b41;
|
color: #ef4444;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-banner {
|
.success-banner {
|
||||||
background: #f0fff4;
|
background: rgba(34, 197, 94, 0.12);
|
||||||
border: 1.5px solid #c6f6d5;
|
border: 1.5px solid rgba(34, 197, 94, 0.35);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
color: #2f855a;
|
color: #22c55e;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-btn {
|
.submit-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 46px;
|
height: 46px;
|
||||||
background: #ff6b00;
|
background: var(--primary, #FF7518);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease;
|
transition: background 0.15s ease, box-shadow 0.15s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-btn:hover:not(:disabled) { background: #e85d00; }
|
.submit-btn:hover:not(:disabled) {
|
||||||
.submit-btn:disabled { background: #ffb380; cursor: not-allowed; }
|
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 {
|
.spinner {
|
||||||
width: 16px; height: 16px;
|
width: 16px; height: 16px;
|
||||||
@ -246,15 +261,18 @@ const ForgotPasswordPage = () => {
|
|||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@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 {
|
.card-footer a {
|
||||||
color: #ff6b00;
|
color: var(--primary, #FF7518);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-footer a:hover { color: #ff944d; text-decoration: underline; }
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
<div className="auth-page">
|
<div className="auth-page">
|
||||||
@ -286,14 +304,14 @@ const ForgotPasswordPage = () => {
|
|||||||
/>
|
/>
|
||||||
{emailIsInvalid && (
|
{emailIsInvalid && (
|
||||||
<svg className="field-status-icon" viewBox="0 0 20 20" fill="none">
|
<svg className="field-status-icon" viewBox="0 0 20 20" fill="none">
|
||||||
<circle cx="10" cy="10" r="9" stroke="#df1b41" strokeWidth="1.5"/>
|
<circle cx="10" cy="10" r="9" stroke="#ef4444" strokeWidth="1.5"/>
|
||||||
<path d="M7 7l6 6M13 7l-6 6" stroke="#df1b41" strokeWidth="1.5" strokeLinecap="round"/>
|
<path d="M7 7l6 6M13 7l-6 6" stroke="#ef4444" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
{emailIsValid && (
|
{emailIsValid && (
|
||||||
<svg className="field-status-icon" viewBox="0 0 20 20" fill="none">
|
<svg className="field-status-icon" viewBox="0 0 20 20" fill="none">
|
||||||
<circle cx="10" cy="10" r="9" stroke="#1a9e5f" strokeWidth="1.5"/>
|
<circle cx="10" cy="10" r="9" stroke="#22c55e" strokeWidth="1.5"/>
|
||||||
<path d="M6 10l3 3 5-5" stroke="#1a9e5f" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
<path d="M6 10l3 3 5-5" stroke="#22c55e" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -309,7 +327,7 @@ const ForgotPasswordPage = () => {
|
|||||||
onSuccess={(token) => setCaptchaToken(token)}
|
onSuccess={(token) => setCaptchaToken(token)}
|
||||||
onExpire={() => setCaptchaToken(null)}
|
onExpire={() => setCaptchaToken(null)}
|
||||||
onError={() => setCaptchaToken(null)}
|
onError={() => setCaptchaToken(null)}
|
||||||
options={{ theme: 'light', appearance: 'interaction-only' } as any}
|
options={{ theme: 'dark', appearance: 'interaction-only' } as any}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -120,17 +120,6 @@ const LoginPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{`
|
<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 {
|
.login-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -142,12 +131,10 @@ const LoginPage = () => {
|
|||||||
.login-card {
|
.login-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 440px;
|
max-width: 440px;
|
||||||
background: #ffffff;
|
background: var(--surface, #141414);
|
||||||
|
border: 1px solid var(--border, #2A2A2A);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow:
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
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);
|
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
animation: cardIn 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
|
animation: cardIn 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
}
|
}
|
||||||
@ -161,8 +148,8 @@ const LoginPage = () => {
|
|||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #0a2540;
|
color: var(--text, #EAEAEA);
|
||||||
letter-spacing: -0.4px;
|
letter-spacing: -0.4px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
@ -170,7 +157,7 @@ const LoginPage = () => {
|
|||||||
.card-subtitle {
|
.card-subtitle {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #697386;
|
color: var(--text-muted, #888888);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -179,8 +166,8 @@ const LoginPage = () => {
|
|||||||
.field-label {
|
.field-label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: #3c4257;
|
color: var(--text, #EAEAEA);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
@ -193,37 +180,38 @@ const LoginPage = () => {
|
|||||||
padding: 0 40px 0 14px;
|
padding: 0 40px 0 14px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: #0a2540;
|
color: var(--text, #EAEAEA);
|
||||||
background: #ffffff;
|
background: rgba(255, 255, 255, 0.05);
|
||||||
border: 1.5px solid #e0e5ec;
|
border: 1.5px solid var(--border, #2A2A2A);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
outline: none;
|
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;
|
-webkit-appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-input::placeholder { color: #a3acb9; font-size: 14px; }
|
.login-input::placeholder { color: var(--text-muted, #888888); font-size: 14px; }
|
||||||
.login-input:hover:not(:disabled) { border-color: #c1c9d5; }
|
.login-input:hover:not(:disabled) { border-color: rgba(255, 255, 255, 0.25); }
|
||||||
|
|
||||||
.login-input:focus {
|
.login-input:focus {
|
||||||
border-color: #ff6b00;
|
border-color: var(--primary, #FF7518);
|
||||||
box-shadow: 0 0 0 3px rgba(255, 107, 0, 0.16);
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
box-shadow: 0 0 0 3px rgba(255, 117, 24, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-input:disabled {
|
.login-input:disabled {
|
||||||
background: #f7fafc;
|
background: rgba(255, 255, 255, 0.02);
|
||||||
color: #a3acb9;
|
color: var(--text-muted, #666666);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-input.email-invalid {
|
.login-input.email-invalid {
|
||||||
border-color: #df1b41 !important;
|
border-color: #ef4444 !important;
|
||||||
box-shadow: 0 0 0 3px rgba(223, 27, 65, 0.12) !important;
|
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-input.email-valid {
|
.login-input.email-valid {
|
||||||
border-color: #1a9e5f !important;
|
border-color: #22c55e !important;
|
||||||
box-shadow: 0 0 0 3px rgba(26, 158, 95, 0.12) !important;
|
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* no-icon variant (password field) */
|
/* no-icon variant (password field) */
|
||||||
@ -260,15 +248,15 @@ const LoginPage = () => {
|
|||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-message.invalid { color: #df1b41; }
|
.field-message.invalid { color: #ef4444; }
|
||||||
.field-message.valid { color: #1a9e5f; }
|
.field-message.valid { color: #22c55e; }
|
||||||
|
|
||||||
.error-banner {
|
.error-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
background: #fff8f8;
|
background: rgba(239, 68, 68, 0.12);
|
||||||
border: 1.5px solid #fecdd3;
|
border: 1.5px solid rgba(239, 68, 68, 0.35);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
@ -283,18 +271,18 @@ const LoginPage = () => {
|
|||||||
80% { transform: translateX(3px); }
|
80% { transform: translateX(3px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-icon { flex-shrink: 0; width: 16px; height: 16px; margin-top: 1px; color: #df1b41; }
|
.error-icon { flex-shrink: 0; width: 16px; height: 16px; margin-top: 1px; color: #ef4444; }
|
||||||
.error-text { font-size: 13px; color: #df1b41; line-height: 1.5; }
|
.error-text { font-size: 13px; color: #ef4444; line-height: 1.5; }
|
||||||
|
|
||||||
.submit-btn {
|
.submit-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 46px;
|
height: 46px;
|
||||||
background: #ff6b00;
|
background: var(--primary, #FF7518);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
letter-spacing: -0.1px;
|
letter-spacing: -0.1px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -308,14 +296,14 @@ const LoginPage = () => {
|
|||||||
|
|
||||||
.submit-btn:hover:not(:disabled) {
|
.submit-btn:hover:not(:disabled) {
|
||||||
background: #e85d00;
|
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);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-btn:active:not(:disabled) { transform: translateY(0); box-shadow: none; }
|
.submit-btn:active:not(:disabled) { transform: translateY(0); box-shadow: none; }
|
||||||
|
|
||||||
.submit-btn:disabled {
|
.submit-btn:disabled {
|
||||||
background: #ffb380;
|
background: rgba(255, 117, 24, 0.5);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
transform: none;
|
transform: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@ -331,18 +319,18 @@ const LoginPage = () => {
|
|||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@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 {
|
.card-footer a {
|
||||||
color: #ff6b00;
|
color: var(--primary, #FF7518);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-footer a:hover { color: #e85d00; text-decoration: underline; }
|
.card-footer a:hover { color: #ff944d; text-decoration: underline; }
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
<div className="login-page">
|
<div className="login-page">
|
||||||
@ -369,7 +357,7 @@ const LoginPage = () => {
|
|||||||
{requires2fa ? (
|
{requires2fa ? (
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<label className="field-label" htmlFor="totpCode">Authentication Code</label>
|
<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">
|
<div className="field-input-wrapper">
|
||||||
<input
|
<input
|
||||||
id="totpCode"
|
id="totpCode"
|
||||||
@ -410,14 +398,14 @@ const LoginPage = () => {
|
|||||||
/>
|
/>
|
||||||
{emailIsInvalid && (
|
{emailIsInvalid && (
|
||||||
<svg key="x" className="field-status-icon" viewBox="0 0 20 20" fill="none">
|
<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"/>
|
<circle cx="10" cy="10" r="9" stroke="#ef4444" strokeWidth="1.5"/>
|
||||||
<path d="M7 7l6 6M13 7l-6 6" stroke="#df1b41" strokeWidth="1.5" strokeLinecap="round"/>
|
<path d="M7 7l6 6M13 7l-6 6" stroke="#ef4444" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
{emailIsValid && (
|
{emailIsValid && (
|
||||||
<svg key="check" className="field-status-icon" viewBox="0 0 20 20" fill="none">
|
<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"/>
|
<circle cx="10" cy="10" r="9" stroke="#22c55e" strokeWidth="1.5"/>
|
||||||
<path d="M6 10l3 3 5-5" stroke="#1a9e5f" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
<path d="M6 10l3 3 5-5" stroke="#22c55e" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -434,7 +422,7 @@ const LoginPage = () => {
|
|||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
|
||||||
<label className="field-label" htmlFor="password" style={{ marginBottom: 0 }}>{t('auth.password')}</label>
|
<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?
|
Forgot password?
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -463,7 +451,7 @@ const LoginPage = () => {
|
|||||||
onSuccess={(token) => setCaptchaToken(token)}
|
onSuccess={(token) => setCaptchaToken(token)}
|
||||||
onExpire={() => setCaptchaToken(null)}
|
onExpire={() => setCaptchaToken(null)}
|
||||||
onError={() => setCaptchaToken(null)}
|
onError={() => setCaptchaToken(null)}
|
||||||
options={{ theme: 'light', appearance: 'interaction-only' } as any}
|
options={{ theme: 'dark', appearance: 'interaction-only' } as any}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -114,9 +114,6 @@ const RegisterPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{`
|
<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 {
|
.register-page {
|
||||||
display: flex; flex-direction: column; align-items: center;
|
display: flex; flex-direction: column; align-items: center;
|
||||||
justify-content: center; padding: 40px 20px; position: relative;
|
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 reqIn { from{opacity:0;transform:translateY(-6px)} to{opacity:1;transform:translateY(0)} }
|
||||||
@keyframes barFill { from{width: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 {
|
.register-card {
|
||||||
width:100%; max-width:440px; background:#ffffff; border-radius:12px;
|
width:100%; max-width:440px; background:var(--surface, #141414); 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);
|
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;
|
padding:40px; animation: cardIn 0.35s cubic-bezier(0.22,1,0.36,1) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header { margin-bottom:28px; }
|
.card-header { margin-bottom:28px; }
|
||||||
.card-title { font-size:22px; font-weight:600; color:#0a2540; letter-spacing:-0.4px; line-height:1.3; }
|
.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:#697386; line-height:1.5; }
|
.card-subtitle { margin-top:6px; font-size:14px; color:var(--text-muted, #888888); line-height:1.5; }
|
||||||
|
|
||||||
.form-field { margin-bottom:18px; }
|
.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-wrapper { position:relative; }
|
||||||
|
|
||||||
.field-input {
|
.field-input {
|
||||||
width:100%; height:44px; padding:0 40px 0 14px; font-size:15px; font-family:inherit;
|
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;
|
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; -webkit-appearance:none;
|
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::placeholder { color:var(--text-muted, #888888); font-size:14px; }
|
||||||
.field-input:hover:not(:disabled) { border-color:#c1c9d5; }
|
.field-input:hover:not(:disabled) { border-color:rgba(255, 255, 255, 0.25); }
|
||||||
.field-input:focus { border-color:#ff6b00; box-shadow:0 0 0 3px rgba(255,107,0,.16); }
|
.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:#f7fafc; color:#a3acb9; cursor:not-allowed; }
|
.field-input:disabled { background:rgba(255, 255, 255, 0.02); color:var(--text-muted, #666666); 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-invalid { border-color:#ef4444 !important; box-shadow:0 0 0 3px rgba(239,68,68,.2) !important; }
|
||||||
.field-input.email-valid { border-color:#1a9e5f !important; box-shadow:0 0 0 3px rgba(26,158,95,.12) !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 */
|
/* password has extra right padding for the toggle button */
|
||||||
.field-input.password-input { padding-right: 44px; }
|
.field-input.password-input { padding-right: 44px; }
|
||||||
@ -176,21 +169,21 @@ const RegisterPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* toggle show/hide password */
|
/* toggle show/hide password */
|
||||||
.password-toggle {
|
.pw-toggle-btn {
|
||||||
position:absolute; right:12px; top:50%; transform:translateY(-50%);
|
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;
|
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 {
|
.field-message {
|
||||||
display:flex; align-items:center; gap:5px; margin-top:5px;
|
display:flex; align-items:center; gap:5px; margin-top:5px;
|
||||||
font-size:12.5px; font-weight:500;
|
font-size:12.5px; font-weight:500;
|
||||||
animation: msgSlide 0.18s cubic-bezier(0.22,1,0.36,1) both;
|
animation: msgSlide 0.18s cubic-bezier(0.22,1,0.36,1) both;
|
||||||
}
|
}
|
||||||
.field-message.invalid { color:#df1b41; }
|
.field-message.invalid { color:#ef4444; }
|
||||||
.field-message.valid { color:#1a9e5f; }
|
.field-message.valid { color:#22c55e; }
|
||||||
|
|
||||||
/* ── Strength meter ── */
|
/* ── Strength meter ── */
|
||||||
.strength-meter { margin-top:10px; animation: reqIn 0.2s cubic-bezier(0.22,1,0.36,1) both; }
|
.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;
|
display:flex; gap:4px; margin-bottom:6px;
|
||||||
}
|
}
|
||||||
.strength-bar {
|
.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;
|
overflow:hidden; position:relative;
|
||||||
}
|
}
|
||||||
.strength-bar-fill {
|
.strength-bar-fill {
|
||||||
@ -209,62 +202,63 @@ const RegisterPage = () => {
|
|||||||
|
|
||||||
.strength-label-row {
|
.strength-label-row {
|
||||||
display:flex; justify-content:space-between; align-items:center;
|
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 checklist ── */
|
||||||
.requirements-list { list-style:none; display:flex; flex-direction:column; gap:4px; }
|
.requirements-list { list-style:none; display:flex; flex-direction:column; gap:4px; }
|
||||||
.req-item {
|
.req-item {
|
||||||
display:flex; align-items:center; gap:6px;
|
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;
|
transition: color 0.2s ease;
|
||||||
}
|
}
|
||||||
.req-item.met { color:#1a9e5f; }
|
.req-item.met { color:#22c55e; }
|
||||||
.req-item.unmet-touched { color:#df1b41; }
|
.req-item.unmet-touched { color:#ef4444; }
|
||||||
|
|
||||||
.req-icon {
|
.req-icon {
|
||||||
width:14px; height:14px; flex-shrink:0;
|
width:14px; height:14px; flex-shrink:0;
|
||||||
border-radius:50%; display:flex; align-items:center; justify-content:center;
|
border-radius:50%; display:flex; align-items:center; justify-content:center;
|
||||||
transition: background 0.2s, border-color 0.2s;
|
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.met .req-icon { background:rgba(34, 197, 94, 0.2); border-color:#22c55e; }
|
||||||
.req-item.unmet-touched .req-icon { border-color:#df1b41; }
|
.req-item.unmet-touched .req-icon { border-color:#ef4444; }
|
||||||
|
|
||||||
/* Error banner */
|
/* Error banner */
|
||||||
.error-banner {
|
.error-banner {
|
||||||
display:flex; align-items:flex-start; gap:10px;
|
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;
|
padding:12px 14px; margin-bottom:20px;
|
||||||
animation: shake 0.3s cubic-bezier(0.36,0.07,0.19,0.97);
|
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-icon { flex-shrink:0; width:16px; height:16px; margin-top:1px; color:#ef4444; }
|
||||||
.error-text { font-size:13px; color:#df1b41; line-height:1.5; }
|
.error-text { font-size:13px; color:#ef4444; line-height:1.5; }
|
||||||
|
|
||||||
.captcha-container { display:flex; justify-content:center; margin-bottom:20px; margin-top:4px; }
|
.captcha-container { display:flex; justify-content:center; margin-bottom:20px; margin-top:4px; }
|
||||||
|
|
||||||
.submit-btn {
|
.submit-btn {
|
||||||
width:100%; height:46px; background:#ff6b00; color:white; border:none;
|
width:100%; height:46px; background:var(--primary, #FF7518); color:white; border:none;
|
||||||
border-radius:7px; font-size:15px; font-weight:500; font-family:inherit;
|
border-radius:7px; font-size:15px; font-weight:600; font-family:inherit;
|
||||||
letter-spacing:-0.1px; cursor:pointer;
|
letter-spacing:-0.1px; cursor:pointer;
|
||||||
transition:background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
|
transition:background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
|
||||||
display:flex; align-items:center; justify-content:center; gap:8px;
|
display:flex; align-items:center; justify-content:center; gap:8px;
|
||||||
position:relative; overflow:hidden;
|
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: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; }
|
.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; }
|
.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; text-decoration:none; font-weight:500; transition:color 0.15s; }
|
.card-footer a { color:var(--primary, #FF7518); text-decoration:none; 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; }
|
||||||
|
|
||||||
.terms-note { text-align:center; font-size:12px; color:#a3acb9; margin-top:16px; line-height:1.6; }
|
.terms-note { text-align:center; font-size:12px; color:var(--text-muted, #888888); margin-top:16px; line-height:1.6; }
|
||||||
.terms-note a { color:#697386; text-decoration:underline; text-underline-offset:2px; }
|
.terms-note a { color:var(--text, #EAEAEA); text-decoration:underline; text-underline-offset:2px; }
|
||||||
|
.terms-note a:hover { color:var(--primary, #FF7518); }
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
<div className="register-page">
|
<div className="register-page">
|
||||||
@ -441,7 +435,7 @@ const RegisterPage = () => {
|
|||||||
onSuccess={(token) => setCaptchaToken(token)}
|
onSuccess={(token) => setCaptchaToken(token)}
|
||||||
onExpire={() => setCaptchaToken(null)}
|
onExpire={() => setCaptchaToken(null)}
|
||||||
onError={() => setCaptchaToken(null)}
|
onError={() => setCaptchaToken(null)}
|
||||||
options={{ theme: 'light', appearance: 'interaction-only' } as any}
|
options={{ theme: 'dark', appearance: 'interaction-only' } as any}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -73,12 +73,10 @@ const ResetPasswordPage = () => {
|
|||||||
.auth-card {
|
.auth-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 440px;
|
max-width: 440px;
|
||||||
background: #ffffff;
|
background: var(--surface, #141414);
|
||||||
|
border: 1px solid var(--border, #2A2A2A);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow:
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
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);
|
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
animation: cardIn 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
|
animation: cardIn 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
}
|
}
|
||||||
@ -92,8 +90,8 @@ const ResetPasswordPage = () => {
|
|||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #0a2540;
|
color: var(--text, #EAEAEA);
|
||||||
letter-spacing: -0.4px;
|
letter-spacing: -0.4px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
@ -101,7 +99,7 @@ const ResetPasswordPage = () => {
|
|||||||
.card-subtitle {
|
.card-subtitle {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #697386;
|
color: var(--text-muted, #888888);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,8 +108,8 @@ const ResetPasswordPage = () => {
|
|||||||
.field-label {
|
.field-label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: #3c4257;
|
color: var(--text, #EAEAEA);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
@ -122,48 +120,65 @@ const ResetPasswordPage = () => {
|
|||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: #0a2540;
|
color: var(--text, #EAEAEA);
|
||||||
background: #ffffff;
|
background: rgba(255, 255, 255, 0.05);
|
||||||
border: 1.5px solid #e0e5ec;
|
border: 1.5px solid var(--border, #2A2A2A);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
outline: none;
|
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 {
|
.auth-input:focus {
|
||||||
border-color: #ff6b00;
|
border-color: var(--primary, #FF7518);
|
||||||
box-shadow: 0 0 0 3px rgba(255, 107, 0, 0.16);
|
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 {
|
.error-banner {
|
||||||
background: #fff8f8;
|
background: rgba(239, 68, 68, 0.12);
|
||||||
border: 1.5px solid #fecdd3;
|
border: 1.5px solid rgba(239, 68, 68, 0.35);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
color: #df1b41;
|
color: #ef4444;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-btn {
|
.submit-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 46px;
|
height: 46px;
|
||||||
background: #ff6b00;
|
background: var(--primary, #FF7518);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease;
|
transition: background 0.15s ease, box-shadow 0.15s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-btn:hover:not(:disabled) { background: #e85d00; }
|
.submit-btn:hover:not(:disabled) {
|
||||||
.submit-btn:disabled { background: #ffb380; cursor: not-allowed; }
|
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 {
|
.spinner {
|
||||||
width: 16px; height: 16px;
|
width: 16px; height: 16px;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user