added more things :D
This commit is contained in:
parent
e3b92108c1
commit
4de3327b20
497
src/App.css
497
src/App.css
@ -220,6 +220,27 @@ h3 {
|
|||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-search-right {
|
||||||
|
margin-right: 1rem;
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-search-right input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-search-right input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.nav-actions {
|
.nav-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -811,13 +832,425 @@ h3 {
|
|||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
/* Search Results Page Enhancement */
|
||||||
color: #ff4444;
|
.search-page-layout {
|
||||||
/* A distinct red for errors */
|
display: grid;
|
||||||
text-align: center;
|
grid-template-columns: 280px 1fr;
|
||||||
margin: 2rem 0;
|
gap: 3rem;
|
||||||
font-size: 1.1rem;
|
align-items: flex-start;
|
||||||
font-weight: 600;
|
}
|
||||||
|
|
||||||
|
.search-sidebar {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text-only {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text-only:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group select,
|
||||||
|
.filter-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group select:focus,
|
||||||
|
.filter-group input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-range-inputs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-range-inputs span {
|
||||||
|
color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-count {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8rem 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state .spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Unified Plugin Card (v2) --- */
|
||||||
|
.plugin-card-link {
|
||||||
|
text-decoration: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-card-v2 {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #16191f;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
will-change: transform;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-card-v2:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
|
||||||
|
border-color: rgba(255, 255, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0d0f12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-preview-img,
|
||||||
|
.pcv2-slideshow-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
transition: transform 0.35s ease, opacity 0.4s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-card-v2:hover .pcv2-preview-img {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-preview-fallback {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-family: system-ui;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-preview-fade {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(to bottom, transparent 30%, rgba(13,15,18,0.55) 65%, rgba(13,15,18,0.96) 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-category-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: rgba(13,15,18,0.72);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-price-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-price-badge.free {
|
||||||
|
background: rgba(6, 214, 160, 0.18);
|
||||||
|
border: 1px solid rgba(6, 214, 160, 0.35);
|
||||||
|
color: #06d6a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-price-badge.paid {
|
||||||
|
background: rgba(255, 209, 102, 0.16);
|
||||||
|
border: 1px solid rgba(255, 209, 102, 0.35);
|
||||||
|
color: #ffd166;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-price-badge.sale {
|
||||||
|
background: rgba(255, 107, 107, 0.18);
|
||||||
|
border: 1px solid rgba(255, 107, 107, 0.4);
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-price-original {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.55;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-sale-pct {
|
||||||
|
font-size: 0.58rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: rgba(255, 107, 107, 0.25);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-info {
|
||||||
|
padding: 0.85rem 1rem 1rem;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-name {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f0f0f0;
|
||||||
|
margin: 0 0 0.15rem;
|
||||||
|
font-family: system-ui;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-dev {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #5a6070;
|
||||||
|
margin: 0 0 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-desc {
|
||||||
|
font-size: 0.77rem;
|
||||||
|
color: #8b909a;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.9rem;
|
||||||
|
padding-top: 0.65rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #5a6070;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-stat svg {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-accent-bar {
|
||||||
|
position: absolute; bottom: 0; left: 0; right: 0;
|
||||||
|
height: 2px; opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-card-v2:hover .pcv2-accent-bar {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-sale-strip {
|
||||||
|
background: linear-gradient(90deg, rgba(255, 107, 107, 0.15), rgba(255, 107, 107, 0.05));
|
||||||
|
border-top: 1px solid rgba(255, 107, 107, 0.2);
|
||||||
|
padding: 0.3rem 1rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ff6b6b;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slideshow */
|
||||||
|
.pcv2-slideshow {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-slideshow-img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-slideshow-img.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-slideshow-progress {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-progress-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 2px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-progress-dot.active {
|
||||||
|
background: var(--primary, #FF7518);
|
||||||
|
box-shadow: 0 0 8px var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pcv2-preview-fallback {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.search-page-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-sidebar {
|
||||||
|
position: static;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
margin-bottom: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -949,6 +1382,56 @@ h3 {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Early Access Banner --- */
|
||||||
|
.early-access-banner {
|
||||||
|
background: rgba(255, 117, 24, 0.08);
|
||||||
|
border: 1px solid rgba(255, 117, 24, 0.3);
|
||||||
|
border-left: 4px solid var(--primary, #FF7518);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.early-access-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.early-access-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.early-access-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary, #FF7518);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.early-access-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pcv2-badge-ea {
|
||||||
|
background: var(--primary, #FF7518);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.payment-success-banner {
|
.payment-success-banner {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|||||||
31
src/App.tsx
31
src/App.tsx
@ -212,18 +212,31 @@ const Navbar = ({ user }: any) => {
|
|||||||
{user && <NavLink to="/dashboard" onClick={() => setIsMenuOpen(false)}>{t('dashboard')}</NavLink>}
|
{user && <NavLink to="/dashboard" onClick={() => setIsMenuOpen(false)}>{t('dashboard')}</NavLink>}
|
||||||
{user && user.role === 'admin' && <NavLink to="/admin" onClick={() => setIsMenuOpen(false)}>Admin</NavLink>}
|
{user && user.role === 'admin' && <NavLink to="/admin" onClick={() => setIsMenuOpen(false)}>Admin</NavLink>}
|
||||||
</div>
|
</div>
|
||||||
<div className="nav-search">
|
|
||||||
<input
|
{!user && (
|
||||||
type="text"
|
<div className="nav-search">
|
||||||
placeholder={t('search')}
|
<input
|
||||||
value={searchQuery}
|
type="text"
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
placeholder={t('search')}
|
||||||
onKeyDown={handleSearch}
|
value={searchQuery}
|
||||||
/>
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
</div>
|
onKeyDown={handleSearch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="nav-actions">
|
<div className="nav-actions">
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
|
<div className="nav-search-right">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('search')}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={handleSearch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<NavLink to="/settings" className="nav-user-link" onClick={() => setIsMenuOpen(false)}>{user.username}</NavLink>
|
<NavLink to="/settings" className="nav-user-link" onClick={() => setIsMenuOpen(false)}>{user.username}</NavLink>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
175
src/components/PluginCard.tsx
Normal file
175
src/components/PluginCard.tsx
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import { useState, useRef, useMemo } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const getAccentColor = (name: string) => {
|
||||||
|
const colors = [
|
||||||
|
'#4f7eff', '#ff6b6b', '#ffd166', '#06d6a0',
|
||||||
|
'#a855f7', '#f97316', '#06b6d4', '#ec4899',
|
||||||
|
];
|
||||||
|
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];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEffectivePrice = (plugin: any): { display: string; isSale: boolean; originalDisplay: string } => {
|
||||||
|
const base = plugin.price_cents ?? 0;
|
||||||
|
const saleActive = plugin.sale_active && plugin.sale_discount_percent > 0;
|
||||||
|
if (saleActive) {
|
||||||
|
const saleCents = Math.round(base * (1 - plugin.sale_discount_percent / 100));
|
||||||
|
return {
|
||||||
|
display: `€${(saleCents / 100).toFixed(2)}`,
|
||||||
|
isSale: true,
|
||||||
|
originalDisplay: `€${(base / 100).toFixed(2)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
display: `€${(base / 100).toFixed(2)}`,
|
||||||
|
isSale: false,
|
||||||
|
originalDisplay: '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const PluginCard = ({ plugin }: { plugin: any }) => {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
|
const timerRef = useRef<any>(null);
|
||||||
|
const cycleRef = useRef<any>(null);
|
||||||
|
|
||||||
|
const accent = useMemo(() => getAccentColor(plugin.name), [plugin.name]);
|
||||||
|
|
||||||
|
const getDescription = (translatedStr: string) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(translatedStr);
|
||||||
|
const currentLang = i18n.language.split('-')[0];
|
||||||
|
return data[currentLang] || data.en || Object.values(data)[0] || 'No description available.';
|
||||||
|
} catch {
|
||||||
|
return 'No description available.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const priceInfo = useMemo(() => getEffectivePrice(plugin), [plugin]);
|
||||||
|
const isSale = plugin.type === 'paid' && priceInfo.isSale;
|
||||||
|
const desc = getDescription(plugin.translated_descriptions);
|
||||||
|
const initial = plugin.name.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
const slideshowImages = useMemo(() => {
|
||||||
|
const images = [];
|
||||||
|
if (plugin.preview_path) images.push(plugin.preview_path);
|
||||||
|
if (plugin.screenshots && plugin.screenshots.length > 0) {
|
||||||
|
images.push(...plugin.screenshots);
|
||||||
|
}
|
||||||
|
return images;
|
||||||
|
}, [plugin.preview_path, plugin.screenshots]);
|
||||||
|
|
||||||
|
const startHover = () => {
|
||||||
|
setIsHovering(true);
|
||||||
|
if (slideshowImages.length <= 1) return;
|
||||||
|
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
cycleRef.current = setInterval(() => {
|
||||||
|
setCurrentIndex(prev => (prev + 1) % slideshowImages.length);
|
||||||
|
}, 3000);
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopHover = () => {
|
||||||
|
setIsHovering(false);
|
||||||
|
setCurrentIndex(0);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
if (cycleRef.current) clearInterval(cycleRef.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={`/plugin/${plugin.id}`} className="plugin-card-link" onMouseEnter={startHover} onMouseLeave={stopHover}>
|
||||||
|
<div className="plugin-card-v2">
|
||||||
|
<div className="pcv2-preview">
|
||||||
|
{slideshowImages.length > 0 ? (
|
||||||
|
<div className="pcv2-slideshow">
|
||||||
|
{slideshowImages.map((src: string, i: number) => (
|
||||||
|
<img
|
||||||
|
key={src}
|
||||||
|
src={src}
|
||||||
|
className={`pcv2-slideshow-img ${i === currentIndex ? 'active' : ''}`}
|
||||||
|
alt={plugin.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isHovering && slideshowImages.length > 1 && (
|
||||||
|
<div className="pcv2-slideshow-progress">
|
||||||
|
{slideshowImages.map((_: any, i: number) => (
|
||||||
|
<div key={i} className={`pcv2-progress-dot ${i === currentIndex ? 'active' : ''}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="pcv2-preview-fallback" style={{ background: `linear-gradient(135deg, ${accent}22 0%, #0d0f12 100%)` }}>
|
||||||
|
<span style={{ color: accent }}>{initial}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pcv2-preview-fade" />
|
||||||
|
<span className="pcv2-category-badge">{plugin.category}</span>
|
||||||
|
|
||||||
|
{plugin.type === 'free' ? (
|
||||||
|
<span className="pcv2-price-badge free">Free</span>
|
||||||
|
) : isSale ? (
|
||||||
|
<span className="pcv2-price-badge sale">
|
||||||
|
<span className="pcv2-price-original">{priceInfo.originalDisplay}</span>
|
||||||
|
{priceInfo.display}
|
||||||
|
<span className="pcv2-sale-pct">-{plugin.sale_discount_percent}%</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="pcv2-price-badge paid">{priceInfo.display}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pcv2-info">
|
||||||
|
<p className="pcv2-name">
|
||||||
|
{plugin.name}
|
||||||
|
{plugin.is_early_access && <span className="pcv2-badge-ea">Early Access</span>}
|
||||||
|
</p>
|
||||||
|
<p className="pcv2-dev">by {plugin.dev_name}</p>
|
||||||
|
<p className="pcv2-desc">{desc}</p>
|
||||||
|
|
||||||
|
<div className="pcv2-footer">
|
||||||
|
<span className="pcv2-stat">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
{(plugin.downloads ?? 0).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{plugin.rating != null && (
|
||||||
|
<span className="pcv2-stat">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||||
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" opacity="0.7"/>
|
||||||
|
</svg>
|
||||||
|
{Number(plugin.rating).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{plugin.version && (
|
||||||
|
<span className="pcv2-stat" style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono, monospace)', fontSize: '0.65rem' }}>
|
||||||
|
v{plugin.version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSale && (
|
||||||
|
<div className="pcv2-sale-strip">
|
||||||
|
🔥 Sale — {plugin.sale_discount_percent}% off
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="pcv2-accent-bar"
|
||||||
|
style={{ background: `linear-gradient(90deg, ${isSale ? '#ff6b6b' : accent}, transparent)` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PluginCard;
|
||||||
@ -5,7 +5,7 @@ import api from '../api';
|
|||||||
import SEO from '../components/SEO';
|
import SEO from '../components/SEO';
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [plugins, setPlugins] = useState<any[]>([]);
|
const [plugins, setPlugins] = useState<any[]>([]);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
|
|
||||||
@ -22,218 +22,12 @@ const Home = () => {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getDescription = (translatedStr: string) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(translatedStr);
|
|
||||||
const currentLang = i18n.language.split('-')[0];
|
|
||||||
return data[currentLang] || data.en || Object.values(data)[0] || 'No description available.';
|
|
||||||
} catch {
|
|
||||||
return 'No description available.';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAccentColor = (name: string) => {
|
|
||||||
const colors = [
|
|
||||||
'#4f7eff', '#ff6b6b', '#ffd166', '#06d6a0',
|
|
||||||
'#a855f7', '#f97316', '#06b6d4', '#ec4899',
|
|
||||||
];
|
|
||||||
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];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Compute effective display price respecting active sale
|
|
||||||
const getEffectivePrice = (plugin: any): { display: string; isSale: boolean; originalDisplay: string } => {
|
|
||||||
const base = plugin.price_cents ?? 0;
|
|
||||||
const saleActive = plugin.sale_active && plugin.sale_discount_percent > 0;
|
|
||||||
if (saleActive) {
|
|
||||||
const saleCents = Math.round(base * (1 - plugin.sale_discount_percent / 100));
|
|
||||||
return {
|
|
||||||
display: `€${(saleCents / 100).toFixed(2)}`,
|
|
||||||
isSale: true,
|
|
||||||
originalDisplay: `€${(base / 100).toFixed(2)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
display: `€${(base / 100).toFixed(2)}`,
|
|
||||||
isSale: false,
|
|
||||||
originalDisplay: '',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SEO
|
<SEO
|
||||||
title="Home"
|
title="Home"
|
||||||
description="Discover the best WASM-powered Minecraft plugins at Pumpkin Market. Performance, security, and variety in one place."
|
description="Discover the best WASM-powered Minecraft plugins at Pumpkin Market. Performance, security, and variety in one place."
|
||||||
/>
|
/>
|
||||||
<style>{`
|
|
||||||
.home-plugin-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: 1.25rem;
|
|
||||||
padding: 2rem 0 4rem;
|
|
||||||
}
|
|
||||||
.plugin-card-link { text-decoration: none; display: block; }
|
|
||||||
.plugin-card-v2 {
|
|
||||||
position: relative;
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--mp-surface, #16191f);
|
|
||||||
border: 1px solid var(--mp-border, rgba(255,255,255,0.07));
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
.plugin-card-v2:hover {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
box-shadow: 0 16px 40px rgba(0,0,0,0.45);
|
|
||||||
border-color: rgba(255,255,255,0.14);
|
|
||||||
}
|
|
||||||
.pcv2-preview {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--mp-bg, #0d0f12);
|
|
||||||
}
|
|
||||||
.pcv2-preview-img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
transition: transform 0.35s ease;
|
|
||||||
}
|
|
||||||
.plugin-card-v2:hover .pcv2-preview-img { transform: scale(1.04); }
|
|
||||||
.pcv2-preview-fallback {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 2.8rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
color: rgba(255,255,255,0.9);
|
|
||||||
font-family: var(--font-display, system-ui);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.pcv2-preview-fade {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: linear-gradient(to bottom, transparent 30%, rgba(13,15,18,0.55) 65%, rgba(13,15,18,0.96) 100%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.pcv2-category-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px; left: 10px;
|
|
||||||
font-size: 0.62rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
background: rgba(13,15,18,0.72);
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
|
||||||
color: rgba(255,255,255,0.75);
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.pcv2-price-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px; right: 10px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 0.2rem 0.55rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
.pcv2-price-badge.free {
|
|
||||||
background: rgba(6,214,160,0.18);
|
|
||||||
border: 1px solid rgba(6,214,160,0.35);
|
|
||||||
color: #06d6a0;
|
|
||||||
}
|
|
||||||
.pcv2-price-badge.paid {
|
|
||||||
background: rgba(255,209,102,0.16);
|
|
||||||
border: 1px solid rgba(255,209,102,0.35);
|
|
||||||
color: #ffd166;
|
|
||||||
}
|
|
||||||
.pcv2-price-badge.sale {
|
|
||||||
background: rgba(255,107,107,0.18);
|
|
||||||
border: 1px solid rgba(255,107,107,0.4);
|
|
||||||
color: #ff6b6b;
|
|
||||||
}
|
|
||||||
.pcv2-price-original {
|
|
||||||
text-decoration: line-through;
|
|
||||||
opacity: 0.55;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.pcv2-sale-pct {
|
|
||||||
font-size: 0.58rem;
|
|
||||||
font-weight: 800;
|
|
||||||
background: rgba(255,107,107,0.25);
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 1px 4px;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.pcv2-info { padding: 0.85rem 1rem 1rem; }
|
|
||||||
.pcv2-name {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--mp-text-1, #f0f0f0);
|
|
||||||
margin: 0 0 0.15rem;
|
|
||||||
font-family: var(--font-display, system-ui);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.pcv2-dev { font-size: 0.72rem; color: var(--mp-text-3, #5a6070); margin: 0 0 0.55rem; }
|
|
||||||
.pcv2-desc {
|
|
||||||
font-size: 0.77rem;
|
|
||||||
color: var(--mp-text-2, #8b909a);
|
|
||||||
line-height: 1.55;
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.pcv2-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.9rem;
|
|
||||||
padding-top: 0.65rem;
|
|
||||||
border-top: 1px solid var(--mp-border, rgba(255,255,255,0.06));
|
|
||||||
}
|
|
||||||
.pcv2-stat {
|
|
||||||
display: flex; align-items: center; gap: 0.3rem;
|
|
||||||
font-size: 0.7rem; color: var(--mp-text-3, #5a6070);
|
|
||||||
}
|
|
||||||
.pcv2-stat svg { opacity: 0.6; }
|
|
||||||
.pcv2-accent-bar {
|
|
||||||
position: absolute; bottom: 0; left: 0; right: 0;
|
|
||||||
height: 2px; opacity: 0;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
.plugin-card-v2:hover .pcv2-accent-bar { opacity: 1; }
|
|
||||||
|
|
||||||
/* Sale banner strip at bottom of card */
|
|
||||||
.pcv2-sale-strip {
|
|
||||||
background: linear-gradient(90deg, rgba(255,107,107,0.15), rgba(255,107,107,0.05));
|
|
||||||
border-top: 1px solid rgba(255,107,107,0.2);
|
|
||||||
padding: 0.3rem 1rem;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #ff6b6b;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
|
|
||||||
<section className="hero">
|
<section className="hero">
|
||||||
<h1>{t('welcome')}</h1>
|
<h1>{t('welcome')}</h1>
|
||||||
@ -242,101 +36,9 @@ const Home = () => {
|
|||||||
|
|
||||||
<div className="container" id="browse">
|
<div className="container" id="browse">
|
||||||
<div className="home-plugin-grid">
|
<div className="home-plugin-grid">
|
||||||
{plugins.map((plugin) => {
|
{plugins.map((plugin) => (
|
||||||
const accent = getAccentColor(plugin.name);
|
<PluginCard key={plugin.id} plugin={plugin} />
|
||||||
const desc = getDescription(plugin.translated_descriptions);
|
))}
|
||||||
const initial = plugin.name.charAt(0).toUpperCase();
|
|
||||||
const priceInfo = getEffectivePrice(plugin);
|
|
||||||
const isSale = plugin.type === 'paid' && priceInfo.isSale;
|
|
||||||
|
|
||||||
const imageUrl = plugin.preview_path;
|
|
||||||
console.log(imageUrl);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link to={`/plugin/${plugin.id}`} key={plugin.id} className="plugin-card-link">
|
|
||||||
<div className="plugin-card-v2">
|
|
||||||
|
|
||||||
{/* Preview image */}
|
|
||||||
<div className="pcv2-preview">
|
|
||||||
{imageUrl ? (
|
|
||||||
<img
|
|
||||||
className="pcv2-preview-img"
|
|
||||||
src={imageUrl}
|
|
||||||
alt={plugin.name}
|
|
||||||
onError={(e) => {
|
|
||||||
// Fallback if image 404s
|
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="pcv2-preview-fallback"
|
|
||||||
style={{ background: `linear-gradient(135deg, ${accent}22 0%, #0d0f12 100%)` }}
|
|
||||||
>
|
|
||||||
<span style={{ color: accent }}>{initial}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="pcv2-preview-fade" />
|
|
||||||
<span className="pcv2-category-badge">{plugin.category}</span>
|
|
||||||
|
|
||||||
{/* Price badge */}
|
|
||||||
{plugin.type === 'free' ? (
|
|
||||||
<span className="pcv2-price-badge free">Free</span>
|
|
||||||
) : isSale ? (
|
|
||||||
<span className="pcv2-price-badge sale">
|
|
||||||
<span className="pcv2-price-original">{priceInfo.originalDisplay}</span>
|
|
||||||
{priceInfo.display}
|
|
||||||
<span className="pcv2-sale-pct">-{plugin.sale_discount_percent}%</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="pcv2-price-badge paid">{priceInfo.display}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="pcv2-info">
|
|
||||||
<p className="pcv2-name">{plugin.name}</p>
|
|
||||||
<p className="pcv2-dev">by {plugin.dev_name}</p>
|
|
||||||
<p className="pcv2-desc">{desc}</p>
|
|
||||||
|
|
||||||
<div className="pcv2-footer">
|
|
||||||
<span className="pcv2-stat">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
|
||||||
</svg>
|
|
||||||
{(plugin.downloads ?? 0).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
{plugin.rating != null && (
|
|
||||||
<span className="pcv2-stat">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
|
||||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" opacity="0.7"/>
|
|
||||||
</svg>
|
|
||||||
{Number(plugin.rating).toFixed(1)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{plugin.version && (
|
|
||||||
<span className="pcv2-stat" style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono, monospace)', fontSize: '0.65rem' }}>
|
|
||||||
v{plugin.version}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sale strip at bottom */}
|
|
||||||
{isSale && (
|
|
||||||
<div className="pcv2-sale-strip">
|
|
||||||
🔥 Sale — {plugin.sale_discount_percent}% off
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="pcv2-accent-bar"
|
|
||||||
style={{ background: `linear-gradient(90deg, ${isSale ? '#ff6b6b' : accent}, transparent)` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{plugins.length === 0 && (
|
{plugins.length === 0 && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
|
|||||||
@ -109,9 +109,15 @@ const PluginDetail = () => {
|
|||||||
|
|
||||||
// Review Report State
|
// Review Report State
|
||||||
const [reviewReportOpen, setReviewReportOpen] = useState<number | null>(null);
|
const [reviewReportOpen, setReviewReportOpen] = useState<number | null>(null);
|
||||||
const [reviewReportSelected, setReviewReportSelected] = useState<string>('');
|
const [reviewReportSelected, setReviewReportSelected] = useState('');
|
||||||
const [reviewReportCustom, setReviewReportCustom] = useState('');
|
const [reviewReportCustom, setReviewReportCustom] = useState('');
|
||||||
const [reviewReportSubmitting, setReviewReportSubmitting] = useState(false);
|
const [reviewReportSubmitting, setReviewReportSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [replyOpen, setReplyOpen] = useState<number | null>(null);
|
||||||
|
const [replyText, setReplyText] = useState('');
|
||||||
|
const [replySubmitting, setReplySubmitting] = useState(false);
|
||||||
|
|
||||||
|
const isOwner = user && plugin && user.id === plugin.dev_id;
|
||||||
const [reportedReviews, setReportedReviews] = useState<Set<number>>(new Set());
|
const [reportedReviews, setReportedReviews] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
// ── Detect ?payment=success and strip from URL ───────────────────────────
|
// ── Detect ?payment=success and strip from URL ───────────────────────────
|
||||||
@ -289,6 +295,34 @@ const PluginDetail = () => {
|
|||||||
setReviewForm({ rating: 5, comment: '' });
|
setReviewForm({ rating: 5, comment: '' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteReview = async () => {
|
||||||
|
if (!window.confirm('Are you sure you want to delete your review?')) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/plugins/${id}/reviews`);
|
||||||
|
fetchPlugin();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete review', err);
|
||||||
|
alert('Failed to delete review');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitReply = async (e: React.FormEvent, reviewId: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!replyText.trim()) return;
|
||||||
|
setReplySubmitting(true);
|
||||||
|
try {
|
||||||
|
await api.post(`/plugins/${id}/reviews/${reviewId}/reply`, { reply: replyText.trim() });
|
||||||
|
setReplyOpen(null);
|
||||||
|
setReplyText('');
|
||||||
|
fetchPlugin();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to submit reply', err);
|
||||||
|
alert('Failed to submit reply');
|
||||||
|
} finally {
|
||||||
|
setReplySubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const submitReport = async (e: React.FormEvent) => {
|
const submitReport = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const reason = reportSelected === 'Other' ? reportCustom.trim() : reportSelected;
|
const reason = reportSelected === 'Other' ? reportCustom.trim() : reportSelected;
|
||||||
@ -390,6 +424,20 @@ const PluginDetail = () => {
|
|||||||
{JSON.stringify(productSchema)}
|
{JSON.stringify(productSchema)}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{/* ── Early Access Banner ── */}
|
||||||
|
{plugin.is_early_access && (
|
||||||
|
<div className="early-access-banner" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<div className="early-access-icon">⏳</div>
|
||||||
|
<div className="early-access-content">
|
||||||
|
<div className="early-access-title">Early Access Plugin</div>
|
||||||
|
<div className="early-access-text">
|
||||||
|
This plugin is currently in <strong>Early Access</strong>. It is still under active development and may contain bugs or incomplete features.
|
||||||
|
Downloading it now helps support the developer and shape the future of the plugin!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Payment success banner ── */}
|
{/* ── Payment success banner ── */}
|
||||||
{showPaymentSuccess && (
|
{showPaymentSuccess && (
|
||||||
<div className="payment-success-banner">
|
<div className="payment-success-banner">
|
||||||
@ -514,13 +562,30 @@ const PluginDetail = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="review-item-body">{r.comment}</p>
|
<p className="review-item-body">{r.comment}</p>
|
||||||
|
|
||||||
|
{/* Developer Reply */}
|
||||||
|
{r.developer_reply && (
|
||||||
|
<div className="developer-reply" style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
padding: '1rem',
|
||||||
|
background: 'rgba(255, 117, 24, 0.05)',
|
||||||
|
borderLeft: '2px solid var(--primary)',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}>
|
||||||
|
<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.7rem', color: 'var(--text-muted)' }}>{formatDate(r.replied_at)}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: '0.9rem', color: 'var(--text)', lineHeight: 1.6 }}>{r.developer_reply}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Review Report Button/Form */}
|
{/* Review Report & Reply Buttons */}
|
||||||
<div style={{ marginTop: '0.75rem' }}>
|
<div style={{ marginTop: '0.75rem', display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||||
{reportedReviews.has(r.id) ? (
|
{reportedReviews.has(r.id) ? (
|
||||||
<p style={{ fontSize: '0.75rem', color: 'var(--success)', margin: 0 }}>✓ Report submitted</p>
|
<p style={{ fontSize: '0.75rem', color: 'var(--success)', margin: 0 }}>✓ Report submitted</p>
|
||||||
) : reviewReportOpen === r.id ? (
|
) : reviewReportOpen === r.id ? (
|
||||||
<form onSubmit={(e) => submitReviewReport(e, r.id)} className="report-form" style={{ maxWidth: '400px' }}>
|
<form onSubmit={(e) => submitReviewReport(e, r.id)} className="report-form" style={{ maxWidth: '400px', width: '100%' }}>
|
||||||
<label className="report-form-label">Reason for reporting this review</label>
|
<label className="report-form-label">Reason for reporting this review</label>
|
||||||
<div className="report-reasons" style={{ transform: 'scale(0.9)', originX: 'left' }}>
|
<div className="report-reasons" style={{ transform: 'scale(0.9)', originX: 'left' }}>
|
||||||
{REVIEW_REPORT_REASONS.map(reason => (
|
{REVIEW_REPORT_REASONS.map(reason => (
|
||||||
@ -563,12 +628,58 @@ const PluginDetail = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
) : replyOpen === r.id ? (
|
||||||
|
<form onSubmit={(e) => submitReply(e, r.id)} style={{ width: '100%', maxWidth: '500px' }}>
|
||||||
|
<textarea
|
||||||
|
className="report-textarea"
|
||||||
|
placeholder="Write your response to this review..."
|
||||||
|
value={replyText}
|
||||||
|
onChange={e => setReplyText(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
autoFocus
|
||||||
|
style={{ fontSize: '0.85rem', marginBottom: '0.5rem' }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-report-cancel"
|
||||||
|
onClick={() => { setReplyOpen(null); setReplyText(''); }}
|
||||||
|
style={{ padding: '4px 12px', fontSize: '0.75rem' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={replySubmitting || !replyText.trim()}
|
||||||
|
style={{ padding: '4px 12px', fontSize: '0.75rem', height: 'auto' }}
|
||||||
|
>
|
||||||
|
{replySubmitting ? 'Replying...' : 'Post Reply'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
) : (
|
) : (
|
||||||
user && user.username !== r.username && (
|
<>
|
||||||
<button className="btn-report" onClick={() => setReviewReportOpen(r.id)} style={{ fontSize: '0.7rem' }}>
|
{user && user.username === r.username ? (
|
||||||
🚩 Report review
|
<button className="btn-report" onClick={deleteReview} style={{ fontSize: '0.7rem', color: '#ff4d4f' }}>
|
||||||
</button>
|
🗑 Delete my review
|
||||||
)
|
</button>
|
||||||
|
) : user && (
|
||||||
|
<button className="btn-report" onClick={() => setReviewReportOpen(r.id)} style={{ fontSize: '0.7rem' }}>
|
||||||
|
🚩 Report review
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOwner && !r.developer_reply && !replyOpen && (
|
||||||
|
<button
|
||||||
|
className="btn-report"
|
||||||
|
onClick={() => setReplyOpen(r.id)}
|
||||||
|
style={{ fontSize: '0.7rem', color: 'var(--primary)' }}
|
||||||
|
>
|
||||||
|
💬 Reply as developer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -579,10 +690,25 @@ const PluginDetail = () => {
|
|||||||
hasReviewed ? (
|
hasReviewed ? (
|
||||||
<div className="review-already-posted">
|
<div className="review-already-posted">
|
||||||
<span className="review-already-icon">✓</span>
|
<span className="review-already-icon">✓</span>
|
||||||
<div>
|
<div style={{ flex: 1 }}>
|
||||||
<div className="review-already-title">Review posted</div>
|
<div className="review-already-title">Review posted</div>
|
||||||
<div className="review-already-sub">You've already reviewed this plugin. Thanks for the feedback!</div>
|
<div className="review-already-sub">You've already reviewed this plugin. Thanks for the feedback!</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={deleteReview}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255, 77, 79, 0.1)',
|
||||||
|
color: '#ff4d4f',
|
||||||
|
border: 'none',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete Review
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={submitReview} className="review-submit-form">
|
<form onSubmit={submitReview} className="review-submit-form">
|
||||||
|
|||||||
@ -1,25 +1,43 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useSearchParams, Link } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import SEO from '../components/SEO';
|
import SEO from '../components/SEO';
|
||||||
|
import PluginCard from '../components/PluginCard';
|
||||||
|
|
||||||
|
const PLUGIN_CATEGORIES = ['Admin Tools', 'Economy', 'Fun', 'World Management', 'Utilities', 'Chat', 'Other'];
|
||||||
|
|
||||||
const SearchResultsPage = () => {
|
const SearchResultsPage = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const searchQuery = searchParams.get('q') || '';
|
const searchQuery = searchParams.get('q') || '';
|
||||||
|
const initialCategory = searchParams.get('category') || '';
|
||||||
|
const initialType = searchParams.get('type') || '';
|
||||||
|
const initialMinPrice = searchParams.get('min_price') || '';
|
||||||
|
const initialMaxPrice = searchParams.get('max_price') || '';
|
||||||
|
|
||||||
const [plugins, setPlugins] = useState<any[]>([]);
|
const [plugins, setPlugins] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [sort, setSort] = useState('');
|
|
||||||
|
const [sort, setSort] = useState(searchParams.get('sort') || '');
|
||||||
|
const [category, setCategory] = useState(initialCategory);
|
||||||
|
const [type, setType] = useState(initialType);
|
||||||
|
const [minPrice, setMinPrice] = useState(initialMinPrice);
|
||||||
|
const [maxPrice, setMaxPrice] = useState(initialMaxPrice);
|
||||||
|
|
||||||
// Fetch ALL plugins whenever sort changes — no search filter sent to API
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPlugins = async () => {
|
const fetchPlugins = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/plugins', {
|
const params: any = {
|
||||||
params: { sort: sort || undefined },
|
q: searchQuery || undefined,
|
||||||
});
|
sort: sort || undefined,
|
||||||
|
category: category || undefined,
|
||||||
|
type: type || undefined,
|
||||||
|
min_price: minPrice ? parseInt(minPrice) * 100 : undefined,
|
||||||
|
max_price: maxPrice ? parseInt(maxPrice) * 100 : undefined,
|
||||||
|
};
|
||||||
|
const response = await api.get('/plugins', { params });
|
||||||
setPlugins(Array.isArray(response.data) ? response.data : []);
|
setPlugins(Array.isArray(response.data) ? response.data : []);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Error fetching plugins:', err);
|
console.error('Error fetching plugins:', err);
|
||||||
@ -31,30 +49,18 @@ const SearchResultsPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchPlugins();
|
fetchPlugins();
|
||||||
}, [sort]);
|
|
||||||
|
|
||||||
// Client-side filter by search query
|
// Update URL params
|
||||||
const getDescription = (plugin: any): string => {
|
const newParams: any = {};
|
||||||
try {
|
if (searchQuery) newParams.q = searchQuery;
|
||||||
const descriptions = JSON.parse(plugin.translated_descriptions || '{}');
|
if (sort) newParams.sort = sort;
|
||||||
return descriptions['en-US'] || descriptions.en || Object.values(descriptions)[0] as string || '';
|
if (category) newParams.category = category;
|
||||||
} catch {
|
if (type) newParams.type = type;
|
||||||
return '';
|
if (minPrice) newParams.min_price = minPrice;
|
||||||
}
|
if (maxPrice) newParams.max_price = maxPrice;
|
||||||
};
|
setSearchParams(newParams, { replace: true });
|
||||||
|
|
||||||
const visiblePlugins = searchQuery.trim()
|
}, [searchQuery, sort, category, type, minPrice, maxPrice, setSearchParams]);
|
||||||
? plugins.filter(p => {
|
|
||||||
const q = searchQuery.toLowerCase();
|
|
||||||
return (
|
|
||||||
p.name?.toLowerCase().includes(q) ||
|
|
||||||
p.dev_name?.toLowerCase().includes(q) ||
|
|
||||||
p.category?.toLowerCase().includes(q) ||
|
|
||||||
p.keywords?.toLowerCase().includes(q) ||
|
|
||||||
getDescription(p).toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
: plugins;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
@ -62,74 +68,113 @@ const SearchResultsPage = () => {
|
|||||||
title={searchQuery ? `Search results for "${searchQuery}"` : "All Plugins"}
|
title={searchQuery ? `Search results for "${searchQuery}"` : "All Plugins"}
|
||||||
description={`Browse our collection of Minecraft plugins. ${searchQuery ? `Showing results for ${searchQuery}.` : ''}`}
|
description={`Browse our collection of Minecraft plugins. ${searchQuery ? `Showing results for ${searchQuery}.` : ''}`}
|
||||||
/>
|
/>
|
||||||
<h1 className="section-title">
|
|
||||||
{searchQuery
|
<div className="search-page-layout">
|
||||||
? <>Search Results <span>for "{searchQuery}"</span></>
|
<aside className="search-sidebar">
|
||||||
: 'All Plugins'
|
<div className="filter-section">
|
||||||
}
|
<h3 className="filter-title">Filters</h3>
|
||||||
</h1>
|
<button className="btn-text-only" onClick={() => {
|
||||||
|
setCategory('');
|
||||||
|
setType('');
|
||||||
|
setMinPrice('');
|
||||||
|
setMaxPrice('');
|
||||||
|
}}>Clear All</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="search-controls">
|
<div className="filter-group">
|
||||||
<label htmlFor="sort-by">Sort by:</label>
|
<label>Category</label>
|
||||||
<select id="sort-by" value={sort} onChange={(e) => setSort(e.target.value)}>
|
<select value={category} onChange={e => setCategory(e.target.value)}>
|
||||||
<option value="">Relevance</option>
|
<option value="">All Categories</option>
|
||||||
<option value="name-asc">Name (A-Z)</option>
|
{PLUGIN_CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
<option value="name-desc">Name (Z-A)</option>
|
</select>
|
||||||
<option value="price-asc">Price (Low to High)</option>
|
</div>
|
||||||
<option value="price-desc">Price (High to Low)</option>
|
|
||||||
<option value="newest">Newest</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="error-message">{error}</p>}
|
<div className="filter-group">
|
||||||
|
<label>License Type</label>
|
||||||
|
<select value={type} onChange={e => setType(e.target.value)}>
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="free">Free</option>
|
||||||
|
<option value="paid">Paid</option>
|
||||||
|
<option value="adwall">Adwall</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
<div className="filter-group">
|
||||||
<p>Loading results...</p>
|
<label>Price Range ($)</label>
|
||||||
) : (
|
<div className="price-range-inputs">
|
||||||
<div className="plugin-grid">
|
<input
|
||||||
{visiblePlugins.length > 0 ? (
|
type="number"
|
||||||
visiblePlugins.map((plugin) => (
|
placeholder="Min"
|
||||||
<Link to={`/plugin/${plugin.id}`} key={plugin.id} className="plugin-card-link">
|
value={minPrice}
|
||||||
<div className="plugin-card">
|
onChange={e => setMinPrice(e.target.value)}
|
||||||
<div className="plugin-card-header">
|
/>
|
||||||
<div
|
<span>-</span>
|
||||||
className="plugin-icon"
|
<input
|
||||||
style={{ backgroundImage: plugin.preview_path ? `url(${plugin.preview_path})` : 'none' }}
|
type="number"
|
||||||
>
|
placeholder="Max"
|
||||||
{!plugin.preview_path && plugin.name.charAt(0)}
|
value={maxPrice}
|
||||||
</div>
|
onChange={e => setMaxPrice(e.target.value)}
|
||||||
<div>
|
/>
|
||||||
<h3>{plugin.name}</h3>
|
</div>
|
||||||
<p className="dev-name">by {plugin.dev_name}</p>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
</div>
|
|
||||||
<p>
|
<main className="search-main">
|
||||||
{(() => {
|
<div className="search-header">
|
||||||
const desc = getDescription(plugin);
|
<h1 className="search-title">
|
||||||
return desc.length > 100 ? desc.substring(0, 100) + '…' : desc || 'No description provided.';
|
{searchQuery
|
||||||
})()}
|
? <>Results <span>for "{searchQuery}"</span></>
|
||||||
</p>
|
: 'All Plugins'
|
||||||
<div className="card-footer">
|
}
|
||||||
<span className="plugin-category">{plugin.category}</span>
|
<small className="results-count">({plugins.length} found)</small>
|
||||||
<span className={`badge badge-${plugin.type}`}>
|
</h1>
|
||||||
{plugin.type === 'paid' ? `$${plugin.price}` : plugin.type}
|
|
||||||
</span>
|
<div className="search-controls">
|
||||||
</div>
|
<label htmlFor="sort-by">Sort by:</label>
|
||||||
</div>
|
<select id="sort-by" value={sort} onChange={(e) => setSort(e.target.value)}>
|
||||||
</Link>
|
<option value="">Newest</option>
|
||||||
))
|
<option value="downloads">Most Popular</option>
|
||||||
|
<option value="name-asc">Name (A-Z)</option>
|
||||||
|
<option value="name-desc">Name (Z-A)</option>
|
||||||
|
<option value="price-asc">Price (Low to High)</option>
|
||||||
|
<option value="price-desc">Price (High to Low)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="error-message">{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="loading-state">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
<p>Searching market...</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-state">
|
<div className="plugin-grid search-grid">
|
||||||
<p>
|
{plugins.length > 0 ? (
|
||||||
{searchQuery
|
plugins.map((plugin) => (
|
||||||
? `No plugins found for "${searchQuery}". Try a different search term!`
|
<PluginCard key={plugin.id} plugin={plugin} />
|
||||||
: 'No plugins available yet.'
|
))
|
||||||
}
|
) : (
|
||||||
</p>
|
<div className="empty-state">
|
||||||
|
<p>
|
||||||
|
{searchQuery
|
||||||
|
? `No plugins found for "${searchQuery}" with these filters.`
|
||||||
|
: 'No plugins available with these filters.'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<button className="btn btn-secondary" onClick={() => {
|
||||||
|
setCategory('');
|
||||||
|
setType('');
|
||||||
|
setMinPrice('');
|
||||||
|
setMaxPrice('');
|
||||||
|
}}>Clear Filters</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</main>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -223,6 +223,7 @@ const AddPlugin = () => {
|
|||||||
|
|
||||||
const [licenseType, setLicenseType] = useState<LicenseType>('free');
|
const [licenseType, setLicenseType] = useState<LicenseType>('free');
|
||||||
const [price, setPrice] = useState(4.99);
|
const [price, setPrice] = useState(4.99);
|
||||||
|
const [isEarlyAccess, setIsEarlyAccess] = useState(false);
|
||||||
const stripeConnected = user.stripe_ready;
|
const stripeConnected = user.stripe_ready;
|
||||||
|
|
||||||
// ── Step 2: Binary ──
|
// ── Step 2: Binary ──
|
||||||
@ -356,6 +357,7 @@ const AddPlugin = () => {
|
|||||||
fd.append('translated_descriptions', JSON.stringify(descriptions));
|
fd.append('translated_descriptions', JSON.stringify(descriptions));
|
||||||
fd.append('type', licenseType);
|
fd.append('type', licenseType);
|
||||||
fd.append('price', licenseType === 'paid' ? String(Math.round(price * 100)) : '0');
|
fd.append('price', licenseType === 'paid' ? String(Math.round(price * 100)) : '0');
|
||||||
|
fd.append('is_early_access', String(isEarlyAccess));
|
||||||
if (previewImage) fd.append('preview_image', previewImage);
|
if (previewImage) fd.append('preview_image', previewImage);
|
||||||
if (wasmFile) fd.append('wasm', wasmFile);
|
if (wasmFile) fd.append('wasm', wasmFile);
|
||||||
screenshots.forEach(f => fd.append('screenshots', f));
|
screenshots.forEach(f => fd.append('screenshots', f));
|
||||||
@ -374,7 +376,7 @@ const AddPlugin = () => {
|
|||||||
setDescriptions({ [DEFAULT_LOCALE]: '' }); setActiveLocale(DEFAULT_LOCALE);
|
setDescriptions({ [DEFAULT_LOCALE]: '' }); setActiveLocale(DEFAULT_LOCALE);
|
||||||
setPreviewImage(null); setPreviewImageUrl(null);
|
setPreviewImage(null); setPreviewImageUrl(null);
|
||||||
setScreenshots([]); setScreenshotPreviews([]);
|
setScreenshots([]); setScreenshotPreviews([]);
|
||||||
setLicenseType('free'); setPrice(4.99);
|
setLicenseType('free'); setPrice(4.99); setIsEarlyAccess(false);
|
||||||
setWasmFile(null);
|
setWasmFile(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -493,7 +495,7 @@ const AddPlugin = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Keywords */}
|
{/* Keywords */}
|
||||||
<div className="mp-form-group" style={{ marginBottom: 0 }}>
|
<div className="mp-form-group" style={{ marginBottom: '1.5rem' }}>
|
||||||
<label className="mp-label">
|
<label className="mp-label">
|
||||||
Keywords{' '}
|
Keywords{' '}
|
||||||
<span style={{ color: 'var(--mp-text-3)', fontWeight: 400 }}>(comma-separated)</span>
|
<span style={{ color: 'var(--mp-text-3)', fontWeight: 400 }}>(comma-separated)</span>
|
||||||
@ -509,6 +511,22 @@ const AddPlugin = () => {
|
|||||||
/>
|
/>
|
||||||
<FieldError msg={fieldErrors.keywords} />
|
<FieldError msg={fieldErrors.keywords} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Early Access */}
|
||||||
|
<div className="mp-form-group">
|
||||||
|
<label className="mp-checkbox-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', userSelect: 'none' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isEarlyAccess}
|
||||||
|
onChange={e => setIsEarlyAccess(e.target.checked)}
|
||||||
|
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.9rem', fontWeight: 700, color: 'var(--mp-text-1)' }}>Early Access</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--mp-text-3)', fontWeight: 400 }}>Flag this plugin as incomplete or in active development.</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Authorship */}
|
{/* Authorship */}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export type PluginData = {
|
|||||||
status?: 'published' | 'draft' | 'review';
|
status?: 'published' | 'draft' | 'review';
|
||||||
sale_active?: boolean;
|
sale_active?: boolean;
|
||||||
sale_discount_percent?: number;
|
sale_discount_percent?: number;
|
||||||
|
is_early_access?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
|
|||||||
@ -148,6 +148,7 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
|
|||||||
const [category, setCategory] = useState(plugin.category);
|
const [category, setCategory] = useState(plugin.category);
|
||||||
const [sourceLink, setSourceLink] = useState(plugin.source_link || '');
|
const [sourceLink, setSourceLink] = useState(plugin.source_link || '');
|
||||||
const [keywords, setKeywords] = useState(plugin.keywords || '');
|
const [keywords, setKeywords] = useState(plugin.keywords || '');
|
||||||
|
const [isEarlyAccess, setIsEarlyAccess] = useState(plugin.is_early_access || false);
|
||||||
const [screenshots, setScreenshots] = useState(plugin.screenshots || []);
|
const [screenshots, setScreenshots] = useState(plugin.screenshots || []);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
@ -181,6 +182,7 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
|
|||||||
fd.append('category', category);
|
fd.append('category', category);
|
||||||
fd.append('source_link', sourceLink);
|
fd.append('source_link', sourceLink);
|
||||||
fd.append('keywords', keywords);
|
fd.append('keywords', keywords);
|
||||||
|
fd.append('is_early_access', String(isEarlyAccess));
|
||||||
try {
|
try {
|
||||||
await api.put(`/plugins/${plugin.id}`, fd, {
|
await api.put(`/plugins/${plugin.id}`, fd, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
@ -259,6 +261,21 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
|
|||||||
onChange={e => setKeywords(e.target.value)}
|
onChange={e => setKeywords(e.target.value)}
|
||||||
placeholder="economy, shop, currency" />
|
placeholder="economy, shop, currency" />
|
||||||
</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
|
||||||
|
type="checkbox"
|
||||||
|
checked={isEarlyAccess}
|
||||||
|
onChange={e => setIsEarlyAccess(e.target.checked)}
|
||||||
|
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.9rem', fontWeight: 700, color: 'var(--mp-text)' }}>Early Access</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--mp-text-3)', fontWeight: 400 }}>Flag this plugin as incomplete or in active development.</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Descriptions ── */}
|
{/* ── Descriptions ── */}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user