fix: some bugs

This commit is contained in:
Alexander Medvedev 2026-08-27 21:54:06 +02:00
parent b280372eb7
commit b162833243
20 changed files with 1080 additions and 211 deletions

View File

@ -216,7 +216,7 @@ h3 {
.nav-search-input-wrap input {
width: 100%;
padding: 8px 16px 8px 40px;
padding: 8px 34px 8px 40px;
border-radius: 20px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.05);
@ -226,6 +226,26 @@ h3 {
transition: all 0.2s ease;
}
.nav-search-clear {
position: absolute;
right: 12px;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 3px;
border-radius: 50%;
transition: color 0.15s, background-color 0.15s;
}
.nav-search-clear:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.1);
}
.nav-search-input-wrap input::placeholder {
color: var(--text-muted);
}

View File

@ -1,7 +1,7 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route, Link, NavLink, useNavigate, Outlet, Navigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Search, Store } from 'lucide-react';
import { Search, Store, X } from 'lucide-react';
import Home from './pages/Home';
import PluginDetail from './pages/PluginDetail';
import LoginPage from './pages/auth/LoginPage';
@ -272,18 +272,45 @@ const DashboardLayout = () => {
// --- Components ---
const Navbar = ({ user }: any) => {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState('');
const location = useLocation();
const [searchQuery, setSearchQuery] = useState(() => {
if (typeof window !== 'undefined' && window.location.pathname === '/search') {
return new URLSearchParams(window.location.search).get('q') || '';
}
return '';
});
const [isMenuOpen, setIsMenuOpen] = useState(false);
const navigate = useNavigate();
const handleSearch = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' && searchQuery.trim()) {
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
// 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);
return (
@ -319,12 +346,26 @@ const Navbar = ({ user }: any) => {
<div className="nav-search-input-wrap">
<Search size={16} className="nav-search-icon" />
<input
type="text"
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>

View File

@ -177,13 +177,16 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
</div>
<div className="dev-form-group">
<label>{t('developer.onboarding.display_name_label')}</label>
<label htmlFor="onboardingDisplayName">{t('developer.onboarding.display_name_label')}</label>
<input
id="onboardingDisplayName"
name="displayName"
type="text"
className="dev-input"
placeholder={t('developer.onboarding.display_name_placeholder')}
value={displayName}
onChange={e => setDisplayName(e.target.value)}
autoComplete="nickname"
maxLength={60}
autoFocus
/>
@ -234,51 +237,63 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
{isSellingPaid && step === 2 && (
<div className="dev-step-content">
<div className="dev-form-group">
<label className="dev-field-label">
<label className="dev-field-label" htmlFor="onboardingLegalName">
<ShieldCheck size={14} /> {t('developer.onboarding.legal_name_label')}
</label>
<input
id="onboardingLegalName"
name="legalName"
type="text"
className="dev-input"
value={legalName}
onChange={e => setLegalName(e.target.value)}
placeholder={entityType === 'individual' ? 'First and Last Name' : 'Legal Company Name Inc.'}
autoComplete="name"
required
/>
</div>
<div className="dev-form-group">
<label className="dev-field-label"><MapPin size={14} /> {t('developer.onboarding.street_label')}</label>
<label className="dev-field-label" htmlFor="onboardingStreetAddress"><MapPin size={14} /> {t('developer.onboarding.street_label')}</label>
<input
id="onboardingStreetAddress"
name="streetAddress"
type="text"
className="dev-input"
value={streetAddress}
onChange={e => setStreetAddress(e.target.value)}
placeholder="123 Main St, Suite 400"
autoComplete="street-address"
required
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div className="dev-form-group">
<label className="dev-field-label">{t('developer.onboarding.city_label')}</label>
<label className="dev-field-label" htmlFor="onboardingCity">{t('developer.onboarding.city_label')}</label>
<input
id="onboardingCity"
name="city"
type="text"
className="dev-input"
value={city}
onChange={e => setCity(e.target.value)}
placeholder="City"
autoComplete="address-level2"
required
/>
</div>
<div className="dev-form-group">
<label className="dev-field-label">{t('developer.onboarding.postal_label')}</label>
<label className="dev-field-label" htmlFor="onboardingPostalCode">{t('developer.onboarding.postal_label')}</label>
<input
id="onboardingPostalCode"
name="postalCode"
type="text"
className="dev-input"
value={postalCode}
onChange={e => setPostalCode(e.target.value)}
placeholder="10001"
autoComplete="postal-code"
required
/>
</div>
@ -286,11 +301,14 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div className="dev-form-group">
<label className="dev-field-label"><Globe size={14} /> {t('developer.onboarding.country_label')}</label>
<label className="dev-field-label" htmlFor="onboardingCountry"><Globe size={14} /> {t('developer.onboarding.country_label')}</label>
<select
id="onboardingCountry"
name="country"
className="dev-input dev-select"
value={country}
onChange={e => setCountry(e.target.value)}
autoComplete="country"
required
>
<option value="">Select country...</option>
@ -301,13 +319,16 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
</div>
<div className="dev-form-group">
<label className="dev-field-label">{t('developer.onboarding.vat_label')}</label>
<label className="dev-field-label" htmlFor="onboardingVatId">{t('developer.onboarding.vat_label')}</label>
<input
id="onboardingVatId"
name="vatId"
type="text"
className="dev-input"
value={vatId}
onChange={e => setVatId(e.target.value)}
placeholder="EU123456789 or Tax ID"
autoComplete="off"
/>
</div>
</div>
@ -329,36 +350,45 @@ export const DeveloperOnboardingModal: React.FC<DeveloperOnboardingModalProps> =
{((!isSellingPaid && step === 2) || (isSellingPaid && step === 3)) && (
<div className="dev-step-content">
<div className="dev-form-group">
<label className="dev-field-label"><Mail size={14} /> {t('developer.onboarding.support_email_label')} *</label>
<label className="dev-field-label" htmlFor="onboardingSupportEmail"><Mail size={14} /> {t('developer.onboarding.support_email_label')} *</label>
<input
id="onboardingSupportEmail"
name="supportEmail"
type="email"
className="dev-input"
value={supportEmail}
onChange={e => setSupportEmail(e.target.value)}
placeholder="support@yourdomain.com"
autoComplete="email"
required
/>
</div>
<div className="dev-form-group">
<label className="dev-field-label"><Globe size={14} /> {t('developer.onboarding.website_label')}</label>
<label className="dev-field-label" htmlFor="onboardingWebsiteUrl"><Globe size={14} /> {t('developer.onboarding.website_label')}</label>
<input
id="onboardingWebsiteUrl"
name="websiteUrl"
type="url"
className="dev-input"
value={websiteUrl}
onChange={e => setWebsiteUrl(e.target.value)}
placeholder="https://yourwebsite.com"
autoComplete="url"
/>
</div>
<div className="dev-form-group">
<label className="dev-field-label"><Code2 size={14} /> {t('developer.onboarding.github_label')}</label>
<label className="dev-field-label" htmlFor="onboardingGithubUrl"><Code2 size={14} /> {t('developer.onboarding.github_label')}</label>
<input
id="onboardingGithubUrl"
name="githubUrl"
type="text"
className="dev-input"
value={githubUrl}
onChange={e => setGithubUrl(e.target.value)}
placeholder="https://github.com/yourname"
autoComplete="url"
/>
</div>

View File

@ -1157,11 +1157,16 @@ const PluginDetail = () => {
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', marginBottom: '0.5rem', letterSpacing: '0.05em' }}>Have a promo code?</label>
<div style={{ display: 'flex', gap: '8px' }}>
<input
id="detailPromoCode"
name="promoCode"
type="text"
value={couponCode}
onChange={e => setCouponCode(e.target.value.toUpperCase())}
placeholder="PROMOCODE"
disabled={validatingCoupon || appliedCoupon !== null}
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
style={{
flex: 1,
padding: '8px 12px',

View File

@ -38,6 +38,110 @@ const NAV: { key: Tab; label: string; icon: React.FC<{ size?: number }> ; danger
{ key: 'danger', label: 'Danger Zone', icon: AlertTriangle, danger: true },
];
// ── Settings Subcomponents (defined at module level to prevent remounting on state changes) ──
interface SettingsCardProps {
title?: string;
description?: string;
icon?: React.FC<{ size?: number }>;
children: React.ReactNode;
}
const SettingsCard: React.FC<SettingsCardProps> = ({ title, description, icon: Icon, children }) => (
<div className="settings-card">
{(title || Icon) && (
<div className="settings-card-header">
{Icon && <div className="settings-card-icon"><Icon size={16} /></div>}
<div>
{title && <p className="settings-card-title">{title}</p>}
{description && <p className="settings-card-desc">{description}</p>}
</div>
</div>
)}
<div className="settings-card-body">{children}</div>
</div>
);
interface FieldProps {
label: string;
hint?: string;
children: React.ReactNode;
}
const Field: React.FC<FieldProps> = ({ label, hint, children }) => (
<div className="settings-field">
<label className="settings-label">{label}</label>
{hint && <p className="settings-hint">{hint}</p>}
{children}
</div>
);
interface SaveBtnProps {
isSaving?: boolean;
label?: string;
}
const SaveBtn: React.FC<SaveBtnProps> = ({ isSaving = false, label = 'Save Changes' }) => (
<button type="submit" className="settings-btn settings-btn-primary" disabled={isSaving}>
{isSaving ? <><span className="spinner-sm" />{label.replace('Save', 'Saving')}…</> : label}
</button>
);
interface ToggleProps {
checked: boolean;
onChange: (v: boolean) => void;
label: string;
desc?: string;
}
const Toggle: React.FC<ToggleProps> = ({ checked, onChange, label, desc }) => (
<div className="settings-toggle-row">
<div>
<p className="settings-toggle-label">{label}</p>
{desc && <p className="settings-toggle-desc">{desc}</p>}
</div>
<button
type="button"
role="switch"
aria-checked={checked}
className={`settings-toggle ${checked ? 'on' : ''}`}
onClick={() => onChange(!checked)}
>
<span className="settings-toggle-thumb" />
</button>
</div>
);
interface PwInputProps {
placeholder: string;
value: string;
onChange: (v: string) => void;
show: boolean;
onToggle: () => void;
autoComplete?: string;
id?: string;
name?: string;
}
const PwInput: React.FC<PwInputProps> = ({ placeholder, value, onChange, show, onToggle, autoComplete, id, name }) => (
<div className="settings-pw-wrap">
<input
id={id}
name={name}
type={show ? 'text' : 'password'}
className="settings-input"
placeholder={placeholder}
value={value}
onChange={e => onChange(e.target.value)}
autoComplete={autoComplete}
required
/>
<button type="button" className="settings-pw-eye" onClick={onToggle} tabIndex={-1} aria-label={show ? 'Hide password' : 'Show password'}>
{show ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
);
const ProfilePage = () => {
const { user, login, logout, refreshUser } = useAuth();
const navigate = useNavigate();
@ -316,75 +420,6 @@ const ProfilePage = () => {
}
};
// ── helpers ──────────────────────────────────────────────────────────────────
const SettingsCard = ({ title, description, icon: Icon, children }: {
title: string; description?: string; icon?: React.FC<{ size?: number }>; children: React.ReactNode;
}) => (
<div className="settings-card">
{(title || Icon) && (
<div className="settings-card-header">
{Icon && <div className="settings-card-icon"><Icon size={16} /></div>}
<div>
<p className="settings-card-title">{title}</p>
{description && <p className="settings-card-desc">{description}</p>}
</div>
</div>
)}
<div className="settings-card-body">{children}</div>
</div>
);
const Field = ({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) => (
<div className="settings-field">
<label className="settings-label">{label}</label>
{hint && <p className="settings-hint">{hint}</p>}
{children}
</div>
);
const SaveBtn = ({ id, label = 'Save Changes' }: { id: string; label?: string }) => (
<button type="submit" className="settings-btn settings-btn-primary" disabled={saving[id]}>
{saving[id] ? <><span className="spinner-sm" />{label.replace('Save', 'Saving')}…</> : label}
</button>
);
const Toggle = ({ checked, onChange, label, desc }: { checked: boolean; onChange: (v: boolean) => void; label: string; desc?: string }) => (
<div className="settings-toggle-row">
<div>
<p className="settings-toggle-label">{label}</p>
{desc && <p className="settings-toggle-desc">{desc}</p>}
</div>
<button
type="button"
role="switch"
aria-checked={checked}
className={`settings-toggle ${checked ? 'on' : ''}`}
onClick={() => onChange(!checked)}
>
<span className="settings-toggle-thumb" />
</button>
</div>
);
const PwInput = ({ placeholder, value, onChange, show, onToggle }: {
placeholder: string; value: string; onChange: (v: string) => void; show: boolean; onToggle: () => void;
}) => (
<div className="settings-pw-wrap">
<input
type={show ? 'text' : 'password'}
className="settings-input"
placeholder={placeholder}
value={value}
onChange={e => onChange(e.target.value)}
required
/>
<button type="button" className="settings-pw-eye" onClick={onToggle} tabIndex={-1}>
{show ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
);
// ── render ────────────────────────────────────────────────────────────────────
return (
@ -452,22 +487,28 @@ const ProfilePage = () => {
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1.25rem' }}>
<Field label="Username">
<input
id="accountUsername"
name="username"
className="settings-input"
type="text"
value={formData.username}
onChange={e => setFormData({ ...formData, username: e.target.value })}
placeholder="Your username"
autoComplete="username"
required
/>
</Field>
<Field label="Email Address">
<input
id="accountEmail"
name="email"
className="settings-input"
type="email"
value={formData.email}
onChange={e => setFormData({ ...formData, email: e.target.value })}
placeholder="your@email.com"
autoComplete="email"
required
/>
</Field>
@ -475,9 +516,12 @@ const ProfilePage = () => {
<Field label="Country">
<select
id="accountCountry"
name="country"
className="settings-input settings-select"
value={formData.country}
onChange={e => setFormData({ ...formData, country: e.target.value })}
autoComplete="country"
required
>
<option value="">Select a country…</option>
@ -490,7 +534,7 @@ const ProfilePage = () => {
</Field>
<div style={{ marginTop: '0.5rem' }}>
<SaveBtn id="accountInfo" label="Save Changes" />
<SaveBtn isSaving={saving['accountInfo']} label="Save Changes" />
</div>
</form>
</SettingsCard>
@ -522,35 +566,44 @@ const ProfilePage = () => {
}}>
<Field label="Current Password">
<PwInput
id="currentPassword"
name="currentPassword"
placeholder="Current password"
value={formData.currentPassword}
onChange={v => setFormData({ ...formData, currentPassword: v })}
show={showCurrentPw}
onToggle={() => setShowCurrentPw(s => !s)}
autoComplete="current-password"
/>
</Field>
<Field label="New Password">
<PwInput
id="newPassword"
name="newPassword"
placeholder="New password"
value={formData.newPassword}
onChange={v => setFormData({ ...formData, newPassword: v })}
show={showNewPw}
onToggle={() => setShowNewPw(s => !s)}
autoComplete="new-password"
/>
</Field>
<Field label="Confirm New Password">
<PwInput
id="confirmPassword"
name="confirmPassword"
placeholder="Repeat new password"
value={formData.confirmPassword}
onChange={v => setFormData({ ...formData, confirmPassword: v })}
show={showConfirmPw}
onToggle={() => setShowConfirmPw(s => !s)}
autoComplete="new-password"
/>
</Field>
{formData.newPassword && formData.confirmPassword && formData.newPassword !== formData.confirmPassword && (
<p className="settings-inline-error"><AlertCircle size={13} /> Passwords do not match</p>
)}
<SaveBtn id="password" label="Update Password" />
<SaveBtn isSaving={saving['password']} label="Update Password" />
</form>
</SettingsCard>
@ -566,11 +619,14 @@ const ProfilePage = () => {
<form onSubmit={disable2fa} style={{ marginTop: '1.5rem' }}>
<Field label="Confirm with your password to disable">
<PwInput
id="disable2faPassword"
name="disable2faPassword"
placeholder="Your current password"
value={formData.disable2faPassword}
onChange={v => setFormData({ ...formData, disable2faPassword: v })}
show={show2faPw}
onToggle={() => setShow2faPw(s => !s)}
autoComplete="current-password"
/>
</Field>
<button type="submit" className="settings-btn settings-btn-danger-outline">
@ -602,12 +658,18 @@ const ProfilePage = () => {
<form onSubmit={confirm2faSetup} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', marginTop: '0.75rem' }}>
<input
type="text"
id="verify2faCode"
name="code"
className="settings-input settings-code-input"
placeholder="000 000"
maxLength={6}
maxLength={8}
value={verify2faCode}
onChange={e => setVerify2faCode(e.target.value.replace(/\D/g, ''))}
onChange={e => setVerify2faCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
autoComplete="one-time-code"
inputMode="numeric"
pattern="[0-9]*"
required
autoFocus
/>
<button type="submit" className="settings-btn settings-btn-primary">Verify & Enable</button>
</form>
@ -663,93 +725,120 @@ const ProfilePage = () => {
<Field label="Public Display Name">
<input
id="devDisplayName"
name="displayName"
className="settings-input"
type="text"
value={devProfileForm.displayName}
onChange={e => setDevProfileForm({ ...devProfileForm, displayName: e.target.value })}
autoComplete="nickname"
required
/>
</Field>
<Field label="Legal Full Name / Business Name" hint="Used for internal verification & tax compliance">
<input
id="devLegalName"
name="legalName"
className="settings-input"
type="text"
value={devProfileForm.legalName}
onChange={e => setDevProfileForm({ ...devProfileForm, legalName: e.target.value })}
autoComplete="name"
/>
</Field>
<Field label="Street Address">
<input
id="devStreetAddress"
name="streetAddress"
className="settings-input"
type="text"
value={devProfileForm.streetAddress}
onChange={e => setDevProfileForm({ ...devProfileForm, streetAddress: e.target.value })}
placeholder="123 Main St"
autoComplete="street-address"
/>
</Field>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<Field label="City">
<input
id="devCity"
name="city"
className="settings-input"
type="text"
value={devProfileForm.city}
onChange={e => setDevProfileForm({ ...devProfileForm, city: e.target.value })}
autoComplete="address-level2"
/>
</Field>
<Field label="Postal / ZIP Code">
<input
id="devPostalCode"
name="postalCode"
className="settings-input"
type="text"
value={devProfileForm.postalCode}
onChange={e => setDevProfileForm({ ...devProfileForm, postalCode: e.target.value })}
autoComplete="postal-code"
/>
</Field>
</div>
<Field label="VAT / Tax ID" hint="Optional for businesses">
<input
id="devVatId"
name="vatId"
className="settings-input"
type="text"
value={devProfileForm.vatId}
onChange={e => setDevProfileForm({ ...devProfileForm, vatId: e.target.value })}
placeholder="EU123456789"
autoComplete="off"
/>
</Field>
<Field label="Support Email" hint="Public email for plugin buyers to contact you">
<input
id="devSupportEmail"
name="supportEmail"
className="settings-input"
type="email"
value={devProfileForm.supportEmail}
onChange={e => setDevProfileForm({ ...devProfileForm, supportEmail: e.target.value })}
placeholder="support@domain.com"
autoComplete="email"
/>
</Field>
<Field label="Website URL">
<input
id="devWebsiteUrl"
name="websiteUrl"
className="settings-input"
type="url"
value={devProfileForm.websiteUrl}
onChange={e => setDevProfileForm({ ...devProfileForm, websiteUrl: e.target.value })}
placeholder="https://website.com"
autoComplete="url"
/>
</Field>
<Field label="GitHub Profile">
<input
id="devGithubUrl"
name="githubUrl"
className="settings-input"
type="text"
value={devProfileForm.githubUrl}
onChange={e => setDevProfileForm({ ...devProfileForm, githubUrl: e.target.value })}
placeholder="https://github.com/username"
autoComplete="url"
/>
</Field>
<SaveBtn id="devProfile" label="Save Developer Info" />
<SaveBtn isSaving={saving['devProfile']} label="Save Developer Info" />
</form>
</SettingsCard>
)}

View File

@ -65,7 +65,7 @@ const SearchResultsPage = () => {
return (
<div className="container">
<SEO
title={searchQuery ? `Search results for "${searchQuery}"` : "All Plugins"}
title={searchQuery ? `${searchQuery} - Plugins` : "All Plugins"}
description={`Browse our collection of Minecraft plugins. ${searchQuery ? `Showing results for ${searchQuery}.` : ''}`}
/>
@ -103,14 +103,22 @@ const SearchResultsPage = () => {
<label>Price Range ($)</label>
<div className="price-range-inputs">
<input
id="searchMinPrice"
name="minPrice"
type="number"
inputMode="decimal"
autoComplete="off"
placeholder="Min"
value={minPrice}
onChange={e => setMinPrice(e.target.value)}
/>
<span>-</span>
<input
id="searchMaxPrice"
name="maxPrice"
type="number"
inputMode="decimal"
autoComplete="off"
placeholder="Max"
value={maxPrice}
onChange={e => setMaxPrice(e.target.value)}
@ -122,10 +130,7 @@ const SearchResultsPage = () => {
<main className="search-main">
<div className="search-header">
<h1 className="search-title">
{searchQuery
? <>Results <span>for "{searchQuery}"</span></>
: 'All Plugins'
}
<span>All</span> Plugins
<small className="results-count">({plugins.length} found)</small>
</h1>
@ -159,7 +164,7 @@ const SearchResultsPage = () => {
<div className="empty-state">
<p>
{searchQuery
? `No plugins found for "${searchQuery}" with these filters.`
? `No plugins found matching your search and filters.`
: 'No plugins available with these filters.'
}
</p>

View File

@ -143,10 +143,14 @@ const AdminPanel = () => {
<div className="admin-controls">
<div className="admin-search">
<input
type="text"
id="adminSearchInput"
name="adminSearch"
type="search"
placeholder={`Search ${activeTab}...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</div>
{activeTab === 'logs' && (

View File

@ -273,12 +273,14 @@ const ForgotPasswordPage = () => {
<div className="field-input-wrapper">
<input
id="email"
name="email"
className={`auth-input${emailIsInvalid ? ' email-invalid' : ''}${emailIsValid ? ' email-valid' : ''}`}
type="email"
placeholder="you@company.com"
value={email}
onChange={handleEmailChange}
onBlur={handleEmailBlur}
autoComplete="email"
required
disabled={isSubmitting}
/>

View File

@ -373,12 +373,16 @@ const LoginPage = () => {
<div className="field-input-wrapper">
<input
id="totpCode"
name="totpCode"
className="login-input no-icon"
type="text"
placeholder="000000"
maxLength={6}
maxLength={8}
value={totpCode}
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))}
onChange={e => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
autoComplete="one-time-code"
inputMode="numeric"
pattern="[0-9]*"
required
disabled={isSubmitting}
autoFocus
@ -393,6 +397,7 @@ const LoginPage = () => {
<div className="field-input-wrapper">
<input
id="email"
name="email"
className={`login-input${emailIsInvalid ? ' email-invalid' : ''}${emailIsValid ? ' email-valid' : ''}`}
type="email"
placeholder="you@company.com"
@ -436,6 +441,7 @@ const LoginPage = () => {
<div className="field-input-wrapper">
<input
id="password"
name="password"
className="login-input no-icon"
type="password"
placeholder="••••••••"

View File

@ -292,7 +292,7 @@ const RegisterPage = () => {
<label className="field-label" htmlFor="username">{t('auth.username')}</label>
<div className="field-input-wrapper">
<input
id="username" className="field-input" type="text" placeholder="johndoe"
id="username" name="username" className="field-input" type="text" placeholder="johndoe"
onChange={e => setForm({ ...form, username: e.target.value })}
required disabled={isSubmitting} autoComplete="username"
autoCapitalize="none" spellCheck={false}
@ -306,6 +306,7 @@ const RegisterPage = () => {
<div className="field-input-wrapper">
<input
id="email"
name="email"
className={`field-input${emailIsInvalid ? ' email-invalid' : ''}${emailIsValid ? ' email-valid' : ''}`}
type="email" placeholder="you@company.com"
value={form.email}
@ -351,6 +352,7 @@ const RegisterPage = () => {
<div className="field-input-wrapper">
<input
id="password"
name="password"
className="field-input password-input"
type={showPassword ? 'text' : 'password'}
placeholder="Create a strong password"

View File

@ -190,11 +190,13 @@ const ResetPasswordPage = () => {
<label className="field-label" htmlFor="password">New Password</label>
<input
id="password"
name="password"
className="auth-input"
type="password"
placeholder="••••••••"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="new-password"
required
disabled={isSubmitting}
/>
@ -204,11 +206,13 @@ const ResetPasswordPage = () => {
<label className="field-label" htmlFor="confirmPassword">Confirm New Password</label>
<input
id="confirmPassword"
name="confirmPassword"
className="auth-input"
type="password"
placeholder="••••••••"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
disabled={isSubmitting}
/>

View File

@ -207,6 +207,348 @@
border: 1px solid var(--dash-border);
}
/* --- Developer Plugins List --- */
.dev-plugins-container {
width: 100%;
max-width: 100%;
}
.dev-plugins-card {
background: var(--dash-surface, #141414);
border: 1px solid var(--dash-border, #2a2a2a);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
}
.dev-plugins-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 1.75rem;
border-bottom: 1px solid var(--dash-border, #2a2a2a);
flex-wrap: wrap;
gap: 1rem;
}
.dev-plugins-title-area {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.dev-plugins-title {
font-size: 1.35rem;
font-weight: 700;
color: var(--dash-text, #eaeaea);
margin: 0;
}
.dev-plugins-count {
font-size: 0.85rem;
font-weight: 600;
color: var(--dash-text-muted, #888);
font-family: var(--font-mono, monospace);
background: rgba(255, 255, 255, 0.06);
padding: 2px 8px;
border-radius: 12px;
}
.dev-plugins-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1.75rem;
background: rgba(0, 0, 0, 0.15);
border-bottom: 1px solid var(--dash-border, #2a2a2a);
gap: 1rem;
flex-wrap: wrap;
}
.dev-plugins-filter-box {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--dash-border, #2a2a2a);
border-radius: 8px;
padding: 6px 12px;
width: 100%;
max-width: 320px;
transition: all 0.2s ease;
}
.dev-plugins-filter-box:focus-within {
border-color: var(--dash-primary, #f97316);
background: rgba(0, 0, 0, 0.4);
box-shadow: 0 0 10px rgba(249, 115, 22, 0.2);
}
.dev-plugins-filter-box input {
background: transparent;
border: none;
color: var(--dash-text, #eaeaea);
font-size: 0.85rem;
outline: none;
width: 100%;
}
.dev-plugins-filter-tabs {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.dev-plugins-filter-tab {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--dash-border, #2a2a2a);
color: var(--dash-text-muted, #888);
padding: 4px 12px;
border-radius: 16px;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.dev-plugins-filter-tab:hover {
color: var(--dash-text, #eaeaea);
border-color: rgba(255, 255, 255, 0.2);
}
.dev-plugins-filter-tab.active {
background: var(--dash-primary-light, rgba(249, 115, 22, 0.15));
color: var(--dash-primary, #f97316);
border-color: rgba(249, 115, 22, 0.4);
font-weight: 600;
}
.dev-plugins-table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.dev-plugins-table th {
padding: 12px 18px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--dash-text-muted, #888);
border-bottom: 1px solid var(--dash-border, #2a2a2a);
background: rgba(0, 0, 0, 0.2);
}
.dev-plugins-table td {
padding: 14px 18px;
border-bottom: 1px solid var(--dash-border, #2a2a2a);
vertical-align: middle;
transition: background 0.15s ease;
}
.dev-plugins-row:hover td {
background: rgba(255, 255, 255, 0.025);
}
.dev-plugins-row:last-child td {
border-bottom: none;
}
/* Plugin Column - Wide & Prominent */
.dev-plugins-col-main {
width: 44%;
min-width: 320px;
}
.dev-plugins-cell {
display: flex;
align-items: center;
gap: 16px;
}
.dev-plugins-thumb {
width: 116px;
height: 65px;
aspect-ratio: 16 / 9;
border-radius: 8px;
background: #181818;
border: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
position: relative;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
cursor: pointer;
}
.dev-plugins-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.dev-plugins-row:hover .dev-plugins-thumb img {
transform: scale(1.05);
}
.dev-plugins-thumb-fallback {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(249, 115, 22, 0.15), rgba(15, 23, 42, 0.9));
color: var(--dash-primary, #f97316);
font-weight: 700;
font-size: 1.25rem;
}
.dev-plugins-info {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
flex: 1;
}
.dev-plugins-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.dev-plugins-item-title {
font-size: 0.95rem;
font-weight: 600;
color: var(--dash-text, #eaeaea);
text-decoration: none;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.15s ease;
}
.dev-plugins-item-title:hover {
color: var(--dash-primary, #f97316);
}
.dev-plugins-meta-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.dev-plugins-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.07);
color: var(--dash-text-muted, #94a3b8);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.dev-plugins-badge-mono {
font-family: var(--font-mono, monospace);
}
.dev-plugins-badge-early {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
border-color: rgba(245, 158, 11, 0.3);
}
.dev-plugins-badge-paid {
background: rgba(16, 185, 129, 0.12);
color: #10b981;
border-color: rgba(16, 185, 129, 0.25);
}
.dev-plugins-hover-actions {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
opacity: 0;
transform: translateY(2px);
transition: all 0.15s ease;
}
.dev-plugins-row:hover .dev-plugins-hover-actions {
opacity: 1;
transform: translateY(0);
}
.dev-plugins-action-icon-btn {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--dash-text-secondary, #a3a3a3);
border-radius: 4px;
padding: 4px 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.dev-plugins-action-icon-btn:hover {
background: rgba(249, 115, 22, 0.15);
color: var(--dash-primary, #f97316);
border-color: rgba(249, 115, 22, 0.35);
}
/* Visibility dot & badge */
.dev-plugins-visibility-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.dev-plugins-visibility-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
font-weight: 500;
color: var(--dash-text, #eaeaea);
}
.dev-plugins-status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #10b981;
box-shadow: 0 0 6px rgba(16, 185, 129, 0.5);
}
.dev-plugins-type-pill {
font-size: 0.72rem;
color: var(--dash-text-muted, #888);
font-weight: 500;
}
.dev-plugins-date-cell {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 0.8rem;
color: var(--dash-text-muted, #888);
}
.dev-plugins-date-primary {
color: var(--dash-text, #eaeaea);
font-weight: 500;
}
@media (max-width: 768px) {
.dashboard-nav {
display: flex;

View File

@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAnalytics } from './useAnalytics';
import { useNavigate } from 'react-router-dom';
import { Settings, Plus, ExternalLink, Sparkles } from 'lucide-react';
import { Settings, Plus, ExternalLink, Sparkles, Search, BarChart2, Package } from 'lucide-react';
import { useAuth } from '../../App';
import DeveloperOnboardingModal from '../../components/DeveloperOnboardingModal';
import { getPluginUrl } from '../../utils/url';
@ -14,6 +14,8 @@ const DashboardPlugins = () => {
const { timeSeries } = useAnalytics();
const navigate = useNavigate();
const [isDevModalOpen, setIsDevModalOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState<string>('all');
// Grouping analytics data by plugin ID
const plugins = useMemo(() => {
@ -22,19 +24,56 @@ const DashboardPlugins = () => {
if (!acc[id]) {
acc[id] = {
id: id,
name: curr.name,
name: curr.name,
version: curr.version,
pluginType: curr.pluginType || curr.type,
category: curr.category,
previewPath: curr.previewPath || curr.preview_path,
priceCents: curr.priceCents || curr.price_cents || 0,
isEarlyAccess: curr.isEarlyAccess || curr.is_early_access || false,
downloads: 0,
earnings: 0,
views: 0,
avgRating: curr.avgRating || 0,
created_at: curr.created_at,
updated_at: curr.updated_at
};
}
acc[id].downloads += (curr.downloads || 0);
acc[id].earnings += (curr.earnings || 0);
acc[id].views += (curr.views || 0);
if (curr.avgRating > 0) acc[id].avgRating = curr.avgRating;
return acc;
}, {} as Record<string, any>));
}, [timeSeries]);
// Check if creator has any paid plugins or earnings
const hasPaidPlugins = useMemo(() => {
return plugins.some((p: any) =>
(p.pluginType && p.pluginType.toLowerCase() === 'paid') ||
(p.priceCents && p.priceCents > 0) ||
(p.earnings && p.earnings > 0)
);
}, [plugins]);
const filteredPlugins = useMemo(() => {
return plugins.filter((p: any) => {
const term = searchTerm.trim().toLowerCase();
const matchesSearch = !term ||
(p.name && p.name.toLowerCase().includes(term)) ||
(p.category && p.category.toLowerCase().includes(term)) ||
String(p.id).includes(term);
const type = (p.pluginType || 'free').toLowerCase();
const matchesType = filterType === 'all' ||
(filterType === 'paid' && type === 'paid') ||
(filterType === 'free' && type === 'free') ||
(filterType === 'early_access' && p.isEarlyAccess);
return matchesSearch && matchesType;
});
}, [plugins, searchTerm, filterType]);
const formatDate = (dateStr: string) => {
if (!dateStr || typeof dateStr !== 'string') return 'N/A';
const normalized = dateStr.toLowerCase().trim();
@ -47,8 +86,8 @@ const DashboardPlugins = () => {
if (!user?.is_developer) {
return (
<div className="dashboard-grid-layout">
<div className="data-card" style={{ textAlign: 'center', padding: '3.5rem 2rem' }}>
<div className="dev-plugins-container">
<div className="dev-plugins-card" style={{ textAlign: 'center', padding: '3.5rem 2rem' }}>
<div style={{
width: 64, height: 64, borderRadius: '50%',
background: 'rgba(249, 115, 22, 0.12)', border: '1px solid rgba(249, 115, 22, 0.3)',
@ -80,82 +119,250 @@ const DashboardPlugins = () => {
}
return (
<div className="dashboard-grid-layout">
<div className="data-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h3>{t('developer.dashboard.plugins')}</h3>
<div className="dev-plugins-container">
<div className="dev-plugins-card">
{/* Header */}
<div className="dev-plugins-header">
<div className="dev-plugins-title-area">
<h3 className="dev-plugins-title">{t('developer.dashboard.plugins')}</h3>
<span className="dev-plugins-count">{plugins.length}</span>
</div>
<button className="btn btn-primary" onClick={() => navigate('/dashboard/add-plugin')}>
<Plus size={18} style={{ marginRight: '8px' }} />
{t('developer.dashboard.add_plugin')}
</button>
</div>
<table className="data-table">
<thead>
<tr>
<th>{t('plugin.version')}</th>
<th>{t('developer.dashboard.total_downloads')}</th>
<th>{t('developer.dashboard.total_revenue')}</th>
<th>Dates</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{plugins.map((p: any) => (
<tr key={p.id}>
<td data-label="Name">
<div style={{ display: 'flex', flexDirection: 'column' }}>
<strong>{p.name}</strong>
<span style={{ fontSize: '0.75rem', color: 'var(--dash-text-muted)' }}>ID: {p.id}</span>
</div>
</td>
<td data-label="Downloads" className="mono">{p.downloads.toLocaleString()}</td>
<td data-label="Earnings" className="mono" style={{ color: 'var(--success)' }}>
+${p.earnings.toFixed(2)}
</td>
<td data-label="Dates">
<div style={{ fontSize: '0.8rem', color: 'var(--dash-text-muted)' }}>
<div>Created: {formatDate(p.created_at)}</div>
<div>Updated: {formatDate(p.updated_at)}</div>
</div>
</td>
<td data-label="Actions" style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
{/* View on Marketplace */}
<button
className="btn btn-icon-only"
onClick={() => navigate(getPluginUrl(p))}
title="View in Store"
>
<ExternalLink size={16} />
</button>
{/* Manage/Edit Plugin */}
<button
className="btn btn-secondary"
onClick={() => navigate(`/dashboard/manage-plugin/${p.id}`)}
style={{ display: 'flex', alignItems: 'center', gap: '4px' }}
>
<Settings size={16} />
{t('developer.dashboard.manage_plugin')}
</button>
</div>
</td>
</tr>
))}
{plugins.length === 0 && (
<tr>
<td colSpan={5} style={{ textAlign: 'center', padding: '3rem' }}>
<div style={{ opacity: 0.6 }}>
<p>No plugins published yet.</p>
<small>Click "Add New Plugin" to get started</small>
</div>
</td>
</tr>
{/* Toolbar */}
<div className="dev-plugins-toolbar">
<div className="dev-plugins-filter-box">
<Search size={15} color="var(--dash-text-muted)" />
<input
type="text"
placeholder="Filter your plugins..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</div>
<div className="dev-plugins-filter-tabs">
<button
type="button"
className={`dev-plugins-filter-tab ${filterType === 'all' ? 'active' : ''}`}
onClick={() => setFilterType('all')}
>
All ({plugins.length})
</button>
{hasPaidPlugins && (
<button
type="button"
className={`dev-plugins-filter-tab ${filterType === 'paid' ? 'active' : ''}`}
onClick={() => setFilterType('paid')}
>
Paid
</button>
)}
</tbody>
</table>
<button
type="button"
className={`dev-plugins-filter-tab ${filterType === 'free' ? 'active' : ''}`}
onClick={() => setFilterType('free')}
>
Free
</button>
<button
type="button"
className={`dev-plugins-filter-tab ${filterType === 'early_access' ? 'active' : ''}`}
onClick={() => setFilterType('early_access')}
>
Early Access
</button>
</div>
</div>
{/* Table */}
<div style={{ overflowX: 'auto' }}>
<table className="dev-plugins-table">
<thead>
<tr>
<th className="dev-plugins-col-main">Plugin</th>
<th>Visibility</th>
<th>{t('developer.dashboard.total_downloads')}</th>
{hasPaidPlugins && <th>{t('developer.dashboard.total_revenue')}</th>}
<th>Date</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{filteredPlugins.map((p: any) => {
const pluginType = (p.pluginType || 'free').toLowerCase();
const isPaid = pluginType === 'paid' || (p.priceCents && p.priceCents > 0);
const priceFormatted = isPaid ? `€${((p.priceCents || 0) / 100).toFixed(2)}` : 'Free';
const initial = (p.name || '?').charAt(0).toUpperCase();
return (
<tr key={p.id} className="dev-plugins-row">
{/* Plugin Details (Wide Column) */}
<td className="dev-plugins-col-main">
<div className="dev-plugins-cell">
{/* 16:9 Thumbnail */}
<div
className="dev-plugins-thumb"
onClick={() => navigate(`/dashboard/manage-plugin/${p.id}`)}
title="Manage Plugin"
>
{p.previewPath ? (
<img src={p.previewPath} alt={p.name} loading="lazy" />
) : (
<div className="dev-plugins-thumb-fallback">
<span>{initial}</span>
</div>
)}
</div>
{/* Info & Meta */}
<div className="dev-plugins-info">
<div className="dev-plugins-title-row">
<span
className="dev-plugins-item-title"
onClick={() => navigate(`/dashboard/manage-plugin/${p.id}`)}
title={p.name}
>
{p.name}
</span>
</div>
<div className="dev-plugins-meta-row">
<span className="dev-plugins-badge dev-plugins-badge-mono">
{p.version ? `v${p.version}` : `ID: ${p.id}`}
</span>
{p.category && (
<span className="dev-plugins-badge">
{p.category}
</span>
)}
{p.isEarlyAccess && (
<span className="dev-plugins-badge dev-plugins-badge-early">
Early Access
</span>
)}
</div>
{/* Hover Quick Actions */}
<div className="dev-plugins-hover-actions">
<button
type="button"
className="dev-plugins-action-icon-btn"
onClick={() => navigate(`/dashboard/manage-plugin/${p.id}`)}
title="Edit plugin details & settings"
>
<Settings size={13} style={{ marginRight: 3 }} />
<span style={{ fontSize: '0.72rem' }}>Edit</span>
</button>
<button
type="button"
className="dev-plugins-action-icon-btn"
onClick={() => navigate(getPluginUrl(p))}
title="View in Marketplace"
>
<ExternalLink size={13} style={{ marginRight: 3 }} />
<span style={{ fontSize: '0.72rem' }}>Store</span>
</button>
<button
type="button"
className="dev-plugins-action-icon-btn"
onClick={() => navigate(`/dashboard/manage-plugin/${p.id}?tab=analytics`)}
title="Plugin Analytics"
>
<BarChart2 size={13} style={{ marginRight: 3 }} />
<span style={{ fontSize: '0.72rem' }}>Analytics</span>
</button>
</div>
</div>
</div>
</td>
{/* Visibility & Type */}
<td>
<div className="dev-plugins-visibility-cell">
<div className="dev-plugins-visibility-status">
<span className="dev-plugins-status-dot" />
<span>Public</span>
</div>
<span className="dev-plugins-type-pill">
{isPaid ? priceFormatted : 'Free'}
</span>
</div>
</td>
{/* Total Downloads */}
<td className="mono">
{p.downloads.toLocaleString()}
</td>
{/* Total Revenue (Only shown if hasPaidPlugins is true) */}
{hasPaidPlugins && (
<td className="mono" style={{ color: p.earnings > 0 ? 'var(--success)' : 'var(--dash-text-muted)' }}>
{isPaid || p.earnings > 0 ? (
p.earnings > 0 ? `+€${p.earnings.toFixed(2)}` : '€0.00'
) : (
'—'
)}
</td>
)}
{/* Dates */}
<td>
<div className="dev-plugins-date-cell">
<div className="dev-plugins-date-primary">{formatDate(p.created_at)}</div>
<div style={{ fontSize: '0.72rem' }}>Uploaded</div>
</div>
</td>
{/* Quick Actions */}
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
className="btn btn-icon-only"
onClick={() => navigate(getPluginUrl(p))}
title="View in Store"
>
<ExternalLink size={16} />
</button>
<button
className="btn btn-secondary"
onClick={() => navigate(`/dashboard/manage-plugin/${p.id}`)}
style={{ display: 'flex', alignItems: 'center', gap: '4px', padding: '0.4rem 0.75rem', fontSize: '0.8rem' }}
>
<Settings size={14} />
{t('developer.dashboard.manage_plugin')}
</button>
</div>
</td>
</tr>
);
})}
{filteredPlugins.length === 0 && (
<tr>
<td colSpan={hasPaidPlugins ? 6 : 5} style={{ textAlign: 'center', padding: '3.5rem 1rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', opacity: 0.7 }}>
<Package size={36} color="var(--dash-text-muted)" />
<p style={{ margin: 0, fontWeight: 600, fontSize: '0.95rem' }}>
{searchTerm ? 'No plugins matched your filter.' : 'No plugins published yet.'}
</p>
<small style={{ color: 'var(--dash-text-muted)' }}>
{searchTerm ? 'Try adjusting your search or category filter.' : 'Click "Add New Plugin" to publish your first creation.'}
</small>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);

View File

@ -567,16 +567,19 @@ const AddPlugin = () => {
{/* Name */}
<div className="mp-form-group">
<label className="mp-label">
<label className="mp-label" htmlFor="pluginName">
Plugin Name <span className="ap-required">*</span>
<CharCount current={name.length} max={MAX_NAME_LEN} />
</label>
<input
id="pluginName"
name="pluginName"
className="mp-input"
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="My Awesome Plugin"
autoComplete="off"
autoFocus
style={{ borderColor: fieldErrors.name ? 'var(--mp-error)' : undefined }}
/>
@ -586,8 +589,8 @@ const AddPlugin = () => {
<div className="mp-form-row">
{/* Category */}
<div className="mp-form-group">
<label className="mp-label">Category</label>
<select className="mp-select" value={category}
<label className="mp-label" htmlFor="pluginCategory">Category</label>
<select id="pluginCategory" name="category" className="mp-select" value={category}
onChange={e => setCategory(e.target.value)}>
{PLUGIN_CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
@ -596,17 +599,20 @@ const AddPlugin = () => {
{/* Source link */}
<div className="mp-form-group">
<label className="mp-label">
<label className="mp-label" htmlFor="pluginSourceLink">
Source Link{' '}
<span style={{ color: 'var(--mp-text-3)', fontWeight: 400 }}>(optional)</span>
<CharCount current={sourceLink.length} max={MAX_SOURCE_LINK_LEN} />
</label>
<input
id="pluginSourceLink"
name="sourceLink"
className="mp-input"
type="url"
value={sourceLink}
onChange={e => setSourceLink(e.target.value)}
placeholder="https://github.com/you/plugin"
autoComplete="url"
style={{ borderColor: fieldErrors.sourceLink ? 'var(--mp-error)' : undefined }}
/>
<FieldError msg={fieldErrors.sourceLink} />
@ -616,17 +622,20 @@ const AddPlugin = () => {
<div className="mp-form-row">
{/* Keywords */}
<div className="mp-form-group">
<label className="mp-label">
<label className="mp-label" htmlFor="pluginKeywords">
Keywords{' '}
<span style={{ color: 'var(--mp-text-3)', fontWeight: 400 }}>(comma-separated)</span>
<CharCount current={keywords.length} max={MAX_KEYWORDS_LEN} />
</label>
<input
id="pluginKeywords"
name="keywords"
className="mp-input"
type="text"
value={keywords}
onChange={e => setKeywords(e.target.value)}
placeholder="economy, shop, currency"
autoComplete="off"
style={{ borderColor: fieldErrors.keywords ? 'var(--mp-error)' : undefined }}
/>
<FieldError msg={fieldErrors.keywords} />
@ -634,17 +643,20 @@ const AddPlugin = () => {
{/* YouTube Video URL */}
<div className="mp-form-group">
<label className="mp-label">
<label className="mp-label" htmlFor="pluginYoutubeUrl">
YouTube Video URL{' '}
<span style={{ color: 'var(--mp-text-3)', fontWeight: 400 }}>(optional)</span>
<CharCount current={youtubeVideoUrl.length} max={MAX_YOUTUBE_URL_LEN} />
</label>
<input
id="pluginYoutubeUrl"
name="youtubeVideoUrl"
className="mp-input"
type="url"
value={youtubeVideoUrl}
onChange={e => setYoutubeVideoUrl(e.target.value)}
placeholder="https://www.youtube.com/watch?v=..."
autoComplete="url"
style={{ borderColor: fieldErrors.youtubeVideoUrl ? 'var(--mp-error)' : undefined }}
/>
<FieldError msg={fieldErrors.youtubeVideoUrl} />
@ -655,6 +667,8 @@ const AddPlugin = () => {
<div className="mp-form-group">
<label className="mp-checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', userSelect: 'none' }}>
<input
id="pluginEarlyAccess"
name="earlyAccess"
type="checkbox"
checked={isEarlyAccess}
onChange={e => setIsEarlyAccess(e.target.checked)}
@ -672,13 +686,16 @@ const AddPlugin = () => {
<div className="mp-card">
<div className="mp-card-title"><User size={14} />Authorship</div>
<div className="mp-form-group" style={{ marginBottom: 0 }}>
<label className="mp-label">Developer Name</label>
<label className="mp-label" htmlFor="pluginDeveloperName">Developer Name</label>
<input
id="pluginDeveloperName"
name="developerName"
className="mp-input"
type="text"
value={developerName}
onChange={e => setDeveloperName(e.target.value)}
placeholder={user?.username ?? 'Developer or studio name'}
autoComplete="name"
/>
<span style={{ fontSize: '0.74rem', color: 'var(--mp-text-3)', marginTop: '0.3rem', display: 'block' }}>
The person or team who built the plugin. Leave blank to use your account name.
@ -998,7 +1015,7 @@ const AddPlugin = () => {
{licenseType === 'paid' && stripeConnected && (
<div className="mp-form-group" style={{ marginTop: '1.5rem', animation: 'fadeIn 0.2s ease' }}>
<label className="mp-label">Base Price (EUR)</label>
<label className="mp-label" htmlFor="pluginBasePrice">Base Price (EUR)</label>
{priceTooLow && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.5rem', padding: '0.45rem 0.65rem', background: 'rgba(255,77,79,0.1)', border: '1px solid rgba(255,77,79,0.35)', borderRadius: 6, fontSize: '0.75rem', color: 'var(--mp-error, #ff4d4f)' }}>
<AlertTriangle size={13} style={{ flexShrink: 0 }} />
@ -1007,9 +1024,19 @@ const AddPlugin = () => {
)}
<div style={{ position: 'relative', maxWidth: 160 }}>
<span style={{ position: 'absolute', left: '0.85rem', top: '50%', transform: 'translateY(-50%)', color: priceTooLow ? 'var(--mp-error, #ff4d4f)' : 'var(--mp-text-3)', fontSize: '0.85rem' }}>€</span>
<input className="mp-input" type="number" step="0.01" min="0.99" value={price}
<input
id="pluginBasePrice"
name="price"
className="mp-input"
type="number"
step="0.01"
min="0.99"
inputMode="decimal"
autoComplete="off"
value={price}
onChange={e => setPrice(parseFloat(e.target.value) || 0)}
style={{ paddingLeft: '1.6rem', borderColor: priceTooLow ? 'var(--mp-error, #ff4d4f)' : undefined }} />
style={{ paddingLeft: '1.6rem', borderColor: priceTooLow ? 'var(--mp-error, #ff4d4f)' : undefined }}
/>
</div>
{(() => {
const priceCents = Math.round(price * 100);
@ -1052,17 +1079,26 @@ const AddPlugin = () => {
</div>
</div>
<label className="mp-toggle" style={{ marginLeft: '1rem', flexShrink: 0 }}>
<input type="checkbox" checked={isPreorder} onChange={e => setIsPreorder(e.target.checked)} />
<input
id="pluginIsPreorder"
name="isPreorder"
type="checkbox"
checked={isPreorder}
onChange={e => setIsPreorder(e.target.checked)}
/>
<span className="mp-toggle-slider" />
</label>
</div>
{isPreorder && (
<div style={{ marginTop: '0.85rem', padding: '0.85rem', background: 'var(--mp-bg-2)', borderRadius: 8, border: '1px solid var(--mp-border)' }}>
<label className="mp-label" style={{ fontSize: '0.75rem', marginBottom: '0.35rem' }}>Expected Release Date (Optional)</label>
<label className="mp-label" htmlFor="pluginPreorderReleaseDate" style={{ fontSize: '0.75rem', marginBottom: '0.35rem' }}>Expected Release Date (Optional)</label>
<input
id="pluginPreorderReleaseDate"
name="preorderReleaseDate"
type="date"
className="mp-input"
autoComplete="off"
value={preorderReleaseDate}
onChange={e => setPreorderReleaseDate(e.target.value)}
style={{ maxWidth: 220 }}

View File

@ -96,13 +96,18 @@ const Coupons = ({ plugin }: Props) => {
</div>
<form onSubmit={handleCreate} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div className="mp-form-group">
<label className="mp-label">Promo Code</label>
<label className="mp-label" htmlFor="couponCode">Promo Code</label>
<input
id="couponCode"
name="code"
className="mp-input"
type="text"
value={code}
onChange={e => setCode(e.target.value.toUpperCase())}
placeholder="E.g., SAVE50"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
required
style={{ textTransform: 'uppercase', fontFamily: 'var(--font-mono)' }}
/>
@ -110,11 +115,15 @@ const Coupons = ({ plugin }: Props) => {
<div className="mp-form-row">
<div className="mp-form-group">
<label className="mp-label">Discount %</label>
<label className="mp-label" htmlFor="couponDiscount">Discount %</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<input
id="couponDiscount"
name="discountPercent"
className="mp-input"
type="number"
inputMode="numeric"
autoComplete="off"
min={1}
max={99}
value={discountPercent}
@ -125,10 +134,14 @@ const Coupons = ({ plugin }: Props) => {
</div>
<div className="mp-form-group">
<label className="mp-label">Max Uses (Optional)</label>
<label className="mp-label" htmlFor="couponMaxUses">Max Uses (Optional)</label>
<input
id="couponMaxUses"
name="maxRedemptions"
className="mp-input"
type="number"
inputMode="numeric"
autoComplete="off"
min={1}
value={maxRedemptions}
onChange={e => setMaxRedemptions(e.target.value)}
@ -138,10 +151,13 @@ const Coupons = ({ plugin }: Props) => {
</div>
<div className="mp-form-group">
<label className="mp-label">Expiration Date (Optional)</label>
<label className="mp-label" htmlFor="couponExpiresAt">Expiration Date (Optional)</label>
<input
id="couponExpiresAt"
name="expiresAt"
className="mp-input"
type="datetime-local"
autoComplete="off"
value={expiresAt}
onChange={e => setExpiresAt(e.target.value)}
/>

View File

@ -75,11 +75,15 @@ const DangerZone = ({ plugin }: Props) => {
<div className="mp-danger-input-row">
<div className="mp-form-group">
<input
id="deleteConfirmInput"
name="deleteConfirm"
className="mp-input"
type="text"
value={deleteConfirm}
onChange={e => setDeleteConfirm(e.target.value)}
placeholder={plugin.name}
autoComplete="off"
spellCheck={false}
style={{fontFamily:'var(--font-mono)'}}
/>
</div>

View File

@ -109,13 +109,17 @@ const Licenses = ({ plugin }: Props) => {
</p>
<form onSubmit={handleGrantLicense} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div className="mp-form-group">
<label className="mp-label">User (Username, Email, or User ID)</label>
<label className="mp-label" htmlFor="licenseUserIdentifier">User (Username, Email, or User ID)</label>
<input
id="licenseUserIdentifier"
name="userIdentifier"
type="text"
className="mp-input"
placeholder="e.g. alex or alex@example.com"
value={userIdentifier}
onChange={e => setUserIdentifier(e.target.value)}
autoComplete="off"
spellCheck={false}
required
/>
</div>

View File

@ -163,14 +163,18 @@ const Pricing = ({ plugin, onSaved }: Props) => {
)}
<div className="mp-form-group" style={{ marginBottom: 0 }}>
<label className="mp-label">Price (EUR)</label>
<label className="mp-label" htmlFor="pluginPriceInput">Price (EUR)</label>
<div style={{ position: 'relative', maxWidth: 180 }}>
<span style={{ position: 'absolute', left: '0.85rem', top: '50%', transform: 'translateY(-50%)', color: priceTooLow ? 'var(--mp-error, #ff4d4f)' : 'var(--mp-text-3)', fontSize: '0.85rem' }}>€</span>
<input
id="pluginPriceInput"
name="price"
className="mp-input"
type="number"
step="0.01"
min="0.99"
inputMode="decimal"
autoComplete="off"
value={price}
onChange={e => handlePriceChange(e.target.value)}
style={{ paddingLeft: '1.6rem', borderColor: priceTooLow ? 'var(--mp-error, #ff4d4f)' : undefined }}
@ -201,13 +205,17 @@ const Pricing = ({ plugin, onSaved }: Props) => {
{/* Discount % — always visible when paid */}
<div className="mp-form-group">
<label className="mp-label">Discount</label>
<label className="mp-label" htmlFor="pluginDiscountPercent">Discount</label>
<div style={{ position: 'relative', maxWidth: 180 }}>
<input
id="pluginDiscountPercent"
name="discountPercent"
className="mp-input"
type="number"
min="1"
max="99"
inputMode="numeric"
autoComplete="off"
value={discountPercent}
onChange={e => setDiscountPercent(Math.min(99, Math.max(1, parseInt(e.target.value) || 0)))}
style={{ paddingRight: '2.2rem' }}
@ -284,17 +292,26 @@ const Pricing = ({ plugin, onSaved }: Props) => {
</div>
</div>
<label className="mp-toggle" style={{ marginLeft: '1rem', flexShrink: 0 }}>
<input type="checkbox" checked={isPreorder} onChange={e => setIsPreorder(e.target.checked)} />
<input
id="pricingIsPreorder"
name="isPreorder"
type="checkbox"
checked={isPreorder}
onChange={e => setIsPreorder(e.target.checked)}
/>
<span className="mp-toggle-slider" />
</label>
</div>
{isPreorder && (
<div style={{ marginTop: '1rem', padding: '0.85rem', background: 'var(--mp-bg-2)', borderRadius: 8, border: '1px solid var(--mp-border)' }}>
<label className="mp-label" style={{ fontSize: '0.78rem', marginBottom: '0.35rem' }}>Expected Release Date (Optional)</label>
<label className="mp-label" htmlFor="pricingPreorderReleaseDate" style={{ fontSize: '0.78rem', marginBottom: '0.35rem' }}>Expected Release Date (Optional)</label>
<input
id="pricingPreorderReleaseDate"
name="preorderReleaseDate"
type="date"
className="mp-input"
autoComplete="off"
value={preorderReleaseDate}
onChange={e => setPreorderReleaseDate(e.target.value)}
style={{ maxWidth: 220 }}

View File

@ -166,15 +166,19 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
</div>
<div className="mp-form-group">
<label className="mp-label">
<label className="mp-label" htmlFor="pluginVersionTag">
Version Tag <span style={{color:'var(--mp-text-3)', fontWeight:400}}>(optional — e.g. 1.2.3)</span>
</label>
<input
id="pluginVersionTag"
name="version"
className="mp-input"
type="text"
value={version}
onChange={e => setVersion(e.target.value)}
placeholder="1.0.0"
autoComplete="off"
spellCheck={false}
style={{fontFamily:'var(--font-mono)'}}
/>
</div>

View File

@ -336,48 +336,79 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
</div>
<div className="mp-form-group">
<label className="mp-label">Plugin Name</label>
<input className="mp-input" type="text" value={name}
onChange={e => setName(e.target.value)} required />
<label className="mp-label" htmlFor="listingPluginName">Plugin Name</label>
<input
id="listingPluginName"
name="pluginName"
className="mp-input"
type="text"
value={name}
onChange={e => setName(e.target.value)}
autoComplete="off"
required
/>
</div>
<div className="mp-form-row">
<div className="mp-form-group">
<label className="mp-label">Category</label>
<select className="mp-select" value={category} onChange={e => setCategory(e.target.value)}>
<label className="mp-label" htmlFor="listingPluginCategory">Category</label>
<select id="listingPluginCategory" name="category" className="mp-select" value={category} onChange={e => setCategory(e.target.value)}>
{PLUGIN_CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div className="mp-form-group">
<label className="mp-label">Source Link</label>
<input className="mp-input" type="url" value={sourceLink}
<label className="mp-label" htmlFor="listingPluginSourceLink">Source Link</label>
<input
id="listingPluginSourceLink"
name="sourceLink"
className="mp-input"
type="url"
value={sourceLink}
onChange={e => setSourceLink(e.target.value)}
placeholder="https://github.com/you/plugin" />
placeholder="https://github.com/you/plugin"
autoComplete="url"
/>
</div>
</div>
<div className="mp-form-row">
<div className="mp-form-group">
<label className="mp-label">
<label className="mp-label" htmlFor="listingPluginKeywords">
Keywords <span style={{color:'var(--mp-text-3)',fontWeight:400}}>(comma-separated)</span>
</label>
<input className="mp-input" type="text" value={keywords}
<input
id="listingPluginKeywords"
name="keywords"
className="mp-input"
type="text"
value={keywords}
onChange={e => setKeywords(e.target.value)}
placeholder="economy, shop, currency" />
placeholder="economy, shop, currency"
autoComplete="off"
/>
</div>
<div className="mp-form-group">
<label className="mp-label">
<label className="mp-label" htmlFor="listingPluginYoutubeUrl">
YouTube Video URL <span style={{color:'var(--mp-text-3)',fontWeight:400}}>(optional)</span>
</label>
<input className="mp-input" type="url" value={youtubeVideoUrl}
<input
id="listingPluginYoutubeUrl"
name="youtubeVideoUrl"
className="mp-input"
type="url"
value={youtubeVideoUrl}
onChange={e => setYoutubeVideoUrl(e.target.value)}
placeholder="https://www.youtube.com/watch?v=..." />
placeholder="https://www.youtube.com/watch?v=..."
autoComplete="url"
/>
</div>
</div>
<div className="mp-form-group">
<label className="mp-checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', userSelect: 'none', marginTop: '0.5rem' }}>
<input
id="listingPluginEarlyAccess"
name="earlyAccess"
type="checkbox"
checked={isEarlyAccess}
onChange={e => setIsEarlyAccess(e.target.checked)}