feat: allow to add commands
This commit is contained in:
parent
7ea58a985c
commit
21f54d9274
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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.';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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={{
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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" />
|
||||
)}
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
77
src/types/plugin.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user