feat: allow to add commands

This commit is contained in:
Alexander Medvedev 2026-08-31 19:43:46 +02:00
parent 7ea58a985c
commit 21f54d9274
22 changed files with 988 additions and 90 deletions

View File

@ -74,6 +74,17 @@
"details_placeholder": "Bitte gib weitere Details an...",
"submit": "Meldung absenden",
"success": "Meldung eingereicht. Unser Team wird sie prüfen."
},
"commands": {
"title": "Befehle & Berechtigungen",
"no_commands": "Keine Befehle für dieses Plugin angegeben.",
"permission": "Berechtigung",
"no_permission": "Keine erforderlich",
"copy_command": "Befehl kopieren",
"copy_permission": "Berechtigung kopieren",
"copied": "Kopiert!",
"show_all": "Alle {{count}} Befehle anzeigen",
"show_less": "Weniger anzeigen"
}
},
"profile": {

View File

@ -74,6 +74,17 @@
"details_placeholder": "Please provide additional details...",
"submit": "Submit Report",
"success": "Report submitted. Our moderation team will investigate."
},
"commands": {
"title": "Commands & Permissions",
"no_commands": "No commands listed for this plugin.",
"permission": "Permission",
"no_permission": "None required",
"copy_command": "Copy command",
"copy_permission": "Copy permission",
"copied": "Copied!",
"show_all": "Show all {{count}} commands",
"show_less": "Show less"
}
},
"profile": {

View File

@ -74,6 +74,17 @@
"details_placeholder": "Proporciona más detalles...",
"submit": "Enviar reporte",
"success": "Reporte enviado. Nuestro equipo lo revisará."
},
"commands": {
"title": "Comandos y Permisos",
"no_commands": "No hay comandos listados para este plugin.",
"permission": "Permiso",
"no_permission": "Ninguno requerido",
"copy_command": "Copiar comando",
"copy_permission": "Copiar permiso",
"copied": "¡Copiado!",
"show_all": "Mostrar todos los {{count}} comandos",
"show_less": "Mostrar menos"
}
},
"profile": {

View File

@ -74,6 +74,17 @@
"details_placeholder": "Veuillez préciser le problème...",
"submit": "Envoyer le signalement",
"success": "Signalement envoyé. Notre équipe va l'examiner."
},
"commands": {
"title": "Commandes et Permissions",
"no_commands": "Aucune commande listée pour ce plugin.",
"permission": "Permission",
"no_permission": "Aucune requise",
"copy_command": "Copier la commande",
"copy_permission": "Copier la permission",
"copied": "Copié !",
"show_all": "Afficher les {{count}} commandes",
"show_less": "Afficher moins"
}
},
"profile": {

View File

@ -74,6 +74,17 @@
"details_placeholder": "Fornisci ulteriori dettagli...",
"submit": "Invia Segnalazione",
"success": "Segnalazione inviata. Il team di moderazione verificherà."
},
"commands": {
"title": "Comandi e Permessi",
"no_commands": "Nessun comando elencato per questo plugin.",
"permission": "Permesso",
"no_permission": "Nessuno richiesto",
"copy_command": "Copia comando",
"copy_permission": "Copia permesso",
"copied": "Copiato!",
"show_all": "Mostra tutti i {{count}} comandi",
"show_less": "Mostra meno"
}
},
"profile": {

View File

@ -74,6 +74,17 @@
"details_placeholder": "詳細を入力してください...",
"submit": "報告を送信",
"success": "報告を受け付けました。運営チームが確認いたします。"
},
"commands": {
"title": "コマンドと権限",
"no_commands": "このプラグインに登録されているコマンドはありません。",
"permission": "権限",
"no_permission": "権限不要",
"copy_command": "コマンドをコピー",
"copy_permission": "権限をコピー",
"copied": "コピーしました!",
"show_all": "全 {{count}} 件のコマンドを表示",
"show_less": "折りたたむ"
}
},
"profile": {

View File

@ -74,6 +74,17 @@
"details_placeholder": "상세 내용을 입력해 주세요...",
"submit": "신고 접수",
"success": "신고가 접수되었습니다. 검토 후 처리하겠습니다."
},
"commands": {
"title": "명령어 및 권한",
"no_commands": "이 플러그인에 등록된 명령어가 없습니다.",
"permission": "권한",
"no_permission": "필요 없음",
"copy_command": "명령어 복사",
"copy_permission": "권한 노드 복사",
"copied": "복사됨!",
"show_all": "전체 {{count}}개 명령어 보기",
"show_less": "간략히 보기"
}
},
"profile": {

View File

@ -74,6 +74,17 @@
"details_placeholder": "Geef aanvullende details...",
"submit": "Melding Versturen",
"success": "Melding verzonden. Ons moderatieteam zal dit onderzoeken."
},
"commands": {
"title": "Commando's & Permissies",
"no_commands": "Geen commando's vermeld voor deze plugin.",
"permission": "Permissie",
"no_permission": "Geen vereist",
"copy_command": "Kopieer commando",
"copy_permission": "Kopieer permissie",
"copied": "Gekopieerd!",
"show_all": "Toon alle {{count}} commando's",
"show_less": "Toon minder"
}
},
"profile": {

View File

@ -74,6 +74,17 @@
"details_placeholder": "Prosimy o podanie dodatkowych szczegółów...",
"submit": "Wyślij Zgłoszenie",
"success": "Zgłoszenie wysłane. Zespół moderacji zajmie się sprawą."
},
"commands": {
"title": "Polecenia i Uprawnienia",
"no_commands": "Brak poleceń dla tego pluginu.",
"permission": "Uprawnienie",
"no_permission": "Brak wymaganego",
"copy_command": "Kopiuj polecenie",
"copy_permission": "Kopiuj uprawnienie",
"copied": "Skopiowano!",
"show_all": "Pokaż wszystkie {{count}} polecenia",
"show_less": "Pokaż mniej"
}
},
"profile": {

View File

@ -74,6 +74,17 @@
"details_placeholder": "Por favor, forneça mais detalhes...",
"submit": "Enviar Denúncia",
"success": "Denúncia enviada com sucesso. Nossa equipe irá analisar."
},
"commands": {
"title": "Comandos e Permissões",
"no_commands": "Nenhum comando listado para este plugin.",
"permission": "Permissão",
"no_permission": "Nenhuma necessária",
"copy_command": "Copiar comando",
"copy_permission": "Copiar permissão",
"copied": "Copiado!",
"show_all": "Mostrar todos os {{count}} comandos",
"show_less": "Mostrar menos"
}
},
"profile": {

View File

@ -74,6 +74,17 @@
"details_placeholder": "Опишите подробности проблемы...",
"submit": "Отправить жалобу",
"success": "Жалоба отправлена на рассмотрение модераторам."
},
"commands": {
"title": "Команды и Права",
"no_commands": "У этого плагина нет зарегистрированных команд.",
"permission": "Право",
"no_permission": "Не требуется",
"copy_command": "Скопировать команду",
"copy_permission": "Скопировать право",
"copied": "Скопировано!",
"show_all": "Показать все команды ({{count}})",
"show_less": "Свернуть"
}
},
"profile": {

View File

@ -74,6 +74,17 @@
"details_placeholder": "Lütfen detayları belirtin...",
"submit": "Bildirimi Gönder",
"success": "Bildirim iletildi. Moderasyon ekibimiz inceleyecektir."
},
"commands": {
"title": "Komutlar ve İzinler",
"no_commands": "Bu eklenti için listelenmiş komut yok.",
"permission": "İzin",
"no_permission": "Gerekli değil",
"copy_command": "Komutu kopyala",
"copy_permission": "İzni kopyala",
"copied": "Kopyalandı!",
"show_all": "Tüm {{count}} komutu göster",
"show_less": "Daha az göster"
}
},
"profile": {

View File

@ -74,6 +74,17 @@
"details_placeholder": "请提供更多详细信息...",
"submit": "提交举报",
"success": "举报已提交,审核团队将尽快处理。"
},
"commands": {
"title": "命令与权限",
"no_commands": "此插件未列出任何指令。",
"permission": "权限",
"no_permission": "无需权限",
"copy_command": "复制指令",
"copy_permission": "复制权限节点",
"copied": "已复制!",
"show_all": "展开全部 {{count}} 条指令",
"show_less": "收起"
}
},
"profile": {

View File

@ -92,14 +92,14 @@ const Home = () => {
const activeImage = featuredImages[heroSubImageIndex] || featuredImages[0];
const getDesc = (translatedStr?: string) => {
if (!translatedStr) return 'Discover this high performance Minecraft plugin.';
const getDesc = (translated?: Record<string, string> | string) => {
if (!translated) return 'Discover this high performance Minecraft plugin.';
try {
const data = JSON.parse(translatedStr);
const data = typeof translated === 'string' ? JSON.parse(translated) : translated;
const currentLang = i18n.language.split('-')[0];
return data[currentLang] || data.en || Object.values(data)[0] || 'Discover this high performance Minecraft plugin.';
} catch {
return 'Discover this high performance Minecraft plugin.';
return typeof translated === 'string' ? translated : 'Discover this high performance Minecraft plugin.';
}
};

View File

@ -8,7 +8,8 @@ import remarkGfm from 'remark-gfm';
import { useNavigate } from 'react-router-dom';
import SEO from '../components/SEO';
import { PluginVideoPlayer } from '../components/PluginVideoPlayer';
import { Sparkles, History, ChevronRight, Star, Check, CheckCircle2, Trash2, Flag, MessageSquare, Clock } from 'lucide-react';
import { Sparkles, History, ChevronRight, Star, Check, CheckCircle2, Trash2, Flag, MessageSquare, Clock, Terminal, Copy, Key, ChevronDown, ChevronUp } from 'lucide-react';
import type { PluginDetail, PluginCommand } from '../types/plugin';
const getYoutubeVideoId = (url: string) => {
if (!url) return null;
@ -152,6 +153,225 @@ const formatDate = (dateStr: string) => {
return `${day} ${month}, ${year}`;
};
const getLocalizedCommandDescription = (
rawDescription: string | Record<string, string> | undefined | null,
activeLanguage: string
): string => {
if (!rawDescription) return '';
if (typeof rawDescription === 'object') {
const full = activeLanguage;
const short = activeLanguage.slice(0, 2);
return (
rawDescription[full] ||
rawDescription[short] ||
rawDescription['en-US'] ||
rawDescription['en'] ||
rawDescription['en-GB'] ||
Object.values(rawDescription)[0] ||
''
);
}
if (typeof rawDescription === 'string') {
try {
const parsed = JSON.parse(rawDescription);
if (parsed && typeof parsed === 'object') {
return getLocalizedCommandDescription(parsed, activeLanguage);
}
return rawDescription;
} catch {
return rawDescription;
}
}
return '';
};
const PluginCommandsSidebarWidget = ({ commands }: { commands?: PluginCommand[] }) => {
const { t, i18n } = useTranslation();
const [copiedId, setCopiedId] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
if (!commands || commands.length === 0) return null;
const handleCopy = (text: string, id: string) => {
navigator.clipboard.writeText(text);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
const activeLang = i18n.language || 'en';
const visibleCommands = expanded ? commands : commands.slice(0, 4);
return (
<div className="sidebar-widget commands-widget" style={{ marginTop: '1.25rem', position: 'static' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.85rem' }}>
<h3 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.45rem', fontSize: '0.82rem' }}>
<Terminal size={14} color="var(--primary)" />
{t('plugin.commands.title', 'Commands & Permissions')}
</h3>
<span style={{
fontSize: '0.7rem',
fontWeight: 700,
background: 'rgba(255, 255, 255, 0.07)',
padding: '2px 7px',
borderRadius: '999px',
color: 'var(--text-muted)'
}}>
{commands.length}
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{visibleCommands.map((cmd: any, index: number) => {
const desc = getLocalizedCommandDescription(cmd.description, activeLang);
const cmdCopyKey = `cmd-${cmd.id || index}`;
const permCopyKey = `perm-${cmd.id || index}`;
const formattedName = cmd.name?.startsWith('/') ? cmd.name : `/${cmd.name}`;
return (
<div
key={cmd.id || index}
style={{
background: 'rgba(0, 0, 0, 0.25)',
border: '1px solid var(--border)',
borderRadius: '8px',
padding: '0.75rem',
display: 'flex',
flexDirection: 'column',
gap: '0.4rem',
}}
>
{/* Command row */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
<code style={{
fontFamily: 'var(--font-mono)',
fontSize: '0.82rem',
fontWeight: 700,
color: 'var(--primary, #4ade80)',
wordBreak: 'break-all',
}}>
{formattedName}
</code>
<button
type="button"
onClick={() => handleCopy(formattedName, cmdCopyKey)}
title={t('plugin.commands.copy_command', 'Copy command')}
style={{
background: copiedId === cmdCopyKey ? 'rgba(74, 222, 128, 0.15)' : 'rgba(255, 255, 255, 0.06)',
border: `1px solid ${copiedId === cmdCopyKey ? 'rgba(74, 222, 128, 0.4)' : 'rgba(255, 255, 255, 0.1)'}`,
borderRadius: '4px',
color: copiedId === cmdCopyKey ? 'var(--success, #4ade80)' : 'var(--text-muted)',
cursor: 'pointer',
padding: '3px 6px',
display: 'flex',
alignItems: 'center',
gap: '3px',
fontSize: '0.68rem',
fontWeight: 600,
transition: 'all 0.15s ease',
}}
>
{copiedId === cmdCopyKey ? (
<>
<Check size={11} />
<span>{t('plugin.commands.copied', 'Copied')}</span>
</>
) : (
<Copy size={11} />
)}
</button>
</div>
{/* Permission row */}
{cmd.permission ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', minWidth: 0 }}>
<Key size={10} color="var(--text-muted)" style={{ flexShrink: 0 }} />
<span style={{
fontSize: '0.7rem',
fontFamily: 'var(--font-mono)',
color: 'var(--text-muted)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{cmd.permission}
</span>
</div>
<button
type="button"
onClick={() => handleCopy(cmd.permission, permCopyKey)}
title={t('plugin.commands.copy_permission', 'Copy permission')}
style={{
background: 'none',
border: 'none',
color: copiedId === permCopyKey ? 'var(--success, #4ade80)' : 'var(--text-muted)',
cursor: 'pointer',
padding: '2px',
display: 'flex',
alignItems: 'center',
flexShrink: 0,
}}
>
{copiedId === permCopyKey ? <Check size={11} /> : <Copy size={10} />}
</button>
</div>
) : null}
{/* Description */}
{desc ? (
<p style={{
margin: '0.15rem 0 0 0',
fontSize: '0.78rem',
color: 'var(--text-2, #94a3b8)',
lineHeight: 1.4,
}}>
{desc}
</p>
) : null}
</div>
);
})}
</div>
{commands.length > 4 && (
<button
type="button"
onClick={() => setExpanded(!expanded)}
style={{
width: '100%',
marginTop: '0.75rem',
background: 'rgba(255, 255, 255, 0.04)',
border: '1px solid var(--border)',
borderRadius: '6px',
color: 'var(--text)',
padding: '6px 10px',
fontSize: '0.75rem',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
transition: 'background 0.15s ease',
}}
>
{expanded ? (
<>
<ChevronUp size={13} />
{t('plugin.commands.show_less', 'Show less')}
</>
) : (
<>
<ChevronDown size={13} />
{t('plugin.commands.show_all', 'Show all {{count}} commands', { count: commands.length })}
</>
)}
</button>
)}
</div>
);
};
const PluginDetail = () => {
const { t, i18n } = useTranslation();
const { id: rawId, slug } = useParams();
@ -160,7 +380,7 @@ const PluginDetail = () => {
const navigate = useNavigate();
const location = useLocation();
const [plugin, setPlugin] = useState<any>(null);
const [plugin, setPlugin] = useState<PluginDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reviewForm, setReviewForm] = useState({ rating: 5, comment: '' });
@ -290,11 +510,14 @@ const PluginDetail = () => {
useEffect(() => {
if (plugin?.translated_descriptions) {
try {
const translations = JSON.parse(plugin.translated_descriptions);
const translations = typeof plugin.translated_descriptions === 'string'
? JSON.parse(plugin.translated_descriptions)
: plugin.translated_descriptions;
const activeLang = (i18n.language || 'en').slice(0, 2);
setCurrentDescription(
translations[activeLang] ||
translations['en'] ||
translations['en-US'] ||
(Object.values(translations)[0] as string) ||
''
);
@ -361,6 +584,7 @@ const PluginDetail = () => {
// ── Trigger a blob download ──────────────────────────────────────────────
const triggerDownload = async (endpoint: string) => {
if (!plugin) return;
const res = await api.get(endpoint, { responseType: 'blob' });
const url = URL.createObjectURL(res.data);
const a = document.createElement('a');
@ -372,6 +596,7 @@ const PluginDetail = () => {
// ── Download / purchase handler ──────────────────────────────────────────
const handleDownload = async () => {
if (!plugin) return;
if (!user) return navigate('/login', { state: { from: `/plugins/${id}` } });
if (plugin.type === 'paid') {
@ -581,7 +806,7 @@ const PluginDetail = () => {
"image": plugin.preview_path || "https://market.pumpkinmc.org/icon.png",
"offers": {
"@type": "Offer",
"price": plugin.type === 'paid' ? (plugin.price_cents / 100).toFixed(2) : "0.00",
"price": plugin.type === 'paid' && plugin.price_cents ? (plugin.price_cents / 100).toFixed(2) : "0.00",
"priceCurrency": "EUR",
"availability": "https://schema.org/InStock"
},
@ -738,18 +963,18 @@ const PluginDetail = () => {
<div className="plugin-main-content">
{/* SCREENSHOTS / MEDIA GALLERY */}
{((Array.isArray(plugin.screenshots) && plugin.screenshots.length > 0) || plugin.youtube_video_url) && (
{((screenshots.length > 0) || plugin.youtube_video_url) && (
<div className="screenshot-gallery">
{mainScreenshot === 'video' ? (
<PluginVideoPlayer url={plugin.youtube_video_url} />
<PluginVideoPlayer url={plugin.youtube_video_url || undefined} />
) : (
<img
src={mainScreenshot || plugin.screenshots[0]?.path}
src={mainScreenshot || screenshots[0]?.path}
alt="Main"
className="main-screenshot"
style={{ cursor: 'pointer' }}
onClick={() => {
const idx = plugin.screenshots.findIndex((s: any) => s.path === (mainScreenshot || plugin.screenshots[0]?.path));
const idx = screenshots.findIndex((s: any) => s.path === (mainScreenshot || screenshots[0]?.path));
openLightbox(idx >= 0 ? idx : 0);
}}
/>
@ -777,7 +1002,7 @@ const PluginDetail = () => {
</div>
</div>
)}
{plugin.screenshots.map((shot: any) => (
{screenshots.map((shot: any) => (
<img
key={shot.id}
src={shot.path}
@ -1116,9 +1341,10 @@ const PluginDetail = () => {
</span>
)}
{(() => {
const base = plugin.price_cents;
const salePrice = plugin.sale_active && plugin.sale_discount_percent > 0
? Math.round(base * (1 - plugin.sale_discount_percent / 100))
const base = plugin.price_cents ?? 0;
const discount = plugin.sale_discount_percent ?? 0;
const salePrice = plugin.sale_active && discount > 0
? Math.round(base * (1 - discount / 100))
: base;
const finalPrice = couponDiscount > 0
@ -1129,15 +1355,15 @@ const PluginDetail = () => {
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.2rem' }}>
<PriceDisplay cents={finalPrice} />
{(plugin.sale_active && plugin.sale_discount_percent > 0) || couponDiscount > 0 ? (
{(plugin.sale_active && discount > 0) || couponDiscount > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)', textDecoration: 'line-through', fontFamily: 'var(--font-mono)' }}>
€{(plugin.price_cents / 100).toFixed(2)}
€{(base / 100).toFixed(2)}
</span>
{plugin.sale_active && plugin.sale_discount_percent > 0 && (
{plugin.sale_active && discount > 0 && (
<span style={{ fontSize: '0.7rem', fontWeight: 800, background: 'rgba(255,107,107,0.1) ', border: '1px solid rgba(255,107,107,0.2) ', color: '#ff6b6b', borderRadius: 4, padding: '1px 6px' }}>
-{plugin.sale_discount_percent}% SALE
-{discount}% SALE
</span>
)}
@ -1249,7 +1475,7 @@ const PluginDetail = () => {
)}
<div className="sidebar-stats">
<div className="stat-item"><strong>{plugin.downloads.toLocaleString()}</strong> Downloads</div>
<div className="stat-item"><strong>{(plugin.downloads ?? 0).toLocaleString()}</strong> Downloads</div>
{plugin.created_at && (
<div className="stat-item">Released: <strong>{formatDate(plugin.created_at)}</strong></div>
)}
@ -1337,6 +1563,8 @@ const PluginDetail = () => {
)}
</div>
</div>
<PluginCommandsSidebarWidget commands={plugin.commands} />
</aside>
</div>
@ -1416,11 +1644,11 @@ const PluginDetail = () => {
</div>
<div style={{ padding: '24px', overflowY: 'auto', flex: 1 }}>
{Array.isArray(plugin.versions) && plugin.versions.map((v: any, idx: number) => (
{(plugin.versions ?? []).map((v: any, idx: number, arr: any[]) => (
<div key={v.id} style={{
marginBottom: idx === plugin.versions.length - 1 ? 0 : '32px',
paddingBottom: idx === plugin.versions.length - 1 ? 0 : '32px',
borderBottom: idx === plugin.versions.length - 1 ? 'none' : '1px solid var(--border)'
marginBottom: idx === arr.length - 1 ? 0 : '32px',
paddingBottom: idx === arr.length - 1 ? 0 : '32px',
borderBottom: idx === arr.length - 1 ? 'none' : '1px solid var(--border)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
<span style={{

View File

@ -1,8 +1,8 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Turnstile } from '@marsidev/react-turnstile';
import { startAuthentication } from '@simplewebauthn/browser';
import { startAuthentication, browserSupportsWebAuthnAutofill } from '@simplewebauthn/browser';
import { Fingerprint, AlertCircle } from 'lucide-react';
import api from '../../api';
import { useAuth } from '../../App';
@ -28,6 +28,68 @@ const LoginPage = () => {
const navigate = useNavigate();
const { login } = useAuth();
// WebAuthn Autofill (Conditional UI / Mediation)
useEffect(() => {
let active = true;
const initAutofill = async () => {
try {
const supported = await browserSupportsWebAuthnAutofill();
if (!supported || !active) return;
const startRes = await api.post('/auth/passkey/login/start', {});
if (!active) return;
const rawOptions =
startRes.data?.options?.publicKey ||
startRes.data?.options ||
startRes.data?.publicKey ||
startRes.data;
const options = { ...rawOptions };
if (Array.isArray(options.allowCredentials) && options.allowCredentials.length === 0) {
delete options.allowCredentials;
}
const credential = await startAuthentication({
optionsJSON: options,
useBrowserAutofill: true,
});
if (!active) return;
setIsSubmitting(true);
const finishRes = await api.post('/auth/passkey/login/finish', {
challengeId: startRes.data.challengeId,
credential,
});
if (finishRes.data.token) {
login(finishRes.data.token);
navigate('/dashboard');
}
} catch (err: any) {
// Ignore user cancellation, timeout, or dismissal during conditional UI autofill
if (
err.name !== 'AbortError' &&
err.name !== 'NotAllowedError' &&
err.name !== 'InvalidStateError'
) {
console.debug('WebAuthn conditional autofill not completed:', err);
}
} finally {
if (active) {
setIsSubmitting(false);
}
}
};
initAutofill();
return () => {
active = false;
};
}, [login, navigate]);
const validateEmail = (email: string) =>
Boolean(String(email).toLowerCase().match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/));
@ -137,7 +199,12 @@ const LoginPage = () => {
email: form.email.trim() || undefined,
});
const options = startRes.data?.options?.publicKey || startRes.data?.options || startRes.data?.publicKey || startRes.data;
const rawOptions = startRes.data?.options?.publicKey || startRes.data?.options || startRes.data?.publicKey || startRes.data;
const options = { ...rawOptions };
if (Array.isArray(options.allowCredentials) && options.allowCredentials.length === 0) {
delete options.allowCredentials;
}
const credential = await startAuthentication({
optionsJSON: options,
});
@ -556,7 +623,7 @@ const LoginPage = () => {
onBlur={handleEmailBlur}
required
disabled={isSubmitting}
autoComplete="email"
autoComplete="username webauthn"
/>
{emailIsInvalid && (
<svg key="x" className="field-status-icon" viewBox="0 0 20 20" fill="none">

View File

@ -4,7 +4,7 @@ import {
Globe, ImageIcon, Plus, Search, X, Upload, FileCode,
DollarSign, CheckCircle, ChevronRight, ChevronLeft,
Zap, Lock, AlertTriangle, Tag, Store, User,
ShieldCheck,
ShieldCheck, Terminal, Trash2,
} from 'lucide-react';
import api from '../../../api';
import { useAuth } from '../../../App';
@ -234,6 +234,49 @@ const AddPlugin = () => {
const [preorderReleaseDate, setPreorderReleaseDate] = useState('');
const stripeConnected = user.stripe_ready;
const [commands, setCommands] = useState<Array<{
name: string;
permission: string;
descriptions: Record<string, string>;
}>>([]);
const addCommand = () => {
setCommands(prev => [
...prev,
{
name: '',
permission: '',
descriptions: { [activeLocale]: '' }
}
]);
};
const updateCommandField = (index: number, field: 'name' | 'permission', value: string) => {
setCommands(prev => {
const next = [...prev];
next[index] = { ...next[index], [field]: value };
return next;
});
};
const updateCommandDescription = (index: number, locale: string, value: string) => {
setCommands(prev => {
const next = [...prev];
next[index] = {
...next[index],
descriptions: {
...next[index].descriptions,
[locale]: value,
}
};
return next;
});
};
const removeCommand = (index: number) => {
setCommands(prev => prev.filter((_, i) => i !== index));
};
// ── Step 2: Binary ──
const [wasmFile, setWasmFile] = useState<File | null>(null);
const [wasmDragOver, setWasmDragOver] = useState(false);
@ -416,20 +459,32 @@ const AddPlugin = () => {
if (!isPreorder && wasmTooLarge) { alert('Plugin binary exceeds 5 MB.'); return; }
setSubmitting(true);
try {
const validCommands = commands
.filter(c => c.name.trim().length > 0)
.map((c, idx) => ({
name: c.name.trim(),
permission: c.permission.trim() || undefined,
description: c.descriptions,
display_order: idx,
}));
const metadata = {
name,
category,
sourceLink: sourceLink || undefined,
youtubeVideoUrl: youtubeVideoUrl || undefined,
keywords: keywords || undefined,
translatedDescriptions: descriptions,
type: licenseType,
price: licenseType === 'paid' ? Math.round(price * 100) : 0,
isEarlyAccess,
isPreorder,
preorderReleaseDate: isPreorder && preorderReleaseDate ? new Date(preorderReleaseDate).toISOString() : undefined,
commands: validCommands,
};
const fd = new FormData();
fd.append('name', name);
fd.append('category', category);
fd.append('source_link', sourceLink);
fd.append('youtube_video_url', youtubeVideoUrl);
fd.append('keywords', keywords);
fd.append('translated_descriptions', JSON.stringify(descriptions));
fd.append('type', licenseType);
fd.append('price', licenseType === 'paid' ? String(Math.round(price * 100)) : '0');
fd.append('is_early_access', String(isEarlyAccess));
fd.append('is_preorder', String(isPreorder));
if (isPreorder && preorderReleaseDate) {
fd.append('preorder_release_date', new Date(preorderReleaseDate).toISOString());
}
fd.append('metadata', JSON.stringify(metadata));
if (previewImage) fd.append('preview_image', previewImage);
if (wasmFile) fd.append('wasm', wasmFile);
screenshots.forEach(f => fd.append('screenshots', f));
@ -450,6 +505,7 @@ const AddPlugin = () => {
setName(''); setDeveloperName(''); setCategory(PLUGIN_CATEGORIES[0]);
setSourceLink(''); setYoutubeVideoUrl(''); setKeywords('');
setDescriptions({ [DEFAULT_LOCALE]: '' }); setActiveLocale(DEFAULT_LOCALE);
setCommands([]);
setPreviewImage(null); setPreviewImageUrl(null);
setScreenshots([]); setScreenshotPreviews([]);
setLicenseType('free'); setPrice(4.99); setIsEarlyAccess(false);
@ -753,6 +809,129 @@ const AddPlugin = () => {
)}
</div>
{/* Commands & Permissions */}
<div className="mp-card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.85rem' }}>
<div className="mp-card-title" style={{ margin: 0 }}>
<Terminal size={14} />Commands & Permissions
</div>
<button
type="button"
className="mp-btn mp-btn-secondary"
onClick={addCommand}
style={{ fontSize: '0.78rem', padding: '0.35rem 0.75rem', display: 'flex', alignItems: 'center', gap: '0.35rem' }}
>
<Plus size={13} /> Add Command
</button>
</div>
<p style={{ fontSize: '0.78rem', color: 'var(--mp-text-3)', marginBottom: '1rem', lineHeight: 1.4 }}>
Specify commands registered by your plugin along with their permissions and descriptions. Descriptions are localized to the active language (<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--mp-text-2)' }}>{getLocaleName(activeLocale)}</span>).
</p>
{commands.length === 0 ? (
<div style={{
padding: '1.5rem 1rem',
textAlign: 'center',
background: 'var(--mp-surface-2)',
borderRadius: 'var(--mp-radius-sm)',
border: '1px dashed var(--mp-border)',
color: 'var(--mp-text-3)',
fontSize: '0.82rem'
}}>
No commands added yet. Click <strong>"Add Command"</strong> above to register commands.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
{commands.map((cmd, idx) => (
<div
key={idx}
style={{
background: 'var(--mp-surface-2)',
border: '1px solid var(--mp-border)',
borderRadius: 'var(--mp-radius-sm)',
padding: '0.85rem 1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.65rem'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{ flex: 1 }}>
<label className="mp-label" style={{ fontSize: '0.72rem', marginBottom: '0.25rem' }}>
Command <span style={{ color: 'var(--mp-error)' }}>*</span>
</label>
<input
className="mp-input"
style={{ fontFamily: 'var(--font-mono)', fontSize: '0.82rem', padding: '0.45rem 0.65rem' }}
placeholder="/spawn [player]"
value={cmd.name}
onChange={e => updateCommandField(idx, 'name', e.target.value)}
required
/>
</div>
<div style={{ flex: 1 }}>
<label className="mp-label" style={{ fontSize: '0.72rem', marginBottom: '0.25rem' }}>
Permission Node <span style={{ color: 'var(--mp-text-3)', fontWeight: 400 }}>(optional)</span>
</label>
<input
className="mp-input"
style={{ fontFamily: 'var(--font-mono)', fontSize: '0.82rem', padding: '0.45rem 0.65rem' }}
placeholder="pumpkin.command.spawn"
value={cmd.permission}
onChange={e => updateCommandField(idx, 'permission', e.target.value)}
/>
</div>
<button
type="button"
onClick={() => removeCommand(idx)}
title="Remove Command"
style={{
alignSelf: 'flex-end',
marginBottom: '2px',
background: 'rgba(242, 65, 90, 0.1)',
border: '1px solid rgba(242, 65, 90, 0.25)',
borderRadius: '6px',
color: 'var(--mp-error)',
padding: '0.48rem 0.6rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(242, 65, 90, 0.2)')}
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(242, 65, 90, 0.1)')}
>
<Trash2 size={15} />
</button>
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.25rem' }}>
<label className="mp-label" style={{ fontSize: '0.72rem', margin: 0 }}>
Description ({getLocaleName(activeLocale)})
</label>
<span style={{ fontSize: '0.68rem', color: 'var(--mp-text-3)' }}>
{activeLocale !== DEFAULT_LOCALE && !cmd.descriptions[activeLocale] ? (
<span>Falls back to: <em style={{ color: 'var(--mp-text-2)' }}>{cmd.descriptions[DEFAULT_LOCALE] || 'English default'}</em></span>
) : null}
</span>
</div>
<input
className="mp-input"
style={{ fontSize: '0.82rem', padding: '0.45rem 0.65rem' }}
placeholder={`What does this command do? (${getLocaleName(activeLocale)})`}
value={cmd.descriptions[activeLocale] ?? ''}
onChange={e => updateCommandDescription(idx, activeLocale, e.target.value)}
/>
</div>
</div>
))}
</div>
)}
</div>
{/* Store Preview Image */}
<div className="mp-card">
<div className="mp-card-title">
@ -1217,6 +1396,12 @@ const AddPlugin = () => {
{licenseType}{licenseType === 'paid' ? ` · €${price.toFixed(2)}` : ''}
</strong>
</div>
{commands.length > 0 && (
<div className="ap-review-field">
<span>Commands</span>
<strong>{commands.filter(c => c.name.trim()).length} registered</strong>
</div>
)}
{(previewImage || screenshots.length > 0) && (
<SizeBudget usedBytes={totalImageBytes} maxBytes={MAX_TOTAL_IMAGE_BYTES} label="Total image assets" />
)}

View File

@ -22,27 +22,10 @@ import PublishReadinessModal from './PublishReadinessModal';
import { getPluginUrl } from '../../../utils/url';
import './ManagePlugin.css';
export type PluginData = {
id: number;
name: string;
translated_descriptions: string;
category: string;
source_link: string;
keywords: string;
price_cents: number;
type: 'free' | 'paid' | 'adwall';
screenshots: { id: number; path: string }[];
preview_path?: string;
version?: string;
status?: 'published' | 'draft' | 'review';
sale_active?: boolean;
sale_discount_percent?: number;
is_early_access?: boolean;
is_preorder?: boolean;
preorder_release_date?: string;
youtube_video_url?: string;
dev_id?: number;
};
import type { Plugin, PluginCommand } from '../../../types/plugin';
export type PluginCommandItem = PluginCommand;
export type PluginData = Plugin;
const NAV_ITEMS = [
{ key: 'listing', label: 'Store Listing', icon: LayoutGrid },

View File

@ -70,20 +70,17 @@ const Pricing = ({ plugin, onSaved }: Props) => {
if (licenseType === 'paid' && price < 0.99) { setPriceTooLow(true); return; }
setSaving(true);
const fd = new FormData();
fd.append('type', licenseType);
fd.append('price', (licenseType === 'paid' ? priceCents : 0).toString());
const metadata = {
type: licenseType,
price: licenseType === 'paid' ? priceCents : 0,
saleActive: licenseType === 'paid' ? isSaleActive : false,
saleDiscountPercent: licenseType === 'paid' ? discountPercent : 0,
isPreorder: licenseType === 'paid' ? isPreorder : false,
preorderReleaseDate: licenseType === 'paid' && isPreorder && preorderReleaseDate ? new Date(preorderReleaseDate).toISOString() : undefined,
};
if (licenseType === 'paid') {
fd.append('sale_active', isSaleActive.toString());
fd.append('sale_discount_percent', discountPercent.toString());
fd.append('is_preorder', isPreorder.toString());
if (isPreorder && preorderReleaseDate) {
fd.append('preorder_release_date', new Date(preorderReleaseDate).toISOString());
} else {
fd.append('preorder_release_date', '');
}
}
const fd = new FormData();
fd.append('metadata', JSON.stringify(metadata));
try {
setPricingError(null);

View File

@ -71,9 +71,12 @@ const PublishUpdate = ({ plugin, onSaved }: Props) => {
setPublishError(null);
const fd = new FormData();
fd.append('wasm', wasmFile);
if (version) fd.append('version', version);
fd.append('track', track);
if (releaseNotes) fd.append('release_notes', releaseNotes);
const metadata = {
version: version || undefined,
track,
releaseNotes: releaseNotes || undefined,
};
fd.append('metadata', JSON.stringify(metadata));
try {
await api.put(`/plugins/${plugin.id}`, fd);

View File

@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Globe, ImageIcon, Plus, Save, Search, X, AlertTriangle, CheckCircle, ShieldCheck, Lock } from 'lucide-react';
import { Globe, ImageIcon, Plus, Save, Search, X, AlertTriangle, CheckCircle, ShieldCheck, Lock, Terminal, Trash2 } from 'lucide-react';
import api from '../../../api';
import { useAuth } from '../../../App';
import type { PluginData } from './ManagePlugin';
@ -147,7 +147,11 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
const [name, setName] = useState(plugin.name);
const [descriptions, setDescriptions] = useState<Record<string, string>>(() => {
let parsed: Record<string, unknown> = {};
try { parsed = JSON.parse(plugin.translated_descriptions || '{}'); } catch { /* ignore */ }
if (typeof plugin.translated_descriptions === 'object' && plugin.translated_descriptions !== null) {
parsed = plugin.translated_descriptions as Record<string, unknown>;
} else if (typeof plugin.translated_descriptions === 'string') {
try { parsed = JSON.parse(plugin.translated_descriptions || '{}'); } catch { /* ignore */ }
}
// Normalize: if a value is a nested object (old format), extract its 'description' string.
const flat: Record<string, string> = {};
@ -166,7 +170,7 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
return flat;
});
const [activeLocale, setActiveLocale] = useState(DEFAULT_LOCALE);
const [category, setCategory] = useState(plugin.category);
const [category, setCategory] = useState(plugin.category || 'Utilities');
const [sourceLink, setSourceLink] = useState(plugin.source_link || '');
const [youtubeVideoUrl, setYoutubeVideoUrl] = useState(plugin.youtube_video_url || '');
const [keywords, setKeywords] = useState(plugin.keywords || '');
@ -201,6 +205,79 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
const [screenshotValidationErr, setScreenshotValidationErr] = useState<string | null>(null);
const [processingImage, setProcessingImage] = useState(false);
const [commands, setCommands] = useState<Array<{
id?: number;
name: string;
permission: string;
descriptions: Record<string, string>;
}>>(() => {
if (!plugin.commands || !Array.isArray(plugin.commands)) return [];
return plugin.commands.map(cmd => {
let descMap: Record<string, string> = {};
if (typeof cmd.description === 'string') {
try {
const parsed = JSON.parse(cmd.description);
if (parsed && typeof parsed === 'object') {
for (const [k, v] of Object.entries(parsed)) {
descMap[k] = String(v ?? '');
}
} else {
descMap[DEFAULT_LOCALE] = cmd.description;
}
} catch {
descMap[DEFAULT_LOCALE] = cmd.description;
}
} else if (cmd.description && typeof cmd.description === 'object') {
for (const [k, v] of Object.entries(cmd.description)) {
descMap[k] = String(v ?? '');
}
}
return {
id: cmd.id,
name: cmd.name || '',
permission: cmd.permission || '',
descriptions: descMap,
};
});
});
const addCommand = () => {
setCommands(prev => [
...prev,
{
name: '',
permission: '',
descriptions: { [activeLocale]: '' },
},
]);
};
const updateCommandField = (index: number, field: 'name' | 'permission', value: string) => {
setCommands(prev => {
const next = [...prev];
next[index] = { ...next[index], [field]: value };
return next;
});
};
const updateCommandDescription = (index: number, locale: string, value: string) => {
setCommands(prev => {
const next = [...prev];
next[index] = {
...next[index],
descriptions: {
...next[index].descriptions,
[locale]: value,
},
};
return next;
});
};
const removeCommand = (index: number) => {
setCommands(prev => prev.filter((_, i) => i !== index));
};
const handleIconChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
setIconValidationErr(null);
if (e.target.files && e.target.files[0]) {
@ -233,14 +310,29 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
setSaving(true);
setSaveError(null);
setSaveSuccess(false);
const validCommands = commands
.filter(c => c.name.trim().length > 0)
.map((c, idx) => ({
id: c.id,
name: c.name.trim(),
permission: c.permission.trim() || undefined,
description: c.descriptions,
display_order: idx,
}));
const metadata = {
name,
category,
sourceLink: sourceLink || undefined,
youtubeVideoUrl: youtubeVideoUrl || undefined,
keywords: keywords || undefined,
translatedDescriptions: descriptions,
isEarlyAccess,
commands: validCommands,
};
const fd = new FormData();
fd.append('name', name);
fd.append('translated_descriptions', JSON.stringify(descriptions));
fd.append('category', category);
fd.append('source_link', sourceLink);
fd.append('youtube_video_url', youtubeVideoUrl);
fd.append('keywords', keywords);
fd.append('is_early_access', String(isEarlyAccess));
fd.append('metadata', JSON.stringify(metadata));
if (iconFile) {
fd.append('preview_image', iconFile);
}
@ -538,6 +630,129 @@ const StoreListing = ({ plugin, onSaved }: Props) => {
</p>
</div>
{/* ── Commands & Permissions ── */}
<div className="mp-card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.85rem' }}>
<div className="mp-card-title" style={{ margin: 0 }}>
<Terminal size={14} />Commands & Permissions
</div>
<button
type="button"
className="mp-btn mp-btn-secondary"
onClick={addCommand}
style={{ fontSize: '0.78rem', padding: '0.35rem 0.75rem', display: 'flex', alignItems: 'center', gap: '0.35rem' }}
>
<Plus size={13} /> Add Command
</button>
</div>
<p style={{ fontSize: '0.78rem', color: 'var(--mp-text-3)', marginBottom: '1rem', lineHeight: 1.4 }}>
List commands registered by your plugin along with their permissions and descriptions. Descriptions are localized to the currently selected language tab (<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--mp-text-2)' }}>{getLocaleName(activeLocale)}</span>).
</p>
{commands.length === 0 ? (
<div style={{
padding: '1.75rem 1rem',
textAlign: 'center',
background: 'var(--mp-surface-2)',
borderRadius: 'var(--mp-radius-sm)',
border: '1px dashed var(--mp-border)',
color: 'var(--mp-text-3)',
fontSize: '0.82rem'
}}>
No commands added yet. Click <strong>"Add Command"</strong> above to register commands.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
{commands.map((cmd, idx) => (
<div
key={idx}
style={{
background: 'var(--mp-surface-2)',
border: '1px solid var(--mp-border)',
borderRadius: 'var(--mp-radius-sm)',
padding: '0.85rem 1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.65rem'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{ flex: 1 }}>
<label className="mp-label" style={{ fontSize: '0.72rem', marginBottom: '0.25rem' }}>
Command <span style={{ color: 'var(--mp-error)' }}>*</span>
</label>
<input
className="mp-input"
style={{ fontFamily: 'var(--font-mono)', fontSize: '0.82rem', padding: '0.45rem 0.65rem' }}
placeholder="/spawn [player]"
value={cmd.name}
onChange={e => updateCommandField(idx, 'name', e.target.value)}
required
/>
</div>
<div style={{ flex: 1 }}>
<label className="mp-label" style={{ fontSize: '0.72rem', marginBottom: '0.25rem' }}>
Permission Node <span style={{ color: 'var(--mp-text-3)', fontWeight: 400 }}>(optional)</span>
</label>
<input
className="mp-input"
style={{ fontFamily: 'var(--font-mono)', fontSize: '0.82rem', padding: '0.45rem 0.65rem' }}
placeholder="pumpkin.command.spawn"
value={cmd.permission}
onChange={e => updateCommandField(idx, 'permission', e.target.value)}
/>
</div>
<button
type="button"
onClick={() => removeCommand(idx)}
title="Remove Command"
style={{
alignSelf: 'flex-end',
marginBottom: '2px',
background: 'rgba(242, 65, 90, 0.1)',
border: '1px solid rgba(242, 65, 90, 0.25)',
borderRadius: '6px',
color: 'var(--mp-error)',
padding: '0.48rem 0.6rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(242, 65, 90, 0.2)')}
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(242, 65, 90, 0.1)')}
>
<Trash2 size={15} />
</button>
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.25rem' }}>
<label className="mp-label" style={{ fontSize: '0.72rem', margin: 0 }}>
Description ({getLocaleName(activeLocale)})
</label>
<span style={{ fontSize: '0.68rem', color: 'var(--mp-text-3)' }}>
{activeLocale !== DEFAULT_LOCALE && !cmd.descriptions[activeLocale] ? (
<span>Falls back to: <em style={{ color: 'var(--mp-text-2)' }}>{cmd.descriptions[DEFAULT_LOCALE] || 'English default'}</em></span>
) : null}
</span>
</div>
<input
className="mp-input"
style={{ fontSize: '0.82rem', padding: '0.45rem 0.65rem' }}
placeholder={`What does this command do? (${getLocaleName(activeLocale)})`}
value={cmd.descriptions[activeLocale] ?? ''}
onChange={e => updateCommandDescription(idx, activeLocale, e.target.value)}
/>
</div>
</div>
))}
</div>
)}
</div>
{saveError && (
<div className="mp-banner error" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', padding: '1.25rem', marginBottom: '1.25rem', background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)', borderRadius: '10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.85rem' }}>

77
src/types/plugin.ts Normal file
View File

@ -0,0 +1,77 @@
export type PluginLicenseType = 'free' | 'paid' | 'adwall';
export type ReleaseTrack = 'stable' | 'beta' | 'alpha';
export type PluginStatus = 'draft' | 'published' | 'under_review' | 'rejected' | 'suspended';
export interface PluginCommand {
id?: number;
plugin_id?: number;
name: string;
permission?: string;
description?: Record<string, string> | string | null;
display_order?: number;
created_at?: string;
}
export interface PluginScreenshot {
id: number;
plugin_id: number;
path: string;
display_order?: number;
}
export interface PluginVersion {
id: number;
plugin_id: number;
version: string;
release_notes?: string;
track: ReleaseTrack;
created_at: string;
}
export interface PluginReview {
id: number;
user_id: number;
username: string;
rating: number;
comment: string;
developer_reply?: string | null;
replied_at?: string | null;
created_at: string;
}
export interface Plugin {
id: number;
name: string;
version?: string | null;
file_size?: number;
dev_id?: number;
price_cents?: number;
type?: PluginLicenseType;
category?: string;
preview_path?: string | null;
source_link?: string | null;
keywords?: string | null;
translated_descriptions?: Record<string, string> | string | null;
description?: string | null;
downloads?: number;
views?: number;
created_at?: string | null;
updated_at?: string | null;
sale_active?: boolean;
sale_discount_percent?: number;
is_early_access?: boolean;
is_preorder?: boolean;
preorder_release_date?: string | null;
youtube_video_url?: string | null;
status?: PluginStatus | string | null;
deleted_at?: string | null;
commands?: PluginCommand[];
screenshots?: PluginScreenshot[];
versions?: PluginVersion[];
reviews?: PluginReview[];
}
export interface PluginDetail extends Plugin {
dev_name: string;
owned?: boolean | null;
}