From 21f54d9274cf6c8b505a19de4d09e8d3742a2e6f Mon Sep 17 00:00:00 2001 From: Alexander Medvedev Date: Mon, 31 Aug 2026 19:43:46 +0200 Subject: [PATCH] feat: allow to add commands --- src/locales/de.json | 11 + src/locales/en.json | 11 + src/locales/es.json | 11 + src/locales/fr.json | 11 + src/locales/it.json | 11 + src/locales/ja.json | 11 + src/locales/ko.json | 11 + src/locales/nl.json | 11 + src/locales/pl.json | 11 + src/locales/pt.json | 11 + src/locales/ru.json | 11 + src/locales/tr.json | 11 + src/locales/zh.json | 11 + src/pages/Home.tsx | 8 +- src/pages/PluginDetail.tsx | 270 +++++++++++++++++-- src/pages/auth/LoginPage.tsx | 75 +++++- src/pages/dashboard/plugin/AddPlugin.tsx | 213 ++++++++++++++- src/pages/dashboard/plugin/ManagePlugin.tsx | 25 +- src/pages/dashboard/plugin/Pricing.tsx | 23 +- src/pages/dashboard/plugin/PublishUpdate.tsx | 9 +- src/pages/dashboard/plugin/StoreListing.tsx | 235 +++++++++++++++- src/types/plugin.ts | 77 ++++++ 22 files changed, 988 insertions(+), 90 deletions(-) create mode 100644 src/types/plugin.ts diff --git a/src/locales/de.json b/src/locales/de.json index f51d9dd..fda361f 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -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": { diff --git a/src/locales/en.json b/src/locales/en.json index f166b17..4d6e500 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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": { diff --git a/src/locales/es.json b/src/locales/es.json index ea70300..238c534 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -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": { diff --git a/src/locales/fr.json b/src/locales/fr.json index a855cc2..7f47361 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -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": { diff --git a/src/locales/it.json b/src/locales/it.json index 6d3a0cb..884c0b4 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -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": { diff --git a/src/locales/ja.json b/src/locales/ja.json index f5ba26d..13fe3e3 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -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": { diff --git a/src/locales/ko.json b/src/locales/ko.json index ffd1a4c..9ee5516 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -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": { diff --git a/src/locales/nl.json b/src/locales/nl.json index 6306137..4708d0f 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -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": { diff --git a/src/locales/pl.json b/src/locales/pl.json index 73bd3d9..a5cdca3 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -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": { diff --git a/src/locales/pt.json b/src/locales/pt.json index ed4aa47..521d239 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -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": { diff --git a/src/locales/ru.json b/src/locales/ru.json index c531495..5518867 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -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": { diff --git a/src/locales/tr.json b/src/locales/tr.json index 3d5fb84..15d725f 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -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": { diff --git a/src/locales/zh.json b/src/locales/zh.json index 23f894e..2a77839 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -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": { diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 896c4a4..fb9d6d2 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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) => { + 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.'; } }; diff --git a/src/pages/PluginDetail.tsx b/src/pages/PluginDetail.tsx index 3fefb42..f052a71 100644 --- a/src/pages/PluginDetail.tsx +++ b/src/pages/PluginDetail.tsx @@ -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 | 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(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 ( +
+
+

+ + {t('plugin.commands.title', 'Commands & Permissions')} +

+ + {commands.length} + +
+ +
+ {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 ( +
+ {/* Command row */} +
+ + {formattedName} + + +
+ + {/* Permission row */} + {cmd.permission ? ( +
+
+ + + {cmd.permission} + +
+ +
+ ) : null} + + {/* Description */} + {desc ? ( +

+ {desc} +

+ ) : null} +
+ ); + })} +
+ + {commands.length > 4 && ( + + )} +
+ ); +}; + 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(null); + const [plugin, setPlugin] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 = () => {
{/* SCREENSHOTS / MEDIA GALLERY */} - {((Array.isArray(plugin.screenshots) && plugin.screenshots.length > 0) || plugin.youtube_video_url) && ( + {((screenshots.length > 0) || plugin.youtube_video_url) && (
{mainScreenshot === 'video' ? ( - + ) : ( Main { - 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 = () => {
)} - {plugin.screenshots.map((shot: any) => ( + {screenshots.map((shot: any) => ( { )} {(() => { - 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 = () => {
- {(plugin.sale_active && plugin.sale_discount_percent > 0) || couponDiscount > 0 ? ( + {(plugin.sale_active && discount > 0) || couponDiscount > 0 ? (
- €{(plugin.price_cents / 100).toFixed(2)} + €{(base / 100).toFixed(2)} - {plugin.sale_active && plugin.sale_discount_percent > 0 && ( + {plugin.sale_active && discount > 0 && ( - -{plugin.sale_discount_percent}% SALE + -{discount}% SALE )} @@ -1249,7 +1475,7 @@ const PluginDetail = () => { )}
-
{plugin.downloads.toLocaleString()} Downloads
+
{(plugin.downloads ?? 0).toLocaleString()} Downloads
{plugin.created_at && (
Released: {formatDate(plugin.created_at)}
)} @@ -1337,6 +1563,8 @@ const PluginDetail = () => { )}
+ +
@@ -1416,11 +1644,11 @@ const PluginDetail = () => {
- {Array.isArray(plugin.versions) && plugin.versions.map((v: any, idx: number) => ( + {(plugin.versions ?? []).map((v: any, idx: number, arr: any[]) => (
{ 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 && ( diff --git a/src/pages/dashboard/plugin/AddPlugin.tsx b/src/pages/dashboard/plugin/AddPlugin.tsx index 79b8c1e..421665d 100644 --- a/src/pages/dashboard/plugin/AddPlugin.tsx +++ b/src/pages/dashboard/plugin/AddPlugin.tsx @@ -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; + }>>([]); + + 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(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 = () => { )}
+ {/* Commands & Permissions */} +
+
+
+ Commands & Permissions +
+ +
+ +

+ Specify commands registered by your plugin along with their permissions and descriptions. Descriptions are localized to the active language ({getLocaleName(activeLocale)}). +

+ + {commands.length === 0 ? ( +
+ No commands added yet. Click "Add Command" above to register commands. +
+ ) : ( +
+ {commands.map((cmd, idx) => ( +
+
+
+ + updateCommandField(idx, 'name', e.target.value)} + required + /> +
+
+ + updateCommandField(idx, 'permission', e.target.value)} + /> +
+ +
+ +
+
+ + + {activeLocale !== DEFAULT_LOCALE && !cmd.descriptions[activeLocale] ? ( + Falls back to: {cmd.descriptions[DEFAULT_LOCALE] || 'English default'} + ) : null} + +
+ updateCommandDescription(idx, activeLocale, e.target.value)} + /> +
+
+ ))} +
+ )} +
+ {/* Store Preview Image */}
@@ -1217,6 +1396,12 @@ const AddPlugin = () => { {licenseType}{licenseType === 'paid' ? ` · €${price.toFixed(2)}` : ''}
+ {commands.length > 0 && ( +
+ Commands + {commands.filter(c => c.name.trim()).length} registered +
+ )} {(previewImage || screenshots.length > 0) && ( )} diff --git a/src/pages/dashboard/plugin/ManagePlugin.tsx b/src/pages/dashboard/plugin/ManagePlugin.tsx index 8996e67..395f0ad 100644 --- a/src/pages/dashboard/plugin/ManagePlugin.tsx +++ b/src/pages/dashboard/plugin/ManagePlugin.tsx @@ -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 }, diff --git a/src/pages/dashboard/plugin/Pricing.tsx b/src/pages/dashboard/plugin/Pricing.tsx index cb2dee5..b023110 100644 --- a/src/pages/dashboard/plugin/Pricing.tsx +++ b/src/pages/dashboard/plugin/Pricing.tsx @@ -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); diff --git a/src/pages/dashboard/plugin/PublishUpdate.tsx b/src/pages/dashboard/plugin/PublishUpdate.tsx index 97c6f2d..31ea37e 100644 --- a/src/pages/dashboard/plugin/PublishUpdate.tsx +++ b/src/pages/dashboard/plugin/PublishUpdate.tsx @@ -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); diff --git a/src/pages/dashboard/plugin/StoreListing.tsx b/src/pages/dashboard/plugin/StoreListing.tsx index 14a61b3..2094314 100644 --- a/src/pages/dashboard/plugin/StoreListing.tsx +++ b/src/pages/dashboard/plugin/StoreListing.tsx @@ -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>(() => { let parsed: Record = {}; - 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; + } 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 = {}; @@ -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(null); const [processingImage, setProcessingImage] = useState(false); + const [commands, setCommands] = useState; + }>>(() => { + if (!plugin.commands || !Array.isArray(plugin.commands)) return []; + return plugin.commands.map(cmd => { + let descMap: Record = {}; + 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) => { 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) => {

+ {/* ── Commands & Permissions ── */} +
+
+
+ Commands & Permissions +
+ +
+ +

+ List commands registered by your plugin along with their permissions and descriptions. Descriptions are localized to the currently selected language tab ({getLocaleName(activeLocale)}). +

+ + {commands.length === 0 ? ( +
+ No commands added yet. Click "Add Command" above to register commands. +
+ ) : ( +
+ {commands.map((cmd, idx) => ( +
+
+
+ + updateCommandField(idx, 'name', e.target.value)} + required + /> +
+
+ + updateCommandField(idx, 'permission', e.target.value)} + /> +
+ +
+ +
+
+ + + {activeLocale !== DEFAULT_LOCALE && !cmd.descriptions[activeLocale] ? ( + Falls back to: {cmd.descriptions[DEFAULT_LOCALE] || 'English default'} + ) : null} + +
+ updateCommandDescription(idx, activeLocale, e.target.value)} + /> +
+
+ ))} +
+ )} +
+ {saveError && (
diff --git a/src/types/plugin.ts b/src/types/plugin.ts new file mode 100644 index 0000000..41a046b --- /dev/null +++ b/src/types/plugin.ts @@ -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 | 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 | 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; +}